Merge remote-tracking branch 'origin/main'

This commit is contained in:
jeez26 2025-09-08 16:07:43 +03:00
commit c5068562c4
10 changed files with 1631 additions and 431 deletions

View File

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

View File

@ -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 отчет по интервью

View File

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

View File

@ -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"Отчет по собеседованию<br/>{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)}")
# Экземпляр сервиса

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,634 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Отчет по собеседованию {{ position }} | Анализ кандидата {{ candidate_name }} | HR Система</title>
<meta name="description" content="Детальный отчет по собеседованию {{ position }} кандидата {{ candidate_name }} с оценкой технических навыков, опыта и рекомендациями по найму">
<meta name="keywords" content="отчет собеседование, оценка кандидата, hr система">
<style>
/* Reset and base styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
background-color: #ffffff;
color: #05000c;
line-height: 1.5;
padding: 16px;
}
/* Layout components */
.main-container {
display: flex;
flex-direction: column;
gap: 28px;
justify-content: flex-start;
align-items: center;
width: 100%;
margin: 0 auto;
}
.content-wrapper {
display: flex;
flex-direction: column;
gap: 64px;
justify-content: flex-start;
align-items: center;
width: 100%;
}
.header-section {
display: flex;
flex-direction: column;
gap: 8px;
justify-content: flex-start;
align-items: flex-start;
width: 100%;
}
.info-analysis-row {
display: flex;
flex-direction: column;
gap: 24px;
justify-content: flex-start;
align-items: flex-start;
width: 100%;
}
.basic-info-section {
display: flex;
flex-direction: column;
gap: 12px;
justify-content: flex-start;
align-items: center;
width: 100%;
}
.analysis-section {
display: flex;
flex-direction: column;
gap: 12px;
justify-content: flex-start;
align-items: center;
width: 100%;
}
.info-grid {
display: flex;
flex-direction: column;
gap: 12px;
justify-content: space-between;
align-items: center;
width: 100%;
}
.info-labels {
display: flex;
flex-direction: column;
gap: 12px;
justify-content: flex-start;
align-items: flex-start;
width: 100%;
}
.info-values {
display: flex;
flex-direction: column;
gap: 12px;
justify-content: flex-start;
align-items: flex-start;
width: 100%;
}
.analysis-grid {
display: flex;
flex-direction: column;
gap: 8px;
justify-content: flex-start;
align-items: flex-start;
width: 100%;
}
.analysis-labels {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: left;
width: 100%;
}
.analysis-content {
display: flex;
flex-direction: column;
gap: 12px;
justify-content: flex-start;
align-items: left;
width: 100%;
}
/* Typography */
.report-title {
font-size: 16px;
font-family: Arial, sans-serif;
font-weight: 400;
line-height: 23px;
text-align: left;
color: #05000c;
}
.candidate-name {
font-size: 32px;
font-family: Arial, sans-serif;
font-weight: 400;
line-height: 40px;
text-align: left;
color: #05000c;
}
.section-title {
font-size: 18px;
font-family: Arial, sans-serif;
font-weight: 400;
line-height: 23px;
text-align: left;
color: #05000c;
padding: 12px;
width: 100%;
background-color: #f8f9fa;
border-radius: 8px;
}
.label-text {
font-size: 14px;
font-family: Arial, sans-serif;
font-weight: 400;
line-height: 19px;
text-align: left;
color: #95a7be;
}
.value-text {
font-size: 14px;
font-family: Arial, sans-serif;
font-weight: 400;
line-height: 19px;
text-align: left;
color: #05000c;
}
.value-text-bold {
font-size: 14px;
font-family: Arial, sans-serif;
font-weight: 700;
line-height: 19px;
text-align: left;
color: #05000c;
}
.content-text {
font-size: 14px;
font-family: Arial, sans-serif;
font-weight: 400;
line-height: 22px;
text-align: left;
color: #05000c;
white-space: pre-line;
}
.resume-link {
font-size: 14px;
font-family: Arial, sans-serif;
font-weight: 400;
line-height: 19px;
text-align: left;
color: #96a7be;
text-decoration: underline;
word-break: break-all;
}
.resume-label {
font-size: 14px;
font-family: Arial, sans-serif;
font-weight: 400;
line-height: 19px;
text-align: left;
color: #96a7be;
}
/* Interactive components */
.score-badge {
font-size: 14px;
font-family: Arial, sans-serif;
font-weight: 700;
line-height: 19px;
text-align: left;
color: #ffffff;
background-color: #26cd58;
border-radius: 10px;
padding: 2px 16px;
display: inline-block;
}
.recommend-button {
font-size: 14px;
font-family: Arial, sans-serif;
font-weight: 700;
line-height: 19px;
text-align: center;
color: #ffffff;
background-color: #26cd58;
border-radius: 10px;
padding: 2px 34px;
border: none;
cursor: pointer;
width: 100%;
transition: all 0.3s ease;
}
.consider-button {
font-size: 14px;
font-family: Arial, sans-serif;
font-weight: 700;
line-height: 19px;
text-align: center;
color: #ffffff;
background-color: #faa61a;
border-radius: 10px;
padding: 2px 34px;
border: none;
cursor: pointer;
width: 100%;
}
.reject-button {
font-size: 14px;
font-family: Arial, sans-serif;
font-weight: 700;
line-height: 19px;
text-align: center;
color: #ffffff;
background-color: #ff3d40;
border-radius: 10px;
padding: 2px 34px;
border: none;
cursor: pointer;
width: 100%;
}
/* Table styles */
.evaluation-table {
width: 100%;
border-collapse: collapse;
margin-top: 16px;
}
.table-header {
background-color: #05000c;
border-radius: 12px 12px 0px 0px;
}
.table-header th {
font-size: 12px;
font-family: Arial, sans-serif;
font-weight: 700;
line-height: 17px;
text-align: left;
color: #ffffff;
padding: 8px 16px;
}
.table-row {
border-bottom: 1px solid #e5e7eb;
}
.table-cell {
font-size: 14px;
font-family: Arial, sans-serif;
font-weight: 400;
line-height: 22px;
text-align: left;
color: #05000c;
padding: 12px;
vertical-align: top;
}
.score-button {
font-size: 14px;
font-family: Arial, sans-serif;
font-weight: 700;
line-height: 19px;
text-align: center;
color: #ffffff;
border-radius: 10px;
padding: 6px 16px;
border: none;
width: 100%;
max-width: 80px;
}
.score-green {
background-color: #26cd58;
}
.score-orange {
background-color: #faa61a;
}
.score-red {
background-color: #ff3d40;
}
/* Red flags section */
.red-flags-section {
display: flex;
flex-direction: column;
gap: 12px;
justify-content: flex-start;
align-items: flex-start;
width: 100%;
margin-top: 48px;
}
.red-flags-list {
display: flex;
flex-direction: column;
gap: 12px;
justify-content: flex-start;
align-items: center;
width: 100%;
}
.flag-item {
display: flex;
flex-direction: row;
gap: 12px;
justify-content: flex-start;
align-items: flex-start;
width: 100%;
padding: 12px 0;
border-top: 1px solid #e5e7eb;
}
.flag-item:first-child {
border-top: 0px solid #e5e7eb;
}
.flag-item:nth-last-child(1) {
border-bottom: 0px solid #e5e7eb;
}
.flag-icon {
width: 24px;
height: 24px;
flex-shrink: 0;
}
.flag-text {
font-size: 14px;
font-family: Arial, sans-serif;
font-weight: 400;
line-height: 22px;
text-align: left;
color: #05000c;
flex: 1;
}
.footer-text {
font-size: 14px;
font-family: Arial, sans-serif;
font-weight: 400;
line-height: 19px;
text-align: left;
color: #96a7be;
margin-top: 118px;
}
.divider-line {
width: 100%;
height: 1px;
background-color: #b1bac6;
}
/* Responsive media queries */
@media (min-width: 640px) {
body {
padding: 24px;
}
.report-title {
font-size: 18px;
}
.candidate-name {
font-size: 40px;
line-height: 48px;
}
.section-title {
font-size: 20px;
}
.label-text, .value-text, .value-text-bold, .resume-link, .resume-label {
font-size: 15px;
}
.content-text, .flag-text {
font-size: 15px;
}
.info-analysis-row {
flex-direction: row;
gap: 32px;
}
.basic-info-section {
width: 45%;
}
.analysis-section {
width: 55%;
}
.info-grid {
flex-direction: row;
}
.info-labels {
width: 40%;
}
.info-values {
width: 60%;
}
.analysis-grid {
flex-direction: row;
}
.analysis-labels {
width: 25%;
gap: 60px;
}
.analysis-content {
width: 75%;
}
}
@media (min-width: 768px) {
body {
padding: 32px;
}
.candidate-name {
font-size: 48px;
line-height: 56px;
}
.label-text, .value-text, .value-text-bold, .resume-link, .resume-label {
font-size: 16px;
}
.content-text, .flag-text {
font-size: 16px;
}
.table-header th {
font-size: 14px;
}
.score-button {
font-size: 16px;
}
}
@media (min-width: 1024px) {
.info-analysis-row {
gap: 40px;
}
.basic-info-section {
width: 34%;
}
.analysis-section {
width: 66%;
}
.analysis-labels {
width: 18%;
gap: 78px;
}
.analysis-content {
width: 82%;
}
}
</style>
</head>
<body>
<main class="main-container">
<div class="content-wrapper">
<header class="header-section">
<h1 class="report-title">Отчет по собеседованию {{ report_id }}</h1>
<h2 class="candidate-name">{{ candidate_name }}</h2>
<div style="display: flex; flex-direction: row; gap: 8px; align-items: center; width: 100%;">
<span class="resume-label">Резюме:</span>
<a href="{{ resume_url }}" class="resume-link" target="_blank" rel="noopener noreferrer">{{ resume_url }}</a>
</div>
</header>
<section class="info-analysis-row">
<div class="basic-info-section">
<div class="section-title">Основная информация</div>
<div class="info-grid">
<div class="info-labels">
<span class="label-text">Кандидат:</span>
<span class="label-text">Позиция:</span>
<span class="label-text">Дата интервью:</span>
<span class="label-text">Общий балл:</span>
<span class="label-text">Рекомендация:</span>
</div>
<div class="info-values">
<span class="value-text-bold">{{ candidate_name }}</span>
<span class="value-text">{{ position }}</span>
<span class="value-text">{{ interview_date }}</span>
<span class="score-badge">{{ overall_score }}</span>
<button class="{{ recommendation_class }}">{{ recommendation_text }}</button>
</div>
</div>
</div>
<div class="analysis-section">
<div class="section-title">Анализ кандидата</div>
<div class="analysis-grid">
<div class="analysis-labels">
<span class="label-text">Сильные стороны:</span>
<span class="label-text">Области для развития:</span>
</div>
<div class="analysis-content">
<p class="content-text">{{ strengths }}</p>
<p class="content-text">{{ areas_for_development }}</p>
</div>
</div>
</div>
</section>
</div>
<section style="display: flex; flex-direction: column; justify-content: flex-start; align-items: flex-start; width: 100%; margin-bottom: 40px;">
<div style="display: flex; flex-direction: column; gap: 16px; justify-content: flex-start; align-items: flex-start; width: 100%;">
<h3 class="section-title" style="background: none; padding: 0;">Детальная оценка</h3>
{% if evaluation_criteria %}
<table class="evaluation-table">
<thead class="table-header">
<tr>
<th style="width: 28%;">Критерий</th>
<th style="width: 10%;">Балл</th>
<th style="width: 40%;">Обоснование</th>
<th style="width: 22%;">Риски</th>
</tr>
</thead>
<tbody>
{% for criterion in evaluation_criteria %}
<tr class="table-row">
<td class="table-cell">{{ criterion.name }}</td>
<td class="table-cell">
<button class="score-button {{ criterion.score_class }}">{{ criterion.score }}/100</button>
</td>
<td class="table-cell">{{ criterion.justification }}</td>
<td class="table-cell">{{ criterion.concerns }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
{% if red_flags %}
<div class="red-flags-section">
<div class="section-title">Красные флаги:</div>
<div class="red-flags-list">
{% for flag in red_flags %}
<div class="flag-item">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 5.00014C4.00002 4.86716 4.02656 4.73552 4.07807 4.61292C4.12959 4.49032 4.20503 4.37923 4.3 4.28614C5.38984 3.21778 6.84585 2.60488 8.37166 2.57218C9.89746 2.53948 11.3784 3.08945 12.513 4.11014L12.864 4.43815C13.5934 5.07724 14.5302 5.42957 15.5 5.42957C16.4698 5.42957 17.4066 5.07724 18.136 4.43815L18.385 4.21114C18.995 3.72814 19.912 4.11414 19.995 4.88714L20 5.00014V14.0001C20 14.1331 19.9734 14.2648 19.9219 14.3874C19.8704 14.51 19.795 14.6211 19.7 14.7141C18.6102 15.7825 17.1542 16.3954 15.6283 16.4281C14.1025 16.4608 12.6216 15.9108 11.487 14.8901L11.136 14.5621C10.4295 13.9431 9.52777 13.5925 8.58872 13.5717C7.64967 13.5508 6.73323 13.8611 6 14.4481V21.0001C5.99972 21.255 5.90212 21.5002 5.72715 21.6855C5.55218 21.8708 5.31305 21.9824 5.05861 21.9973C4.80416 22.0123 4.55362 21.9295 4.35817 21.7659C4.16271 21.6023 4.0371 21.3702 4.007 21.1171L4 21.0001V5.00014Z" fill="#FF3E41"/>
</svg>
<p class="flag-text">{{ flag }}</p>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<p class="footer-text">Отчет сгенерирован автоматически • {{ generation_date }}</p>
</section>
</main>
</body>
</html>

13
uv.lock
View File

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