upd pdf service
This commit is contained in:
parent
1ca7efe4d1
commit
02469f89d5
@ -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
|
||||
|
@ -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)}")
|
||||
|
||||
|
||||
# Экземпляр сервиса
|
||||
|
@ -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)}")
|
||||
|
@ -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]
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user