diff --git a/ai_interviewer_agent.py b/ai_interviewer_agent.py index 8e85b87..a291509 100644 --- a/ai_interviewer_agent.py +++ b/ai_interviewer_agent.py @@ -18,7 +18,7 @@ if os.name == "nt": # Windows from livekit.agents import Agent, AgentSession, JobContext, WorkerOptions, cli from livekit.api import DeleteRoomRequest, LiveKitAPI -from livekit.plugins import cartesia, deepgram, openai, silero +from livekit.plugins import openai, silero from app.core.database import get_session from app.repositories.interview_repository import InterviewRepository @@ -63,10 +63,10 @@ class InterviewAgent: self.last_user_response = None self.intro_done = False # Новый флаг — произнесено ли приветствие self.interview_finalized = False # Флаг завершения интервью - + # Трекинг времени интервью self.interview_start_time = None # Устанавливается при фактическом старте - self.interview_end_time = None # Устанавливается при завершении + self.interview_end_time = None # Устанавливается при завершении self.duration_minutes = interview_plan.get("interview_structure", {}).get( "duration_minutes", 10 ) @@ -123,7 +123,7 @@ class InterviewAgent: # Вычисляем текущее время интервью time_info = self.get_time_info() elapsed_minutes = time_info["elapsed_minutes"] - remaining_minutes = time_info["remaining_minutes"] + remaining_minutes = time_info["remaining_minutes"] time_percentage = time_info["time_percentage"] # Формируем план интервью для агента @@ -154,40 +154,41 @@ class InterviewAgent: if self.vacancy_data: employment_type_map = { "full": "Полная занятость", - "part": "Частичная занятость", + "part": "Частичная занятость", "project": "Проектная работа", "volunteer": "Волонтёрство", - "probation": "Стажировка" + "probation": "Стажировка", } experience_map = { "noExperience": "Без опыта", "between1And3": "1-3 года", - "between3And6": "3-6 лет", - "moreThan6": "Более 6 лет" + "between3And6": "3-6 лет", + "moreThan6": "Более 6 лет", } schedule_map = { "fullDay": "Полный день", "shift": "Сменный график", "flexible": "Гибкий график", "remote": "Удалённая работа", - "flyInFlyOut": "Вахтовый метод" + "flyInFlyOut": "Вахтовый метод", } - + vacancy_info = f""" ИНФОРМАЦИЯ О ВАКАНСИИ: -- Должность: {self.vacancy_data.get('title', 'Не указана')} -- Описание: {self.vacancy_data.get('description', 'Не указано')} -- Ключевые навыки: {self.vacancy_data.get('key_skills') or 'Не указаны'} -- Тип занятости: {employment_type_map.get(self.vacancy_data.get('employment_type'), self.vacancy_data.get('employment_type', 'Не указан'))} -- Опыт работы: {experience_map.get(self.vacancy_data.get('experience'), self.vacancy_data.get('experience', 'Не указан'))} -- График работы: {schedule_map.get(self.vacancy_data.get('schedule'), self.vacancy_data.get('schedule', 'Не указан'))} -- Регион: {self.vacancy_data.get('area_name', 'Не указан')} -- Профессиональные роли: {self.vacancy_data.get('professional_roles') or 'Не указаны'} -- Контактное лицо: {self.vacancy_data.get('contacts_name') or 'Не указано'}""" +- Должность: {self.vacancy_data.get("title", "Не указана")} +- Описание: {self.vacancy_data.get("description", "Не указано")} +- Ключевые навыки: {self.vacancy_data.get("key_skills") or "Не указаны"} +- Тип занятости: {employment_type_map.get(self.vacancy_data.get("employment_type"), self.vacancy_data.get("employment_type", "Не указан"))} +- Опыт работы: {experience_map.get(self.vacancy_data.get("experience"), self.vacancy_data.get("experience", "Не указан"))} +- График работы: {schedule_map.get(self.vacancy_data.get("schedule"), self.vacancy_data.get("schedule", "Не указан"))} +- Регион: {self.vacancy_data.get("area_name", "Не указан")} +- Профессиональные роли: {self.vacancy_data.get("professional_roles") or "Не указаны"} +- Контактное лицо: {self.vacancy_data.get("contacts_name") or "Не указано"}""" return f""" -Ты опытный HR-интервьюер Стефани, который проводит адаптивное голосовое собеседование. Представься контактным именем из вакансии (если оно есть) +Ты опытный HR-интервьюер Стефани, который проводит адаптивное голосовое собеседование. Представься как Стефани +Разговаривай только на русском языке. ИНФОРМАЦИЯ О ВАКАНСИИ: @@ -197,6 +198,7 @@ class InterviewAgent: - Имя: {candidate_name} - Опыт работы: {candidate_years} лет - Ключевые навыки: {candidate_skills} +Из имени определи пол и упоминай кандидата исходя из пола ЦЕЛЬ ИНТЕРВЬЮ: @@ -213,7 +215,7 @@ class InterviewAgent: - Способность учиться и адаптироваться. - Совпадение ценностей и принципов с командой и компанией. -ПЛАН ИНТЕРВЬЮ (как руководство, адаптируйся по ситуации) +ПЛАН ИНТЕРВЬЮ (имей его ввиду, но адаптируйся под ситуацию: либо углубиться в детали, либо перейти к следующему вопросу) {sections_info} @@ -227,9 +229,8 @@ class InterviewAgent: Проблемные / кейсы (20%) — проверить мышление и подход к решению. Пример: "У нас есть система, которая падает раз в неделю. Как бы ты подошёл к диагностике проблемы?" -Задавай вопросы кратко и понятно. Не вываливай кучу информации на человека. +Задавай вопросы кратко и понятно (максимум тремя предложениями). Не вываливай кучу информации на кандидата. Не перечисляй человеку все пункты и вопросы из секции. Предлагай один общий вопрос или задавай уточняющие по по очереди. -Ты должна спрашивать вопросы максимум в 3 предложения ВРЕМЯ ИНТЕРВЬЮ: - Запланированная длительность: {self.duration_minutes} минут @@ -251,7 +252,7 @@ class InterviewAgent: ИНСТРУКЦИИ: 1. Начни с приветствия: {greeting} 2. Адаптируй вопросы под ответы кандидата -3. Не повторяй то, что клиент тебе сказал, лучше показывай, что понял, услышал и иди дальше. Лишний раз его не хвали +3. Не повторяй то, что клиент тебе сказал, лучше показывай, что поняла, услышала, и иди дальше. Лишний раз его не хвали 3. Следи за временем - при превышении 80% времени начинай завершать интервью 4. Оценивай качество и глубину ответов кандидата 5. Если получаешь сообщение "[СИСТЕМА] Клиент молчит..." - это означает проблемы со связью или кандидат растерялся. Скажи что-то вроде "Приём! Ты меня слышишь?" или "Всё в порядке? Связь не пропала?" @@ -282,7 +283,6 @@ class InterviewAgent: def get_time_info(self) -> dict[str, float]: """Получает информацию о времени интервью""" - import time if self.interview_start_time is None: # Интервью еще не началось @@ -294,7 +294,9 @@ class InterviewAgent: current_time = self.interview_end_time or time.time() elapsed_minutes = (current_time - self.interview_start_time) / 60 remaining_minutes = max(0.0, self.duration_minutes - elapsed_minutes) - time_percentage = min(100.0, (elapsed_minutes / self.duration_minutes) * 100) + time_percentage = min( + 100.0, (elapsed_minutes / self.duration_minutes) * 100 + ) return { "elapsed_minutes": elapsed_minutes, @@ -366,7 +368,9 @@ async def entrypoint(ctx: JobContext): session_id = metadata.get("session_id", session_id) logger.info(f"[INIT] Loaded interview plan for session {session_id}") if vacancy_data: - logger.info(f"[INIT] Loaded vacancy data from metadata: {vacancy_data.get('title', 'Unknown')}") + logger.info( + f"[INIT] Loaded vacancy data from metadata: {vacancy_data.get('title', 'Unknown')}" + ) except Exception as e: logger.warning(f"[INIT] Failed to load metadata: {str(e)}") interview_plan = {} @@ -409,42 +413,24 @@ async def entrypoint(ctx: JobContext): ) # STT - stt = ( - openai.STT( - model="whisper-1", language="ru", api_key=settings.openai_api_key - ) - if settings.openai_api_key - else openai.STT( - model="whisper-1", language="ru", api_key=settings.openai_api_key - ) - ) + stt = openai.STT(model="whisper-1", language="ru", api_key=settings.openai_api_key) # LLM - llm = openai.LLM( - model="gpt-5-mini", api_key=settings.openai_api_key - ) + llm = openai.LLM(model="gpt-5-mini", api_key=settings.openai_api_key) # TTS - tts = ( - openai.TTS( - model="tts-1-hd", - api_key=settings.openai_api_key, - voice='nova' - ) - if settings.openai_api_key - else silero.TTS(language="ru", model="v4_ru") - ) + tts = openai.TTS(model="tts-1-hd", api_key=settings.openai_api_key, voice="nova") # Создаем обычный Agent и Session agent = Agent(instructions=interviewer.get_system_instructions()) # Создаем AgentSession с обычным TTS и детекцией неактивности пользователя session = AgentSession( - vad=silero.VAD.load(), - stt=stt, - llm=llm, + vad=silero.VAD.load(), + stt=stt, + llm=llm, tts=tts, - user_away_timeout=7.0 # 7 секунд неактивности для срабатывания away + user_away_timeout=7.0, # 7 секунд неактивности для срабатывания away ) # --- Сохранение диалога в БД --- @@ -480,18 +466,23 @@ async def entrypoint(ctx: JobContext): return interviewer_instance.interview_finalized = True - + # Устанавливаем время завершения интервью - import time + interviewer_instance.interview_end_time = time.time() - + if interviewer_instance.interview_start_time: - total_minutes = (interviewer_instance.interview_end_time - interviewer_instance.interview_start_time) / 60 + total_minutes = ( + interviewer_instance.interview_end_time + - interviewer_instance.interview_start_time + ) / 60 logger.info( f"[TIME] Interview ended at {time.strftime('%H:%M:%S')}, total duration: {total_minutes:.1f} min" ) else: - logger.info(f"[TIME] Interview ended at {time.strftime('%H:%M:%S')} (no start time recorded)") + logger.info( + f"[TIME] Interview ended at {time.strftime('%H:%M:%S')} (no start time recorded)" + ) try: logger.info( @@ -553,9 +544,7 @@ async def entrypoint(ctx: JobContext): ) if not interviewer.interview_finalized: - await complete_interview_sequence( - ctx.room.name, interviewer - ) + await complete_interview_sequence(ctx.room.name, interviewer) break return False @@ -595,7 +584,7 @@ async def entrypoint(ctx: JobContext): f"[TIME_LIMIT] Interview exceeded {TIME_LIMIT_MINUTES} minutes " f"({time_info['elapsed_minutes']:.1f} min), forcing completion" ) - + if not interviewer.interview_finalized: await complete_interview_sequence( ctx.room.name, interviewer @@ -610,18 +599,19 @@ async def entrypoint(ctx: JobContext): # Запускаем мониторинг команд в фоне asyncio.create_task(monitor_end_commands()) - + @session.on("user_state_changed") def on_user_state_changed(event): """Обработчик изменения состояния пользователя (активен/неактивен)""" - - async def on_change(): + async def on_change(): logger.info(f"[USER_STATE] User state changed to: {event.new_state}") # === Пользователь молчит более 10 секунд (state == away) === if event.new_state == "away" and interviewer.intro_done: - logger.info("[USER_STATE] User away detected, sending check-in message...") + logger.info( + "[USER_STATE] User away detected, sending check-in message..." + ) # сообщение — проверка связи await session.generate_reply( @@ -673,21 +663,18 @@ async def entrypoint(ctx: JobContext): "room_name": room_name, "timestamp": datetime.now(UTC).isoformat(), } - + with open(command_file, "w", encoding="utf-8") as f: json.dump(release_command, f, ensure_ascii=False, indent=2) - + logger.info(f"[SEQUENCE] Step 3: Session {session_id} release signal sent") - + except Exception as e: logger.error(f"[SEQUENCE] Step 3: Failed to send release signal: {str(e)}") logger.info("[SEQUENCE] Step 3: Continuing without release signal") - - # --- Упрощенная логика обработки пользовательского ответа --- async def handle_user_input(user_response: str): - current_section = interviewer.get_current_section() # Сохраняем ответ пользователя @@ -707,6 +694,7 @@ async def entrypoint(ctx: JobContext): interviewer.intro_done = True # Устанавливаем время начала интервью при первом сообщении import time + interviewer.interview_start_time = time.time() logger.info( f"[TIME] Interview started at {time.strftime('%H:%M:%S')}, duration: {interviewer.duration_minutes} min" @@ -734,7 +722,6 @@ async def entrypoint(ctx: JobContext): if role == "user": asyncio.create_task(handle_user_input(text)) elif role == "assistant": - # Сохраняем ответ агента в историю диалога current_section = interviewer.get_current_section() interviewer.conversation_history.append( diff --git a/app/core/s3.py b/app/core/s3.py index 7dbb43e..435539f 100644 --- a/app/core/s3.py +++ b/app/core/s3.py @@ -18,7 +18,11 @@ class S3Service: self.bucket_name = settings.s3_bucket_name async def upload_file( - self, file_content: bytes, file_name: str, content_type: str, public: bool = False + self, + file_content: bytes, + file_name: str, + content_type: str, + public: bool = False, ) -> str | None: try: file_key = f"{uuid.uuid4()}_{file_name}" @@ -29,13 +33,15 @@ class S3Service: "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"https://d8d88bee-afd2-4266-8332-538389e25f52.selstorage.ru/{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 908497f..9ec6046 100644 --- a/app/models/interview.py +++ b/app/models/interview.py @@ -38,12 +38,17 @@ class InterviewSession(InterviewSessionBase, table=True): id: int | None = Field(default=None, primary_key=True) started_at: datetime = Field(default_factory=datetime.utcnow) completed_at: datetime | None = None + interview_start_time: datetime | None = None + interview_end_time: datetime | None = None # Связь с отчетом (один к одному) report: Optional["InterviewReport"] = Relationship( back_populates="interview_session" ) + # Связь с резюме + resume: Optional["Resume"] = Relationship() + class InterviewSessionCreate(SQLModel): resume_id: int diff --git a/app/repositories/interview_reports_repository.py b/app/repositories/interview_reports_repository.py index 57a63b6..b89ad08 100644 --- a/app/repositories/interview_reports_repository.py +++ b/app/repositories/interview_reports_repository.py @@ -6,8 +6,8 @@ from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_session -from app.models.interview_report import InterviewReport from app.models.interview import InterviewSession +from app.models.interview_report import InterviewReport from app.models.resume import Resume from app.models.vacancy import Vacancy from app.repositories.base_repository import BaseRepository @@ -64,7 +64,10 @@ class InterviewReportRepository(BaseRepository[InterviewReport]): """Получить все отчёты по вакансии""" statement = ( select(InterviewReport) - .join(InterviewSession, InterviewSession.id == InterviewReport.interview_session_id) + .join( + InterviewSession, + InterviewSession.id == InterviewReport.interview_session_id, + ) .join(Resume, Resume.id == InterviewSession.resume_id) .join(Vacancy, Vacancy.id == Resume.vacancy_id) .where(Vacancy.id == vacancy_id) diff --git a/app/routers/admin_router.py b/app/routers/admin_router.py index 036bc7b..37852b8 100644 --- a/app/routers/admin_router.py +++ b/app/routers/admin_router.py @@ -1,5 +1,4 @@ import json -import os from datetime import UTC, datetime from fastapi import APIRouter, Depends, HTTPException @@ -128,42 +127,41 @@ async def force_end_interview(session_id: int) -> dict: try: # Получаем статус агента agent_status = agent_manager.get_status() - + if agent_status["status"] != "active": raise HTTPException( - status_code=400, - detail=f"Agent is not active, current status: {agent_status['status']}" + status_code=400, + detail=f"Agent is not active, current status: {agent_status['status']}", ) - + if agent_status["session_id"] != session_id: raise HTTPException( status_code=400, - detail=f"Agent is not handling session {session_id}, current session: {agent_status['session_id']}" + detail=f"Agent is not handling session {session_id}, current session: {agent_status['session_id']}", ) - + # Записываем команду завершения в файл команд command_file = "agent_commands.json" end_command = { "action": "end_session", "session_id": session_id, "timestamp": datetime.now(UTC).isoformat(), - "initiated_by": "admin_api" + "initiated_by": "admin_api", } - + with open(command_file, "w", encoding="utf-8") as f: json.dump(end_command, f, ensure_ascii=False, indent=2) - + return { "success": True, "message": f"Force end command sent for session {session_id}", "session_id": session_id, - "command_file": command_file + "command_file": command_file, } - + except HTTPException: raise except Exception as e: raise HTTPException( - status_code=500, - detail=f"Failed to send force end command: {str(e)}" + status_code=500, detail=f"Failed to send force end command: {str(e)}" ) diff --git a/app/routers/analysis_router.py b/app/routers/analysis_router.py index ec5a55e..b6f82e5 100644 --- a/app/routers/analysis_router.py +++ b/app/routers/analysis_router.py @@ -4,7 +4,6 @@ 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 PDFReportService from celery_worker.interview_analysis_task import ( analyze_multiple_candidates, generate_interview_report, @@ -300,23 +299,23 @@ async def get_pdf_report( return RedirectResponse(url=report.pdf_report_url, status_code=302) -@router.post("/generate-pdf/{resume_id}", response_model=PDFGenerationResponse) +@router.post("/generate-pdf/{resume_id}") async def generate_pdf_report( resume_id: int, session=Depends(get_session), resume_repo: ResumeRepository = Depends(ResumeRepository), - pdf_report_service: PDFReportService = Depends(PDFReportService), ): """ - Генерирует PDF отчет по интервью - - Проверяет наличие отчета в базе данных и генерирует PDF файл. + Запускает асинхронную генерацию PDF отчета по интервью + + Проверяет наличие отчета в базе данных и запускает Celery задачу для генерации PDF файла. Если PDF уже существует, возвращает существующий URL. """ from sqlmodel import select from app.models.interview import InterviewSession from app.models.interview_report import InterviewReport + from celery_worker.tasks import generate_pdf_report_task # Проверяем, существует ли резюме resume = await resume_repo.get_by_id(resume_id) @@ -346,57 +345,132 @@ async def generate_pdf_report( # Если 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", - ) + return { + "message": "PDF report already exists", + "resume_id": resume_id, + "report_id": report.id, + "candidate_name": resume.applicant_name, + "pdf_url": report.pdf_report_url, + "status": "exists", + } + + # Получаем позицию из связанной вакансии + 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 "Позиция не указана" + + # Сериализуем данные отчета + report_data = { + "id": report.id, + "interview_session_id": report.interview_session_id, + "technical_skills_score": report.technical_skills_score, + "technical_skills_justification": report.technical_skills_justification, + "technical_skills_concerns": report.technical_skills_concerns, + "experience_relevance_score": report.experience_relevance_score, + "experience_relevance_justification": report.experience_relevance_justification, + "experience_relevance_concerns": report.experience_relevance_concerns, + "communication_score": report.communication_score, + "communication_justification": report.communication_justification, + "communication_concerns": report.communication_concerns, + "problem_solving_score": report.problem_solving_score, + "problem_solving_justification": report.problem_solving_justification, + "problem_solving_concerns": report.problem_solving_concerns, + "cultural_fit_score": report.cultural_fit_score, + "cultural_fit_justification": report.cultural_fit_justification, + "cultural_fit_concerns": report.cultural_fit_concerns, + "overall_score": report.overall_score, + "recommendation": report.recommendation, + "strengths": report.strengths, + "weaknesses": report.weaknesses, + "red_flags": report.red_flags, + "questions_quality_score": report.questions_quality_score, + "interview_duration_minutes": report.interview_duration_minutes, + "response_count": report.response_count, + "dialogue_messages_count": report.dialogue_messages_count, + "next_steps": report.next_steps, + "interviewer_notes": report.interviewer_notes, + "questions_analysis": report.questions_analysis, + "analysis_method": report.analysis_method, + "llm_model_used": report.llm_model_used, + "analysis_duration_seconds": report.analysis_duration_seconds, + "pdf_report_url": report.pdf_report_url, + "created_at": report.created_at.isoformat() if report.created_at else None, + "updated_at": report.updated_at.isoformat() if report.updated_at else None, + } + + # Запускаем Celery задачу для генерации PDF + task = generate_pdf_report_task.delay( + report_data=report_data, + candidate_name=resume.applicant_name, + position=position, + resume_file_url=resume.resume_file_url, + ) + + return { + "message": "PDF generation started", + "resume_id": resume_id, + "report_id": report.id, + "candidate_name": resume.applicant_name, + "task_id": task.id, + "status": "in_progress", + } + + +@router.get("/pdf-task-status/{task_id}") +async def get_pdf_task_status(task_id: str): + """ + Получить статус выполнения Celery задачи генерации PDF + """ + from celery_worker.celery_app import celery_app - # Генерируем 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 - ) + task_result = celery_app.AsyncResult(task_id) - 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", - ) + if task_result.state == "PENDING": + return { + "task_id": task_id, + "status": "pending", + "message": "Task is waiting to be processed", + } + elif task_result.state == "PROGRESS": + return { + "task_id": task_id, + "status": "in_progress", + "progress": task_result.info.get("progress", 0), + "message": task_result.info.get("status", "Processing..."), + } + elif task_result.state == "SUCCESS": + result = task_result.result + return { + "task_id": task_id, + "status": "completed", + "progress": 100, + "message": "PDF generation completed successfully", + "pdf_url": result.get("pdf_url"), + "file_size": result.get("file_size"), + "report_id": result.get("interview_report_id"), + } + elif task_result.state == "FAILURE": + return { + "task_id": task_id, + "status": "failed", + "message": str(task_result.info), + "error": str(task_result.info), + } + else: + return { + "task_id": task_id, + "status": task_result.state.lower(), + "message": f"Task state: {task_result.state}", + } except Exception as e: raise HTTPException( - status_code=500, detail=f"Error generating PDF report: {str(e)}" + status_code=500, detail=f"Error checking task status: {str(e)}" ) @@ -439,11 +513,11 @@ async def get_report_data( # Получаем позицию из связанной вакансии 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 { diff --git a/app/routers/interview_reports_router.py b/app/routers/interview_reports_router.py index 15c6405..e43c0ae 100644 --- a/app/routers/interview_reports_router.py +++ b/app/routers/interview_reports_router.py @@ -1,15 +1,15 @@ -from fastapi import APIRouter, Depends, HTTPException, Request -from typing import List + +from fastapi import APIRouter, Depends, HTTPException from app.core.session_middleware import get_current_session -from app.models.session import Session from app.models.interview_report import InterviewReport +from app.models.session import Session from app.services.interview_reports_service import InterviewReportService router = APIRouter(prefix="/interview-reports", tags=["interview-reports"]) -@router.get("/vacancy/{vacancy_id}", response_model=List[InterviewReport]) +@router.get("/vacancy/{vacancy_id}", response_model=list[InterviewReport]) async def get_reports_by_vacancy( vacancy_id: int, current_session: Session = Depends(get_current_session), diff --git a/app/routers/vacancy_router.py b/app/routers/vacancy_router.py index 97b8c76..5128b9e 100644 --- a/app/routers/vacancy_router.py +++ b/app/routers/vacancy_router.py @@ -2,14 +2,15 @@ from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Upload 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 +from app.services.vacancy_service import VacancyService router = APIRouter(prefix="/vacancies", tags=["vacancies"]) class VacancyParseResponse(BaseModel): """Ответ на запрос парсинга вакансии""" + message: str parsed_data: dict | None = None task_id: str | None = None @@ -97,43 +98,49 @@ async def parse_vacancy_from_file( ): """ Парсинг вакансии из загруженного файла (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'] - + + 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)}" + 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)") - + raise HTTPException( + status_code=400, detail="Файл слишком большой (максимум 10MB)" + ) + try: # Извлекаем текст из файла - raw_text = vacancy_parser_service.extract_text_from_file(file_content, file.filename) - + raw_text = vacancy_parser_service.extract_text_from_file( + file_content, file.filename + ) + if not raw_text.strip(): - raise HTTPException(status_code=400, detail="Не удалось извлечь текст из файла") - + 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: @@ -144,22 +151,21 @@ async def parse_vacancy_from_file( # Возвращаем парсинг, но предупреждаем об ошибке создания return VacancyParseResponse( message=f"Парсинг выполнен, но ошибка при создании вакансии: {str(e)}", - parsed_data=parsed_data + 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 - ) - + + 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)}") + raise HTTPException( + status_code=500, detail=f"Ошибка при парсинге вакансии: {str(e)}" + ) @router.post("/parse-text", response_model=VacancyParseResponse) @@ -170,25 +176,29 @@ async def parse_vacancy_from_text( ): """ Парсинг вакансии из текста - + Args: text: Текст вакансии create_vacancy: Создать вакансию в БД после парсинга - + Returns: VacancyParseResponse: Результат парсинга """ - + if not text.strip(): - raise HTTPException(status_code=400, detail="Текст вакансии не может быть пустым") - + raise HTTPException( + status_code=400, detail="Текст вакансии не может быть пустым" + ) + if len(text) > 50000: # Ограничение на длину текста - raise HTTPException(status_code=400, detail="Текст слишком длинный (максимум 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: @@ -198,61 +208,48 @@ async def parse_vacancy_from_text( except Exception as e: return VacancyParseResponse( message=f"Парсинг выполнен, но ошибка при создании вакансии: {str(e)}", - parsed_data=parsed_data + 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 - ) - + + 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)}") + 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": "pdf", - "description": "PDF документы", - "max_size_mb": 10 - }, - { - "extension": "docx", + "extension": "docx", "description": "Microsoft Word документы", - "max_size_mb": 10 + "max_size_mb": 10, }, - { - "extension": "rtf", - "description": "Rich Text Format", - "max_size_mb": 10 - }, - { - "extension": "txt", - "description": "Текстовые файлы", - "max_size_mb": 10 - } + {"extension": "rtf", "description": "Rich Text Format", "max_size_mb": 10}, + {"extension": "txt", "description": "Текстовые файлы", "max_size_mb": 10}, ], "features": [ "Автоматическое извлечение текста из файлов", "AI-парсинг структурированной информации", "Создание вакансии в базе данных", - "Валидация данных" - ] + "Валидация данных", + ], } @@ -263,108 +260,115 @@ async def parse_vacancy_from_file_async( ): """ Асинхронный парсинг вакансии из загруженного файла (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'] - + + 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)}" + 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)") - + raise HTTPException( + status_code=400, detail="Файл слишком большой (максимум 10MB)" + ) + try: # Кодируем содержимое файла в base64 для передачи в Celery - file_content_base64 = base64.b64encode(file_content).decode('utf-8') - + file_content_base64 = base64.b64encode(file_content).decode("utf-8") + # Конвертируем строку в boolean - create_vacancy_bool = create_vacancy.lower() in ('true', '1', 'yes', 'on') - + 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 + create_vacancy=create_vacancy_bool, ) - + return { "message": "Задача парсинга запущена", "task_id": task.id, "status": "pending", - "check_status_url": f"/api/v1/vacancies/parse-status/{task.id}" + "check_status_url": f"/api/v1/vacancies/parse-status/{task.id}", } - + except Exception as e: - raise HTTPException(status_code=500, detail=f"Ошибка при запуске парсинга: {str(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': + + if task.state == "PENDING": response = { - 'task_id': task_id, - 'state': task.state, - 'status': 'Задача ожидает выполнения...', - 'progress': 0 + "task_id": task_id, + "state": task.state, + "status": "Задача ожидает выполнения...", + "progress": 0, } - elif task.state == 'PROGRESS': + elif task.state == "PROGRESS": response = { - 'task_id': task_id, - 'state': task.state, - 'status': task.info.get('status', ''), - 'progress': task.info.get('progress', 0) + "task_id": task_id, + "state": task.state, + "status": task.info.get("status", ""), + "progress": task.info.get("progress", 0), } - elif task.state == 'SUCCESS': + elif task.state == "SUCCESS": response = { - 'task_id': task_id, - 'state': task.state, - 'status': 'completed', - 'progress': 100, - 'result': task.result + "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) + "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)}") + raise HTTPException( + status_code=500, detail=f"Ошибка при получении статуса задачи: {str(e)}" + ) diff --git a/app/services/agent_manager.py b/app/services/agent_manager.py index b193a63..471bb1c 100644 --- a/app/services/agent_manager.py +++ b/app/services/agent_manager.py @@ -96,11 +96,13 @@ class AgentManager: ) logger.info(f"AI Agent started with PID {process.pid}") - + # Запускаем мониторинг команд if not self._monitoring_task: - self._monitoring_task = asyncio.create_task(self._monitor_commands()) - + self._monitoring_task = asyncio.create_task( + self._monitor_commands() + ) + return True except Exception as e: @@ -132,12 +134,12 @@ class AgentManager: logger.info(f"AI Agent with PID {self._agent_process.pid} stopped") self._agent_process = None - + # Останавливаем мониторинг команд if self._monitoring_task: self._monitoring_task.cancel() self._monitoring_task = None - + return True except Exception as e: @@ -259,7 +261,9 @@ class AgentManager: """Обрабатывает сигнал о завершении сессии от агента""" async with self._lock: if not self._agent_process: - logger.warning(f"No agent process to handle session_completed for {session_id}") + logger.warning( + f"No agent process to handle session_completed for {session_id}" + ) return False if self._agent_process.session_id != session_id: @@ -281,7 +285,9 @@ class AgentManager: self._agent_process.room_name = None self._agent_process.status = "idle" - logger.info(f"Agent automatically released from session {old_session_id}") + logger.info( + f"Agent automatically released from session {old_session_id}" + ) return True except Exception as e: @@ -346,36 +352,43 @@ class AgentManager: """Мониторит файл команд для обработки сигналов от агента""" command_file = "agent_commands.json" last_processed_timestamp = None - + logger.info("[MONITOR] Starting command monitoring") - + try: while True: try: if os.path.exists(command_file): - with open(command_file, "r", encoding="utf-8") as f: + with open(command_file, encoding="utf-8") as f: command = json.load(f) - + # Проверяем timestamp чтобы избежать повторной обработки command_timestamp = command.get("timestamp") - if command_timestamp and command_timestamp != last_processed_timestamp: + if ( + command_timestamp + and command_timestamp != last_processed_timestamp + ): action = command.get("action") - + if action == "session_completed": session_id = command.get("session_id") room_name = command.get("room_name") - - logger.info(f"[MONITOR] Processing session_completed for {session_id}") - await self.handle_session_completed(session_id, room_name) - + + logger.info( + f"[MONITOR] Processing session_completed for {session_id}" + ) + await self.handle_session_completed( + session_id, room_name + ) + last_processed_timestamp = command_timestamp - + await asyncio.sleep(2) # Проверяем каждые 2 секунды - + except Exception as e: logger.error(f"[MONITOR] Error processing command: {e}") await asyncio.sleep(5) # Больший интервал при ошибке - + except asyncio.CancelledError: logger.info("[MONITOR] Command monitoring stopped") except Exception as e: diff --git a/app/services/interview_reports_service.py b/app/services/interview_reports_service.py index 0eea84c..dba344d 100644 --- a/app/services/interview_reports_service.py +++ b/app/services/interview_reports_service.py @@ -10,7 +10,9 @@ from app.repositories.interview_reports_repository import InterviewReportReposit class InterviewReportService: def __init__( self, - report_repo: Annotated[InterviewReportRepository, Depends(InterviewReportRepository)], + report_repo: Annotated[ + InterviewReportRepository, Depends(InterviewReportRepository) + ], ): self.report_repo = report_repo @@ -22,9 +24,7 @@ class InterviewReportService: """Получить все отчёты по вакансии""" return await self.report_repo.get_by_vacancy_id(vacancy_id) - async def update_report_scores( - self, report_id: int, scores: dict - ) -> bool: + async def update_report_scores(self, report_id: int, scores: dict) -> bool: """ Обновить оценки отчёта. Пример scores: diff --git a/app/services/pdf_report_service.py b/app/services/pdf_report_service.py index 95ff537..068133f 100644 --- a/app/services/pdf_report_service.py +++ b/app/services/pdf_report_service.py @@ -1,9 +1,13 @@ import io import os +import shutil +import tempfile from datetime import datetime +from urllib.parse import quote +import requests from jinja2 import Template -import pdfkit +from playwright.async_api import async_playwright from app.core.s3 import s3_service from app.models.interview_report import InterviewReport, RecommendationType @@ -14,249 +18,493 @@ class PDFReportService: 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""" + + """ + return font_css + def _load_html_template(self) -> str: """Загружает HTML шаблон из файла""" try: - with open(self.template_path, 'r', encoding='utf-8') as file: + with open(self.template_path, 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(concerns, list): return "; ".join(concerns) elif isinstance(concerns, str): return concerns else: return str(concerns) - + + def _format_list_field(self, field_value) -> str: + """Форматирует поле со списком для отображения""" + if not field_value: + return "Не указаны" + + if isinstance(field_value, list): + return "\n".join([f"• {item}" for item in field_value]) + elif isinstance(field_value, str): + return field_value + else: + return str(field_value) + def _get_score_class(self, score: int) -> str: """Возвращает CSS класс для цвета оценки""" - if score >= 80: - return "score-green" + if score >= 90: + return "score-green" # STRONGLY_RECOMMEND + elif score >= 75: + return "score-light-green" # RECOMMEND elif score >= 60: - return "score-orange" + return "score-orange" # CONSIDER else: - return "score-red" - + return "score-red" # REJECT + def _format_recommendation(self, recommendation: RecommendationType) -> tuple: """Форматирует рекомендацию для отображения""" - if recommendation == RecommendationType.HIRE: + if recommendation == RecommendationType.STRONGLY_RECOMMEND: + return ("Настоятельно рекомендуем", "recommend-button") + elif recommendation == RecommendationType.RECOMMEND: return ("Рекомендуем", "recommend-button") elif recommendation == RecommendationType.CONSIDER: return ("К рассмотрению", "consider-button") - else: + else: # REJECT return ("Не рекомендуем", "reject-button") - - def generate_pdf_report(self, interview_report: InterviewReport) -> bytes: + + 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( + self, + interview_report: InterviewReport, + candidate_name: str = None, + position: str = None, + resume_file_url: str = None, + ) -> bytes: """ Генерирует PDF отчет на основе HTML шаблона - + Args: interview_report: Данные отчета по интервью - + Returns: bytes: PDF файл в виде байтов """ try: # Загружаем HTML шаблон html_template = self._load_html_template() - + # Подготавливаем данные для шаблона - template_data = self._prepare_template_data(interview_report) - + template_data = self._prepare_template_data( + interview_report, + candidate_name or "Не указано", + position or "Не указана", + resume_file_url, + ) + # Рендерим 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) - + + # Получаем CSS с проверенными шрифтами + font_css = self._get_font_css() + + # Вставляем стили + if "" in rendered_html: + rendered_html = rendered_html.replace("", f"{font_css}") + else: + rendered_html = font_css + rendered_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 - + except Exception as e: raise Exception(f"Ошибка при генерации PDF: {str(e)}") - - def _prepare_template_data(self, interview_report: InterviewReport) -> dict: + + def _prepare_template_data( + self, + interview_report: InterviewReport, + candidate_name: str, + position: str, + resume_file_url: str = None, + ) -> 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 - + + # Используем переданные параметры как в старой версии + resume_url = resume_file_url # Пока оставим заглушку для ссылки на резюме + # Форматируем дату интервью 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") - + 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) - }) - - # Красные флаги - 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 "#" - + recommendation_text, recommendation_class = self._format_recommendation( + interview_report.recommendation + ) + + # Сильные стороны и области развития (используем правильные поля модели) + strengths = ( + self._format_list_field(interview_report.strengths) + if interview_report.strengths + else "Не указаны" + ) + areas_for_development = ( + self._format_list_field(interview_report.weaknesses) + if interview_report.weaknesses + else "Не указаны" + ) + + # Детальная оценка - всегда все критерии, как в старой версии + evaluation_criteria = [ + { + "name": "Технические навыки", + "score": interview_report.technical_skills_score or 0, + "score_class": self._get_score_class( + interview_report.technical_skills_score or 0 + ), + "justification": interview_report.technical_skills_justification or "—", + "concerns": self._format_concerns_field( + interview_report.technical_skills_concerns + ), + }, + { + "name": "Релевантность опыта", + "score": interview_report.experience_relevance_score or 0, + "score_class": self._get_score_class( + interview_report.experience_relevance_score or 0 + ), + "justification": interview_report.experience_relevance_justification + or "—", + "concerns": self._format_concerns_field( + interview_report.experience_relevance_concerns + ), + }, + { + "name": "Коммуникация", + "score": interview_report.communication_score or 0, + "score_class": self._get_score_class( + interview_report.communication_score or 0 + ), + "justification": interview_report.communication_justification or "—", + "concerns": self._format_concerns_field( + interview_report.communication_concerns + ), + }, + { + "name": "Решение задач", + "score": interview_report.problem_solving_score or 0, + "score_class": self._get_score_class( + interview_report.problem_solving_score or 0 + ), + "justification": interview_report.problem_solving_justification or "—", + "concerns": self._format_concerns_field( + interview_report.problem_solving_concerns + ), + }, + { + "name": "Культурное соответствие", + "score": interview_report.cultural_fit_score or 0, + "score_class": self._get_score_class( + interview_report.cultural_fit_score or 0 + ), + "justification": interview_report.cultural_fit_justification or "—", + "concerns": self._format_concerns_field( + interview_report.cultural_fit_concerns + ), + }, + ] + + # Красные флаги - используем поле модели напрямую + red_flags = interview_report.red_flags or [] + + # Ссылка на резюме (уже определена выше) + # 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 + "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, } async def upload_pdf_to_s3(self, pdf_bytes: bytes, filename: str) -> str: """ Загружает PDF файл в S3 и возвращает публичную ссылку - + Args: pdf_bytes: PDF файл в виде байтов filename: Имя файла - + Returns: 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 + 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: + 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 = 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() + 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)}") diff --git a/app/services/vacancy_parser_service.py b/app/services/vacancy_parser_service.py index 2d173de..cea9c55 100644 --- a/app/services/vacancy_parser_service.py +++ b/app/services/vacancy_parser_service.py @@ -2,7 +2,7 @@ import io import json import logging from pathlib import Path -from typing import Any, Dict +from typing import Any logger = logging.getLogger(__name__) @@ -11,33 +11,33 @@ class VacancyParserService: """Сервис для парсинга вакансий из файлов различных форматов""" def __init__(self): - self.supported_formats = ['.pdf', '.docx', '.rtf', '.txt'] + 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': + if file_extension == ".pdf": return self._extract_from_pdf(file_content) - elif file_extension == '.docx': + elif file_extension == ".docx": return self._extract_from_docx(file_content) - elif file_extension == '.rtf': + elif file_extension == ".rtf": return self._extract_from_rtf(file_content) - elif file_extension == '.txt': + 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 @@ -46,126 +46,132 @@ class VacancyParserService: """Извлекает текст из 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") + 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") + 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') + + 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: + except Exception: # Альтернативный метод через pyth try: - from pyth.plugins.rtf15.reader import Rtf15Reader from pyth.plugins.plaintext.writer import PlaintextWriter - + from pyth.plugins.rtf15.reader import Rtf15Reader + 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") + 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'] - + 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') + 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]: + 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""" Проанализируй текст вакансии и извлеки из него структурированную информацию. @@ -217,83 +223,86 @@ class VacancyParserService: ) 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]: + 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 + "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 + "experience": self._validate_enum( + data.get("experience"), Experience, Experience.BETWEEN_1_AND_3 ), - 'schedule': self._validate_enum( - data.get('schedule'), - Schedule, - Schedule.FULL_DAY + "schedule": self._validate_enum( + data.get("schedule"), Schedule, Schedule.FULL_DAY ), - 'company_name': data.get('company_name'), - 'area_name': data.get('area_name'), + "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' + "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'): + if data.get("salary_from"): try: - validated_data['salary_from'] = int(data['salary_from']) + validated_data["salary_from"] = int(data["salary_from"]) except (ValueError, TypeError): pass - - if data.get('salary_to'): + + if data.get("salary_to"): try: - validated_data['salary_to'] = int(data['salary_to']) + validated_data["salary_to"] = int(data["salary_to"]) except (ValueError, TypeError): pass - + # Валюта по умолчанию - validated_data['salary_currency'] = data.get('salary_currency', 'RUR') - + 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}") + logger.warning( + f"Неизвестное значение {value} для {enum_class.__name__}, используем {default_value}" + ) return default_value # Экземпляр сервиса -vacancy_parser_service = VacancyParserService() \ No newline at end of file +vacancy_parser_service = VacancyParserService() diff --git a/celery_worker/database.py b/celery_worker/database.py index 8d6ad8a..ca38311 100644 --- a/celery_worker/database.py +++ b/celery_worker/database.py @@ -139,35 +139,72 @@ 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'): + if hasattr(vacancy_create, "dict"): vacancy_data = vacancy_create.dict() - elif hasattr(vacancy_create, 'model_dump'): + 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() + **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) + + +class SyncInterviewReportRepository: + """Синхронный repository для работы с InterviewReport в Celery tasks""" + + def __init__(self, session: Session): + self.session = session + + def get_by_id(self, report_id: int): + """Получить отчет по ID""" + from app.models.interview_report import InterviewReport + + return ( + self.session.query(InterviewReport) + .filter(InterviewReport.id == report_id) + .first() + ) + + def update_pdf_url(self, report_id: int, pdf_url: str) -> bool: + """Обновить ссылку на PDF отчёта""" + from datetime import datetime + + from app.models.interview_report import InterviewReport + + try: + report = ( + self.session.query(InterviewReport) + .filter(InterviewReport.id == report_id) + .first() + ) + if report: + report.pdf_report_url = pdf_url + report.updated_at = datetime.utcnow() + self.session.add(report) + return True + return False + except Exception: + return False diff --git a/celery_worker/interview_analysis_task.py b/celery_worker/interview_analysis_task.py index 19f1e75..669e814 100644 --- a/celery_worker/interview_analysis_task.py +++ b/celery_worker/interview_analysis_task.py @@ -108,6 +108,7 @@ def generate_interview_report(resume_id: int): report_instance, resume.applicant_name, vacancy.get("title", "Unknown Position"), + resume.resume_file_url, ) ) @@ -321,8 +322,6 @@ def _prepare_analysis_context( # Формируем контекст context = f""" -АНАЛИЗ КАНДИДАТА НА СОБЕСЕДОВАНИЕ - ВАКАНСИЯ: - Позиция: {vacancy.get("title", "Не указана")} - Описание: {vacancy.get("description", "Не указано")[:500]} @@ -337,10 +336,10 @@ def _prepare_analysis_context( - Образование: {parsed_resume.get("education", "Не указано")} - Предыдущие позиции: {"; ".join([pos.get("title", "") + " в " + pos.get("company", "") for pos in parsed_resume.get("work_experience", [])])} -ПЛАН ИНТЕРВЬЮ: +ПЛАН СОБЕСЕДОВАНИЯ: {json.dumps(interview_plan, ensure_ascii=False, indent=2) if interview_plan else "План интервью не найден"} -ДИАЛОГ ИНТЕРВЬЮ: +ДИАЛОГ СОБЕСЕДОВАНИЯ: {dialogue_text if dialogue_text else "Диалог интервью не найден или пуст"} """ @@ -363,11 +362,15 @@ def _call_openai_for_evaluation(context: str) -> dict | None: {context} ЗАДАЧА: -Проанализируй кандидата и дай оценку по критериям (0-100): -1. technical_skills: Соответствие техническим требованиям -2. experience_relevance: Релевантность опыта -3. communication: Коммуникативные навыки (на основе диалога) -4. problem_solving: Навыки решения задач +Проанализируй ДИАЛОГ с кандидатом. Если кандидат ответил на вопросы и подтвердил знания из резюме, то только тогда можно считать его навыки резюме подтвержденными +и можно оценивать их соответствие вакансионным требованиям. Если клиент уклонялся от вопросов или закончил интервью раньше (или диалог выглядит неполным исходя из плана, хотя интервьюер адаптирует план и сторого ему не следует), +чем это сделал сам интервьюер, то навыки не считаются подтвержденными и по ним нельзя оценивать кандидата + +Дай оценку по критериям (0-100): +1. technical_skills: Соответствие диалога (и резюме если диалог подтверждает) техническим требованиям вакансии +2. experience_relevance: Релевантность опыта судя по диалогу (и резюме если диалог подтверждает) +3. communication: Коммуникативные навыки на основе диалога +4. problem_solving: Навыки решения задач на основе диалога 5. cultural_fit: Соответствие корпоративной культуре Для каждого критерия: @@ -587,19 +590,27 @@ def _save_report_to_db(db, resume_id: int, report: dict): async def _generate_and_upload_pdf_report( - db, report_instance: "InterviewReport", candidate_name: str, position: str + db, + report_instance: "InterviewReport", + candidate_name: str, + position: str, + resume_file_url: str = None, ): """Генерирует PDF отчет и загружает его в S3""" try: + from app.services.pdf_report_service import pdf_report_service logger.info( f"[PDF_GENERATION] Starting PDF generation for report ID: {report_instance.id}" ) - # Генерируем и загружаем PDF + # Генерируем и загружаем PDF - используем переданные параметры как в старой версии pdf_url = await pdf_report_service.generate_and_upload_pdf( - report=report_instance, candidate_name=candidate_name, position=position + report=report_instance, + candidate_name=candidate_name, + position=position, + resume_file_url=resume_file_url, ) if pdf_url: @@ -623,7 +634,7 @@ def _format_concerns_field(concerns_data) -> str: """Форматирует поле concerns для сохранения как строку""" if not concerns_data: return "" - + if isinstance(concerns_data, list): # Если это массив, объединяем элементы через запятую с переносом строки return "; ".join(concerns_data) diff --git a/celery_worker/tasks.py b/celery_worker/tasks.py index f6377ad..96b1a61 100644 --- a/celery_worker/tasks.py +++ b/celery_worker/tasks.py @@ -581,10 +581,12 @@ def generate_interview_questions_task(self, resume_id: str, job_description: str @celery_app.task(bind=True) -def parse_vacancy_task(self, file_content_base64: str, filename: str, create_vacancy: bool = False): +def parse_vacancy_task( + self, file_content_base64: str, filename: str, create_vacancy: bool = False +): """ Асинхронная задача парсинга вакансии из файла - + Args: file_content_base64: Содержимое файла в base64 filename: Имя файла для определения формата @@ -592,64 +594,73 @@ def parse_vacancy_task(self, file_content_base64: str, filename: str, create_vac """ try: import base64 - from app.services.vacancy_parser_service import vacancy_parser_service + from app.models.vacancy import VacancyCreate - + from app.services.vacancy_parser_service import vacancy_parser_service + # Обновляем статус задачи self.update_state( state="PENDING", - meta={"status": "Начинаем парсинг вакансии...", "progress": 10} + meta={"status": "Начинаем парсинг вакансии...", "progress": 10}, ) - + # Декодируем содержимое файла file_content = base64.b64decode(file_content_base64) - + # Шаг 1: Извлечение текста из файла self.update_state( state="PROGRESS", - meta={"status": "Извлекаем текст из файла...", "progress": 30} + 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} + state="PROGRESS", + meta={"status": "Обрабатываем текст с помощью AI...", "progress": 70}, ) - + import asyncio - parsed_data = asyncio.run(vacancy_parser_service.parse_vacancy_with_ai(raw_text)) - + + 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)}") - + print( + f"create_vacancy parameter: {create_vacancy}, type: {type(create_vacancy)}" + ) + if create_vacancy: self.update_state( state="PROGRESS", - meta={"status": "Создаем вакансию в базе данных...", "progress": 90} + 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'}") - + 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", @@ -657,38 +668,38 @@ def parse_vacancy_task(self, file_content_base64: str, filename: str, create_vac "status": f"Парсинг выполнен, но ошибка при создании вакансии: {str(e)}", "progress": 100, "result": parsed_data, - "warning": f"Ошибка создания вакансии: {str(e)}" - } + "warning": f"Ошибка создания вакансии: {str(e)}", + }, ) - + return { "status": "parsed_with_warning", "parsed_data": parsed_data, - "warning": f"Ошибка при создании вакансии: {str(e)}" + "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 - } + "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 + "message": response_message, } - + except Exception as e: # В случае ошибки self.update_state( @@ -696,8 +707,150 @@ def parse_vacancy_task(self, file_content_base64: str, filename: str, create_vac meta={ "status": f"Ошибка при парсинге вакансии: {str(e)}", "progress": 0, - "error": str(e) - } + "error": str(e), + }, ) - + raise Exception(f"Ошибка при парсинге вакансии: {str(e)}") + + +@celery_app.task(bind=True) +def generate_pdf_report_task( + self, + report_data: dict, + candidate_name: str = None, + position: str = None, + resume_file_url: str = None, +): + """ + Асинхронная задача для генерации PDF отчета по интервью + + Args: + report_data: Словарь с данными отчета InterviewReport + candidate_name: Имя кандидата + position: Позиция + resume_file_url: URL резюме + """ + try: + import asyncio + + from app.models.interview_report import InterviewReport + from app.services.pdf_report_service import pdf_report_service + from celery_worker.database import ( + SyncInterviewReportRepository, + get_sync_session, + ) + + # Обновляем статус задачи + self.update_state( + state="PENDING", + meta={"status": "Начинаем генерацию PDF отчета...", "progress": 10}, + ) + + # Создаем объект InterviewReport из переданных данных + self.update_state( + state="PROGRESS", + meta={"status": "Подготавливаем данные отчета...", "progress": 20}, + ) + + # Подготавливаем данные для создания объекта + clean_report_data = report_data.copy() + + # Обрабатываем datetime поля - убираем их, так как они не нужны для создания mock объекта + clean_report_data.pop('created_at', None) + clean_report_data.pop('updated_at', None) + + # Создаем объект InterviewReport с обработанными данными + mock_report = InterviewReport(**clean_report_data) + + # Генерируем PDF + self.update_state( + state="PROGRESS", meta={"status": "Генерируем PDF отчет...", "progress": 40} + ) + + # Запускаем асинхронную функцию в новом цикле событий + def run_pdf_generation(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + return loop.run_until_complete( + pdf_report_service.generate_pdf_report( + mock_report, candidate_name, position, resume_file_url + ) + ) + finally: + loop.close() + + pdf_bytes = run_pdf_generation() + + # Загружаем в S3 + self.update_state( + state="PROGRESS", + meta={"status": "Загружаем PDF в хранилище...", "progress": 80}, + ) + + def run_s3_upload(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + # Создаем имя файла + 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() + report_id = report_data.get("id") + filename = f"interview_report_{safe_name}_{report_id}.pdf" + + return loop.run_until_complete( + pdf_report_service.upload_pdf_to_s3(pdf_bytes, filename) + ) + finally: + loop.close() + + pdf_url = run_s3_upload() + + # Обновляем отчет с URL PDF файла + self.update_state( + state="PROGRESS", + meta={"status": "Сохраняем ссылку на отчет...", "progress": 90}, + ) + + report_id = report_data.get("id") + with get_sync_session() as session: + report_repo = SyncInterviewReportRepository(session) + report_repo.update_pdf_url(report_id, pdf_url) + + # Завершено успешно + self.update_state( + state="SUCCESS", + meta={ + "status": "PDF отчет успешно сгенерирован", + "progress": 100, + "pdf_url": pdf_url, + "file_size": len(pdf_bytes), + }, + ) + + return { + "interview_report_id": report_id, + "status": "completed", + "pdf_url": pdf_url, + "file_size": len(pdf_bytes), + } + + except Exception as e: + # В случае ошибки + self.update_state( + state="FAILURE", + meta={ + "status": f"Ошибка при генерации PDF: {str(e)}", + "progress": 0, + "error": str(e), + }, + ) + + raise Exception(f"Ошибка при генерации PDF: {str(e)}") diff --git a/main.py b/main.py index 7b03ce1..8533276 100644 --- a/main.py +++ b/main.py @@ -1,3 +1,5 @@ +import asyncio +import sys from contextlib import asynccontextmanager from fastapi import FastAPI @@ -7,9 +9,9 @@ from app.core.session_middleware import SessionMiddleware from app.routers import resume_router, vacancy_router from app.routers.admin_router import router as admin_router from app.routers.analysis_router import router as analysis_router +from app.routers.interview_reports_router import router as interview_report_router from app.routers.interview_router import router as interview_router from app.routers.session_router import router as session_router -from app.routers.interview_reports_router import router as interview_report_router @asynccontextmanager @@ -17,6 +19,9 @@ async def lifespan(app: FastAPI): # Запускаем AI агента при старте приложения from app.services.agent_manager import agent_manager + if sys.platform.startswith("win"): + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + print("[STARTUP] Starting AI Agent...") success = await agent_manager.start_agent() @@ -59,6 +64,7 @@ app.include_router(analysis_router, prefix="/api/v1") app.include_router(admin_router, prefix="/api/v1") app.include_router(interview_report_router, prefix="/api/v1") + @app.get("/") async def root(): return {"message": "HR AI Backend API", "version": "1.0.0"} diff --git a/migrations/versions/efeebe53c76c_add_interview_session_resume_.py b/migrations/versions/efeebe53c76c_add_interview_session_resume_.py new file mode 100644 index 0000000..71e4e2a --- /dev/null +++ b/migrations/versions/efeebe53c76c_add_interview_session_resume_.py @@ -0,0 +1,52 @@ +"""Add interview session resume relationship and timing fields + +Revision ID: efeebe53c76c +Revises: 86cfa6ee73af +Create Date: 2025-09-09 00:13:58.304145 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "efeebe53c76c" +down_revision: str | Sequence[str] | None = "86cfa6ee73af" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "interview_sessions", + sa.Column("interview_start_time", sa.DateTime(), nullable=True), + ) + op.add_column( + "interview_sessions", + sa.Column("interview_end_time", sa.DateTime(), nullable=True), + ) + op.alter_column( + "vacancy", "company_name", existing_type=sa.VARCHAR(length=255), nullable=True + ) + op.alter_column( + "vacancy", "area_name", existing_type=sa.VARCHAR(length=255), nullable=True + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + "vacancy", "area_name", existing_type=sa.VARCHAR(length=255), nullable=False + ) + op.alter_column( + "vacancy", "company_name", existing_type=sa.VARCHAR(length=255), nullable=False + ) + op.drop_column("interview_sessions", "interview_end_time") + op.drop_column("interview_sessions", "interview_start_time") + # ### end Alembic commands ### diff --git a/pyproject.toml b/pyproject.toml index d9dd49c..0d73ff6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,8 @@ dependencies = [ "pdfkit>=1.0.0", "jinja2>=3.1.6", "greenlet>=3.2.4", + "xhtml2pdf>=0.2.17", + "playwright>=1.55.0", ] [build-system] diff --git a/rag/registry.py b/rag/registry.py index 7df73e3..59c7170 100644 --- a/rag/registry.py +++ b/rag/registry.py @@ -31,9 +31,7 @@ class ModelRegistry: """Получить или создать chat модель""" if self._chat_model is None: if settings.openai_api_key: - llm = ChatOpenAI( - api_key=settings.openai_api_key, model="gpt-5-mini" - ) + llm = ChatOpenAI(api_key=settings.openai_api_key, model="gpt-5-mini") self._chat_model = ChatModel(llm) else: raise ValueError("OpenAI API key не настроен в settings") diff --git a/static/fonts/DejaVuSans-Bold.ttf b/static/fonts/DejaVuSans-Bold.ttf new file mode 100644 index 0000000..6d65fa7 Binary files /dev/null and b/static/fonts/DejaVuSans-Bold.ttf differ diff --git a/static/fonts/DejaVuSans.ttf b/static/fonts/DejaVuSans.ttf new file mode 100644 index 0000000..e5f7eec Binary files /dev/null and b/static/fonts/DejaVuSans.ttf differ diff --git a/uv.lock b/uv.lock index 7501730..7a03994 100644 --- a/uv.lock +++ b/uv.lock @@ -154,6 +154,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213 }, ] +[[package]] +name = "arabic-reshaper" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/27/9f488e21f87fd8b7ff3b52c372b9510c619ecf1398e4ba30d5f4becc7d86/arabic_reshaper-3.0.0.tar.gz", hash = "sha256:ffcd13ba5ec007db71c072f5b23f420da92ac7f268512065d49e790e62237099", size = 23420 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/fb/e20b45d81d74d810b01bff408baf8af04abf1d55a1a289c8395ad0919a7c/arabic_reshaper-3.0.0-py3-none-any.whl", hash = "sha256:3f71d5034bb694204a239a6f1ebcf323ac3c5b059de02259235e2016a1a5e2dc", size = 20364 }, +] + [[package]] name = "argcomplete" version = "3.6.2" @@ -163,6 +172,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/da/e42d7a9d8dd33fa775f467e4028a47936da2f01e4b0e561f9ba0d74cb0ca/argcomplete-3.6.2-py3-none-any.whl", hash = "sha256:65b3133a29ad53fb42c48cf5114752c7ab66c1c38544fdf6460f450c09b42591", size = 43708 }, ] +[[package]] +name = "asn1crypto" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/cf/d547feed25b5244fcb9392e288ff9fdc3280b10260362fc45d37a798a6ee/asn1crypto-1.5.1.tar.gz", hash = "sha256:13ae38502be632115abf8a24cbe5f4da52e3b5231990aff31123c805306ccb9c", size = 121080 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/7f/09065fd9e27da0eda08b4d6897f1c13535066174cc023af248fc2a8d5e5a/asn1crypto-1.5.1-py2.py3-none-any.whl", hash = "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67", size = 105045 }, +] + [[package]] name = "async-timeout" version = "5.0.1" @@ -617,6 +635,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/23/87/7ce86f3fa14bc11a5a48c30d8103c26e09b6465f8d8e9d74cf7a0714f043/cryptography-45.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:1f3d56f73595376f4244646dd5c5870c14c196949807be39e79e7bd9bac3da63", size = 3332908 }, ] +[[package]] +name = "cssselect2" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tinycss2" }, + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/86/fd7f58fc498b3166f3a7e8e0cddb6e620fe1da35b02248b1bd59e95dbaaa/cssselect2-0.8.0.tar.gz", hash = "sha256:7674ffb954a3b46162392aee2a3a0aedb2e14ecf99fcc28644900f4e6e3e9d3a", size = 35716 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/e7/aa315e6a749d9b96c2504a1ba0ba031ba2d0517e972ce22682e3fccecb09/cssselect2-0.8.0-py3-none-any.whl", hash = "sha256:46fc70ebc41ced7a32cd42d58b1884d72ade23d21e5a4eaaf022401c13f0e76e", size = 15454 }, +] + [[package]] name = "dataclasses-json" version = "0.6.7" @@ -995,6 +1026,7 @@ dependencies = [ { name = "comtypes" }, { name = "docx2txt" }, { name = "fastapi", extra = ["standard"] }, + { name = "greenlet" }, { name = "jinja2" }, { name = "langchain" }, { name = "langchain-community" }, @@ -1006,6 +1038,7 @@ dependencies = [ { name = "livekit-api" }, { name = "pdfkit" }, { name = "pdfplumber" }, + { name = "playwright" }, { name = "psycopg2-binary" }, { name = "pydantic-settings" }, { name = "python-docx" }, @@ -1016,6 +1049,7 @@ dependencies = [ { name = "sqlmodel" }, { name = "textract" }, { name = "uvicorn", extra = ["standard"] }, + { name = "xhtml2pdf" }, { name = "yandex-speechkit" }, ] @@ -1037,6 +1071,7 @@ requires-dist = [ { name = "comtypes", specifier = ">=1.4.12" }, { name = "docx2txt", specifier = ">=0.9" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.104.0" }, + { name = "greenlet", specifier = ">=3.2.4" }, { name = "jinja2", specifier = ">=3.1.6" }, { name = "langchain", specifier = ">=0.1.0" }, { name = "langchain-community", specifier = ">=0.0.10" }, @@ -1048,6 +1083,7 @@ requires-dist = [ { name = "livekit-api", specifier = ">=1.0.5" }, { name = "pdfkit", specifier = ">=1.0.0" }, { name = "pdfplumber", specifier = ">=0.10.0" }, + { name = "playwright", specifier = ">=1.55.0" }, { name = "psycopg2-binary", specifier = ">=2.9.0" }, { name = "pydantic-settings", specifier = ">=2.1.0" }, { name = "python-docx", specifier = ">=1.2.0" }, @@ -1058,6 +1094,7 @@ requires-dist = [ { name = "sqlmodel", specifier = ">=0.0.14" }, { name = "textract", specifier = ">=1.5.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" }, + { name = "xhtml2pdf", specifier = ">=0.2.17" }, { name = "yandex-speechkit", specifier = ">=1.5.0" }, ] @@ -1070,6 +1107,19 @@ dev = [ { name = "ruff", specifier = ">=0.12.12" }, ] +[[package]] +name = "html5lib" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/b6/b55c3f49042f1df3dcd422b7f224f939892ee94f22abcf503a9b7339eaf2/html5lib-1.1.tar.gz", hash = "sha256:b2e5b40261e20f354d198eae92afc10d750afb487ed5e50f9c4eaf07c184146f", size = 272215 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/dd/a834df6482147d48e225a49515aabc28974ad5a4ca3215c18a882565b028/html5lib-1.1-py2.py3-none-any.whl", hash = "sha256:0d78f8fde1c230e99fe37986a60526d7049ed4bf8a9fadbad5f00e22e58e041d", size = 112173 }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -2288,6 +2338,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/01/d6b274a0635be0468d4dbd9cafe80c47105937a0d42434e805e67cd2ed8b/orjson-3.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:e8f6a7a27d7b7bec81bd5924163e9af03d49bbb63013f107b48eb5d16db711bc", size = 125985 }, ] +[[package]] +name = "oscrypto" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asn1crypto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/81/a7654e654a4b30eda06ef9ad8c1b45d1534bfd10b5c045d0c0f6b16fecd2/oscrypto-1.3.0.tar.gz", hash = "sha256:6f5fef59cb5b3708321db7cca56aed8ad7e662853351e7991fcf60ec606d47a4", size = 184590 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/7c/fa07d3da2b6253eb8474be16eab2eadf670460e364ccc895ca7ff388ee30/oscrypto-1.3.0-py2.py3-none-any.whl", hash = "sha256:2b2f1d2d42ec152ca90ccb5682f3e051fb55986e1b170ebde472b133713e7085", size = 194553 }, +] + [[package]] name = "packaging" version = "25.0" @@ -2467,6 +2529,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8", size = 6984598 }, ] +[[package]] +name = "playwright" +version = "1.55.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/3a/c81ff76df266c62e24f19718df9c168f49af93cabdbc4608ae29656a9986/playwright-1.55.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:d7da108a95001e412effca4f7610de79da1637ccdf670b1ae3fdc08b9694c034", size = 40428109 }, + { url = "https://files.pythonhosted.org/packages/cf/f5/bdb61553b20e907196a38d864602a9b4a461660c3a111c67a35179b636fa/playwright-1.55.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8290cf27a5d542e2682ac274da423941f879d07b001f6575a5a3a257b1d4ba1c", size = 38687254 }, + { url = "https://files.pythonhosted.org/packages/4a/64/48b2837ef396487807e5ab53c76465747e34c7143fac4a084ef349c293a8/playwright-1.55.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:25b0d6b3fd991c315cca33c802cf617d52980108ab8431e3e1d37b5de755c10e", size = 40428108 }, + { url = "https://files.pythonhosted.org/packages/08/33/858312628aa16a6de97839adc2ca28031ebc5391f96b6fb8fdf1fcb15d6c/playwright-1.55.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c6d4d8f6f8c66c483b0835569c7f0caa03230820af8e500c181c93509c92d831", size = 45905643 }, + { url = "https://files.pythonhosted.org/packages/83/83/b8d06a5b5721931aa6d5916b83168e28bd891f38ff56fe92af7bdee9860f/playwright-1.55.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29a0777c4ce1273acf90c87e4ae2fe0130182100d99bcd2ae5bf486093044838", size = 45296647 }, + { url = "https://files.pythonhosted.org/packages/06/2e/9db64518aebcb3d6ef6cd6d4d01da741aff912c3f0314dadb61226c6a96a/playwright-1.55.0-py3-none-win32.whl", hash = "sha256:29e6d1558ad9d5b5c19cbec0a72f6a2e35e6353cd9f262e22148685b86759f90", size = 35476046 }, + { url = "https://files.pythonhosted.org/packages/46/4f/9ba607fa94bb9cee3d4beb1c7b32c16efbfc9d69d5037fa85d10cafc618b/playwright-1.55.0-py3-none-win_amd64.whl", hash = "sha256:7eb5956473ca1951abb51537e6a0da55257bb2e25fc37c2b75af094a5c93736c", size = 35476048 }, + { url = "https://files.pythonhosted.org/packages/21/98/5ca173c8ec906abde26c28e1ecb34887343fd71cc4136261b90036841323/playwright-1.55.0-py3-none-win_arm64.whl", hash = "sha256:012dc89ccdcbd774cdde8aeee14c08e0dd52ddb9135bf10e9db040527386bd76", size = 31225543 }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -2759,6 +2840,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a6/53/d78dc063216e62fc55f6b2eebb447f6a4b0a59f55c8406376f76bf959b08/pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6", size = 32327 }, ] +[[package]] +name = "pyee" +version = "13.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/03/1fd98d5841cd7964a27d729ccf2199602fe05eb7a405c1462eb7277945ed/pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37", size = 31250 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730 }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -2768,6 +2861,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 }, ] +[[package]] +name = "pyhanko" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asn1crypto" }, + { name = "cryptography" }, + { name = "lxml" }, + { name = "pyhanko-certvalidator" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/c4/06672abd225149dde9302d64e8962abc2b5aca4bba4c50388005fa32ab90/pyhanko-0.30.0.tar.gz", hash = "sha256:efaa9e5401d4912fa5b2aeb4cdbe729196d98dae0671bd6d37a824dc6fde5ca4", size = 405860 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/73/c1b4f69d25ab00552a49d180783b7de29a8313b30a31049028f54a01ac69/pyhanko-0.30.0-py3-none-any.whl", hash = "sha256:cd65837b42c5ce3fbd88d1996b0cd44895cd634fc7cf12764b9b56ec100b9994", size = 465232 }, +] + +[[package]] +name = "pyhanko-certvalidator" +version = "0.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asn1crypto" }, + { name = "cryptography" }, + { name = "oscrypto" }, + { name = "requests" }, + { name = "uritools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/be/9ffcb4cb17f223589579b3dd005d1004e9586d58730c8bbc688ffd563e19/pyhanko_certvalidator-0.28.0.tar.gz", hash = "sha256:6b2911520a3e9cf24a640f67488fadac82ad3818f4256ddfb7e8fa1fada80f2d", size = 93049 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/3c/d4a5f18d21962c8c3626c3c4d7a8774a3d649923c18e717f7f1ca471c126/pyhanko_certvalidator-0.28.0-py3-none-any.whl", hash = "sha256:37d02f61974175843ce36b467c0d9d7eae78caa6e356beeb753360c351494dc2", size = 111617 }, +] + [[package]] name = "pyjwt" version = "2.10.1" @@ -2795,6 +2922,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/1a/8b677e0f4ef683bbfb00d495960573fff0844ed509b3cf0abede79a48e90/pymilvus-2.6.1-py3-none-any.whl", hash = "sha256:e3d76d45ce04d3555a6849645a18a1e2992706e248d5b6dc58a00504d0b60165", size = 254252 }, ] +[[package]] +name = "pypdf" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/ac/a300a03c3b34967c050677ccb16e7a4b65607ee5df9d51e8b6d713de4098/pypdf-6.0.0.tar.gz", hash = "sha256:282a99d2cc94a84a3a3159f0d9358c0af53f85b4d28d76ea38b96e9e5ac2a08d", size = 5033827 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/83/2cacc506eb322bb31b747bc06ccb82cc9aa03e19ee9c1245e538e49d52be/pypdf-6.0.0-py3-none-any.whl", hash = "sha256:56ea60100ce9f11fc3eec4f359da15e9aec3821b036c1f06d2b660d35683abb8", size = 310465 }, +] + [[package]] name = "pypdfium2" version = "4.30.0" @@ -2852,6 +2988,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157 }, ] +[[package]] +name = "python-bidi" +version = "0.6.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/de/1822200711beaadb2f334fa25f59ad9c2627de423c103dde7e81aedbc8e2/python_bidi-0.6.6.tar.gz", hash = "sha256:07db4c7da502593bd6e39c07b3a38733704070de0cbf92a7b7277b7be8867dd9", size = 45102 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/03/b10c5c320fa5f3bc3d7736b2268179cc7f4dca4d054cdf2c932532d6b11a/python_bidi-0.6.6-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:da4949496e563b51f53ff34aad5a9f4c3aaf06f4180cf3bcb42bec649486c8f1", size = 269512 }, + { url = "https://files.pythonhosted.org/packages/91/d8/8f6bd8f4662e8340e1aabb3b9a01fb1de24e8d1ce4f38b160f5cac2524f4/python_bidi-0.6.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c48a755ca8ba3f2b242d6795d4a60e83ca580cc4fa270a3aaa8af05d93b7ba7f", size = 264042 }, + { url = "https://files.pythonhosted.org/packages/51/9f/2c831510ab8afb03b5ec4b15271dc547a2e8643563a7bcc712cd43b29d26/python_bidi-0.6.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76a1cd320993ba3e91a567e97f057a03f2c6b493096b3fff8b5630f51a38e7eb", size = 290963 }, + { url = "https://files.pythonhosted.org/packages/95/45/17a76e7052d4d4bc1549ac2061f1fdebbaa9b7448ce81e774b7f77dc70b2/python_bidi-0.6.6-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e8bf3e396f9ebe8f4f81e92fa4c98c50160d60c58964b89c8ff4ee0c482befaa", size = 298639 }, + { url = "https://files.pythonhosted.org/packages/00/11/fb5857168dcc50a2ebb2a5d8771a64b7fc66c19c9586b6f2a4d8a76db2e8/python_bidi-0.6.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2a49b506ed21f762ebf332de6de689bc4912e24dcc3b85f120b34e5f01e541a", size = 351898 }, + { url = "https://files.pythonhosted.org/packages/18/e7/d25b3e767e204b9e236e7cb042bf709fd5a985cfede8c990da3bbca862a3/python_bidi-0.6.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3428331e7ce0d58c15b5a57e18a43a12e28f8733086066e6fd75b0ded80e1cae", size = 331117 }, + { url = "https://files.pythonhosted.org/packages/75/50/248decd41096b4954c3887fc7fae864b8e1e90d28d1b4ce5a28c087c3d8d/python_bidi-0.6.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35adfb9fed3e72b9043a5c00b6ab69e4b33d53d2d8f8b9f60d4df700f77bc2c0", size = 292950 }, + { url = "https://files.pythonhosted.org/packages/0b/d8/6ae7827fbba1403882930d4da8cbab28ab6b86b61a381c991074fb5003d1/python_bidi-0.6.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:589c5b24a8c4b5e07a1e97654020734bf16ed01a4353911ab663a37aaf1c281d", size = 307909 }, + { url = "https://files.pythonhosted.org/packages/4c/a3/5b369c5da7b08b36907dcce7a78c730370ad6899459282f5e703ec1964c6/python_bidi-0.6.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:994534e47260d712c3b3291a6ab55b46cdbfd78a879ef95d14b27bceebfd4049", size = 465552 }, + { url = "https://files.pythonhosted.org/packages/82/07/7779668967c0f17a107a916ec7891507b7bcdc9c7ee4d2c4b6a80ba1ac5e/python_bidi-0.6.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:00622f54a80826a918b22a2d6d5481bb3f669147e17bac85c81136b6ffbe7c06", size = 557371 }, + { url = "https://files.pythonhosted.org/packages/2d/e5/3154ac009a167bf0811195f12cf5e896c77a29243522b4b0697985881bc4/python_bidi-0.6.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:965e6f2182e7b9352f2d79221f6c49502a307a9778d7d87d82dc36bb1ffecbab", size = 485458 }, + { url = "https://files.pythonhosted.org/packages/fd/db/88af6f0048d8ec7281b44b5599a3d2afa18fac5dd22eb72526f28f4ea647/python_bidi-0.6.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:53d7d3a550d176df99dd0bb0cc2da16b40634f11c8b9f5715777441d679c0a62", size = 459588 }, + { url = "https://files.pythonhosted.org/packages/bb/d2/77b649c8b32c2b88e2facf5a42fb51dfdcc9e13db411c8bc84831ad64893/python_bidi-0.6.6-cp311-cp311-win32.whl", hash = "sha256:b271cd05cb40f47eb4600de79a8e47f8579d81ce35f5650b39b7860d018c3ece", size = 155683 }, + { url = "https://files.pythonhosted.org/packages/95/41/d4dbc72b96e2eea3aeb9292707459372c8682ef039cd19fcac7e09d513ef/python_bidi-0.6.6-cp311-cp311-win_amd64.whl", hash = "sha256:4ff1eba0ff87e04bd35d7e164203ad6e5ce19f0bac0bdf673134c0b78d919608", size = 160587 }, + { url = "https://files.pythonhosted.org/packages/6f/84/45484b091e89d657b0edbfc4378d94ae39915e1f230cb13614f355ff7f22/python_bidi-0.6.6-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:166060a31c10aa3ffadd52cf10a3c9c2b8d78d844e0f2c5801e2ed511d3ec316", size = 267218 }, + { url = "https://files.pythonhosted.org/packages/b7/17/b314c260366a8fb370c58b98298f903fb2a3c476267efbe792bb8694ac7c/python_bidi-0.6.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8706addd827840c2c3b3a9963060d9b979b43801cc9be982efa9644facd3ed26", size = 262129 }, + { url = "https://files.pythonhosted.org/packages/27/b6/8212d0f83aaa361ab33f98c156a453ea5cfb9ac40fab06eef9a156ba4dfa/python_bidi-0.6.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69c02316a4f72a168ea6f66b90d845086e2f2d2de6b08eb32c576db36582177c", size = 290811 }, + { url = "https://files.pythonhosted.org/packages/cd/05/cd503307cd478d18f09b301d20e38ef4107526e65e9cbb9ce489cc2ddbf3/python_bidi-0.6.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a525bcb77b8edbfdcf8b199dbed24556e6d1436af8f5fa392f6cdc93ed79b4af", size = 298175 }, + { url = "https://files.pythonhosted.org/packages/e0/0c/bd7bbd70bd330f282c534f03235a9b8da56262ea97a353d8fe9e367d0d7c/python_bidi-0.6.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb186c8da4bdc953893504bba93f41d5b412fd767ba5661ff606f22950ec609", size = 351470 }, + { url = "https://files.pythonhosted.org/packages/5e/ab/05a1864d5317e69e022930457f198c2d0344fd281117499ad3fedec5b77c/python_bidi-0.6.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25fa21b46dc80ac7099d2dee424b634eb1f76b2308d518e505a626c55cdbf7b1", size = 329468 }, + { url = "https://files.pythonhosted.org/packages/07/7c/094bbcb97089ac79f112afa762051129c55d52a7f58923203dfc62f75feb/python_bidi-0.6.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b31f5562839e7ecea881ba337f9d39716e2e0e6b3ba395e824620ee5060050ff", size = 292102 }, + { url = "https://files.pythonhosted.org/packages/99/6b/5e2e6c2d76e7669b9dd68227e8e70cf72a6566ffdf414b31b64098406030/python_bidi-0.6.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fb750d3d5ac028e8afd62d000928a2110dbca012fee68b1a325a38caa03dc50b", size = 307282 }, + { url = "https://files.pythonhosted.org/packages/5e/da/6cbe04f605100978755fc5f4d8a8209789b167568e1e08e753d1a88edcc5/python_bidi-0.6.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b5f648ee8e9f4ac0400f71e671934b39837d7031496e0edde867a303344d758", size = 464487 }, + { url = "https://files.pythonhosted.org/packages/d5/83/d15a0c944b819b8f101418b973772c42fb818c325c82236978db71b1ed7e/python_bidi-0.6.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c4c0255940e6ff98fb05f9d5de3ffcaab7b60d821d4ca072b50c4f871b036562", size = 556449 }, + { url = "https://files.pythonhosted.org/packages/0f/9a/80f0551adcbc9dd02304a4e4ae46113bb1f6f5172831ad86b860814ff498/python_bidi-0.6.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e7e36601edda15e67527560b1c00108b0d27831260b6b251cf7c6dd110645c03", size = 484368 }, + { url = "https://files.pythonhosted.org/packages/9e/05/4a4074530e54a3e384535d185c77fe9bf0321b207bfcb3a9c1676ee9976f/python_bidi-0.6.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:07c9f000671b187319bacebb9e98d8b75005ccd16aa41b9d4411e66813c467bb", size = 458846 }, + { url = "https://files.pythonhosted.org/packages/9f/10/91d112d152b273e54ca7b7d476faaf27e9a350ef85b4fcc281bdd577d13b/python_bidi-0.6.6-cp312-cp312-win32.whl", hash = "sha256:57c0ca449a116c4f804422111b3345281c4e69c733c4556fa216644ec9907078", size = 155236 }, + { url = "https://files.pythonhosted.org/packages/30/da/e1537900bc8a838b0637124cf8f7ef36ce87b5cdc41fb4c26752a4b9c25a/python_bidi-0.6.6-cp312-cp312-win_amd64.whl", hash = "sha256:f60afe457a37bd908fdc7b520c07620b1a7cc006e08b6e3e70474025b4f5e5c7", size = 160251 }, + { url = "https://files.pythonhosted.org/packages/a5/b1/b24cb64b441dadd911b39d8b86a91606481f84be1b3f01ffca3f9847a4f1/python_bidi-0.6.6-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:61cf12f6b7d0b9bb37838a5f045e6acbd91e838b57f0369c55319bb3969ffa4d", size = 266728 }, + { url = "https://files.pythonhosted.org/packages/0c/19/d4d449dcdc5eb72b6ffb97b34db710ea307682cae065fbe83a0e42fee00a/python_bidi-0.6.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:33bd0ba5eedf18315a1475ac0f215b5134e48011b7320aedc2fb97df31d4e5bf", size = 261475 }, + { url = "https://files.pythonhosted.org/packages/0a/87/4ecaecf7cc17443129b0f3a967b6f455c0d773b58d68b93c5949a91a0b8b/python_bidi-0.6.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c9f798dd49b24bb1a9d90f065ef25c7bffa94c04c554f1fc02d0aea0a9b10b0", size = 290153 }, + { url = "https://files.pythonhosted.org/packages/42/6e/4b57a3dba455f42fa82a9b5caf3d35535bd6eb644a37a031ac1d5e8b6a3e/python_bidi-0.6.6-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43a0409570c618d93706dc875b1d33b4adfe67144f6f2ebeb32d85d8bbdb85ed", size = 297567 }, + { url = "https://files.pythonhosted.org/packages/39/39/dc9ce9b15888b6391206d77fc36fd23447fb5313aee1fa1031432b2a4072/python_bidi-0.6.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ada1aecd32773c61b16f7c9f74d9ec1b57ea433e2083e08ca387c5cd4b0ceaed", size = 351186 }, + { url = "https://files.pythonhosted.org/packages/9e/66/cc9795903be4ce781b89fa4fe0e493369d58cd0fc0dda9287ab227d410d3/python_bidi-0.6.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:125a815f2b20313a2f6d331aa84abdd07de7d270985b056e6729390a4cda90df", size = 329159 }, + { url = "https://files.pythonhosted.org/packages/ca/40/071dc08645daa09cb8c008db888141998a895d2d1ed03ba780971b595297/python_bidi-0.6.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:183fee39bd2de787f632376bd5ba0d5f1daf6a09d3ebfaa211df25d62223e531", size = 291743 }, + { url = "https://files.pythonhosted.org/packages/17/5a/5f60915a9f73f48df27bf262a210fa66ea8ffe5fd0072c67288e55e3304e/python_bidi-0.6.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c4e08753d32d633f5ecb5eb02624272eeffaa6d5c6f4f9ddf012637bcaabfc0a", size = 306568 }, + { url = "https://files.pythonhosted.org/packages/9e/01/03341516d895ee937036d38ab4f9987857b1066f7c267b99963ee056eb9e/python_bidi-0.6.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d1dcd7a82ae00b86821fce627e310791f56da90924f15877cfda844e340679de", size = 463890 }, + { url = "https://files.pythonhosted.org/packages/4f/a8/36bb9553e00d33acee2d2d447b60bccb0aad5c1d589cd364ddd95d9b876b/python_bidi-0.6.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5506ba56380140b3cb3504029de014d21eb8874c5e081d88495f8775f6ed90bc", size = 555980 }, + { url = "https://files.pythonhosted.org/packages/46/05/88aa85522472afda215a6b436eaa0aac6bbe9e29a64db0f99f61d1aa6527/python_bidi-0.6.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:207b0a7082ec38045910d37700a0dd73c10d4ffccb22a4fd0391d7e9ce241672", size = 483881 }, + { url = "https://files.pythonhosted.org/packages/48/7e/f813de1a92e10c302649134ea3a8c6429f9c2e5dd161e82e88f08b4c7565/python_bidi-0.6.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:686642a52acdeffb1d9a593a284d07b175c63877c596fa3ccceeb2649ced1dd8", size = 458296 }, + { url = "https://files.pythonhosted.org/packages/e9/ea/a775bec616ec01d9a0df7d5a6e1b3729285dd5e7f1fdb0dfce2e0604c6a3/python_bidi-0.6.6-cp313-cp313-win32.whl", hash = "sha256:485f2ee109e7aa73efc165b90a6d90da52546801413540c08b7133fe729d5e0a", size = 155033 }, + { url = "https://files.pythonhosted.org/packages/74/79/3323f08c98b9a5b726303b68babdd26cf4fe710709b7c61c96e6bb4f3d10/python_bidi-0.6.6-cp313-cp313-win_amd64.whl", hash = "sha256:63f7a9eaec31078e7611ab958b6e18e796c05b63ca50c1f7298311dc1e15ac3e", size = 159973 }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -3399,6 +3585,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/fd/901cfa59aaa5b30a99e16876f11abe38b59a1a2c51ffb3d7142bb6089069/starlette-0.47.3-py3-none-any.whl", hash = "sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51", size = 72991 }, ] +[[package]] +name = "svglib" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cssselect2" }, + { name = "lxml" }, + { name = "reportlab" }, + { name = "tinycss2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/5b/53ca0fd447f73423c7dc59d34e523530ef434481a3d18808ff7537ad33ec/svglib-1.5.1.tar.gz", hash = "sha256:3ae765d3a9409ee60c0fb4d24c2deb6a80617aa927054f5bcd7fc98f0695e587", size = 913900 } + [[package]] name = "sympy" version = "1.14.0" @@ -3467,6 +3665,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/50/79/bcf350609f3a10f09fe4fc207f132085e497fdd3612f3925ab24d86a0ca0/tiktoken-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:2177ffda31dec4023356a441793fed82f7af5291120751dee4d696414f54db0c", size = 883901 }, ] +[[package]] +name = "tinycss2" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/fd/7a5ee21fd08ff70d3d33a5781c255cbe779659bd03278feb98b19ee550f4/tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", size = 87085 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289", size = 26610 }, +] + [[package]] name = "tokenizers" version = "0.22.0" @@ -3592,6 +3802,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839 }, ] +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026 }, +] + [[package]] name = "ujson" version = "5.11.0" @@ -3661,6 +3883,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/5b/8c5e33228f7f83f05719964db59f3f9f276d272dc43752fa3bbf0df53e7b/ujson-5.11.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:416389ec19ef5f2013592f791486bef712ebce0cd59299bf9df1ba40bb2f6e04", size = 43835 }, ] +[[package]] +name = "uritools" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/b1/e482d43db3209663b82a59e37cf31f641254180190667c6b0bf18a297de8/uritools-5.0.0.tar.gz", hash = "sha256:68180cad154062bd5b5d9ffcdd464f8de6934414b25462ae807b00b8df9345de", size = 22730 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/74/0987d204b5fbf83861affa6b36a20da22cb3fe708583b955c99ab834bd5a/uritools-5.0.0-py3-none-any.whl", hash = "sha256:cead3a49ba8fbca3f91857343849d506d8639718f4a2e51b62e87393b493bd6f", size = 10432 }, +] + [[package]] name = "urllib3" version = "2.5.0" @@ -3822,6 +4053,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166 }, ] +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774 }, +] + [[package]] name = "websockets" version = "15.0.1" @@ -3864,6 +4104,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743 }, ] +[[package]] +name = "xhtml2pdf" +version = "0.2.17" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "arabic-reshaper" }, + { name = "html5lib" }, + { name = "pillow" }, + { name = "pyhanko" }, + { name = "pyhanko-certvalidator" }, + { name = "pypdf" }, + { name = "python-bidi" }, + { name = "reportlab" }, + { name = "svglib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/9a/3b29831d8617ecbcf0b0aaa2b3e1b24f3fd1bbd204678ae86e9fee2f4239/xhtml2pdf-0.2.17.tar.gz", hash = "sha256:09ddbc31aa0e38a16f2f3cb73be89af5f7c968c17a564afdd685d280e39c526d", size = 139727 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/ca/d53764f0534ff857239595f090f4cb83b599d226cc326c7de5eb3d802715/xhtml2pdf-0.2.17-py3-none-any.whl", hash = "sha256:61a7ecac829fed518f7dbcb916e9d56bea6e521e02e54644b3d0ca33f0658315", size = 125349 }, +] + [[package]] name = "xlrd" version = "2.0.2"