diff --git a/app/core/s3.py b/app/core/s3.py index e3edcb2..7dbb43e 100644 --- a/app/core/s3.py +++ b/app/core/s3.py @@ -18,19 +18,24 @@ class S3Service: self.bucket_name = settings.s3_bucket_name 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: try: file_key = f"{uuid.uuid4()}_{file_name}" - self.s3_client.put_object( - Bucket=self.bucket_name, - Key=file_key, - Body=file_content, - ContentType=content_type, - ) + put_object_kwargs = { + "Bucket": self.bucket_name, + "Key": file_key, + "Body": file_content, + "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 except ClientError as e: diff --git a/app/models/interview.py b/app/models/interview.py index 8f3aaf6..908497f 100644 --- a/app/models/interview.py +++ b/app/models/interview.py @@ -73,3 +73,4 @@ class LiveKitTokenResponse(SQLModel): token: str room_name: str server_url: str + session_id: int diff --git a/app/routers/analysis_router.py b/app/routers/analysis_router.py index 3050260..60dacad 100644 --- a/app/routers/analysis_router.py +++ b/app/routers/analysis_router.py @@ -4,6 +4,7 @@ from pydantic import BaseModel from app.core.database import get_session from app.repositories.resume_repository import ResumeRepository +from app.services.pdf_report_service import pdf_report_service from celery_worker.interview_analysis_task import ( analyze_multiple_candidates, generate_interview_report, @@ -44,6 +45,16 @@ class CandidateRanking(BaseModel): 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) async def start_interview_analysis( resume_id: int, @@ -264,7 +275,7 @@ async def get_pdf_report( .where(InterviewSession.resume_id == resume_id) ) - result = await session.exec(statement) + result = await session.execute(statement) report_session = result.first() if not report_session: @@ -289,6 +300,105 @@ async def get_pdf_report( 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}") async def get_report_data( resume_id: int, @@ -318,7 +428,7 @@ async def get_report_data( .where(InterviewSession.resume_id == resume_id) ) - result = await session.exec(statement) + result = await session.execute(statement) report_session = result.first() if not report_session: @@ -326,10 +436,19 @@ async def get_report_data( 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 { "report_id": report.id, "candidate_name": resume.applicant_name, - "position": "Unknown Position", # Можно расширить через vacancy + "position": position, "interview_date": report.created_at.isoformat(), "overall_score": report.overall_score, "recommendation": report.recommendation.value, diff --git a/app/services/interview_service.py b/app/services/interview_service.py index b4acc66..3038a17 100644 --- a/app/services/interview_service.py +++ b/app/services/interview_service.py @@ -182,6 +182,7 @@ class InterviewRoomService: token=token, room_name=interview_session.room_name, server_url=self.livekit_url, + session_id=interview_session.id, ) except Exception as e: diff --git a/app/services/pdf_report_service.py b/app/services/pdf_report_service.py index 8675dad..83edae3 100644 --- a/app/services/pdf_report_service.py +++ b/app/services/pdf_report_service.py @@ -6,6 +6,8 @@ from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY 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, @@ -22,9 +24,43 @@ class PDFReportService: """Сервис для генерации PDF отчетов по интервью""" def __init__(self): + self._register_fonts() self.styles = getSampleStyleSheet() 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): """Настройка кастомных стилей для документа""" # Заголовок отчета @@ -36,7 +72,7 @@ class PDFReportService: spaceAfter=30, alignment=TA_CENTER, textColor=colors.HexColor("#2E3440"), - fontName="Helvetica-Bold", + fontName="Arial-Unicode-Bold", ) ) @@ -49,7 +85,7 @@ class PDFReportService: spaceAfter=12, spaceBefore=20, textColor=colors.HexColor("#5E81AC"), - fontName="Helvetica-Bold", + fontName="Arial-Unicode-Bold", ) ) @@ -62,19 +98,20 @@ class PDFReportService: spaceAfter=8, spaceBefore=15, textColor=colors.HexColor("#81A1C1"), - fontName="Helvetica-Bold", + fontName="Arial-Unicode-Bold", ) ) # Обычный текст self.styles.add( ParagraphStyle( - name="BodyText", + name="CustomBodyText", parent=self.styles["Normal"], fontSize=10, spaceAfter=6, alignment=TA_JUSTIFY, textColor=colors.HexColor("#2E3440"), + fontName="Arial-Unicode", ) ) @@ -86,7 +123,7 @@ class PDFReportService: fontSize=12, alignment=TA_CENTER, textColor=colors.HexColor("#5E81AC"), - fontName="Helvetica-Bold", + fontName="Arial-Unicode-Bold", ) ) @@ -132,7 +169,7 @@ class PDFReportService: ["Кандидат:", candidate_name], ["Позиция:", position], ["Дата интервью:", report.created_at.strftime("%d.%m.%Y %H:%M")], - ["Общий балл:", f"{report.overall_score}/100"], + ["Общий балл:", f"{report.overall_score}/100"], ["Рекомендация:", self._format_recommendation(report.recommendation)], ] @@ -141,7 +178,8 @@ class PDFReportService: TableStyle( [ ("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), ("BOTTOMPADDING", (0, 0), (-1, -1), 6), ("TOPPADDING", (0, 0), (-1, -1), 6), @@ -154,57 +192,71 @@ class PDFReportService: # Оценки по критериям 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 = [ - ["Критерий", "Балл", "Обоснование", "Риски"], [ - "Технические навыки", - f"{report.technical_skills_score}/100", - report.technical_skills_justification or "—", - report.technical_skills_concerns or "—", + Paragraph("Критерий", self.styles["CustomBodyText"]), + Paragraph("Балл", self.styles["CustomBodyText"]), + Paragraph("Обоснование", self.styles["CustomBodyText"]), + Paragraph("Риски", self.styles["CustomBodyText"]), ], [ - "Релевантность опыта", - f"{report.experience_relevance_score}/100", - report.experience_relevance_justification or "—", - report.experience_relevance_concerns or "—", + Paragraph("Технические навыки", table_text_style), + Paragraph(f"{report.technical_skills_score}/100", table_text_style), + Paragraph(report.technical_skills_justification or "—", table_text_style), + Paragraph(report.technical_skills_concerns or "—", table_text_style), ], [ - "Коммуникация", - f"{report.communication_score}/100", - report.communication_justification or "—", - report.communication_concerns or "—", + Paragraph("Релевантность опыта", table_text_style), + Paragraph(f"{report.experience_relevance_score}/100", table_text_style), + Paragraph(report.experience_relevance_justification or "—", table_text_style), + Paragraph(report.experience_relevance_concerns or "—", table_text_style), ], [ - "Решение задач", - f"{report.problem_solving_score}/100", - report.problem_solving_justification or "—", - report.problem_solving_concerns or "—", + Paragraph("Коммуникация", table_text_style), + Paragraph(f"{report.communication_score}/100", table_text_style), + Paragraph(report.communication_justification or "—", table_text_style), + Paragraph(report.communication_concerns or "—", table_text_style), ], [ - "Культурное соответствие", - f"{report.cultural_fit_score}/100", - report.cultural_fit_justification or "—", - report.cultural_fit_concerns or "—", + Paragraph("Решение задач", table_text_style), + Paragraph(f"{report.problem_solving_score}/100", table_text_style), + Paragraph(report.problem_solving_justification or "—", table_text_style), + 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_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( TableStyle( [ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#5E81AC")), ("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke), - ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), - ("FONTSIZE", (0, 0), (-1, -1), 9), - ("ALIGN", (1, 1), (1, -1), "CENTER"), + ("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")]), ] ) ) @@ -282,7 +334,7 @@ class PDFReportService: if report.strengths: story.append(Paragraph("Сильные стороны:", self.styles["SubHeader"])) 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)) if report.weaknesses: @@ -290,7 +342,7 @@ class PDFReportService: Paragraph("Области для развития:", self.styles["SubHeader"]) ) 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)) # Красные флаги @@ -302,7 +354,7 @@ class PDFReportService: f"⚠ {red_flag}", ParagraphStyle( name="RedFlag", - parent=self.styles["BodyText"], + parent=self.styles["CustomBodyText"], textColor=colors.HexColor("#BF616A"), ), ) @@ -312,45 +364,9 @@ class PDFReportService: # Рекомендации и следующие шаги if report.next_steps: 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)) - # Метрики интервью - 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( @@ -362,6 +378,7 @@ class PDFReportService: fontSize=8, alignment=TA_CENTER, textColor=colors.HexColor("#4C566A"), + fontName="Arial-Unicode", ), ) ) @@ -374,10 +391,10 @@ class PDFReportService: def _format_recommendation(self, recommendation: RecommendationType) -> str: """Форматирует рекомендацию для отображения""" recommendation_map = { - RecommendationType.STRONGLY_RECOMMEND: "✅ Настоятельно рекомендуем", - RecommendationType.RECOMMEND: "👍 Рекомендуем", - RecommendationType.CONSIDER: "🤔 Рассмотреть кандидатуру", - RecommendationType.REJECT: "❌ Не рекомендуем", + RecommendationType.STRONGLY_RECOMMEND: "Настоятельно рекомендуем", + RecommendationType.RECOMMEND: "Рекомендуем", + RecommendationType.CONSIDER: "Рассмотреть кандидатуру", + RecommendationType.REJECT: "Не рекомендуем", } return recommendation_map.get(recommendation, str(recommendation)) @@ -408,11 +425,12 @@ class PDFReportService: safe_name = safe_name.replace(" ", "_") filename = f"interview_report_{safe_name}_{report.id}.pdf" - # Загружаем в S3 + # Загружаем в S3 с публичным доступом file_url = await s3_service.upload_file( file_content=pdf_bytes, file_name=filename, content_type="application/pdf", + public=True, ) return file_url