add pdf report generating

This commit is contained in:
Даниил Ивлев 2025-09-08 00:20:17 +05:00
parent d397704bd5
commit 8d449af338
5 changed files with 232 additions and 88 deletions

View File

@ -18,19 +18,24 @@ class S3Service:
self.bucket_name = settings.s3_bucket_name self.bucket_name = settings.s3_bucket_name
async def upload_file( async def upload_file(
self, file_content: bytes, file_name: str, content_type: str self, file_content: bytes, file_name: str, content_type: str, public: bool = False
) -> str | None: ) -> str | None:
try: try:
file_key = f"{uuid.uuid4()}_{file_name}" file_key = f"{uuid.uuid4()}_{file_name}"
self.s3_client.put_object( put_object_kwargs = {
Bucket=self.bucket_name, "Bucket": self.bucket_name,
Key=file_key, "Key": file_key,
Body=file_content, "Body": file_content,
ContentType=content_type, "ContentType": content_type,
) }
if public:
put_object_kwargs["ACL"] = "public-read"
self.s3_client.put_object(**put_object_kwargs)
file_url = f"{settings.s3_endpoint_url}/{self.bucket_name}/{file_key}" file_url = f"https://d8d88bee-afd2-4266-8332-538389e25f52.selstorage.ru/{file_key}"
return file_url return file_url
except ClientError as e: except ClientError as e:

View File

@ -73,3 +73,4 @@ class LiveKitTokenResponse(SQLModel):
token: str token: str
room_name: str room_name: str
server_url: str server_url: str
session_id: int

View File

@ -4,6 +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 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,
@ -44,6 +45,16 @@ class CandidateRanking(BaseModel):
position: str position: str
class PDFGenerationResponse(BaseModel):
"""Ответ генерации PDF отчета"""
message: str
resume_id: int
candidate_name: str
pdf_url: str | None = None
status: str # "generated", "exists", "failed"
@router.post("/interview-report/{resume_id}", response_model=AnalysisResponse) @router.post("/interview-report/{resume_id}", response_model=AnalysisResponse)
async def start_interview_analysis( async def start_interview_analysis(
resume_id: int, resume_id: int,
@ -264,7 +275,7 @@ async def get_pdf_report(
.where(InterviewSession.resume_id == resume_id) .where(InterviewSession.resume_id == resume_id)
) )
result = await session.exec(statement) result = await session.execute(statement)
report_session = result.first() report_session = result.first()
if not report_session: if not report_session:
@ -289,6 +300,105 @@ async def get_pdf_report(
return RedirectResponse(url=report.pdf_report_url, status_code=302) return RedirectResponse(url=report.pdf_report_url, status_code=302)
@router.post("/generate-pdf/{resume_id}", response_model=PDFGenerationResponse)
async def generate_pdf_report(
resume_id: int,
session=Depends(get_session),
resume_repo: ResumeRepository = Depends(ResumeRepository),
):
"""
Генерирует PDF отчет по интервью
Проверяет наличие отчета в базе данных и генерирует PDF файл.
Если PDF уже существует, возвращает существующий URL.
"""
from sqlmodel import select
from app.models.interview import InterviewSession
from app.models.interview_report import InterviewReport
# Проверяем, существует ли резюме
resume = await resume_repo.get_by_id(resume_id)
if not resume:
raise HTTPException(status_code=404, detail="Resume not found")
# Ищем отчет интервью
statement = (
select(InterviewReport, InterviewSession)
.join(
InterviewSession,
InterviewReport.interview_session_id == InterviewSession.id,
)
.where(InterviewSession.resume_id == resume_id)
)
result = await session.execute(statement)
report_session = result.first()
if not report_session:
raise HTTPException(
status_code=404,
detail="Interview report not found. Run analysis first using POST /analysis/interview-report/{resume_id}",
)
report, interview_session = report_session
# Если PDF уже существует, возвращаем его
if report.pdf_report_url:
return PDFGenerationResponse(
message="PDF report already exists",
resume_id=resume_id,
candidate_name=resume.applicant_name,
pdf_url=report.pdf_report_url,
status="exists",
)
# Генерируем PDF отчет
try:
# Получаем позицию из связанной вакансии
from app.models.vacancy import Vacancy
vacancy_stmt = select(Vacancy).where(Vacancy.id == resume.vacancy_id)
vacancy_result = await session.execute(vacancy_stmt)
vacancy = vacancy_result.scalar_one_or_none()
position = vacancy.title if vacancy else "Позиция не указана"
# Генерируем и загружаем PDF
pdf_url = await pdf_report_service.generate_and_upload_pdf(
report, resume.applicant_name, position
)
if not pdf_url:
raise HTTPException(
status_code=500, detail="Failed to generate or upload PDF report"
)
# Обновляем отчет в БД
from sqlmodel import update
stmt = (
update(InterviewReport)
.where(InterviewReport.id == report.id)
.values(pdf_report_url=pdf_url)
)
await session.execute(stmt)
await session.commit()
return PDFGenerationResponse(
message="PDF report generated successfully",
resume_id=resume_id,
candidate_name=resume.applicant_name,
pdf_url=pdf_url,
status="generated",
)
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Error generating PDF report: {str(e)}"
)
@router.get("/report-data/{resume_id}") @router.get("/report-data/{resume_id}")
async def get_report_data( async def get_report_data(
resume_id: int, resume_id: int,
@ -318,7 +428,7 @@ async def get_report_data(
.where(InterviewSession.resume_id == resume_id) .where(InterviewSession.resume_id == resume_id)
) )
result = await session.exec(statement) result = await session.execute(statement)
report_session = result.first() report_session = result.first()
if not report_session: if not report_session:
@ -326,10 +436,19 @@ async def get_report_data(
report, interview_session = report_session report, interview_session = report_session
# Получаем позицию из связанной вакансии
from app.models.vacancy import Vacancy
vacancy_stmt = select(Vacancy).where(Vacancy.id == resume.vacancy_id)
vacancy_result = await session.execute(vacancy_stmt)
vacancy = vacancy_result.scalar_one_or_none()
position = vacancy.title if vacancy else "Позиция не указана"
return { return {
"report_id": report.id, "report_id": report.id,
"candidate_name": resume.applicant_name, "candidate_name": resume.applicant_name,
"position": "Unknown Position", # Можно расширить через vacancy "position": position,
"interview_date": report.created_at.isoformat(), "interview_date": report.created_at.isoformat(),
"overall_score": report.overall_score, "overall_score": report.overall_score,
"recommendation": report.recommendation.value, "recommendation": report.recommendation.value,

View File

@ -182,6 +182,7 @@ class InterviewRoomService:
token=token, token=token,
room_name=interview_session.room_name, room_name=interview_session.room_name,
server_url=self.livekit_url, server_url=self.livekit_url,
session_id=interview_session.id,
) )
except Exception as e: except Exception as e:

View File

@ -6,6 +6,8 @@ from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY
from reportlab.lib.pagesizes import A4 from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import inch from reportlab.lib.units import inch
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import ( from reportlab.platypus import (
Paragraph, Paragraph,
SimpleDocTemplate, SimpleDocTemplate,
@ -22,9 +24,43 @@ class PDFReportService:
"""Сервис для генерации PDF отчетов по интервью""" """Сервис для генерации PDF отчетов по интервью"""
def __init__(self): def __init__(self):
self._register_fonts()
self.styles = getSampleStyleSheet() self.styles = getSampleStyleSheet()
self._setup_custom_styles() self._setup_custom_styles()
def _register_fonts(self):
"""Регистрация шрифтов для поддержки кириллицы"""
try:
# Пытаемся использовать системные шрифты Windows
import os
# Пути к шрифтам Windows
fonts_dir = "C:/Windows/Fonts"
# Регистрируем Arial для русского текста
if os.path.exists(f"{fonts_dir}/arial.ttf"):
pdfmetrics.registerFont(TTFont('Arial-Unicode', f"{fonts_dir}/arial.ttf"))
pdfmetrics.registerFont(TTFont('Arial-Unicode-Bold', f"{fonts_dir}/arialbd.ttf"))
# Альтернативно используем Calibri
elif os.path.exists(f"{fonts_dir}/calibri.ttf"):
pdfmetrics.registerFont(TTFont('Arial-Unicode', f"{fonts_dir}/calibri.ttf"))
pdfmetrics.registerFont(TTFont('Arial-Unicode-Bold', f"{fonts_dir}/calibrib.ttf"))
# Если ничего не найдено, используем встроенный DejaVu
else:
# 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): def _setup_custom_styles(self):
"""Настройка кастомных стилей для документа""" """Настройка кастомных стилей для документа"""
# Заголовок отчета # Заголовок отчета
@ -36,7 +72,7 @@ class PDFReportService:
spaceAfter=30, spaceAfter=30,
alignment=TA_CENTER, alignment=TA_CENTER,
textColor=colors.HexColor("#2E3440"), textColor=colors.HexColor("#2E3440"),
fontName="Helvetica-Bold", fontName="Arial-Unicode-Bold",
) )
) )
@ -49,7 +85,7 @@ class PDFReportService:
spaceAfter=12, spaceAfter=12,
spaceBefore=20, spaceBefore=20,
textColor=colors.HexColor("#5E81AC"), textColor=colors.HexColor("#5E81AC"),
fontName="Helvetica-Bold", fontName="Arial-Unicode-Bold",
) )
) )
@ -62,19 +98,20 @@ class PDFReportService:
spaceAfter=8, spaceAfter=8,
spaceBefore=15, spaceBefore=15,
textColor=colors.HexColor("#81A1C1"), textColor=colors.HexColor("#81A1C1"),
fontName="Helvetica-Bold", fontName="Arial-Unicode-Bold",
) )
) )
# Обычный текст # Обычный текст
self.styles.add( self.styles.add(
ParagraphStyle( ParagraphStyle(
name="BodyText", name="CustomBodyText",
parent=self.styles["Normal"], parent=self.styles["Normal"],
fontSize=10, fontSize=10,
spaceAfter=6, spaceAfter=6,
alignment=TA_JUSTIFY, alignment=TA_JUSTIFY,
textColor=colors.HexColor("#2E3440"), textColor=colors.HexColor("#2E3440"),
fontName="Arial-Unicode",
) )
) )
@ -86,7 +123,7 @@ class PDFReportService:
fontSize=12, fontSize=12,
alignment=TA_CENTER, alignment=TA_CENTER,
textColor=colors.HexColor("#5E81AC"), textColor=colors.HexColor("#5E81AC"),
fontName="Helvetica-Bold", fontName="Arial-Unicode-Bold",
) )
) )
@ -132,7 +169,7 @@ class PDFReportService:
["Кандидат:", candidate_name], ["Кандидат:", candidate_name],
["Позиция:", position], ["Позиция:", position],
["Дата интервью:", report.created_at.strftime("%d.%m.%Y %H:%M")], ["Дата интервью:", report.created_at.strftime("%d.%m.%Y %H:%M")],
["Общий балл:", f"<b>{report.overall_score}/100</b>"], ["Общий балл:", f"{report.overall_score}/100"],
["Рекомендация:", self._format_recommendation(report.recommendation)], ["Рекомендация:", self._format_recommendation(report.recommendation)],
] ]
@ -141,7 +178,8 @@ class PDFReportService:
TableStyle( TableStyle(
[ [
("ALIGN", (0, 0), (-1, -1), "LEFT"), ("ALIGN", (0, 0), (-1, -1), "LEFT"),
("FONTNAME", (0, 0), (0, -1), "Helvetica-Bold"), ("FONTNAME", (0, 0), (0, -1), "Arial-Unicode-Bold"),
("FONTNAME", (1, 0), (-1, -1), "Arial-Unicode"), # Правая колонка обычным шрифтом
("FONTSIZE", (0, 0), (-1, -1), 10), ("FONTSIZE", (0, 0), (-1, -1), 10),
("BOTTOMPADDING", (0, 0), (-1, -1), 6), ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("TOPPADDING", (0, 0), (-1, -1), 6), ("TOPPADDING", (0, 0), (-1, -1), 6),
@ -154,57 +192,71 @@ class PDFReportService:
# Оценки по критериям # Оценки по критериям
story.append(Paragraph("Детальная оценка", self.styles["SectionHeader"])) story.append(Paragraph("Детальная оценка", self.styles["SectionHeader"]))
# Стиль для текста в таблице с автопереносом
table_text_style = ParagraphStyle(
name="TableText",
parent=self.styles["Normal"],
fontSize=8,
fontName="Arial-Unicode",
leading=10,
)
criteria_data = [ criteria_data = [
["Критерий", "Балл", "Обоснование", "Риски"],
[ [
"Технические навыки", Paragraph("Критерий", self.styles["CustomBodyText"]),
f"{report.technical_skills_score}/100", Paragraph("Балл", self.styles["CustomBodyText"]),
report.technical_skills_justification or "", Paragraph("Обоснование", self.styles["CustomBodyText"]),
report.technical_skills_concerns or "", Paragraph("Риски", self.styles["CustomBodyText"]),
], ],
[ [
"Релевантность опыта", Paragraph("Технические навыки", table_text_style),
f"{report.experience_relevance_score}/100", Paragraph(f"{report.technical_skills_score}/100", table_text_style),
report.experience_relevance_justification or "", Paragraph(report.technical_skills_justification or "", table_text_style),
report.experience_relevance_concerns or "", Paragraph(report.technical_skills_concerns or "", table_text_style),
], ],
[ [
"Коммуникация", Paragraph("Релевантность опыта", table_text_style),
f"{report.communication_score}/100", Paragraph(f"{report.experience_relevance_score}/100", table_text_style),
report.communication_justification or "", Paragraph(report.experience_relevance_justification or "", table_text_style),
report.communication_concerns or "", Paragraph(report.experience_relevance_concerns or "", table_text_style),
], ],
[ [
"Решение задач", Paragraph("Коммуникация", table_text_style),
f"{report.problem_solving_score}/100", Paragraph(f"{report.communication_score}/100", table_text_style),
report.problem_solving_justification or "", Paragraph(report.communication_justification or "", table_text_style),
report.problem_solving_concerns or "", Paragraph(report.communication_concerns or "", table_text_style),
], ],
[ [
"Культурное соответствие", Paragraph("Решение задач", table_text_style),
f"{report.cultural_fit_score}/100", Paragraph(f"{report.problem_solving_score}/100", table_text_style),
report.cultural_fit_justification or "", Paragraph(report.problem_solving_justification or "", table_text_style),
report.cultural_fit_concerns or "", Paragraph(report.problem_solving_concerns or "", 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(report.cultural_fit_concerns or "", table_text_style),
], ],
] ]
criteria_table = Table( criteria_table = Table(
criteria_data, colWidths=[2 * inch, 0.8 * inch, 2.2 * inch, 1.8 * inch] criteria_data, colWidths=[1.5 * inch, 0.6 * inch, 2.8 * inch, 2.1 * inch]
) )
criteria_table.setStyle( criteria_table.setStyle(
TableStyle( TableStyle(
[ [
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#5E81AC")), ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#5E81AC")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke), ("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), ("ALIGN", (1, 1), (1, -1), "CENTER"), # Центрирование баллов
("FONTSIZE", (0, 0), (-1, -1), 9), ("ALIGN", (0, 0), (-1, -1), "LEFT"), # Остальное слева
("ALIGN", (1, 1), (1, -1), "CENTER"),
("GRID", (0, 0), (-1, -1), 1, colors.HexColor("#D8DEE9")), ("GRID", (0, 0), (-1, -1), 1, colors.HexColor("#D8DEE9")),
("VALIGN", (0, 0), (-1, -1), "TOP"), ("VALIGN", (0, 0), (-1, -1), "TOP"),
("BOTTOMPADDING", (0, 0), (-1, -1), 8), ("BOTTOMPADDING", (0, 0), (-1, -1), 8),
("TOPPADDING", (0, 0), (-1, -1), 8), ("TOPPADDING", (0, 0), (-1, -1), 8),
("LEFTPADDING", (0, 0), (-1, -1), 6), ("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6), ("RIGHTPADDING", (0, 0), (-1, -1), 6),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8F9FA")]),
] ]
) )
) )
@ -282,7 +334,7 @@ class PDFReportService:
if report.strengths: if report.strengths:
story.append(Paragraph("Сильные стороны:", self.styles["SubHeader"])) story.append(Paragraph("Сильные стороны:", self.styles["SubHeader"]))
for strength in report.strengths: for strength in report.strengths:
story.append(Paragraph(f"{strength}", self.styles["BodyText"])) story.append(Paragraph(f"{strength}", self.styles["CustomBodyText"]))
story.append(Spacer(1, 10)) story.append(Spacer(1, 10))
if report.weaknesses: if report.weaknesses:
@ -290,7 +342,7 @@ class PDFReportService:
Paragraph("Области для развития:", self.styles["SubHeader"]) Paragraph("Области для развития:", self.styles["SubHeader"])
) )
for weakness in report.weaknesses: for weakness in report.weaknesses:
story.append(Paragraph(f"{weakness}", self.styles["BodyText"])) story.append(Paragraph(f"{weakness}", self.styles["CustomBodyText"]))
story.append(Spacer(1, 10)) story.append(Spacer(1, 10))
# Красные флаги # Красные флаги
@ -302,7 +354,7 @@ class PDFReportService:
f"{red_flag}", f"{red_flag}",
ParagraphStyle( ParagraphStyle(
name="RedFlag", name="RedFlag",
parent=self.styles["BodyText"], parent=self.styles["CustomBodyText"],
textColor=colors.HexColor("#BF616A"), textColor=colors.HexColor("#BF616A"),
), ),
) )
@ -312,45 +364,9 @@ class PDFReportService:
# Рекомендации и следующие шаги # Рекомендации и следующие шаги
if report.next_steps: if report.next_steps:
story.append(Paragraph("Рекомендации:", self.styles["SectionHeader"])) story.append(Paragraph("Рекомендации:", self.styles["SectionHeader"]))
story.append(Paragraph(report.next_steps, self.styles["BodyText"])) story.append(Paragraph(report.next_steps, self.styles["CustomBodyText"]))
story.append(Spacer(1, 15)) story.append(Spacer(1, 15))
# Метрики интервью
if any(
[
report.interview_duration_minutes,
report.dialogue_messages_count,
report.questions_quality_score,
]
):
story.append(Paragraph("Метрики интервью", self.styles["SectionHeader"]))
metrics = []
if report.interview_duration_minutes:
metrics.append(
["Длительность:", f"{report.interview_duration_minutes} мин"]
)
if report.dialogue_messages_count:
metrics.append(
["Сообщений в диалоге:", str(report.dialogue_messages_count)]
)
if report.questions_quality_score:
metrics.append(
["Качество ответов:", f"{report.questions_quality_score:.1f}/10"]
)
metrics_table = Table(metrics, colWidths=[2 * inch, 2 * inch])
metrics_table.setStyle(
TableStyle(
[
("FONTNAME", (0, 0), (0, -1), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 10),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
]
)
)
story.append(metrics_table)
# Подпись # Подпись
story.append(Spacer(1, 30)) story.append(Spacer(1, 30))
story.append( story.append(
@ -362,6 +378,7 @@ class PDFReportService:
fontSize=8, fontSize=8,
alignment=TA_CENTER, alignment=TA_CENTER,
textColor=colors.HexColor("#4C566A"), textColor=colors.HexColor("#4C566A"),
fontName="Arial-Unicode",
), ),
) )
) )
@ -374,10 +391,10 @@ class PDFReportService:
def _format_recommendation(self, recommendation: RecommendationType) -> str: def _format_recommendation(self, recommendation: RecommendationType) -> str:
"""Форматирует рекомендацию для отображения""" """Форматирует рекомендацию для отображения"""
recommendation_map = { recommendation_map = {
RecommendationType.STRONGLY_RECOMMEND: "Настоятельно рекомендуем", RecommendationType.STRONGLY_RECOMMEND: "Настоятельно рекомендуем",
RecommendationType.RECOMMEND: "👍 Рекомендуем", RecommendationType.RECOMMEND: "Рекомендуем",
RecommendationType.CONSIDER: "🤔 Рассмотреть кандидатуру", RecommendationType.CONSIDER: "Рассмотреть кандидатуру",
RecommendationType.REJECT: "Не рекомендуем", RecommendationType.REJECT: "Не рекомендуем",
} }
return recommendation_map.get(recommendation, str(recommendation)) return recommendation_map.get(recommendation, str(recommendation))
@ -408,11 +425,12 @@ class PDFReportService:
safe_name = safe_name.replace(" ", "_") 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( file_url = await s3_service.upload_file(
file_content=pdf_bytes, file_content=pdf_bytes,
file_name=filename, file_name=filename,
content_type="application/pdf", content_type="application/pdf",
public=True,
) )
return file_url return file_url