diff --git a/app/models/vacancy.py b/app/models/vacancy.py
index 2abcd8c..ca5dd4e 100644
--- a/app/models/vacancy.py
+++ b/app/models/vacancy.py
@@ -38,15 +38,15 @@ class VacancyBase(SQLModel):
salary_to: int | None = None
salary_currency: str | None = Field(default="RUR", max_length=3)
gross_salary: bool | None = False
- company_name: str = Field(max_length=255)
+ company_name: str | None = Field(default=None, max_length=255)
company_description: str | None = None
- area_name: str = Field(max_length=255)
+ area_name: str | None = Field(default=None, max_length=255)
metro_stations: str | None = None
address: str | None = None
professional_roles: str | None = None
- contacts_name: str | None = Field(max_length=255)
- contacts_email: str | None = Field(max_length=255)
- contacts_phone: str | None = Field(max_length=50)
+ contacts_name: str | None = Field(default=None, max_length=255)
+ contacts_email: str | None = Field(default=None, max_length=255)
+ contacts_phone: str | None = Field(default=None, max_length=50)
is_archived: bool = Field(default=False)
premium: bool = Field(default=False)
published_at: datetime | None = Field(default_factory=datetime.utcnow)
diff --git a/app/routers/analysis_router.py b/app/routers/analysis_router.py
index 60dacad..ec5a55e 100644
--- a/app/routers/analysis_router.py
+++ b/app/routers/analysis_router.py
@@ -4,7 +4,7 @@ from pydantic import BaseModel
from app.core.database import get_session
from app.repositories.resume_repository import ResumeRepository
-from app.services.pdf_report_service import pdf_report_service
+from app.services.pdf_report_service import PDFReportService
from celery_worker.interview_analysis_task import (
analyze_multiple_candidates,
generate_interview_report,
@@ -305,6 +305,7 @@ async def generate_pdf_report(
resume_id: int,
session=Depends(get_session),
resume_repo: ResumeRepository = Depends(ResumeRepository),
+ pdf_report_service: PDFReportService = Depends(PDFReportService),
):
"""
Генерирует PDF отчет по интервью
diff --git a/app/routers/vacancy_router.py b/app/routers/vacancy_router.py
index 647cddb..97b8c76 100644
--- a/app/routers/vacancy_router.py
+++ b/app/routers/vacancy_router.py
@@ -1,11 +1,20 @@
-from fastapi import APIRouter, Depends, HTTPException, Query
+from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
+from pydantic import BaseModel
from app.models.vacancy import VacancyCreate, VacancyRead, VacancyUpdate
from app.services.vacancy_service import VacancyService
+from app.services.vacancy_parser_service import vacancy_parser_service
router = APIRouter(prefix="/vacancies", tags=["vacancies"])
+class VacancyParseResponse(BaseModel):
+ """Ответ на запрос парсинга вакансии"""
+ message: str
+ parsed_data: dict | None = None
+ task_id: str | None = None
+
+
@router.post("/", response_model=VacancyRead)
async def create_vacancy(
vacancy: VacancyCreate, vacancy_service: VacancyService = Depends(VacancyService)
@@ -78,3 +87,284 @@ async def archive_vacancy(
if not archived_vacancy:
raise HTTPException(status_code=404, detail="Vacancy not found")
return archived_vacancy
+
+
+@router.post("/parse-file", response_model=VacancyParseResponse)
+async def parse_vacancy_from_file(
+ file: UploadFile = File(...),
+ create_vacancy: bool = Query(False, description="Создать вакансию после парсинга"),
+ vacancy_service: VacancyService = Depends(VacancyService),
+):
+ """
+ Парсинг вакансии из загруженного файла (PDF, DOCX, RTF, TXT)
+
+ Args:
+ file: Файл вакансии
+ create_vacancy: Создать вакансию в БД после парсинга
+
+ Returns:
+ VacancyParseResponse: Результат парсинга
+ """
+
+ # Проверяем формат файла
+ if not file.filename:
+ raise HTTPException(status_code=400, detail="Имя файла не указано")
+
+ file_extension = file.filename.lower().split('.')[-1]
+ supported_formats = ['pdf', 'docx', 'rtf', 'txt']
+
+ if file_extension not in supported_formats:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Неподдерживаемый формат файла. Поддерживаются: {', '.join(supported_formats)}"
+ )
+
+ # Проверяем размер файла (максимум 10MB)
+ file_content = await file.read()
+ if len(file_content) > 10 * 1024 * 1024:
+ raise HTTPException(status_code=400, detail="Файл слишком большой (максимум 10MB)")
+
+ try:
+ # Извлекаем текст из файла
+ raw_text = vacancy_parser_service.extract_text_from_file(file_content, file.filename)
+
+ if not raw_text.strip():
+ raise HTTPException(status_code=400, detail="Не удалось извлечь текст из файла")
+
+ # Парсим с помощью AI
+ parsed_data = await vacancy_parser_service.parse_vacancy_with_ai(raw_text)
+
+ # Если нужно создать вакансию, создаем её
+ created_vacancy = None
+ if create_vacancy:
+ try:
+ vacancy_create = VacancyCreate(**parsed_data)
+ created_vacancy = await vacancy_service.create_vacancy(vacancy_create)
+ except Exception as e:
+ # Возвращаем парсинг, но предупреждаем об ошибке создания
+ return VacancyParseResponse(
+ message=f"Парсинг выполнен, но ошибка при создании вакансии: {str(e)}",
+ parsed_data=parsed_data
+ )
+
+ response_message = "Парсинг выполнен успешно"
+ if created_vacancy:
+ response_message += f". Вакансия создана с ID: {created_vacancy.id}"
+
+ return VacancyParseResponse(
+ message=response_message,
+ parsed_data=parsed_data
+ )
+
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Ошибка при парсинге вакансии: {str(e)}")
+
+
+@router.post("/parse-text", response_model=VacancyParseResponse)
+async def parse_vacancy_from_text(
+ text: str = Query(..., description="Текст вакансии для парсинга"),
+ create_vacancy: bool = Query(False, description="Создать вакансию после парсинга"),
+ vacancy_service: VacancyService = Depends(VacancyService),
+):
+ """
+ Парсинг вакансии из текста
+
+ Args:
+ text: Текст вакансии
+ create_vacancy: Создать вакансию в БД после парсинга
+
+ Returns:
+ VacancyParseResponse: Результат парсинга
+ """
+
+ if not text.strip():
+ raise HTTPException(status_code=400, detail="Текст вакансии не может быть пустым")
+
+ if len(text) > 50000: # Ограничение на длину текста
+ raise HTTPException(status_code=400, detail="Текст слишком длинный (максимум 50000 символов)")
+
+ try:
+ # Парсим с помощью AI
+ parsed_data = await vacancy_parser_service.parse_vacancy_with_ai(text)
+
+ # Если нужно создать вакансию, создаем её
+ created_vacancy = None
+ if create_vacancy:
+ try:
+ vacancy_create = VacancyCreate(**parsed_data)
+ created_vacancy = await vacancy_service.create_vacancy(vacancy_create)
+ except Exception as e:
+ return VacancyParseResponse(
+ message=f"Парсинг выполнен, но ошибка при создании вакансии: {str(e)}",
+ parsed_data=parsed_data
+ )
+
+ response_message = "Парсинг выполнен успешно"
+ if created_vacancy:
+ response_message += f". Вакансия создана с ID: {created_vacancy.id}"
+
+ return VacancyParseResponse(
+ message=response_message,
+ parsed_data=parsed_data
+ )
+
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Ошибка при парсинге вакансии: {str(e)}")
+
+
+@router.get("/parse-formats")
+async def get_supported_formats():
+ """
+ Получить список поддерживаемых форматов файлов для парсинга вакансий
+
+ Returns:
+ dict: Информация о поддерживаемых форматах
+ """
+ return {
+ "supported_formats": [
+ {
+ "extension": "pdf",
+ "description": "PDF документы",
+ "max_size_mb": 10
+ },
+ {
+ "extension": "docx",
+ "description": "Microsoft Word документы",
+ "max_size_mb": 10
+ },
+ {
+ "extension": "rtf",
+ "description": "Rich Text Format",
+ "max_size_mb": 10
+ },
+ {
+ "extension": "txt",
+ "description": "Текстовые файлы",
+ "max_size_mb": 10
+ }
+ ],
+ "features": [
+ "Автоматическое извлечение текста из файлов",
+ "AI-парсинг структурированной информации",
+ "Создание вакансии в базе данных",
+ "Валидация данных"
+ ]
+ }
+
+
+@router.post("/parse-file-async", response_model=dict)
+async def parse_vacancy_from_file_async(
+ file: UploadFile = File(...),
+ create_vacancy: str = Form("false", description="Создать вакансию после парсинга"),
+):
+ """
+ Асинхронный парсинг вакансии из загруженного файла (PDF, DOCX, RTF, TXT)
+
+ Args:
+ file: Файл вакансии
+ create_vacancy: Создать вакансию в БД после парсинга
+
+ Returns:
+ dict: ID задачи для отслеживания статуса
+ """
+ import base64
+ from celery_worker.tasks import parse_vacancy_task
+
+ # Проверяем формат файла
+ if not file.filename:
+ raise HTTPException(status_code=400, detail="Имя файла не указано")
+
+ file_extension = file.filename.lower().split('.')[-1]
+ supported_formats = ['pdf', 'docx', 'rtf', 'txt']
+
+ if file_extension not in supported_formats:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Неподдерживаемый формат файла. Поддерживаются: {', '.join(supported_formats)}"
+ )
+
+ # Проверяем размер файла (максимум 10MB)
+ file_content = await file.read()
+ if len(file_content) > 10 * 1024 * 1024:
+ raise HTTPException(status_code=400, detail="Файл слишком большой (максимум 10MB)")
+
+ try:
+ # Кодируем содержимое файла в base64 для передачи в Celery
+ file_content_base64 = base64.b64encode(file_content).decode('utf-8')
+
+ # Конвертируем строку в boolean
+ create_vacancy_bool = create_vacancy.lower() in ('true', '1', 'yes', 'on')
+
+ # Запускаем асинхронную задачу
+ task = parse_vacancy_task.delay(
+ file_content_base64=file_content_base64,
+ filename=file.filename,
+ create_vacancy=create_vacancy_bool
+ )
+
+ return {
+ "message": "Задача парсинга запущена",
+ "task_id": task.id,
+ "status": "pending",
+ "check_status_url": f"/api/v1/vacancies/parse-status/{task.id}"
+ }
+
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Ошибка при запуске парсинга: {str(e)}")
+
+
+@router.get("/parse-status/{task_id}")
+async def get_parse_status(task_id: str):
+ """
+ Получить статус асинхронной задачи парсинга вакансии
+
+ Args:
+ task_id: ID задачи
+
+ Returns:
+ dict: Статус задачи и результат (если завершена)
+ """
+ from celery_worker.celery_app import celery_app
+
+ try:
+ task = celery_app.AsyncResult(task_id)
+
+ if task.state == 'PENDING':
+ response = {
+ 'task_id': task_id,
+ 'state': task.state,
+ 'status': 'Задача ожидает выполнения...',
+ 'progress': 0
+ }
+ elif task.state == 'PROGRESS':
+ response = {
+ 'task_id': task_id,
+ 'state': task.state,
+ 'status': task.info.get('status', ''),
+ 'progress': task.info.get('progress', 0)
+ }
+ elif task.state == 'SUCCESS':
+ response = {
+ 'task_id': task_id,
+ 'state': task.state,
+ 'status': 'completed',
+ 'progress': 100,
+ 'result': task.result
+ }
+ else: # FAILURE
+ response = {
+ 'task_id': task_id,
+ 'state': task.state,
+ 'status': 'failed',
+ 'progress': 0,
+ 'error': str(task.info)
+ }
+
+ return response
+
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Ошибка при получении статуса задачи: {str(e)}")
diff --git a/app/services/pdf_report_service.py b/app/services/pdf_report_service.py
index 18bfc07..95ff537 100644
--- a/app/services/pdf_report_service.py
+++ b/app/services/pdf_report_service.py
@@ -1,458 +1,264 @@
import io
+import os
from datetime import datetime
-from reportlab.lib import colors
-from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY
-from reportlab.lib.pagesizes import A4
-from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
-from reportlab.lib.units import inch
-from reportlab.pdfbase import pdfmetrics
-from reportlab.pdfbase.ttfonts import TTFont
-from reportlab.platypus import (
- Paragraph,
- SimpleDocTemplate,
- Spacer,
- Table,
- TableStyle,
-)
+from jinja2 import Template
+import pdfkit
from app.core.s3 import s3_service
from app.models.interview_report import InterviewReport, RecommendationType
class PDFReportService:
- """Сервис для генерации PDF отчетов по интервью"""
+ """Сервис для генерации PDF отчетов по интервью на основе HTML шаблона"""
def __init__(self):
- self._register_fonts()
- self.styles = getSampleStyleSheet()
- self._setup_custom_styles()
-
- def _format_list_field(self, field_value) -> str:
- """Форматирует поле со списком для отображения в PDF"""
- if not field_value:
+ self.template_path = "templates/interview_report.html"
+
+ def _load_html_template(self) -> str:
+ """Загружает HTML шаблон из файла"""
+ try:
+ with open(self.template_path, 'r', encoding='utf-8') as file:
+ return file.read()
+ except FileNotFoundError:
+ raise FileNotFoundError(f"HTML шаблон не найден: {self.template_path}")
+
+ def _format_concerns_field(self, concerns):
+ """Форматирует поле concerns для отображения"""
+ if not concerns:
return "—"
- if isinstance(field_value, list):
- # Если это список, объединяем элементы
- return "\n• ".join([""] + field_value)
- elif isinstance(field_value, str):
- # Если это строка, возвращаем как есть
- return field_value
+ if isinstance(concerns, list):
+ return "; ".join(concerns)
+ elif isinstance(concerns, str):
+ return concerns
else:
- # Для других типов конвертируем в строку
- return str(field_value)
-
- def _register_fonts(self):
- """Регистрация шрифтов для поддержки кириллицы"""
- try:
- # Пытаемся использовать системные шрифты Windows
- import os
-
- # Пути к шрифтам Windows
- fonts_dir = "C:/Windows/Fonts"
-
- # Регистрируем Arial для русского текста
- if os.path.exists(f"{fonts_dir}/arial.ttf"):
- pdfmetrics.registerFont(TTFont('Arial-Unicode', f"{fonts_dir}/arial.ttf"))
- pdfmetrics.registerFont(TTFont('Arial-Unicode-Bold', f"{fonts_dir}/arialbd.ttf"))
- # Альтернативно используем Calibri
- elif os.path.exists(f"{fonts_dir}/calibri.ttf"):
- pdfmetrics.registerFont(TTFont('Arial-Unicode', f"{fonts_dir}/calibri.ttf"))
- pdfmetrics.registerFont(TTFont('Arial-Unicode-Bold', f"{fonts_dir}/calibrib.ttf"))
- # Если ничего не найдено, используем встроенный DejaVu
- else:
- # Fallback к стандартным шрифтам ReportLab с поддержкой Unicode
- from reportlab.lib.fonts import addMapping
- addMapping('Arial-Unicode', 0, 0, 'Helvetica')
- addMapping('Arial-Unicode', 1, 0, 'Helvetica-Bold')
- addMapping('Arial-Unicode', 0, 1, 'Helvetica-Oblique')
- addMapping('Arial-Unicode', 1, 1, 'Helvetica-BoldOblique')
-
- except Exception as e:
- print(f"Warning: Could not register custom fonts: {e}")
- # Используем стандартные шрифты как fallback
- from reportlab.lib.fonts import addMapping
- addMapping('Arial-Unicode', 0, 0, 'Helvetica')
- addMapping('Arial-Unicode', 1, 0, 'Helvetica-Bold')
-
- def _setup_custom_styles(self):
- """Настройка кастомных стилей для документа"""
- # Заголовок отчета
- self.styles.add(
- ParagraphStyle(
- name="ReportTitle",
- parent=self.styles["Title"],
- fontSize=18,
- spaceAfter=30,
- alignment=TA_CENTER,
- textColor=colors.HexColor("#2E3440"),
- fontName="Arial-Unicode-Bold",
- )
- )
-
- # Заголовки секций
- self.styles.add(
- ParagraphStyle(
- name="SectionHeader",
- parent=self.styles["Heading1"],
- fontSize=14,
- spaceAfter=12,
- spaceBefore=20,
- textColor=colors.HexColor("#5E81AC"),
- fontName="Arial-Unicode-Bold",
- )
- )
-
- # Подзаголовки
- self.styles.add(
- ParagraphStyle(
- name="SubHeader",
- parent=self.styles["Heading2"],
- fontSize=12,
- spaceAfter=8,
- spaceBefore=15,
- textColor=colors.HexColor("#81A1C1"),
- fontName="Arial-Unicode-Bold",
- )
- )
-
- # Обычный текст
- self.styles.add(
- ParagraphStyle(
- name="CustomBodyText",
- parent=self.styles["Normal"],
- fontSize=10,
- spaceAfter=6,
- alignment=TA_JUSTIFY,
- textColor=colors.HexColor("#2E3440"),
- fontName="Arial-Unicode",
- )
- )
-
- # Стиль для метрик
- self.styles.add(
- ParagraphStyle(
- name="MetricValue",
- parent=self.styles["Normal"],
- fontSize=12,
- alignment=TA_CENTER,
- textColor=colors.HexColor("#5E81AC"),
- fontName="Arial-Unicode-Bold",
- )
- )
-
- async def generate_interview_report_pdf(
- self, report: InterviewReport, candidate_name: str, position: str
- ) -> bytes:
+ return str(concerns)
+
+ def _get_score_class(self, score: int) -> str:
+ """Возвращает CSS класс для цвета оценки"""
+ if score >= 80:
+ return "score-green"
+ elif score >= 60:
+ return "score-orange"
+ else:
+ return "score-red"
+
+ def _format_recommendation(self, recommendation: RecommendationType) -> tuple:
+ """Форматирует рекомендацию для отображения"""
+ if recommendation == RecommendationType.HIRE:
+ return ("Рекомендуем", "recommend-button")
+ elif recommendation == RecommendationType.CONSIDER:
+ return ("К рассмотрению", "consider-button")
+ else:
+ return ("Не рекомендуем", "reject-button")
+
+ def generate_pdf_report(self, interview_report: InterviewReport) -> bytes:
"""
- Генерирует PDF отчет по интервью
-
+ Генерирует PDF отчет на основе HTML шаблона
+
Args:
- report: Модель отчета из БД
- candidate_name: Имя кандидата
- position: Название позиции
-
+ interview_report: Данные отчета по интервью
+
Returns:
bytes: PDF файл в виде байтов
"""
- buffer = io.BytesIO()
- doc = SimpleDocTemplate(
- buffer,
- pagesize=A4,
- rightMargin=72,
- leftMargin=72,
- topMargin=72,
- bottomMargin=72,
- )
-
- # Собираем элементы документа
- story = []
-
- # Заголовок отчета
- story.append(
- Paragraph(
- f"Отчет по собеседованию
{candidate_name}",
- self.styles["ReportTitle"],
- )
- )
-
- # Основная информация
- story.append(Paragraph("Основная информация", self.styles["SectionHeader"]))
-
- basic_info = [
- ["Кандидат:", candidate_name],
- ["Позиция:", position],
- ["Дата интервью:", report.created_at.strftime("%d.%m.%Y %H:%M")],
- ["Общий балл:", f"{report.overall_score}/100"],
- ["Рекомендация:", self._format_recommendation(report.recommendation)],
- ]
-
- basic_table = Table(basic_info, colWidths=[2 * inch, 4 * inch])
- basic_table.setStyle(
- TableStyle(
- [
- ("ALIGN", (0, 0), (-1, -1), "LEFT"),
- ("FONTNAME", (0, 0), (0, -1), "Arial-Unicode-Bold"),
- ("FONTNAME", (1, 0), (-1, -1), "Arial-Unicode"), # Правая колонка обычным шрифтом
- ("FONTSIZE", (0, 0), (-1, -1), 10),
- ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
- ("TOPPADDING", (0, 0), (-1, -1), 6),
- ]
- )
- )
- story.append(basic_table)
- story.append(Spacer(1, 20))
-
- # Оценки по критериям
- story.append(Paragraph("Детальная оценка", self.styles["SectionHeader"]))
-
- # Стиль для текста в таблице с автопереносом
- table_text_style = ParagraphStyle(
- name="TableText",
- parent=self.styles["Normal"],
- fontSize=8,
- fontName="Arial-Unicode",
- leading=10,
- )
+ try:
+ # Загружаем HTML шаблон
+ html_template = self._load_html_template()
+
+ # Подготавливаем данные для шаблона
+ template_data = self._prepare_template_data(interview_report)
+
+ # Рендерим HTML с данными
+ template = Template(html_template)
+ rendered_html = template.render(**template_data)
+
+ # Настройки для wkhtmltopdf
+ options = {
+ 'page-size': 'A4',
+ 'margin-top': '0.75in',
+ 'margin-right': '0.75in',
+ 'margin-bottom': '0.75in',
+ 'margin-left': '0.75in',
+ 'encoding': 'UTF-8',
+ 'no-outline': None,
+ 'enable-local-file-access': None
+ }
+
+ # Генерируем PDF
+ pdf_bytes = pdfkit.from_string(rendered_html, False, options=options)
+
+ return pdf_bytes
+
+ except Exception as e:
+ raise Exception(f"Ошибка при генерации PDF: {str(e)}")
+
+ def _prepare_template_data(self, interview_report: InterviewReport) -> dict:
+ """Подготавливает данные для HTML шаблона"""
+
+ # Основная информация о кандидате
+ candidate_name = interview_report.resume.applicant_name or "Не указано"
+ position = "Не указана"
+
+ # Получаем название позиции из связанной вакансии
+ if hasattr(interview_report.resume, 'vacancy') and interview_report.resume.vacancy:
+ position = interview_report.resume.vacancy.title
+
+ # Форматируем дату интервью
+ interview_date = "Не указана"
+ if interview_report.interview_session and interview_report.interview_session.interview_start_time:
+ interview_date = interview_report.interview_session.interview_start_time.strftime("%d.%m.%Y %H:%M")
+
+ # Общий балл и рекомендация
+ overall_score = interview_report.overall_score or 0
+ recommendation_text, recommendation_class = self._format_recommendation(interview_report.recommendation)
+
+ # Сильные стороны и области развития
+ strengths = self._format_concerns_field(interview_report.strengths_concerns) if interview_report.strengths_concerns else "Не указаны"
+ areas_for_development = self._format_concerns_field(interview_report.areas_for_development_concerns) if interview_report.areas_for_development_concerns else "Не указаны"
+
+ # Детальная оценка
+ evaluation_criteria = []
+
+ # Технические навыки
+ if interview_report.technical_skills_score is not None:
+ evaluation_criteria.append({
+ 'name': 'Технические навыки',
+ 'score': interview_report.technical_skills_score,
+ 'score_class': self._get_score_class(interview_report.technical_skills_score),
+ 'justification': interview_report.technical_skills_justification or "—",
+ 'concerns': self._format_concerns_field(interview_report.technical_skills_concerns)
+ })
+
+ # Релевантность опыта
+ if interview_report.experience_relevance_score is not None:
+ evaluation_criteria.append({
+ 'name': 'Релевантность опыта',
+ 'score': interview_report.experience_relevance_score,
+ 'score_class': self._get_score_class(interview_report.experience_relevance_score),
+ 'justification': interview_report.experience_relevance_justification or "—",
+ 'concerns': self._format_concerns_field(interview_report.experience_relevance_concerns)
+ })
+
+ # Коммуникация
+ if interview_report.communication_score is not None:
+ evaluation_criteria.append({
+ 'name': 'Коммуникация',
+ 'score': interview_report.communication_score,
+ 'score_class': self._get_score_class(interview_report.communication_score),
+ 'justification': interview_report.communication_justification or "—",
+ 'concerns': self._format_concerns_field(interview_report.communication_concerns)
+ })
+
+ # Решение задач
+ if interview_report.problem_solving_score is not None:
+ evaluation_criteria.append({
+ 'name': 'Решение задач',
+ 'score': interview_report.problem_solving_score,
+ 'score_class': self._get_score_class(interview_report.problem_solving_score),
+ 'justification': interview_report.problem_solving_justification or "—",
+ 'concerns': self._format_concerns_field(interview_report.problem_solving_concerns)
+ })
+
+ # Культурное соответствие
+ if interview_report.cultural_fit_score is not None:
+ evaluation_criteria.append({
+ 'name': 'Культурное соответствие',
+ 'score': interview_report.cultural_fit_score,
+ 'score_class': self._get_score_class(interview_report.cultural_fit_score),
+ 'justification': interview_report.cultural_fit_justification or "—",
+ 'concerns': self._format_concerns_field(interview_report.cultural_fit_concerns)
+ })
- criteria_data = [
- [
- Paragraph("Критерий", self.styles["CustomBodyText"]),
- Paragraph("Балл", self.styles["CustomBodyText"]),
- Paragraph("Обоснование", self.styles["CustomBodyText"]),
- Paragraph("Риски", self.styles["CustomBodyText"]),
- ],
- [
- Paragraph("Технические навыки", table_text_style),
- Paragraph(f"{report.technical_skills_score}/100", table_text_style),
- Paragraph(report.technical_skills_justification 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(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(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(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(self._format_list_field(report.cultural_fit_concerns), table_text_style),
- ],
- ]
-
- criteria_table = Table(
- criteria_data, colWidths=[1.5 * inch, 0.6 * inch, 2.8 * inch, 2.1 * inch]
- )
- criteria_table.setStyle(
- TableStyle(
- [
- ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#5E81AC")),
- ("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke),
- ("ALIGN", (1, 1), (1, -1), "CENTER"), # Центрирование баллов
- ("ALIGN", (0, 0), (-1, -1), "LEFT"), # Остальное слева
- ("GRID", (0, 0), (-1, -1), 1, colors.HexColor("#D8DEE9")),
- ("VALIGN", (0, 0), (-1, -1), "TOP"),
- ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
- ("TOPPADDING", (0, 0), (-1, -1), 8),
- ("LEFTPADDING", (0, 0), (-1, -1), 6),
- ("RIGHTPADDING", (0, 0), (-1, -1), 6),
- ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8F9FA")]),
- ]
- )
- )
-
- # Цветовое кодирование баллов
- for i in range(1, 6): # строки с баллами
- score_cell = (1, i)
- if hasattr(
- report,
- [
- "technical_skills_score",
- "experience_relevance_score",
- "communication_score",
- "problem_solving_score",
- "cultural_fit_score",
- ][i - 1],
- ):
- score = getattr(
- report,
- [
- "technical_skills_score",
- "experience_relevance_score",
- "communication_score",
- "problem_solving_score",
- "cultural_fit_score",
- ][i - 1],
- )
- if score >= 80:
- criteria_table.setStyle(
- TableStyle(
- [
- (
- "BACKGROUND",
- score_cell,
- score_cell,
- colors.HexColor("#A3BE8C"),
- )
- ]
- )
- )
- elif score >= 60:
- criteria_table.setStyle(
- TableStyle(
- [
- (
- "BACKGROUND",
- score_cell,
- score_cell,
- colors.HexColor("#EBCB8B"),
- )
- ]
- )
- )
- else:
- criteria_table.setStyle(
- TableStyle(
- [
- (
- "BACKGROUND",
- score_cell,
- score_cell,
- colors.HexColor("#BF616A"),
- )
- ]
- )
- )
-
- story.append(criteria_table)
- story.append(Spacer(1, 20))
-
- # Сильные и слабые стороны
- if report.strengths or report.weaknesses:
- story.append(Paragraph("Анализ кандидата", self.styles["SectionHeader"]))
-
- if report.strengths:
- story.append(Paragraph("Сильные стороны:", self.styles["SubHeader"]))
- for strength in report.strengths:
- story.append(Paragraph(f"• {strength}", self.styles["CustomBodyText"]))
- story.append(Spacer(1, 10))
-
- if report.weaknesses:
- story.append(
- Paragraph("Области для развития:", self.styles["SubHeader"])
- )
- for weakness in report.weaknesses:
- story.append(Paragraph(f"• {weakness}", self.styles["CustomBodyText"]))
- story.append(Spacer(1, 15))
-
# Красные флаги
- if report.red_flags:
- story.append(Paragraph("Красные флаги:", self.styles["SectionHeader"]))
- for red_flag in report.red_flags:
- story.append(
- Paragraph(
- f"{red_flag}",
- ParagraphStyle(
- name="RedFlag",
- parent=self.styles["CustomBodyText"],
- textColor=colors.HexColor("#BF616A"),
- ),
- )
- )
- story.append(Spacer(1, 15))
-
- # Рекомендации и следующие шаги
- if report.next_steps:
- story.append(Paragraph("Рекомендации:", self.styles["SectionHeader"]))
- story.append(Paragraph(report.next_steps, self.styles["CustomBodyText"]))
- story.append(Spacer(1, 15))
-
- # Подпись
- story.append(Spacer(1, 30))
- story.append(
- Paragraph(
- f"Отчет сгенерирован автоматически • {datetime.now().strftime('%d.%m.%Y %H:%M')}",
- ParagraphStyle(
- name="Footer",
- parent=self.styles["Normal"],
- fontSize=8,
- alignment=TA_CENTER,
- textColor=colors.HexColor("#4C566A"),
- fontName="Arial-Unicode",
- ),
- )
- )
-
- # Генерируем PDF
- doc.build(story)
- buffer.seek(0)
- return buffer.getvalue()
-
- def _format_recommendation(self, recommendation: RecommendationType) -> str:
- """Форматирует рекомендацию для отображения"""
- recommendation_map = {
- RecommendationType.STRONGLY_RECOMMEND: "Настоятельно рекомендуем",
- RecommendationType.RECOMMEND: "Рекомендуем",
- RecommendationType.CONSIDER: "Рассмотреть кандидатуру",
- RecommendationType.REJECT: "Не рекомендуем",
+ red_flags = []
+ if interview_report.red_flags:
+ if isinstance(interview_report.red_flags, list):
+ red_flags = interview_report.red_flags
+ elif isinstance(interview_report.red_flags, str):
+ red_flags = [interview_report.red_flags]
+
+ # Ссылка на резюме
+ resume_url = interview_report.resume.file_url if interview_report.resume.file_url else "#"
+
+ # ID отчета
+ report_id = f"#{interview_report.id}" if interview_report.id else "#0"
+
+ # Дата генерации отчета
+ generation_date = datetime.now().strftime("%d.%m.%Y %H:%M")
+
+ return {
+ 'report_id': report_id,
+ 'candidate_name': candidate_name,
+ 'position': position,
+ 'interview_date': interview_date,
+ 'overall_score': overall_score,
+ 'recommendation_text': recommendation_text,
+ 'recommendation_class': recommendation_class,
+ 'strengths': strengths,
+ 'areas_for_development': areas_for_development,
+ 'evaluation_criteria': evaluation_criteria,
+ 'red_flags': red_flags,
+ 'resume_url': resume_url,
+ 'generation_date': generation_date
}
- return recommendation_map.get(recommendation, str(recommendation))
- async def generate_and_upload_pdf(
- self, report: InterviewReport, candidate_name: str, position: str
- ) -> str | None:
+ async def upload_pdf_to_s3(self, pdf_bytes: bytes, filename: str) -> str:
"""
- Генерирует PDF отчет и загружает его в S3
-
+ Загружает PDF файл в S3 и возвращает публичную ссылку
+
Args:
- report: Модель отчета из БД
- candidate_name: Имя кандидата
- position: Название позиции
-
+ pdf_bytes: PDF файл в виде байтов
+ filename: Имя файла
+
Returns:
- str | None: URL файла в S3 или None при ошибке
+ str: Публичная ссылка на файл в S3
+ """
+ try:
+ pdf_stream = io.BytesIO(pdf_bytes)
+
+ # Загружаем с публичным доступом
+ file_url = await s3_service.upload_file(
+ pdf_stream,
+ filename,
+ content_type="application/pdf",
+ public=True
+ )
+
+ return file_url
+
+ except Exception as e:
+ raise Exception(f"Ошибка при загрузке PDF в S3: {str(e)}")
+
+ async def generate_and_upload_pdf(self, report: InterviewReport, candidate_name: str = None, position: str = None) -> str:
+ """
+ Генерирует PDF отчет и загружает его в S3 (метод обратной совместимости)
+
+ Args:
+ report: Отчет по интервью
+ candidate_name: Имя кандидата (не используется, берется из отчета)
+ position: Позиция (не используется, берется из отчета)
+
+ Returns:
+ str: Публичная ссылка на PDF файл
"""
try:
# Генерируем PDF
- pdf_bytes = await self.generate_interview_report_pdf(
- report, candidate_name, position
- )
-
- # Формируем имя файла
- safe_name = "".join(
- c for c in candidate_name if c.isalnum() or c in (" ", "-", "_")
- ).strip()
- safe_name = safe_name.replace(" ", "_")
+ pdf_bytes = self.generate_pdf_report(report)
+
+ # Создаем имя файла
+ safe_name = report.resume.applicant_name or "candidate"
+ safe_name = "".join(c for c in safe_name if c.isalnum() or c in (' ', '-', '_')).strip()
filename = f"interview_report_{safe_name}_{report.id}.pdf"
-
- # Загружаем в S3 с публичным доступом
- file_url = await s3_service.upload_file(
- file_content=pdf_bytes,
- file_name=filename,
- content_type="application/pdf",
- public=True,
- )
-
- return file_url
-
+
+ # Загружаем в S3
+ pdf_url = await self.upload_pdf_to_s3(pdf_bytes, filename)
+
+ return pdf_url
+
except Exception as e:
- print(f"Error generating and uploading PDF report: {e}")
- return None
+ raise Exception(f"Ошибка при генерации и загрузке PDF: {str(e)}")
# Экземпляр сервиса
diff --git a/app/services/vacancy_parser_service.py b/app/services/vacancy_parser_service.py
new file mode 100644
index 0000000..2d173de
--- /dev/null
+++ b/app/services/vacancy_parser_service.py
@@ -0,0 +1,299 @@
+import io
+import json
+import logging
+from pathlib import Path
+from typing import Any, Dict
+
+logger = logging.getLogger(__name__)
+
+
+class VacancyParserService:
+ """Сервис для парсинга вакансий из файлов различных форматов"""
+
+ def __init__(self):
+ self.supported_formats = ['.pdf', '.docx', '.rtf', '.txt']
+
+ def extract_text_from_file(self, file_content: bytes, filename: str) -> str:
+ """
+ Извлекает текст из файла в зависимости от его формата
+
+ Args:
+ file_content: Содержимое файла в байтах
+ filename: Имя файла для определения формата
+
+ Returns:
+ str: Извлеченный текст
+ """
+ file_extension = Path(filename).suffix.lower()
+
+ try:
+ if file_extension == '.pdf':
+ return self._extract_from_pdf(file_content)
+ elif file_extension == '.docx':
+ return self._extract_from_docx(file_content)
+ elif file_extension == '.rtf':
+ return self._extract_from_rtf(file_content)
+ elif file_extension == '.txt':
+ return self._extract_from_txt(file_content)
+ else:
+ raise ValueError(f"Неподдерживаемый формат файла: {file_extension}")
+
+ except Exception as e:
+ logger.error(f"Ошибка при извлечении текста из файла {filename}: {str(e)}")
+ raise
+
+ def _extract_from_pdf(self, file_content: bytes) -> str:
+ """Извлекает текст из PDF файла"""
+ try:
+ import PyPDF2
+
+ pdf_file = io.BytesIO(file_content)
+ pdf_reader = PyPDF2.PdfReader(pdf_file)
+
+ text = ""
+ for page in pdf_reader.pages:
+ text += page.extract_text() + "\n"
+
+ return text.strip()
+
+ except ImportError:
+ # Fallback to pdfplumber if PyPDF2 doesn't work well
+ try:
+ import pdfplumber
+
+ pdf_file = io.BytesIO(file_content)
+ text = ""
+
+ with pdfplumber.open(pdf_file) as pdf:
+ for page in pdf.pages:
+ page_text = page.extract_text()
+ if page_text:
+ text += page_text + "\n"
+
+ return text.strip()
+
+ except ImportError:
+ raise ImportError("Требуется установить PyPDF2 или pdfplumber: pip install PyPDF2 pdfplumber")
+
+ def _extract_from_docx(self, file_content: bytes) -> str:
+ """Извлекает текст из DOCX файла"""
+ try:
+ import docx
+
+ doc_file = io.BytesIO(file_content)
+ doc = docx.Document(doc_file)
+
+ text = ""
+ for paragraph in doc.paragraphs:
+ text += paragraph.text + "\n"
+
+ # Также извлекаем текст из таблиц
+ for table in doc.tables:
+ for row in table.rows:
+ for cell in row.cells:
+ text += cell.text + "\t"
+ text += "\n"
+
+ return text.strip()
+
+ except ImportError:
+ raise ImportError("Требуется установить python-docx: pip install python-docx")
+
+ def _extract_from_rtf(self, file_content: bytes) -> str:
+ """Извлекает текст из RTF файла"""
+ try:
+ from striprtf.striprtf import rtf_to_text
+
+ rtf_content = file_content.decode('utf-8', errors='ignore')
+ text = rtf_to_text(rtf_content)
+
+ return text.strip()
+
+ except ImportError:
+ raise ImportError("Требуется установить striprtf: pip install striprtf")
+ except Exception as e:
+ # Альтернативный метод через pyth
+ try:
+ from pyth.plugins.rtf15.reader import Rtf15Reader
+ from pyth.plugins.plaintext.writer import PlaintextWriter
+
+ doc = Rtf15Reader.read(io.BytesIO(file_content))
+ text = PlaintextWriter.write(doc).getvalue()
+
+ return text.strip()
+
+ except ImportError:
+ raise ImportError("Требуется установить striprtf или pyth: pip install striprtf pyth")
+
+ def _extract_from_txt(self, file_content: bytes) -> str:
+ """Извлекает текст из TXT файла"""
+ try:
+ # Пробуем различные кодировки
+ encodings = ['utf-8', 'windows-1251', 'cp1252', 'iso-8859-1']
+
+ for encoding in encodings:
+ try:
+ text = file_content.decode(encoding)
+ return text.strip()
+ except UnicodeDecodeError:
+ continue
+
+ # Если все кодировки не подошли, используем errors='ignore'
+ text = file_content.decode('utf-8', errors='ignore')
+ return text.strip()
+
+ except Exception as e:
+ logger.error(f"Ошибка при чтении txt файла: {str(e)}")
+ raise
+
+ async def parse_vacancy_with_ai(self, raw_text: str) -> Dict[str, Any]:
+ """
+ Парсит текст вакансии с помощью AI для извлечения структурированной информации
+
+ Args:
+ raw_text: Сырой текст вакансии
+
+ Returns:
+ Dict с полями для модели Vacancy
+ """
+ from rag.settings import settings
+
+ if not settings.openai_api_key:
+ raise ValueError("OpenAI API ключ не настроен")
+
+ try:
+ import openai
+
+ openai.api_key = settings.openai_api_key
+
+ parsing_prompt = f"""
+Проанализируй текст вакансии и извлеки из него структурированную информацию.
+
+ТЕКСТ ВАКАНСИИ:
+{raw_text}
+
+ЗАДАЧА:
+Извлеки следующие поля для вакансии:
+
+1. title - название позиции (строка)
+2. description - описание вакансии (полное описание обязанностей, требований)
+3. key_skills - ключевые навыки через запятую (строка)
+4. employment_type - тип занятости: "full", "part", "project", "volunteer", "probation"
+5. experience - опыт работы: "noExperience", "between1And3", "between3And6", "moreThan6"
+6. schedule - график работы: "fullDay", "shift", "flexible", "remote", "flyInFlyOut"
+7. salary_from - зарплата от (число или null)
+8. salary_to - зарплата до (число или null)
+9. salary_currency - валюта (строка, по умолчанию "RUR")
+10. company_name - название компании (строка)
+11. company_description - описание компании (строка или null)
+12. area_name - город/регион (строка)
+13. address - адрес (строка или null)
+14. professional_roles - профессиональные роли (строка или null)
+15. contacts_name - контактное лицо (строка или null)
+16. contacts_email - email для связи (строка или null)
+17. contacts_phone - телефон для связи (строка или null)
+
+ПРАВИЛА:
+- Если информация не найдена, ставь null для необязательных полей
+- Для обязательных полей используй разумные значения по умолчанию
+- Зарплату указывай в рублях, конвертируй если нужно
+- Опыт определяй по годам: 0-1 = noExperience, 1-3 = between1And3, 3-6 = between3And6, 6+ = moreThan6
+- График работы определяй по описанию: офис = fullDay, удаленка = remote, гибкий = flexible
+
+ФОРМАТИРОВАНИЕ ТЕКСТА:
+- Если в тексте есть списки (обязанности, требования, навыки), форматируй их с переносами строк
+- Используй символ \n для переноса строки между пунктами списка
+- Пример: "Обязанности:\nВедение переговоров\nПодготовка документов\nОбучение персонала"
+- Для ключевых навыков разделяй запятыми, но если их много - используй переносы строк
+- В описании компании тоже используй переносы для лучшей читаемости
+
+ОТВЕТЬ СТРОГО В JSON ФОРМАТЕ с указанными полями:
+"""
+
+ response = openai.chat.completions.create(
+ model="gpt-4o-mini",
+ messages=[{"role": "user", "content": parsing_prompt}],
+ response_format={"type": "json_object"},
+ )
+
+ parsed_data = json.loads(response.choices[0].message.content)
+
+ # Валидируем и обрабатываем данные
+ return self._validate_parsed_data(parsed_data)
+
+ except Exception as e:
+ logger.error(f"Ошибка при парсинге вакансии через AI: {str(e)}")
+ raise
+
+ def _validate_parsed_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
+ """Валидирует и очищает спарсенные данные"""
+ from app.models.vacancy import EmploymentType, Experience, Schedule
+
+ # Обязательные поля с дефолтными значениями
+ validated_data = {
+ 'title': data.get('title', 'Название не указано'),
+ 'description': data.get('description', 'Описание не указано'),
+ 'key_skills': data.get('key_skills'),
+ 'employment_type': self._validate_enum(
+ data.get('employment_type'),
+ EmploymentType,
+ EmploymentType.FULL_TIME
+ ),
+ 'experience': self._validate_enum(
+ data.get('experience'),
+ Experience,
+ Experience.BETWEEN_1_AND_3
+ ),
+ 'schedule': self._validate_enum(
+ data.get('schedule'),
+ Schedule,
+ Schedule.FULL_DAY
+ ),
+ 'company_name': data.get('company_name'),
+ 'area_name': data.get('area_name'),
+ }
+
+ # Необязательные поля
+ optional_fields = [
+ 'salary_from', 'salary_to', 'salary_currency', 'company_description',
+ 'address', 'professional_roles', 'contacts_name', 'contacts_email', 'contacts_phone'
+ ]
+
+ for field in optional_fields:
+ value = data.get(field)
+ if value and value != "null":
+ validated_data[field] = value
+
+ # Специальная обработка зарплаты
+ if data.get('salary_from'):
+ try:
+ validated_data['salary_from'] = int(data['salary_from'])
+ except (ValueError, TypeError):
+ pass
+
+ if data.get('salary_to'):
+ try:
+ validated_data['salary_to'] = int(data['salary_to'])
+ except (ValueError, TypeError):
+ pass
+
+ # Валюта по умолчанию
+ validated_data['salary_currency'] = data.get('salary_currency', 'RUR')
+
+ return validated_data
+
+ def _validate_enum(self, value: str, enum_class, default_value):
+ """Валидирует значение enum"""
+ if not value:
+ return default_value
+
+ # Проверяем, есть ли такое значение в enum
+ try:
+ return enum_class(value)
+ except ValueError:
+ logger.warning(f"Неизвестное значение {value} для {enum_class.__name__}, используем {default_value}")
+ return default_value
+
+
+# Экземпляр сервиса
+vacancy_parser_service = VacancyParserService()
\ No newline at end of file
diff --git a/celery_worker/database.py b/celery_worker/database.py
index 4efb23f..8d6ad8a 100644
--- a/celery_worker/database.py
+++ b/celery_worker/database.py
@@ -139,3 +139,35 @@ class SyncVacancyRepository:
from app.models.vacancy import Vacancy
return self.session.query(Vacancy).filter(Vacancy.id == vacancy_id).first()
+
+ def create_vacancy(self, vacancy_create):
+ """Создать новую вакансию"""
+ from datetime import datetime
+ from app.models.vacancy import Vacancy
+
+ # Конвертируем VacancyCreate в dict
+ if hasattr(vacancy_create, 'dict'):
+ vacancy_data = vacancy_create.dict()
+ elif hasattr(vacancy_create, 'model_dump'):
+ vacancy_data = vacancy_create.model_dump()
+ else:
+ vacancy_data = vacancy_create
+
+ # Создаем новую вакансию
+ vacancy = Vacancy(
+ **vacancy_data,
+ created_at=datetime.utcnow(),
+ updated_at=datetime.utcnow()
+ )
+
+ self.session.add(vacancy)
+ self.session.flush() # Получаем ID без коммита
+ self.session.refresh(vacancy) # Обновляем объект из БД
+
+ # Создаем простой объект с нужными данными для возврата
+ class VacancyResult:
+ def __init__(self, id, title):
+ self.id = id
+ self.title = title
+
+ return VacancyResult(vacancy.id, vacancy.title)
diff --git a/celery_worker/tasks.py b/celery_worker/tasks.py
index 53b4b80..f6377ad 100644
--- a/celery_worker/tasks.py
+++ b/celery_worker/tasks.py
@@ -578,3 +578,126 @@ def generate_interview_questions_task(self, resume_id: str, job_description: str
},
)
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.services.vacancy_parser_service import vacancy_parser_service
+ from app.models.vacancy import VacancyCreate
+
+ # Обновляем статус задачи
+ 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)}")
diff --git a/pyproject.toml b/pyproject.toml
index 06fd15d..d705d25 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -31,6 +31,8 @@ dependencies = [
"comtypes>=1.4.12",
"reportlab>=4.4.3",
"yandex-speechkit>=1.5.0",
+ "pdfkit>=1.0.0",
+ "jinja2>=3.1.6",
]
[build-system]
diff --git a/templates/interview_report.html b/templates/interview_report.html
new file mode 100644
index 0000000..3a9ec65
--- /dev/null
+++ b/templates/interview_report.html
@@ -0,0 +1,634 @@
+
+
+
+
+
+ Отчет по собеседованию {{ position }} | Анализ кандидата {{ candidate_name }} | HR Система
+
+
+
+
+
+
+
+
+
+
+
+
+
Основная информация
+
+
+ Кандидат:
+ Позиция:
+ Дата интервью:
+ Общий балл:
+ Рекомендация:
+
+
+ {{ candidate_name }}
+ {{ position }}
+ {{ interview_date }}
+ {{ overall_score }}
+
+
+
+
+
+
+
Анализ кандидата
+
+
+ Сильные стороны:
+ Области для развития:
+
+
+
{{ strengths }}
+
{{ areas_for_development }}
+
+
+
+
+
+
+
+
+
Детальная оценка
+
+ {% if evaluation_criteria %}
+
+
+
+ {% for criterion in evaluation_criteria %}
+
+ {{ criterion.name }} |
+
+
+ |
+ {{ criterion.justification }} |
+ {{ criterion.concerns }} |
+
+ {% endfor %}
+
+
+ {% endif %}
+
+
+ {% if red_flags %}
+
+
Красные флаги:
+
+ {% for flag in red_flags %}
+
+ {% endfor %}
+
+
+ {% endif %}
+
+
+
+
+
+
\ No newline at end of file
diff --git a/uv.lock b/uv.lock
index 6f88e5b..7501730 100644
--- a/uv.lock
+++ b/uv.lock
@@ -995,6 +995,7 @@ dependencies = [
{ name = "comtypes" },
{ name = "docx2txt" },
{ name = "fastapi", extra = ["standard"] },
+ { name = "jinja2" },
{ name = "langchain" },
{ name = "langchain-community" },
{ name = "langchain-core" },
@@ -1003,6 +1004,7 @@ dependencies = [
{ name = "livekit" },
{ name = "livekit-agents", extra = ["cartesia", "deepgram", "openai", "resemble", "silero", "turn-detector"] },
{ name = "livekit-api" },
+ { name = "pdfkit" },
{ name = "pdfplumber" },
{ name = "psycopg2-binary" },
{ name = "pydantic-settings" },
@@ -1035,6 +1037,7 @@ requires-dist = [
{ name = "comtypes", specifier = ">=1.4.12" },
{ name = "docx2txt", specifier = ">=0.9" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.104.0" },
+ { name = "jinja2", specifier = ">=3.1.6" },
{ name = "langchain", specifier = ">=0.1.0" },
{ name = "langchain-community", specifier = ">=0.0.10" },
{ name = "langchain-core", specifier = ">=0.1.0" },
@@ -1043,6 +1046,7 @@ requires-dist = [
{ name = "livekit", specifier = ">=1.0.12" },
{ name = "livekit-agents", extras = ["cartesia", "deepgram", "openai", "silero", "resemble", "turn-detector"], specifier = "~=1.2" },
{ name = "livekit-api", specifier = ">=1.0.5" },
+ { name = "pdfkit", specifier = ">=1.0.0" },
{ name = "pdfplumber", specifier = ">=0.10.0" },
{ name = "psycopg2-binary", specifier = ">=2.9.0" },
{ name = "pydantic-settings", specifier = ">=2.1.0" },
@@ -2343,6 +2347,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 },
]
+[[package]]
+name = "pdfkit"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/58/bb/6ddc62b4622776a6514fd749041c2b4bccd343e006d00de590f8090ac8b1/pdfkit-1.0.0.tar.gz", hash = "sha256:992f821e1e18fc8a0e701ecae24b51a2d598296a180caee0a24c0af181da02a9", size = 13288 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/64/1b/26c080096dd93936dccfd32c682bed3d5630a84aae9d493ff68afb2ae0fb/pdfkit-1.0.0-py3-none-any.whl", hash = "sha256:a7a4ca0d978e44fa8310c4909f087052430a6e8e0b1dd7ceef657f139789f96f", size = 12099 },
+]
+
[[package]]
name = "pdfminer-six"
version = "20250506"