upd pdf service

This commit is contained in:
Даниил Ивлев 2025-09-10 23:55:56 +05:00
parent 1ca7efe4d1
commit 02469f89d5
6 changed files with 2147 additions and 2595 deletions

View File

@ -27,6 +27,9 @@ class Settings(BaseSettings):
openai_model: str = "gpt-5-mini"
openai_embeddings_model: str = "text-embedding-3-small"
# PDF Generation API Keys
cloudmersive_api_key: str | None = None
# AI Agent API Keys (for voice interviewer)
deepgram_api_key: str | None = None
cartesia_api_key: str | None = None

View File

@ -1,161 +1,18 @@
import io
import os
import shutil
import tempfile
from datetime import datetime
from urllib.parse import quote
import requests
from jinja2 import Template
from playwright.async_api import async_playwright
from app.core.s3 import s3_service
from app.models.interview_report import InterviewReport, RecommendationType
class PDFReportService:
"""Сервис для генерации PDF отчетов по интервью на основе HTML шаблона"""
"""Сервис для генерации и загрузки PDF отчетов в S3 хранилище"""
def __init__(self):
self.template_path = "templates/interview_report.html"
self._setup_fonts()
def _download_font(self, url: str, dest_path: str) -> str:
"""Скачивает шрифт по URL в dest_path (перезаписывает если нужно)."""
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
try:
resp = requests.get(url, stream=True, timeout=15)
resp.raise_for_status()
with open(dest_path, "wb") as f:
shutil.copyfileobj(resp.raw, f)
print(f"[OK] Downloaded font {url} -> {dest_path}")
return dest_path
except Exception as e:
print(f"[ERROR] Failed to download font {url}: {e}")
raise
def _register_local_fonts(self, regular_path: str, bold_path: str):
"""Регистрирует шрифты в ReportLab, чтобы xhtml2pdf мог ими пользоваться."""
try:
from reportlab.lib.fonts import addMapping
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
pdfmetrics.registerFont(TTFont("DejaVuSans", regular_path))
pdfmetrics.registerFont(TTFont("DejaVuSans-Bold", bold_path))
# mapping: family, bold(1)/normal(0), italic(1)/normal(0), fontkey
addMapping("DejaVuSans", 0, 0, "DejaVuSans")
addMapping("DejaVuSans", 1, 0, "DejaVuSans-Bold")
self.available_fonts = ["DejaVuSans", "DejaVuSans-Bold"]
print("[OK] Registered DejaVu fonts in ReportLab")
except Exception as e:
print(f"[ERROR] Register fonts failed: {e}")
self.available_fonts = []
def _setup_fonts(self):
"""Настройка русских шрифтов для xhtml2pdf"""
self.available_fonts = []
try:
from reportlab.lib.fonts import addMapping
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
# Используем скачанные DejaVu шрифты
fonts_dir = "static/fonts"
font_paths = [
(os.path.join(fonts_dir, "DejaVuSans.ttf"), "DejaVu", False, False),
(os.path.join(fonts_dir, "DejaVuSans-Bold.ttf"), "DejaVu", True, False),
]
for font_path, font_name, is_bold, is_italic in font_paths:
if os.path.exists(font_path):
try:
font_key = f"{font_name}"
if is_bold:
font_key += "-Bold"
if is_italic:
font_key += "-Italic"
# Проверяем, что шрифт можно загрузить
test_font = TTFont(font_key, font_path)
pdfmetrics.registerFont(test_font)
addMapping(font_name, is_bold, is_italic, font_key)
self.available_fonts.append(font_key)
print(f"[OK] Successfully registered font: {font_key}")
except Exception as e:
print(f"[ERROR] Failed to register font {font_path}: {e}")
else:
print(f"[ERROR] Font file not found: {font_path}")
except Exception as e:
print(f"[ERROR] Font setup failed: {e}")
print(f"Available fonts: {self.available_fonts}")
def _get_font_css(self) -> str:
"""Возвращает CSS с подключением локальных шрифтов (скачивает при необходимости)."""
# paths локальные
fonts_dir = os.path.abspath("static/fonts").replace("\\", "/")
regular_local = os.path.join(fonts_dir, "DejaVuSans.ttf").replace("\\", "/")
bold_local = os.path.join(fonts_dir, "DejaVuSans-Bold.ttf").replace("\\", "/")
# твои удалённые URL (используй свои)
remote_regular = (
"https://d8d88bee-afd2-4266-8332-538389e25f52.selstorage.ru/DejaVuSans.ttf"
)
remote_bold = "https://d8d88bee-afd2-4266-8332-538389e25f52.selstorage.ru/DejaVuSans-Bold.ttf"
# скачиваем если локально нет
try:
if not os.path.exists(regular_local) or os.path.getsize(regular_local) == 0:
self._download_font(remote_regular, regular_local)
if not os.path.exists(bold_local) or os.path.getsize(bold_local) == 0:
self._download_font(remote_bold, bold_local)
except Exception as e:
print("[WARNING] Failed to ensure local fonts:", e)
# регистрируем в ReportLab (чтобы гарантировать поддержку кириллицы)
try:
self._register_local_fonts(regular_local, bold_local)
except Exception as e:
print("[WARNING] Font registration error:", e)
# используем file:/// абсолютный путь в src и УБИРАЕМ format('...') — это важно
# url-энкодим путь на случай пробелов
reg_quoted = quote(regular_local)
bold_quoted = quote(bold_local)
font_css = f"""
<style>
@font-face {{
font-family: 'DejaVuSans';
src: url('file:///{reg_quoted}');
font-weight: normal;
font-style: normal;
}}
@font-face {{
font-family: 'DejaVuSans';
src: url('file:///{bold_quoted}');
font-weight: bold;
font-style: normal;
}}
/* Применяем семейство без !important, чтобы не ломать шаблон */
body, * {{
font-family: 'DejaVuSans', Arial, sans-serif;
}}
@page {{
size: A4;
margin: 0.75in;
}}
</style>
"""
return font_css
def _load_html_template(self) -> str:
"""Загружает HTML шаблон из файла"""
@ -194,7 +51,7 @@ class PDFReportService:
if score >= 90:
return "score-green" # STRONGLY_RECOMMEND
elif score >= 75:
return "score-light-green" # RECOMMEND
return "score-green" # RECOMMEND
elif score >= 60:
return "score-orange" # CONSIDER
else:
@ -211,48 +68,24 @@ class PDFReportService:
else: # REJECT
return ("Не рекомендуем", "reject-button")
def link_callback(self, uri, rel):
"""Скачивает удалённый ресурс в temp файл и возвращает путь (для xhtml2pdf)."""
# remote -> сохранить во временный файл и вернуть путь
if uri.startswith("http://") or uri.startswith("https://"):
try:
r = requests.get(uri, stream=True, timeout=15)
r.raise_for_status()
fd, tmp_path = tempfile.mkstemp(suffix=os.path.basename(uri))
with os.fdopen(fd, "wb") as f:
for chunk in r.iter_content(8192):
f.write(chunk)
return tmp_path
except Exception as e:
raise Exception(f"Не удалось скачать ресурс {uri}: {e}")
# file:///path -> без префикса
if uri.startswith("file:///"):
return uri[7:]
# локальные относительные пути
if os.path.isfile(uri):
return uri
# fallback — возвращаем как есть (pisa попробует обработать)
return uri
def fetch_resources(self, uri, rel):
# Разрешаем xhtml2pdf скачивать https
return self.link_callback(uri, rel)
async def generate_pdf_report(
async def generate_html_report(
self,
interview_report: InterviewReport,
candidate_name: str = None,
position: str = None,
resume_file_url: str = None,
) -> bytes:
) -> str:
"""
Генерирует PDF отчет на основе HTML шаблона
Генерирует HTML отчет на основе Jinja2 шаблона
Args:
interview_report: Данные отчета по интервью
candidate_name: Имя кандидата
position: Позиция
resume_file_url: URL резюме
Returns:
bytes: PDF файл в виде байтов
str: HTML содержимое отчета
"""
try:
# Загружаем HTML шаблон
@ -270,46 +103,14 @@ class PDFReportService:
template = Template(html_template)
rendered_html = template.render(**template_data)
# Получаем CSS с проверенными шрифтами
font_css = self._get_font_css()
# Вставляем стили
if "<head>" in rendered_html:
rendered_html = rendered_html.replace("<head>", f"<head>{font_css}")
else:
rendered_html = font_css + rendered_html
# Сохраняем debug.html для отладки
with open("debug.html", "w", encoding="utf-8") as f:
f.write(rendered_html)
# Генерируем PDF из debug.html с помощью Playwright
print("[OK] Using Playwright to generate PDF from debug.html")
async def generate_pdf():
async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page()
await page.goto(f"file://{os.path.abspath('debug.html')}")
await page.wait_for_load_state("networkidle")
pdf_bytes = await page.pdf(
format="A4",
margin={
"top": "0.75in",
"bottom": "0.75in",
"left": "0.75in",
"right": "0.75in",
},
print_background=True,
)
await browser.close()
return pdf_bytes
pdf_bytes = await generate_pdf()
return pdf_bytes
return rendered_html
except Exception as e:
raise Exception(f"Ошибка при генерации PDF: {str(e)}")
raise Exception(f"Ошибка при генерации HTML отчета: {str(e)}")
def _prepare_template_data(
self,
@ -320,8 +121,8 @@ class PDFReportService:
) -> dict:
"""Подготавливает данные для HTML шаблона"""
# Используем переданные параметры как в старой версии
resume_url = resume_file_url # Пока оставим заглушку для ссылки на резюме
# Используем переданные параметры
resume_url = resume_file_url or "#"
# Форматируем дату интервью
interview_date = "Не указана"
@ -341,7 +142,7 @@ class PDFReportService:
interview_report.recommendation
)
# Сильные стороны и области развития (используем правильные поля модели)
# Сильные стороны и области развития
strengths = (
self._format_list_field(interview_report.strengths)
if interview_report.strengths
@ -353,7 +154,7 @@ class PDFReportService:
else "Не указаны"
)
# Детальная оценка - всегда все критерии, как в старой версии
# Детальная оценка - всегда все критерии
evaluation_criteria = [
{
"name": "Технические навыки",
@ -413,11 +214,9 @@ class PDFReportService:
},
]
# Красные флаги - используем поле модели напрямую
# Красные флаги
red_flags = interview_report.red_flags or []
# Ссылка на резюме (уже определена выше)
# ID отчета
report_id = f"#{interview_report.id}" if interview_report.id else "#0"
@ -464,49 +263,6 @@ class PDFReportService:
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,
resume_file_url: str = None,
) -> str:
"""
Генерирует PDF отчет и загружает его в S3 (метод обратной совместимости)
Args:
report: Отчет по интервью
candidate_name: Имя кандидата (не используется, берется из отчета)
position: Позиция (не используется, берется из отчета)
Returns:
str: Публичная ссылка на PDF файл
"""
try:
# Генерируем PDF
pdf_bytes = await self.generate_pdf_report(
report, candidate_name, position, resume_file_url
)
# Создаем имя файла - используем переданный параметр как в старой версии
safe_name = (
candidate_name
if candidate_name and candidate_name != "Не указано"
else "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

@ -787,7 +787,7 @@ def generate_pdf_report_task(
resume_file_url: str = None,
):
"""
Асинхронная задача для генерации PDF отчета по интервью
Асинхронная задача для генерации PDF отчета по интервью с использованием PDFShift API
Args:
report_data: Словарь с данными отчета InterviewReport
@ -797,9 +797,9 @@ def generate_pdf_report_task(
"""
try:
import asyncio
from app.models.interview_report import InterviewReport
from app.services.pdf_report_service import pdf_report_service
import requests
from datetime import datetime
from app.core.config import settings
from celery_worker.database import (
SyncInterviewReportRepository,
get_sync_session,
@ -811,41 +811,43 @@ def generate_pdf_report_task(
meta={"status": "Начинаем генерацию PDF отчета...", "progress": 10},
)
# Создаем объект InterviewReport из переданных данных
# Генерируем HTML контент отчета из Jinja шаблона
self.update_state(
state="PROGRESS",
meta={"status": "Подготавливаем данные отчета...", "progress": 20},
meta={"status": "Подготавливаем HTML отчета...", "progress": 20},
)
# Подготавливаем данные для создания объекта
# Создаем объект InterviewReport для использования в сервисе
clean_report_data = report_data.copy()
# Обрабатываем datetime поля - убираем их, так как они не нужны для создания mock объекта
clean_report_data.pop('created_at', None)
clean_report_data.pop('updated_at', None)
# Создаем объект InterviewReport с обработанными данными
from app.models.interview_report import InterviewReport
mock_report = InterviewReport(**clean_report_data)
# Генерируем PDF
self.update_state(
state="PROGRESS", meta={"status": "Генерируем PDF отчет...", "progress": 40}
)
# Запускаем асинхронную функцию в новом цикле событий
def run_pdf_generation():
# Генерируем HTML через сервис
def run_html_generation():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
from app.services.pdf_report_service import pdf_report_service
return loop.run_until_complete(
pdf_report_service.generate_pdf_report(
pdf_report_service.generate_html_report(
mock_report, candidate_name, position, resume_file_url
)
)
finally:
loop.close()
pdf_bytes = run_pdf_generation()
html_content = run_html_generation()
# Генерируем PDF через Cloudmersive API
self.update_state(
state="PROGRESS",
meta={"status": "Конвертируем HTML в PDF через Cloudmersive...", "progress": 50}
)
pdf_bytes = _convert_html_to_pdf_cloudmersive(html_content)
# Загружаем в S3
self.update_state(
@ -857,6 +859,8 @@ def generate_pdf_report_task(
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
from app.services.pdf_report_service import pdf_report_service
# Создаем имя файла
safe_name = (
candidate_name
@ -918,3 +922,63 @@ def generate_pdf_report_task(
)
raise Exception(f"Ошибка при генерации PDF: {str(e)}")
def _convert_html_to_pdf_cloudmersive(html_content: str) -> bytes:
"""
Конвертирует HTML в PDF используя Cloudmersive API через HTTP requests
Args:
html_content: HTML содержимое для конвертации
Returns:
bytes: PDF файл в виде байтов
"""
import requests
from app.core.config import settings
# Проверяем наличие API ключа
if not hasattr(settings, 'cloudmersive_api_key') or not settings.cloudmersive_api_key:
raise Exception("Cloudmersive API ключ не настроен в settings.cloudmersive_api_key")
# Проверяем HTML на корректность
if not html_content or len(html_content.strip()) == 0:
raise Exception("HTML содержимое пустое")
try:
print(f"[Cloudmersive] Starting HTML to PDF conversion via HTTP...")
print(f"[Cloudmersive] HTML length: {len(html_content)} characters")
# Отправляем HTTP запрос с HTML как raw данные - правильный эндпоинт
url = "https://api.cloudmersive.com/convert/html/to/pdf"
headers = {
'Apikey': settings.cloudmersive_api_key,
'Content-Type': 'text/html'
}
print(f"[Cloudmersive] Sending HTTP request to {url}")
print(f"[Cloudmersive] Sending HTML as raw data...")
response = requests.post(
url,
headers=headers,
data=html_content.encode('utf-8'),
timeout=60
)
print(f"[Cloudmersive] Response status: {response.status_code}")
if response.status_code == 200:
pdf_bytes = response.content
print(f"[Cloudmersive] Conversion successful, PDF size: {len(pdf_bytes)} bytes")
return pdf_bytes
else:
print(f"[Cloudmersive] Error response: {response.text}")
raise Exception(f"Cloudmersive API returned {response.status_code}: {response.text}")
except requests.exceptions.RequestException as e:
print(f"[Cloudmersive] HTTP Error: {e}")
raise Exception(f"Ошибка HTTP запроса к Cloudmersive: {str(e)}")
except Exception as e:
print(f"[Cloudmersive] Unexpected error: {e}")
raise Exception(f"Неожиданная ошибка при генерации PDF: {str(e)}")

View File

@ -34,9 +34,7 @@ dependencies = [
"pdfkit>=1.0.0",
"jinja2>=3.1.6",
"greenlet>=3.2.4",
"xhtml2pdf>=0.2.17",
"playwright>=1.55.0",
"celery-types==0.23.0",
"requests>=2.31.0",
]
[build-system]

View File

@ -9,6 +9,8 @@ class RagSettings(BaseSettings):
milvus_uri: str = "milvus_uri"
milvus_collection: str = "candidate_profiles"
cloudmersive_api_key: str = "cloudmersive_api_key"
# Redis
redis_cache_url: str = "localhost"
redis_cache_port: int = 6379

4341
uv.lock

File diff suppressed because it is too large Load Diff