435 lines
18 KiB
Python
435 lines
18 KiB
Python
import json
|
||
import os
|
||
from typing import Any
|
||
|
||
from celery_worker.celery_app import celery_app
|
||
from celery_worker.database import SyncResumeRepository, 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:
|
||
repo = SyncResumeRepository(session)
|
||
resume_record = repo.get_by_id(resume_id)
|
||
|
||
if not resume_record:
|
||
return None
|
||
|
||
# Здесь нужно получить данные вакансии
|
||
# Пока используем заглушку, потом добавим связь с vacancy
|
||
vacancy_data = {
|
||
"title": "Python Developer",
|
||
"requirements": "Python, FastAPI, PostgreSQL, Docker",
|
||
"company_name": "Tech Company",
|
||
"experience_level": "Middle",
|
||
}
|
||
|
||
# Генерируем план через LLM
|
||
chat_model = registry.get_chat_model()
|
||
|
||
plan_prompt = f"""
|
||
Создай детальный план интервью для кандидата на основе его резюме и требований вакансии.
|
||
|
||
РЕЗЮМЕ КАНДИДАТА:
|
||
- Имя: {combined_data.get("name", "Не указано")}
|
||
- Навыки: {", ".join(combined_data.get("skills", []))}
|
||
- Опыт: {combined_data.get("total_years", 0)} лет
|
||
- Образование: {combined_data.get("education", "Не указано")}
|
||
|
||
ВАКАНСИЯ:
|
||
- Позиция: {vacancy_data["title"]}
|
||
- Требования: {vacancy_data["requirements"]}
|
||
- Компания: {vacancy_data["company_name"]}
|
||
- Уровень: {vacancy_data["experience_level"]}
|
||
|
||
Создай план интервью в формате 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 ответ
|
||
if response_text.startswith("{") and response_text.endswith("}"):
|
||
return json.loads(response_text)
|
||
else:
|
||
# Ищем JSON в тексте
|
||
start = response_text.find("{")
|
||
end = response_text.rfind("}") + 1
|
||
if start != -1 and end > start:
|
||
return json.loads(response_text[start:end])
|
||
|
||
return None
|
||
|
||
except Exception as e:
|
||
print(f"Ошибка генерации плана интервью: {str(e)}")
|
||
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 файлу резюме
|
||
"""
|
||
|
||
try:
|
||
# Шаг 0: Обновляем статус в БД - начали парсинг
|
||
with get_sync_session() as session:
|
||
repo = SyncResumeRepository(session)
|
||
repo.update_status(int(resume_id), "parsing")
|
||
|
||
# Обновляем статус задачи
|
||
self.update_state(
|
||
state="PENDING",
|
||
meta={"status": "Начинаем парсинг резюме...", "progress": 10},
|
||
)
|
||
|
||
# Инициализируем модели из registry
|
||
try:
|
||
chat_model = registry.get_chat_model()
|
||
embeddings_model = registry.get_embeddings_model()
|
||
vector_store = registry.get_vector_store()
|
||
except Exception as e:
|
||
# Обновляем статус в БД - ошибка инициализации
|
||
with get_sync_session() as session:
|
||
repo = SyncResumeRepository(session)
|
||
repo.update_status(
|
||
int(resume_id),
|
||
"failed",
|
||
error_message=f"Ошибка инициализации моделей: {str(e)}",
|
||
)
|
||
raise Exception(f"Ошибка инициализации моделей: {str(e)}")
|
||
|
||
# Шаг 1: Парсинг резюме
|
||
self.update_state(
|
||
state="PROGRESS",
|
||
meta={"status": "Извлекаем текст из PDF...", "progress": 20},
|
||
)
|
||
|
||
parser = ResumeParser(chat_model)
|
||
|
||
if not os.path.exists(file_path):
|
||
# Обновляем статус в БД - файл не найден
|
||
with get_sync_session() as session:
|
||
repo = SyncResumeRepository(session)
|
||
repo.update_status(
|
||
int(resume_id),
|
||
"failed",
|
||
error_message=f"Файл не найден: {file_path}",
|
||
)
|
||
raise Exception(f"Файл не найден: {file_path}")
|
||
|
||
parsed_resume = parser.parse_resume_from_file(file_path)
|
||
|
||
# Получаем оригинальные данные из формы
|
||
with get_sync_session() as session:
|
||
repo = SyncResumeRepository(session)
|
||
resume_record = repo.get_by_id(int(resume_id))
|
||
if not resume_record:
|
||
raise Exception(f"Резюме с ID {resume_id} не найдено в базе данных")
|
||
|
||
# Извлекаем нужные данные пока сессия активна
|
||
applicant_name = resume_record.applicant_name
|
||
applicant_email = resume_record.applicant_email
|
||
applicant_phone = resume_record.applicant_phone
|
||
|
||
# Создаем комбинированные данные: навыки и опыт из парсинга, контакты из формы
|
||
combined_data = parsed_resume.copy()
|
||
combined_data["name"] = applicant_name
|
||
combined_data["email"] = applicant_email
|
||
combined_data["phone"] = applicant_phone or parsed_resume.get("phone", "")
|
||
|
||
# Шаг 2: Векторизация и сохранение в Milvus
|
||
self.update_state(
|
||
state="PENDING",
|
||
meta={"status": "Сохраняем в векторную базу...", "progress": 60},
|
||
)
|
||
|
||
vector_store.add_candidate_profile(str(resume_id), combined_data)
|
||
|
||
# Шаг 3: Обновляем статус в PostgreSQL - успешно обработано
|
||
self.update_state(
|
||
state="PENDING",
|
||
meta={"status": "Обновляем статус в базе данных...", "progress": 85},
|
||
)
|
||
|
||
# Шаг 4: Генерируем план интервью
|
||
self.update_state(
|
||
state="PENDING",
|
||
meta={"status": "Генерируем план интервью...", "progress": 90},
|
||
)
|
||
|
||
interview_plan = generate_interview_plan(int(resume_id), combined_data)
|
||
|
||
with get_sync_session() as session:
|
||
repo = SyncResumeRepository(session)
|
||
repo.update_status(int(resume_id), "parsed", parsed_data=combined_data)
|
||
# Сохраняем план интервью
|
||
if interview_plan:
|
||
repo.update_interview_plan(int(resume_id), interview_plan)
|
||
|
||
# Завершено успешно
|
||
self.update_state(
|
||
state="SUCCESS",
|
||
meta={
|
||
"status": "Резюме успешно обработано и план интервью готов",
|
||
"progress": 100,
|
||
"result": combined_data,
|
||
},
|
||
)
|
||
|
||
return {
|
||
"resume_id": resume_id,
|
||
"status": "completed",
|
||
"parsed_data": combined_data,
|
||
}
|
||
|
||
except Exception as e:
|
||
# В случае ошибки
|
||
self.update_state(
|
||
state="FAILURE",
|
||
meta={
|
||
"status": f"Ошибка при обработке резюме: {str(e)}",
|
||
"progress": 0,
|
||
"error": str(e),
|
||
},
|
||
)
|
||
|
||
# Обновляем статус в БД как failed
|
||
try:
|
||
with get_sync_session() as session:
|
||
repo = SyncResumeRepository(session)
|
||
repo.update_status(int(resume_id), "failed", error_message=str(e))
|
||
except Exception as db_error:
|
||
print(f"Ошибка при обновлении статуса в БД: {str(db_error)}")
|
||
|
||
raise
|
||
|
||
|
||
# Функция больше не нужна - используем 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 Exception(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)}")
|