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_model: str = "gpt-5-mini"
openai_embeddings_model: str = "text-embedding-3-small" 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) # AI Agent API Keys (for voice interviewer)
deepgram_api_key: str | None = None deepgram_api_key: str | None = None
cartesia_api_key: str | None = None cartesia_api_key: str | None = None

View File

@ -1,161 +1,18 @@
import io import io
import os import os
import shutil
import tempfile
from datetime import datetime from datetime import datetime
from urllib.parse import quote
import requests
from jinja2 import Template from jinja2 import Template
from playwright.async_api import async_playwright
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 отчетов по интервью на основе HTML шаблона""" """Сервис для генерации и загрузки PDF отчетов в S3 хранилище"""
def __init__(self): def __init__(self):
self.template_path = "templates/interview_report.html" 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: def _load_html_template(self) -> str:
"""Загружает HTML шаблон из файла""" """Загружает HTML шаблон из файла"""
@ -194,7 +51,7 @@ class PDFReportService:
if score >= 90: if score >= 90:
return "score-green" # STRONGLY_RECOMMEND return "score-green" # STRONGLY_RECOMMEND
elif score >= 75: elif score >= 75:
return "score-light-green" # RECOMMEND return "score-green" # RECOMMEND
elif score >= 60: elif score >= 60:
return "score-orange" # CONSIDER return "score-orange" # CONSIDER
else: else:
@ -211,48 +68,24 @@ class PDFReportService:
else: # REJECT else: # REJECT
return ("Не рекомендуем", "reject-button") return ("Не рекомендуем", "reject-button")
def link_callback(self, uri, rel): async def generate_html_report(
"""Скачивает удалённый ресурс в 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(
self, self,
interview_report: InterviewReport, interview_report: InterviewReport,
candidate_name: str = None, candidate_name: str = None,
position: str = None, position: str = None,
resume_file_url: str = None, resume_file_url: str = None,
) -> bytes: ) -> str:
""" """
Генерирует PDF отчет на основе HTML шаблона Генерирует HTML отчет на основе Jinja2 шаблона
Args: Args:
interview_report: Данные отчета по интервью interview_report: Данные отчета по интервью
candidate_name: Имя кандидата
position: Позиция
resume_file_url: URL резюме
Returns: Returns:
bytes: PDF файл в виде байтов str: HTML содержимое отчета
""" """
try: try:
# Загружаем HTML шаблон # Загружаем HTML шаблон
@ -270,46 +103,14 @@ class PDFReportService:
template = Template(html_template) template = Template(html_template)
rendered_html = template.render(**template_data) rendered_html = template.render(**template_data)
# Получаем CSS с проверенными шрифтами # Сохраняем debug.html для отладки
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
with open("debug.html", "w", encoding="utf-8") as f: with open("debug.html", "w", encoding="utf-8") as f:
f.write(rendered_html) f.write(rendered_html)
# Генерируем PDF из debug.html с помощью Playwright return rendered_html
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
except Exception as e: except Exception as e:
raise Exception(f"Ошибка при генерации PDF: {str(e)}") raise Exception(f"Ошибка при генерации HTML отчета: {str(e)}")
def _prepare_template_data( def _prepare_template_data(
self, self,
@ -320,8 +121,8 @@ class PDFReportService:
) -> dict: ) -> dict:
"""Подготавливает данные для HTML шаблона""" """Подготавливает данные для HTML шаблона"""
# Используем переданные параметры как в старой версии # Используем переданные параметры
resume_url = resume_file_url # Пока оставим заглушку для ссылки на резюме resume_url = resume_file_url or "#"
# Форматируем дату интервью # Форматируем дату интервью
interview_date = "Не указана" interview_date = "Не указана"
@ -341,7 +142,7 @@ class PDFReportService:
interview_report.recommendation interview_report.recommendation
) )
# Сильные стороны и области развития (используем правильные поля модели) # Сильные стороны и области развития
strengths = ( strengths = (
self._format_list_field(interview_report.strengths) self._format_list_field(interview_report.strengths)
if interview_report.strengths if interview_report.strengths
@ -353,7 +154,7 @@ class PDFReportService:
else "Не указаны" else "Не указаны"
) )
# Детальная оценка - всегда все критерии, как в старой версии # Детальная оценка - всегда все критерии
evaluation_criteria = [ evaluation_criteria = [
{ {
"name": "Технические навыки", "name": "Технические навыки",
@ -413,11 +214,9 @@ class PDFReportService:
}, },
] ]
# Красные флаги - используем поле модели напрямую # Красные флаги
red_flags = interview_report.red_flags or [] red_flags = interview_report.red_flags or []
# Ссылка на резюме (уже определена выше)
# ID отчета # ID отчета
report_id = f"#{interview_report.id}" if interview_report.id else "#0" report_id = f"#{interview_report.id}" if interview_report.id else "#0"
@ -464,49 +263,6 @@ class PDFReportService:
except Exception as e: except Exception as e:
raise Exception(f"Ошибка при загрузке PDF в S3: {str(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

@ -780,14 +780,14 @@ def parse_vacancy_task(
@celery_app.task(bind=True) @celery_app.task(bind=True)
def generate_pdf_report_task( def generate_pdf_report_task(
self, self,
report_data: dict, report_data: dict,
candidate_name: str = None, candidate_name: str = None,
position: str = None, position: str = None,
resume_file_url: str = None, resume_file_url: str = None,
): ):
""" """
Асинхронная задача для генерации PDF отчета по интервью Асинхронная задача для генерации PDF отчета по интервью с использованием PDFShift API
Args: Args:
report_data: Словарь с данными отчета InterviewReport report_data: Словарь с данными отчета InterviewReport
@ -797,9 +797,9 @@ def generate_pdf_report_task(
""" """
try: try:
import asyncio import asyncio
import requests
from app.models.interview_report import InterviewReport from datetime import datetime
from app.services.pdf_report_service import pdf_report_service from app.core.config import settings
from celery_worker.database import ( from celery_worker.database import (
SyncInterviewReportRepository, SyncInterviewReportRepository,
get_sync_session, get_sync_session,
@ -811,41 +811,43 @@ def generate_pdf_report_task(
meta={"status": "Начинаем генерацию PDF отчета...", "progress": 10}, meta={"status": "Начинаем генерацию PDF отчета...", "progress": 10},
) )
# Создаем объект InterviewReport из переданных данных # Генерируем HTML контент отчета из Jinja шаблона
self.update_state( self.update_state(
state="PROGRESS", state="PROGRESS",
meta={"status": "Подготавливаем данные отчета...", "progress": 20}, meta={"status": "Подготавливаем HTML отчета...", "progress": 20},
) )
# Подготавливаем данные для создания объекта # Создаем объект InterviewReport для использования в сервисе
clean_report_data = report_data.copy() clean_report_data = report_data.copy()
# Обрабатываем datetime поля - убираем их, так как они не нужны для создания mock объекта
clean_report_data.pop('created_at', None) clean_report_data.pop('created_at', None)
clean_report_data.pop('updated_at', None) clean_report_data.pop('updated_at', None)
# Создаем объект InterviewReport с обработанными данными from app.models.interview_report import InterviewReport
mock_report = InterviewReport(**clean_report_data) mock_report = InterviewReport(**clean_report_data)
# Генерируем PDF # Генерируем HTML через сервис
self.update_state( def run_html_generation():
state="PROGRESS", meta={"status": "Генерируем PDF отчет...", "progress": 40}
)
# Запускаем асинхронную функцию в новом цикле событий
def run_pdf_generation():
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
try: try:
from app.services.pdf_report_service import pdf_report_service
return loop.run_until_complete( 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 mock_report, candidate_name, position, resume_file_url
) )
) )
finally: finally:
loop.close() 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 # Загружаем в S3
self.update_state( self.update_state(
@ -857,6 +859,8 @@ def generate_pdf_report_task(
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
try: try:
from app.services.pdf_report_service import pdf_report_service
# Создаем имя файла # Создаем имя файла
safe_name = ( safe_name = (
candidate_name candidate_name
@ -918,3 +922,63 @@ def generate_pdf_report_task(
) )
raise Exception(f"Ошибка при генерации PDF: {str(e)}") 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", "pdfkit>=1.0.0",
"jinja2>=3.1.6", "jinja2>=3.1.6",
"greenlet>=3.2.4", "greenlet>=3.2.4",
"xhtml2pdf>=0.2.17", "requests>=2.31.0",
"playwright>=1.55.0",
"celery-types==0.23.0",
] ]
[build-system] [build-system]

View File

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

4341
uv.lock

File diff suppressed because it is too large Load Diff