add vacancy parsing; add generating template pdf from html [wip]

This commit is contained in:
Даниил Ивлев 2025-09-08 17:26:12 +05:00
parent 9128bb8881
commit d3018952bb
10 changed files with 1631 additions and 431 deletions

View File

@ -38,15 +38,15 @@ class VacancyBase(SQLModel):
salary_to: int | None = None salary_to: int | None = None
salary_currency: str | None = Field(default="RUR", max_length=3) salary_currency: str | None = Field(default="RUR", max_length=3)
gross_salary: bool | None = False 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 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 metro_stations: str | None = None
address: str | None = None address: str | None = None
professional_roles: str | None = None professional_roles: str | None = None
contacts_name: str | None = Field(max_length=255) contacts_name: str | None = Field(default=None, max_length=255)
contacts_email: str | None = Field(max_length=255) contacts_email: str | None = Field(default=None, max_length=255)
contacts_phone: str | None = Field(max_length=50) contacts_phone: str | None = Field(default=None, max_length=50)
is_archived: bool = Field(default=False) is_archived: bool = Field(default=False)
premium: bool = Field(default=False) premium: bool = Field(default=False)
published_at: datetime | None = Field(default_factory=datetime.utcnow) 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.core.database import get_session
from app.repositories.resume_repository import ResumeRepository 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 ( from celery_worker.interview_analysis_task import (
analyze_multiple_candidates, analyze_multiple_candidates,
generate_interview_report, generate_interview_report,
@ -305,6 +305,7 @@ async def generate_pdf_report(
resume_id: int, resume_id: int,
session=Depends(get_session), session=Depends(get_session),
resume_repo: ResumeRepository = Depends(ResumeRepository), resume_repo: ResumeRepository = Depends(ResumeRepository),
pdf_report_service: PDFReportService = Depends(PDFReportService),
): ):
""" """
Генерирует PDF отчет по интервью Генерирует 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.models.vacancy import VacancyCreate, VacancyRead, VacancyUpdate
from app.services.vacancy_service import VacancyService from app.services.vacancy_service import VacancyService
from app.services.vacancy_parser_service import vacancy_parser_service
router = APIRouter(prefix="/vacancies", tags=["vacancies"]) 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) @router.post("/", response_model=VacancyRead)
async def create_vacancy( async def create_vacancy(
vacancy: VacancyCreate, vacancy_service: VacancyService = Depends(VacancyService) vacancy: VacancyCreate, vacancy_service: VacancyService = Depends(VacancyService)
@ -78,3 +87,284 @@ async def archive_vacancy(
if not archived_vacancy: if not archived_vacancy:
raise HTTPException(status_code=404, detail="Vacancy not found") raise HTTPException(status_code=404, detail="Vacancy not found")
return archived_vacancy 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 io
import os
from datetime import datetime from datetime import datetime
from reportlab.lib import colors from jinja2 import Template
from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY import pdfkit
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 app.core.s3 import s3_service from app.core.s3 import s3_service
from app.models.interview_report import InterviewReport, RecommendationType from app.models.interview_report import InterviewReport, RecommendationType
class PDFReportService: class PDFReportService:
"""Сервис для генерации PDF отчетов по интервью""" """Сервис для генерации PDF отчетов по интервью на основе HTML шаблона"""
def __init__(self): def __init__(self):
self._register_fonts() self.template_path = "templates/interview_report.html"
self.styles = getSampleStyleSheet()
self._setup_custom_styles()
def _format_list_field(self, field_value) -> str: def _load_html_template(self) -> str:
"""Форматирует поле со списком для отображения в PDF""" """Загружает HTML шаблон из файла"""
if not field_value: 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 "" return ""
if isinstance(field_value, list): if isinstance(concerns, list):
# Если это список, объединяем элементы return "; ".join(concerns)
return "\n".join([""] + field_value) elif isinstance(concerns, str):
elif isinstance(field_value, str): return concerns
# Если это строка, возвращаем как есть
return field_value
else: else:
# Для других типов конвертируем в строку return str(concerns)
return str(field_value)
def _register_fonts(self): def _get_score_class(self, score: int) -> str:
"""Регистрация шрифтов для поддержки кириллицы""" """Возвращает CSS класс для цвета оценки"""
try: if score >= 80:
# Пытаемся использовать системные шрифты Windows return "score-green"
import os elif score >= 60:
return "score-orange"
# Пути к шрифтам 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: else:
# Fallback к стандартным шрифтам ReportLab с поддержкой Unicode return "score-red"
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: def _format_recommendation(self, recommendation: RecommendationType) -> tuple:
print(f"Warning: Could not register custom fonts: {e}") """Форматирует рекомендацию для отображения"""
# Используем стандартные шрифты как fallback if recommendation == RecommendationType.HIRE:
from reportlab.lib.fonts import addMapping return ("Рекомендуем", "recommend-button")
addMapping('Arial-Unicode', 0, 0, 'Helvetica') elif recommendation == RecommendationType.CONSIDER:
addMapping('Arial-Unicode', 1, 0, 'Helvetica-Bold') return ("К рассмотрению", "consider-button")
else:
return ("Не рекомендуем", "reject-button")
def _setup_custom_styles(self): def generate_pdf_report(self, interview_report: InterviewReport) -> bytes:
"""Настройка кастомных стилей для документа"""
# Заголовок отчета
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:
""" """
Генерирует PDF отчет по интервью Генерирует PDF отчет на основе HTML шаблона
Args: Args:
report: Модель отчета из БД interview_report: Данные отчета по интервью
candidate_name: Имя кандидата
position: Название позиции
Returns: Returns:
bytes: PDF файл в виде байтов bytes: PDF файл в виде байтов
""" """
buffer = io.BytesIO() try:
doc = SimpleDocTemplate( # Загружаем HTML шаблон
buffer, html_template = self._load_html_template()
pagesize=A4,
rightMargin=72,
leftMargin=72,
topMargin=72,
bottomMargin=72,
)
# Собираем элементы документа # Подготавливаем данные для шаблона
story = [] template_data = self._prepare_template_data(interview_report)
# Заголовок отчета # Рендерим HTML с данными
story.append( template = Template(html_template)
Paragraph( rendered_html = template.render(**template_data)
f"Отчет по собеседованию<br/>{candidate_name}",
self.styles["ReportTitle"],
)
)
# Основная информация # Настройки для wkhtmltopdf
story.append(Paragraph("Основная информация", self.styles["SectionHeader"])) 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
}
basic_info = [ # Генерируем PDF
["Кандидат:", candidate_name], pdf_bytes = pdfkit.from_string(rendered_html, False, options=options)
["Позиция:", 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]) return pdf_bytes
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))
# Оценки по критериям except Exception as e:
story.append(Paragraph("Детальная оценка", self.styles["SectionHeader"])) raise Exception(f"Ошибка при генерации PDF: {str(e)}")
# Стиль для текста в таблице с автопереносом def _prepare_template_data(self, interview_report: InterviewReport) -> dict:
table_text_style = ParagraphStyle( """Подготавливает данные для HTML шаблона"""
name="TableText",
parent=self.styles["Normal"],
fontSize=8,
fontName="Arial-Unicode",
leading=10,
)
criteria_data = [ # Основная информация о кандидате
[ candidate_name = interview_report.resume.applicant_name or "Не указано"
Paragraph("Критерий", self.styles["CustomBodyText"]), position = "Не указана"
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] if hasattr(interview_report.resume, 'vacancy') and interview_report.resume.vacancy:
) position = interview_report.resume.vacancy.title
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): # строки с баллами interview_date = "Не указана"
score_cell = (1, i) if interview_report.interview_session and interview_report.interview_session.interview_start_time:
if hasattr( interview_date = interview_report.interview_session.interview_start_time.strftime("%d.%m.%Y %H:%M")
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)) overall_score = interview_report.overall_score or 0
recommendation_text, recommendation_class = self._format_recommendation(interview_report.recommendation)
# Сильные и слабые стороны # Сильные стороны и области развития
if report.strengths or report.weaknesses: strengths = self._format_concerns_field(interview_report.strengths_concerns) if interview_report.strengths_concerns else "Не указаны"
story.append(Paragraph("Анализ кандидата", self.styles["SectionHeader"])) areas_for_development = self._format_concerns_field(interview_report.areas_for_development_concerns) if interview_report.areas_for_development_concerns else "Не указаны"
if report.strengths: # Детальная оценка
story.append(Paragraph("Сильные стороны:", self.styles["SubHeader"])) evaluation_criteria = []
for strength in report.strengths:
story.append(Paragraph(f"{strength}", self.styles["CustomBodyText"]))
story.append(Spacer(1, 10))
if report.weaknesses: # Технические навыки
story.append( if interview_report.technical_skills_score is not None:
Paragraph("Области для развития:", self.styles["SubHeader"]) evaluation_criteria.append({
) 'name': 'Технические навыки',
for weakness in report.weaknesses: 'score': interview_report.technical_skills_score,
story.append(Paragraph(f"{weakness}", self.styles["CustomBodyText"])) 'score_class': self._get_score_class(interview_report.technical_skills_score),
story.append(Spacer(1, 15)) '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)
})
# Красные флаги # Красные флаги
if report.red_flags: red_flags = []
story.append(Paragraph("Красные флаги:", self.styles["SectionHeader"])) if interview_report.red_flags:
for red_flag in report.red_flags: if isinstance(interview_report.red_flags, list):
story.append( red_flags = interview_report.red_flags
Paragraph( elif isinstance(interview_report.red_flags, str):
f"{red_flag}", red_flags = [interview_report.red_flags]
ParagraphStyle(
name="RedFlag",
parent=self.styles["CustomBodyText"],
textColor=colors.HexColor("#BF616A"),
),
)
)
story.append(Spacer(1, 15))
# Рекомендации и следующие шаги # Ссылка на резюме
if report.next_steps: resume_url = interview_report.resume.file_url if interview_report.resume.file_url else "#"
story.append(Paragraph("Рекомендации:", self.styles["SectionHeader"]))
story.append(Paragraph(report.next_steps, self.styles["CustomBodyText"]))
story.append(Spacer(1, 15))
# Подпись # ID отчета
story.append(Spacer(1, 30)) report_id = f"#{interview_report.id}" if interview_report.id else "#0"
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) generation_date = datetime.now().strftime("%d.%m.%Y %H:%M")
buffer.seek(0)
return buffer.getvalue()
def _format_recommendation(self, recommendation: RecommendationType) -> str: return {
"""Форматирует рекомендацию для отображения""" 'report_id': report_id,
recommendation_map = { 'candidate_name': candidate_name,
RecommendationType.STRONGLY_RECOMMEND: "Настоятельно рекомендуем", 'position': position,
RecommendationType.RECOMMEND: "Рекомендуем", 'interview_date': interview_date,
RecommendationType.CONSIDER: "Рассмотреть кандидатуру", 'overall_score': overall_score,
RecommendationType.REJECT: "Не рекомендуем", '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( async def upload_pdf_to_s3(self, pdf_bytes: bytes, filename: str) -> str:
self, report: InterviewReport, candidate_name: str, position: str
) -> str | None:
""" """
Генерирует PDF отчет и загружает его в S3 Загружает PDF файл в S3 и возвращает публичную ссылку
Args: Args:
report: Модель отчета из БД pdf_bytes: PDF файл в виде байтов
candidate_name: Имя кандидата filename: Имя файла
position: Название позиции
Returns: Returns:
str | None: URL файла в S3 или None при ошибке str: Публичная ссылка на файл в S3
""" """
try: try:
# Генерируем PDF pdf_stream = io.BytesIO(pdf_bytes)
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(" ", "_")
filename = f"interview_report_{safe_name}_{report.id}.pdf"
# Загружаем в S3 с публичным доступом
file_url = await s3_service.upload_file( file_url = await s3_service.upload_file(
file_content=pdf_bytes, pdf_stream,
file_name=filename, filename,
content_type="application/pdf", content_type="application/pdf",
public=True, public=True
) )
return file_url return file_url
except Exception as e: except Exception as e:
print(f"Error generating and uploading PDF report: {e}") raise Exception(f"Ошибка при загрузке PDF в S3: {str(e)}")
return None
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 = 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
pdf_url = await self.upload_pdf_to_s3(pdf_bytes, filename)
return pdf_url
except Exception as e:
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 from app.models.vacancy import Vacancy
return self.session.query(Vacancy).filter(Vacancy.id == vacancy_id).first() 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)}") 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", "comtypes>=1.4.12",
"reportlab>=4.4.3", "reportlab>=4.4.3",
"yandex-speechkit>=1.5.0", "yandex-speechkit>=1.5.0",
"pdfkit>=1.0.0",
"jinja2>=3.1.6",
] ]
[build-system] [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 = "comtypes" },
{ name = "docx2txt" }, { name = "docx2txt" },
{ name = "fastapi", extra = ["standard"] }, { name = "fastapi", extra = ["standard"] },
{ name = "jinja2" },
{ name = "langchain" }, { name = "langchain" },
{ name = "langchain-community" }, { name = "langchain-community" },
{ name = "langchain-core" }, { name = "langchain-core" },
@ -1003,6 +1004,7 @@ dependencies = [
{ name = "livekit" }, { name = "livekit" },
{ name = "livekit-agents", extra = ["cartesia", "deepgram", "openai", "resemble", "silero", "turn-detector"] }, { name = "livekit-agents", extra = ["cartesia", "deepgram", "openai", "resemble", "silero", "turn-detector"] },
{ name = "livekit-api" }, { name = "livekit-api" },
{ name = "pdfkit" },
{ name = "pdfplumber" }, { name = "pdfplumber" },
{ name = "psycopg2-binary" }, { name = "psycopg2-binary" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
@ -1035,6 +1037,7 @@ requires-dist = [
{ name = "comtypes", specifier = ">=1.4.12" }, { name = "comtypes", specifier = ">=1.4.12" },
{ name = "docx2txt", specifier = ">=0.9" }, { name = "docx2txt", specifier = ">=0.9" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.104.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.104.0" },
{ name = "jinja2", specifier = ">=3.1.6" },
{ name = "langchain", specifier = ">=0.1.0" }, { name = "langchain", specifier = ">=0.1.0" },
{ name = "langchain-community", specifier = ">=0.0.10" }, { name = "langchain-community", specifier = ">=0.0.10" },
{ name = "langchain-core", specifier = ">=0.1.0" }, { name = "langchain-core", specifier = ">=0.1.0" },
@ -1043,6 +1046,7 @@ requires-dist = [
{ name = "livekit", specifier = ">=1.0.12" }, { name = "livekit", specifier = ">=1.0.12" },
{ name = "livekit-agents", extras = ["cartesia", "deepgram", "openai", "silero", "resemble", "turn-detector"], specifier = "~=1.2" }, { name = "livekit-agents", extras = ["cartesia", "deepgram", "openai", "silero", "resemble", "turn-detector"], specifier = "~=1.2" },
{ name = "livekit-api", specifier = ">=1.0.5" }, { name = "livekit-api", specifier = ">=1.0.5" },
{ name = "pdfkit", specifier = ">=1.0.0" },
{ name = "pdfplumber", specifier = ">=0.10.0" }, { name = "pdfplumber", specifier = ">=0.10.0" },
{ name = "psycopg2-binary", specifier = ">=2.9.0" }, { name = "psycopg2-binary", specifier = ">=2.9.0" },
{ name = "pydantic-settings", specifier = ">=2.1.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 }, { 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]] [[package]]
name = "pdfminer-six" name = "pdfminer-six"
version = "20250506" version = "20250506"