ai-hackaton-backend/celery_worker/tasks.py
2025-09-10 23:55:56 +05:00

985 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

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)}")