985 lines
44 KiB
Python
985 lines
44 KiB
Python
import json
|
||
import logging
|
||
import os
|
||
from typing import Any
|
||
|
||
from celery_worker.celery_app import celery_app
|
||
|
||
# Настраиваем логгер для задач
|
||
logger = logging.getLogger(__name__)
|
||
from celery_worker.database import (
|
||
SyncResumeRepository,
|
||
SyncVacancyRepository,
|
||
get_sync_session,
|
||
)
|
||
from rag.llm.model import ResumeParser
|
||
from rag.registry import registry
|
||
|
||
# Импортируем новые задачи анализа интервью
|
||
|
||
|
||
def generate_interview_plan(
|
||
resume_id: int, combined_data: dict[str, Any]
|
||
) -> dict[str, Any]:
|
||
"""Генерирует план интервью на основе резюме и вакансии"""
|
||
try:
|
||
# Получаем данные о вакансии из БД
|
||
with get_sync_session() as session:
|
||
resume_repo = SyncResumeRepository(session)
|
||
vacancy_repo = SyncVacancyRepository(session)
|
||
|
||
resume_record = resume_repo.get_by_id(resume_id)
|
||
if not resume_record:
|
||
return {
|
||
"is_suitable": False,
|
||
"rejection_reason": "Резюме не найдено в БД",
|
||
}
|
||
|
||
# Получаем данные вакансии
|
||
vacancy_record = None
|
||
if resume_record.vacancy_id:
|
||
vacancy_record = vacancy_repo.get_by_id(resume_record.vacancy_id)
|
||
|
||
if not vacancy_record:
|
||
return {"is_suitable": False, "rejection_reason": "Вакансия не найдена"}
|
||
|
||
vacancy_data = {
|
||
"title": vacancy_record.title,
|
||
"description": vacancy_record.description,
|
||
"key_skills": vacancy_record.key_skills,
|
||
"experience": vacancy_record.experience,
|
||
"area_name": vacancy_record.area_name,
|
||
"professional_roles": vacancy_record.professional_roles,
|
||
}
|
||
|
||
# Сначала проверяем соответствие резюме и вакансии через LLM
|
||
chat_model = registry.get_chat_model()
|
||
|
||
# Формируем опыт кандидата
|
||
experience_map = {
|
||
"noExperience": "Без опыта",
|
||
"between1And3": "1-3 года",
|
||
"between3And6": "3-6 лет",
|
||
"moreThan6": "Более 6 лет",
|
||
}
|
||
|
||
compatibility_prompt = f"""
|
||
Проанализируй соответствие кандидата вакансии и определи, стоит ли проводить интервью.
|
||
|
||
КЛЮЧЕВЫЕ КРИТЕРИИ ОТКЛОНЕНИЯ:
|
||
1. Несоответствие профессиональной сферы — опыт и навыки кандидата не относятся к области деятельности, связанной с вакансией.
|
||
2. Несоответствие уровня и фокуса позиции — текущая или предыдущая должность кандидата существенно отличается по направлению или уровню ответственности от требований вакансии. Допускаются смежные переходы (например, переход из fullstack в frontend или переход кандидата уровня senior на позицию middle/junior).
|
||
КЛЮЧЕВЫЕ КРИТЕРИИ ДОПУСКА:
|
||
3. Остальные показатели кандидата примерно соответствуют вакансии: скиллы кандидата похожи или смежны вакансионным, опыт попадает в указанных промежуток
|
||
4. Учитывай опыт с аналогичными, похожими, смежными технологиями
|
||
5. Когда смотришь на вакансию и кандидата не учитывай строгие слова, такие как "Требования", "Ключевые" и тп. Это лишь маркеры,
|
||
но не оценочные указатели
|
||
6. Если есть спорные вопросы соответствия, лучше допустить к собеседованию и уточнить их там
|
||
|
||
КАНДИДАТ:
|
||
- Имя: {combined_data.get("name", "Не указано")}
|
||
- Навыки: {", ".join(combined_data.get("skills", []))}
|
||
- Общий опыт: {combined_data.get("total_years", 0)} лет
|
||
- Образование: {combined_data.get("education", "Не указано")}
|
||
- Про работу: {combined_data.get("experience", "Не указано")}
|
||
- Саммари: {combined_data.get("summary", "Не указано")}
|
||
|
||
ВАКАНСИЯ:
|
||
- Должность: {vacancy_data["title"]}
|
||
- Описание: {vacancy_data["description"]}...
|
||
- Ключевые навыки: {vacancy_data["key_skills"] or "Не указаны"}
|
||
- Требуемый опыт: {experience_map.get(vacancy_data["experience"], "Не указан")}
|
||
- Профессиональные роли: {vacancy_data["professional_roles"] or "Не указаны"}
|
||
|
||
|
||
Верни ответ в JSON формате:
|
||
{{
|
||
"is_suitable": true/false,
|
||
"rejection_reason": "Конкретная подробная причина отклонения с цитированием (если is_suitable=false)",
|
||
}}
|
||
"""
|
||
|
||
from langchain.schema import HumanMessage, SystemMessage
|
||
|
||
compatibility_messages = [
|
||
SystemMessage(
|
||
content="Ты эксперт по подбору персонала. Анализируй соответствие кандидатов вакансиям строго и объективно."
|
||
),
|
||
HumanMessage(content=compatibility_prompt),
|
||
]
|
||
|
||
compatibility_response = chat_model.get_llm().invoke(compatibility_messages)
|
||
compatibility_text = compatibility_response.content.strip()
|
||
|
||
# Парсим ответ о соответствии
|
||
compatibility_result = None
|
||
if compatibility_text.startswith("{") and compatibility_text.endswith("}"):
|
||
compatibility_result = json.loads(compatibility_text)
|
||
else:
|
||
# Ищем JSON в тексте
|
||
start = compatibility_text.find("{")
|
||
end = compatibility_text.rfind("}") + 1
|
||
if start != -1 and end > start:
|
||
compatibility_result = json.loads(compatibility_text[start:end])
|
||
|
||
# Если кандидат не подходит - возвращаем результат отклонения
|
||
if not compatibility_result or not compatibility_result.get(
|
||
"is_suitable", True
|
||
):
|
||
return {
|
||
"is_suitable": False,
|
||
"rejection_reason": compatibility_result.get(
|
||
"rejection_reason", "Кандидат не соответствует требованиям вакансии"
|
||
)
|
||
if compatibility_result
|
||
else "Ошибка анализа соответствия",
|
||
"match_details": compatibility_result,
|
||
}
|
||
|
||
# Если кандидат подходит - генерируем план интервью
|
||
plan_prompt = f"""
|
||
Создай детальный план интервью для кандидата на основе его резюме и требований вакансии на 45 МИНУТ.
|
||
|
||
РЕЗЮМЕ КАНДИДАТА:
|
||
- Имя: {combined_data.get("name", "Не указано")}
|
||
- Навыки: {", ".join(combined_data.get("skills", []))}
|
||
- Опыт: {combined_data.get("total_years", 0)} лет
|
||
- Образование: {combined_data.get("education", "Не указано")}
|
||
|
||
ВАКАНСИЯ:
|
||
- Должность: {vacancy_data["title"]}
|
||
- Описание: {vacancy_data["description"]}...
|
||
- Ключевые навыки: {vacancy_data["key_skills"] or "Не указаны"}
|
||
- Требуемый опыт: {experience_map.get(vacancy_data["experience"], "Не указан")}
|
||
|
||
Создай план интервью в формате JSON:
|
||
{{
|
||
"interview_structure": {{
|
||
"duration_minutes": 45,
|
||
"greeting": "Краткое приветствие и знакомство (3 мин)",
|
||
"sections": [
|
||
{{
|
||
"name": "Знакомство с кандидатом",
|
||
"duration_minutes": 5,
|
||
"questions": ["Расскажи о себе", "Что привлекло в этой позиции?"]
|
||
}},
|
||
{{
|
||
"name": "Технические навыки",
|
||
"duration_minutes": 20,
|
||
"questions": ["Опыт с Python", "Работа с базами данных"]
|
||
}},
|
||
{{
|
||
"name": "Опыт и проекты",
|
||
"duration_minutes": 15,
|
||
"questions": ["Расскажи о сложном проекте", "Как решаешь проблемы?"]
|
||
}},
|
||
{{
|
||
"name": "Вопросы кандидата",
|
||
"duration_minutes": 2,
|
||
"questions": ["Есть ли вопросы ко мне?"]
|
||
}}
|
||
]
|
||
}},
|
||
"focus_areas": ["technical_skills", "problem_solving", "cultural_fit"],
|
||
"key_evaluation_points": [
|
||
"Глубина знаний Python",
|
||
"Опыт командной работы",
|
||
"Мотивация к изучению нового"
|
||
],
|
||
"red_flags_to_check": [Шаблонные ответы, уклонения от вопросов, расхождение в стаже],
|
||
"personalization_notes": "Кандидат имеет хороший технический опыт"
|
||
}}
|
||
"""
|
||
|
||
from langchain.schema import HumanMessage, SystemMessage
|
||
|
||
messages = [
|
||
SystemMessage(
|
||
content="Ты HR эксперт по планированию интервью. Создавай структурированные планы."
|
||
),
|
||
HumanMessage(content=plan_prompt),
|
||
]
|
||
|
||
response = chat_model.get_llm().invoke(messages)
|
||
response_text = response.content.strip()
|
||
|
||
# Парсим JSON ответ
|
||
interview_plan = None
|
||
if response_text.startswith("{") and response_text.endswith("}"):
|
||
interview_plan = json.loads(response_text)
|
||
else:
|
||
# Ищем JSON в тексте
|
||
start = response_text.find("{")
|
||
end = response_text.rfind("}") + 1
|
||
if start != -1 and end > start:
|
||
interview_plan = json.loads(response_text[start:end])
|
||
|
||
if interview_plan:
|
||
# Добавляем информацию о том, что кандидат подходит
|
||
interview_plan["is_suitable"] = True
|
||
interview_plan["match_details"] = compatibility_result
|
||
return interview_plan
|
||
|
||
return {
|
||
"is_suitable": True,
|
||
"match_details": compatibility_result,
|
||
"error": "Не удалось сгенерировать план интервью",
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Ошибка генерации плана интервью: {str(e)}", exc_info=True)
|
||
return None
|
||
|
||
|
||
@celery_app.task(bind=True)
|
||
def parse_resume_task(self, resume_id: str, file_path: str):
|
||
"""
|
||
Асинхронная задача парсинга резюме
|
||
|
||
Args:
|
||
resume_id: ID резюме
|
||
file_path: Путь к PDF файлу резюме
|
||
"""
|
||
logger.info(f"=== НАЧАЛО ОБРАБОТКИ РЕЗЮМЕ {resume_id} ===")
|
||
logger.info(f"Путь к файлу: {file_path}")
|
||
|
||
try:
|
||
# Шаг 0: Обновляем статус в БД - начали парсинг
|
||
logger.info(f"Шаг 0: Обновляем статус резюме {resume_id} на 'parsing'")
|
||
with get_sync_session() as session:
|
||
repo = SyncResumeRepository(session)
|
||
repo.update_status(int(resume_id), "parsing")
|
||
logger.info(f"Статус резюме {resume_id} успешно обновлен на 'parsing'")
|
||
|
||
# Обновляем статус задачи
|
||
logger.info(f"Обновляем состояние Celery задачи на PENDING")
|
||
self.update_state(
|
||
state="PENDING",
|
||
meta={"status": "Начинаем парсинг резюме...", "progress": 10},
|
||
)
|
||
logger.info(f"Состояние Celery задачи обновлено")
|
||
|
||
# Инициализируем модели из registry
|
||
logger.info(f"Шаг 1: Инициализируем модели из registry")
|
||
try:
|
||
logger.info("Получаем chat_model из registry")
|
||
chat_model = registry.get_chat_model()
|
||
logger.info("Chat model успешно получен")
|
||
|
||
logger.info("Получаем vector_store из registry")
|
||
vector_store = registry.get_vector_store()
|
||
logger.info("Vector store успешно получен")
|
||
except Exception as e:
|
||
logger.error(f"ОШИБКА при инициализации моделей: {str(e)}", exc_info=True)
|
||
# Обновляем статус в БД - ошибка инициализации
|
||
with get_sync_session() as session:
|
||
repo = SyncResumeRepository(session)
|
||
repo.update_status(
|
||
int(resume_id),
|
||
"failed",
|
||
error_message=f"Ошибка инициализации моделей: {str(e)}",
|
||
)
|
||
raise RuntimeError(f"Ошибка инициализации моделей: {str(e)}")
|
||
|
||
# Шаг 2: Парсинг резюме
|
||
logger.info(f"Шаг 2: Начинаем парсинг резюме")
|
||
self.update_state(
|
||
state="PROGRESS",
|
||
meta={"status": "Извлекаем текст из PDF...", "progress": 20},
|
||
)
|
||
logger.info(f"Состояние Celery обновлено на PROGRESS (20%)")
|
||
|
||
logger.info(f"Создаем ResumeParser")
|
||
parser = ResumeParser(chat_model)
|
||
logger.info(f"ResumeParser создан успешно")
|
||
|
||
logger.info(f"Проверяем существование файла: {file_path}")
|
||
if not os.path.exists(file_path):
|
||
logger.error(f"ФАЙЛ НЕ НАЙДЕН: {file_path}")
|
||
# Обновляем статус в БД - файл не найден
|
||
with get_sync_session() as session:
|
||
repo = SyncResumeRepository(session)
|
||
repo.update_status(
|
||
int(resume_id),
|
||
"failed",
|
||
error_message=f"Файл не найден: {file_path}",
|
||
)
|
||
logger.info(f"Статус резюме {resume_id} обновлен на 'failed' в БД")
|
||
raise FileNotFoundError(f"Файл не найден: {file_path}")
|
||
|
||
logger.info(f"Файл существует, начинаем парсинг")
|
||
parsed_resume = parser.parse_resume_from_file(file_path)
|
||
logger.info(f"Парсинг резюме завершен, получены данные: {list(parsed_resume.keys())}")
|
||
|
||
# Получаем оригинальные данные из формы
|
||
logger.info(f"Шаг 3: Получаем данные резюме из БД")
|
||
with get_sync_session() as session:
|
||
repo = SyncResumeRepository(session)
|
||
resume_record = repo.get_by_id(int(resume_id))
|
||
if not resume_record:
|
||
logger.error(f"РЕЗЮМЕ С ID {resume_id} НЕ НАЙДЕНО В БД")
|
||
raise ValueError(f"Резюме с ID {resume_id} не найдено в базе данных")
|
||
|
||
# Извлекаем нужные данные пока сессия активна
|
||
applicant_name = resume_record.applicant_name
|
||
applicant_email = resume_record.applicant_email
|
||
applicant_phone = resume_record.applicant_phone
|
||
logger.info(f"Данные резюме получены: name={applicant_name}, email={applicant_email}, phone={applicant_phone}")
|
||
|
||
# Создаем комбинированные данные: навыки и опыт из парсинга, контакты из формы
|
||
logger.info(f"Шаг 4: Объединяем данные из парсинга и формы")
|
||
combined_data = parsed_resume.copy()
|
||
combined_data["name"] = applicant_name or parsed_resume.get("name", "")
|
||
combined_data["email"] = applicant_email or parsed_resume.get("email", "")
|
||
combined_data["phone"] = applicant_phone or parsed_resume.get("phone", "")
|
||
logger.info(f"Комбинированные данные подготовлены")
|
||
|
||
# Шаг 5: Векторизация и сохранение в Milvus
|
||
logger.info(f"Шаг 5: Векторизация и сохранение в Milvus")
|
||
self.update_state(
|
||
state="PENDING",
|
||
meta={"status": "Сохраняем в векторную базу...", "progress": 60},
|
||
)
|
||
logger.info(f"Состояние Celery обновлено на 60%")
|
||
|
||
logger.info(f"Добавляем профиль кандидата в vector store")
|
||
vector_store.add_candidate_profile(str(resume_id), combined_data)
|
||
logger.info(f"Профиль кандидата добавлен в vector store")
|
||
|
||
# Шаг 6: Обновляем статус в PostgreSQL
|
||
logger.info(f"Шаг 6: Подготовка к обновлению статуса в БД")
|
||
self.update_state(
|
||
state="PENDING",
|
||
meta={"status": "Обновляем статус в базе данных...", "progress": 85},
|
||
)
|
||
logger.info(f"Состояние Celery обновлено на 85%")
|
||
|
||
# Шаг 7: Генерируем план интервью
|
||
logger.info(f"Шаг 7: Генерация плана интервью")
|
||
self.update_state(
|
||
state="PENDING",
|
||
meta={"status": "Генерируем план интервью...", "progress": 90},
|
||
)
|
||
logger.info(f"Состояние Celery обновлено на 90%")
|
||
|
||
logger.info(f"Вызываем generate_interview_plan для резюме {resume_id}")
|
||
interview_plan = generate_interview_plan(int(resume_id), combined_data)
|
||
logger.info(f"План интервью сгенерирован: {interview_plan is not None}")
|
||
|
||
logger.info(f"Шаг 8: Обновляем статус в БД на основе плана интервью")
|
||
with get_sync_session() as session:
|
||
repo = SyncResumeRepository(session)
|
||
|
||
# Проверяем результат генерации плана интервью
|
||
logger.info(f"Анализируем план интервью для резюме {resume_id}")
|
||
logger.info(f"План интервью: {interview_plan}")
|
||
|
||
if interview_plan and interview_plan.get("is_suitable", True):
|
||
logger.info(f"Кандидат подходит, обновляем статус на 'parsed'")
|
||
# Кандидат подходит - обновляем статус на parsed
|
||
repo.update_status(int(resume_id), "parsed", parsed_data=combined_data)
|
||
logger.info(f"Статус резюме {resume_id} обновлен на 'parsed'")
|
||
|
||
# Сохраняем план интервью
|
||
logger.info(f"Сохраняем план интервью для резюме {resume_id}")
|
||
repo.update_interview_plan(int(resume_id), interview_plan)
|
||
logger.info(f"План интервью сохранен")
|
||
else:
|
||
logger.info(f"Кандидат НЕ подходит, отклоняем")
|
||
# Кандидат не подходит - отклоняем
|
||
rejection_reason = (
|
||
interview_plan.get(
|
||
"rejection_reason", "Не соответствует требованиям вакансии"
|
||
)
|
||
if interview_plan
|
||
else "Ошибка анализа соответствия"
|
||
)
|
||
logger.info(f"Причина отклонения: {rejection_reason}")
|
||
repo.update_status(
|
||
int(resume_id),
|
||
"rejected",
|
||
parsed_data=combined_data,
|
||
rejection_reason=rejection_reason,
|
||
)
|
||
logger.info(f"Статус резюме {resume_id} обновлен на 'rejected'")
|
||
|
||
# Завершаем с информацией об отклонении
|
||
logger.info(f"Обновляем состояние Celery на SUCCESS (отклонен)")
|
||
self.update_state(
|
||
state="SUCCESS",
|
||
meta={
|
||
"status": f"Резюме обработано, но кандидат отклонен: {rejection_reason}",
|
||
"progress": 100,
|
||
"result": combined_data,
|
||
"rejected": True,
|
||
"rejection_reason": rejection_reason,
|
||
},
|
||
)
|
||
logger.info(f"=== ЗАВЕРШЕНИЕ ОБРАБОТКИ РЕЗЮМЕ {resume_id} (ОТКЛОНЕН) ===")
|
||
|
||
return {
|
||
"resume_id": resume_id,
|
||
"status": "rejected",
|
||
"parsed_data": combined_data,
|
||
"rejection_reason": rejection_reason,
|
||
}
|
||
|
||
# Завершено успешно
|
||
logger.info(f"Обновляем состояние Celery на SUCCESS (принят)")
|
||
self.update_state(
|
||
state="SUCCESS",
|
||
meta={
|
||
"status": "Резюме успешно обработано и план интервью готов",
|
||
"progress": 100,
|
||
"result": combined_data,
|
||
},
|
||
)
|
||
logger.info(f"=== УСПЕШНОЕ ЗАВЕРШЕНИЕ ОБРАБОТКИ РЕЗЮМЕ {resume_id} ===")
|
||
|
||
return {
|
||
"resume_id": resume_id,
|
||
"status": "completed",
|
||
"parsed_data": combined_data,
|
||
}
|
||
|
||
except Exception as e:
|
||
error_message = str(e)
|
||
logger.error(f"Ошибка при обработке резюме {resume_id}: {error_message}", exc_info=True)
|
||
|
||
# В случае ошибки
|
||
self.update_state(
|
||
state="FAILURE",
|
||
meta={
|
||
"status": f"Ошибка при обработке резюме: {error_message}",
|
||
"progress": 0,
|
||
"error": error_message,
|
||
},
|
||
)
|
||
|
||
# Обновляем статус в БД как failed
|
||
try:
|
||
with get_sync_session() as session:
|
||
repo = SyncResumeRepository(session)
|
||
repo.update_status(int(resume_id), "failed", error_message=error_message)
|
||
except Exception as db_error:
|
||
logger.error(f"Ошибка при обновлении статуса в БД: {str(db_error)}", exc_info=True)
|
||
|
||
# Возвращаем стандартное исключение вместо re-raise
|
||
return {
|
||
"resume_id": resume_id,
|
||
"status": "failed",
|
||
"error": error_message,
|
||
}
|
||
|
||
|
||
# Функция больше не нужна - используем SyncResumeRepository напрямую
|
||
|
||
|
||
@celery_app.task(bind=True)
|
||
def generate_interview_questions_task(self, resume_id: str, job_description: str):
|
||
"""
|
||
Генерация персонализированных вопросов для интервью на основе резюме и описания вакансии
|
||
|
||
Args:
|
||
resume_id: ID резюме
|
||
job_description: Описание вакансии
|
||
"""
|
||
try:
|
||
self.update_state(
|
||
state="PENDING",
|
||
meta={"status": "Начинаем генерацию вопросов...", "progress": 10},
|
||
)
|
||
|
||
# Инициализируем модели
|
||
try:
|
||
chat_model = registry.get_chat_model()
|
||
vector_store = registry.get_vector_store()
|
||
except Exception as e:
|
||
raise RuntimeError(f"Ошибка инициализации моделей: {str(e)}")
|
||
|
||
# Шаг 1: Получить parsed резюме из базы данных
|
||
self.update_state(
|
||
state="PENDING",
|
||
meta={"status": "Получаем данные резюме...", "progress": 20},
|
||
)
|
||
|
||
with get_sync_session() as session:
|
||
repo = SyncResumeRepository(session)
|
||
resume = repo.get_by_id(int(resume_id))
|
||
|
||
if not resume:
|
||
raise Exception(f"Резюме с ID {resume_id} не найдено")
|
||
|
||
if not resume.parsed_data:
|
||
raise Exception(f"Резюме {resume_id} еще не обработано")
|
||
|
||
# Шаг 2: Получить похожие кандидатов из Milvus для анализа
|
||
self.update_state(
|
||
state="PENDING",
|
||
meta={"status": "Анализируем профиль кандидата...", "progress": 40},
|
||
)
|
||
|
||
candidate_skills = " ".join(resume.parsed_data.get("skills", []))
|
||
similar_candidates = vector_store.search_similar_candidates(
|
||
candidate_skills, k=3
|
||
)
|
||
|
||
# Шаг 3: Сгенерировать персонализированные вопросы через LLM
|
||
self.update_state(
|
||
state="PENDING",
|
||
meta={"status": "Генерируем вопросы для интервью...", "progress": 70},
|
||
)
|
||
|
||
questions_prompt = f"""
|
||
Сгенерируй 10 персонализированных вопросов для интервью кандидата на основе его резюме и описания вакансии.
|
||
|
||
РЕЗЮМЕ КАНДИДАТА:
|
||
Имя: {resume.parsed_data.get("name", "Не указано")}
|
||
Навыки: {", ".join(resume.parsed_data.get("skills", []))}
|
||
Опыт работы: {resume.parsed_data.get("total_years", 0)} лет
|
||
Образование: {resume.parsed_data.get("education", "Не указано")}
|
||
|
||
ОПИСАНИЕ ВАКАНСИИ:
|
||
{job_description}
|
||
|
||
ИНСТРУКЦИИ:
|
||
1. Задавай вопросы, которые помогут оценить технические навыки кандидата
|
||
2. Включи вопросы о конкретном опыте работы из резюме
|
||
3. Добавь вопросы на соответствие требованиям вакансии
|
||
4. Включи 2-3 поведенческих вопроса
|
||
5. Верни ответ в JSON формате
|
||
|
||
Формат ответа:
|
||
{{
|
||
"questions": [
|
||
{{
|
||
"id": 1,
|
||
"category": "technical|experience|behavioral|vacancy_specific",
|
||
"question": "Текст вопроса",
|
||
"reasoning": "Почему этот вопрос важен для данного кандидата"
|
||
}}
|
||
]
|
||
}}
|
||
"""
|
||
|
||
from langchain.schema import HumanMessage, SystemMessage
|
||
|
||
messages = [
|
||
SystemMessage(
|
||
content="Ты эксперт по проведению технических интервью. Генерируй качественные, персонализированные вопросы."
|
||
),
|
||
HumanMessage(content=questions_prompt),
|
||
]
|
||
|
||
response = chat_model.get_llm().invoke(messages)
|
||
|
||
# Парсим ответ
|
||
import json
|
||
|
||
response_text = response.content.strip()
|
||
|
||
# Извлекаем JSON из ответа
|
||
if response_text.startswith("{") and response_text.endswith("}"):
|
||
questions_data = json.loads(response_text)
|
||
else:
|
||
# Ищем JSON внутри текста
|
||
start = response_text.find("{")
|
||
end = response_text.rfind("}") + 1
|
||
if start != -1 and end > start:
|
||
json_str = response_text[start:end]
|
||
questions_data = json.loads(json_str)
|
||
else:
|
||
raise ValueError("JSON не найден в ответе LLM")
|
||
|
||
# Шаг 4: Сохранить вопросы в notes резюме (пока так, потом можно создать отдельную таблицу)
|
||
self.update_state(
|
||
state="PENDING", meta={"status": "Сохраняем вопросы...", "progress": 90}
|
||
)
|
||
|
||
with get_sync_session() as session:
|
||
repo = SyncResumeRepository(session)
|
||
resume = repo.get_by_id(int(resume_id))
|
||
|
||
if resume:
|
||
# Сохраняем вопросы в notes (временно)
|
||
existing_notes = resume.notes or ""
|
||
interview_questions = json.dumps(
|
||
questions_data, ensure_ascii=False, indent=2
|
||
)
|
||
resume.notes = (
|
||
f"{existing_notes}\n\nINTERVIEW QUESTIONS:\n{interview_questions}"
|
||
)
|
||
from datetime import datetime
|
||
|
||
resume.updated_at = datetime.utcnow()
|
||
|
||
session.add(resume)
|
||
|
||
# Завершено успешно
|
||
self.update_state(
|
||
state="SUCCESS",
|
||
meta={
|
||
"status": "Вопросы для интервью успешно сгенерированы",
|
||
"progress": 100,
|
||
"result": questions_data,
|
||
},
|
||
)
|
||
|
||
return {
|
||
"resume_id": resume_id,
|
||
"status": "questions_generated",
|
||
"questions": questions_data["questions"],
|
||
}
|
||
|
||
except Exception as e:
|
||
# В случае ошибки
|
||
self.update_state(
|
||
state="FAILURE",
|
||
meta={
|
||
"status": f"Ошибка при генерации вопросов: {str(e)}",
|
||
"progress": 0,
|
||
"error": str(e),
|
||
},
|
||
)
|
||
raise Exception(f"Ошибка при генерации вопросов: {str(e)}")
|
||
|
||
|
||
@celery_app.task(bind=True)
|
||
def parse_vacancy_task(
|
||
self, file_content_base64: str, filename: str, create_vacancy: bool = False
|
||
):
|
||
"""
|
||
Асинхронная задача парсинга вакансии из файла
|
||
|
||
Args:
|
||
file_content_base64: Содержимое файла в base64
|
||
filename: Имя файла для определения формата
|
||
create_vacancy: Создать вакансию в БД после парсинга
|
||
"""
|
||
try:
|
||
import base64
|
||
|
||
from app.models.vacancy import VacancyCreate
|
||
from app.services.vacancy_parser_service import vacancy_parser_service
|
||
|
||
# Обновляем статус задачи
|
||
self.update_state(
|
||
state="PENDING",
|
||
meta={"status": "Начинаем парсинг вакансии...", "progress": 10},
|
||
)
|
||
|
||
# Декодируем содержимое файла
|
||
file_content = base64.b64decode(file_content_base64)
|
||
|
||
# Шаг 1: Извлечение текста из файла
|
||
self.update_state(
|
||
state="PROGRESS",
|
||
meta={"status": "Извлекаем текст из файла...", "progress": 30},
|
||
)
|
||
|
||
raw_text = vacancy_parser_service.extract_text_from_file(file_content, filename)
|
||
|
||
if not raw_text.strip():
|
||
raise ValueError("Не удалось извлечь текст из файла")
|
||
|
||
# Шаг 2: Парсинг с помощью AI
|
||
self.update_state(
|
||
state="PROGRESS",
|
||
meta={"status": "Обрабатываем текст с помощью AI...", "progress": 70},
|
||
)
|
||
|
||
import asyncio
|
||
|
||
parsed_data = asyncio.run(
|
||
vacancy_parser_service.parse_vacancy_with_ai(raw_text)
|
||
)
|
||
|
||
# Шаг 3: Создание вакансии (если требуется)
|
||
created_vacancy = None
|
||
print(
|
||
f"create_vacancy parameter: {create_vacancy}, type: {type(create_vacancy)}"
|
||
)
|
||
|
||
if create_vacancy:
|
||
self.update_state(
|
||
state="PROGRESS",
|
||
meta={"status": "Создаем вакансию в базе данных...", "progress": 90},
|
||
)
|
||
|
||
try:
|
||
print(f"Parsed data for vacancy creation: {parsed_data}")
|
||
vacancy_create = VacancyCreate(**parsed_data)
|
||
print(f"VacancyCreate object created successfully: {vacancy_create}")
|
||
|
||
with get_sync_session() as session:
|
||
vacancy_repo = SyncVacancyRepository(session)
|
||
created_vacancy = vacancy_repo.create_vacancy(vacancy_create)
|
||
print(
|
||
f"Vacancy created with ID: {created_vacancy.id if created_vacancy else 'None'}"
|
||
)
|
||
|
||
except Exception as e:
|
||
import traceback
|
||
|
||
error_details = traceback.format_exc()
|
||
print(f"Error creating vacancy: {str(e)}")
|
||
print(f"Full traceback: {error_details}")
|
||
|
||
# Возвращаем парсинг, но предупреждаем об ошибке создания
|
||
self.update_state(
|
||
state="SUCCESS",
|
||
meta={
|
||
"status": f"Парсинг выполнен, но ошибка при создании вакансии: {str(e)}",
|
||
"progress": 100,
|
||
"result": parsed_data,
|
||
"warning": f"Ошибка создания вакансии: {str(e)}",
|
||
},
|
||
)
|
||
|
||
return {
|
||
"status": "parsed_with_warning",
|
||
"parsed_data": parsed_data,
|
||
"warning": f"Ошибка при создании вакансии: {str(e)}",
|
||
}
|
||
|
||
# Завершено успешно
|
||
response_message = "Парсинг выполнен успешно"
|
||
if created_vacancy:
|
||
response_message += f". Вакансия создана с ID: {created_vacancy.id}"
|
||
|
||
self.update_state(
|
||
state="SUCCESS",
|
||
meta={
|
||
"status": response_message,
|
||
"progress": 100,
|
||
"result": parsed_data,
|
||
"vacancy_id": created_vacancy.id if created_vacancy else None,
|
||
},
|
||
)
|
||
|
||
return {
|
||
"status": "completed",
|
||
"parsed_data": parsed_data,
|
||
"vacancy_id": created_vacancy.id if created_vacancy else None,
|
||
"message": response_message,
|
||
}
|
||
|
||
except Exception as e:
|
||
# В случае ошибки
|
||
self.update_state(
|
||
state="FAILURE",
|
||
meta={
|
||
"status": f"Ошибка при парсинге вакансии: {str(e)}",
|
||
"progress": 0,
|
||
"error": str(e),
|
||
},
|
||
)
|
||
|
||
raise Exception(f"Ошибка при парсинге вакансии: {str(e)}")
|
||
|
||
|
||
@celery_app.task(bind=True)
|
||
def generate_pdf_report_task(
|
||
self,
|
||
report_data: dict,
|
||
candidate_name: str = None,
|
||
position: str = None,
|
||
resume_file_url: str = None,
|
||
):
|
||
"""
|
||
Асинхронная задача для генерации PDF отчета по интервью с использованием PDFShift API
|
||
|
||
Args:
|
||
report_data: Словарь с данными отчета InterviewReport
|
||
candidate_name: Имя кандидата
|
||
position: Позиция
|
||
resume_file_url: URL резюме
|
||
"""
|
||
try:
|
||
import asyncio
|
||
import requests
|
||
from datetime import datetime
|
||
from app.core.config import settings
|
||
from celery_worker.database import (
|
||
SyncInterviewReportRepository,
|
||
get_sync_session,
|
||
)
|
||
|
||
# Обновляем статус задачи
|
||
self.update_state(
|
||
state="PENDING",
|
||
meta={"status": "Начинаем генерацию PDF отчета...", "progress": 10},
|
||
)
|
||
|
||
# Генерируем HTML контент отчета из Jinja шаблона
|
||
self.update_state(
|
||
state="PROGRESS",
|
||
meta={"status": "Подготавливаем HTML отчета...", "progress": 20},
|
||
)
|
||
|
||
# Создаем объект InterviewReport для использования в сервисе
|
||
clean_report_data = report_data.copy()
|
||
clean_report_data.pop('created_at', None)
|
||
clean_report_data.pop('updated_at', None)
|
||
|
||
from app.models.interview_report import InterviewReport
|
||
mock_report = InterviewReport(**clean_report_data)
|
||
|
||
# Генерируем HTML через сервис
|
||
def run_html_generation():
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
try:
|
||
from app.services.pdf_report_service import pdf_report_service
|
||
return loop.run_until_complete(
|
||
pdf_report_service.generate_html_report(
|
||
mock_report, candidate_name, position, resume_file_url
|
||
)
|
||
)
|
||
finally:
|
||
loop.close()
|
||
|
||
html_content = run_html_generation()
|
||
|
||
# Генерируем PDF через Cloudmersive API
|
||
self.update_state(
|
||
state="PROGRESS",
|
||
meta={"status": "Конвертируем HTML в PDF через Cloudmersive...", "progress": 50}
|
||
)
|
||
|
||
pdf_bytes = _convert_html_to_pdf_cloudmersive(html_content)
|
||
|
||
# Загружаем в S3
|
||
self.update_state(
|
||
state="PROGRESS",
|
||
meta={"status": "Загружаем PDF в хранилище...", "progress": 80},
|
||
)
|
||
|
||
def run_s3_upload():
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
try:
|
||
from app.services.pdf_report_service import pdf_report_service
|
||
|
||
# Создаем имя файла
|
||
safe_name = (
|
||
candidate_name
|
||
if candidate_name and candidate_name != "Не указано"
|
||
else "candidate"
|
||
)
|
||
safe_name = "".join(
|
||
c for c in safe_name if c.isalnum() or c in (" ", "-", "_")
|
||
).strip()
|
||
report_id = report_data.get("id")
|
||
filename = f"interview_report_{safe_name}_{report_id}.pdf"
|
||
|
||
return loop.run_until_complete(
|
||
pdf_report_service.upload_pdf_to_s3(pdf_bytes, filename)
|
||
)
|
||
finally:
|
||
loop.close()
|
||
|
||
pdf_url = run_s3_upload()
|
||
|
||
# Обновляем отчет с URL PDF файла
|
||
self.update_state(
|
||
state="PROGRESS",
|
||
meta={"status": "Сохраняем ссылку на отчет...", "progress": 90},
|
||
)
|
||
|
||
report_id = report_data.get("id")
|
||
with get_sync_session() as session:
|
||
report_repo = SyncInterviewReportRepository(session)
|
||
report_repo.update_pdf_url(report_id, pdf_url)
|
||
|
||
# Завершено успешно
|
||
self.update_state(
|
||
state="SUCCESS",
|
||
meta={
|
||
"status": "PDF отчет успешно сгенерирован",
|
||
"progress": 100,
|
||
"pdf_url": pdf_url,
|
||
"file_size": len(pdf_bytes),
|
||
},
|
||
)
|
||
|
||
return {
|
||
"interview_report_id": report_id,
|
||
"status": "completed",
|
||
"pdf_url": pdf_url,
|
||
"file_size": len(pdf_bytes),
|
||
}
|
||
|
||
except Exception as e:
|
||
# В случае ошибки
|
||
self.update_state(
|
||
state="FAILURE",
|
||
meta={
|
||
"status": f"Ошибка при генерации PDF: {str(e)}",
|
||
"progress": 0,
|
||
"error": str(e),
|
||
},
|
||
)
|
||
|
||
raise Exception(f"Ошибка при генерации PDF: {str(e)}")
|
||
|
||
|
||
def _convert_html_to_pdf_cloudmersive(html_content: str) -> bytes:
|
||
"""
|
||
Конвертирует HTML в PDF используя Cloudmersive API через HTTP requests
|
||
|
||
Args:
|
||
html_content: HTML содержимое для конвертации
|
||
|
||
Returns:
|
||
bytes: PDF файл в виде байтов
|
||
"""
|
||
import requests
|
||
from app.core.config import settings
|
||
|
||
# Проверяем наличие API ключа
|
||
if not hasattr(settings, 'cloudmersive_api_key') or not settings.cloudmersive_api_key:
|
||
raise Exception("Cloudmersive API ключ не настроен в settings.cloudmersive_api_key")
|
||
|
||
# Проверяем HTML на корректность
|
||
if not html_content or len(html_content.strip()) == 0:
|
||
raise Exception("HTML содержимое пустое")
|
||
|
||
try:
|
||
print(f"[Cloudmersive] Starting HTML to PDF conversion via HTTP...")
|
||
print(f"[Cloudmersive] HTML length: {len(html_content)} characters")
|
||
|
||
# Отправляем HTTP запрос с HTML как raw данные - правильный эндпоинт
|
||
url = "https://api.cloudmersive.com/convert/html/to/pdf"
|
||
headers = {
|
||
'Apikey': settings.cloudmersive_api_key,
|
||
'Content-Type': 'text/html'
|
||
}
|
||
|
||
print(f"[Cloudmersive] Sending HTTP request to {url}")
|
||
print(f"[Cloudmersive] Sending HTML as raw data...")
|
||
|
||
response = requests.post(
|
||
url,
|
||
headers=headers,
|
||
data=html_content.encode('utf-8'),
|
||
timeout=60
|
||
)
|
||
|
||
print(f"[Cloudmersive] Response status: {response.status_code}")
|
||
|
||
if response.status_code == 200:
|
||
pdf_bytes = response.content
|
||
print(f"[Cloudmersive] Conversion successful, PDF size: {len(pdf_bytes)} bytes")
|
||
return pdf_bytes
|
||
else:
|
||
print(f"[Cloudmersive] Error response: {response.text}")
|
||
raise Exception(f"Cloudmersive API returned {response.status_code}: {response.text}")
|
||
|
||
except requests.exceptions.RequestException as e:
|
||
print(f"[Cloudmersive] HTTP Error: {e}")
|
||
raise Exception(f"Ошибка HTTP запроса к Cloudmersive: {str(e)}")
|
||
except Exception as e:
|
||
print(f"[Cloudmersive] Unexpected error: {e}")
|
||
raise Exception(f"Неожиданная ошибка при генерации PDF: {str(e)}")
|