add vacancy parsing; add generating template pdf from html [wip]
This commit is contained in:
parent
9128bb8881
commit
d3018952bb
@ -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)
|
||||||
|
@ -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 отчет по интервью
|
||||||
|
@ -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)}")
|
||||||
|
@ -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 _load_html_template(self) -> str:
|
||||||
|
"""Загружает HTML шаблон из файла"""
|
||||||
def _format_list_field(self, field_value) -> str:
|
try:
|
||||||
"""Форматирует поле со списком для отображения в PDF"""
|
with open(self.template_path, 'r', encoding='utf-8') as file:
|
||||||
if not field_value:
|
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 _get_score_class(self, score: int) -> str:
|
||||||
def _register_fonts(self):
|
"""Возвращает CSS класс для цвета оценки"""
|
||||||
"""Регистрация шрифтов для поддержки кириллицы"""
|
if score >= 80:
|
||||||
try:
|
return "score-green"
|
||||||
# Пытаемся использовать системные шрифты Windows
|
elif score >= 60:
|
||||||
import os
|
return "score-orange"
|
||||||
|
else:
|
||||||
# Пути к шрифтам Windows
|
return "score-red"
|
||||||
fonts_dir = "C:/Windows/Fonts"
|
|
||||||
|
def _format_recommendation(self, recommendation: RecommendationType) -> tuple:
|
||||||
# Регистрируем Arial для русского текста
|
"""Форматирует рекомендацию для отображения"""
|
||||||
if os.path.exists(f"{fonts_dir}/arial.ttf"):
|
if recommendation == RecommendationType.HIRE:
|
||||||
pdfmetrics.registerFont(TTFont('Arial-Unicode', f"{fonts_dir}/arial.ttf"))
|
return ("Рекомендуем", "recommend-button")
|
||||||
pdfmetrics.registerFont(TTFont('Arial-Unicode-Bold', f"{fonts_dir}/arialbd.ttf"))
|
elif recommendation == RecommendationType.CONSIDER:
|
||||||
# Альтернативно используем Calibri
|
return ("К рассмотрению", "consider-button")
|
||||||
elif os.path.exists(f"{fonts_dir}/calibri.ttf"):
|
else:
|
||||||
pdfmetrics.registerFont(TTFont('Arial-Unicode', f"{fonts_dir}/calibri.ttf"))
|
return ("Не рекомендуем", "reject-button")
|
||||||
pdfmetrics.registerFont(TTFont('Arial-Unicode-Bold', f"{fonts_dir}/calibrib.ttf"))
|
|
||||||
# Если ничего не найдено, используем встроенный DejaVu
|
def generate_pdf_report(self, interview_report: InterviewReport) -> bytes:
|
||||||
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:
|
|
||||||
"""
|
"""
|
||||||
Генерирует 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,
|
template_data = self._prepare_template_data(interview_report)
|
||||||
topMargin=72,
|
|
||||||
bottomMargin=72,
|
# Рендерим HTML с данными
|
||||||
)
|
template = Template(html_template)
|
||||||
|
rendered_html = template.render(**template_data)
|
||||||
# Собираем элементы документа
|
|
||||||
story = []
|
# Настройки для wkhtmltopdf
|
||||||
|
options = {
|
||||||
# Заголовок отчета
|
'page-size': 'A4',
|
||||||
story.append(
|
'margin-top': '0.75in',
|
||||||
Paragraph(
|
'margin-right': '0.75in',
|
||||||
f"Отчет по собеседованию<br/>{candidate_name}",
|
'margin-bottom': '0.75in',
|
||||||
self.styles["ReportTitle"],
|
'margin-left': '0.75in',
|
||||||
)
|
'encoding': 'UTF-8',
|
||||||
)
|
'no-outline': None,
|
||||||
|
'enable-local-file-access': None
|
||||||
# Основная информация
|
}
|
||||||
story.append(Paragraph("Основная информация", self.styles["SectionHeader"]))
|
|
||||||
|
# Генерируем PDF
|
||||||
basic_info = [
|
pdf_bytes = pdfkit.from_string(rendered_html, False, options=options)
|
||||||
["Кандидат:", candidate_name],
|
|
||||||
["Позиция:", position],
|
return pdf_bytes
|
||||||
["Дата интервью:", report.created_at.strftime("%d.%m.%Y %H:%M")],
|
|
||||||
["Общий балл:", f"{report.overall_score}/100"],
|
except Exception as e:
|
||||||
["Рекомендация:", self._format_recommendation(report.recommendation)],
|
raise Exception(f"Ошибка при генерации PDF: {str(e)}")
|
||||||
]
|
|
||||||
|
def _prepare_template_data(self, interview_report: InterviewReport) -> dict:
|
||||||
basic_table = Table(basic_info, colWidths=[2 * inch, 4 * inch])
|
"""Подготавливает данные для HTML шаблона"""
|
||||||
basic_table.setStyle(
|
|
||||||
TableStyle(
|
# Основная информация о кандидате
|
||||||
[
|
candidate_name = interview_report.resume.applicant_name or "Не указано"
|
||||||
("ALIGN", (0, 0), (-1, -1), "LEFT"),
|
position = "Не указана"
|
||||||
("FONTNAME", (0, 0), (0, -1), "Arial-Unicode-Bold"),
|
|
||||||
("FONTNAME", (1, 0), (-1, -1), "Arial-Unicode"), # Правая колонка обычным шрифтом
|
# Получаем название позиции из связанной вакансии
|
||||||
("FONTSIZE", (0, 0), (-1, -1), 10),
|
if hasattr(interview_report.resume, 'vacancy') and interview_report.resume.vacancy:
|
||||||
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
|
position = interview_report.resume.vacancy.title
|
||||||
("TOPPADDING", (0, 0), (-1, -1), 6),
|
|
||||||
]
|
# Форматируем дату интервью
|
||||||
)
|
interview_date = "Не указана"
|
||||||
)
|
if interview_report.interview_session and interview_report.interview_session.interview_start_time:
|
||||||
story.append(basic_table)
|
interview_date = interview_report.interview_session.interview_start_time.strftime("%d.%m.%Y %H:%M")
|
||||||
story.append(Spacer(1, 20))
|
|
||||||
|
# Общий балл и рекомендация
|
||||||
# Оценки по критериям
|
overall_score = interview_report.overall_score or 0
|
||||||
story.append(Paragraph("Детальная оценка", self.styles["SectionHeader"]))
|
recommendation_text, recommendation_class = self._format_recommendation(interview_report.recommendation)
|
||||||
|
|
||||||
# Стиль для текста в таблице с автопереносом
|
# Сильные стороны и области развития
|
||||||
table_text_style = ParagraphStyle(
|
strengths = self._format_concerns_field(interview_report.strengths_concerns) if interview_report.strengths_concerns else "Не указаны"
|
||||||
name="TableText",
|
areas_for_development = self._format_concerns_field(interview_report.areas_for_development_concerns) if interview_report.areas_for_development_concerns else "Не указаны"
|
||||||
parent=self.styles["Normal"],
|
|
||||||
fontSize=8,
|
# Детальная оценка
|
||||||
fontName="Arial-Unicode",
|
evaluation_criteria = []
|
||||||
leading=10,
|
|
||||||
)
|
# Технические навыки
|
||||||
|
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:
|
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"],
|
resume_url = interview_report.resume.file_url if interview_report.resume.file_url else "#"
|
||||||
textColor=colors.HexColor("#BF616A"),
|
|
||||||
),
|
# ID отчета
|
||||||
)
|
report_id = f"#{interview_report.id}" if interview_report.id else "#0"
|
||||||
)
|
|
||||||
story.append(Spacer(1, 15))
|
# Дата генерации отчета
|
||||||
|
generation_date = datetime.now().strftime("%d.%m.%Y %H:%M")
|
||||||
# Рекомендации и следующие шаги
|
|
||||||
if report.next_steps:
|
return {
|
||||||
story.append(Paragraph("Рекомендации:", self.styles["SectionHeader"]))
|
'report_id': report_id,
|
||||||
story.append(Paragraph(report.next_steps, self.styles["CustomBodyText"]))
|
'candidate_name': candidate_name,
|
||||||
story.append(Spacer(1, 15))
|
'position': position,
|
||||||
|
'interview_date': interview_date,
|
||||||
# Подпись
|
'overall_score': overall_score,
|
||||||
story.append(Spacer(1, 30))
|
'recommendation_text': recommendation_text,
|
||||||
story.append(
|
'recommendation_class': recommendation_class,
|
||||||
Paragraph(
|
'strengths': strengths,
|
||||||
f"Отчет сгенерирован автоматически • {datetime.now().strftime('%d.%m.%Y %H:%M')}",
|
'areas_for_development': areas_for_development,
|
||||||
ParagraphStyle(
|
'evaluation_criteria': evaluation_criteria,
|
||||||
name="Footer",
|
'red_flags': red_flags,
|
||||||
parent=self.styles["Normal"],
|
'resume_url': resume_url,
|
||||||
fontSize=8,
|
'generation_date': generation_date
|
||||||
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: "Не рекомендуем",
|
|
||||||
}
|
}
|
||||||
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:
|
||||||
|
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:
|
try:
|
||||||
# Генерируем PDF
|
# Генерируем PDF
|
||||||
pdf_bytes = await self.generate_interview_report_pdf(
|
pdf_bytes = self.generate_pdf_report(report)
|
||||||
report, candidate_name, position
|
|
||||||
)
|
# Создаем имя файла
|
||||||
|
safe_name = report.resume.applicant_name or "candidate"
|
||||||
# Формируем имя файла
|
safe_name = "".join(c for c in safe_name if c.isalnum() or c in (' ', '-', '_')).strip()
|
||||||
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"
|
filename = f"interview_report_{safe_name}_{report.id}.pdf"
|
||||||
|
|
||||||
# Загружаем в S3 с публичным доступом
|
# Загружаем в S3
|
||||||
file_url = await s3_service.upload_file(
|
pdf_url = await self.upload_pdf_to_s3(pdf_bytes, filename)
|
||||||
file_content=pdf_bytes,
|
|
||||||
file_name=filename,
|
return pdf_url
|
||||||
content_type="application/pdf",
|
|
||||||
public=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
return file_url
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error generating and uploading PDF report: {e}")
|
raise Exception(f"Ошибка при генерации и загрузке PDF: {str(e)}")
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# Экземпляр сервиса
|
# Экземпляр сервиса
|
||||||
|
299
app/services/vacancy_parser_service.py
Normal file
299
app/services/vacancy_parser_service.py
Normal 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()
|
@ -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)
|
||||||
|
@ -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)}")
|
||||||
|
@ -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]
|
||||||
|
634
templates/interview_report.html
Normal file
634
templates/interview_report.html
Normal 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
13
uv.lock
@ -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"
|
||||||
|
Loading…
Reference in New Issue
Block a user