upd gpt; add pdf generating
This commit is contained in:
parent
e9a70cf393
commit
954fa2bc50
@ -18,7 +18,7 @@ if os.name == "nt": # Windows
|
|||||||
|
|
||||||
from livekit.agents import Agent, AgentSession, JobContext, WorkerOptions, cli
|
from livekit.agents import Agent, AgentSession, JobContext, WorkerOptions, cli
|
||||||
from livekit.api import DeleteRoomRequest, LiveKitAPI
|
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.core.database import get_session
|
||||||
from app.repositories.interview_repository import InterviewRepository
|
from app.repositories.interview_repository import InterviewRepository
|
||||||
@ -63,10 +63,10 @@ class InterviewAgent:
|
|||||||
self.last_user_response = None
|
self.last_user_response = None
|
||||||
self.intro_done = False # Новый флаг — произнесено ли приветствие
|
self.intro_done = False # Новый флаг — произнесено ли приветствие
|
||||||
self.interview_finalized = False # Флаг завершения интервью
|
self.interview_finalized = False # Флаг завершения интервью
|
||||||
|
|
||||||
# Трекинг времени интервью
|
# Трекинг времени интервью
|
||||||
self.interview_start_time = None # Устанавливается при фактическом старте
|
self.interview_start_time = None # Устанавливается при фактическом старте
|
||||||
self.interview_end_time = None # Устанавливается при завершении
|
self.interview_end_time = None # Устанавливается при завершении
|
||||||
self.duration_minutes = interview_plan.get("interview_structure", {}).get(
|
self.duration_minutes = interview_plan.get("interview_structure", {}).get(
|
||||||
"duration_minutes", 10
|
"duration_minutes", 10
|
||||||
)
|
)
|
||||||
@ -123,7 +123,7 @@ class InterviewAgent:
|
|||||||
# Вычисляем текущее время интервью
|
# Вычисляем текущее время интервью
|
||||||
time_info = self.get_time_info()
|
time_info = self.get_time_info()
|
||||||
elapsed_minutes = time_info["elapsed_minutes"]
|
elapsed_minutes = time_info["elapsed_minutes"]
|
||||||
remaining_minutes = time_info["remaining_minutes"]
|
remaining_minutes = time_info["remaining_minutes"]
|
||||||
time_percentage = time_info["time_percentage"]
|
time_percentage = time_info["time_percentage"]
|
||||||
|
|
||||||
# Формируем план интервью для агента
|
# Формируем план интервью для агента
|
||||||
@ -154,40 +154,41 @@ class InterviewAgent:
|
|||||||
if self.vacancy_data:
|
if self.vacancy_data:
|
||||||
employment_type_map = {
|
employment_type_map = {
|
||||||
"full": "Полная занятость",
|
"full": "Полная занятость",
|
||||||
"part": "Частичная занятость",
|
"part": "Частичная занятость",
|
||||||
"project": "Проектная работа",
|
"project": "Проектная работа",
|
||||||
"volunteer": "Волонтёрство",
|
"volunteer": "Волонтёрство",
|
||||||
"probation": "Стажировка"
|
"probation": "Стажировка",
|
||||||
}
|
}
|
||||||
experience_map = {
|
experience_map = {
|
||||||
"noExperience": "Без опыта",
|
"noExperience": "Без опыта",
|
||||||
"between1And3": "1-3 года",
|
"between1And3": "1-3 года",
|
||||||
"between3And6": "3-6 лет",
|
"between3And6": "3-6 лет",
|
||||||
"moreThan6": "Более 6 лет"
|
"moreThan6": "Более 6 лет",
|
||||||
}
|
}
|
||||||
schedule_map = {
|
schedule_map = {
|
||||||
"fullDay": "Полный день",
|
"fullDay": "Полный день",
|
||||||
"shift": "Сменный график",
|
"shift": "Сменный график",
|
||||||
"flexible": "Гибкий график",
|
"flexible": "Гибкий график",
|
||||||
"remote": "Удалённая работа",
|
"remote": "Удалённая работа",
|
||||||
"flyInFlyOut": "Вахтовый метод"
|
"flyInFlyOut": "Вахтовый метод",
|
||||||
}
|
}
|
||||||
|
|
||||||
vacancy_info = f"""
|
vacancy_info = f"""
|
||||||
|
|
||||||
ИНФОРМАЦИЯ О ВАКАНСИИ:
|
ИНФОРМАЦИЯ О ВАКАНСИИ:
|
||||||
- Должность: {self.vacancy_data.get('title', 'Не указана')}
|
- Должность: {self.vacancy_data.get("title", "Не указана")}
|
||||||
- Описание: {self.vacancy_data.get('description', 'Не указано')}
|
- Описание: {self.vacancy_data.get("description", "Не указано")}
|
||||||
- Ключевые навыки: {self.vacancy_data.get('key_skills') or 'Не указаны'}
|
- Ключевые навыки: {self.vacancy_data.get("key_skills") or "Не указаны"}
|
||||||
- Тип занятости: {employment_type_map.get(self.vacancy_data.get('employment_type'), self.vacancy_data.get('employment_type', 'Не указан'))}
|
- Тип занятости: {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', 'Не указан'))}
|
- Опыт работы: {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', 'Не указан'))}
|
- График работы: {schedule_map.get(self.vacancy_data.get("schedule"), self.vacancy_data.get("schedule", "Не указан"))}
|
||||||
- Регион: {self.vacancy_data.get('area_name', 'Не указан')}
|
- Регион: {self.vacancy_data.get("area_name", "Не указан")}
|
||||||
- Профессиональные роли: {self.vacancy_data.get('professional_roles') or 'Не указаны'}
|
- Профессиональные роли: {self.vacancy_data.get("professional_roles") or "Не указаны"}
|
||||||
- Контактное лицо: {self.vacancy_data.get('contacts_name') or 'Не указано'}"""
|
- Контактное лицо: {self.vacancy_data.get("contacts_name") or "Не указано"}"""
|
||||||
|
|
||||||
return f"""
|
return f"""
|
||||||
Ты опытный HR-интервьюер Стефани, который проводит адаптивное голосовое собеседование. Представься контактным именем из вакансии (если оно есть)
|
Ты опытный HR-интервьюер Стефани, который проводит адаптивное голосовое собеседование. Представься как Стефани
|
||||||
|
Разговаривай только на русском языке.
|
||||||
|
|
||||||
ИНФОРМАЦИЯ О ВАКАНСИИ:
|
ИНФОРМАЦИЯ О ВАКАНСИИ:
|
||||||
|
|
||||||
@ -197,6 +198,7 @@ class InterviewAgent:
|
|||||||
- Имя: {candidate_name}
|
- Имя: {candidate_name}
|
||||||
- Опыт работы: {candidate_years} лет
|
- Опыт работы: {candidate_years} лет
|
||||||
- Ключевые навыки: {candidate_skills}
|
- Ключевые навыки: {candidate_skills}
|
||||||
|
Из имени определи пол и упоминай кандидата исходя из пола
|
||||||
|
|
||||||
ЦЕЛЬ ИНТЕРВЬЮ:
|
ЦЕЛЬ ИНТЕРВЬЮ:
|
||||||
|
|
||||||
@ -213,7 +215,7 @@ class InterviewAgent:
|
|||||||
- Способность учиться и адаптироваться.
|
- Способность учиться и адаптироваться.
|
||||||
- Совпадение ценностей и принципов с командой и компанией.
|
- Совпадение ценностей и принципов с командой и компанией.
|
||||||
|
|
||||||
ПЛАН ИНТЕРВЬЮ (как руководство, адаптируйся по ситуации)
|
ПЛАН ИНТЕРВЬЮ (имей его ввиду, но адаптируйся под ситуацию: либо углубиться в детали, либо перейти к следующему вопросу)
|
||||||
|
|
||||||
{sections_info}
|
{sections_info}
|
||||||
|
|
||||||
@ -227,9 +229,8 @@ class InterviewAgent:
|
|||||||
Проблемные / кейсы (20%) — проверить мышление и подход к решению.
|
Проблемные / кейсы (20%) — проверить мышление и подход к решению.
|
||||||
Пример: "У нас есть система, которая падает раз в неделю. Как бы ты подошёл к диагностике проблемы?"
|
Пример: "У нас есть система, которая падает раз в неделю. Как бы ты подошёл к диагностике проблемы?"
|
||||||
|
|
||||||
Задавай вопросы кратко и понятно. Не вываливай кучу информации на человека.
|
Задавай вопросы кратко и понятно (максимум тремя предложениями). Не вываливай кучу информации на кандидата.
|
||||||
Не перечисляй человеку все пункты и вопросы из секции. Предлагай один общий вопрос или задавай уточняющие по по очереди.
|
Не перечисляй человеку все пункты и вопросы из секции. Предлагай один общий вопрос или задавай уточняющие по по очереди.
|
||||||
Ты должна спрашивать вопросы максимум в 3 предложения
|
|
||||||
|
|
||||||
ВРЕМЯ ИНТЕРВЬЮ:
|
ВРЕМЯ ИНТЕРВЬЮ:
|
||||||
- Запланированная длительность: {self.duration_minutes} минут
|
- Запланированная длительность: {self.duration_minutes} минут
|
||||||
@ -251,7 +252,7 @@ class InterviewAgent:
|
|||||||
ИНСТРУКЦИИ:
|
ИНСТРУКЦИИ:
|
||||||
1. Начни с приветствия: {greeting}
|
1. Начни с приветствия: {greeting}
|
||||||
2. Адаптируй вопросы под ответы кандидата
|
2. Адаптируй вопросы под ответы кандидата
|
||||||
3. Не повторяй то, что клиент тебе сказал, лучше показывай, что понял, услышал и иди дальше. Лишний раз его не хвали
|
3. Не повторяй то, что клиент тебе сказал, лучше показывай, что поняла, услышала, и иди дальше. Лишний раз его не хвали
|
||||||
3. Следи за временем - при превышении 80% времени начинай завершать интервью
|
3. Следи за временем - при превышении 80% времени начинай завершать интервью
|
||||||
4. Оценивай качество и глубину ответов кандидата
|
4. Оценивай качество и глубину ответов кандидата
|
||||||
5. Если получаешь сообщение "[СИСТЕМА] Клиент молчит..." - это означает проблемы со связью или кандидат растерялся. Скажи что-то вроде "Приём! Ты меня слышишь?" или "Всё в порядке? Связь не пропала?"
|
5. Если получаешь сообщение "[СИСТЕМА] Клиент молчит..." - это означает проблемы со связью или кандидат растерялся. Скажи что-то вроде "Приём! Ты меня слышишь?" или "Всё в порядке? Связь не пропала?"
|
||||||
@ -282,7 +283,6 @@ class InterviewAgent:
|
|||||||
|
|
||||||
def get_time_info(self) -> dict[str, float]:
|
def get_time_info(self) -> dict[str, float]:
|
||||||
"""Получает информацию о времени интервью"""
|
"""Получает информацию о времени интервью"""
|
||||||
import time
|
|
||||||
|
|
||||||
if self.interview_start_time is None:
|
if self.interview_start_time is None:
|
||||||
# Интервью еще не началось
|
# Интервью еще не началось
|
||||||
@ -294,7 +294,9 @@ class InterviewAgent:
|
|||||||
current_time = self.interview_end_time or time.time()
|
current_time = self.interview_end_time or time.time()
|
||||||
elapsed_minutes = (current_time - self.interview_start_time) / 60
|
elapsed_minutes = (current_time - self.interview_start_time) / 60
|
||||||
remaining_minutes = max(0.0, self.duration_minutes - elapsed_minutes)
|
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 {
|
return {
|
||||||
"elapsed_minutes": elapsed_minutes,
|
"elapsed_minutes": elapsed_minutes,
|
||||||
@ -366,7 +368,9 @@ async def entrypoint(ctx: JobContext):
|
|||||||
session_id = metadata.get("session_id", session_id)
|
session_id = metadata.get("session_id", session_id)
|
||||||
logger.info(f"[INIT] Loaded interview plan for session {session_id}")
|
logger.info(f"[INIT] Loaded interview plan for session {session_id}")
|
||||||
if vacancy_data:
|
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:
|
except Exception as e:
|
||||||
logger.warning(f"[INIT] Failed to load metadata: {str(e)}")
|
logger.warning(f"[INIT] Failed to load metadata: {str(e)}")
|
||||||
interview_plan = {}
|
interview_plan = {}
|
||||||
@ -409,42 +413,24 @@ async def entrypoint(ctx: JobContext):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# STT
|
# STT
|
||||||
stt = (
|
stt = openai.STT(model="whisper-1", language="ru", api_key=settings.openai_api_key)
|
||||||
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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# LLM
|
# LLM
|
||||||
llm = openai.LLM(
|
llm = openai.LLM(model="gpt-5-mini", api_key=settings.openai_api_key)
|
||||||
model="gpt-5-mini", api_key=settings.openai_api_key
|
|
||||||
)
|
|
||||||
|
|
||||||
# TTS
|
# TTS
|
||||||
tts = (
|
tts = openai.TTS(model="tts-1-hd", api_key=settings.openai_api_key, voice="nova")
|
||||||
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")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Создаем обычный Agent и Session
|
# Создаем обычный Agent и Session
|
||||||
agent = Agent(instructions=interviewer.get_system_instructions())
|
agent = Agent(instructions=interviewer.get_system_instructions())
|
||||||
|
|
||||||
# Создаем AgentSession с обычным TTS и детекцией неактивности пользователя
|
# Создаем AgentSession с обычным TTS и детекцией неактивности пользователя
|
||||||
session = AgentSession(
|
session = AgentSession(
|
||||||
vad=silero.VAD.load(),
|
vad=silero.VAD.load(),
|
||||||
stt=stt,
|
stt=stt,
|
||||||
llm=llm,
|
llm=llm,
|
||||||
tts=tts,
|
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
|
return
|
||||||
|
|
||||||
interviewer_instance.interview_finalized = True
|
interviewer_instance.interview_finalized = True
|
||||||
|
|
||||||
# Устанавливаем время завершения интервью
|
# Устанавливаем время завершения интервью
|
||||||
import time
|
|
||||||
interviewer_instance.interview_end_time = time.time()
|
interviewer_instance.interview_end_time = time.time()
|
||||||
|
|
||||||
if interviewer_instance.interview_start_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(
|
logger.info(
|
||||||
f"[TIME] Interview ended at {time.strftime('%H:%M:%S')}, total duration: {total_minutes:.1f} min"
|
f"[TIME] Interview ended at {time.strftime('%H:%M:%S')}, total duration: {total_minutes:.1f} min"
|
||||||
)
|
)
|
||||||
else:
|
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:
|
try:
|
||||||
logger.info(
|
logger.info(
|
||||||
@ -553,9 +544,7 @@ async def entrypoint(ctx: JobContext):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not interviewer.interview_finalized:
|
if not interviewer.interview_finalized:
|
||||||
await complete_interview_sequence(
|
await complete_interview_sequence(ctx.room.name, interviewer)
|
||||||
ctx.room.name, interviewer
|
|
||||||
)
|
|
||||||
break
|
break
|
||||||
|
|
||||||
return False
|
return False
|
||||||
@ -595,7 +584,7 @@ async def entrypoint(ctx: JobContext):
|
|||||||
f"[TIME_LIMIT] Interview exceeded {TIME_LIMIT_MINUTES} minutes "
|
f"[TIME_LIMIT] Interview exceeded {TIME_LIMIT_MINUTES} minutes "
|
||||||
f"({time_info['elapsed_minutes']:.1f} min), forcing completion"
|
f"({time_info['elapsed_minutes']:.1f} min), forcing completion"
|
||||||
)
|
)
|
||||||
|
|
||||||
if not interviewer.interview_finalized:
|
if not interviewer.interview_finalized:
|
||||||
await complete_interview_sequence(
|
await complete_interview_sequence(
|
||||||
ctx.room.name, interviewer
|
ctx.room.name, interviewer
|
||||||
@ -610,18 +599,19 @@ async def entrypoint(ctx: JobContext):
|
|||||||
|
|
||||||
# Запускаем мониторинг команд в фоне
|
# Запускаем мониторинг команд в фоне
|
||||||
asyncio.create_task(monitor_end_commands())
|
asyncio.create_task(monitor_end_commands())
|
||||||
|
|
||||||
@session.on("user_state_changed")
|
@session.on("user_state_changed")
|
||||||
def on_user_state_changed(event):
|
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}")
|
logger.info(f"[USER_STATE] User state changed to: {event.new_state}")
|
||||||
|
|
||||||
# === Пользователь молчит более 10 секунд (state == away) ===
|
# === Пользователь молчит более 10 секунд (state == away) ===
|
||||||
if event.new_state == "away" and interviewer.intro_done:
|
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(
|
await session.generate_reply(
|
||||||
@ -673,21 +663,18 @@ async def entrypoint(ctx: JobContext):
|
|||||||
"room_name": room_name,
|
"room_name": room_name,
|
||||||
"timestamp": datetime.now(UTC).isoformat(),
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
with open(command_file, "w", encoding="utf-8") as f:
|
with open(command_file, "w", encoding="utf-8") as f:
|
||||||
json.dump(release_command, f, ensure_ascii=False, indent=2)
|
json.dump(release_command, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
logger.info(f"[SEQUENCE] Step 3: Session {session_id} release signal sent")
|
logger.info(f"[SEQUENCE] Step 3: Session {session_id} release signal sent")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[SEQUENCE] Step 3: Failed to send release signal: {str(e)}")
|
logger.error(f"[SEQUENCE] Step 3: Failed to send release signal: {str(e)}")
|
||||||
logger.info("[SEQUENCE] Step 3: Continuing without release signal")
|
logger.info("[SEQUENCE] Step 3: Continuing without release signal")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# --- Упрощенная логика обработки пользовательского ответа ---
|
# --- Упрощенная логика обработки пользовательского ответа ---
|
||||||
async def handle_user_input(user_response: str):
|
async def handle_user_input(user_response: str):
|
||||||
|
|
||||||
current_section = interviewer.get_current_section()
|
current_section = interviewer.get_current_section()
|
||||||
|
|
||||||
# Сохраняем ответ пользователя
|
# Сохраняем ответ пользователя
|
||||||
@ -707,6 +694,7 @@ async def entrypoint(ctx: JobContext):
|
|||||||
interviewer.intro_done = True
|
interviewer.intro_done = True
|
||||||
# Устанавливаем время начала интервью при первом сообщении
|
# Устанавливаем время начала интервью при первом сообщении
|
||||||
import time
|
import time
|
||||||
|
|
||||||
interviewer.interview_start_time = time.time()
|
interviewer.interview_start_time = time.time()
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[TIME] Interview started at {time.strftime('%H:%M:%S')}, duration: {interviewer.duration_minutes} min"
|
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":
|
if role == "user":
|
||||||
asyncio.create_task(handle_user_input(text))
|
asyncio.create_task(handle_user_input(text))
|
||||||
elif role == "assistant":
|
elif role == "assistant":
|
||||||
|
|
||||||
# Сохраняем ответ агента в историю диалога
|
# Сохраняем ответ агента в историю диалога
|
||||||
current_section = interviewer.get_current_section()
|
current_section = interviewer.get_current_section()
|
||||||
interviewer.conversation_history.append(
|
interviewer.conversation_history.append(
|
||||||
|
@ -18,7 +18,11 @@ class S3Service:
|
|||||||
self.bucket_name = settings.s3_bucket_name
|
self.bucket_name = settings.s3_bucket_name
|
||||||
|
|
||||||
async def upload_file(
|
async def upload_file(
|
||||||
self, file_content: bytes, file_name: str, content_type: str, public: bool = False
|
self,
|
||||||
|
file_content: bytes,
|
||||||
|
file_name: str,
|
||||||
|
content_type: str,
|
||||||
|
public: bool = False,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
try:
|
try:
|
||||||
file_key = f"{uuid.uuid4()}_{file_name}"
|
file_key = f"{uuid.uuid4()}_{file_name}"
|
||||||
@ -29,13 +33,15 @@ class S3Service:
|
|||||||
"Body": file_content,
|
"Body": file_content,
|
||||||
"ContentType": content_type,
|
"ContentType": content_type,
|
||||||
}
|
}
|
||||||
|
|
||||||
if public:
|
if public:
|
||||||
put_object_kwargs["ACL"] = "public-read"
|
put_object_kwargs["ACL"] = "public-read"
|
||||||
|
|
||||||
self.s3_client.put_object(**put_object_kwargs)
|
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
|
return file_url
|
||||||
|
|
||||||
except ClientError as e:
|
except ClientError as e:
|
||||||
|
@ -38,12 +38,17 @@ class InterviewSession(InterviewSessionBase, table=True):
|
|||||||
id: int | None = Field(default=None, primary_key=True)
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
started_at: datetime = Field(default_factory=datetime.utcnow)
|
started_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
completed_at: datetime | None = None
|
completed_at: datetime | None = None
|
||||||
|
interview_start_time: datetime | None = None
|
||||||
|
interview_end_time: datetime | None = None
|
||||||
|
|
||||||
# Связь с отчетом (один к одному)
|
# Связь с отчетом (один к одному)
|
||||||
report: Optional["InterviewReport"] = Relationship(
|
report: Optional["InterviewReport"] = Relationship(
|
||||||
back_populates="interview_session"
|
back_populates="interview_session"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Связь с резюме
|
||||||
|
resume: Optional["Resume"] = Relationship()
|
||||||
|
|
||||||
|
|
||||||
class InterviewSessionCreate(SQLModel):
|
class InterviewSessionCreate(SQLModel):
|
||||||
resume_id: int
|
resume_id: int
|
||||||
|
@ -6,8 +6,8 @@ from sqlalchemy import select, update
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.database import get_session
|
from app.core.database import get_session
|
||||||
from app.models.interview_report import InterviewReport
|
|
||||||
from app.models.interview import InterviewSession
|
from app.models.interview import InterviewSession
|
||||||
|
from app.models.interview_report import InterviewReport
|
||||||
from app.models.resume import Resume
|
from app.models.resume import Resume
|
||||||
from app.models.vacancy import Vacancy
|
from app.models.vacancy import Vacancy
|
||||||
from app.repositories.base_repository import BaseRepository
|
from app.repositories.base_repository import BaseRepository
|
||||||
@ -64,7 +64,10 @@ class InterviewReportRepository(BaseRepository[InterviewReport]):
|
|||||||
"""Получить все отчёты по вакансии"""
|
"""Получить все отчёты по вакансии"""
|
||||||
statement = (
|
statement = (
|
||||||
select(InterviewReport)
|
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(Resume, Resume.id == InterviewSession.resume_id)
|
||||||
.join(Vacancy, Vacancy.id == Resume.vacancy_id)
|
.join(Vacancy, Vacancy.id == Resume.vacancy_id)
|
||||||
.where(Vacancy.id == vacancy_id)
|
.where(Vacancy.id == vacancy_id)
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
@ -128,42 +127,41 @@ async def force_end_interview(session_id: int) -> dict:
|
|||||||
try:
|
try:
|
||||||
# Получаем статус агента
|
# Получаем статус агента
|
||||||
agent_status = agent_manager.get_status()
|
agent_status = agent_manager.get_status()
|
||||||
|
|
||||||
if agent_status["status"] != "active":
|
if agent_status["status"] != "active":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Agent is not active, current status: {agent_status['status']}"
|
detail=f"Agent is not active, current status: {agent_status['status']}",
|
||||||
)
|
)
|
||||||
|
|
||||||
if agent_status["session_id"] != session_id:
|
if agent_status["session_id"] != session_id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
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"
|
command_file = "agent_commands.json"
|
||||||
end_command = {
|
end_command = {
|
||||||
"action": "end_session",
|
"action": "end_session",
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
"timestamp": datetime.now(UTC).isoformat(),
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
"initiated_by": "admin_api"
|
"initiated_by": "admin_api",
|
||||||
}
|
}
|
||||||
|
|
||||||
with open(command_file, "w", encoding="utf-8") as f:
|
with open(command_file, "w", encoding="utf-8") as f:
|
||||||
json.dump(end_command, f, ensure_ascii=False, indent=2)
|
json.dump(end_command, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Force end command sent for session {session_id}",
|
"message": f"Force end command sent for session {session_id}",
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
"command_file": command_file
|
"command_file": command_file,
|
||||||
}
|
}
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500, detail=f"Failed to send force end command: {str(e)}"
|
||||||
detail=f"Failed to send force end command: {str(e)}"
|
|
||||||
)
|
)
|
||||||
|
@ -4,7 +4,6 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from app.core.database import get_session
|
from app.core.database import get_session
|
||||||
from app.repositories.resume_repository import ResumeRepository
|
from app.repositories.resume_repository import ResumeRepository
|
||||||
from app.services.pdf_report_service import PDFReportService
|
|
||||||
from celery_worker.interview_analysis_task import (
|
from celery_worker.interview_analysis_task import (
|
||||||
analyze_multiple_candidates,
|
analyze_multiple_candidates,
|
||||||
generate_interview_report,
|
generate_interview_report,
|
||||||
@ -300,23 +299,23 @@ async def get_pdf_report(
|
|||||||
return RedirectResponse(url=report.pdf_report_url, status_code=302)
|
return RedirectResponse(url=report.pdf_report_url, status_code=302)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/generate-pdf/{resume_id}", response_model=PDFGenerationResponse)
|
@router.post("/generate-pdf/{resume_id}")
|
||||||
async def generate_pdf_report(
|
async def generate_pdf_report(
|
||||||
resume_id: int,
|
resume_id: int,
|
||||||
session=Depends(get_session),
|
session=Depends(get_session),
|
||||||
resume_repo: ResumeRepository = Depends(ResumeRepository),
|
resume_repo: ResumeRepository = Depends(ResumeRepository),
|
||||||
pdf_report_service: PDFReportService = Depends(PDFReportService),
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Генерирует PDF отчет по интервью
|
Запускает асинхронную генерацию PDF отчета по интервью
|
||||||
|
|
||||||
Проверяет наличие отчета в базе данных и генерирует PDF файл.
|
Проверяет наличие отчета в базе данных и запускает Celery задачу для генерации PDF файла.
|
||||||
Если PDF уже существует, возвращает существующий URL.
|
Если PDF уже существует, возвращает существующий URL.
|
||||||
"""
|
"""
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
|
|
||||||
from app.models.interview import InterviewSession
|
from app.models.interview import InterviewSession
|
||||||
from app.models.interview_report import InterviewReport
|
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)
|
resume = await resume_repo.get_by_id(resume_id)
|
||||||
@ -346,57 +345,132 @@ async def generate_pdf_report(
|
|||||||
|
|
||||||
# Если PDF уже существует, возвращаем его
|
# Если PDF уже существует, возвращаем его
|
||||||
if report.pdf_report_url:
|
if report.pdf_report_url:
|
||||||
return PDFGenerationResponse(
|
return {
|
||||||
message="PDF report already exists",
|
"message": "PDF report already exists",
|
||||||
resume_id=resume_id,
|
"resume_id": resume_id,
|
||||||
candidate_name=resume.applicant_name,
|
"report_id": report.id,
|
||||||
pdf_url=report.pdf_report_url,
|
"candidate_name": resume.applicant_name,
|
||||||
status="exists",
|
"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:
|
try:
|
||||||
# Получаем позицию из связанной вакансии
|
task_result = celery_app.AsyncResult(task_id)
|
||||||
from app.models.vacancy import Vacancy
|
|
||||||
|
|
||||||
vacancy_stmt = select(Vacancy).where(Vacancy.id == resume.vacancy_id)
|
|
||||||
vacancy_result = await session.execute(vacancy_stmt)
|
|
||||||
vacancy = vacancy_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
position = vacancy.title if vacancy else "Позиция не указана"
|
|
||||||
|
|
||||||
# Генерируем и загружаем PDF
|
|
||||||
pdf_url = await pdf_report_service.generate_and_upload_pdf(
|
|
||||||
report, resume.applicant_name, position
|
|
||||||
)
|
|
||||||
|
|
||||||
if not pdf_url:
|
if task_result.state == "PENDING":
|
||||||
raise HTTPException(
|
return {
|
||||||
status_code=500, detail="Failed to generate or upload PDF report"
|
"task_id": task_id,
|
||||||
)
|
"status": "pending",
|
||||||
|
"message": "Task is waiting to be processed",
|
||||||
# Обновляем отчет в БД
|
}
|
||||||
from sqlmodel import update
|
elif task_result.state == "PROGRESS":
|
||||||
|
return {
|
||||||
stmt = (
|
"task_id": task_id,
|
||||||
update(InterviewReport)
|
"status": "in_progress",
|
||||||
.where(InterviewReport.id == report.id)
|
"progress": task_result.info.get("progress", 0),
|
||||||
.values(pdf_report_url=pdf_url)
|
"message": task_result.info.get("status", "Processing..."),
|
||||||
)
|
}
|
||||||
await session.execute(stmt)
|
elif task_result.state == "SUCCESS":
|
||||||
await session.commit()
|
result = task_result.result
|
||||||
|
return {
|
||||||
return PDFGenerationResponse(
|
"task_id": task_id,
|
||||||
message="PDF report generated successfully",
|
"status": "completed",
|
||||||
resume_id=resume_id,
|
"progress": 100,
|
||||||
candidate_name=resume.applicant_name,
|
"message": "PDF generation completed successfully",
|
||||||
pdf_url=pdf_url,
|
"pdf_url": result.get("pdf_url"),
|
||||||
status="generated",
|
"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:
|
except Exception as e:
|
||||||
raise HTTPException(
|
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
|
from app.models.vacancy import Vacancy
|
||||||
|
|
||||||
vacancy_stmt = select(Vacancy).where(Vacancy.id == resume.vacancy_id)
|
vacancy_stmt = select(Vacancy).where(Vacancy.id == resume.vacancy_id)
|
||||||
vacancy_result = await session.execute(vacancy_stmt)
|
vacancy_result = await session.execute(vacancy_stmt)
|
||||||
vacancy = vacancy_result.scalar_one_or_none()
|
vacancy = vacancy_result.scalar_one_or_none()
|
||||||
|
|
||||||
position = vacancy.title if vacancy else "Позиция не указана"
|
position = vacancy.title if vacancy else "Позиция не указана"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -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.core.session_middleware import get_current_session
|
||||||
from app.models.session import Session
|
|
||||||
from app.models.interview_report import InterviewReport
|
from app.models.interview_report import InterviewReport
|
||||||
|
from app.models.session import Session
|
||||||
from app.services.interview_reports_service import InterviewReportService
|
from app.services.interview_reports_service import InterviewReportService
|
||||||
|
|
||||||
router = APIRouter(prefix="/interview-reports", tags=["interview-reports"])
|
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(
|
async def get_reports_by_vacancy(
|
||||||
vacancy_id: int,
|
vacancy_id: int,
|
||||||
current_session: Session = Depends(get_current_session),
|
current_session: Session = Depends(get_current_session),
|
||||||
|
@ -2,14 +2,15 @@ from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Upload
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.models.vacancy import VacancyCreate, VacancyRead, VacancyUpdate
|
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_parser_service import vacancy_parser_service
|
||||||
|
from app.services.vacancy_service import VacancyService
|
||||||
|
|
||||||
router = APIRouter(prefix="/vacancies", tags=["vacancies"])
|
router = APIRouter(prefix="/vacancies", tags=["vacancies"])
|
||||||
|
|
||||||
|
|
||||||
class VacancyParseResponse(BaseModel):
|
class VacancyParseResponse(BaseModel):
|
||||||
"""Ответ на запрос парсинга вакансии"""
|
"""Ответ на запрос парсинга вакансии"""
|
||||||
|
|
||||||
message: str
|
message: str
|
||||||
parsed_data: dict | None = None
|
parsed_data: dict | None = None
|
||||||
task_id: str | None = None
|
task_id: str | None = None
|
||||||
@ -97,43 +98,49 @@ async def parse_vacancy_from_file(
|
|||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Парсинг вакансии из загруженного файла (PDF, DOCX, RTF, TXT)
|
Парсинг вакансии из загруженного файла (PDF, DOCX, RTF, TXT)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file: Файл вакансии
|
file: Файл вакансии
|
||||||
create_vacancy: Создать вакансию в БД после парсинга
|
create_vacancy: Создать вакансию в БД после парсинга
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
VacancyParseResponse: Результат парсинга
|
VacancyParseResponse: Результат парсинга
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Проверяем формат файла
|
# Проверяем формат файла
|
||||||
if not file.filename:
|
if not file.filename:
|
||||||
raise HTTPException(status_code=400, detail="Имя файла не указано")
|
raise HTTPException(status_code=400, detail="Имя файла не указано")
|
||||||
|
|
||||||
file_extension = file.filename.lower().split('.')[-1]
|
file_extension = file.filename.lower().split(".")[-1]
|
||||||
supported_formats = ['pdf', 'docx', 'rtf', 'txt']
|
supported_formats = ["pdf", "docx", "rtf", "txt"]
|
||||||
|
|
||||||
if file_extension not in supported_formats:
|
if file_extension not in supported_formats:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Неподдерживаемый формат файла. Поддерживаются: {', '.join(supported_formats)}"
|
detail=f"Неподдерживаемый формат файла. Поддерживаются: {', '.join(supported_formats)}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Проверяем размер файла (максимум 10MB)
|
# Проверяем размер файла (максимум 10MB)
|
||||||
file_content = await file.read()
|
file_content = await file.read()
|
||||||
if len(file_content) > 10 * 1024 * 1024:
|
if len(file_content) > 10 * 1024 * 1024:
|
||||||
raise HTTPException(status_code=400, detail="Файл слишком большой (максимум 10MB)")
|
raise HTTPException(
|
||||||
|
status_code=400, detail="Файл слишком большой (максимум 10MB)"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
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():
|
if not raw_text.strip():
|
||||||
raise HTTPException(status_code=400, detail="Не удалось извлечь текст из файла")
|
raise HTTPException(
|
||||||
|
status_code=400, detail="Не удалось извлечь текст из файла"
|
||||||
|
)
|
||||||
|
|
||||||
# Парсим с помощью AI
|
# Парсим с помощью AI
|
||||||
parsed_data = await vacancy_parser_service.parse_vacancy_with_ai(raw_text)
|
parsed_data = await vacancy_parser_service.parse_vacancy_with_ai(raw_text)
|
||||||
|
|
||||||
# Если нужно создать вакансию, создаем её
|
# Если нужно создать вакансию, создаем её
|
||||||
created_vacancy = None
|
created_vacancy = None
|
||||||
if create_vacancy:
|
if create_vacancy:
|
||||||
@ -144,22 +151,21 @@ async def parse_vacancy_from_file(
|
|||||||
# Возвращаем парсинг, но предупреждаем об ошибке создания
|
# Возвращаем парсинг, но предупреждаем об ошибке создания
|
||||||
return VacancyParseResponse(
|
return VacancyParseResponse(
|
||||||
message=f"Парсинг выполнен, но ошибка при создании вакансии: {str(e)}",
|
message=f"Парсинг выполнен, но ошибка при создании вакансии: {str(e)}",
|
||||||
parsed_data=parsed_data
|
parsed_data=parsed_data,
|
||||||
)
|
)
|
||||||
|
|
||||||
response_message = "Парсинг выполнен успешно"
|
response_message = "Парсинг выполнен успешно"
|
||||||
if created_vacancy:
|
if created_vacancy:
|
||||||
response_message += f". Вакансия создана с ID: {created_vacancy.id}"
|
response_message += f". Вакансия создана с ID: {created_vacancy.id}"
|
||||||
|
|
||||||
return VacancyParseResponse(
|
return VacancyParseResponse(message=response_message, parsed_data=parsed_data)
|
||||||
message=response_message,
|
|
||||||
parsed_data=parsed_data
|
|
||||||
)
|
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as 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)
|
@router.post("/parse-text", response_model=VacancyParseResponse)
|
||||||
@ -170,25 +176,29 @@ async def parse_vacancy_from_text(
|
|||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Парсинг вакансии из текста
|
Парсинг вакансии из текста
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: Текст вакансии
|
text: Текст вакансии
|
||||||
create_vacancy: Создать вакансию в БД после парсинга
|
create_vacancy: Создать вакансию в БД после парсинга
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
VacancyParseResponse: Результат парсинга
|
VacancyParseResponse: Результат парсинга
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not text.strip():
|
if not text.strip():
|
||||||
raise HTTPException(status_code=400, detail="Текст вакансии не может быть пустым")
|
raise HTTPException(
|
||||||
|
status_code=400, detail="Текст вакансии не может быть пустым"
|
||||||
|
)
|
||||||
|
|
||||||
if len(text) > 50000: # Ограничение на длину текста
|
if len(text) > 50000: # Ограничение на длину текста
|
||||||
raise HTTPException(status_code=400, detail="Текст слишком длинный (максимум 50000 символов)")
|
raise HTTPException(
|
||||||
|
status_code=400, detail="Текст слишком длинный (максимум 50000 символов)"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Парсим с помощью AI
|
# Парсим с помощью AI
|
||||||
parsed_data = await vacancy_parser_service.parse_vacancy_with_ai(text)
|
parsed_data = await vacancy_parser_service.parse_vacancy_with_ai(text)
|
||||||
|
|
||||||
# Если нужно создать вакансию, создаем её
|
# Если нужно создать вакансию, создаем её
|
||||||
created_vacancy = None
|
created_vacancy = None
|
||||||
if create_vacancy:
|
if create_vacancy:
|
||||||
@ -198,61 +208,48 @@ async def parse_vacancy_from_text(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return VacancyParseResponse(
|
return VacancyParseResponse(
|
||||||
message=f"Парсинг выполнен, но ошибка при создании вакансии: {str(e)}",
|
message=f"Парсинг выполнен, но ошибка при создании вакансии: {str(e)}",
|
||||||
parsed_data=parsed_data
|
parsed_data=parsed_data,
|
||||||
)
|
)
|
||||||
|
|
||||||
response_message = "Парсинг выполнен успешно"
|
response_message = "Парсинг выполнен успешно"
|
||||||
if created_vacancy:
|
if created_vacancy:
|
||||||
response_message += f". Вакансия создана с ID: {created_vacancy.id}"
|
response_message += f". Вакансия создана с ID: {created_vacancy.id}"
|
||||||
|
|
||||||
return VacancyParseResponse(
|
return VacancyParseResponse(message=response_message, parsed_data=parsed_data)
|
||||||
message=response_message,
|
|
||||||
parsed_data=parsed_data
|
|
||||||
)
|
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as 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")
|
@router.get("/parse-formats")
|
||||||
async def get_supported_formats():
|
async def get_supported_formats():
|
||||||
"""
|
"""
|
||||||
Получить список поддерживаемых форматов файлов для парсинга вакансий
|
Получить список поддерживаемых форматов файлов для парсинга вакансий
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Информация о поддерживаемых форматах
|
dict: Информация о поддерживаемых форматах
|
||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
"supported_formats": [
|
"supported_formats": [
|
||||||
|
{"extension": "pdf", "description": "PDF документы", "max_size_mb": 10},
|
||||||
{
|
{
|
||||||
"extension": "pdf",
|
"extension": "docx",
|
||||||
"description": "PDF документы",
|
|
||||||
"max_size_mb": 10
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"extension": "docx",
|
|
||||||
"description": "Microsoft Word документы",
|
"description": "Microsoft Word документы",
|
||||||
"max_size_mb": 10
|
"max_size_mb": 10,
|
||||||
},
|
},
|
||||||
{
|
{"extension": "rtf", "description": "Rich Text Format", "max_size_mb": 10},
|
||||||
"extension": "rtf",
|
{"extension": "txt", "description": "Текстовые файлы", "max_size_mb": 10},
|
||||||
"description": "Rich Text Format",
|
|
||||||
"max_size_mb": 10
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"extension": "txt",
|
|
||||||
"description": "Текстовые файлы",
|
|
||||||
"max_size_mb": 10
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
"features": [
|
"features": [
|
||||||
"Автоматическое извлечение текста из файлов",
|
"Автоматическое извлечение текста из файлов",
|
||||||
"AI-парсинг структурированной информации",
|
"AI-парсинг структурированной информации",
|
||||||
"Создание вакансии в базе данных",
|
"Создание вакансии в базе данных",
|
||||||
"Валидация данных"
|
"Валидация данных",
|
||||||
]
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -263,108 +260,115 @@ async def parse_vacancy_from_file_async(
|
|||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Асинхронный парсинг вакансии из загруженного файла (PDF, DOCX, RTF, TXT)
|
Асинхронный парсинг вакансии из загруженного файла (PDF, DOCX, RTF, TXT)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file: Файл вакансии
|
file: Файл вакансии
|
||||||
create_vacancy: Создать вакансию в БД после парсинга
|
create_vacancy: Создать вакансию в БД после парсинга
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: ID задачи для отслеживания статуса
|
dict: ID задачи для отслеживания статуса
|
||||||
"""
|
"""
|
||||||
import base64
|
import base64
|
||||||
|
|
||||||
from celery_worker.tasks import parse_vacancy_task
|
from celery_worker.tasks import parse_vacancy_task
|
||||||
|
|
||||||
# Проверяем формат файла
|
# Проверяем формат файла
|
||||||
if not file.filename:
|
if not file.filename:
|
||||||
raise HTTPException(status_code=400, detail="Имя файла не указано")
|
raise HTTPException(status_code=400, detail="Имя файла не указано")
|
||||||
|
|
||||||
file_extension = file.filename.lower().split('.')[-1]
|
file_extension = file.filename.lower().split(".")[-1]
|
||||||
supported_formats = ['pdf', 'docx', 'rtf', 'txt']
|
supported_formats = ["pdf", "docx", "rtf", "txt"]
|
||||||
|
|
||||||
if file_extension not in supported_formats:
|
if file_extension not in supported_formats:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Неподдерживаемый формат файла. Поддерживаются: {', '.join(supported_formats)}"
|
detail=f"Неподдерживаемый формат файла. Поддерживаются: {', '.join(supported_formats)}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Проверяем размер файла (максимум 10MB)
|
# Проверяем размер файла (максимум 10MB)
|
||||||
file_content = await file.read()
|
file_content = await file.read()
|
||||||
if len(file_content) > 10 * 1024 * 1024:
|
if len(file_content) > 10 * 1024 * 1024:
|
||||||
raise HTTPException(status_code=400, detail="Файл слишком большой (максимум 10MB)")
|
raise HTTPException(
|
||||||
|
status_code=400, detail="Файл слишком большой (максимум 10MB)"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Кодируем содержимое файла в base64 для передачи в Celery
|
# Кодируем содержимое файла в base64 для передачи в Celery
|
||||||
file_content_base64 = base64.b64encode(file_content).decode('utf-8')
|
file_content_base64 = base64.b64encode(file_content).decode("utf-8")
|
||||||
|
|
||||||
# Конвертируем строку в boolean
|
# Конвертируем строку в 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(
|
task = parse_vacancy_task.delay(
|
||||||
file_content_base64=file_content_base64,
|
file_content_base64=file_content_base64,
|
||||||
filename=file.filename,
|
filename=file.filename,
|
||||||
create_vacancy=create_vacancy_bool
|
create_vacancy=create_vacancy_bool,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"message": "Задача парсинга запущена",
|
"message": "Задача парсинга запущена",
|
||||||
"task_id": task.id,
|
"task_id": task.id,
|
||||||
"status": "pending",
|
"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:
|
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}")
|
@router.get("/parse-status/{task_id}")
|
||||||
async def get_parse_status(task_id: str):
|
async def get_parse_status(task_id: str):
|
||||||
"""
|
"""
|
||||||
Получить статус асинхронной задачи парсинга вакансии
|
Получить статус асинхронной задачи парсинга вакансии
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
task_id: ID задачи
|
task_id: ID задачи
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Статус задачи и результат (если завершена)
|
dict: Статус задачи и результат (если завершена)
|
||||||
"""
|
"""
|
||||||
from celery_worker.celery_app import celery_app
|
from celery_worker.celery_app import celery_app
|
||||||
|
|
||||||
try:
|
try:
|
||||||
task = celery_app.AsyncResult(task_id)
|
task = celery_app.AsyncResult(task_id)
|
||||||
|
|
||||||
if task.state == 'PENDING':
|
if task.state == "PENDING":
|
||||||
response = {
|
response = {
|
||||||
'task_id': task_id,
|
"task_id": task_id,
|
||||||
'state': task.state,
|
"state": task.state,
|
||||||
'status': 'Задача ожидает выполнения...',
|
"status": "Задача ожидает выполнения...",
|
||||||
'progress': 0
|
"progress": 0,
|
||||||
}
|
}
|
||||||
elif task.state == 'PROGRESS':
|
elif task.state == "PROGRESS":
|
||||||
response = {
|
response = {
|
||||||
'task_id': task_id,
|
"task_id": task_id,
|
||||||
'state': task.state,
|
"state": task.state,
|
||||||
'status': task.info.get('status', ''),
|
"status": task.info.get("status", ""),
|
||||||
'progress': task.info.get('progress', 0)
|
"progress": task.info.get("progress", 0),
|
||||||
}
|
}
|
||||||
elif task.state == 'SUCCESS':
|
elif task.state == "SUCCESS":
|
||||||
response = {
|
response = {
|
||||||
'task_id': task_id,
|
"task_id": task_id,
|
||||||
'state': task.state,
|
"state": task.state,
|
||||||
'status': 'completed',
|
"status": "completed",
|
||||||
'progress': 100,
|
"progress": 100,
|
||||||
'result': task.result
|
"result": task.result,
|
||||||
}
|
}
|
||||||
else: # FAILURE
|
else: # FAILURE
|
||||||
response = {
|
response = {
|
||||||
'task_id': task_id,
|
"task_id": task_id,
|
||||||
'state': task.state,
|
"state": task.state,
|
||||||
'status': 'failed',
|
"status": "failed",
|
||||||
'progress': 0,
|
"progress": 0,
|
||||||
'error': str(task.info)
|
"error": str(task.info),
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Ошибка при получении статуса задачи: {str(e)}")
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Ошибка при получении статуса задачи: {str(e)}"
|
||||||
|
)
|
||||||
|
@ -96,11 +96,13 @@ class AgentManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"AI Agent started with PID {process.pid}")
|
logger.info(f"AI Agent started with PID {process.pid}")
|
||||||
|
|
||||||
# Запускаем мониторинг команд
|
# Запускаем мониторинг команд
|
||||||
if not self._monitoring_task:
|
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
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -132,12 +134,12 @@ class AgentManager:
|
|||||||
|
|
||||||
logger.info(f"AI Agent with PID {self._agent_process.pid} stopped")
|
logger.info(f"AI Agent with PID {self._agent_process.pid} stopped")
|
||||||
self._agent_process = None
|
self._agent_process = None
|
||||||
|
|
||||||
# Останавливаем мониторинг команд
|
# Останавливаем мониторинг команд
|
||||||
if self._monitoring_task:
|
if self._monitoring_task:
|
||||||
self._monitoring_task.cancel()
|
self._monitoring_task.cancel()
|
||||||
self._monitoring_task = None
|
self._monitoring_task = None
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -259,7 +261,9 @@ class AgentManager:
|
|||||||
"""Обрабатывает сигнал о завершении сессии от агента"""
|
"""Обрабатывает сигнал о завершении сессии от агента"""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
if not self._agent_process:
|
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
|
return False
|
||||||
|
|
||||||
if self._agent_process.session_id != session_id:
|
if self._agent_process.session_id != session_id:
|
||||||
@ -281,7 +285,9 @@ class AgentManager:
|
|||||||
self._agent_process.room_name = None
|
self._agent_process.room_name = None
|
||||||
self._agent_process.status = "idle"
|
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
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -346,36 +352,43 @@ class AgentManager:
|
|||||||
"""Мониторит файл команд для обработки сигналов от агента"""
|
"""Мониторит файл команд для обработки сигналов от агента"""
|
||||||
command_file = "agent_commands.json"
|
command_file = "agent_commands.json"
|
||||||
last_processed_timestamp = None
|
last_processed_timestamp = None
|
||||||
|
|
||||||
logger.info("[MONITOR] Starting command monitoring")
|
logger.info("[MONITOR] Starting command monitoring")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
if os.path.exists(command_file):
|
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)
|
command = json.load(f)
|
||||||
|
|
||||||
# Проверяем timestamp чтобы избежать повторной обработки
|
# Проверяем timestamp чтобы избежать повторной обработки
|
||||||
command_timestamp = command.get("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")
|
action = command.get("action")
|
||||||
|
|
||||||
if action == "session_completed":
|
if action == "session_completed":
|
||||||
session_id = command.get("session_id")
|
session_id = command.get("session_id")
|
||||||
room_name = command.get("room_name")
|
room_name = command.get("room_name")
|
||||||
|
|
||||||
logger.info(f"[MONITOR] Processing session_completed for {session_id}")
|
logger.info(
|
||||||
await self.handle_session_completed(session_id, room_name)
|
f"[MONITOR] Processing session_completed for {session_id}"
|
||||||
|
)
|
||||||
|
await self.handle_session_completed(
|
||||||
|
session_id, room_name
|
||||||
|
)
|
||||||
|
|
||||||
last_processed_timestamp = command_timestamp
|
last_processed_timestamp = command_timestamp
|
||||||
|
|
||||||
await asyncio.sleep(2) # Проверяем каждые 2 секунды
|
await asyncio.sleep(2) # Проверяем каждые 2 секунды
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[MONITOR] Error processing command: {e}")
|
logger.error(f"[MONITOR] Error processing command: {e}")
|
||||||
await asyncio.sleep(5) # Больший интервал при ошибке
|
await asyncio.sleep(5) # Больший интервал при ошибке
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.info("[MONITOR] Command monitoring stopped")
|
logger.info("[MONITOR] Command monitoring stopped")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
@ -10,7 +10,9 @@ from app.repositories.interview_reports_repository import InterviewReportReposit
|
|||||||
class InterviewReportService:
|
class InterviewReportService:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
report_repo: Annotated[InterviewReportRepository, Depends(InterviewReportRepository)],
|
report_repo: Annotated[
|
||||||
|
InterviewReportRepository, Depends(InterviewReportRepository)
|
||||||
|
],
|
||||||
):
|
):
|
||||||
self.report_repo = report_repo
|
self.report_repo = report_repo
|
||||||
|
|
||||||
@ -22,9 +24,7 @@ class InterviewReportService:
|
|||||||
"""Получить все отчёты по вакансии"""
|
"""Получить все отчёты по вакансии"""
|
||||||
return await self.report_repo.get_by_vacancy_id(vacancy_id)
|
return await self.report_repo.get_by_vacancy_id(vacancy_id)
|
||||||
|
|
||||||
async def update_report_scores(
|
async def update_report_scores(self, report_id: int, scores: dict) -> bool:
|
||||||
self, report_id: int, scores: dict
|
|
||||||
) -> bool:
|
|
||||||
"""
|
"""
|
||||||
Обновить оценки отчёта.
|
Обновить оценки отчёта.
|
||||||
Пример scores:
|
Пример scores:
|
||||||
|
@ -1,9 +1,13 @@
|
|||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import requests
|
||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
import pdfkit
|
from playwright.async_api import async_playwright
|
||||||
|
|
||||||
from app.core.s3 import s3_service
|
from app.core.s3 import s3_service
|
||||||
from app.models.interview_report import InterviewReport, RecommendationType
|
from app.models.interview_report import InterviewReport, RecommendationType
|
||||||
@ -14,249 +18,493 @@ class PDFReportService:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.template_path = "templates/interview_report.html"
|
self.template_path = "templates/interview_report.html"
|
||||||
|
self._setup_fonts()
|
||||||
|
|
||||||
|
def _download_font(self, url: str, dest_path: str) -> str:
|
||||||
|
"""Скачивает шрифт по URL в dest_path (перезаписывает если нужно)."""
|
||||||
|
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, stream=True, timeout=15)
|
||||||
|
resp.raise_for_status()
|
||||||
|
with open(dest_path, "wb") as f:
|
||||||
|
shutil.copyfileobj(resp.raw, f)
|
||||||
|
print(f"[OK] Downloaded font {url} -> {dest_path}")
|
||||||
|
return dest_path
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] Failed to download font {url}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _register_local_fonts(self, regular_path: str, bold_path: str):
|
||||||
|
"""Регистрирует шрифты в ReportLab, чтобы xhtml2pdf мог ими пользоваться."""
|
||||||
|
try:
|
||||||
|
from reportlab.lib.fonts import addMapping
|
||||||
|
from reportlab.pdfbase import pdfmetrics
|
||||||
|
from reportlab.pdfbase.ttfonts import TTFont
|
||||||
|
|
||||||
|
pdfmetrics.registerFont(TTFont("DejaVuSans", regular_path))
|
||||||
|
pdfmetrics.registerFont(TTFont("DejaVuSans-Bold", bold_path))
|
||||||
|
# mapping: family, bold(1)/normal(0), italic(1)/normal(0), fontkey
|
||||||
|
addMapping("DejaVuSans", 0, 0, "DejaVuSans")
|
||||||
|
addMapping("DejaVuSans", 1, 0, "DejaVuSans-Bold")
|
||||||
|
|
||||||
|
self.available_fonts = ["DejaVuSans", "DejaVuSans-Bold"]
|
||||||
|
print("[OK] Registered DejaVu fonts in ReportLab")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] Register fonts failed: {e}")
|
||||||
|
self.available_fonts = []
|
||||||
|
|
||||||
|
def _setup_fonts(self):
|
||||||
|
"""Настройка русских шрифтов для xhtml2pdf"""
|
||||||
|
self.available_fonts = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
from reportlab.lib.fonts import addMapping
|
||||||
|
from reportlab.pdfbase import pdfmetrics
|
||||||
|
from reportlab.pdfbase.ttfonts import TTFont
|
||||||
|
|
||||||
|
# Используем скачанные DejaVu шрифты
|
||||||
|
fonts_dir = "static/fonts"
|
||||||
|
font_paths = [
|
||||||
|
(os.path.join(fonts_dir, "DejaVuSans.ttf"), "DejaVu", False, False),
|
||||||
|
(os.path.join(fonts_dir, "DejaVuSans-Bold.ttf"), "DejaVu", True, False),
|
||||||
|
]
|
||||||
|
|
||||||
|
for font_path, font_name, is_bold, is_italic in font_paths:
|
||||||
|
if os.path.exists(font_path):
|
||||||
|
try:
|
||||||
|
font_key = f"{font_name}"
|
||||||
|
if is_bold:
|
||||||
|
font_key += "-Bold"
|
||||||
|
if is_italic:
|
||||||
|
font_key += "-Italic"
|
||||||
|
|
||||||
|
# Проверяем, что шрифт можно загрузить
|
||||||
|
test_font = TTFont(font_key, font_path)
|
||||||
|
pdfmetrics.registerFont(test_font)
|
||||||
|
addMapping(font_name, is_bold, is_italic, font_key)
|
||||||
|
|
||||||
|
self.available_fonts.append(font_key)
|
||||||
|
print(f"[OK] Successfully registered font: {font_key}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] Failed to register font {font_path}: {e}")
|
||||||
|
else:
|
||||||
|
print(f"[ERROR] Font file not found: {font_path}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] Font setup failed: {e}")
|
||||||
|
|
||||||
|
print(f"Available fonts: {self.available_fonts}")
|
||||||
|
|
||||||
|
def _get_font_css(self) -> str:
|
||||||
|
"""Возвращает CSS с подключением локальных шрифтов (скачивает при необходимости)."""
|
||||||
|
# paths локальные
|
||||||
|
fonts_dir = os.path.abspath("static/fonts").replace("\\", "/")
|
||||||
|
regular_local = os.path.join(fonts_dir, "DejaVuSans.ttf").replace("\\", "/")
|
||||||
|
bold_local = os.path.join(fonts_dir, "DejaVuSans-Bold.ttf").replace("\\", "/")
|
||||||
|
|
||||||
|
# твои удалённые URL (используй свои)
|
||||||
|
remote_regular = (
|
||||||
|
"https://d8d88bee-afd2-4266-8332-538389e25f52.selstorage.ru/DejaVuSans.ttf"
|
||||||
|
)
|
||||||
|
remote_bold = "https://d8d88bee-afd2-4266-8332-538389e25f52.selstorage.ru/DejaVuSans-Bold.ttf"
|
||||||
|
|
||||||
|
# скачиваем если локально нет
|
||||||
|
try:
|
||||||
|
if not os.path.exists(regular_local) or os.path.getsize(regular_local) == 0:
|
||||||
|
self._download_font(remote_regular, regular_local)
|
||||||
|
if not os.path.exists(bold_local) or os.path.getsize(bold_local) == 0:
|
||||||
|
self._download_font(remote_bold, bold_local)
|
||||||
|
except Exception as e:
|
||||||
|
print("[WARNING] Failed to ensure local fonts:", e)
|
||||||
|
|
||||||
|
# регистрируем в ReportLab (чтобы гарантировать поддержку кириллицы)
|
||||||
|
try:
|
||||||
|
self._register_local_fonts(regular_local, bold_local)
|
||||||
|
except Exception as e:
|
||||||
|
print("[WARNING] Font registration error:", e)
|
||||||
|
|
||||||
|
# используем file:/// абсолютный путь в src и УБИРАЕМ format('...') — это важно
|
||||||
|
# url-энкодим путь на случай пробелов
|
||||||
|
reg_quoted = quote(regular_local)
|
||||||
|
bold_quoted = quote(bold_local)
|
||||||
|
|
||||||
|
font_css = f"""
|
||||||
|
<style>
|
||||||
|
@font-face {{
|
||||||
|
font-family: 'DejaVuSans';
|
||||||
|
src: url('file:///{reg_quoted}');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
}}
|
||||||
|
@font-face {{
|
||||||
|
font-family: 'DejaVuSans';
|
||||||
|
src: url('file:///{bold_quoted}');
|
||||||
|
font-weight: bold;
|
||||||
|
font-style: normal;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* Применяем семейство — без !important, чтобы не ломать шаблон */
|
||||||
|
body, * {{
|
||||||
|
font-family: 'DejaVuSans', Arial, sans-serif;
|
||||||
|
}}
|
||||||
|
|
||||||
|
@page {{
|
||||||
|
size: A4;
|
||||||
|
margin: 0.75in;
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
"""
|
||||||
|
return font_css
|
||||||
|
|
||||||
def _load_html_template(self) -> str:
|
def _load_html_template(self) -> str:
|
||||||
"""Загружает HTML шаблон из файла"""
|
"""Загружает HTML шаблон из файла"""
|
||||||
try:
|
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()
|
return file.read()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise FileNotFoundError(f"HTML шаблон не найден: {self.template_path}")
|
raise FileNotFoundError(f"HTML шаблон не найден: {self.template_path}")
|
||||||
|
|
||||||
def _format_concerns_field(self, concerns):
|
def _format_concerns_field(self, concerns):
|
||||||
"""Форматирует поле concerns для отображения"""
|
"""Форматирует поле concerns для отображения"""
|
||||||
if not concerns:
|
if not concerns:
|
||||||
return "—"
|
return "—"
|
||||||
|
|
||||||
if isinstance(concerns, list):
|
if isinstance(concerns, list):
|
||||||
return "; ".join(concerns)
|
return "; ".join(concerns)
|
||||||
elif isinstance(concerns, str):
|
elif isinstance(concerns, str):
|
||||||
return concerns
|
return concerns
|
||||||
else:
|
else:
|
||||||
return str(concerns)
|
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:
|
def _get_score_class(self, score: int) -> str:
|
||||||
"""Возвращает CSS класс для цвета оценки"""
|
"""Возвращает CSS класс для цвета оценки"""
|
||||||
if score >= 80:
|
if score >= 90:
|
||||||
return "score-green"
|
return "score-green" # STRONGLY_RECOMMEND
|
||||||
|
elif score >= 75:
|
||||||
|
return "score-light-green" # RECOMMEND
|
||||||
elif score >= 60:
|
elif score >= 60:
|
||||||
return "score-orange"
|
return "score-orange" # CONSIDER
|
||||||
else:
|
else:
|
||||||
return "score-red"
|
return "score-red" # REJECT
|
||||||
|
|
||||||
def _format_recommendation(self, recommendation: RecommendationType) -> tuple:
|
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")
|
return ("Рекомендуем", "recommend-button")
|
||||||
elif recommendation == RecommendationType.CONSIDER:
|
elif recommendation == RecommendationType.CONSIDER:
|
||||||
return ("К рассмотрению", "consider-button")
|
return ("К рассмотрению", "consider-button")
|
||||||
else:
|
else: # REJECT
|
||||||
return ("Не рекомендуем", "reject-button")
|
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 шаблона
|
Генерирует PDF отчет на основе HTML шаблона
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
interview_report: Данные отчета по интервью
|
interview_report: Данные отчета по интервью
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bytes: PDF файл в виде байтов
|
bytes: PDF файл в виде байтов
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Загружаем HTML шаблон
|
# Загружаем HTML шаблон
|
||||||
html_template = self._load_html_template()
|
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 с данными
|
# Рендерим HTML с данными
|
||||||
template = Template(html_template)
|
template = Template(html_template)
|
||||||
rendered_html = template.render(**template_data)
|
rendered_html = template.render(**template_data)
|
||||||
|
|
||||||
# Настройки для wkhtmltopdf
|
# Получаем CSS с проверенными шрифтами
|
||||||
options = {
|
font_css = self._get_font_css()
|
||||||
'page-size': 'A4',
|
|
||||||
'margin-top': '0.75in',
|
# Вставляем стили
|
||||||
'margin-right': '0.75in',
|
if "<head>" in rendered_html:
|
||||||
'margin-bottom': '0.75in',
|
rendered_html = rendered_html.replace("<head>", f"<head>{font_css}")
|
||||||
'margin-left': '0.75in',
|
else:
|
||||||
'encoding': 'UTF-8',
|
rendered_html = font_css + rendered_html
|
||||||
'no-outline': None,
|
|
||||||
'enable-local-file-access': None
|
with open("debug.html", "w", encoding="utf-8") as f:
|
||||||
}
|
f.write(rendered_html)
|
||||||
|
|
||||||
# Генерируем PDF
|
# Генерируем PDF из debug.html с помощью Playwright
|
||||||
pdf_bytes = pdfkit.from_string(rendered_html, False, options=options)
|
print("[OK] Using Playwright to generate PDF from debug.html")
|
||||||
|
|
||||||
|
async def generate_pdf():
|
||||||
|
async with async_playwright() as p:
|
||||||
|
browser = await p.chromium.launch()
|
||||||
|
page = await browser.new_page()
|
||||||
|
await page.goto(f"file://{os.path.abspath('debug.html')}")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
pdf_bytes = await page.pdf(
|
||||||
|
format="A4",
|
||||||
|
margin={
|
||||||
|
"top": "0.75in",
|
||||||
|
"bottom": "0.75in",
|
||||||
|
"left": "0.75in",
|
||||||
|
"right": "0.75in",
|
||||||
|
},
|
||||||
|
print_background=True,
|
||||||
|
)
|
||||||
|
await browser.close()
|
||||||
|
return pdf_bytes
|
||||||
|
|
||||||
|
pdf_bytes = await generate_pdf()
|
||||||
|
|
||||||
return pdf_bytes
|
return pdf_bytes
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Ошибка при генерации PDF: {str(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 шаблона"""
|
"""Подготавливает данные для HTML шаблона"""
|
||||||
|
|
||||||
# Основная информация о кандидате
|
# Используем переданные параметры как в старой версии
|
||||||
candidate_name = interview_report.resume.applicant_name or "Не указано"
|
resume_url = resume_file_url # Пока оставим заглушку для ссылки на резюме
|
||||||
position = "Не указана"
|
|
||||||
|
|
||||||
# Получаем название позиции из связанной вакансии
|
|
||||||
if hasattr(interview_report.resume, 'vacancy') and interview_report.resume.vacancy:
|
|
||||||
position = interview_report.resume.vacancy.title
|
|
||||||
|
|
||||||
# Форматируем дату интервью
|
# Форматируем дату интервью
|
||||||
interview_date = "Не указана"
|
interview_date = "Не указана"
|
||||||
if interview_report.interview_session and interview_report.interview_session.interview_start_time:
|
if (
|
||||||
interview_date = interview_report.interview_session.interview_start_time.strftime("%d.%m.%Y %H:%M")
|
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
|
overall_score = interview_report.overall_score or 0
|
||||||
recommendation_text, recommendation_class = self._format_recommendation(interview_report.recommendation)
|
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 "Не указаны"
|
# Сильные стороны и области развития (используем правильные поля модели)
|
||||||
|
strengths = (
|
||||||
# Детальная оценка
|
self._format_list_field(interview_report.strengths)
|
||||||
evaluation_criteria = []
|
if interview_report.strengths
|
||||||
|
else "Не указаны"
|
||||||
# Технические навыки
|
)
|
||||||
if interview_report.technical_skills_score is not None:
|
areas_for_development = (
|
||||||
evaluation_criteria.append({
|
self._format_list_field(interview_report.weaknesses)
|
||||||
'name': 'Технические навыки',
|
if interview_report.weaknesses
|
||||||
'score': interview_report.technical_skills_score,
|
else "Не указаны"
|
||||||
'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)
|
# Детальная оценка - всегда все критерии, как в старой версии
|
||||||
})
|
evaluation_criteria = [
|
||||||
|
{
|
||||||
# Релевантность опыта
|
"name": "Технические навыки",
|
||||||
if interview_report.experience_relevance_score is not None:
|
"score": interview_report.technical_skills_score or 0,
|
||||||
evaluation_criteria.append({
|
"score_class": self._get_score_class(
|
||||||
'name': 'Релевантность опыта',
|
interview_report.technical_skills_score or 0
|
||||||
'score': interview_report.experience_relevance_score,
|
),
|
||||||
'score_class': self._get_score_class(interview_report.experience_relevance_score),
|
"justification": interview_report.technical_skills_justification or "—",
|
||||||
'justification': interview_report.experience_relevance_justification or "—",
|
"concerns": self._format_concerns_field(
|
||||||
'concerns': self._format_concerns_field(interview_report.experience_relevance_concerns)
|
interview_report.technical_skills_concerns
|
||||||
})
|
),
|
||||||
|
},
|
||||||
# Коммуникация
|
{
|
||||||
if interview_report.communication_score is not None:
|
"name": "Релевантность опыта",
|
||||||
evaluation_criteria.append({
|
"score": interview_report.experience_relevance_score or 0,
|
||||||
'name': 'Коммуникация',
|
"score_class": self._get_score_class(
|
||||||
'score': interview_report.communication_score,
|
interview_report.experience_relevance_score or 0
|
||||||
'score_class': self._get_score_class(interview_report.communication_score),
|
),
|
||||||
'justification': interview_report.communication_justification or "—",
|
"justification": interview_report.experience_relevance_justification
|
||||||
'concerns': self._format_concerns_field(interview_report.communication_concerns)
|
or "—",
|
||||||
})
|
"concerns": self._format_concerns_field(
|
||||||
|
interview_report.experience_relevance_concerns
|
||||||
# Решение задач
|
),
|
||||||
if interview_report.problem_solving_score is not None:
|
},
|
||||||
evaluation_criteria.append({
|
{
|
||||||
'name': 'Решение задач',
|
"name": "Коммуникация",
|
||||||
'score': interview_report.problem_solving_score,
|
"score": interview_report.communication_score or 0,
|
||||||
'score_class': self._get_score_class(interview_report.problem_solving_score),
|
"score_class": self._get_score_class(
|
||||||
'justification': interview_report.problem_solving_justification or "—",
|
interview_report.communication_score or 0
|
||||||
'concerns': self._format_concerns_field(interview_report.problem_solving_concerns)
|
),
|
||||||
})
|
"justification": interview_report.communication_justification or "—",
|
||||||
|
"concerns": self._format_concerns_field(
|
||||||
# Культурное соответствие
|
interview_report.communication_concerns
|
||||||
if interview_report.cultural_fit_score is not None:
|
),
|
||||||
evaluation_criteria.append({
|
},
|
||||||
'name': 'Культурное соответствие',
|
{
|
||||||
'score': interview_report.cultural_fit_score,
|
"name": "Решение задач",
|
||||||
'score_class': self._get_score_class(interview_report.cultural_fit_score),
|
"score": interview_report.problem_solving_score or 0,
|
||||||
'justification': interview_report.cultural_fit_justification or "—",
|
"score_class": self._get_score_class(
|
||||||
'concerns': self._format_concerns_field(interview_report.cultural_fit_concerns)
|
interview_report.problem_solving_score or 0
|
||||||
})
|
),
|
||||||
|
"justification": interview_report.problem_solving_justification or "—",
|
||||||
# Красные флаги
|
"concerns": self._format_concerns_field(
|
||||||
red_flags = []
|
interview_report.problem_solving_concerns
|
||||||
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):
|
"name": "Культурное соответствие",
|
||||||
red_flags = [interview_report.red_flags]
|
"score": interview_report.cultural_fit_score or 0,
|
||||||
|
"score_class": self._get_score_class(
|
||||||
# Ссылка на резюме
|
interview_report.cultural_fit_score or 0
|
||||||
resume_url = interview_report.resume.file_url if interview_report.resume.file_url else "#"
|
),
|
||||||
|
"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 отчета
|
# ID отчета
|
||||||
report_id = f"#{interview_report.id}" if interview_report.id else "#0"
|
report_id = f"#{interview_report.id}" if interview_report.id else "#0"
|
||||||
|
|
||||||
# Дата генерации отчета
|
# Дата генерации отчета
|
||||||
generation_date = datetime.now().strftime("%d.%m.%Y %H:%M")
|
generation_date = datetime.now().strftime("%d.%m.%Y %H:%M")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'report_id': report_id,
|
"report_id": report_id,
|
||||||
'candidate_name': candidate_name,
|
"candidate_name": candidate_name,
|
||||||
'position': position,
|
"position": position,
|
||||||
'interview_date': interview_date,
|
"interview_date": interview_date,
|
||||||
'overall_score': overall_score,
|
"overall_score": overall_score,
|
||||||
'recommendation_text': recommendation_text,
|
"recommendation_text": recommendation_text,
|
||||||
'recommendation_class': recommendation_class,
|
"recommendation_class": recommendation_class,
|
||||||
'strengths': strengths,
|
"strengths": strengths,
|
||||||
'areas_for_development': areas_for_development,
|
"areas_for_development": areas_for_development,
|
||||||
'evaluation_criteria': evaluation_criteria,
|
"evaluation_criteria": evaluation_criteria,
|
||||||
'red_flags': red_flags,
|
"red_flags": red_flags,
|
||||||
'resume_url': resume_url,
|
"resume_url": resume_url,
|
||||||
'generation_date': generation_date
|
"generation_date": generation_date,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def upload_pdf_to_s3(self, pdf_bytes: bytes, filename: str) -> str:
|
async def upload_pdf_to_s3(self, pdf_bytes: bytes, filename: str) -> str:
|
||||||
"""
|
"""
|
||||||
Загружает PDF файл в S3 и возвращает публичную ссылку
|
Загружает PDF файл в S3 и возвращает публичную ссылку
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
pdf_bytes: PDF файл в виде байтов
|
pdf_bytes: PDF файл в виде байтов
|
||||||
filename: Имя файла
|
filename: Имя файла
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: Публичная ссылка на файл в S3
|
str: Публичная ссылка на файл в S3
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
pdf_stream = io.BytesIO(pdf_bytes)
|
pdf_stream = io.BytesIO(pdf_bytes)
|
||||||
|
|
||||||
# Загружаем с публичным доступом
|
# Загружаем с публичным доступом
|
||||||
file_url = await s3_service.upload_file(
|
file_url = await s3_service.upload_file(
|
||||||
pdf_stream,
|
pdf_stream, filename, content_type="application/pdf", public=True
|
||||||
filename,
|
|
||||||
content_type="application/pdf",
|
|
||||||
public=True
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return file_url
|
return file_url
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Ошибка при загрузке PDF в S3: {str(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 (метод обратной совместимости)
|
Генерирует PDF отчет и загружает его в S3 (метод обратной совместимости)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
report: Отчет по интервью
|
report: Отчет по интервью
|
||||||
candidate_name: Имя кандидата (не используется, берется из отчета)
|
candidate_name: Имя кандидата (не используется, берется из отчета)
|
||||||
position: Позиция (не используется, берется из отчета)
|
position: Позиция (не используется, берется из отчета)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: Публичная ссылка на PDF файл
|
str: Публичная ссылка на PDF файл
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Генерируем PDF
|
# Генерируем PDF
|
||||||
pdf_bytes = self.generate_pdf_report(report)
|
pdf_bytes = await self.generate_pdf_report(
|
||||||
|
report, candidate_name, position, resume_file_url
|
||||||
# Создаем имя файла
|
)
|
||||||
safe_name = report.resume.applicant_name or "candidate"
|
|
||||||
safe_name = "".join(c for c in safe_name if c.isalnum() or c in (' ', '-', '_')).strip()
|
# Создаем имя файла - используем переданный параметр как в старой версии
|
||||||
|
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"
|
filename = f"interview_report_{safe_name}_{report.id}.pdf"
|
||||||
|
|
||||||
# Загружаем в S3
|
# Загружаем в S3
|
||||||
pdf_url = await self.upload_pdf_to_s3(pdf_bytes, filename)
|
pdf_url = await self.upload_pdf_to_s3(pdf_bytes, filename)
|
||||||
|
|
||||||
return pdf_url
|
return pdf_url
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"Ошибка при генерации и загрузке PDF: {str(e)}")
|
raise Exception(f"Ошибка при генерации и загрузке PDF: {str(e)}")
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@ import io
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -11,33 +11,33 @@ class VacancyParserService:
|
|||||||
"""Сервис для парсинга вакансий из файлов различных форматов"""
|
"""Сервис для парсинга вакансий из файлов различных форматов"""
|
||||||
|
|
||||||
def __init__(self):
|
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:
|
def extract_text_from_file(self, file_content: bytes, filename: str) -> str:
|
||||||
"""
|
"""
|
||||||
Извлекает текст из файла в зависимости от его формата
|
Извлекает текст из файла в зависимости от его формата
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_content: Содержимое файла в байтах
|
file_content: Содержимое файла в байтах
|
||||||
filename: Имя файла для определения формата
|
filename: Имя файла для определения формата
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: Извлеченный текст
|
str: Извлеченный текст
|
||||||
"""
|
"""
|
||||||
file_extension = Path(filename).suffix.lower()
|
file_extension = Path(filename).suffix.lower()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if file_extension == '.pdf':
|
if file_extension == ".pdf":
|
||||||
return self._extract_from_pdf(file_content)
|
return self._extract_from_pdf(file_content)
|
||||||
elif file_extension == '.docx':
|
elif file_extension == ".docx":
|
||||||
return self._extract_from_docx(file_content)
|
return self._extract_from_docx(file_content)
|
||||||
elif file_extension == '.rtf':
|
elif file_extension == ".rtf":
|
||||||
return self._extract_from_rtf(file_content)
|
return self._extract_from_rtf(file_content)
|
||||||
elif file_extension == '.txt':
|
elif file_extension == ".txt":
|
||||||
return self._extract_from_txt(file_content)
|
return self._extract_from_txt(file_content)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Неподдерживаемый формат файла: {file_extension}")
|
raise ValueError(f"Неподдерживаемый формат файла: {file_extension}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при извлечении текста из файла {filename}: {str(e)}")
|
logger.error(f"Ошибка при извлечении текста из файла {filename}: {str(e)}")
|
||||||
raise
|
raise
|
||||||
@ -46,126 +46,132 @@ class VacancyParserService:
|
|||||||
"""Извлекает текст из PDF файла"""
|
"""Извлекает текст из PDF файла"""
|
||||||
try:
|
try:
|
||||||
import PyPDF2
|
import PyPDF2
|
||||||
|
|
||||||
pdf_file = io.BytesIO(file_content)
|
pdf_file = io.BytesIO(file_content)
|
||||||
pdf_reader = PyPDF2.PdfReader(pdf_file)
|
pdf_reader = PyPDF2.PdfReader(pdf_file)
|
||||||
|
|
||||||
text = ""
|
text = ""
|
||||||
for page in pdf_reader.pages:
|
for page in pdf_reader.pages:
|
||||||
text += page.extract_text() + "\n"
|
text += page.extract_text() + "\n"
|
||||||
|
|
||||||
return text.strip()
|
return text.strip()
|
||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# Fallback to pdfplumber if PyPDF2 doesn't work well
|
# Fallback to pdfplumber if PyPDF2 doesn't work well
|
||||||
try:
|
try:
|
||||||
import pdfplumber
|
import pdfplumber
|
||||||
|
|
||||||
pdf_file = io.BytesIO(file_content)
|
pdf_file = io.BytesIO(file_content)
|
||||||
text = ""
|
text = ""
|
||||||
|
|
||||||
with pdfplumber.open(pdf_file) as pdf:
|
with pdfplumber.open(pdf_file) as pdf:
|
||||||
for page in pdf.pages:
|
for page in pdf.pages:
|
||||||
page_text = page.extract_text()
|
page_text = page.extract_text()
|
||||||
if page_text:
|
if page_text:
|
||||||
text += page_text + "\n"
|
text += page_text + "\n"
|
||||||
|
|
||||||
return text.strip()
|
return text.strip()
|
||||||
|
|
||||||
except ImportError:
|
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:
|
def _extract_from_docx(self, file_content: bytes) -> str:
|
||||||
"""Извлекает текст из DOCX файла"""
|
"""Извлекает текст из DOCX файла"""
|
||||||
try:
|
try:
|
||||||
import docx
|
import docx
|
||||||
|
|
||||||
doc_file = io.BytesIO(file_content)
|
doc_file = io.BytesIO(file_content)
|
||||||
doc = docx.Document(doc_file)
|
doc = docx.Document(doc_file)
|
||||||
|
|
||||||
text = ""
|
text = ""
|
||||||
for paragraph in doc.paragraphs:
|
for paragraph in doc.paragraphs:
|
||||||
text += paragraph.text + "\n"
|
text += paragraph.text + "\n"
|
||||||
|
|
||||||
# Также извлекаем текст из таблиц
|
# Также извлекаем текст из таблиц
|
||||||
for table in doc.tables:
|
for table in doc.tables:
|
||||||
for row in table.rows:
|
for row in table.rows:
|
||||||
for cell in row.cells:
|
for cell in row.cells:
|
||||||
text += cell.text + "\t"
|
text += cell.text + "\t"
|
||||||
text += "\n"
|
text += "\n"
|
||||||
|
|
||||||
return text.strip()
|
return text.strip()
|
||||||
|
|
||||||
except ImportError:
|
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:
|
def _extract_from_rtf(self, file_content: bytes) -> str:
|
||||||
"""Извлекает текст из RTF файла"""
|
"""Извлекает текст из RTF файла"""
|
||||||
try:
|
try:
|
||||||
from striprtf.striprtf import rtf_to_text
|
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)
|
text = rtf_to_text(rtf_content)
|
||||||
|
|
||||||
return text.strip()
|
return text.strip()
|
||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise ImportError("Требуется установить striprtf: pip install striprtf")
|
raise ImportError("Требуется установить striprtf: pip install striprtf")
|
||||||
except Exception as e:
|
except Exception:
|
||||||
# Альтернативный метод через pyth
|
# Альтернативный метод через pyth
|
||||||
try:
|
try:
|
||||||
from pyth.plugins.rtf15.reader import Rtf15Reader
|
|
||||||
from pyth.plugins.plaintext.writer import PlaintextWriter
|
from pyth.plugins.plaintext.writer import PlaintextWriter
|
||||||
|
from pyth.plugins.rtf15.reader import Rtf15Reader
|
||||||
|
|
||||||
doc = Rtf15Reader.read(io.BytesIO(file_content))
|
doc = Rtf15Reader.read(io.BytesIO(file_content))
|
||||||
text = PlaintextWriter.write(doc).getvalue()
|
text = PlaintextWriter.write(doc).getvalue()
|
||||||
|
|
||||||
return text.strip()
|
return text.strip()
|
||||||
|
|
||||||
except ImportError:
|
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:
|
def _extract_from_txt(self, file_content: bytes) -> str:
|
||||||
"""Извлекает текст из TXT файла"""
|
"""Извлекает текст из TXT файла"""
|
||||||
try:
|
try:
|
||||||
# Пробуем различные кодировки
|
# Пробуем различные кодировки
|
||||||
encodings = ['utf-8', 'windows-1251', 'cp1252', 'iso-8859-1']
|
encodings = ["utf-8", "windows-1251", "cp1252", "iso-8859-1"]
|
||||||
|
|
||||||
for encoding in encodings:
|
for encoding in encodings:
|
||||||
try:
|
try:
|
||||||
text = file_content.decode(encoding)
|
text = file_content.decode(encoding)
|
||||||
return text.strip()
|
return text.strip()
|
||||||
except UnicodeDecodeError:
|
except UnicodeDecodeError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Если все кодировки не подошли, используем errors='ignore'
|
# Если все кодировки не подошли, используем errors='ignore'
|
||||||
text = file_content.decode('utf-8', errors='ignore')
|
text = file_content.decode("utf-8", errors="ignore")
|
||||||
return text.strip()
|
return text.strip()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при чтении txt файла: {str(e)}")
|
logger.error(f"Ошибка при чтении txt файла: {str(e)}")
|
||||||
raise
|
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 для извлечения структурированной информации
|
Парсит текст вакансии с помощью AI для извлечения структурированной информации
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
raw_text: Сырой текст вакансии
|
raw_text: Сырой текст вакансии
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict с полями для модели Vacancy
|
Dict с полями для модели Vacancy
|
||||||
"""
|
"""
|
||||||
from rag.settings import settings
|
from rag.settings import settings
|
||||||
|
|
||||||
if not settings.openai_api_key:
|
if not settings.openai_api_key:
|
||||||
raise ValueError("OpenAI API ключ не настроен")
|
raise ValueError("OpenAI API ключ не настроен")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import openai
|
import openai
|
||||||
|
|
||||||
openai.api_key = settings.openai_api_key
|
openai.api_key = settings.openai_api_key
|
||||||
|
|
||||||
parsing_prompt = f"""
|
parsing_prompt = f"""
|
||||||
Проанализируй текст вакансии и извлеки из него структурированную информацию.
|
Проанализируй текст вакансии и извлеки из него структурированную информацию.
|
||||||
|
|
||||||
@ -217,83 +223,86 @@ class VacancyParserService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
parsed_data = json.loads(response.choices[0].message.content)
|
parsed_data = json.loads(response.choices[0].message.content)
|
||||||
|
|
||||||
# Валидируем и обрабатываем данные
|
# Валидируем и обрабатываем данные
|
||||||
return self._validate_parsed_data(parsed_data)
|
return self._validate_parsed_data(parsed_data)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка при парсинге вакансии через AI: {str(e)}")
|
logger.error(f"Ошибка при парсинге вакансии через AI: {str(e)}")
|
||||||
raise
|
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
|
from app.models.vacancy import EmploymentType, Experience, Schedule
|
||||||
|
|
||||||
# Обязательные поля с дефолтными значениями
|
# Обязательные поля с дефолтными значениями
|
||||||
validated_data = {
|
validated_data = {
|
||||||
'title': data.get('title', 'Название не указано'),
|
"title": data.get("title", "Название не указано"),
|
||||||
'description': data.get('description', 'Описание не указано'),
|
"description": data.get("description", "Описание не указано"),
|
||||||
'key_skills': data.get('key_skills'),
|
"key_skills": data.get("key_skills"),
|
||||||
'employment_type': self._validate_enum(
|
"employment_type": self._validate_enum(
|
||||||
data.get('employment_type'),
|
data.get("employment_type"), EmploymentType, EmploymentType.FULL_TIME
|
||||||
EmploymentType,
|
|
||||||
EmploymentType.FULL_TIME
|
|
||||||
),
|
),
|
||||||
'experience': self._validate_enum(
|
"experience": self._validate_enum(
|
||||||
data.get('experience'),
|
data.get("experience"), Experience, Experience.BETWEEN_1_AND_3
|
||||||
Experience,
|
|
||||||
Experience.BETWEEN_1_AND_3
|
|
||||||
),
|
),
|
||||||
'schedule': self._validate_enum(
|
"schedule": self._validate_enum(
|
||||||
data.get('schedule'),
|
data.get("schedule"), Schedule, Schedule.FULL_DAY
|
||||||
Schedule,
|
|
||||||
Schedule.FULL_DAY
|
|
||||||
),
|
),
|
||||||
'company_name': data.get('company_name'),
|
"company_name": data.get("company_name"),
|
||||||
'area_name': data.get('area_name'),
|
"area_name": data.get("area_name"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Необязательные поля
|
# Необязательные поля
|
||||||
optional_fields = [
|
optional_fields = [
|
||||||
'salary_from', 'salary_to', 'salary_currency', 'company_description',
|
"salary_from",
|
||||||
'address', 'professional_roles', 'contacts_name', 'contacts_email', 'contacts_phone'
|
"salary_to",
|
||||||
|
"salary_currency",
|
||||||
|
"company_description",
|
||||||
|
"address",
|
||||||
|
"professional_roles",
|
||||||
|
"contacts_name",
|
||||||
|
"contacts_email",
|
||||||
|
"contacts_phone",
|
||||||
]
|
]
|
||||||
|
|
||||||
for field in optional_fields:
|
for field in optional_fields:
|
||||||
value = data.get(field)
|
value = data.get(field)
|
||||||
if value and value != "null":
|
if value and value != "null":
|
||||||
validated_data[field] = value
|
validated_data[field] = value
|
||||||
|
|
||||||
# Специальная обработка зарплаты
|
# Специальная обработка зарплаты
|
||||||
if data.get('salary_from'):
|
if data.get("salary_from"):
|
||||||
try:
|
try:
|
||||||
validated_data['salary_from'] = int(data['salary_from'])
|
validated_data["salary_from"] = int(data["salary_from"])
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if data.get('salary_to'):
|
if data.get("salary_to"):
|
||||||
try:
|
try:
|
||||||
validated_data['salary_to'] = int(data['salary_to'])
|
validated_data["salary_to"] = int(data["salary_to"])
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Валюта по умолчанию
|
# Валюта по умолчанию
|
||||||
validated_data['salary_currency'] = data.get('salary_currency', 'RUR')
|
validated_data["salary_currency"] = data.get("salary_currency", "RUR")
|
||||||
|
|
||||||
return validated_data
|
return validated_data
|
||||||
|
|
||||||
def _validate_enum(self, value: str, enum_class, default_value):
|
def _validate_enum(self, value: str, enum_class, default_value):
|
||||||
"""Валидирует значение enum"""
|
"""Валидирует значение enum"""
|
||||||
if not value:
|
if not value:
|
||||||
return default_value
|
return default_value
|
||||||
|
|
||||||
# Проверяем, есть ли такое значение в enum
|
# Проверяем, есть ли такое значение в enum
|
||||||
try:
|
try:
|
||||||
return enum_class(value)
|
return enum_class(value)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.warning(f"Неизвестное значение {value} для {enum_class.__name__}, используем {default_value}")
|
logger.warning(
|
||||||
|
f"Неизвестное значение {value} для {enum_class.__name__}, используем {default_value}"
|
||||||
|
)
|
||||||
return default_value
|
return default_value
|
||||||
|
|
||||||
|
|
||||||
# Экземпляр сервиса
|
# Экземпляр сервиса
|
||||||
vacancy_parser_service = VacancyParserService()
|
vacancy_parser_service = VacancyParserService()
|
||||||
|
@ -139,35 +139,72 @@ class SyncVacancyRepository:
|
|||||||
from app.models.vacancy import Vacancy
|
from app.models.vacancy import Vacancy
|
||||||
|
|
||||||
return self.session.query(Vacancy).filter(Vacancy.id == vacancy_id).first()
|
return self.session.query(Vacancy).filter(Vacancy.id == vacancy_id).first()
|
||||||
|
|
||||||
def create_vacancy(self, vacancy_create):
|
def create_vacancy(self, vacancy_create):
|
||||||
"""Создать новую вакансию"""
|
"""Создать новую вакансию"""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from app.models.vacancy import Vacancy
|
from app.models.vacancy import Vacancy
|
||||||
|
|
||||||
# Конвертируем VacancyCreate в dict
|
# Конвертируем VacancyCreate в dict
|
||||||
if hasattr(vacancy_create, 'dict'):
|
if hasattr(vacancy_create, "dict"):
|
||||||
vacancy_data = 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()
|
vacancy_data = vacancy_create.model_dump()
|
||||||
else:
|
else:
|
||||||
vacancy_data = vacancy_create
|
vacancy_data = vacancy_create
|
||||||
|
|
||||||
# Создаем новую вакансию
|
# Создаем новую вакансию
|
||||||
vacancy = Vacancy(
|
vacancy = Vacancy(
|
||||||
**vacancy_data,
|
**vacancy_data, created_at=datetime.utcnow(), updated_at=datetime.utcnow()
|
||||||
created_at=datetime.utcnow(),
|
|
||||||
updated_at=datetime.utcnow()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.session.add(vacancy)
|
self.session.add(vacancy)
|
||||||
self.session.flush() # Получаем ID без коммита
|
self.session.flush() # Получаем ID без коммита
|
||||||
self.session.refresh(vacancy) # Обновляем объект из БД
|
self.session.refresh(vacancy) # Обновляем объект из БД
|
||||||
|
|
||||||
# Создаем простой объект с нужными данными для возврата
|
# Создаем простой объект с нужными данными для возврата
|
||||||
class VacancyResult:
|
class VacancyResult:
|
||||||
def __init__(self, id, title):
|
def __init__(self, id, title):
|
||||||
self.id = id
|
self.id = id
|
||||||
self.title = title
|
self.title = title
|
||||||
|
|
||||||
return VacancyResult(vacancy.id, vacancy.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
|
||||||
|
@ -108,6 +108,7 @@ def generate_interview_report(resume_id: int):
|
|||||||
report_instance,
|
report_instance,
|
||||||
resume.applicant_name,
|
resume.applicant_name,
|
||||||
vacancy.get("title", "Unknown Position"),
|
vacancy.get("title", "Unknown Position"),
|
||||||
|
resume.resume_file_url,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -321,8 +322,6 @@ def _prepare_analysis_context(
|
|||||||
|
|
||||||
# Формируем контекст
|
# Формируем контекст
|
||||||
context = f"""
|
context = f"""
|
||||||
АНАЛИЗ КАНДИДАТА НА СОБЕСЕДОВАНИЕ
|
|
||||||
|
|
||||||
ВАКАНСИЯ:
|
ВАКАНСИЯ:
|
||||||
- Позиция: {vacancy.get("title", "Не указана")}
|
- Позиция: {vacancy.get("title", "Не указана")}
|
||||||
- Описание: {vacancy.get("description", "Не указано")[:500]}
|
- Описание: {vacancy.get("description", "Не указано")[:500]}
|
||||||
@ -337,10 +336,10 @@ def _prepare_analysis_context(
|
|||||||
- Образование: {parsed_resume.get("education", "Не указано")}
|
- Образование: {parsed_resume.get("education", "Не указано")}
|
||||||
- Предыдущие позиции: {"; ".join([pos.get("title", "") + " в " + pos.get("company", "") for pos in parsed_resume.get("work_experience", [])])}
|
- Предыдущие позиции: {"; ".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 "План интервью не найден"}
|
{json.dumps(interview_plan, ensure_ascii=False, indent=2) if interview_plan else "План интервью не найден"}
|
||||||
|
|
||||||
ДИАЛОГ ИНТЕРВЬЮ:
|
ДИАЛОГ СОБЕСЕДОВАНИЯ:
|
||||||
{dialogue_text if dialogue_text else "Диалог интервью не найден или пуст"}
|
{dialogue_text if dialogue_text else "Диалог интервью не найден или пуст"}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -363,11 +362,15 @@ def _call_openai_for_evaluation(context: str) -> dict | None:
|
|||||||
{context}
|
{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: Соответствие корпоративной культуре
|
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(
|
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"""
|
"""Генерирует PDF отчет и загружает его в S3"""
|
||||||
try:
|
try:
|
||||||
|
|
||||||
from app.services.pdf_report_service import pdf_report_service
|
from app.services.pdf_report_service import pdf_report_service
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[PDF_GENERATION] Starting PDF generation for report ID: {report_instance.id}"
|
f"[PDF_GENERATION] Starting PDF generation for report ID: {report_instance.id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Генерируем и загружаем PDF
|
# Генерируем и загружаем PDF - используем переданные параметры как в старой версии
|
||||||
pdf_url = await pdf_report_service.generate_and_upload_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:
|
if pdf_url:
|
||||||
@ -623,7 +634,7 @@ def _format_concerns_field(concerns_data) -> str:
|
|||||||
"""Форматирует поле concerns для сохранения как строку"""
|
"""Форматирует поле concerns для сохранения как строку"""
|
||||||
if not concerns_data:
|
if not concerns_data:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
if isinstance(concerns_data, list):
|
if isinstance(concerns_data, list):
|
||||||
# Если это массив, объединяем элементы через запятую с переносом строки
|
# Если это массив, объединяем элементы через запятую с переносом строки
|
||||||
return "; ".join(concerns_data)
|
return "; ".join(concerns_data)
|
||||||
|
@ -581,10 +581,12 @@ def generate_interview_questions_task(self, resume_id: str, job_description: str
|
|||||||
|
|
||||||
|
|
||||||
@celery_app.task(bind=True)
|
@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:
|
Args:
|
||||||
file_content_base64: Содержимое файла в base64
|
file_content_base64: Содержимое файла в base64
|
||||||
filename: Имя файла для определения формата
|
filename: Имя файла для определения формата
|
||||||
@ -592,64 +594,73 @@ def parse_vacancy_task(self, file_content_base64: str, filename: str, create_vac
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
import base64
|
import base64
|
||||||
from app.services.vacancy_parser_service import vacancy_parser_service
|
|
||||||
from app.models.vacancy import VacancyCreate
|
from app.models.vacancy import VacancyCreate
|
||||||
|
from app.services.vacancy_parser_service import vacancy_parser_service
|
||||||
|
|
||||||
# Обновляем статус задачи
|
# Обновляем статус задачи
|
||||||
self.update_state(
|
self.update_state(
|
||||||
state="PENDING",
|
state="PENDING",
|
||||||
meta={"status": "Начинаем парсинг вакансии...", "progress": 10}
|
meta={"status": "Начинаем парсинг вакансии...", "progress": 10},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Декодируем содержимое файла
|
# Декодируем содержимое файла
|
||||||
file_content = base64.b64decode(file_content_base64)
|
file_content = base64.b64decode(file_content_base64)
|
||||||
|
|
||||||
# Шаг 1: Извлечение текста из файла
|
# Шаг 1: Извлечение текста из файла
|
||||||
self.update_state(
|
self.update_state(
|
||||||
state="PROGRESS",
|
state="PROGRESS",
|
||||||
meta={"status": "Извлекаем текст из файла...", "progress": 30}
|
meta={"status": "Извлекаем текст из файла...", "progress": 30},
|
||||||
)
|
)
|
||||||
|
|
||||||
raw_text = vacancy_parser_service.extract_text_from_file(file_content, filename)
|
raw_text = vacancy_parser_service.extract_text_from_file(file_content, filename)
|
||||||
|
|
||||||
if not raw_text.strip():
|
if not raw_text.strip():
|
||||||
raise ValueError("Не удалось извлечь текст из файла")
|
raise ValueError("Не удалось извлечь текст из файла")
|
||||||
|
|
||||||
# Шаг 2: Парсинг с помощью AI
|
# Шаг 2: Парсинг с помощью AI
|
||||||
self.update_state(
|
self.update_state(
|
||||||
state="PROGRESS",
|
state="PROGRESS",
|
||||||
meta={"status": "Обрабатываем текст с помощью AI...", "progress": 70}
|
meta={"status": "Обрабатываем текст с помощью AI...", "progress": 70},
|
||||||
)
|
)
|
||||||
|
|
||||||
import asyncio
|
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: Создание вакансии (если требуется)
|
# Шаг 3: Создание вакансии (если требуется)
|
||||||
created_vacancy = None
|
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:
|
if create_vacancy:
|
||||||
self.update_state(
|
self.update_state(
|
||||||
state="PROGRESS",
|
state="PROGRESS",
|
||||||
meta={"status": "Создаем вакансию в базе данных...", "progress": 90}
|
meta={"status": "Создаем вакансию в базе данных...", "progress": 90},
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
print(f"Parsed data for vacancy creation: {parsed_data}")
|
print(f"Parsed data for vacancy creation: {parsed_data}")
|
||||||
vacancy_create = VacancyCreate(**parsed_data)
|
vacancy_create = VacancyCreate(**parsed_data)
|
||||||
print(f"VacancyCreate object created successfully: {vacancy_create}")
|
print(f"VacancyCreate object created successfully: {vacancy_create}")
|
||||||
|
|
||||||
with get_sync_session() as session:
|
with get_sync_session() as session:
|
||||||
vacancy_repo = SyncVacancyRepository(session)
|
vacancy_repo = SyncVacancyRepository(session)
|
||||||
created_vacancy = vacancy_repo.create_vacancy(vacancy_create)
|
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:
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
error_details = traceback.format_exc()
|
error_details = traceback.format_exc()
|
||||||
print(f"Error creating vacancy: {str(e)}")
|
print(f"Error creating vacancy: {str(e)}")
|
||||||
print(f"Full traceback: {error_details}")
|
print(f"Full traceback: {error_details}")
|
||||||
|
|
||||||
# Возвращаем парсинг, но предупреждаем об ошибке создания
|
# Возвращаем парсинг, но предупреждаем об ошибке создания
|
||||||
self.update_state(
|
self.update_state(
|
||||||
state="SUCCESS",
|
state="SUCCESS",
|
||||||
@ -657,38 +668,38 @@ def parse_vacancy_task(self, file_content_base64: str, filename: str, create_vac
|
|||||||
"status": f"Парсинг выполнен, но ошибка при создании вакансии: {str(e)}",
|
"status": f"Парсинг выполнен, но ошибка при создании вакансии: {str(e)}",
|
||||||
"progress": 100,
|
"progress": 100,
|
||||||
"result": parsed_data,
|
"result": parsed_data,
|
||||||
"warning": f"Ошибка создания вакансии: {str(e)}"
|
"warning": f"Ошибка создания вакансии: {str(e)}",
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "parsed_with_warning",
|
"status": "parsed_with_warning",
|
||||||
"parsed_data": parsed_data,
|
"parsed_data": parsed_data,
|
||||||
"warning": f"Ошибка при создании вакансии: {str(e)}"
|
"warning": f"Ошибка при создании вакансии: {str(e)}",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Завершено успешно
|
# Завершено успешно
|
||||||
response_message = "Парсинг выполнен успешно"
|
response_message = "Парсинг выполнен успешно"
|
||||||
if created_vacancy:
|
if created_vacancy:
|
||||||
response_message += f". Вакансия создана с ID: {created_vacancy.id}"
|
response_message += f". Вакансия создана с ID: {created_vacancy.id}"
|
||||||
|
|
||||||
self.update_state(
|
self.update_state(
|
||||||
state="SUCCESS",
|
state="SUCCESS",
|
||||||
meta={
|
meta={
|
||||||
"status": response_message,
|
"status": response_message,
|
||||||
"progress": 100,
|
"progress": 100,
|
||||||
"result": parsed_data,
|
"result": parsed_data,
|
||||||
"vacancy_id": created_vacancy.id if created_vacancy else None
|
"vacancy_id": created_vacancy.id if created_vacancy else None,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"parsed_data": parsed_data,
|
"parsed_data": parsed_data,
|
||||||
"vacancy_id": created_vacancy.id if created_vacancy else None,
|
"vacancy_id": created_vacancy.id if created_vacancy else None,
|
||||||
"message": response_message
|
"message": response_message,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# В случае ошибки
|
# В случае ошибки
|
||||||
self.update_state(
|
self.update_state(
|
||||||
@ -696,8 +707,150 @@ def parse_vacancy_task(self, file_content_base64: str, filename: str, create_vac
|
|||||||
meta={
|
meta={
|
||||||
"status": f"Ошибка при парсинге вакансии: {str(e)}",
|
"status": f"Ошибка при парсинге вакансии: {str(e)}",
|
||||||
"progress": 0,
|
"progress": 0,
|
||||||
"error": str(e)
|
"error": str(e),
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
raise Exception(f"Ошибка при парсинге вакансии: {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)}")
|
||||||
|
8
main.py
8
main.py
@ -1,3 +1,5 @@
|
|||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
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 import resume_router, vacancy_router
|
||||||
from app.routers.admin_router import router as admin_router
|
from app.routers.admin_router import router as admin_router
|
||||||
from app.routers.analysis_router import router as analysis_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.interview_router import router as interview_router
|
||||||
from app.routers.session_router import router as session_router
|
from app.routers.session_router import router as session_router
|
||||||
from app.routers.interview_reports_router import router as interview_report_router
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@ -17,6 +19,9 @@ async def lifespan(app: FastAPI):
|
|||||||
# Запускаем AI агента при старте приложения
|
# Запускаем AI агента при старте приложения
|
||||||
from app.services.agent_manager import agent_manager
|
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...")
|
print("[STARTUP] Starting AI Agent...")
|
||||||
success = await agent_manager.start_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(admin_router, prefix="/api/v1")
|
||||||
app.include_router(interview_report_router, prefix="/api/v1")
|
app.include_router(interview_report_router, prefix="/api/v1")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
return {"message": "HR AI Backend API", "version": "1.0.0"}
|
return {"message": "HR AI Backend API", "version": "1.0.0"}
|
||||||
|
@ -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 ###
|
@ -34,6 +34,8 @@ dependencies = [
|
|||||||
"pdfkit>=1.0.0",
|
"pdfkit>=1.0.0",
|
||||||
"jinja2>=3.1.6",
|
"jinja2>=3.1.6",
|
||||||
"greenlet>=3.2.4",
|
"greenlet>=3.2.4",
|
||||||
|
"xhtml2pdf>=0.2.17",
|
||||||
|
"playwright>=1.55.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
@ -31,9 +31,7 @@ class ModelRegistry:
|
|||||||
"""Получить или создать chat модель"""
|
"""Получить или создать chat модель"""
|
||||||
if self._chat_model is None:
|
if self._chat_model is None:
|
||||||
if settings.openai_api_key:
|
if settings.openai_api_key:
|
||||||
llm = ChatOpenAI(
|
llm = ChatOpenAI(api_key=settings.openai_api_key, model="gpt-5-mini")
|
||||||
api_key=settings.openai_api_key, model="gpt-5-mini"
|
|
||||||
)
|
|
||||||
self._chat_model = ChatModel(llm)
|
self._chat_model = ChatModel(llm)
|
||||||
else:
|
else:
|
||||||
raise ValueError("OpenAI API key не настроен в settings")
|
raise ValueError("OpenAI API key не настроен в settings")
|
||||||
|
BIN
static/fonts/DejaVuSans-Bold.ttf
Normal file
BIN
static/fonts/DejaVuSans-Bold.ttf
Normal file
Binary file not shown.
BIN
static/fonts/DejaVuSans.ttf
Normal file
BIN
static/fonts/DejaVuSans.ttf
Normal file
Binary file not shown.
260
uv.lock
260
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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "argcomplete"
|
name = "argcomplete"
|
||||||
version = "3.6.2"
|
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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "async-timeout"
|
name = "async-timeout"
|
||||||
version = "5.0.1"
|
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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "dataclasses-json"
|
name = "dataclasses-json"
|
||||||
version = "0.6.7"
|
version = "0.6.7"
|
||||||
@ -995,6 +1026,7 @@ dependencies = [
|
|||||||
{ name = "comtypes" },
|
{ name = "comtypes" },
|
||||||
{ name = "docx2txt" },
|
{ name = "docx2txt" },
|
||||||
{ name = "fastapi", extra = ["standard"] },
|
{ name = "fastapi", extra = ["standard"] },
|
||||||
|
{ name = "greenlet" },
|
||||||
{ name = "jinja2" },
|
{ name = "jinja2" },
|
||||||
{ name = "langchain" },
|
{ name = "langchain" },
|
||||||
{ name = "langchain-community" },
|
{ name = "langchain-community" },
|
||||||
@ -1006,6 +1038,7 @@ dependencies = [
|
|||||||
{ name = "livekit-api" },
|
{ name = "livekit-api" },
|
||||||
{ name = "pdfkit" },
|
{ name = "pdfkit" },
|
||||||
{ name = "pdfplumber" },
|
{ name = "pdfplumber" },
|
||||||
|
{ name = "playwright" },
|
||||||
{ name = "psycopg2-binary" },
|
{ name = "psycopg2-binary" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
{ name = "python-docx" },
|
{ name = "python-docx" },
|
||||||
@ -1016,6 +1049,7 @@ dependencies = [
|
|||||||
{ name = "sqlmodel" },
|
{ name = "sqlmodel" },
|
||||||
{ name = "textract" },
|
{ name = "textract" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
|
{ name = "xhtml2pdf" },
|
||||||
{ name = "yandex-speechkit" },
|
{ name = "yandex-speechkit" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -1037,6 +1071,7 @@ requires-dist = [
|
|||||||
{ name = "comtypes", specifier = ">=1.4.12" },
|
{ name = "comtypes", specifier = ">=1.4.12" },
|
||||||
{ name = "docx2txt", specifier = ">=0.9" },
|
{ name = "docx2txt", specifier = ">=0.9" },
|
||||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.104.0" },
|
{ name = "fastapi", extras = ["standard"], specifier = ">=0.104.0" },
|
||||||
|
{ name = "greenlet", specifier = ">=3.2.4" },
|
||||||
{ name = "jinja2", specifier = ">=3.1.6" },
|
{ name = "jinja2", specifier = ">=3.1.6" },
|
||||||
{ name = "langchain", specifier = ">=0.1.0" },
|
{ name = "langchain", specifier = ">=0.1.0" },
|
||||||
{ name = "langchain-community", specifier = ">=0.0.10" },
|
{ name = "langchain-community", specifier = ">=0.0.10" },
|
||||||
@ -1048,6 +1083,7 @@ requires-dist = [
|
|||||||
{ name = "livekit-api", specifier = ">=1.0.5" },
|
{ name = "livekit-api", specifier = ">=1.0.5" },
|
||||||
{ name = "pdfkit", specifier = ">=1.0.0" },
|
{ name = "pdfkit", specifier = ">=1.0.0" },
|
||||||
{ name = "pdfplumber", specifier = ">=0.10.0" },
|
{ name = "pdfplumber", specifier = ">=0.10.0" },
|
||||||
|
{ name = "playwright", specifier = ">=1.55.0" },
|
||||||
{ name = "psycopg2-binary", specifier = ">=2.9.0" },
|
{ name = "psycopg2-binary", specifier = ">=2.9.0" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.1.0" },
|
{ name = "pydantic-settings", specifier = ">=2.1.0" },
|
||||||
{ name = "python-docx", specifier = ">=1.2.0" },
|
{ name = "python-docx", specifier = ">=1.2.0" },
|
||||||
@ -1058,6 +1094,7 @@ requires-dist = [
|
|||||||
{ name = "sqlmodel", specifier = ">=0.0.14" },
|
{ name = "sqlmodel", specifier = ">=0.0.14" },
|
||||||
{ name = "textract", specifier = ">=1.5.0" },
|
{ name = "textract", specifier = ">=1.5.0" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" },
|
||||||
|
{ name = "xhtml2pdf", specifier = ">=0.2.17" },
|
||||||
{ name = "yandex-speechkit", specifier = ">=1.5.0" },
|
{ name = "yandex-speechkit", specifier = ">=1.5.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -1070,6 +1107,19 @@ dev = [
|
|||||||
{ name = "ruff", specifier = ">=0.12.12" },
|
{ 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]]
|
[[package]]
|
||||||
name = "httpcore"
|
name = "httpcore"
|
||||||
version = "1.0.9"
|
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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
version = "25.0"
|
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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "pluggy"
|
name = "pluggy"
|
||||||
version = "1.6.0"
|
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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "pygments"
|
name = "pygments"
|
||||||
version = "2.19.2"
|
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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "pyjwt"
|
name = "pyjwt"
|
||||||
version = "2.10.1"
|
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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "pypdfium2"
|
name = "pypdfium2"
|
||||||
version = "4.30.0"
|
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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "python-dateutil"
|
name = "python-dateutil"
|
||||||
version = "2.9.0.post0"
|
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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "sympy"
|
name = "sympy"
|
||||||
version = "1.14.0"
|
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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "tokenizers"
|
name = "tokenizers"
|
||||||
version = "0.22.0"
|
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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "ujson"
|
name = "ujson"
|
||||||
version = "5.11.0"
|
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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
version = "2.5.0"
|
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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "websockets"
|
name = "websockets"
|
||||||
version = "15.0.1"
|
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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "xlrd"
|
name = "xlrd"
|
||||||
version = "2.0.2"
|
version = "2.0.2"
|
||||||
|
Loading…
Reference in New Issue
Block a user