diff --git a/app/models/vacancy.py b/app/models/vacancy.py index 2abcd8c..ca5dd4e 100644 --- a/app/models/vacancy.py +++ b/app/models/vacancy.py @@ -38,15 +38,15 @@ class VacancyBase(SQLModel): salary_to: int | None = None salary_currency: str | None = Field(default="RUR", max_length=3) gross_salary: bool | None = False - company_name: str = Field(max_length=255) + company_name: str | None = Field(default=None, max_length=255) company_description: str | None = None - area_name: str = Field(max_length=255) + area_name: str | None = Field(default=None, max_length=255) metro_stations: str | None = None address: str | None = None professional_roles: str | None = None - contacts_name: str | None = Field(max_length=255) - contacts_email: str | None = Field(max_length=255) - contacts_phone: str | None = Field(max_length=50) + contacts_name: str | None = Field(default=None, max_length=255) + contacts_email: str | None = Field(default=None, max_length=255) + contacts_phone: str | None = Field(default=None, max_length=50) is_archived: bool = Field(default=False) premium: bool = Field(default=False) published_at: datetime | None = Field(default_factory=datetime.utcnow) diff --git a/app/routers/analysis_router.py b/app/routers/analysis_router.py index 60dacad..ec5a55e 100644 --- a/app/routers/analysis_router.py +++ b/app/routers/analysis_router.py @@ -4,7 +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 app.services.pdf_report_service import PDFReportService from celery_worker.interview_analysis_task import ( analyze_multiple_candidates, generate_interview_report, @@ -305,6 +305,7 @@ async def generate_pdf_report( resume_id: int, session=Depends(get_session), resume_repo: ResumeRepository = Depends(ResumeRepository), + pdf_report_service: PDFReportService = Depends(PDFReportService), ): """ Генерирует PDF отчет по интервью diff --git a/app/routers/vacancy_router.py b/app/routers/vacancy_router.py index 647cddb..97b8c76 100644 --- a/app/routers/vacancy_router.py +++ b/app/routers/vacancy_router.py @@ -1,11 +1,20 @@ -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile +from pydantic import BaseModel from app.models.vacancy import VacancyCreate, VacancyRead, VacancyUpdate from app.services.vacancy_service import VacancyService +from app.services.vacancy_parser_service import vacancy_parser_service router = APIRouter(prefix="/vacancies", tags=["vacancies"]) +class VacancyParseResponse(BaseModel): + """Ответ на запрос парсинга вакансии""" + message: str + parsed_data: dict | None = None + task_id: str | None = None + + @router.post("/", response_model=VacancyRead) async def create_vacancy( vacancy: VacancyCreate, vacancy_service: VacancyService = Depends(VacancyService) @@ -78,3 +87,284 @@ async def archive_vacancy( if not archived_vacancy: raise HTTPException(status_code=404, detail="Vacancy not found") return archived_vacancy + + +@router.post("/parse-file", response_model=VacancyParseResponse) +async def parse_vacancy_from_file( + file: UploadFile = File(...), + create_vacancy: bool = Query(False, description="Создать вакансию после парсинга"), + vacancy_service: VacancyService = Depends(VacancyService), +): + """ + Парсинг вакансии из загруженного файла (PDF, DOCX, RTF, TXT) + + Args: + file: Файл вакансии + create_vacancy: Создать вакансию в БД после парсинга + + Returns: + VacancyParseResponse: Результат парсинга + """ + + # Проверяем формат файла + if not file.filename: + raise HTTPException(status_code=400, detail="Имя файла не указано") + + file_extension = file.filename.lower().split('.')[-1] + supported_formats = ['pdf', 'docx', 'rtf', 'txt'] + + if file_extension not in supported_formats: + raise HTTPException( + status_code=400, + detail=f"Неподдерживаемый формат файла. Поддерживаются: {', '.join(supported_formats)}" + ) + + # Проверяем размер файла (максимум 10MB) + file_content = await file.read() + if len(file_content) > 10 * 1024 * 1024: + raise HTTPException(status_code=400, detail="Файл слишком большой (максимум 10MB)") + + try: + # Извлекаем текст из файла + raw_text = vacancy_parser_service.extract_text_from_file(file_content, file.filename) + + if not raw_text.strip(): + raise HTTPException(status_code=400, detail="Не удалось извлечь текст из файла") + + # Парсим с помощью AI + parsed_data = await vacancy_parser_service.parse_vacancy_with_ai(raw_text) + + # Если нужно создать вакансию, создаем её + created_vacancy = None + if create_vacancy: + try: + vacancy_create = VacancyCreate(**parsed_data) + created_vacancy = await vacancy_service.create_vacancy(vacancy_create) + except Exception as e: + # Возвращаем парсинг, но предупреждаем об ошибке создания + return VacancyParseResponse( + message=f"Парсинг выполнен, но ошибка при создании вакансии: {str(e)}", + parsed_data=parsed_data + ) + + response_message = "Парсинг выполнен успешно" + if created_vacancy: + response_message += f". Вакансия создана с ID: {created_vacancy.id}" + + return VacancyParseResponse( + message=response_message, + parsed_data=parsed_data + ) + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Ошибка при парсинге вакансии: {str(e)}") + + +@router.post("/parse-text", response_model=VacancyParseResponse) +async def parse_vacancy_from_text( + text: str = Query(..., description="Текст вакансии для парсинга"), + create_vacancy: bool = Query(False, description="Создать вакансию после парсинга"), + vacancy_service: VacancyService = Depends(VacancyService), +): + """ + Парсинг вакансии из текста + + Args: + text: Текст вакансии + create_vacancy: Создать вакансию в БД после парсинга + + Returns: + VacancyParseResponse: Результат парсинга + """ + + if not text.strip(): + raise HTTPException(status_code=400, detail="Текст вакансии не может быть пустым") + + if len(text) > 50000: # Ограничение на длину текста + raise HTTPException(status_code=400, detail="Текст слишком длинный (максимум 50000 символов)") + + try: + # Парсим с помощью AI + parsed_data = await vacancy_parser_service.parse_vacancy_with_ai(text) + + # Если нужно создать вакансию, создаем её + created_vacancy = None + if create_vacancy: + try: + vacancy_create = VacancyCreate(**parsed_data) + created_vacancy = await vacancy_service.create_vacancy(vacancy_create) + except Exception as e: + return VacancyParseResponse( + message=f"Парсинг выполнен, но ошибка при создании вакансии: {str(e)}", + parsed_data=parsed_data + ) + + response_message = "Парсинг выполнен успешно" + if created_vacancy: + response_message += f". Вакансия создана с ID: {created_vacancy.id}" + + return VacancyParseResponse( + message=response_message, + parsed_data=parsed_data + ) + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Ошибка при парсинге вакансии: {str(e)}") + + +@router.get("/parse-formats") +async def get_supported_formats(): + """ + Получить список поддерживаемых форматов файлов для парсинга вакансий + + Returns: + dict: Информация о поддерживаемых форматах + """ + return { + "supported_formats": [ + { + "extension": "pdf", + "description": "PDF документы", + "max_size_mb": 10 + }, + { + "extension": "docx", + "description": "Microsoft Word документы", + "max_size_mb": 10 + }, + { + "extension": "rtf", + "description": "Rich Text Format", + "max_size_mb": 10 + }, + { + "extension": "txt", + "description": "Текстовые файлы", + "max_size_mb": 10 + } + ], + "features": [ + "Автоматическое извлечение текста из файлов", + "AI-парсинг структурированной информации", + "Создание вакансии в базе данных", + "Валидация данных" + ] + } + + +@router.post("/parse-file-async", response_model=dict) +async def parse_vacancy_from_file_async( + file: UploadFile = File(...), + create_vacancy: str = Form("false", description="Создать вакансию после парсинга"), +): + """ + Асинхронный парсинг вакансии из загруженного файла (PDF, DOCX, RTF, TXT) + + Args: + file: Файл вакансии + create_vacancy: Создать вакансию в БД после парсинга + + Returns: + dict: ID задачи для отслеживания статуса + """ + import base64 + from celery_worker.tasks import parse_vacancy_task + + # Проверяем формат файла + if not file.filename: + raise HTTPException(status_code=400, detail="Имя файла не указано") + + file_extension = file.filename.lower().split('.')[-1] + supported_formats = ['pdf', 'docx', 'rtf', 'txt'] + + if file_extension not in supported_formats: + raise HTTPException( + status_code=400, + detail=f"Неподдерживаемый формат файла. Поддерживаются: {', '.join(supported_formats)}" + ) + + # Проверяем размер файла (максимум 10MB) + file_content = await file.read() + if len(file_content) > 10 * 1024 * 1024: + raise HTTPException(status_code=400, detail="Файл слишком большой (максимум 10MB)") + + try: + # Кодируем содержимое файла в base64 для передачи в Celery + file_content_base64 = base64.b64encode(file_content).decode('utf-8') + + # Конвертируем строку в boolean + create_vacancy_bool = create_vacancy.lower() in ('true', '1', 'yes', 'on') + + # Запускаем асинхронную задачу + task = parse_vacancy_task.delay( + file_content_base64=file_content_base64, + filename=file.filename, + create_vacancy=create_vacancy_bool + ) + + return { + "message": "Задача парсинга запущена", + "task_id": task.id, + "status": "pending", + "check_status_url": f"/api/v1/vacancies/parse-status/{task.id}" + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Ошибка при запуске парсинга: {str(e)}") + + +@router.get("/parse-status/{task_id}") +async def get_parse_status(task_id: str): + """ + Получить статус асинхронной задачи парсинга вакансии + + Args: + task_id: ID задачи + + Returns: + dict: Статус задачи и результат (если завершена) + """ + from celery_worker.celery_app import celery_app + + try: + task = celery_app.AsyncResult(task_id) + + if task.state == 'PENDING': + response = { + 'task_id': task_id, + 'state': task.state, + 'status': 'Задача ожидает выполнения...', + 'progress': 0 + } + elif task.state == 'PROGRESS': + response = { + 'task_id': task_id, + 'state': task.state, + 'status': task.info.get('status', ''), + 'progress': task.info.get('progress', 0) + } + elif task.state == 'SUCCESS': + response = { + 'task_id': task_id, + 'state': task.state, + 'status': 'completed', + 'progress': 100, + 'result': task.result + } + else: # FAILURE + response = { + 'task_id': task_id, + 'state': task.state, + 'status': 'failed', + 'progress': 0, + 'error': str(task.info) + } + + return response + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Ошибка при получении статуса задачи: {str(e)}") diff --git a/app/services/pdf_report_service.py b/app/services/pdf_report_service.py index 18bfc07..95ff537 100644 --- a/app/services/pdf_report_service.py +++ b/app/services/pdf_report_service.py @@ -1,458 +1,264 @@ import io +import os from datetime import datetime -from reportlab.lib import colors -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, - Spacer, - Table, - TableStyle, -) +from jinja2 import Template +import pdfkit from app.core.s3 import s3_service from app.models.interview_report import InterviewReport, RecommendationType class PDFReportService: - """Сервис для генерации PDF отчетов по интервью""" + """Сервис для генерации PDF отчетов по интервью на основе HTML шаблона""" def __init__(self): - self._register_fonts() - self.styles = getSampleStyleSheet() - self._setup_custom_styles() - - def _format_list_field(self, field_value) -> str: - """Форматирует поле со списком для отображения в PDF""" - if not field_value: + self.template_path = "templates/interview_report.html" + + def _load_html_template(self) -> str: + """Загружает HTML шаблон из файла""" + try: + with open(self.template_path, 'r', encoding='utf-8') as file: + return file.read() + except FileNotFoundError: + raise FileNotFoundError(f"HTML шаблон не найден: {self.template_path}") + + def _format_concerns_field(self, concerns): + """Форматирует поле concerns для отображения""" + if not concerns: return "—" - if isinstance(field_value, list): - # Если это список, объединяем элементы - return "\n• ".join([""] + field_value) - elif isinstance(field_value, str): - # Если это строка, возвращаем как есть - return field_value + if isinstance(concerns, list): + return "; ".join(concerns) + elif isinstance(concerns, str): + return concerns else: - # Для других типов конвертируем в строку - return str(field_value) - - 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): - """Настройка кастомных стилей для документа""" - # Заголовок отчета - self.styles.add( - ParagraphStyle( - name="ReportTitle", - parent=self.styles["Title"], - fontSize=18, - spaceAfter=30, - alignment=TA_CENTER, - textColor=colors.HexColor("#2E3440"), - fontName="Arial-Unicode-Bold", - ) - ) - - # Заголовки секций - self.styles.add( - ParagraphStyle( - name="SectionHeader", - parent=self.styles["Heading1"], - fontSize=14, - spaceAfter=12, - spaceBefore=20, - textColor=colors.HexColor("#5E81AC"), - fontName="Arial-Unicode-Bold", - ) - ) - - # Подзаголовки - self.styles.add( - ParagraphStyle( - name="SubHeader", - parent=self.styles["Heading2"], - fontSize=12, - spaceAfter=8, - spaceBefore=15, - textColor=colors.HexColor("#81A1C1"), - fontName="Arial-Unicode-Bold", - ) - ) - - # Обычный текст - self.styles.add( - ParagraphStyle( - name="CustomBodyText", - parent=self.styles["Normal"], - fontSize=10, - spaceAfter=6, - alignment=TA_JUSTIFY, - textColor=colors.HexColor("#2E3440"), - fontName="Arial-Unicode", - ) - ) - - # Стиль для метрик - self.styles.add( - ParagraphStyle( - name="MetricValue", - parent=self.styles["Normal"], - fontSize=12, - alignment=TA_CENTER, - textColor=colors.HexColor("#5E81AC"), - fontName="Arial-Unicode-Bold", - ) - ) - - async def generate_interview_report_pdf( - self, report: InterviewReport, candidate_name: str, position: str - ) -> bytes: + return str(concerns) + + def _get_score_class(self, score: int) -> str: + """Возвращает CSS класс для цвета оценки""" + if score >= 80: + return "score-green" + elif score >= 60: + return "score-orange" + else: + return "score-red" + + def _format_recommendation(self, recommendation: RecommendationType) -> tuple: + """Форматирует рекомендацию для отображения""" + if recommendation == RecommendationType.HIRE: + return ("Рекомендуем", "recommend-button") + elif recommendation == RecommendationType.CONSIDER: + return ("К рассмотрению", "consider-button") + else: + return ("Не рекомендуем", "reject-button") + + def generate_pdf_report(self, interview_report: InterviewReport) -> bytes: """ - Генерирует PDF отчет по интервью - + Генерирует PDF отчет на основе HTML шаблона + Args: - report: Модель отчета из БД - candidate_name: Имя кандидата - position: Название позиции - + interview_report: Данные отчета по интервью + Returns: bytes: PDF файл в виде байтов """ - buffer = io.BytesIO() - doc = SimpleDocTemplate( - buffer, - pagesize=A4, - rightMargin=72, - leftMargin=72, - topMargin=72, - bottomMargin=72, - ) - - # Собираем элементы документа - story = [] - - # Заголовок отчета - story.append( - Paragraph( - f"Отчет по собеседованию
{candidate_name}", - self.styles["ReportTitle"], - ) - ) - - # Основная информация - story.append(Paragraph("Основная информация", self.styles["SectionHeader"])) - - basic_info = [ - ["Кандидат:", candidate_name], - ["Позиция:", position], - ["Дата интервью:", report.created_at.strftime("%d.%m.%Y %H:%M")], - ["Общий балл:", f"{report.overall_score}/100"], - ["Рекомендация:", self._format_recommendation(report.recommendation)], - ] - - basic_table = Table(basic_info, colWidths=[2 * inch, 4 * inch]) - basic_table.setStyle( - TableStyle( - [ - ("ALIGN", (0, 0), (-1, -1), "LEFT"), - ("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), - ] - ) - ) - story.append(basic_table) - story.append(Spacer(1, 20)) - - # Оценки по критериям - story.append(Paragraph("Детальная оценка", self.styles["SectionHeader"])) - - # Стиль для текста в таблице с автопереносом - table_text_style = ParagraphStyle( - name="TableText", - parent=self.styles["Normal"], - fontSize=8, - fontName="Arial-Unicode", - leading=10, - ) + try: + # Загружаем HTML шаблон + html_template = self._load_html_template() + + # Подготавливаем данные для шаблона + template_data = self._prepare_template_data(interview_report) + + # Рендерим HTML с данными + template = Template(html_template) + rendered_html = template.render(**template_data) + + # Настройки для wkhtmltopdf + options = { + 'page-size': 'A4', + 'margin-top': '0.75in', + 'margin-right': '0.75in', + 'margin-bottom': '0.75in', + 'margin-left': '0.75in', + 'encoding': 'UTF-8', + 'no-outline': None, + 'enable-local-file-access': None + } + + # Генерируем PDF + pdf_bytes = pdfkit.from_string(rendered_html, False, options=options) + + return pdf_bytes + + except Exception as e: + raise Exception(f"Ошибка при генерации PDF: {str(e)}") + + def _prepare_template_data(self, interview_report: InterviewReport) -> dict: + """Подготавливает данные для HTML шаблона""" + + # Основная информация о кандидате + candidate_name = interview_report.resume.applicant_name or "Не указано" + position = "Не указана" + + # Получаем название позиции из связанной вакансии + if hasattr(interview_report.resume, 'vacancy') and interview_report.resume.vacancy: + position = interview_report.resume.vacancy.title + + # Форматируем дату интервью + interview_date = "Не указана" + if interview_report.interview_session and interview_report.interview_session.interview_start_time: + interview_date = interview_report.interview_session.interview_start_time.strftime("%d.%m.%Y %H:%M") + + # Общий балл и рекомендация + overall_score = interview_report.overall_score or 0 + recommendation_text, recommendation_class = self._format_recommendation(interview_report.recommendation) + + # Сильные стороны и области развития + strengths = self._format_concerns_field(interview_report.strengths_concerns) if interview_report.strengths_concerns else "Не указаны" + areas_for_development = self._format_concerns_field(interview_report.areas_for_development_concerns) if interview_report.areas_for_development_concerns else "Не указаны" + + # Детальная оценка + evaluation_criteria = [] + + # Технические навыки + if interview_report.technical_skills_score is not None: + evaluation_criteria.append({ + 'name': 'Технические навыки', + 'score': interview_report.technical_skills_score, + 'score_class': self._get_score_class(interview_report.technical_skills_score), + 'justification': interview_report.technical_skills_justification or "—", + 'concerns': self._format_concerns_field(interview_report.technical_skills_concerns) + }) + + # Релевантность опыта + if interview_report.experience_relevance_score is not None: + evaluation_criteria.append({ + 'name': 'Релевантность опыта', + 'score': interview_report.experience_relevance_score, + 'score_class': self._get_score_class(interview_report.experience_relevance_score), + 'justification': interview_report.experience_relevance_justification or "—", + 'concerns': self._format_concerns_field(interview_report.experience_relevance_concerns) + }) + + # Коммуникация + if interview_report.communication_score is not None: + evaluation_criteria.append({ + 'name': 'Коммуникация', + 'score': interview_report.communication_score, + 'score_class': self._get_score_class(interview_report.communication_score), + 'justification': interview_report.communication_justification or "—", + 'concerns': self._format_concerns_field(interview_report.communication_concerns) + }) + + # Решение задач + if interview_report.problem_solving_score is not None: + evaluation_criteria.append({ + 'name': 'Решение задач', + 'score': interview_report.problem_solving_score, + 'score_class': self._get_score_class(interview_report.problem_solving_score), + 'justification': interview_report.problem_solving_justification or "—", + 'concerns': self._format_concerns_field(interview_report.problem_solving_concerns) + }) + + # Культурное соответствие + if interview_report.cultural_fit_score is not None: + evaluation_criteria.append({ + 'name': 'Культурное соответствие', + 'score': interview_report.cultural_fit_score, + 'score_class': self._get_score_class(interview_report.cultural_fit_score), + 'justification': interview_report.cultural_fit_justification or "—", + 'concerns': self._format_concerns_field(interview_report.cultural_fit_concerns) + }) - criteria_data = [ - [ - Paragraph("Критерий", self.styles["CustomBodyText"]), - Paragraph("Балл", self.styles["CustomBodyText"]), - Paragraph("Обоснование", self.styles["CustomBodyText"]), - Paragraph("Риски", self.styles["CustomBodyText"]), - ], - [ - Paragraph("Технические навыки", table_text_style), - Paragraph(f"{report.technical_skills_score}/100", table_text_style), - Paragraph(report.technical_skills_justification or "—", table_text_style), - Paragraph(self._format_list_field(report.technical_skills_concerns), table_text_style), - ], - [ - Paragraph("Релевантность опыта", table_text_style), - Paragraph(f"{report.experience_relevance_score}/100", table_text_style), - Paragraph(report.experience_relevance_justification or "—", table_text_style), - Paragraph(self._format_list_field(report.experience_relevance_concerns), table_text_style), - ], - [ - Paragraph("Коммуникация", table_text_style), - Paragraph(f"{report.communication_score}/100", table_text_style), - Paragraph(report.communication_justification or "—", table_text_style), - Paragraph(self._format_list_field(report.communication_concerns), table_text_style), - ], - [ - Paragraph("Решение задач", table_text_style), - Paragraph(f"{report.problem_solving_score}/100", table_text_style), - Paragraph(report.problem_solving_justification or "—", table_text_style), - Paragraph(self._format_list_field(report.problem_solving_concerns), 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(self._format_list_field(report.cultural_fit_concerns), table_text_style), - ], - ] - - criteria_table = Table( - 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), - ("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")]), - ] - ) - ) - - # Цветовое кодирование баллов - for i in range(1, 6): # строки с баллами - score_cell = (1, i) - if hasattr( - report, - [ - "technical_skills_score", - "experience_relevance_score", - "communication_score", - "problem_solving_score", - "cultural_fit_score", - ][i - 1], - ): - score = getattr( - report, - [ - "technical_skills_score", - "experience_relevance_score", - "communication_score", - "problem_solving_score", - "cultural_fit_score", - ][i - 1], - ) - if score >= 80: - criteria_table.setStyle( - TableStyle( - [ - ( - "BACKGROUND", - score_cell, - score_cell, - colors.HexColor("#A3BE8C"), - ) - ] - ) - ) - elif score >= 60: - criteria_table.setStyle( - TableStyle( - [ - ( - "BACKGROUND", - score_cell, - score_cell, - colors.HexColor("#EBCB8B"), - ) - ] - ) - ) - else: - criteria_table.setStyle( - TableStyle( - [ - ( - "BACKGROUND", - score_cell, - score_cell, - colors.HexColor("#BF616A"), - ) - ] - ) - ) - - story.append(criteria_table) - story.append(Spacer(1, 20)) - - # Сильные и слабые стороны - if report.strengths or report.weaknesses: - story.append(Paragraph("Анализ кандидата", self.styles["SectionHeader"])) - - if report.strengths: - story.append(Paragraph("Сильные стороны:", self.styles["SubHeader"])) - for strength in report.strengths: - story.append(Paragraph(f"• {strength}", self.styles["CustomBodyText"])) - story.append(Spacer(1, 10)) - - if report.weaknesses: - story.append( - Paragraph("Области для развития:", self.styles["SubHeader"]) - ) - for weakness in report.weaknesses: - story.append(Paragraph(f"• {weakness}", self.styles["CustomBodyText"])) - story.append(Spacer(1, 15)) - # Красные флаги - if report.red_flags: - story.append(Paragraph("Красные флаги:", self.styles["SectionHeader"])) - for red_flag in report.red_flags: - story.append( - Paragraph( - f"{red_flag}", - ParagraphStyle( - name="RedFlag", - parent=self.styles["CustomBodyText"], - textColor=colors.HexColor("#BF616A"), - ), - ) - ) - story.append(Spacer(1, 15)) - - # Рекомендации и следующие шаги - if report.next_steps: - story.append(Paragraph("Рекомендации:", self.styles["SectionHeader"])) - story.append(Paragraph(report.next_steps, self.styles["CustomBodyText"])) - story.append(Spacer(1, 15)) - - # Подпись - story.append(Spacer(1, 30)) - story.append( - Paragraph( - f"Отчет сгенерирован автоматически • {datetime.now().strftime('%d.%m.%Y %H:%M')}", - ParagraphStyle( - name="Footer", - parent=self.styles["Normal"], - fontSize=8, - alignment=TA_CENTER, - textColor=colors.HexColor("#4C566A"), - fontName="Arial-Unicode", - ), - ) - ) - - # Генерируем PDF - doc.build(story) - buffer.seek(0) - return buffer.getvalue() - - def _format_recommendation(self, recommendation: RecommendationType) -> str: - """Форматирует рекомендацию для отображения""" - recommendation_map = { - RecommendationType.STRONGLY_RECOMMEND: "Настоятельно рекомендуем", - RecommendationType.RECOMMEND: "Рекомендуем", - RecommendationType.CONSIDER: "Рассмотреть кандидатуру", - RecommendationType.REJECT: "Не рекомендуем", + red_flags = [] + if interview_report.red_flags: + if isinstance(interview_report.red_flags, list): + red_flags = interview_report.red_flags + elif isinstance(interview_report.red_flags, str): + red_flags = [interview_report.red_flags] + + # Ссылка на резюме + resume_url = interview_report.resume.file_url if interview_report.resume.file_url else "#" + + # ID отчета + report_id = f"#{interview_report.id}" if interview_report.id else "#0" + + # Дата генерации отчета + generation_date = datetime.now().strftime("%d.%m.%Y %H:%M") + + return { + 'report_id': report_id, + 'candidate_name': candidate_name, + 'position': position, + 'interview_date': interview_date, + 'overall_score': overall_score, + 'recommendation_text': recommendation_text, + 'recommendation_class': recommendation_class, + 'strengths': strengths, + 'areas_for_development': areas_for_development, + 'evaluation_criteria': evaluation_criteria, + 'red_flags': red_flags, + 'resume_url': resume_url, + 'generation_date': generation_date } - return recommendation_map.get(recommendation, str(recommendation)) - async def generate_and_upload_pdf( - self, report: InterviewReport, candidate_name: str, position: str - ) -> str | None: + async def upload_pdf_to_s3(self, pdf_bytes: bytes, filename: str) -> str: """ - Генерирует PDF отчет и загружает его в S3 - + Загружает PDF файл в S3 и возвращает публичную ссылку + Args: - report: Модель отчета из БД - candidate_name: Имя кандидата - position: Название позиции - + pdf_bytes: PDF файл в виде байтов + filename: Имя файла + Returns: - str | None: URL файла в S3 или None при ошибке + str: Публичная ссылка на файл в S3 + """ + try: + pdf_stream = io.BytesIO(pdf_bytes) + + # Загружаем с публичным доступом + file_url = await s3_service.upload_file( + pdf_stream, + filename, + content_type="application/pdf", + public=True + ) + + return file_url + + 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) -> str: + """ + Генерирует PDF отчет и загружает его в S3 (метод обратной совместимости) + + Args: + report: Отчет по интервью + candidate_name: Имя кандидата (не используется, берется из отчета) + position: Позиция (не используется, берется из отчета) + + Returns: + str: Публичная ссылка на PDF файл """ try: # Генерируем PDF - pdf_bytes = await self.generate_interview_report_pdf( - report, candidate_name, position - ) - - # Формируем имя файла - safe_name = "".join( - c for c in candidate_name if c.isalnum() or c in (" ", "-", "_") - ).strip() - safe_name = safe_name.replace(" ", "_") + pdf_bytes = self.generate_pdf_report(report) + + # Создаем имя файла + safe_name = report.resume.applicant_name or "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 с публичным доступом - file_url = await s3_service.upload_file( - file_content=pdf_bytes, - file_name=filename, - content_type="application/pdf", - public=True, - ) - - return file_url - + + # Загружаем в S3 + pdf_url = await self.upload_pdf_to_s3(pdf_bytes, filename) + + return pdf_url + except Exception as e: - print(f"Error generating and uploading PDF report: {e}") - return None + raise Exception(f"Ошибка при генерации и загрузке PDF: {str(e)}") # Экземпляр сервиса diff --git a/app/services/vacancy_parser_service.py b/app/services/vacancy_parser_service.py new file mode 100644 index 0000000..2d173de --- /dev/null +++ b/app/services/vacancy_parser_service.py @@ -0,0 +1,299 @@ +import io +import json +import logging +from pathlib import Path +from typing import Any, Dict + +logger = logging.getLogger(__name__) + + +class VacancyParserService: + """Сервис для парсинга вакансий из файлов различных форматов""" + + def __init__(self): + self.supported_formats = ['.pdf', '.docx', '.rtf', '.txt'] + + def extract_text_from_file(self, file_content: bytes, filename: str) -> str: + """ + Извлекает текст из файла в зависимости от его формата + + Args: + file_content: Содержимое файла в байтах + filename: Имя файла для определения формата + + Returns: + str: Извлеченный текст + """ + file_extension = Path(filename).suffix.lower() + + try: + if file_extension == '.pdf': + return self._extract_from_pdf(file_content) + elif file_extension == '.docx': + return self._extract_from_docx(file_content) + elif file_extension == '.rtf': + return self._extract_from_rtf(file_content) + elif file_extension == '.txt': + return self._extract_from_txt(file_content) + else: + raise ValueError(f"Неподдерживаемый формат файла: {file_extension}") + + except Exception as e: + logger.error(f"Ошибка при извлечении текста из файла {filename}: {str(e)}") + raise + + def _extract_from_pdf(self, file_content: bytes) -> str: + """Извлекает текст из PDF файла""" + try: + import PyPDF2 + + pdf_file = io.BytesIO(file_content) + pdf_reader = PyPDF2.PdfReader(pdf_file) + + text = "" + for page in pdf_reader.pages: + text += page.extract_text() + "\n" + + return text.strip() + + except ImportError: + # Fallback to pdfplumber if PyPDF2 doesn't work well + try: + import pdfplumber + + pdf_file = io.BytesIO(file_content) + text = "" + + with pdfplumber.open(pdf_file) as pdf: + for page in pdf.pages: + page_text = page.extract_text() + if page_text: + text += page_text + "\n" + + return text.strip() + + except ImportError: + raise ImportError("Требуется установить PyPDF2 или pdfplumber: pip install PyPDF2 pdfplumber") + + def _extract_from_docx(self, file_content: bytes) -> str: + """Извлекает текст из DOCX файла""" + try: + import docx + + doc_file = io.BytesIO(file_content) + doc = docx.Document(doc_file) + + text = "" + for paragraph in doc.paragraphs: + text += paragraph.text + "\n" + + # Также извлекаем текст из таблиц + for table in doc.tables: + for row in table.rows: + for cell in row.cells: + text += cell.text + "\t" + text += "\n" + + return text.strip() + + except ImportError: + raise ImportError("Требуется установить python-docx: pip install python-docx") + + def _extract_from_rtf(self, file_content: bytes) -> str: + """Извлекает текст из RTF файла""" + try: + from striprtf.striprtf import rtf_to_text + + rtf_content = file_content.decode('utf-8', errors='ignore') + text = rtf_to_text(rtf_content) + + return text.strip() + + except ImportError: + raise ImportError("Требуется установить striprtf: pip install striprtf") + except Exception as e: + # Альтернативный метод через pyth + try: + from pyth.plugins.rtf15.reader import Rtf15Reader + from pyth.plugins.plaintext.writer import PlaintextWriter + + doc = Rtf15Reader.read(io.BytesIO(file_content)) + text = PlaintextWriter.write(doc).getvalue() + + return text.strip() + + except ImportError: + raise ImportError("Требуется установить striprtf или pyth: pip install striprtf pyth") + + def _extract_from_txt(self, file_content: bytes) -> str: + """Извлекает текст из TXT файла""" + try: + # Пробуем различные кодировки + encodings = ['utf-8', 'windows-1251', 'cp1252', 'iso-8859-1'] + + for encoding in encodings: + try: + text = file_content.decode(encoding) + return text.strip() + except UnicodeDecodeError: + continue + + # Если все кодировки не подошли, используем errors='ignore' + text = file_content.decode('utf-8', errors='ignore') + return text.strip() + + except Exception as e: + logger.error(f"Ошибка при чтении txt файла: {str(e)}") + raise + + async def parse_vacancy_with_ai(self, raw_text: str) -> Dict[str, Any]: + """ + Парсит текст вакансии с помощью AI для извлечения структурированной информации + + Args: + raw_text: Сырой текст вакансии + + Returns: + Dict с полями для модели Vacancy + """ + from rag.settings import settings + + if not settings.openai_api_key: + raise ValueError("OpenAI API ключ не настроен") + + try: + import openai + + openai.api_key = settings.openai_api_key + + parsing_prompt = f""" +Проанализируй текст вакансии и извлеки из него структурированную информацию. + +ТЕКСТ ВАКАНСИИ: +{raw_text} + +ЗАДАЧА: +Извлеки следующие поля для вакансии: + +1. title - название позиции (строка) +2. description - описание вакансии (полное описание обязанностей, требований) +3. key_skills - ключевые навыки через запятую (строка) +4. employment_type - тип занятости: "full", "part", "project", "volunteer", "probation" +5. experience - опыт работы: "noExperience", "between1And3", "between3And6", "moreThan6" +6. schedule - график работы: "fullDay", "shift", "flexible", "remote", "flyInFlyOut" +7. salary_from - зарплата от (число или null) +8. salary_to - зарплата до (число или null) +9. salary_currency - валюта (строка, по умолчанию "RUR") +10. company_name - название компании (строка) +11. company_description - описание компании (строка или null) +12. area_name - город/регион (строка) +13. address - адрес (строка или null) +14. professional_roles - профессиональные роли (строка или null) +15. contacts_name - контактное лицо (строка или null) +16. contacts_email - email для связи (строка или null) +17. contacts_phone - телефон для связи (строка или null) + +ПРАВИЛА: +- Если информация не найдена, ставь null для необязательных полей +- Для обязательных полей используй разумные значения по умолчанию +- Зарплату указывай в рублях, конвертируй если нужно +- Опыт определяй по годам: 0-1 = noExperience, 1-3 = between1And3, 3-6 = between3And6, 6+ = moreThan6 +- График работы определяй по описанию: офис = fullDay, удаленка = remote, гибкий = flexible + +ФОРМАТИРОВАНИЕ ТЕКСТА: +- Если в тексте есть списки (обязанности, требования, навыки), форматируй их с переносами строк +- Используй символ \n для переноса строки между пунктами списка +- Пример: "Обязанности:\nВедение переговоров\nПодготовка документов\nОбучение персонала" +- Для ключевых навыков разделяй запятыми, но если их много - используй переносы строк +- В описании компании тоже используй переносы для лучшей читаемости + +ОТВЕТЬ СТРОГО В JSON ФОРМАТЕ с указанными полями: +""" + + response = openai.chat.completions.create( + model="gpt-4o-mini", + messages=[{"role": "user", "content": parsing_prompt}], + response_format={"type": "json_object"}, + ) + + parsed_data = json.loads(response.choices[0].message.content) + + # Валидируем и обрабатываем данные + return self._validate_parsed_data(parsed_data) + + except Exception as e: + logger.error(f"Ошибка при парсинге вакансии через AI: {str(e)}") + raise + + def _validate_parsed_data(self, data: Dict[str, Any]) -> Dict[str, Any]: + """Валидирует и очищает спарсенные данные""" + from app.models.vacancy import EmploymentType, Experience, Schedule + + # Обязательные поля с дефолтными значениями + validated_data = { + 'title': data.get('title', 'Название не указано'), + 'description': data.get('description', 'Описание не указано'), + 'key_skills': data.get('key_skills'), + 'employment_type': self._validate_enum( + data.get('employment_type'), + EmploymentType, + EmploymentType.FULL_TIME + ), + 'experience': self._validate_enum( + data.get('experience'), + Experience, + Experience.BETWEEN_1_AND_3 + ), + 'schedule': self._validate_enum( + data.get('schedule'), + Schedule, + Schedule.FULL_DAY + ), + 'company_name': data.get('company_name'), + 'area_name': data.get('area_name'), + } + + # Необязательные поля + optional_fields = [ + 'salary_from', 'salary_to', 'salary_currency', 'company_description', + 'address', 'professional_roles', 'contacts_name', 'contacts_email', 'contacts_phone' + ] + + for field in optional_fields: + value = data.get(field) + if value and value != "null": + validated_data[field] = value + + # Специальная обработка зарплаты + if data.get('salary_from'): + try: + validated_data['salary_from'] = int(data['salary_from']) + except (ValueError, TypeError): + pass + + if data.get('salary_to'): + try: + validated_data['salary_to'] = int(data['salary_to']) + except (ValueError, TypeError): + pass + + # Валюта по умолчанию + validated_data['salary_currency'] = data.get('salary_currency', 'RUR') + + return validated_data + + def _validate_enum(self, value: str, enum_class, default_value): + """Валидирует значение enum""" + if not value: + return default_value + + # Проверяем, есть ли такое значение в enum + try: + return enum_class(value) + except ValueError: + logger.warning(f"Неизвестное значение {value} для {enum_class.__name__}, используем {default_value}") + return default_value + + +# Экземпляр сервиса +vacancy_parser_service = VacancyParserService() \ No newline at end of file diff --git a/celery_worker/database.py b/celery_worker/database.py index 4efb23f..8d6ad8a 100644 --- a/celery_worker/database.py +++ b/celery_worker/database.py @@ -139,3 +139,35 @@ class SyncVacancyRepository: from app.models.vacancy import Vacancy return self.session.query(Vacancy).filter(Vacancy.id == vacancy_id).first() + + def create_vacancy(self, vacancy_create): + """Создать новую вакансию""" + from datetime import datetime + from app.models.vacancy import Vacancy + + # Конвертируем VacancyCreate в dict + if hasattr(vacancy_create, 'dict'): + vacancy_data = vacancy_create.dict() + elif hasattr(vacancy_create, 'model_dump'): + vacancy_data = vacancy_create.model_dump() + else: + vacancy_data = vacancy_create + + # Создаем новую вакансию + vacancy = Vacancy( + **vacancy_data, + created_at=datetime.utcnow(), + updated_at=datetime.utcnow() + ) + + self.session.add(vacancy) + self.session.flush() # Получаем ID без коммита + self.session.refresh(vacancy) # Обновляем объект из БД + + # Создаем простой объект с нужными данными для возврата + class VacancyResult: + def __init__(self, id, title): + self.id = id + self.title = title + + return VacancyResult(vacancy.id, vacancy.title) diff --git a/celery_worker/tasks.py b/celery_worker/tasks.py index 53b4b80..f6377ad 100644 --- a/celery_worker/tasks.py +++ b/celery_worker/tasks.py @@ -578,3 +578,126 @@ def generate_interview_questions_task(self, resume_id: str, job_description: str }, ) raise Exception(f"Ошибка при генерации вопросов: {str(e)}") + + +@celery_app.task(bind=True) +def parse_vacancy_task(self, file_content_base64: str, filename: str, create_vacancy: bool = False): + """ + Асинхронная задача парсинга вакансии из файла + + Args: + file_content_base64: Содержимое файла в base64 + filename: Имя файла для определения формата + create_vacancy: Создать вакансию в БД после парсинга + """ + try: + import base64 + from app.services.vacancy_parser_service import vacancy_parser_service + from app.models.vacancy import VacancyCreate + + # Обновляем статус задачи + self.update_state( + state="PENDING", + meta={"status": "Начинаем парсинг вакансии...", "progress": 10} + ) + + # Декодируем содержимое файла + file_content = base64.b64decode(file_content_base64) + + # Шаг 1: Извлечение текста из файла + self.update_state( + state="PROGRESS", + meta={"status": "Извлекаем текст из файла...", "progress": 30} + ) + + raw_text = vacancy_parser_service.extract_text_from_file(file_content, filename) + + if not raw_text.strip(): + raise ValueError("Не удалось извлечь текст из файла") + + # Шаг 2: Парсинг с помощью AI + self.update_state( + state="PROGRESS", + meta={"status": "Обрабатываем текст с помощью AI...", "progress": 70} + ) + + import asyncio + parsed_data = asyncio.run(vacancy_parser_service.parse_vacancy_with_ai(raw_text)) + + # Шаг 3: Создание вакансии (если требуется) + created_vacancy = None + print(f"create_vacancy parameter: {create_vacancy}, type: {type(create_vacancy)}") + + if create_vacancy: + self.update_state( + state="PROGRESS", + meta={"status": "Создаем вакансию в базе данных...", "progress": 90} + ) + + try: + print(f"Parsed data for vacancy creation: {parsed_data}") + vacancy_create = VacancyCreate(**parsed_data) + print(f"VacancyCreate object created successfully: {vacancy_create}") + + with get_sync_session() as session: + vacancy_repo = SyncVacancyRepository(session) + created_vacancy = vacancy_repo.create_vacancy(vacancy_create) + print(f"Vacancy created with ID: {created_vacancy.id if created_vacancy else 'None'}") + + except Exception as e: + import traceback + error_details = traceback.format_exc() + print(f"Error creating vacancy: {str(e)}") + print(f"Full traceback: {error_details}") + + # Возвращаем парсинг, но предупреждаем об ошибке создания + self.update_state( + state="SUCCESS", + meta={ + "status": f"Парсинг выполнен, но ошибка при создании вакансии: {str(e)}", + "progress": 100, + "result": parsed_data, + "warning": f"Ошибка создания вакансии: {str(e)}" + } + ) + + return { + "status": "parsed_with_warning", + "parsed_data": parsed_data, + "warning": f"Ошибка при создании вакансии: {str(e)}" + } + + # Завершено успешно + response_message = "Парсинг выполнен успешно" + if created_vacancy: + response_message += f". Вакансия создана с ID: {created_vacancy.id}" + + self.update_state( + state="SUCCESS", + meta={ + "status": response_message, + "progress": 100, + "result": parsed_data, + "vacancy_id": created_vacancy.id if created_vacancy else None + } + ) + + return { + "status": "completed", + "parsed_data": parsed_data, + "vacancy_id": created_vacancy.id if created_vacancy else None, + "message": response_message + } + + except Exception as e: + # В случае ошибки + self.update_state( + state="FAILURE", + meta={ + "status": f"Ошибка при парсинге вакансии: {str(e)}", + "progress": 0, + "error": str(e) + } + ) + + raise Exception(f"Ошибка при парсинге вакансии: {str(e)}") diff --git a/pyproject.toml b/pyproject.toml index 06fd15d..d705d25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,8 @@ dependencies = [ "comtypes>=1.4.12", "reportlab>=4.4.3", "yandex-speechkit>=1.5.0", + "pdfkit>=1.0.0", + "jinja2>=3.1.6", ] [build-system] diff --git a/templates/interview_report.html b/templates/interview_report.html new file mode 100644 index 0000000..3a9ec65 --- /dev/null +++ b/templates/interview_report.html @@ -0,0 +1,634 @@ + + + + + + Отчет по собеседованию {{ position }} | Анализ кандидата {{ candidate_name }} | HR Система + + + + + + +
+
+
+

Отчет по собеседованию {{ report_id }}

+

{{ candidate_name }}

+
+ Резюме: + {{ resume_url }} +
+
+ +
+
+
Основная информация
+
+
+ Кандидат: + Позиция: + Дата интервью: + Общий балл: + Рекомендация: +
+
+ {{ candidate_name }} + {{ position }} + {{ interview_date }} + {{ overall_score }} + +
+
+
+ +
+
Анализ кандидата
+
+
+ Сильные стороны: + Области для развития: +
+
+

{{ strengths }}

+

{{ areas_for_development }}

+
+
+
+
+
+ +
+
+

Детальная оценка

+ + {% if evaluation_criteria %} + + + + + + + + + + + {% for criterion in evaluation_criteria %} + + + + + + + {% endfor %} + +
КритерийБаллОбоснованиеРиски
{{ criterion.name }} + + {{ criterion.justification }}{{ criterion.concerns }}
+ {% endif %} +
+ + {% if red_flags %} +
+
Красные флаги:
+
+ {% for flag in red_flags %} +
+ + + +

{{ flag }}

+
+ {% endfor %} +
+
+ {% endif %} + + +
+
+ + \ No newline at end of file diff --git a/uv.lock b/uv.lock index 6f88e5b..7501730 100644 --- a/uv.lock +++ b/uv.lock @@ -995,6 +995,7 @@ dependencies = [ { name = "comtypes" }, { name = "docx2txt" }, { name = "fastapi", extra = ["standard"] }, + { name = "jinja2" }, { name = "langchain" }, { name = "langchain-community" }, { name = "langchain-core" }, @@ -1003,6 +1004,7 @@ dependencies = [ { name = "livekit" }, { name = "livekit-agents", extra = ["cartesia", "deepgram", "openai", "resemble", "silero", "turn-detector"] }, { name = "livekit-api" }, + { name = "pdfkit" }, { name = "pdfplumber" }, { name = "psycopg2-binary" }, { name = "pydantic-settings" }, @@ -1035,6 +1037,7 @@ requires-dist = [ { name = "comtypes", specifier = ">=1.4.12" }, { name = "docx2txt", specifier = ">=0.9" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.104.0" }, + { name = "jinja2", specifier = ">=3.1.6" }, { name = "langchain", specifier = ">=0.1.0" }, { name = "langchain-community", specifier = ">=0.0.10" }, { name = "langchain-core", specifier = ">=0.1.0" }, @@ -1043,6 +1046,7 @@ requires-dist = [ { name = "livekit", specifier = ">=1.0.12" }, { name = "livekit-agents", extras = ["cartesia", "deepgram", "openai", "silero", "resemble", "turn-detector"], specifier = "~=1.2" }, { name = "livekit-api", specifier = ">=1.0.5" }, + { name = "pdfkit", specifier = ">=1.0.0" }, { name = "pdfplumber", specifier = ">=0.10.0" }, { name = "psycopg2-binary", specifier = ">=2.9.0" }, { name = "pydantic-settings", specifier = ">=2.1.0" }, @@ -2343,6 +2347,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, ] +[[package]] +name = "pdfkit" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/bb/6ddc62b4622776a6514fd749041c2b4bccd343e006d00de590f8090ac8b1/pdfkit-1.0.0.tar.gz", hash = "sha256:992f821e1e18fc8a0e701ecae24b51a2d598296a180caee0a24c0af181da02a9", size = 13288 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/1b/26c080096dd93936dccfd32c682bed3d5630a84aae9d493ff68afb2ae0fb/pdfkit-1.0.0-py3-none-any.whl", hash = "sha256:a7a4ca0d978e44fa8310c4909f087052430a6e8e0b1dd7ceef657f139789f96f", size = 12099 }, +] + [[package]] name = "pdfminer-six" version = "20250506"