Compare commits

..

10 Commits

Author SHA1 Message Date
1ca7efe4d1 updates 2025-09-10 21:41:42 +03:00
f7ed0cb14e upd milvus 2025-09-09 21:43:44 +05:00
7694b1e1b5 upd readme 2025-09-09 21:13:32 +05:00
954fa2bc50 upd gpt; add pdf generating 2025-09-09 20:26:14 +05:00
e9a70cf393 upd tts voice 2025-09-08 23:45:56 +05:00
87135f5c60 Added reports router 2025-09-08 16:08:25 +03:00
c5068562c4 Merge remote-tracking branch 'origin/main' 2025-09-08 16:07:43 +03:00
d3018952bb add vacancy parsing; add generating template pdf from html [wip] 2025-09-08 17:26:12 +05:00
58286c23b6 Fix status migration 2025-09-08 14:49:44 +03:00
9128bb8881 upd promts; fix reports 2025-09-08 14:07:09 +05:00
37 changed files with 6237 additions and 2731 deletions

View File

@ -23,16 +23,14 @@ ANTHROPIC_API_KEY=your-anthropic-api-key-here
OPENAI_MODEL=gpt-4o-mini
OPENAI_EMBEDDINGS_MODEL=text-embedding-3-small
# AI Agent API Keys (for voice interviewer)
DEEPGRAM_API_KEY=your-deepgram-api-key-here
CARTESIA_API_KEY=your-cartesia-api-key-here
ELEVENLABS_API_KEY=your-elevenlabs-api-key-here
# LiveKit Configuration (for interview feature)
LIVEKIT_URL=ws://localhost:7880
LIVEKIT_URL=wss://hackaton-eizc9zqk.livekit.cloud
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=devkey_secret_32chars_minimum_length
# App Configuration
APP_ENV=development
DEBUG=true
DEBUG=true
# Domain for Caddy (use your domain for automatic HTTPS)
DOMAIN=hr.aiquity.xyz

2
.env.local Normal file
View File

@ -0,0 +1,2 @@
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000/api
NEXT_PUBLIC_LIVEKIT_URL=ws://localhost:7880

106
Caddyfile Normal file
View File

@ -0,0 +1,106 @@
# Caddyfile for HR AI Backend with automatic HTTPS
# Environment variable DOMAIN will be used, defaults to localhost
{$DOMAIN:localhost} {
# Backend API routes
handle /api/* {
reverse_proxy backend:8000
}
# Health check endpoint
handle /health {
reverse_proxy backend:8000
}
# LiveKit WebSocket and HTTP endpoints
handle /livekit/* {
reverse_proxy livekit:7880
}
# LiveKit WebSocket upgrade
handle /rtc {
reverse_proxy livekit:7880
}
# Frontend (SPA) - serve everything else
handle {
reverse_proxy frontend:3000
# SPA fallback - serve index.html for all non-API routes
@notapi {
not path /api/*
not path /health
not path /livekit/*
not path /rtc
file {
try_files {path} /index.html
}
}
rewrite @notapi /index.html
}
# Enable gzip compression
encode gzip
# Security headers
header {
# HSTS
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
# XSS Protection
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
X-XSS-Protection "1; mode=block"
# CORS for API (adjust origins as needed)
Access-Control-Allow-Origin "*"
Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
Access-Control-Allow-Headers "Content-Type, Authorization"
}
# Logging
log {
output file /var/log/caddy/access.log
format json
}
}
# Development/localhost configuration (no HTTPS)
localhost {
# Backend API routes
handle /api/* {
reverse_proxy backend:8000
}
# Health check endpoint
handle /health {
reverse_proxy backend:8000
}
# LiveKit endpoints
handle /livekit/* {
reverse_proxy livekit:7880
}
handle /rtc {
reverse_proxy livekit:7880
}
# Frontend
handle {
reverse_proxy frontend:3000
@notapi {
not path /api/*
not path /health
not path /livekit/*
not path /rtc
file {
try_files {path} /index.html
}
}
rewrite @notapi /index.html
}
encode gzip
}

75
Dockerfile Normal file
View File

@ -0,0 +1,75 @@
FROM --platform=linux/amd64 python:3.11-slim
# Set working directory
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
g++ \
curl \
git \
&& rm -rf /var/lib/apt/lists/*
# Install uv for faster package management
RUN pip install uv
# Copy dependency files
COPY pyproject.toml uv.lock ./
# Install Python dependencies
RUN uv sync --frozen --no-dev
# Install Playwright and Chromium for PDF generation
RUN uv run playwright install-deps
RUN uv run playwright install chromium
# Copy application code
COPY . .
# Create directories for agent communication
RUN mkdir -p /tmp/agent_commands
# Expose the port
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
# Create startup script
COPY <<EOF /app/start.sh
#!/bin/bash
set -e
# Load environment variables from .env file if it exists
# Only set variables that are not already set
if [ -f .env ]; then
echo "Loading environment variables from .env file..."
set -a # automatically export all variables
source .env
set +a # stop auto-exporting
fi
# Run database migrations
echo "Running database migrations..."
uv run alembic upgrade head
# Start Celery worker in background
echo "Starting Celery worker in background..."
uv run celery -A celery_worker.celery_app worker --loglevel=info --pool=solo &
# Start FastAPI server
if [ "\$APP_ENV" = "development" ]; then
echo "Starting FastAPI development server..."
exec uv run fastapi dev main.py --host 0.0.0.0 --port 8000
else
echo "Starting FastAPI production server..."
exec uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
fi
EOF
RUN chmod +x /app/start.sh
# Default command
CMD ["/app/start.sh"]

View File

@ -42,7 +42,7 @@ POST /api/interview/{resumeId}/token
{
"token": "livekit_jwt_token_here",
"roomName": "interview_room_123",
"serverUrl": "wss://your-livekit-server.com"
"serverUrl": "ws://your-livekit-server.com"
}
```
@ -50,7 +50,7 @@ POST /api/interview/{resumeId}/token
#### Environment Variables
```env
NEXT_PUBLIC_LIVEKIT_URL=wss://your-livekit-server.com
NEXT_PUBLIC_LIVEKIT_URL=ws://your-livekit-server.com
LIVEKIT_API_KEY=your_api_key
LIVEKIT_API_SECRET=your_api_secret
```

265
README.md
View File

@ -1 +1,264 @@
# hr-ai-backend
# HR AI Backend
Система автоматического проведения собеседований с помощью ИИ агента. Включает парсинг резюме, проведение голосовых интервью через LiveKit, анализ результатов и генерацию PDF отчетов.
## 🚀 Основной функционал
### 📄 Управление резюме
- **Загрузка и парсинг резюме** в форматах PDF, DOCX
- **Автоматическое извлечение данных**: ФИО, навыки, опыт работы, образование
- **Генерация плана интервью** на основе содержания резюме
- **Поиск и фильтрация** резюме по различным критериям
- **Векторный поиск** с использованием Milvus для семантического поиска
### 🎯 Управление вакансиями
- **Создание и редактирование** вакансий
- **Парсинг вакансий** из файлов (txt, docx)
- **Автоматическое сопоставление** резюме с вакансиями
- **Ранжирование кандидатов** по соответствию вакансии
### 🎤 AI-собеседования
- **Голосовые интервью** с использованием LiveKit и OpenAI
- **Адаптивный AI-интервьюер "Стефани"** с русскоязычным интерфейсом
- **Автоматическая генерация вопросов** под конкретного кандидата
- **Реальное время** распознавание речи и синтез голоса
- **Трекинг времени и прогресса** интервью
### 📊 Анализ и отчетность
- **Автоматический анализ интервью** с помощью GPT-4
- **Комплексная оценка** по 5 критериям:
- Технические навыки
- Релевантность опыта
- Коммуникативные навыки
- Решение проблем
- Культурное соответствие
- **Генерация PDF отчетов** с детальной оценкой
- **Рекомендации** по найму (strongly_recommend/recommend/consider/reject)
- **Аналитика по вакансиям** и статистика кандидатов
### 🔧 Администрирование
- **Мониторинг системы** и активных процессов
- **Управление AI агентами** (запуск/остановка)
- **Аналитические панели** с метриками
- **Логирование и отладка** всех процессов
## 🏗️ Архитектура
### Основные компоненты
**FastAPI приложение** (`app/`):
- `main.py` - точка входа с middleware и настройкой роутеров
- `routers/` - API эндпоинты по доменам (resume, interview, vacancy, admin)
- `models/` - SQLModel схемы базы данных с отношениями
- `services/` - бизнес-логика для обработки сложных операций
- `repositories/` - слой доступа к данным с использованием SQLModel/SQLAlchemy
**Фоновая обработка** (`celery_worker/`):
- `celery_app.py` - настройка Celery с Redis backend
- `tasks.py` - асинхронные задачи для парсинга резюме и анализа
- `interview_analysis_task.py` - специализированная задача для анализа интервью
**AI система интервью**:
- `ai_interviewer_agent.py` - голосовой AI агент на базе LiveKit
- `app/services/agent_manager.py` - singleton менеджер для управления агентами
- Агент работает как единый процесс, обрабатывая одно интервью за раз
- Межпроцессное взаимодействие через JSON файлы команд
- Автоматический запуск/остановка с жизненным циклом FastAPI
**RAG система** (`rag/`):
- `vector_store.py` - интеграция с векторной БД Milvus для поиска резюме
- `llm/model.py` - интеграция с OpenAI GPT для парсинга и планирования интервью
- `service/model.py` - оркестрация RAG сервисов
### База данных
**Ключевые модели**:
- `Resume` - резюме кандидатов с статусами парсинга и планами интервью
- `InterviewSession` - сессии LiveKit с трекингом AI агента
- `InterviewReport` - детальные отчеты по интервью с оценками
- `Vacancy` - вакансии с требованиями и описанием
- `Session` - управление пользовательскими сессиями через cookies
**Статусы**:
- `ResumeStatus`: pending → parsing → parsed → interview_scheduled → interviewed
- `InterviewStatus`: created → active → completed/failed
- `RecommendationType`: strongly_recommend/recommend/consider/reject
## 🛠️ Технологический стек
- **Backend**: FastAPI, Python 3.11+
- **База данных**: PostgreSQL с asyncpg
- **Кэш и брокер**: Redis
- **Векторная БД**: Milvus (опционально, есть fallback)
- **Файловое хранилище**: S3-совместимое хранилище
- **AI/ML**: OpenAI GPT-4, Whisper STT
- **Голосовые технологии**: LiveKit, Deepgram, Cartesia, ElevenLabs
- **Очереди**: Celery для асинхронных задач
- **PDF генерация**: Playwright (заменил WeasyPrint)
- **Контейнеризация**: Docker для некоторых сервисов
## 📦 Установка и запуск
### Предварительные требования
1. **Python 3.11+** с uv пакетным менеджером
2. **PostgreSQL** (локально или в Docker)
3. **Redis** (для Celery и кэширования)
4. **Milvus** (опционально, для векторного поиска)
5. **S3-совместимое хранилище** (MinIO или AWS S3)
### 1. Клонирование и зависимости
```bash
git clone <repository-url>
cd hr-ai-backend
# Установка зависимостей через uv
uv sync
# Установка Playwright браузеров для PDF генерации
uv run playwright install chromium
```
### 2. Настройка окружения
Создайте файл `.env` на основе `.env.example`:
```bash
cp .env.example .env
```
Заполните основные переменные:
```env
# База данных
DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/hr_ai_backend
# Redis
REDIS_URL=redis://localhost:6379/0
# OpenAI API
OPENAI_API_KEY=your-openai-api-key
# LiveKit (для голосовых интервью)
LIVEKIT_URL=ws://localhost:7880
LIVEKIT_API_KEY=your-livekit-key
LIVEKIT_API_SECRET=your-livekit-secret
# S3 хранилище
S3_ENDPOINT_URL=http://localhost:9000
S3_ACCESS_KEY_ID=minioadmin
S3_SECRET_ACCESS_KEY=minioadmin
S3_BUCKET_NAME=hr-ai-files
# Milvus (опционально)
MILVUS_URI=http://localhost:19530
```
### 3. Запуск сервисов
```bash
# 1. Запуск Redis
docker run -d --name redis -p 6379:6379 redis
# 2. Запуск LiveKit сервера (для интервью)
docker run -d --name livekit \
-p 7880:7880 -p 7881:7881 \
livekit/livekit-server --dev
```
### 4. Миграции базы данных
```bash
# Применить миграции
uv run alembic upgrade head
# Создать новую миграцию (при изменении моделей)
uv run alembic revision --autogenerate -m "описание изменений"
```
### 5. Запуск приложения
```bash
# FastAPI сервер
uv run fastapi dev main.py
# Celery worker (в отдельном терминале)
uv run celery -A celery_worker.celery_app worker --loglevel=info
```
### База данных
```bash
# Новая миграция
uv run alembic revision --autogenerate -m "описание"
# Применить миграции
uv run alembic upgrade head
```
## 🎯 Основные API эндпоинты
### Резюме
- `POST /api/v1/resume/upload` - загрузка резюме
- `GET /api/v1/resume/` - список резюме с фильтрацией
- `GET /api/v1/resume/{id}` - получение резюме
- `DELETE /api/v1/resume/{id}` - удаление резюме
### Вакансии
- `POST /api/v1/vacancy/` - создание вакансии
- `GET /api/v1/vacancy/` - список вакансий
- `POST /api/v1/vacancy/parse` - парсинг вакансии из файла
### Интервью
- `POST /api/v1/interview/{resume_id}/validate` - валидация готовности к интервью
- `POST /api/v1/interview/{resume_id}/token` - получение токена LiveKit
- `GET /api/v1/interview/{resume_id}/status` - статус интервью
### Анализ и отчеты
- `POST /api/v1/analysis/interview-report/{resume_id}` - запуск анализа интервью
- `GET /api/v1/analysis/report/{resume_id}` - получение отчета
- `POST /api/v1/analysis/generate-pdf/{resume_id}` - генерация PDF отчета
- `GET /api/v1/analysis/pdf-report/{resume_id}` - скачивание PDF
## 🔄 Рабочий процесс
### 1. Обработка резюме
1. Загрузка файла через `/api/v1/resume/upload`
2. Celery задача извлекает текст и парсит данные через OpenAI
3. Генерируется план интервью под кандидата
4. Создаются векторные эмбеддинги для поиска
5. Статус обновляется через enum: `parsing``parsed`
### 2. Проведение интервью
1. Валидация готовности через `/api/v1/interview/{id}/validate`
2. Получение токена LiveKit для подключения
3. AI агент автоматически назначается на сессию
4. Проведение голосового интервью в реальном времени
5. Сохранение диалога и автоматическое завершение
6. Статус резюме: `interview_scheduled``interviewed`
### 3. Анализ результатов
1. После завершения интервью запускается анализ через HTTP fallback
2. GPT-5-mini анализирует диалог по 5 критериям с оценками 0-100
3. Создается `InterviewReport` с детальной оценкой
4. Генерируется рекомендация: `strongly_recommend`/`recommend`/`consider`/`reject`
5. При необходимости создается PDF отчет через Playwright
## 🐛 Отладка и мониторинг
### Логирование
- Все процессы логируются с детальным трейсингом
- AI агент: отдельный лог файл `ai_agent.log`
- Celery worker: стандартный вывод с уровнем INFO
- FastAPI: встроенное логирование с middleware
### Частые проблемы
1. **Агент не запускается**: проверьте LiveKit сервер и API ключи
2. **Celery задачи висят**: проверьте подключение к Redis
3. **PDF не генерируется**: убедитесь что Playwright браузеры установлены
4. **Парсинг не работает**: проверьте OpenAI API ключ и квоты
💡 **Примечание**: Система в активной разработке. AI агент работает как singleton (одно интервью за раз) - это ограничение хакатона, в продакшене можно масштабировать на несколько агентов.

View File

@ -18,7 +18,7 @@ if os.name == "nt": # Windows
from livekit.agents import Agent, AgentSession, JobContext, WorkerOptions, cli
from livekit.api import DeleteRoomRequest, LiveKitAPI
from livekit.plugins import cartesia, deepgram, openai, silero
from livekit.plugins import openai, silero
from app.core.database import get_session
from app.repositories.interview_repository import InterviewRepository
@ -63,11 +63,10 @@ class InterviewAgent:
self.last_user_response = None
self.intro_done = False # Новый флаг — произнесено ли приветствие
self.interview_finalized = False # Флаг завершения интервью
# Трекинг времени интервью
import time
self.interview_start_time = time.time()
# Трекинг времени интервью
self.interview_start_time = None # Устанавливается при фактическом старте
self.interview_end_time = None # Устанавливается при завершении
self.duration_minutes = interview_plan.get("interview_structure", {}).get(
"duration_minutes", 10
)
@ -77,16 +76,34 @@ class InterviewAgent:
)
self.total_sections = len(self.sections)
logger.info(
f"[TIME] Interview started at {time.strftime('%H:%M:%S')}, duration: {self.duration_minutes} min"
)
def get_current_section(self) -> dict:
"""Получить текущую секцию интервью"""
if self.current_section < len(self.sections):
return self.sections[self.current_section]
return {}
def _format_questions(self, questions):
"""
Форматирует список вопросов в строку, независимо от их структуры
"""
if not questions:
return "Нет вопросов"
formatted = []
for question in questions:
if isinstance(question, str):
# Простая строка
formatted.append(question)
elif isinstance(question, dict):
# Объект с полями (например, из LLM генерации)
question_text = question.get("question", question.get("text", str(question)))
formatted.append(question_text)
else:
# Любой другой тип - приводим к строке
formatted.append(str(question))
return ", ".join(formatted)
def get_next_question(self) -> str:
"""Получить следующий вопрос"""
section = self.get_current_section()
@ -126,16 +143,15 @@ class InterviewAgent:
key_evaluation_points = self.interview_plan.get("key_evaluation_points", [])
# Вычисляем текущее время интервью
import time
elapsed_minutes = (time.time() - self.interview_start_time) / 60
remaining_minutes = max(0, self.duration_minutes - elapsed_minutes)
time_percentage = min(100, (elapsed_minutes / self.duration_minutes) * 100)
time_info = self.get_time_info()
elapsed_minutes = time_info["elapsed_minutes"]
remaining_minutes = time_info["remaining_minutes"]
time_percentage = time_info["time_percentage"]
# Формируем план интервью для агента
sections_info = "\n".join(
[
f"- {section.get('name', 'Секция')}: {', '.join(section.get('questions', []))}"
f"- {section.get('name', 'Секция')}: {self._format_questions(section.get('questions', []))}"
for section in self.sections
]
)
@ -160,40 +176,41 @@ class InterviewAgent:
if self.vacancy_data:
employment_type_map = {
"full": "Полная занятость",
"part": "Частичная занятость",
"part": "Частичная занятость",
"project": "Проектная работа",
"volunteer": "Волонтёрство",
"probation": "Стажировка"
"probation": "Стажировка",
}
experience_map = {
"noExperience": "Без опыта",
"between1And3": "1-3 года",
"between3And6": "3-6 лет",
"moreThan6": "Более 6 лет"
"between3And6": "3-6 лет",
"moreThan6": "Более 6 лет",
}
schedule_map = {
"fullDay": "Полный день",
"shift": "Сменный график",
"flexible": "Гибкий график",
"remote": "Удалённая работа",
"flyInFlyOut": "Вахтовый метод"
"flyInFlyOut": "Вахтовый метод",
}
vacancy_info = f"""
ИНФОРМАЦИЯ О ВАКАНСИИ:
- Должность: {self.vacancy_data.get('title', 'Не указана')}
- Описание: {self.vacancy_data.get('description', 'Не указано')}
- Ключевые навыки: {self.vacancy_data.get('key_skills') or 'Не указаны'}
- Тип занятости: {employment_type_map.get(self.vacancy_data.get('employment_type'), self.vacancy_data.get('employment_type', 'Не указан'))}
- Опыт работы: {experience_map.get(self.vacancy_data.get('experience'), self.vacancy_data.get('experience', 'Не указан'))}
- График работы: {schedule_map.get(self.vacancy_data.get('schedule'), self.vacancy_data.get('schedule', 'Не указан'))}
- Регион: {self.vacancy_data.get('area_name', 'Не указан')}
- Профессиональные роли: {self.vacancy_data.get('professional_roles') or 'Не указаны'}
- Контактное лицо: {self.vacancy_data.get('contacts_name') or 'Не указано'}"""
- Должность: {self.vacancy_data.get("title", "Не указана")}
- Описание: {self.vacancy_data.get("description", "Не указано")}
- Ключевые навыки: {self.vacancy_data.get("key_skills") or "Не указаны"}
- Тип занятости: {employment_type_map.get(self.vacancy_data.get("employment_type"), self.vacancy_data.get("employment_type", "Не указан"))}
- Опыт работы: {experience_map.get(self.vacancy_data.get("experience"), self.vacancy_data.get("experience", "Не указан"))}
- График работы: {schedule_map.get(self.vacancy_data.get("schedule"), self.vacancy_data.get("schedule", "Не указан"))}
- Регион: {self.vacancy_data.get("area_name", "Не указан")}
- Профессиональные роли: {self.vacancy_data.get("professional_roles") or "Не указаны"}
- Контактное лицо: {self.vacancy_data.get("contacts_name") or "Не указано"}"""
return f"""
Ты опытный HR-интервьюер, который проводит адаптивное голосовое собеседование. Представься контактным именем из вакансии (если оно есть)
Ты опытный HR-интервьюер Стефани, который проводит адаптивное голосовое собеседование. Представься как Стефани
Разговаривай только на русском языке.
ИНФОРМАЦИЯ О ВАКАНСИИ:
@ -203,6 +220,7 @@ class InterviewAgent:
- Имя: {candidate_name}
- Опыт работы: {candidate_years} лет
- Ключевые навыки: {candidate_skills}
Из имени определи пол и упоминай кандидата исходя из пола
ЦЕЛЬ ИНТЕРВЬЮ:
@ -219,7 +237,7 @@ class InterviewAgent:
- Способность учиться и адаптироваться.
- Совпадение ценностей и принципов с командой и компанией.
ПЛАН ИНТЕРВЬЮ (как руководство, адаптируйся по ситуации)
ПЛАН ИНТЕРВЬЮ (имей его ввиду, но адаптируйся под ситуацию: либо углубиться в детали, либо перейти к следующему вопросу)
{sections_info}
@ -233,6 +251,9 @@ class InterviewAgent:
Проблемные / кейсы (20%) проверить мышление и подход к решению.
Пример: "У нас есть система, которая падает раз в неделю. Как бы ты подошёл к диагностике проблемы?"
Задавай вопросы кратко и понятно (максимум тремя предложениями). Не вываливай кучу информации на кандидата.
Не перечисляй человеку все пункты и вопросы из секции. Предлагай один общий вопрос или задавай уточняющие по по очереди.
ВРЕМЯ ИНТЕРВЬЮ:
- Запланированная длительность: {self.duration_minutes} минут
- Прошло времени: {elapsed_minutes:.1f} минут ({time_percentage:.0f}%)
@ -253,7 +274,7 @@ class InterviewAgent:
ИНСТРУКЦИИ:
1. Начни с приветствия: {greeting}
2. Адаптируй вопросы под ответы кандидата
3. Не повторяй то, что клиент тебе сказал, лучше показывай, что понял, услышал и иди дальше. Лишний раз его не хвали
3. Не повторяй то, что клиент тебе сказал, лучше показывай, что поняла, услышала, и иди дальше. Лишний раз его не хвали
3. Следи за временем - при превышении 80% времени начинай завершать интервью
4. Оценивай качество и глубину ответов кандидата
5. Если получаешь сообщение "[СИСТЕМА] Клиент молчит..." - это означает проблемы со связью или кандидат растерялся. Скажи что-то вроде "Приём! Ты меня слышишь?" или "Всё в порядке? Связь не пропала?"
@ -284,11 +305,20 @@ class InterviewAgent:
def get_time_info(self) -> dict[str, float]:
"""Получает информацию о времени интервью"""
import time
elapsed_minutes = (time.time() - self.interview_start_time) / 60
remaining_minutes = max(0.0, self.duration_minutes - elapsed_minutes)
time_percentage = min(100.0, (elapsed_minutes / self.duration_minutes) * 100)
if self.interview_start_time is None:
# Интервью еще не началось
elapsed_minutes = 0.0
remaining_minutes = float(self.duration_minutes)
time_percentage = 0.0
else:
# Интервью идет
current_time = self.interview_end_time or time.time()
elapsed_minutes = (current_time - self.interview_start_time) / 60
remaining_minutes = max(0.0, self.duration_minutes - elapsed_minutes)
time_percentage = min(
100.0, (elapsed_minutes / self.duration_minutes) * 100
)
return {
"elapsed_minutes": elapsed_minutes,
@ -360,7 +390,9 @@ async def entrypoint(ctx: JobContext):
session_id = metadata.get("session_id", session_id)
logger.info(f"[INIT] Loaded interview plan for session {session_id}")
if vacancy_data:
logger.info(f"[INIT] Loaded vacancy data from metadata: {vacancy_data.get('title', 'Unknown')}")
logger.info(
f"[INIT] Loaded vacancy data from metadata: {vacancy_data.get('title', 'Unknown')}"
)
except Exception as e:
logger.warning(f"[INIT] Failed to load metadata: {str(e)}")
interview_plan = {}
@ -403,41 +435,24 @@ async def entrypoint(ctx: JobContext):
)
# STT
stt = (
openai.STT(
model="whisper-1", language="ru", api_key=settings.openai_api_key
)
if settings.openai_api_key
else openai.STT(
model="whisper-1", language="ru", api_key=settings.openai_api_key
)
)
stt = openai.STT(model="whisper-1", language="ru", api_key=settings.openai_api_key)
# LLM
llm = openai.LLM(
model="gpt-5-mini", api_key=settings.openai_api_key
)
llm = openai.LLM(model="gpt-5-mini", api_key=settings.openai_api_key)
# TTS
tts = (
openai.TTS(
model="gpt-4o-mini-tts",
api_key=settings.openai_api_key,
)
if settings.openai_api_key
else silero.TTS(language="ru", model="v4_ru")
)
tts = openai.TTS(model="tts-1-hd", api_key=settings.openai_api_key, voice="nova")
# Создаем обычный Agent и Session
agent = Agent(instructions=interviewer.get_system_instructions())
# Создаем AgentSession с обычным TTS и детекцией неактивности пользователя
session = AgentSession(
vad=silero.VAD.load(),
stt=stt,
llm=llm,
vad=silero.VAD.load(),
stt=stt,
llm=llm,
tts=tts,
user_away_timeout=7.0 # 7 секунд неактивности для срабатывания away
user_away_timeout=7.0, # 7 секунд неактивности для срабатывания away
)
# --- Сохранение диалога в БД ---
@ -474,6 +489,23 @@ async def entrypoint(ctx: JobContext):
interviewer_instance.interview_finalized = True
# Устанавливаем время завершения интервью
interviewer_instance.interview_end_time = time.time()
if interviewer_instance.interview_start_time:
total_minutes = (
interviewer_instance.interview_end_time
- interviewer_instance.interview_start_time
) / 60
logger.info(
f"[TIME] Interview ended at {time.strftime('%H:%M:%S')}, total duration: {total_minutes:.1f} min"
)
else:
logger.info(
f"[TIME] Interview ended at {time.strftime('%H:%M:%S')} (no start time recorded)"
)
try:
logger.info(
f"[FINALIZE] Starting interview finalization for room: {room_name}"
@ -534,20 +566,20 @@ async def entrypoint(ctx: JobContext):
)
if not interviewer.interview_finalized:
await complete_interview_sequence(
ctx.room.name, interviewer
)
await complete_interview_sequence(ctx.room.name, interviewer)
break
return False
# --- Мониторинг команд завершения ---
async def monitor_end_commands():
"""Мониторит команды завершения сессии"""
"""Мониторит команды завершения сессии и лимит времени"""
command_file = "agent_commands.json"
TIME_LIMIT_MINUTES = 60 # Жесткий лимит времени интервью
while not interviewer.interview_finalized:
try:
# Проверяем команды завершения
if os.path.exists(command_file):
with open(command_file, encoding="utf-8") as f:
command = json.load(f)
@ -566,71 +598,51 @@ async def entrypoint(ctx: JobContext):
)
break
await asyncio.sleep(1) # Проверяем каждые 1 секунды
# Проверяем превышение лимита времени
if interviewer.interview_start_time is not None:
time_info = interviewer.get_time_info()
if time_info["elapsed_minutes"] >= TIME_LIMIT_MINUTES:
logger.warning(
f"[TIME_LIMIT] Interview exceeded {TIME_LIMIT_MINUTES} minutes "
f"({time_info['elapsed_minutes']:.1f} min), forcing completion"
)
if not interviewer.interview_finalized:
await complete_interview_sequence(
ctx.room.name, interviewer
)
break
await asyncio.sleep(2) # Проверяем каждые 5 секунд
except Exception as e:
logger.error(f"[COMMAND] Error monitoring commands: {str(e)}")
await asyncio.sleep(5)
await asyncio.sleep(2)
# Запускаем мониторинг команд в фоне
asyncio.create_task(monitor_end_commands())
# --- Обработчик состояния пользователя (замена мониторинга тишины) ---
disconnect_timer: asyncio.Task | None = None
@session.on("user_state_changed")
def on_user_state_changed(event):
"""Обработчик изменения состояния пользователя (активен/неактивен)"""
async def on_change():
nonlocal disconnect_timer
async def on_change():
logger.info(f"[USER_STATE] User state changed to: {event.new_state}")
# === Пользователь начал говорить ===
if event.new_state == "speaking":
# Если есть таймер на 30 секунд — отменяем его
if disconnect_timer is not None:
logger.info("[USER_STATE] Cancelling disconnect timer due to speaking")
disconnect_timer.cancel()
disconnect_timer = None
# === Пользователь молчит более 10 секунд (state == away) ===
elif event.new_state == "away" and interviewer.intro_done:
logger.info("[USER_STATE] User away detected, sending check-in message...")
if event.new_state == "away" and interviewer.intro_done:
logger.info(
"[USER_STATE] User away detected, sending check-in message..."
)
# 1) Первое сообщение — проверка связи
handle = await session.generate_reply(
# сообщение — проверка связи
await session.generate_reply(
instructions=(
"Клиент молчит уже больше 10 секунд. "
"Проверь связь фразой вроде 'Приём! Ты меня слышишь?' "
"или 'Связь не пропала?'"
)
)
await handle # ждем завершения первой реплики
# 2) Таймер на 30 секунд
async def disconnect_timeout():
try:
await asyncio.sleep(30)
logger.info("[DISCONNECT_TIMER] 30 seconds passed, sending disconnect message")
# Второе сообщение — считаем, что клиент отключился
await session.generate_reply(
instructions="Похоже клиент отключился"
)
logger.info("[DISCONNECT_TIMER] Disconnect message sent successfully")
except asyncio.CancelledError:
logger.info("[DISCONNECT_TIMER] Timer cancelled before completion")
except Exception as e:
logger.error(f"[DISCONNECT_TIMER] Error in disconnect timeout: {e}")
# 3) Если уже есть активный таймер — отменяем его перед запуском нового
if disconnect_timer is not None:
disconnect_timer.cancel()
disconnect_timer = asyncio.create_task(disconnect_timeout())
asyncio.create_task(on_change())
@ -673,21 +685,18 @@ async def entrypoint(ctx: JobContext):
"room_name": room_name,
"timestamp": datetime.now(UTC).isoformat(),
}
with open(command_file, "w", encoding="utf-8") as f:
json.dump(release_command, f, ensure_ascii=False, indent=2)
logger.info(f"[SEQUENCE] Step 3: Session {session_id} release signal sent")
except Exception as e:
logger.error(f"[SEQUENCE] Step 3: Failed to send release signal: {str(e)}")
logger.info("[SEQUENCE] Step 3: Continuing without release signal")
# --- Упрощенная логика обработки пользовательского ответа ---
async def handle_user_input(user_response: str):
current_section = interviewer.get_current_section()
# Сохраняем ответ пользователя
@ -705,6 +714,13 @@ async def entrypoint(ctx: JobContext):
# Обновляем прогресс интервью
if not interviewer.intro_done:
interviewer.intro_done = True
# Устанавливаем время начала интервью при первом сообщении
import time
interviewer.interview_start_time = time.time()
logger.info(
f"[TIME] Interview started at {time.strftime('%H:%M:%S')}, duration: {interviewer.duration_minutes} min"
)
# Обновляем счетчик сообщений и треким время
interviewer.questions_asked_total += 1
@ -728,7 +744,6 @@ async def entrypoint(ctx: JobContext):
if role == "user":
asyncio.create_task(handle_user_input(text))
elif role == "assistant":
# Сохраняем ответ агента в историю диалога
current_section = interviewer.get_current_section()
interviewer.conversation_history.append(
@ -755,10 +770,23 @@ async def entrypoint(ctx: JobContext):
def main():
logging.basicConfig(level=logging.INFO)
asyncio.set_event_loop_policy(
asyncio.WindowsSelectorEventLoopPolicy()
) # фикс для Windows
# Настройка логирования для продакшена
if os.getenv("APP_ENV") == "production":
logging.basicConfig(
level=logging.INFO,
format='{"timestamp": "%(asctime)s", "level": "%(levelname)s", "logger": "%(name)s", "message": "%(message)s", "module": "%(module)s", "line": %(lineno)d}',
datefmt='%Y-%m-%dT%H:%M:%S'
)
else:
logging.basicConfig(
level=logging.DEBUG if os.getenv("DEBUG") == "true" else logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
# Устанавливаем политику цикла событий только для Windows
if os.name == "nt": # Windows
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))

View File

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

View File

@ -38,12 +38,17 @@ class InterviewSession(InterviewSessionBase, table=True):
id: int | None = Field(default=None, primary_key=True)
started_at: datetime = Field(default_factory=datetime.utcnow)
completed_at: datetime | None = None
interview_start_time: datetime | None = None
interview_end_time: datetime | None = None
# Связь с отчетом (один к одному)
report: Optional["InterviewReport"] = Relationship(
back_populates="interview_session"
)
# Связь с резюме
resume: Optional["Resume"] = Relationship()
class InterviewSessionCreate(SQLModel):
resume_id: int

View File

@ -38,15 +38,15 @@ class VacancyBase(SQLModel):
salary_to: int | None = None
salary_currency: str | None = Field(default="RUR", max_length=3)
gross_salary: bool | None = False
company_name: str = Field(max_length=255)
company_name: str | None = Field(default=None, max_length=255)
company_description: str | None = None
area_name: str = Field(max_length=255)
area_name: str | None = Field(default=None, max_length=255)
metro_stations: str | None = None
address: str | None = None
professional_roles: str | None = None
contacts_name: str | None = Field(max_length=255)
contacts_email: str | None = Field(max_length=255)
contacts_phone: str | None = Field(max_length=50)
contacts_name: str | None = Field(default=None, max_length=255)
contacts_email: str | None = Field(default=None, max_length=255)
contacts_phone: str | None = Field(default=None, max_length=50)
is_archived: bool = Field(default=False)
premium: bool = Field(default=False)
published_at: datetime | None = Field(default_factory=datetime.utcnow)

View File

@ -0,0 +1,91 @@
from datetime import datetime
from typing import Annotated
from fastapi import Depends
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_session
from app.models.interview import InterviewSession
from app.models.interview_report import InterviewReport
from app.models.resume import Resume
from app.models.vacancy import Vacancy
from app.repositories.base_repository import BaseRepository
class InterviewReportRepository(BaseRepository[InterviewReport]):
def __init__(self, session: Annotated[AsyncSession, Depends(get_session)]):
super().__init__(InterviewReport, session)
async def get_by_session_id(self, session_id: int) -> InterviewReport | None:
"""Получить отчёт по ID сессии интервью"""
statement = select(InterviewReport).where(
InterviewReport.interview_session_id == session_id
)
result = await self._session.execute(statement)
return result.scalar_one_or_none()
async def update_scores(
self,
report_id: int,
scores: dict,
) -> bool:
"""Обновить оценки в отчёте"""
try:
await self._session.execute(
update(InterviewReport)
.where(InterviewReport.id == report_id)
.values(
**scores,
updated_at=datetime.utcnow(),
)
)
await self._session.commit()
return True
except Exception:
await self._session.rollback()
return False
async def update_pdf_url(self, report_id: int, pdf_url: str) -> bool:
"""Обновить ссылку на PDF отчёта"""
try:
await self._session.execute(
update(InterviewReport)
.where(InterviewReport.id == report_id)
.values(pdf_report_url=pdf_url, updated_at=datetime.utcnow())
)
await self._session.commit()
return True
except Exception:
await self._session.rollback()
return False
async def get_by_vacancy_id(self, vacancy_id: int) -> list[InterviewReport]:
"""Получить все отчёты по вакансии"""
statement = (
select(InterviewReport)
.join(
InterviewSession,
InterviewSession.id == InterviewReport.interview_session_id,
)
.join(Resume, Resume.id == InterviewSession.resume_id)
.join(Vacancy, Vacancy.id == Resume.vacancy_id)
.where(Vacancy.id == vacancy_id)
.order_by(InterviewReport.overall_score.desc())
)
result = await self._session.execute(statement)
return result.scalars().all()
async def update_notes(self, report_id: int, notes: str) -> bool:
"""Обновить заметки интервьюера"""
try:
await self._session.execute(
update(InterviewReport)
.where(InterviewReport.id == report_id)
.values(interviewer_notes=notes, updated_at=datetime.utcnow())
)
await self._session.commit()
return True
except Exception:
await self._session.rollback()
return False

View File

@ -1,5 +1,4 @@
import json
import os
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException
@ -128,42 +127,41 @@ async def force_end_interview(session_id: int) -> dict:
try:
# Получаем статус агента
agent_status = agent_manager.get_status()
if agent_status["status"] != "active":
raise HTTPException(
status_code=400,
detail=f"Agent is not active, current status: {agent_status['status']}"
status_code=400,
detail=f"Agent is not active, current status: {agent_status['status']}",
)
if agent_status["session_id"] != session_id:
raise HTTPException(
status_code=400,
detail=f"Agent is not handling session {session_id}, current session: {agent_status['session_id']}"
detail=f"Agent is not handling session {session_id}, current session: {agent_status['session_id']}",
)
# Записываем команду завершения в файл команд
command_file = "agent_commands.json"
end_command = {
"action": "end_session",
"session_id": session_id,
"timestamp": datetime.now(UTC).isoformat(),
"initiated_by": "admin_api"
"initiated_by": "admin_api",
}
with open(command_file, "w", encoding="utf-8") as f:
json.dump(end_command, f, ensure_ascii=False, indent=2)
return {
"success": True,
"message": f"Force end command sent for session {session_id}",
"session_id": session_id,
"command_file": command_file
"command_file": command_file,
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to send force end command: {str(e)}"
status_code=500, detail=f"Failed to send force end command: {str(e)}"
)

View File

@ -4,7 +4,6 @@ from pydantic import BaseModel
from app.core.database import get_session
from app.repositories.resume_repository import ResumeRepository
from app.services.pdf_report_service import pdf_report_service
from celery_worker.interview_analysis_task import (
analyze_multiple_candidates,
generate_interview_report,
@ -300,22 +299,23 @@ async def get_pdf_report(
return RedirectResponse(url=report.pdf_report_url, status_code=302)
@router.post("/generate-pdf/{resume_id}", response_model=PDFGenerationResponse)
@router.post("/generate-pdf/{resume_id}")
async def generate_pdf_report(
resume_id: int,
session=Depends(get_session),
resume_repo: ResumeRepository = Depends(ResumeRepository),
):
"""
Генерирует PDF отчет по интервью
Проверяет наличие отчета в базе данных и генерирует PDF файл.
Запускает асинхронную генерацию PDF отчета по интервью
Проверяет наличие отчета в базе данных и запускает Celery задачу для генерации PDF файла.
Если PDF уже существует, возвращает существующий URL.
"""
from sqlmodel import select
from app.models.interview import InterviewSession
from app.models.interview_report import InterviewReport
from celery_worker.tasks import generate_pdf_report_task
# Проверяем, существует ли резюме
resume = await resume_repo.get_by_id(resume_id)
@ -345,57 +345,132 @@ async def generate_pdf_report(
# Если PDF уже существует, возвращаем его
if report.pdf_report_url:
return PDFGenerationResponse(
message="PDF report already exists",
resume_id=resume_id,
candidate_name=resume.applicant_name,
pdf_url=report.pdf_report_url,
status="exists",
)
return {
"message": "PDF report already exists",
"resume_id": resume_id,
"report_id": report.id,
"candidate_name": resume.applicant_name,
"pdf_url": report.pdf_report_url,
"status": "exists",
}
# Получаем позицию из связанной вакансии
from app.models.vacancy import Vacancy
vacancy_stmt = select(Vacancy).where(Vacancy.id == resume.vacancy_id)
vacancy_result = await session.execute(vacancy_stmt)
vacancy = vacancy_result.scalar_one_or_none()
position = vacancy.title if vacancy else "Позиция не указана"
# Сериализуем данные отчета
report_data = {
"id": report.id,
"interview_session_id": report.interview_session_id,
"technical_skills_score": report.technical_skills_score,
"technical_skills_justification": report.technical_skills_justification,
"technical_skills_concerns": report.technical_skills_concerns,
"experience_relevance_score": report.experience_relevance_score,
"experience_relevance_justification": report.experience_relevance_justification,
"experience_relevance_concerns": report.experience_relevance_concerns,
"communication_score": report.communication_score,
"communication_justification": report.communication_justification,
"communication_concerns": report.communication_concerns,
"problem_solving_score": report.problem_solving_score,
"problem_solving_justification": report.problem_solving_justification,
"problem_solving_concerns": report.problem_solving_concerns,
"cultural_fit_score": report.cultural_fit_score,
"cultural_fit_justification": report.cultural_fit_justification,
"cultural_fit_concerns": report.cultural_fit_concerns,
"overall_score": report.overall_score,
"recommendation": report.recommendation,
"strengths": report.strengths,
"weaknesses": report.weaknesses,
"red_flags": report.red_flags,
"questions_quality_score": report.questions_quality_score,
"interview_duration_minutes": report.interview_duration_minutes,
"response_count": report.response_count,
"dialogue_messages_count": report.dialogue_messages_count,
"next_steps": report.next_steps,
"interviewer_notes": report.interviewer_notes,
"questions_analysis": report.questions_analysis,
"analysis_method": report.analysis_method,
"llm_model_used": report.llm_model_used,
"analysis_duration_seconds": report.analysis_duration_seconds,
"pdf_report_url": report.pdf_report_url,
"created_at": report.created_at.isoformat() if report.created_at else None,
"updated_at": report.updated_at.isoformat() if report.updated_at else None,
}
# Запускаем Celery задачу для генерации PDF
task = generate_pdf_report_task.delay(
report_data=report_data,
candidate_name=resume.applicant_name,
position=position,
resume_file_url=resume.resume_file_url,
)
return {
"message": "PDF generation started",
"resume_id": resume_id,
"report_id": report.id,
"candidate_name": resume.applicant_name,
"task_id": task.id,
"status": "in_progress",
}
@router.get("/pdf-task-status/{task_id}")
async def get_pdf_task_status(task_id: str):
"""
Получить статус выполнения Celery задачи генерации PDF
"""
from celery_worker.celery_app import celery_app
# Генерируем PDF отчет
try:
# Получаем позицию из связанной вакансии
from app.models.vacancy import Vacancy
vacancy_stmt = select(Vacancy).where(Vacancy.id == resume.vacancy_id)
vacancy_result = await session.execute(vacancy_stmt)
vacancy = vacancy_result.scalar_one_or_none()
position = vacancy.title if vacancy else "Позиция не указана"
# Генерируем и загружаем PDF
pdf_url = await pdf_report_service.generate_and_upload_pdf(
report, resume.applicant_name, position
)
task_result = celery_app.AsyncResult(task_id)
if not pdf_url:
raise HTTPException(
status_code=500, detail="Failed to generate or upload PDF report"
)
# Обновляем отчет в БД
from sqlmodel import update
stmt = (
update(InterviewReport)
.where(InterviewReport.id == report.id)
.values(pdf_report_url=pdf_url)
)
await session.execute(stmt)
await session.commit()
return PDFGenerationResponse(
message="PDF report generated successfully",
resume_id=resume_id,
candidate_name=resume.applicant_name,
pdf_url=pdf_url,
status="generated",
)
if task_result.state == "PENDING":
return {
"task_id": task_id,
"status": "pending",
"message": "Task is waiting to be processed",
}
elif task_result.state == "PROGRESS":
return {
"task_id": task_id,
"status": "in_progress",
"progress": task_result.info.get("progress", 0),
"message": task_result.info.get("status", "Processing..."),
}
elif task_result.state == "SUCCESS":
result = task_result.result
return {
"task_id": task_id,
"status": "completed",
"progress": 100,
"message": "PDF generation completed successfully",
"pdf_url": result.get("pdf_url"),
"file_size": result.get("file_size"),
"report_id": result.get("interview_report_id"),
}
elif task_result.state == "FAILURE":
return {
"task_id": task_id,
"status": "failed",
"message": str(task_result.info),
"error": str(task_result.info),
}
else:
return {
"task_id": task_id,
"status": task_result.state.lower(),
"message": f"Task state: {task_result.state}",
}
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Error generating PDF report: {str(e)}"
status_code=500, detail=f"Error checking task status: {str(e)}"
)
@ -438,11 +513,11 @@ async def get_report_data(
# Получаем позицию из связанной вакансии
from app.models.vacancy import Vacancy
vacancy_stmt = select(Vacancy).where(Vacancy.id == resume.vacancy_id)
vacancy_result = await session.execute(vacancy_stmt)
vacancy = vacancy_result.scalar_one_or_none()
position = vacancy.title if vacancy else "Позиция не указана"
return {

View File

@ -0,0 +1,113 @@
from fastapi import APIRouter, Depends, HTTPException
from app.core.session_middleware import get_current_session
from app.models.interview_report import InterviewReport
from app.models.session import Session
from app.services.interview_reports_service import InterviewReportService
router = APIRouter(prefix="/interview-reports", tags=["interview-reports"])
@router.get("/vacancy/{vacancy_id}", response_model=list[InterviewReport])
async def get_reports_by_vacancy(
vacancy_id: int,
current_session: Session = Depends(get_current_session),
report_service: InterviewReportService = Depends(InterviewReportService),
):
"""Получить все отчёты по вакансии"""
if not current_session:
raise HTTPException(status_code=401, detail="No active session")
reports = await report_service.get_reports_by_vacancy(vacancy_id)
return reports
@router.get("/session/{session_id}", response_model=InterviewReport)
async def get_report_by_session(
session_id: int,
current_session: Session = Depends(get_current_session),
report_service: InterviewReportService = Depends(InterviewReportService),
):
"""Получить отчёт по сессии интервью"""
if not current_session:
raise HTTPException(status_code=401, detail="No active session")
report = await report_service.get_report_by_session(session_id)
if not report:
raise HTTPException(status_code=404, detail="Report not found")
return report
@router.patch("/{report_id}/scores")
async def update_report_scores(
report_id: int,
scores: dict,
current_session: Session = Depends(get_current_session),
report_service: InterviewReportService = Depends(InterviewReportService),
):
"""Обновить оценки отчёта"""
if not current_session:
raise HTTPException(status_code=401, detail="No active session")
success = await report_service.update_report_scores(report_id, scores)
if not success:
raise HTTPException(status_code=500, detail="Failed to update report scores")
return {"message": "Report scores updated successfully"}
@router.patch("/{report_id}/notes")
async def update_report_notes(
report_id: int,
notes: str,
current_session: Session = Depends(get_current_session),
report_service: InterviewReportService = Depends(InterviewReportService),
):
"""Обновить заметки интервьюера"""
if not current_session:
raise HTTPException(status_code=401, detail="No active session")
success = await report_service.update_interviewer_notes(report_id, notes)
if not success:
raise HTTPException(
status_code=500, detail="Failed to update interviewer notes"
)
return {"message": "Interviewer notes updated successfully"}
@router.patch("/{report_id}/pdf")
async def update_report_pdf(
report_id: int,
pdf_url: str,
current_session: Session = Depends(get_current_session),
report_service: InterviewReportService = Depends(InterviewReportService),
):
"""Обновить PDF отчёта"""
if not current_session:
raise HTTPException(status_code=401, detail="No active session")
success = await report_service.update_pdf_url(report_id, pdf_url)
if not success:
raise HTTPException(status_code=500, detail="Failed to update PDF URL")
return {"message": "PDF URL updated successfully"}
@router.post("/create")
async def create_report(
report_data: dict,
current_session: Session = Depends(get_current_session),
report_service: InterviewReportService = Depends(InterviewReportService),
):
"""Создать новый отчёт интервью"""
if not current_session:
raise HTTPException(status_code=401, detail="No active session")
report = await report_service.create_report(**report_data)
if not report:
raise HTTPException(status_code=500, detail="Failed to create report")
return report

View File

@ -1,11 +1,21 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
from pydantic import BaseModel
from app.models.vacancy import VacancyCreate, VacancyRead, VacancyUpdate
from app.services.vacancy_parser_service import vacancy_parser_service
from app.services.vacancy_service import VacancyService
router = APIRouter(prefix="/vacancies", tags=["vacancies"])
class VacancyParseResponse(BaseModel):
"""Ответ на запрос парсинга вакансии"""
message: str
parsed_data: dict | None = None
task_id: str | None = None
@router.post("/", response_model=VacancyRead)
async def create_vacancy(
vacancy: VacancyCreate, vacancy_service: VacancyService = Depends(VacancyService)
@ -78,3 +88,287 @@ async def archive_vacancy(
if not archived_vacancy:
raise HTTPException(status_code=404, detail="Vacancy not found")
return archived_vacancy
@router.post("/parse-file", response_model=VacancyParseResponse)
async def parse_vacancy_from_file(
file: UploadFile = File(...),
create_vacancy: bool = Query(False, description="Создать вакансию после парсинга"),
vacancy_service: VacancyService = Depends(VacancyService),
):
"""
Парсинг вакансии из загруженного файла (PDF, DOCX, RTF, TXT)
Args:
file: Файл вакансии
create_vacancy: Создать вакансию в БД после парсинга
Returns:
VacancyParseResponse: Результат парсинга
"""
# Проверяем формат файла
if not file.filename:
raise HTTPException(status_code=400, detail="Имя файла не указано")
file_extension = file.filename.lower().split(".")[-1]
supported_formats = ["pdf", "docx", "rtf", "txt"]
if file_extension not in supported_formats:
raise HTTPException(
status_code=400,
detail=f"Неподдерживаемый формат файла. Поддерживаются: {', '.join(supported_formats)}",
)
# Проверяем размер файла (максимум 10MB)
file_content = await file.read()
if len(file_content) > 10 * 1024 * 1024:
raise HTTPException(
status_code=400, detail="Файл слишком большой (максимум 10MB)"
)
try:
# Извлекаем текст из файла
raw_text = vacancy_parser_service.extract_text_from_file(
file_content, file.filename
)
if not raw_text.strip():
raise HTTPException(
status_code=400, detail="Не удалось извлечь текст из файла"
)
# Парсим с помощью AI
parsed_data = await vacancy_parser_service.parse_vacancy_with_ai(raw_text)
# Если нужно создать вакансию, создаем её
created_vacancy = None
if create_vacancy:
try:
vacancy_create = VacancyCreate(**parsed_data)
created_vacancy = await vacancy_service.create_vacancy(vacancy_create)
except Exception as e:
# Возвращаем парсинг, но предупреждаем об ошибке создания
return VacancyParseResponse(
message=f"Парсинг выполнен, но ошибка при создании вакансии: {str(e)}",
parsed_data=parsed_data,
)
response_message = "Парсинг выполнен успешно"
if created_vacancy:
response_message += f". Вакансия создана с ID: {created_vacancy.id}"
return VacancyParseResponse(message=response_message, parsed_data=parsed_data)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Ошибка при парсинге вакансии: {str(e)}"
)
@router.post("/parse-text", response_model=VacancyParseResponse)
async def parse_vacancy_from_text(
text: str = Query(..., description="Текст вакансии для парсинга"),
create_vacancy: bool = Query(False, description="Создать вакансию после парсинга"),
vacancy_service: VacancyService = Depends(VacancyService),
):
"""
Парсинг вакансии из текста
Args:
text: Текст вакансии
create_vacancy: Создать вакансию в БД после парсинга
Returns:
VacancyParseResponse: Результат парсинга
"""
if not text.strip():
raise HTTPException(
status_code=400, detail="Текст вакансии не может быть пустым"
)
if len(text) > 50000: # Ограничение на длину текста
raise HTTPException(
status_code=400, detail="Текст слишком длинный (максимум 50000 символов)"
)
try:
# Парсим с помощью AI
parsed_data = await vacancy_parser_service.parse_vacancy_with_ai(text)
# Если нужно создать вакансию, создаем её
created_vacancy = None
if create_vacancy:
try:
vacancy_create = VacancyCreate(**parsed_data)
created_vacancy = await vacancy_service.create_vacancy(vacancy_create)
except Exception as e:
return VacancyParseResponse(
message=f"Парсинг выполнен, но ошибка при создании вакансии: {str(e)}",
parsed_data=parsed_data,
)
response_message = "Парсинг выполнен успешно"
if created_vacancy:
response_message += f". Вакансия создана с ID: {created_vacancy.id}"
return VacancyParseResponse(message=response_message, parsed_data=parsed_data)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Ошибка при парсинге вакансии: {str(e)}"
)
@router.get("/parse-formats")
async def get_supported_formats():
"""
Получить список поддерживаемых форматов файлов для парсинга вакансий
Returns:
dict: Информация о поддерживаемых форматах
"""
return {
"supported_formats": [
{"extension": "pdf", "description": "PDF документы", "max_size_mb": 10},
{
"extension": "docx",
"description": "Microsoft Word документы",
"max_size_mb": 10,
},
{"extension": "rtf", "description": "Rich Text Format", "max_size_mb": 10},
{"extension": "txt", "description": "Текстовые файлы", "max_size_mb": 10},
],
"features": [
"Автоматическое извлечение текста из файлов",
"AI-парсинг структурированной информации",
"Создание вакансии в базе данных",
"Валидация данных",
],
}
@router.post("/parse-file-async", response_model=dict)
async def parse_vacancy_from_file_async(
file: UploadFile = File(...),
create_vacancy: str = Form("false", description="Создать вакансию после парсинга"),
):
"""
Асинхронный парсинг вакансии из загруженного файла (PDF, DOCX, RTF, TXT)
Args:
file: Файл вакансии
create_vacancy: Создать вакансию в БД после парсинга
Returns:
dict: ID задачи для отслеживания статуса
"""
import base64
from celery_worker.tasks import parse_vacancy_task
# Проверяем формат файла
if not file.filename:
raise HTTPException(status_code=400, detail="Имя файла не указано")
file_extension = file.filename.lower().split(".")[-1]
supported_formats = ["pdf", "docx", "rtf", "txt"]
if file_extension not in supported_formats:
raise HTTPException(
status_code=400,
detail=f"Неподдерживаемый формат файла. Поддерживаются: {', '.join(supported_formats)}",
)
# Проверяем размер файла (максимум 10MB)
file_content = await file.read()
if len(file_content) > 10 * 1024 * 1024:
raise HTTPException(
status_code=400, detail="Файл слишком большой (максимум 10MB)"
)
try:
# Кодируем содержимое файла в base64 для передачи в Celery
file_content_base64 = base64.b64encode(file_content).decode("utf-8")
# Конвертируем строку в boolean
create_vacancy_bool = create_vacancy.lower() in ("true", "1", "yes", "on")
# Запускаем асинхронную задачу
task = parse_vacancy_task.delay(
file_content_base64=file_content_base64,
filename=file.filename,
create_vacancy=create_vacancy_bool,
)
return {
"message": "Задача парсинга запущена",
"task_id": task.id,
"status": "pending",
"check_status_url": f"/api/v1/vacancies/parse-status/{task.id}",
}
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Ошибка при запуске парсинга: {str(e)}"
)
@router.get("/parse-status/{task_id}")
async def get_parse_status(task_id: str):
"""
Получить статус асинхронной задачи парсинга вакансии
Args:
task_id: ID задачи
Returns:
dict: Статус задачи и результат (если завершена)
"""
from celery_worker.celery_app import celery_app
try:
task = celery_app.AsyncResult(task_id)
if task.state == "PENDING":
response = {
"task_id": task_id,
"state": task.state,
"status": "Задача ожидает выполнения...",
"progress": 0,
}
elif task.state == "PROGRESS":
response = {
"task_id": task_id,
"state": task.state,
"status": task.info.get("status", ""),
"progress": task.info.get("progress", 0),
}
elif task.state == "SUCCESS":
response = {
"task_id": task_id,
"state": task.state,
"status": "completed",
"progress": 100,
"result": task.result,
}
else: # FAILURE
response = {
"task_id": task_id,
"state": task.state,
"status": "failed",
"progress": 0,
"error": str(task.info),
}
return response
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Ошибка при получении статуса задачи: {str(e)}"
)

View File

@ -96,11 +96,13 @@ class AgentManager:
)
logger.info(f"AI Agent started with PID {process.pid}")
# Запускаем мониторинг команд
if not self._monitoring_task:
self._monitoring_task = asyncio.create_task(self._monitor_commands())
self._monitoring_task = asyncio.create_task(
self._monitor_commands()
)
return True
except Exception as e:
@ -132,12 +134,12 @@ class AgentManager:
logger.info(f"AI Agent with PID {self._agent_process.pid} stopped")
self._agent_process = None
# Останавливаем мониторинг команд
if self._monitoring_task:
self._monitoring_task.cancel()
self._monitoring_task = None
return True
except Exception as e:
@ -259,7 +261,9 @@ class AgentManager:
"""Обрабатывает сигнал о завершении сессии от агента"""
async with self._lock:
if not self._agent_process:
logger.warning(f"No agent process to handle session_completed for {session_id}")
logger.warning(
f"No agent process to handle session_completed for {session_id}"
)
return False
if self._agent_process.session_id != session_id:
@ -281,7 +285,9 @@ class AgentManager:
self._agent_process.room_name = None
self._agent_process.status = "idle"
logger.info(f"Agent automatically released from session {old_session_id}")
logger.info(
f"Agent automatically released from session {old_session_id}"
)
return True
except Exception as e:
@ -346,36 +352,43 @@ class AgentManager:
"""Мониторит файл команд для обработки сигналов от агента"""
command_file = "agent_commands.json"
last_processed_timestamp = None
logger.info("[MONITOR] Starting command monitoring")
try:
while True:
try:
if os.path.exists(command_file):
with open(command_file, "r", encoding="utf-8") as f:
with open(command_file, encoding="utf-8") as f:
command = json.load(f)
# Проверяем timestamp чтобы избежать повторной обработки
command_timestamp = command.get("timestamp")
if command_timestamp and command_timestamp != last_processed_timestamp:
if (
command_timestamp
and command_timestamp != last_processed_timestamp
):
action = command.get("action")
if action == "session_completed":
session_id = command.get("session_id")
room_name = command.get("room_name")
logger.info(f"[MONITOR] Processing session_completed for {session_id}")
await self.handle_session_completed(session_id, room_name)
logger.info(
f"[MONITOR] Processing session_completed for {session_id}"
)
await self.handle_session_completed(
session_id, room_name
)
last_processed_timestamp = command_timestamp
await asyncio.sleep(2) # Проверяем каждые 2 секунды
except Exception as e:
logger.error(f"[MONITOR] Error processing command: {e}")
await asyncio.sleep(5) # Больший интервал при ошибке
except asyncio.CancelledError:
logger.info("[MONITOR] Command monitoring stopped")
except Exception as e:

View File

@ -0,0 +1,87 @@
from datetime import datetime
from typing import Annotated
from fastapi import Depends
from app.models.interview_report import InterviewReport
from app.repositories.interview_reports_repository import InterviewReportRepository
class InterviewReportService:
def __init__(
self,
report_repo: Annotated[
InterviewReportRepository, Depends(InterviewReportRepository)
],
):
self.report_repo = report_repo
async def get_report_by_session(self, session_id: int) -> InterviewReport | None:
"""Получить отчёт по ID сессии"""
return await self.report_repo.get_by_session_id(session_id)
async def get_reports_by_vacancy(self, vacancy_id: int) -> list[InterviewReport]:
"""Получить все отчёты по вакансии"""
return await self.report_repo.get_by_vacancy_id(vacancy_id)
async def update_report_scores(self, report_id: int, scores: dict) -> bool:
"""
Обновить оценки отчёта.
Пример scores:
{
"technical_skills_score": 8,
"communication_score": 7,
"overall_score": 8
}
"""
return await self.report_repo.update_scores(report_id, scores)
async def update_pdf_url(self, report_id: int, pdf_url: str) -> bool:
"""Обновить ссылку на PDF отчёта"""
return await self.report_repo.update_pdf_url(report_id, pdf_url)
async def update_interviewer_notes(self, report_id: int, notes: str) -> bool:
"""Обновить заметки интервьюера"""
return await self.report_repo.update_notes(report_id, notes)
async def create_report(
self,
interview_session_id: int,
technical_skills_score: int,
experience_relevance_score: int,
communication_score: int,
problem_solving_score: int,
cultural_fit_score: int,
overall_score: int,
recommendation: str,
strengths: dict | None = None,
weaknesses: dict | None = None,
red_flags: dict | None = None,
next_steps: str | None = None,
interviewer_notes: str | None = None,
pdf_report_url: str | None = None,
) -> InterviewReport | None:
"""Создать новый отчёт для сессии"""
try:
report_data = {
"interview_session_id": interview_session_id,
"technical_skills_score": technical_skills_score,
"experience_relevance_score": experience_relevance_score,
"communication_score": communication_score,
"problem_solving_score": problem_solving_score,
"cultural_fit_score": cultural_fit_score,
"overall_score": overall_score,
"recommendation": recommendation,
"strengths": strengths or {},
"weaknesses": weaknesses or {},
"red_flags": red_flags or {},
"next_steps": next_steps,
"interviewer_notes": interviewer_notes,
"pdf_report_url": pdf_report_url,
"created_at": datetime.utcnow(),
"updated_at": datetime.utcnow(),
}
return await self.report_repo.create(report_data)
except Exception as e:
print(f"Error creating interview report: {str(e)}")
return None

View File

@ -233,7 +233,7 @@ class InterviewRoomService:
# Если плана нет, создаем базовый план на основе имеющихся данных
fallback_plan = {
"interview_structure": {
"duration_minutes": 30,
"duration_minutes": 45,
"greeting": f"Привет, {resume.applicant_name}! Готов к интервью?",
"sections": [
{

View File

@ -1,443 +1,512 @@
import io
import os
import shutil
import tempfile
from datetime import datetime
from urllib.parse import quote
from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import inch
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import (
Paragraph,
SimpleDocTemplate,
Spacer,
Table,
TableStyle,
)
import requests
from jinja2 import Template
from playwright.async_api import async_playwright
from app.core.s3 import s3_service
from app.models.interview_report import InterviewReport, RecommendationType
class PDFReportService:
"""Сервис для генерации PDF отчетов по интервью"""
"""Сервис для генерации PDF отчетов по интервью на основе HTML шаблона"""
def __init__(self):
self._register_fonts()
self.styles = getSampleStyleSheet()
self._setup_custom_styles()
self.template_path = "templates/interview_report.html"
self._setup_fonts()
def _register_fonts(self):
"""Регистрация шрифтов для поддержки кириллицы"""
def _download_font(self, url: str, dest_path: str) -> str:
"""Скачивает шрифт по URL в dest_path (перезаписывает если нужно)."""
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
try:
# Пытаемся использовать системные шрифты Windows
import os
# Пути к шрифтам Windows
fonts_dir = "C:/Windows/Fonts"
# Регистрируем Arial для русского текста
if os.path.exists(f"{fonts_dir}/arial.ttf"):
pdfmetrics.registerFont(TTFont('Arial-Unicode', f"{fonts_dir}/arial.ttf"))
pdfmetrics.registerFont(TTFont('Arial-Unicode-Bold', f"{fonts_dir}/arialbd.ttf"))
# Альтернативно используем Calibri
elif os.path.exists(f"{fonts_dir}/calibri.ttf"):
pdfmetrics.registerFont(TTFont('Arial-Unicode', f"{fonts_dir}/calibri.ttf"))
pdfmetrics.registerFont(TTFont('Arial-Unicode-Bold', f"{fonts_dir}/calibrib.ttf"))
# Если ничего не найдено, используем встроенный DejaVu
else:
# Fallback к стандартным шрифтам ReportLab с поддержкой Unicode
from reportlab.lib.fonts import addMapping
addMapping('Arial-Unicode', 0, 0, 'Helvetica')
addMapping('Arial-Unicode', 1, 0, 'Helvetica-Bold')
addMapping('Arial-Unicode', 0, 1, 'Helvetica-Oblique')
addMapping('Arial-Unicode', 1, 1, 'Helvetica-BoldOblique')
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"Warning: Could not register custom fonts: {e}")
# Используем стандартные шрифты как fallback
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
addMapping('Arial-Unicode', 0, 0, 'Helvetica')
addMapping('Arial-Unicode', 1, 0, 'Helvetica-Bold')
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
def _setup_custom_styles(self):
"""Настройка кастомных стилей для документа"""
# Заголовок отчета
self.styles.add(
ParagraphStyle(
name="ReportTitle",
parent=self.styles["Title"],
fontSize=18,
spaceAfter=30,
alignment=TA_CENTER,
textColor=colors.HexColor("#2E3440"),
fontName="Arial-Unicode-Bold",
)
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"
# Заголовки секций
self.styles.add(
ParagraphStyle(
name="SectionHeader",
parent=self.styles["Heading1"],
fontSize=14,
spaceAfter=12,
spaceBefore=20,
textColor=colors.HexColor("#5E81AC"),
fontName="Arial-Unicode-Bold",
)
)
# скачиваем если локально нет
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)
# Подзаголовки
self.styles.add(
ParagraphStyle(
name="SubHeader",
parent=self.styles["Heading2"],
fontSize=12,
spaceAfter=8,
spaceBefore=15,
textColor=colors.HexColor("#81A1C1"),
fontName="Arial-Unicode-Bold",
)
)
# регистрируем в ReportLab (чтобы гарантировать поддержку кириллицы)
try:
self._register_local_fonts(regular_local, bold_local)
except Exception as e:
print("[WARNING] Font registration error:", e)
# Обычный текст
self.styles.add(
ParagraphStyle(
name="CustomBodyText",
parent=self.styles["Normal"],
fontSize=10,
spaceAfter=6,
alignment=TA_JUSTIFY,
textColor=colors.HexColor("#2E3440"),
fontName="Arial-Unicode",
)
)
# используем file:/// абсолютный путь в src и УБИРАЕМ format('...') — это важно
# url-энкодим путь на случай пробелов
reg_quoted = quote(regular_local)
bold_quoted = quote(bold_local)
# Стиль для метрик
self.styles.add(
ParagraphStyle(
name="MetricValue",
parent=self.styles["Normal"],
fontSize=12,
alignment=TA_CENTER,
textColor=colors.HexColor("#5E81AC"),
fontName="Arial-Unicode-Bold",
)
)
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;
}}
async def generate_interview_report_pdf(
self, report: InterviewReport, candidate_name: str, position: str
/* Применяем семейство без !important, чтобы не ломать шаблон */
body, * {{
font-family: 'DejaVuSans', Arial, sans-serif;
}}
@page {{
size: A4;
margin: 0.75in;
}}
</style>
"""
return font_css
def _load_html_template(self) -> str:
"""Загружает HTML шаблон из файла"""
try:
with open(self.template_path, encoding="utf-8") as file:
return file.read()
except FileNotFoundError:
raise FileNotFoundError(f"HTML шаблон не найден: {self.template_path}")
def _format_concerns_field(self, concerns):
"""Форматирует поле concerns для отображения"""
if not concerns:
return ""
if isinstance(concerns, list):
return "; ".join(concerns)
elif isinstance(concerns, str):
return concerns
else:
return str(concerns)
def _format_list_field(self, field_value) -> str:
"""Форматирует поле со списком для отображения"""
if not field_value:
return "Не указаны"
if isinstance(field_value, list):
return "\n".join([f"{item}" for item in field_value])
elif isinstance(field_value, str):
return field_value
else:
return str(field_value)
def _get_score_class(self, score: int) -> str:
"""Возвращает CSS класс для цвета оценки"""
if score >= 90:
return "score-green" # STRONGLY_RECOMMEND
elif score >= 75:
return "score-light-green" # RECOMMEND
elif score >= 60:
return "score-orange" # CONSIDER
else:
return "score-red" # REJECT
def _format_recommendation(self, recommendation: RecommendationType) -> tuple:
"""Форматирует рекомендацию для отображения"""
if recommendation == RecommendationType.STRONGLY_RECOMMEND:
return ("Настоятельно рекомендуем", "recommend-button")
elif recommendation == RecommendationType.RECOMMEND:
return ("Рекомендуем", "recommend-button")
elif recommendation == RecommendationType.CONSIDER:
return ("К рассмотрению", "consider-button")
else: # REJECT
return ("Не рекомендуем", "reject-button")
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 отчет по интервью
Генерирует PDF отчет на основе HTML шаблона
Args:
report: Модель отчета из БД
candidate_name: Имя кандидата
position: Название позиции
interview_report: Данные отчета по интервью
Returns:
bytes: PDF файл в виде байтов
"""
buffer = io.BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=A4,
rightMargin=72,
leftMargin=72,
topMargin=72,
bottomMargin=72,
)
try:
# Загружаем HTML шаблон
html_template = self._load_html_template()
# Собираем элементы документа
story = []
# Заголовок отчета
story.append(
Paragraph(
f"Отчет по собеседованию<br/>{candidate_name}",
self.styles["ReportTitle"],
# Подготавливаем данные для шаблона
template_data = self._prepare_template_data(
interview_report,
candidate_name or "Не указано",
position or "Не указана",
resume_file_url,
)
)
# Основная информация
story.append(Paragraph("Основная информация", self.styles["SectionHeader"]))
# Рендерим HTML с данными
template = Template(html_template)
rendered_html = template.render(**template_data)
basic_info = [
["Кандидат:", candidate_name],
["Позиция:", position],
["Дата интервью:", report.created_at.strftime("%d.%m.%Y %H:%M")],
["Общий балл:", f"{report.overall_score}/100"],
["Рекомендация:", self._format_recommendation(report.recommendation)],
]
# Получаем CSS с проверенными шрифтами
font_css = self._get_font_css()
basic_table = Table(basic_info, colWidths=[2 * inch, 4 * inch])
basic_table.setStyle(
TableStyle(
[
("ALIGN", (0, 0), (-1, -1), "LEFT"),
("FONTNAME", (0, 0), (0, -1), "Arial-Unicode-Bold"),
("FONTNAME", (1, 0), (-1, -1), "Arial-Unicode"), # Правая колонка обычным шрифтом
("FONTSIZE", (0, 0), (-1, -1), 10),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("TOPPADDING", (0, 0), (-1, -1), 6),
]
# Вставляем стили
if "<head>" in rendered_html:
rendered_html = rendered_html.replace("<head>", f"<head>{font_css}")
else:
rendered_html = font_css + rendered_html
with open("debug.html", "w", encoding="utf-8") as f:
f.write(rendered_html)
# Генерируем PDF из debug.html с помощью Playwright
print("[OK] Using Playwright to generate PDF from debug.html")
async def generate_pdf():
async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page()
await page.goto(f"file://{os.path.abspath('debug.html')}")
await page.wait_for_load_state("networkidle")
pdf_bytes = await page.pdf(
format="A4",
margin={
"top": "0.75in",
"bottom": "0.75in",
"left": "0.75in",
"right": "0.75in",
},
print_background=True,
)
await browser.close()
return pdf_bytes
pdf_bytes = await generate_pdf()
return pdf_bytes
except Exception as e:
raise Exception(f"Ошибка при генерации PDF: {str(e)}")
def _prepare_template_data(
self,
interview_report: InterviewReport,
candidate_name: str,
position: str,
resume_file_url: str = None,
) -> dict:
"""Подготавливает данные для HTML шаблона"""
# Используем переданные параметры как в старой версии
resume_url = resume_file_url # Пока оставим заглушку для ссылки на резюме
# Форматируем дату интервью
interview_date = "Не указана"
if (
interview_report.interview_session
and interview_report.interview_session.interview_start_time
):
interview_date = (
interview_report.interview_session.interview_start_time.strftime(
"%d.%m.%Y %H:%M"
)
)
)
story.append(basic_table)
story.append(Spacer(1, 20))
# Оценки по критериям
story.append(Paragraph("Детальная оценка", self.styles["SectionHeader"]))
# Стиль для текста в таблице с автопереносом
table_text_style = ParagraphStyle(
name="TableText",
parent=self.styles["Normal"],
fontSize=8,
fontName="Arial-Unicode",
leading=10,
)
criteria_data = [
[
Paragraph("Критерий", self.styles["CustomBodyText"]),
Paragraph("Балл", self.styles["CustomBodyText"]),
Paragraph("Обоснование", self.styles["CustomBodyText"]),
Paragraph("Риски", self.styles["CustomBodyText"]),
],
[
Paragraph("Технические навыки", table_text_style),
Paragraph(f"{report.technical_skills_score}/100", table_text_style),
Paragraph(report.technical_skills_justification or "", table_text_style),
Paragraph(report.technical_skills_concerns or "", table_text_style),
],
[
Paragraph("Релевантность опыта", table_text_style),
Paragraph(f"{report.experience_relevance_score}/100", table_text_style),
Paragraph(report.experience_relevance_justification or "", table_text_style),
Paragraph(report.experience_relevance_concerns or "", table_text_style),
],
[
Paragraph("Коммуникация", table_text_style),
Paragraph(f"{report.communication_score}/100", table_text_style),
Paragraph(report.communication_justification or "", table_text_style),
Paragraph(report.communication_concerns or "", table_text_style),
],
[
Paragraph("Решение задач", table_text_style),
Paragraph(f"{report.problem_solving_score}/100", table_text_style),
Paragraph(report.problem_solving_justification or "", table_text_style),
Paragraph(report.problem_solving_concerns or "", table_text_style),
],
[
Paragraph("Культурное соответствие", table_text_style),
Paragraph(f"{report.cultural_fit_score}/100", table_text_style),
Paragraph(report.cultural_fit_justification or "", table_text_style),
Paragraph(report.cultural_fit_concerns or "", table_text_style),
],
]
criteria_table = Table(
criteria_data, colWidths=[1.5 * inch, 0.6 * inch, 2.8 * inch, 2.1 * inch]
)
criteria_table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#5E81AC")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke),
("ALIGN", (1, 1), (1, -1), "CENTER"), # Центрирование баллов
("ALIGN", (0, 0), (-1, -1), "LEFT"), # Остальное слева
("GRID", (0, 0), (-1, -1), 1, colors.HexColor("#D8DEE9")),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
("TOPPADDING", (0, 0), (-1, -1), 8),
("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8F9FA")]),
]
)
# Общий балл и рекомендация
overall_score = interview_report.overall_score or 0
recommendation_text, recommendation_class = self._format_recommendation(
interview_report.recommendation
)
# Цветовое кодирование баллов
for i in range(1, 6): # строки с баллами
score_cell = (1, i)
if hasattr(
report,
[
"technical_skills_score",
"experience_relevance_score",
"communication_score",
"problem_solving_score",
"cultural_fit_score",
][i - 1],
):
score = getattr(
report,
[
"technical_skills_score",
"experience_relevance_score",
"communication_score",
"problem_solving_score",
"cultural_fit_score",
][i - 1],
)
if score >= 80:
criteria_table.setStyle(
TableStyle(
[
(
"BACKGROUND",
score_cell,
score_cell,
colors.HexColor("#A3BE8C"),
)
]
)
)
elif score >= 60:
criteria_table.setStyle(
TableStyle(
[
(
"BACKGROUND",
score_cell,
score_cell,
colors.HexColor("#EBCB8B"),
)
]
)
)
else:
criteria_table.setStyle(
TableStyle(
[
(
"BACKGROUND",
score_cell,
score_cell,
colors.HexColor("#BF616A"),
)
]
)
)
# Сильные стороны и области развития (используем правильные поля модели)
strengths = (
self._format_list_field(interview_report.strengths)
if interview_report.strengths
else "Не указаны"
)
areas_for_development = (
self._format_list_field(interview_report.weaknesses)
if interview_report.weaknesses
else "Не указаны"
)
story.append(criteria_table)
story.append(Spacer(1, 20))
# Сильные и слабые стороны
if report.strengths or report.weaknesses:
story.append(Paragraph("Анализ кандидата", self.styles["SectionHeader"]))
if report.strengths:
story.append(Paragraph("Сильные стороны:", self.styles["SubHeader"]))
for strength in report.strengths:
story.append(Paragraph(f"{strength}", self.styles["CustomBodyText"]))
story.append(Spacer(1, 10))
if report.weaknesses:
story.append(
Paragraph("Области для развития:", self.styles["SubHeader"])
)
for weakness in report.weaknesses:
story.append(Paragraph(f"{weakness}", self.styles["CustomBodyText"]))
story.append(Spacer(1, 10))
# Красные флаги
if report.red_flags:
story.append(Paragraph("Важные риски:", self.styles["SubHeader"]))
for red_flag in report.red_flags:
story.append(
Paragraph(
f"{red_flag}",
ParagraphStyle(
name="RedFlag",
parent=self.styles["CustomBodyText"],
textColor=colors.HexColor("#BF616A"),
),
)
)
story.append(Spacer(1, 15))
# Рекомендации и следующие шаги
if report.next_steps:
story.append(Paragraph("Рекомендации:", self.styles["SectionHeader"]))
story.append(Paragraph(report.next_steps, self.styles["CustomBodyText"]))
story.append(Spacer(1, 15))
# Подпись
story.append(Spacer(1, 30))
story.append(
Paragraph(
f"Отчет сгенерирован автоматически • {datetime.now().strftime('%d.%m.%Y %H:%M')}",
ParagraphStyle(
name="Footer",
parent=self.styles["Normal"],
fontSize=8,
alignment=TA_CENTER,
textColor=colors.HexColor("#4C566A"),
fontName="Arial-Unicode",
# Детальная оценка - всегда все критерии, как в старой версии
evaluation_criteria = [
{
"name": "Технические навыки",
"score": interview_report.technical_skills_score or 0,
"score_class": self._get_score_class(
interview_report.technical_skills_score or 0
),
)
)
"justification": interview_report.technical_skills_justification or "",
"concerns": self._format_concerns_field(
interview_report.technical_skills_concerns
),
},
{
"name": "Релевантность опыта",
"score": interview_report.experience_relevance_score or 0,
"score_class": self._get_score_class(
interview_report.experience_relevance_score or 0
),
"justification": interview_report.experience_relevance_justification
or "",
"concerns": self._format_concerns_field(
interview_report.experience_relevance_concerns
),
},
{
"name": "Коммуникация",
"score": interview_report.communication_score or 0,
"score_class": self._get_score_class(
interview_report.communication_score or 0
),
"justification": interview_report.communication_justification or "",
"concerns": self._format_concerns_field(
interview_report.communication_concerns
),
},
{
"name": "Решение задач",
"score": interview_report.problem_solving_score or 0,
"score_class": self._get_score_class(
interview_report.problem_solving_score or 0
),
"justification": interview_report.problem_solving_justification or "",
"concerns": self._format_concerns_field(
interview_report.problem_solving_concerns
),
},
{
"name": "Культурное соответствие",
"score": interview_report.cultural_fit_score or 0,
"score_class": self._get_score_class(
interview_report.cultural_fit_score or 0
),
"justification": interview_report.cultural_fit_justification or "",
"concerns": self._format_concerns_field(
interview_report.cultural_fit_concerns
),
},
]
# Генерируем PDF
doc.build(story)
buffer.seek(0)
return buffer.getvalue()
# Красные флаги - используем поле модели напрямую
red_flags = interview_report.red_flags or []
def _format_recommendation(self, recommendation: RecommendationType) -> str:
"""Форматирует рекомендацию для отображения"""
recommendation_map = {
RecommendationType.STRONGLY_RECOMMEND: "Настоятельно рекомендуем",
RecommendationType.RECOMMEND: "Рекомендуем",
RecommendationType.CONSIDER: "Рассмотреть кандидатуру",
RecommendationType.REJECT: "Не рекомендуем",
# Ссылка на резюме (уже определена выше)
# ID отчета
report_id = f"#{interview_report.id}" if interview_report.id else "#0"
# Дата генерации отчета
generation_date = datetime.now().strftime("%d.%m.%Y %H:%M")
return {
"report_id": report_id,
"candidate_name": candidate_name,
"position": position,
"interview_date": interview_date,
"overall_score": overall_score,
"recommendation_text": recommendation_text,
"recommendation_class": recommendation_class,
"strengths": strengths,
"areas_for_development": areas_for_development,
"evaluation_criteria": evaluation_criteria,
"red_flags": red_flags,
"resume_url": resume_url,
"generation_date": generation_date,
}
return recommendation_map.get(recommendation, str(recommendation))
async def generate_and_upload_pdf(
self, report: InterviewReport, candidate_name: str, position: str
) -> str | None:
async def upload_pdf_to_s3(self, pdf_bytes: bytes, filename: str) -> str:
"""
Генерирует PDF отчет и загружает его в S3
Загружает PDF файл в S3 и возвращает публичную ссылку
Args:
report: Модель отчета из БД
candidate_name: Имя кандидата
position: Название позиции
pdf_bytes: PDF файл в виде байтов
filename: Имя файла
Returns:
str | None: URL файла в S3 или None при ошибке
str: Публичная ссылка на файл в S3
"""
try:
# Генерируем PDF
pdf_bytes = await self.generate_interview_report_pdf(
report, candidate_name, position
)
pdf_stream = io.BytesIO(pdf_bytes)
# Формируем имя файла
safe_name = "".join(
c for c in candidate_name if c.isalnum() or c in (" ", "-", "_")
).strip()
safe_name = safe_name.replace(" ", "_")
filename = f"interview_report_{safe_name}_{report.id}.pdf"
# Загружаем в S3 с публичным доступом
# Загружаем с публичным доступом
file_url = await s3_service.upload_file(
file_content=pdf_bytes,
file_name=filename,
content_type="application/pdf",
public=True,
pdf_stream, filename, content_type="application/pdf", public=True
)
return file_url
except Exception as e:
print(f"Error generating and uploading PDF report: {e}")
return None
raise Exception(f"Ошибка при загрузке PDF в S3: {str(e)}")
async def generate_and_upload_pdf(
self,
report: InterviewReport,
candidate_name: str = None,
position: str = None,
resume_file_url: str = None,
) -> str:
"""
Генерирует PDF отчет и загружает его в S3 (метод обратной совместимости)
Args:
report: Отчет по интервью
candidate_name: Имя кандидата (не используется, берется из отчета)
position: Позиция (не используется, берется из отчета)
Returns:
str: Публичная ссылка на PDF файл
"""
try:
# Генерируем PDF
pdf_bytes = await self.generate_pdf_report(
report, candidate_name, position, resume_file_url
)
# Создаем имя файла - используем переданный параметр как в старой версии
safe_name = (
candidate_name
if candidate_name and candidate_name != "Не указано"
else "candidate"
)
safe_name = "".join(
c for c in safe_name if c.isalnum() or c in (" ", "-", "_")
).strip()
filename = f"interview_report_{safe_name}_{report.id}.pdf"
# Загружаем в S3
pdf_url = await self.upload_pdf_to_s3(pdf_bytes, filename)
return pdf_url
except Exception as e:
raise Exception(f"Ошибка при генерации и загрузке PDF: {str(e)}")
# Экземпляр сервиса

View File

@ -0,0 +1,308 @@
import io
import json
import logging
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
class VacancyParserService:
"""Сервис для парсинга вакансий из файлов различных форматов"""
def __init__(self):
self.supported_formats = [".pdf", ".docx", ".rtf", ".txt"]
def extract_text_from_file(self, file_content: bytes, filename: str) -> str:
"""
Извлекает текст из файла в зависимости от его формата
Args:
file_content: Содержимое файла в байтах
filename: Имя файла для определения формата
Returns:
str: Извлеченный текст
"""
file_extension = Path(filename).suffix.lower()
try:
if file_extension == ".pdf":
return self._extract_from_pdf(file_content)
elif file_extension == ".docx":
return self._extract_from_docx(file_content)
elif file_extension == ".rtf":
return self._extract_from_rtf(file_content)
elif file_extension == ".txt":
return self._extract_from_txt(file_content)
else:
raise ValueError(f"Неподдерживаемый формат файла: {file_extension}")
except Exception as e:
logger.error(f"Ошибка при извлечении текста из файла {filename}: {str(e)}")
raise
def _extract_from_pdf(self, file_content: bytes) -> str:
"""Извлекает текст из PDF файла"""
try:
import PyPDF2
pdf_file = io.BytesIO(file_content)
pdf_reader = PyPDF2.PdfReader(pdf_file)
text = ""
for page in pdf_reader.pages:
text += page.extract_text() + "\n"
return text.strip()
except ImportError:
# Fallback to pdfplumber if PyPDF2 doesn't work well
try:
import pdfplumber
pdf_file = io.BytesIO(file_content)
text = ""
with pdfplumber.open(pdf_file) as pdf:
for page in pdf.pages:
page_text = page.extract_text()
if page_text:
text += page_text + "\n"
return text.strip()
except ImportError:
raise ImportError(
"Требуется установить PyPDF2 или pdfplumber: pip install PyPDF2 pdfplumber"
)
def _extract_from_docx(self, file_content: bytes) -> str:
"""Извлекает текст из DOCX файла"""
try:
import docx
doc_file = io.BytesIO(file_content)
doc = docx.Document(doc_file)
text = ""
for paragraph in doc.paragraphs:
text += paragraph.text + "\n"
# Также извлекаем текст из таблиц
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
text += cell.text + "\t"
text += "\n"
return text.strip()
except ImportError:
raise ImportError(
"Требуется установить python-docx: pip install python-docx"
)
def _extract_from_rtf(self, file_content: bytes) -> str:
"""Извлекает текст из RTF файла"""
try:
from striprtf.striprtf import rtf_to_text
rtf_content = file_content.decode("utf-8", errors="ignore")
text = rtf_to_text(rtf_content)
return text.strip()
except ImportError:
raise ImportError("Требуется установить striprtf: pip install striprtf")
except Exception:
# Альтернативный метод через pyth
try:
from pyth.plugins.plaintext.writer import PlaintextWriter
from pyth.plugins.rtf15.reader import Rtf15Reader
doc = Rtf15Reader.read(io.BytesIO(file_content))
text = PlaintextWriter.write(doc).getvalue()
return text.strip()
except ImportError:
raise ImportError(
"Требуется установить striprtf или pyth: pip install striprtf pyth"
)
def _extract_from_txt(self, file_content: bytes) -> str:
"""Извлекает текст из TXT файла"""
try:
# Пробуем различные кодировки
encodings = ["utf-8", "windows-1251", "cp1252", "iso-8859-1"]
for encoding in encodings:
try:
text = file_content.decode(encoding)
return text.strip()
except UnicodeDecodeError:
continue
# Если все кодировки не подошли, используем errors='ignore'
text = file_content.decode("utf-8", errors="ignore")
return text.strip()
except Exception as e:
logger.error(f"Ошибка при чтении txt файла: {str(e)}")
raise
async def parse_vacancy_with_ai(self, raw_text: str) -> dict[str, Any]:
"""
Парсит текст вакансии с помощью AI для извлечения структурированной информации
Args:
raw_text: Сырой текст вакансии
Returns:
Dict с полями для модели Vacancy
"""
from rag.settings import settings
if not settings.openai_api_key:
raise ValueError("OpenAI API ключ не настроен")
try:
import openai
openai.api_key = settings.openai_api_key
parsing_prompt = f"""
Проанализируй текст вакансии и извлеки из него структурированную информацию.
ТЕКСТ ВАКАНСИИ:
{raw_text}
ЗАДАЧА:
Извлеки следующие поля для вакансии:
1. title - название позиции (строка)
2. description - описание вакансии (полное описание обязанностей, требований)
3. key_skills - ключевые навыки через запятую (строка)
4. employment_type - тип занятости: "full", "part", "project", "volunteer", "probation"
5. experience - опыт работы: "noExperience", "between1And3", "between3And6", "moreThan6"
6. schedule - график работы: "fullDay", "shift", "flexible", "remote", "flyInFlyOut"
7. salary_from - зарплата от (число или null)
8. salary_to - зарплата до (число или null)
9. salary_currency - валюта (строка, по умолчанию "RUR")
10. company_name - название компании (строка)
11. company_description - описание компании (строка или null)
12. area_name - город/регион (строка)
13. address - адрес (строка или null)
14. professional_roles - профессиональные роли (строка или null)
15. contacts_name - контактное лицо (строка или null)
16. contacts_email - email для связи (строка или null)
17. contacts_phone - телефон для связи (строка или null)
ПРАВИЛА:
- Если информация не найдена, ставь null для необязательных полей
- Для обязательных полей используй разумные значения по умолчанию
- Зарплату указывай в рублях, конвертируй если нужно
- Опыт определяй по годам: 0-1 = noExperience, 1-3 = between1And3, 3-6 = between3And6, 6+ = moreThan6
- График работы определяй по описанию: офис = fullDay, удаленка = remote, гибкий = flexible
ФОРМАТИРОВАНИЕ ТЕКСТА:
- Если в тексте есть списки (обязанности, требования, навыки), форматируй их с переносами строк
- Используй символ \n для переноса строки между пунктами списка
- Пример: "Обязанности:\nВедение переговоров\nПодготовка документов\nОбучение персонала"
- Для ключевых навыков разделяй запятыми, но если их много - используй переносы строк
- В описании компании тоже используй переносы для лучшей читаемости
ОТВЕТЬ СТРОГО В JSON ФОРМАТЕ с указанными полями:
"""
response = openai.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": parsing_prompt}],
response_format={"type": "json_object"},
)
parsed_data = json.loads(response.choices[0].message.content)
# Валидируем и обрабатываем данные
return self._validate_parsed_data(parsed_data)
except Exception as e:
logger.error(f"Ошибка при парсинге вакансии через AI: {str(e)}")
raise
def _validate_parsed_data(self, data: dict[str, Any]) -> dict[str, Any]:
"""Валидирует и очищает спарсенные данные"""
from app.models.vacancy import EmploymentType, Experience, Schedule
# Обязательные поля с дефолтными значениями
validated_data = {
"title": data.get("title", "Название не указано"),
"description": data.get("description", "Описание не указано"),
"key_skills": data.get("key_skills"),
"employment_type": self._validate_enum(
data.get("employment_type"), EmploymentType, EmploymentType.FULL_TIME
),
"experience": self._validate_enum(
data.get("experience"), Experience, Experience.BETWEEN_1_AND_3
),
"schedule": self._validate_enum(
data.get("schedule"), Schedule, Schedule.FULL_DAY
),
"company_name": data.get("company_name"),
"area_name": data.get("area_name"),
}
# Необязательные поля
optional_fields = [
"salary_from",
"salary_to",
"salary_currency",
"company_description",
"address",
"professional_roles",
"contacts_name",
"contacts_email",
"contacts_phone",
]
for field in optional_fields:
value = data.get(field)
if value and value != "null":
validated_data[field] = value
# Специальная обработка зарплаты
if data.get("salary_from"):
try:
validated_data["salary_from"] = int(data["salary_from"])
except (ValueError, TypeError):
pass
if data.get("salary_to"):
try:
validated_data["salary_to"] = int(data["salary_to"])
except (ValueError, TypeError):
pass
# Валюта по умолчанию
validated_data["salary_currency"] = data.get("salary_currency", "RUR")
return validated_data
def _validate_enum(self, value: str, enum_class, default_value):
"""Валидирует значение enum"""
if not value:
return default_value
# Проверяем, есть ли такое значение в enum
try:
return enum_class(value)
except ValueError:
logger.warning(
f"Неизвестное значение {value} для {enum_class.__name__}, используем {default_value}"
)
return default_value
# Экземпляр сервиса
vacancy_parser_service = VacancyParserService()

View File

@ -1,4 +1,6 @@
import logging
from celery import Celery
from celery.signals import setup_logging
from rag.settings import settings
@ -15,4 +17,24 @@ celery_app.conf.update(
result_serializer="json",
timezone="UTC",
enable_utc=True,
worker_log_format="[%(asctime)s: %(levelname)s/%(processName)s] %(message)s",
worker_task_log_format="[%(asctime)s: %(levelname)s/%(processName)s][%(task_name)s(%(task_id)s)] %(message)s",
task_acks_late=True,
worker_prefetch_multiplier=1,
task_reject_on_worker_lost=True,
result_extended=True,
)
@setup_logging.connect
def config_loggers(*args, **kwargs):
"""Configure logging for Celery worker"""
logging.basicConfig(
level=logging.INFO,
format="[%(asctime)s: %(levelname)s/%(name)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
# Set specific log levels
logging.getLogger("celery").setLevel(logging.INFO)
logging.getLogger("celery.worker").setLevel(logging.INFO)
logging.getLogger("celery.task").setLevel(logging.INFO)

View File

@ -139,3 +139,72 @@ class SyncVacancyRepository:
from app.models.vacancy import Vacancy
return self.session.query(Vacancy).filter(Vacancy.id == vacancy_id).first()
def create_vacancy(self, vacancy_create):
"""Создать новую вакансию"""
from datetime import datetime
from app.models.vacancy import Vacancy
# Конвертируем VacancyCreate в dict
if hasattr(vacancy_create, "dict"):
vacancy_data = vacancy_create.dict()
elif hasattr(vacancy_create, "model_dump"):
vacancy_data = vacancy_create.model_dump()
else:
vacancy_data = vacancy_create
# Создаем новую вакансию
vacancy = Vacancy(
**vacancy_data, created_at=datetime.utcnow(), updated_at=datetime.utcnow()
)
self.session.add(vacancy)
self.session.flush() # Получаем ID без коммита
self.session.refresh(vacancy) # Обновляем объект из БД
# Создаем простой объект с нужными данными для возврата
class VacancyResult:
def __init__(self, id, title):
self.id = id
self.title = title
return VacancyResult(vacancy.id, vacancy.title)
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

View File

@ -1,4 +1,3 @@
import asyncio
import json
import logging
from datetime import datetime
@ -100,15 +99,45 @@ def generate_interview_report(resume_id: int):
# Сохраняем отчет в БД
report_instance = _save_report_to_db(db, resume_id, report)
# Генерируем и загружаем PDF отчет
# Запускаем отдельную задачу для генерации PDF
if report_instance:
asyncio.run(
_generate_and_upload_pdf_report(
db,
report_instance,
resume.applicant_name,
vacancy.get("title", "Unknown Position"),
)
from celery_worker.tasks import generate_pdf_report_task
report_data = {
"id": report_instance.id,
"interview_session_id": report_instance.interview_session_id,
"overall_score": report_instance.overall_score,
"technical_skills_score": report_instance.technical_skills_score,
"technical_skills_justification": report_instance.technical_skills_justification,
"technical_skills_concerns": report_instance.technical_skills_concerns,
"communication_score": report_instance.communication_score,
"communication_justification": report_instance.communication_justification,
"communication_concerns": report_instance.communication_concerns,
"problem_solving_score": report_instance.problem_solving_score,
"problem_solving_justification": report_instance.problem_solving_justification,
"problem_solving_concerns": report_instance.problem_solving_concerns,
"experience_relevance_score": report_instance.experience_relevance_score,
"experience_relevance_justification": report_instance.experience_relevance_justification,
"experience_relevance_concerns": report_instance.experience_relevance_concerns,
"cultural_fit_score": report_instance.cultural_fit_score,
"cultural_fit_justification": report_instance.cultural_fit_justification,
"cultural_fit_concerns": report_instance.cultural_fit_concerns,
"recommendation": report_instance.recommendation,
"strengths": report_instance.strengths,
"weaknesses": report_instance.weaknesses,
"red_flags": report_instance.red_flags,
"questions_analysis": report_instance.questions_analysis,
"next_steps": report_instance.next_steps,
"analysis_method": report_instance.analysis_method,
"created_at": report_instance.created_at.isoformat() if report_instance.created_at else None,
"updated_at": report_instance.updated_at.isoformat() if report_instance.updated_at else None,
}
generate_pdf_report_task.delay(
report_data=report_data,
candidate_name=resume.applicant_name,
position=vacancy.get("title", "Unknown Position"),
resume_file_url=resume.resume_file_url,
)
logger.info(
@ -224,12 +253,12 @@ def _parse_json_field(field_data) -> dict:
def _generate_comprehensive_report(
resume_id: int,
candidate_name: str,
vacancy: dict,
parsed_resume: dict,
interview_plan: dict,
dialogue_history: list[dict],
resume_id: int,
candidate_name: str,
vacancy: dict,
parsed_resume: dict,
interview_plan: dict,
dialogue_history: list[dict],
) -> dict[str, Any]:
"""
Генерирует комплексный отчет о кандидате с использованием LLM
@ -302,10 +331,10 @@ def _calculate_overall_score(evaluation: dict) -> int:
def _prepare_analysis_context(
vacancy: dict,
parsed_resume: dict,
interview_plan: dict,
dialogue_history: list[dict],
vacancy: dict,
parsed_resume: dict,
interview_plan: dict,
dialogue_history: list[dict],
) -> str:
"""Подготавливает контекст для анализа LLM"""
@ -321,8 +350,6 @@ def _prepare_analysis_context(
# Формируем контекст
context = f"""
АНАЛИЗ КАНДИДАТА НА СОБЕСЕДОВАНИЕ
ВАКАНСИЯ:
- Позиция: {vacancy.get("title", "Не указана")}
- Описание: {vacancy.get("description", "Не указано")[:500]}
@ -337,10 +364,10 @@ def _prepare_analysis_context(
- Образование: {parsed_resume.get("education", "Не указано")}
- Предыдущие позиции: {"; ".join([pos.get("title", "") + " в " + pos.get("company", "") for pos in parsed_resume.get("work_experience", [])])}
ПЛАН ИНТЕРВЬЮ:
ПЛАН СОБЕСЕДОВАНИЯ:
{json.dumps(interview_plan, ensure_ascii=False, indent=2) if interview_plan else "План интервью не найден"}
ДИАЛОГ ИНТЕРВЬЮ:
ДИАЛОГ СОБЕСЕДОВАНИЯ:
{dialogue_text if dialogue_text else "Диалог интервью не найден или пуст"}
"""
@ -363,11 +390,15 @@ def _call_openai_for_evaluation(context: str) -> dict | None:
{context}
ЗАДАЧА:
Проанализируй кандидата и дай оценку по критериям (0-100):
1. technical_skills: Соответствие техническим требованиям
2. experience_relevance: Релевантность опыта
3. communication: Коммуникативные навыки (на основе диалога)
4. problem_solving: Навыки решения задач
Проанализируй ДИАЛОГ с кандидатом. Если кандидат ответил на вопросы и подтвердил знания из резюме, то только тогда можно считать его навыки резюме подтвержденными
и можно оценивать их соответствие вакансионным требованиям. Если клиент уклонялся от вопросов или закончил интервью раньше (или диалог выглядит неполным исходя из плана, хотя интервьюер адаптирует план и сторого ему не следует),
чем это сделал сам интервьюер, то навыки не считаются подтвержденными и по ним нельзя оценивать кандидата
Дай оценку по критериям (0-100):
1. technical_skills: Соответствие диалога (и резюме если диалог подтверждает) техническим требованиям вакансии
2. experience_relevance: Релевантность опыта судя по диалогу (и резюме если диалог подтверждает)
3. communication: Коммуникативные навыки на основе диалога
4. problem_solving: Навыки решения задач на основе диалога
5. cultural_fit: Соответствие корпоративной культуре
Для каждого критерия:
@ -385,19 +416,21 @@ def _call_openai_for_evaluation(context: str) -> dict | None:
И топ 3 сильные/слабые стороны.
И red_flags (если есть): расхождение в стаже и опыте резюме и собеседования, шаблонные ответы, уклонение от вопросов
ОТВЕТЬ СТРОГО В JSON ФОРМАТЕ с обязательными полями:
- scores: объект с 5 критериями, каждый содержит score, justification, concerns
- overall_score: число от 0 до 100 (среднее арифметическое всех scores)
- recommendation: одно из 4 значений выше
- strengths: массив из 3 сильных сторон
- weaknesses: массив из 3 слабых сторон
- red_flags: массив из красных флагов (если есть)
"""
response = openai.chat.completions.create(
model="gpt-4o-mini",
model="gpt-5-mini",
messages=[{"role": "user", "content": evaluation_prompt}],
response_format={"type": "json_object"},
temperature=0.3,
)
evaluation = json.loads(response.choices[0].message.content)
@ -410,7 +443,7 @@ def _call_openai_for_evaluation(context: str) -> dict | None:
def _generate_fallback_evaluation(
parsed_resume: dict, vacancy: dict, dialogue_history: list[dict]
parsed_resume: dict, vacancy: dict, dialogue_history: list[dict]
) -> dict[str, Any]:
"""Генерирует базовую оценку без LLM"""
@ -585,19 +618,27 @@ def _save_report_to_db(db, resume_id: int, report: dict):
async def _generate_and_upload_pdf_report(
db, report_instance: "InterviewReport", candidate_name: str, position: str
db,
report_instance: "InterviewReport",
candidate_name: str,
position: str,
resume_file_url: str = None,
):
"""Генерирует PDF отчет и загружает его в S3"""
try:
from app.services.pdf_report_service import pdf_report_service
logger.info(
f"[PDF_GENERATION] Starting PDF generation for report ID: {report_instance.id}"
)
# Генерируем и загружаем PDF
# Генерируем и загружаем PDF - используем переданные параметры как в старой версии
pdf_url = await pdf_report_service.generate_and_upload_pdf(
report=report_instance, candidate_name=candidate_name, position=position
report=report_instance,
candidate_name=candidate_name,
position=position,
resume_file_url=resume_file_url,
)
if pdf_url:
@ -617,8 +658,22 @@ async def _generate_and_upload_pdf_report(
logger.error(f"[PDF_GENERATION] Error generating PDF report: {str(e)}")
def _format_concerns_field(concerns_data) -> str:
"""Форматирует поле concerns для сохранения как строку"""
if not concerns_data:
return ""
if isinstance(concerns_data, list):
# Если это массив, объединяем элементы через запятую с переносом строки
return "; ".join(concerns_data)
elif isinstance(concerns_data, str):
return concerns_data
else:
return str(concerns_data)
def _create_report_from_dict(
interview_session_id: int, report: dict
interview_session_id: int, report: dict
) -> "InterviewReport":
"""Создает объект InterviewReport из словаря отчета"""
from app.models.interview_report import InterviewReport, RecommendationType
@ -633,8 +688,8 @@ def _create_report_from_dict(
technical_skills_justification=scores.get("technical_skills", {}).get(
"justification", ""
),
technical_skills_concerns=scores.get("technical_skills", {}).get(
"concerns", ""
technical_skills_concerns=_format_concerns_field(
scores.get("technical_skills", {}).get("concerns", "")
),
experience_relevance_score=scores.get("experience_relevance", {}).get(
"score", 0
@ -642,24 +697,30 @@ def _create_report_from_dict(
experience_relevance_justification=scores.get("experience_relevance", {}).get(
"justification", ""
),
experience_relevance_concerns=scores.get("experience_relevance", {}).get(
"concerns", ""
experience_relevance_concerns=_format_concerns_field(
scores.get("experience_relevance", {}).get("concerns", "")
),
communication_score=scores.get("communication", {}).get("score", 0),
communication_justification=scores.get("communication", {}).get(
"justification", ""
),
communication_concerns=scores.get("communication", {}).get("concerns", ""),
communication_concerns=_format_concerns_field(
scores.get("communication", {}).get("concerns", "")
),
problem_solving_score=scores.get("problem_solving", {}).get("score", 0),
problem_solving_justification=scores.get("problem_solving", {}).get(
"justification", ""
),
problem_solving_concerns=scores.get("problem_solving", {}).get("concerns", ""),
problem_solving_concerns=_format_concerns_field(
scores.get("problem_solving", {}).get("concerns", "")
),
cultural_fit_score=scores.get("cultural_fit", {}).get("score", 0),
cultural_fit_justification=scores.get("cultural_fit", {}).get(
"justification", ""
),
cultural_fit_concerns=scores.get("cultural_fit", {}).get("concerns", ""),
cultural_fit_concerns=_format_concerns_field(
scores.get("cultural_fit", {}).get("concerns", "")
),
# Агрегированные поля
overall_score=report.get("overall_score", 0),
recommendation=RecommendationType(report.get("recommendation", "reject")),
@ -693,8 +754,8 @@ def _update_report_from_dict(existing_report, report: dict):
existing_report.technical_skills_justification = scores["technical_skills"].get(
"justification", ""
)
existing_report.technical_skills_concerns = scores["technical_skills"].get(
"concerns", ""
existing_report.technical_skills_concerns = _format_concerns_field(
scores["technical_skills"].get("concerns", "")
)
if "experience_relevance" in scores:
@ -704,17 +765,17 @@ def _update_report_from_dict(existing_report, report: dict):
existing_report.experience_relevance_justification = scores[
"experience_relevance"
].get("justification", "")
existing_report.experience_relevance_concerns = scores[
"experience_relevance"
].get("concerns", "")
existing_report.experience_relevance_concerns = _format_concerns_field(
scores["experience_relevance"].get("concerns", "")
)
if "communication" in scores:
existing_report.communication_score = scores["communication"].get("score", 0)
existing_report.communication_justification = scores["communication"].get(
"justification", ""
)
existing_report.communication_concerns = scores["communication"].get(
"concerns", ""
existing_report.communication_concerns = _format_concerns_field(
scores["communication"].get("concerns", "")
)
if "problem_solving" in scores:
@ -724,8 +785,8 @@ def _update_report_from_dict(existing_report, report: dict):
existing_report.problem_solving_justification = scores["problem_solving"].get(
"justification", ""
)
existing_report.problem_solving_concerns = scores["problem_solving"].get(
"concerns", ""
existing_report.problem_solving_concerns = _format_concerns_field(
scores["problem_solving"].get("concerns", "")
)
if "cultural_fit" in scores:
@ -733,8 +794,8 @@ def _update_report_from_dict(existing_report, report: dict):
existing_report.cultural_fit_justification = scores["cultural_fit"].get(
"justification", ""
)
existing_report.cultural_fit_concerns = scores["cultural_fit"].get(
"concerns", ""
existing_report.cultural_fit_concerns = _format_concerns_field(
scores["cultural_fit"].get("concerns", "")
)
# Агрегированные поля

View File

@ -1,8 +1,12 @@
import json
import logging
import os
from typing import Any
from celery_worker.celery_app import celery_app
# Настраиваем логгер для задач
logger = logging.getLogger(__name__)
from celery_worker.database import (
SyncResumeRepository,
SyncVacancyRepository,
@ -62,15 +66,15 @@ def generate_interview_plan(
compatibility_prompt = f"""
Проанализируй соответствие кандидата вакансии и определи, стоит ли проводить интервью.
КЛЮЧЕВОЙ И ЕДИНСТВЕННЫй КРИТЕРИЙ ОТКЛОНЕНИЯ:
1. Профессиональная область кандидата: Полное несоответствие сферы деятельности вакансии (иначе 100 за критерий)
ДОПУСТИМЫЕ КРИТЕРИИ:
2. Остальные показатели кандидата хотя бы примерно соответствуют вакансии: скиллы кандидата похожи или смежны вакансионным, опыт не сильно отдален
от указанного
3. Учитывай опыт с аналогичными, похожими, смежными технологиями
4. Когда смотришь на вакансию и кандидата не учитывай строгие слова, такие как "Требования", "Ключевые" и тп. Это лишь маркеры,
КЛЮЧЕВЫЕ КРИТЕРИИ ОТКЛОНЕНИЯ:
1. Несоответствие профессиональной сферы опыт и навыки кандидата не относятся к области деятельности, связанной с вакансией.
2. Несоответствие уровня и фокуса позиции текущая или предыдущая должность кандидата существенно отличается по направлению или уровню ответственности от требований вакансии. Допускаются смежные переходы (например, переход из fullstack в frontend или переход кандидата уровня senior на позицию middle/junior).
КЛЮЧЕВЫЕ КРИТЕРИИ ДОПУСКА:
3. Остальные показатели кандидата примерно соответствуют вакансии: скиллы кандидата похожи или смежны вакансионным, опыт попадает в указанных промежуток
4. Учитывай опыт с аналогичными, похожими, смежными технологиями
5. Когда смотришь на вакансию и кандидата не учитывай строгие слова, такие как "Требования", "Ключевые" и тп. Это лишь маркеры,
но не оценочные указатели
5. Если есть спорные вопросы соответствия, лучше допустить к собеседованию и уточнить их там
6. Если есть спорные вопросы соответствия, лучше допустить к собеседованию и уточнить их там
КАНДИДАТ:
- Имя: {combined_data.get("name", "Не указано")}
@ -134,7 +138,7 @@ def generate_interview_plan(
# Если кандидат подходит - генерируем план интервью
plan_prompt = f"""
Создай детальный план интервью для кандидата на основе его резюме и требований вакансии.
Создай детальный план интервью для кандидата на основе его резюме и требований вакансии на 45 МИНУТ.
РЕЗЮМЕ КАНДИДАТА:
- Имя: {combined_data.get("name", "Не указано")}
@ -182,7 +186,7 @@ def generate_interview_plan(
"Опыт командной работы",
"Мотивация к изучению нового"
],
"red_flags_to_check": [],
"red_flags_to_check": [Шаблонные ответы, уклонения от вопросов, расхождение в стаже],
"personalization_notes": "Кандидат имеет хороший технический опыт"
}}
"""
@ -223,7 +227,7 @@ def generate_interview_plan(
}
except Exception as e:
print(f"Ошибка генерации плана интервью: {str(e)}")
logger.error(f"Ошибка генерации плана интервью: {str(e)}", exc_info=True)
return None
@ -236,24 +240,37 @@ def parse_resume_task(self, resume_id: str, file_path: str):
resume_id: ID резюме
file_path: Путь к PDF файлу резюме
"""
logger.info(f"=== НАЧАЛО ОБРАБОТКИ РЕЗЮМЕ {resume_id} ===")
logger.info(f"Путь к файлу: {file_path}")
try:
# Шаг 0: Обновляем статус в БД - начали парсинг
logger.info(f"Шаг 0: Обновляем статус резюме {resume_id} на 'parsing'")
with get_sync_session() as session:
repo = SyncResumeRepository(session)
repo.update_status(int(resume_id), "parsing")
logger.info(f"Статус резюме {resume_id} успешно обновлен на 'parsing'")
# Обновляем статус задачи
logger.info(f"Обновляем состояние Celery задачи на PENDING")
self.update_state(
state="PENDING",
meta={"status": "Начинаем парсинг резюме...", "progress": 10},
)
logger.info(f"Состояние Celery задачи обновлено")
# Инициализируем модели из registry
logger.info(f"Шаг 1: Инициализируем модели из registry")
try:
logger.info("Получаем chat_model из registry")
chat_model = registry.get_chat_model()
logger.info("Chat model успешно получен")
logger.info("Получаем vector_store из registry")
vector_store = registry.get_vector_store()
logger.info("Vector store успешно получен")
except Exception as e:
logger.error(f"ОШИБКА при инициализации моделей: {str(e)}", exc_info=True)
# Обновляем статус в БД - ошибка инициализации
with get_sync_session() as session:
repo = SyncResumeRepository(session)
@ -262,17 +279,23 @@ def parse_resume_task(self, resume_id: str, file_path: str):
"failed",
error_message=f"Ошибка инициализации моделей: {str(e)}",
)
raise Exception(f"Ошибка инициализации моделей: {str(e)}")
raise RuntimeError(f"Ошибка инициализации моделей: {str(e)}")
# Шаг 1: Парсинг резюме
# Шаг 2: Парсинг резюме
logger.info(f"Шаг 2: Начинаем парсинг резюме")
self.update_state(
state="PROGRESS",
meta={"status": "Извлекаем текст из PDF...", "progress": 20},
)
logger.info(f"Состояние Celery обновлено на PROGRESS (20%)")
logger.info(f"Создаем ResumeParser")
parser = ResumeParser(chat_model)
logger.info(f"ResumeParser создан успешно")
logger.info(f"Проверяем существование файла: {file_path}")
if not os.path.exists(file_path):
logger.error(f"ФАЙЛ НЕ НАЙДЕН: {file_path}")
# Обновляем статус в БД - файл не найден
with get_sync_session() as session:
repo = SyncResumeRepository(session)
@ -281,61 +304,88 @@ def parse_resume_task(self, resume_id: str, file_path: str):
"failed",
error_message=f"Файл не найден: {file_path}",
)
raise Exception(f"Файл не найден: {file_path}")
logger.info(f"Статус резюме {resume_id} обновлен на 'failed' в БД")
raise FileNotFoundError(f"Файл не найден: {file_path}")
logger.info(f"Файл существует, начинаем парсинг")
parsed_resume = parser.parse_resume_from_file(file_path)
logger.info(f"Парсинг резюме завершен, получены данные: {list(parsed_resume.keys())}")
# Получаем оригинальные данные из формы
logger.info(f"Шаг 3: Получаем данные резюме из БД")
with get_sync_session() as session:
repo = SyncResumeRepository(session)
resume_record = repo.get_by_id(int(resume_id))
if not resume_record:
raise Exception(f"Резюме с ID {resume_id} не найдено в базе данных")
logger.error(f"РЕЗЮМЕ С ID {resume_id} НЕ НАЙДЕНО В БД")
raise ValueError(f"Резюме с ID {resume_id} не найдено в базе данных")
# Извлекаем нужные данные пока сессия активна
applicant_name = resume_record.applicant_name
applicant_email = resume_record.applicant_email
applicant_phone = resume_record.applicant_phone
logger.info(f"Данные резюме получены: name={applicant_name}, email={applicant_email}, phone={applicant_phone}")
# Создаем комбинированные данные: навыки и опыт из парсинга, контакты из формы
logger.info(f"Шаг 4: Объединяем данные из парсинга и формы")
combined_data = parsed_resume.copy()
combined_data["name"] = applicant_name or parsed_resume.get("name", "")
combined_data["email"] = applicant_email or parsed_resume.get("email", "")
combined_data["phone"] = applicant_phone or parsed_resume.get("phone", "")
logger.info(f"Комбинированные данные подготовлены")
# Шаг 2: Векторизация и сохранение в Milvus
# Шаг 5: Векторизация и сохранение в Milvus
logger.info(f"Шаг 5: Векторизация и сохранение в Milvus")
self.update_state(
state="PENDING",
meta={"status": "Сохраняем в векторную базу...", "progress": 60},
)
logger.info(f"Состояние Celery обновлено на 60%")
logger.info(f"Добавляем профиль кандидата в vector store")
vector_store.add_candidate_profile(str(resume_id), combined_data)
logger.info(f"Профиль кандидата добавлен в vector store")
# Шаг 3: Обновляем статус в PostgreSQL - успешно обработано
# Шаг 6: Обновляем статус в PostgreSQL
logger.info(f"Шаг 6: Подготовка к обновлению статуса в БД")
self.update_state(
state="PENDING",
meta={"status": "Обновляем статус в базе данных...", "progress": 85},
)
logger.info(f"Состояние Celery обновлено на 85%")
# Шаг 4: Генерируем план интервью
# Шаг 7: Генерируем план интервью
logger.info(f"Шаг 7: Генерация плана интервью")
self.update_state(
state="PENDING",
meta={"status": "Генерируем план интервью...", "progress": 90},
)
logger.info(f"Состояние Celery обновлено на 90%")
logger.info(f"Вызываем generate_interview_plan для резюме {resume_id}")
interview_plan = generate_interview_plan(int(resume_id), combined_data)
logger.info(f"План интервью сгенерирован: {interview_plan is not None}")
logger.info(f"Шаг 8: Обновляем статус в БД на основе плана интервью")
with get_sync_session() as session:
repo = SyncResumeRepository(session)
# Проверяем результат генерации плана интервью
print("interview_plan", interview_plan)
logger.info(f"Анализируем план интервью для резюме {resume_id}")
logger.info(f"План интервью: {interview_plan}")
if interview_plan and interview_plan.get("is_suitable", True):
logger.info(f"Кандидат подходит, обновляем статус на 'parsed'")
# Кандидат подходит - обновляем статус на parsed
repo.update_status(int(resume_id), "parsed", parsed_data=combined_data)
logger.info(f"Статус резюме {resume_id} обновлен на 'parsed'")
# Сохраняем план интервью
logger.info(f"Сохраняем план интервью для резюме {resume_id}")
repo.update_interview_plan(int(resume_id), interview_plan)
logger.info(f"План интервью сохранен")
else:
logger.info(f"Кандидат НЕ подходит, отклоняем")
# Кандидат не подходит - отклоняем
rejection_reason = (
interview_plan.get(
@ -344,14 +394,17 @@ def parse_resume_task(self, resume_id: str, file_path: str):
if interview_plan
else "Ошибка анализа соответствия"
)
logger.info(f"Причина отклонения: {rejection_reason}")
repo.update_status(
int(resume_id),
"rejected",
parsed_data=combined_data,
rejection_reason=rejection_reason,
)
logger.info(f"Статус резюме {resume_id} обновлен на 'rejected'")
# Завершаем с информацией об отклонении
logger.info(f"Обновляем состояние Celery на SUCCESS (отклонен)")
self.update_state(
state="SUCCESS",
meta={
@ -362,6 +415,7 @@ def parse_resume_task(self, resume_id: str, file_path: str):
"rejection_reason": rejection_reason,
},
)
logger.info(f"=== ЗАВЕРШЕНИЕ ОБРАБОТКИ РЕЗЮМЕ {resume_id} (ОТКЛОНЕН) ===")
return {
"resume_id": resume_id,
@ -371,6 +425,7 @@ def parse_resume_task(self, resume_id: str, file_path: str):
}
# Завершено успешно
logger.info(f"Обновляем состояние Celery на SUCCESS (принят)")
self.update_state(
state="SUCCESS",
meta={
@ -379,6 +434,7 @@ def parse_resume_task(self, resume_id: str, file_path: str):
"result": combined_data,
},
)
logger.info(f"=== УСПЕШНОЕ ЗАВЕРШЕНИЕ ОБРАБОТКИ РЕЗЮМЕ {resume_id} ===")
return {
"resume_id": resume_id,
@ -387,13 +443,16 @@ def parse_resume_task(self, resume_id: str, file_path: str):
}
except Exception as e:
error_message = str(e)
logger.error(f"Ошибка при обработке резюме {resume_id}: {error_message}", exc_info=True)
# В случае ошибки
self.update_state(
state="FAILURE",
meta={
"status": f"Ошибка при обработке резюме: {str(e)}",
"status": f"Ошибка при обработке резюме: {error_message}",
"progress": 0,
"error": str(e),
"error": error_message,
},
)
@ -401,11 +460,16 @@ def parse_resume_task(self, resume_id: str, file_path: str):
try:
with get_sync_session() as session:
repo = SyncResumeRepository(session)
repo.update_status(int(resume_id), "failed", error_message=str(e))
repo.update_status(int(resume_id), "failed", error_message=error_message)
except Exception as db_error:
print(f"Ошибка при обновлении статуса в БД: {str(db_error)}")
logger.error(f"Ошибка при обновлении статуса в БД: {str(db_error)}", exc_info=True)
raise
# Возвращаем стандартное исключение вместо re-raise
return {
"resume_id": resume_id,
"status": "failed",
"error": error_message,
}
# Функция больше не нужна - используем SyncResumeRepository напрямую
@ -431,7 +495,7 @@ def generate_interview_questions_task(self, resume_id: str, job_description: str
chat_model = registry.get_chat_model()
vector_store = registry.get_vector_store()
except Exception as e:
raise Exception(f"Ошибка инициализации моделей: {str(e)}")
raise RuntimeError(f"Ошибка инициализации моделей: {str(e)}")
# Шаг 1: Получить parsed резюме из базы данных
self.update_state(
@ -578,3 +642,279 @@ def generate_interview_questions_task(self, resume_id: str, job_description: str
},
)
raise Exception(f"Ошибка при генерации вопросов: {str(e)}")
@celery_app.task(bind=True)
def parse_vacancy_task(
self, file_content_base64: str, filename: str, create_vacancy: bool = False
):
"""
Асинхронная задача парсинга вакансии из файла
Args:
file_content_base64: Содержимое файла в base64
filename: Имя файла для определения формата
create_vacancy: Создать вакансию в БД после парсинга
"""
try:
import base64
from app.models.vacancy import VacancyCreate
from app.services.vacancy_parser_service import vacancy_parser_service
# Обновляем статус задачи
self.update_state(
state="PENDING",
meta={"status": "Начинаем парсинг вакансии...", "progress": 10},
)
# Декодируем содержимое файла
file_content = base64.b64decode(file_content_base64)
# Шаг 1: Извлечение текста из файла
self.update_state(
state="PROGRESS",
meta={"status": "Извлекаем текст из файла...", "progress": 30},
)
raw_text = vacancy_parser_service.extract_text_from_file(file_content, filename)
if not raw_text.strip():
raise ValueError("Не удалось извлечь текст из файла")
# Шаг 2: Парсинг с помощью AI
self.update_state(
state="PROGRESS",
meta={"status": "Обрабатываем текст с помощью AI...", "progress": 70},
)
import asyncio
parsed_data = asyncio.run(
vacancy_parser_service.parse_vacancy_with_ai(raw_text)
)
# Шаг 3: Создание вакансии (если требуется)
created_vacancy = None
print(
f"create_vacancy parameter: {create_vacancy}, type: {type(create_vacancy)}"
)
if create_vacancy:
self.update_state(
state="PROGRESS",
meta={"status": "Создаем вакансию в базе данных...", "progress": 90},
)
try:
print(f"Parsed data for vacancy creation: {parsed_data}")
vacancy_create = VacancyCreate(**parsed_data)
print(f"VacancyCreate object created successfully: {vacancy_create}")
with get_sync_session() as session:
vacancy_repo = SyncVacancyRepository(session)
created_vacancy = vacancy_repo.create_vacancy(vacancy_create)
print(
f"Vacancy created with ID: {created_vacancy.id if created_vacancy else 'None'}"
)
except Exception as e:
import traceback
error_details = traceback.format_exc()
print(f"Error creating vacancy: {str(e)}")
print(f"Full traceback: {error_details}")
# Возвращаем парсинг, но предупреждаем об ошибке создания
self.update_state(
state="SUCCESS",
meta={
"status": f"Парсинг выполнен, но ошибка при создании вакансии: {str(e)}",
"progress": 100,
"result": parsed_data,
"warning": f"Ошибка создания вакансии: {str(e)}",
},
)
return {
"status": "parsed_with_warning",
"parsed_data": parsed_data,
"warning": f"Ошибка при создании вакансии: {str(e)}",
}
# Завершено успешно
response_message = "Парсинг выполнен успешно"
if created_vacancy:
response_message += f". Вакансия создана с ID: {created_vacancy.id}"
self.update_state(
state="SUCCESS",
meta={
"status": response_message,
"progress": 100,
"result": parsed_data,
"vacancy_id": created_vacancy.id if created_vacancy else None,
},
)
return {
"status": "completed",
"parsed_data": parsed_data,
"vacancy_id": created_vacancy.id if created_vacancy else None,
"message": response_message,
}
except Exception as e:
# В случае ошибки
self.update_state(
state="FAILURE",
meta={
"status": f"Ошибка при парсинге вакансии: {str(e)}",
"progress": 0,
"error": str(e),
},
)
raise Exception(f"Ошибка при парсинге вакансии: {str(e)}")
@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)}")

133
docker-compose.yml Normal file
View File

@ -0,0 +1,133 @@
services:
# PostgreSQL Database
postgres:
image: postgres:15
environment:
POSTGRES_DB: hr_ai
POSTGRES_USER: hr_user
POSTGRES_PASSWORD: hr_password
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U hr_user -d hr_ai"]
interval: 30s
timeout: 10s
retries: 5
# Redis for Celery and caching
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
# LiveKit Server
livekit:
image: livekit/livekit-server:latest
ports:
- "7880:7880"
- "7881:7881"
- "7882:7882/udp"
- "3478:3478/udp"
volumes:
- caddy_data:/certs
depends_on:
- caddy
restart: unless-stopped
environment:
LIVEKIT_CONFIG: |
keys:
devkey: devkey_secret_32chars_minimum_length
webhook:
api_key: devkey
turn:
enabled: true
tls_port: 5349
domain: hr.aiquity.xyz
cert_file: /certs/certificates/acme-v02.api.letsencrypt.org-directory/${DOMAIN:-localhost}/${DOMAIN:-localhost}.crt
key_file: /certs/certificates/acme-v02.api.letsencrypt.org-directory/${DOMAIN:-localhost}/${DOMAIN:-localhost}.key
port: 7880
rtc:
tcp_port: 7881
port_range_start: 50000
port_range_end: 60000
use_external_ip: true
redis:
address: redis:6379
# HR AI Backend
backend:
image: cr.yandex/crp9p5rtbnbop36duusi/hr-ai-backend:latest
expose:
- "8000"
env_file:
- .env
environment:
- DATABASE_URL=postgresql+asyncpg://hr_user:hr_password@postgres:5432/hr_ai
- REDIS_CACHE_URL=redis
- REDIS_CACHE_PORT=6379
- REDIS_CACHE_DB=0
- LIVEKIT_URL=ws://livekit:7880
- LIVEKIT_API_KEY=devkey
- LIVEKIT_API_SECRET=devkey_secret_32chars_minimum_length
- APP_ENV=development
- DEBUG=true
volumes:
- ./agent_commands:/tmp/agent_commands
- backend_uploads:/app/uploads
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
livekit:
condition: service_started
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
# Caddy reverse proxy with automatic HTTPS
caddy:
image: caddy:2-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- backend
- livekit
environment:
- DOMAIN=${DOMAIN:-localhost}
restart: unless-stopped
frontend:
image: cr.yandex/crp9p5rtbnbop36duusi/hr-ai-frontend:latest
expose:
- "3000"
environment:
- NODE_ENV=production
restart: unless-stopped
volumes:
- ./.env.local:/app/.env.local:ro
volumes:
postgres_data:
redis_data:
backend_uploads:
caddy_data:
caddy_config:

View File

@ -1,3 +1,5 @@
import asyncio
import sys
from contextlib import asynccontextmanager
from fastapi import FastAPI
@ -7,6 +9,7 @@ from app.core.session_middleware import SessionMiddleware
from app.routers import resume_router, vacancy_router
from app.routers.admin_router import router as admin_router
from app.routers.analysis_router import router as analysis_router
from app.routers.interview_reports_router import router as interview_report_router
from app.routers.interview_router import router as interview_router
from app.routers.session_router import router as session_router
@ -16,6 +19,9 @@ async def lifespan(app: FastAPI):
# Запускаем AI агента при старте приложения
from app.services.agent_manager import agent_manager
if sys.platform.startswith("win"):
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
print("[STARTUP] Starting AI Agent...")
success = await agent_manager.start_agent()
@ -56,6 +62,7 @@ app.include_router(session_router, prefix="/api/v1")
app.include_router(interview_router, prefix="/api/v1")
app.include_router(analysis_router, prefix="/api/v1")
app.include_router(admin_router, prefix="/api/v1")
app.include_router(interview_report_router, prefix="/api/v1")
@app.get("/")

View File

@ -27,14 +27,7 @@ def upgrade() -> None:
sa.Column("room_name", sa.String(length=255), nullable=False),
sa.Column(
"status",
sa.Enum(
"created",
"active",
"completed",
"failed",
name="interviewstatus",
create_type=False,
),
sa.TEXT(),
nullable=True,
),
sa.Column("transcript", sa.Text(), nullable=True),

View File

@ -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 ###

View File

@ -31,6 +31,12 @@ dependencies = [
"comtypes>=1.4.12",
"reportlab>=4.4.3",
"yandex-speechkit>=1.5.0",
"pdfkit>=1.0.0",
"jinja2>=3.1.6",
"greenlet>=3.2.4",
"xhtml2pdf>=0.2.17",
"playwright>=1.55.0",
"celery-types==0.23.0",
]
[build-system]

View File

@ -31,9 +31,7 @@ class ModelRegistry:
"""Получить или создать chat модель"""
if self._chat_model is None:
if settings.openai_api_key:
llm = ChatOpenAI(
api_key=settings.openai_api_key, model="gpt-5-mini"
)
llm = ChatOpenAI(api_key=settings.openai_api_key, model="gpt-5-mini")
self._chat_model = ChatModel(llm)
else:
raise ValueError("OpenAI API key не настроен в settings")

View File

@ -6,7 +6,7 @@ class RagSettings(BaseSettings):
database_url: str = "postgresql+asyncpg://tdjx:1309@localhost:5432/hr_ai"
# Milvus Settings
milvus_uri: str = "http://5.188.159.90:19530"
milvus_uri: str = "milvus_uri"
milvus_collection: str = "candidate_profiles"
# Redis

71
scripts/build-and-push.sh Executable file
View File

@ -0,0 +1,71 @@
#!/bin/bash
# Build and push script for Yandex Cloud Container Registry
# Usage: ./scripts/build-and-push. [tag]
set -e
# Configuration
REGISTRY_ID="${YANDEX_REGISTRY_ID:-your-registry-id}"
IMAGE_NAME="hr-ai-backend"
TAG="${1:-latest}"
FULL_IMAGE_NAME="cr.yandex/${REGISTRY_ID}/${IMAGE_NAME}:${TAG}"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${YELLOW}Building and pushing HR AI Backend to Yandex Cloud Container Registry${NC}"
# Check if required environment variables are set
if [ -z "$REGISTRY_ID" ] || [ "$REGISTRY_ID" = "your-registry-id" ]; then
echo -e "${RED}Error: YANDEX_REGISTRY_ID environment variable is not set${NC}"
echo "Please set it to your Yandex Cloud Container Registry ID"
echo "Example: export YANDEX_REGISTRY_ID=crp1234567890abcdef"
exit 1
fi
# Check if yc CLI is installed and authenticated
if ! command -v yc &> /dev/null; then
echo -e "${RED}Error: Yandex Cloud CLI (yc) is not installed${NC}"
echo "Please install it from: https://cloud.yandex.ru/docs/cli/quickstart"
exit 1
fi
# Check authentication
if ! yc config list | grep -q "token:"; then
echo -e "${RED}Error: Not authenticated with Yandex Cloud${NC}"
echo "Please run: yc init"
exit 1
fi
echo -e "${YELLOW}Configuring Docker for Yandex Cloud Container Registry...${NC}"
yc container registry configure-docker
echo -e "${YELLOW}Building Docker image: ${FULL_IMAGE_NAME}${NC}"
docker build -t "${FULL_IMAGE_NAME}" .
echo -e "${YELLOW}Pushing image to registry...${NC}"
docker push "${FULL_IMAGE_NAME}"
echo -e "${GREEN}✓ Successfully built and pushed: ${FULL_IMAGE_NAME}${NC}"
# Also tag as latest if a specific tag was provided
if [ "$TAG" != "latest" ]; then
LATEST_IMAGE_NAME="cr.yandex/${REGISTRY_ID}/${IMAGE_NAME}:latest"
echo -e "${YELLOW}Tagging as latest...${NC}"
docker tag "${FULL_IMAGE_NAME}" "${LATEST_IMAGE_NAME}"
docker push "${LATEST_IMAGE_NAME}"
echo -e "${GREEN}✓ Also pushed as: ${LATEST_IMAGE_NAME}${NC}"
fi
echo -e "${GREEN}Build and push completed successfully!${NC}"
echo ""
echo "Image is available at:"
echo " ${FULL_IMAGE_NAME}"
echo ""
echo "To use in production, update your docker-compose.prod.yml:"
echo " backend:"
echo " image: ${FULL_IMAGE_NAME}"

301
scripts/deploy.sh Executable file
View File

@ -0,0 +1,301 @@
#!/bin/bash
# SSH Deploy script for HR AI Backend
# Usage: ./scripts/deploy.sh [environment] [image_tag]
set -e
# Configuration
ENVIRONMENT="${1:-production}"
IMAGE_TAG="${2:-latest}"
REGISTRY_ID="${YANDEX_REGISTRY_ID:-your-registry-id}"
IMAGE_NAME="hr-ai-backend"
FULL_IMAGE_NAME="cr.yandex/${REGISTRY_ID}/${IMAGE_NAME}:${IMAGE_TAG}"
# Server configuration (set these as environment variables)
SERVER_HOST="${DEPLOY_HOST:-your-server.com}"
SERVER_USER="${DEPLOY_USER:-deploy}"
SERVER_PORT="${DEPLOY_PORT:-22}"
DEPLOY_PATH="${DEPLOY_PATH:-/opt/hr-ai-backend}"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
echo -e "${YELLOW}Deploying HR AI Backend to ${ENVIRONMENT} environment${NC}"
# Check if required environment variables are set
missing_vars=""
if [ -z "$SERVER_HOST" ] || [ "$SERVER_HOST" = "your-server.com" ]; then
missing_vars="$missing_vars DEPLOY_HOST"
fi
if [ -z "$SERVER_USER" ] || [ "$SERVER_USER" = "deploy" ]; then
missing_vars="$missing_vars DEPLOY_USER"
fi
if [ -z "$REGISTRY_ID" ] || [ "$REGISTRY_ID" = "your-registry-id" ]; then
missing_vars="$missing_vars YANDEX_REGISTRY_ID"
fi
if [ -n "$missing_vars" ]; then
echo -e "${RED}Error: Required environment variables are not set:${NC}"
for var in $missing_vars; do
echo " - $var"
done
echo ""
echo "Example configuration:"
echo "export DEPLOY_HOST=your-server.example.com"
echo "export DEPLOY_USER=deploy"
echo "export YANDEX_REGISTRY_ID=crp1234567890abcdef"
echo "export DEPLOY_PATH=/opt/hr-ai-backend # optional"
echo "export DEPLOY_PORT=22 # optional"
exit 1
fi
# Test SSH connection
echo -e "${BLUE}Testing SSH connection to ${SERVER_USER}@${SERVER_HOST}:${SERVER_PORT}...${NC}"
if ! ssh -p "${SERVER_PORT}" -o ConnectTimeout=10 -o StrictHostKeyChecking=no "${SERVER_USER}@${SERVER_HOST}" "echo 'SSH connection successful'"; then
echo -e "${RED}Error: Cannot connect to server via SSH${NC}"
echo "Please check your SSH key configuration and server details"
exit 1
fi
# Create deployment directory structure on server
echo -e "${BLUE}Creating deployment directories on server...${NC}"
ssh -p "${SERVER_PORT}" "${SERVER_USER}@${SERVER_HOST}" "
sudo mkdir -p ${DEPLOY_PATH}/{config,logs,data,agent_commands}
sudo mkdir -p ${DEPLOY_PATH}/data/{postgres,redis,uploads,caddy_data,caddy_config}
sudo mkdir -p ${DEPLOY_PATH}/logs/{caddy}
sudo chown -R ${SERVER_USER}:${SERVER_USER} ${DEPLOY_PATH}
"
# Copy configuration files to server
echo -e "${BLUE}Copying configuration files to server...${NC}"
scp -P "${SERVER_PORT}" docker-compose.yml "${SERVER_USER}@${SERVER_HOST}:${DEPLOY_PATH}/"
scp -P "${SERVER_PORT}" Caddyfile "${SERVER_USER}@${SERVER_HOST}:${DEPLOY_PATH}/"
# Create frontend environment file
echo -e "${BLUE}Creating frontend environment configuration...${NC}"
ssh -p "${SERVER_PORT}" "${SERVER_USER}@${SERVER_HOST}" "
cat > ${DEPLOY_PATH}/.env.local << 'EOF'
# Frontend Environment Configuration
NODE_ENV=production
# API URL (adjust based on your setup)
NEXT_PUBLIC_API_URL=https://\${SERVER_HOST}/api
REACT_APP_API_URL=https://\${SERVER_HOST}/api
VUE_APP_API_URL=https://\${SERVER_HOST}/api
# LiveKit Configuration for frontend
NEXT_PUBLIC_LIVEKIT_URL=ws://\${SERVER_HOST}/rtc
REACT_APP_LIVEKIT_URL=ws://\${SERVER_HOST}/rtc
VUE_APP_LIVEKIT_URL=ws://\${SERVER_HOST}/rtc
# For localhost development (no HTTPS)
# NEXT_PUBLIC_API_URL=http://\${SERVER_HOST}/api
# REACT_APP_API_URL=http://\${SERVER_HOST}/api
# VUE_APP_API_URL=http://\${SERVER_HOST}/api
# NEXT_PUBLIC_LIVEKIT_URL=ws://\${SERVER_HOST}/rtc
# REACT_APP_LIVEKIT_URL=ws://\${SERVER_HOST}/rtc
# VUE_APP_LIVEKIT_URL=ws://\${SERVER_HOST}/rtc
# Add your frontend-specific environment variables here
EOF
"
# Create production environment file on server
echo -e "${BLUE}Creating production environment configuration...${NC}"
ssh -p "${SERVER_PORT}" "${SERVER_USER}@${SERVER_HOST}" "
cat > ${DEPLOY_PATH}/.env << 'EOF'
# Production Environment Configuration
DATABASE_URL=postgresql+asyncpg://hr_user:hr_password@postgres:5432/hr_ai
REDIS_CACHE_URL=redis
REDIS_CACHE_PORT=6379
REDIS_CACHE_DB=0
# LiveKit Configuration
LIVEKIT_URL=ws://livekit:7880
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=devkey_secret_32chars_minimum_length
# Caddy Domain Configuration (set your domain for automatic HTTPS)
DOMAIN=${SERVER_HOST:-localhost}
# App Configuration
APP_ENV=production
DEBUG=false
# Add your production API keys here:
# OPENAI_API_KEY=your-openai-api-key
# DEEPGRAM_API_KEY=your-deepgram-api-key
# CARTESIA_API_KEY=your-cartesia-api-key
# ELEVENLABS_API_KEY=your-elevenlabs-api-key
# S3 Storage Configuration (optional)
# S3_ENDPOINT_URL=https://s3.storage.selcloud.ru
# S3_ACCESS_KEY_ID=your_s3_access_key
# S3_SECRET_ACCESS_KEY=your_s3_secret_key
# S3_BUCKET_NAME=your-bucket-name
# S3_REGION=ru-1
# Milvus Vector Database Configuration (optional)
# MILVUS_URI=http://milvus:19530
# MILVUS_COLLECTION=hr_candidate_profiles
EOF
"
# Create production docker compose override
echo -e "${BLUE}Creating production docker compose configuration...${NC}"
ssh -p "${SERVER_PORT}" "${SERVER_USER}@${SERVER_HOST}" "
cat > ${DEPLOY_PATH}/docker-compose.prod.yml << 'EOF'
services:
backend:
image: ${FULL_IMAGE_NAME}
env_file:
- .env
restart: unless-stopped
volumes:
- ./agent_commands:/tmp/agent_commands
- ./data/uploads:/app/uploads
- ./logs:/app/logs
postgres:
restart: unless-stopped
volumes:
- ./data/postgres:/var/lib/postgresql/data
redis:
restart: unless-stopped
volumes:
- ./data/redis:/data
livekit:
restart: unless-stopped
ports:
- \"3478:3478/udp\"
caddy:
env_file:
- .env
restart: unless-stopped
volumes:
- ./data/caddy_data:/data
- ./data/caddy_config:/config
- ./logs/caddy:/var/log/caddy
frontend:
restart: unless-stopped
EOF
"
# Pull latest image and deploy
echo -e "${BLUE}Pulling latest image and starting services...${NC}"
ssh -p "${SERVER_PORT}" "${SERVER_USER}@${SERVER_HOST}" "
cd ${DEPLOY_PATH}
# Configure Docker for Yandex Cloud Registry
echo 'Configuring Docker for Yandex Cloud Registry...'
# Completely reset Docker config to fix credential helper issues
echo 'Resetting Docker configuration...'
mkdir -p ~/.docker
cat > ~/.docker/config.json << 'DOCKER_CONFIG'
{
\"auths\": {},
\"HttpHeaders\": {
\"User-Agent\": \"Docker-Client/20.10.0 (linux)\"
}
}
DOCKER_CONFIG
# Install yc CLI if not found
if ! command -v yc &> /dev/null; then
echo 'Installing Yandex Cloud CLI...'
curl -sSL https://storage.yandexcloud.net/yandexcloud-yc/install.sh | bash
source ~/.bashrc || source ~/.bash_profile || export PATH=\"\$HOME/yandex-cloud/bin:\$PATH\"
fi
# Use manual login instead of yc configure-docker
if command -v yc &> /dev/null; then
echo 'Getting Yandex Cloud token and logging in manually...'
YC_TOKEN=\$(yc iam create-token 2>/dev/null)
if [ ! -z \"\$YC_TOKEN\" ]; then
echo \"\$YC_TOKEN\" | docker login --username oauth --password-stdin cr.yandex
echo 'Docker login successful'
else
echo 'Error: Could not get YC token. Please run: yc init'
echo 'You need to authenticate yc CLI first on the server'
exit 1
fi
else
echo 'Error: yc CLI installation failed'
exit 1
fi
echo 'Current Docker config:'
cat ~/.docker/config.json
# Pull only our custom images from Yandex Registry first
echo 'Pulling custom images from Yandex Registry...'
docker pull ${FULL_IMAGE_NAME} || echo 'Failed to pull backend image'
docker pull cr.yandex/crp9p5rtbnbop36duusi/hr-ai-frontend:latest || echo 'Failed to pull frontend image'
# Reset Docker config to default for pulling public images
echo 'Resetting Docker config for public images...'
cat > ~/.docker/config.json << 'DOCKER_CONFIG'
{
\"auths\": {}
}
DOCKER_CONFIG
# Stop old containers
echo 'Stopping existing services...'
docker compose -f docker-compose.yml -f docker-compose.prod.yml down --remove-orphans
# Start new containers
echo 'Starting services with new image...'
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
# Wait for services to start
echo 'Waiting for services to start...'
sleep 10
# Run database migrations
echo 'Running database migrations...'
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec -T backend uv run alembic upgrade head || echo 'Migration failed or already up to date'
# Show service status
echo 'Service status:'
docker compose -f docker-compose.yml -f docker-compose.prod.yml ps
"
# Health check
echo -e "${BLUE}Performing health check...${NC}"
sleep 20
if ssh -p "${SERVER_PORT}" "${SERVER_USER}@${SERVER_HOST}" "curl -f http://localhost/health" >/dev/null 2>&1; then
echo -e "${GREEN}✓ Deployment successful! Service is healthy.${NC}"
else
echo -e "${YELLOW}⚠ Service deployed but health check failed. Check logs:${NC}"
echo "ssh -p ${SERVER_PORT} ${SERVER_USER}@${SERVER_HOST} 'cd ${DEPLOY_PATH} && docker compose logs backend caddy'"
fi
echo -e "${GREEN}Deployment completed!${NC}"
echo ""
echo "Service URLs:"
if [ "\$DOMAIN" != "localhost" ]; then
echo " Main site: https://${SERVER_HOST}"
echo " API: https://${SERVER_HOST}/api"
echo " LiveKit: https://${SERVER_HOST}/livekit"
else
echo " Main site: http://${SERVER_HOST}"
echo " API: http://${SERVER_HOST}/api"
echo " LiveKit: http://${SERVER_HOST}/livekit"
fi
echo ""
echo "Useful commands:"
echo " Check logs: ssh ${SERVER_USER}@${SERVER_HOST} 'cd ${DEPLOY_PATH} && docker compose logs -f'"
echo " Service status: ssh ${SERVER_USER}@${SERVER_HOST} 'cd ${DEPLOY_PATH} && docker compose ps'"
echo " Restart: ssh ${SERVER_USER}@${SERVER_HOST} 'cd ${DEPLOY_PATH} && docker compose restart'"

Binary file not shown.

BIN
static/fonts/DejaVuSans.ttf Normal file

Binary file not shown.

View File

@ -0,0 +1,634 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Отчет по собеседованию {{ position }} | Анализ кандидата {{ candidate_name }} | HR Система</title>
<meta name="description" content="Детальный отчет по собеседованию {{ position }} кандидата {{ candidate_name }} с оценкой технических навыков, опыта и рекомендациями по найму">
<meta name="keywords" content="отчет собеседование, оценка кандидата, hr система">
<style>
/* Reset and base styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
background-color: #ffffff;
color: #05000c;
line-height: 1.5;
padding: 16px;
}
/* Layout components */
.main-container {
display: flex;
flex-direction: column;
gap: 28px;
justify-content: flex-start;
align-items: center;
width: 100%;
margin: 0 auto;
}
.content-wrapper {
display: flex;
flex-direction: column;
gap: 64px;
justify-content: flex-start;
align-items: center;
width: 100%;
}
.header-section {
display: flex;
flex-direction: column;
gap: 8px;
justify-content: flex-start;
align-items: flex-start;
width: 100%;
}
.info-analysis-row {
display: flex;
flex-direction: column;
gap: 24px;
justify-content: flex-start;
align-items: flex-start;
width: 100%;
}
.basic-info-section {
display: flex;
flex-direction: column;
gap: 12px;
justify-content: flex-start;
align-items: center;
width: 100%;
}
.analysis-section {
display: flex;
flex-direction: column;
gap: 12px;
justify-content: flex-start;
align-items: center;
width: 100%;
}
.info-grid {
display: flex;
flex-direction: column;
gap: 12px;
justify-content: space-between;
align-items: center;
width: 100%;
}
.info-labels {
display: flex;
flex-direction: column;
gap: 12px;
justify-content: flex-start;
align-items: flex-start;
width: 100%;
}
.info-values {
display: flex;
flex-direction: column;
gap: 12px;
justify-content: flex-start;
align-items: flex-start;
width: 100%;
}
.analysis-grid {
display: flex;
flex-direction: column;
gap: 8px;
justify-content: flex-start;
align-items: flex-start;
width: 100%;
}
.analysis-labels {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: left;
width: 100%;
}
.analysis-content {
display: flex;
flex-direction: column;
gap: 12px;
justify-content: flex-start;
align-items: left;
width: 100%;
}
/* Typography */
.report-title {
font-size: 16px;
font-family: Arial, sans-serif;
font-weight: 400;
line-height: 23px;
text-align: left;
color: #05000c;
}
.candidate-name {
font-size: 32px;
font-family: Arial, sans-serif;
font-weight: 400;
line-height: 40px;
text-align: left;
color: #05000c;
}
.section-title {
font-size: 18px;
font-family: Arial, sans-serif;
font-weight: 400;
line-height: 23px;
text-align: left;
color: #05000c;
padding: 12px;
width: 100%;
background-color: #f8f9fa;
border-radius: 8px;
}
.label-text {
font-size: 14px;
font-family: Arial, sans-serif;
font-weight: 400;
line-height: 19px;
text-align: left;
color: #95a7be;
}
.value-text {
font-size: 14px;
font-family: Arial, sans-serif;
font-weight: 400;
line-height: 19px;
text-align: left;
color: #05000c;
}
.value-text-bold {
font-size: 14px;
font-family: Arial, sans-serif;
font-weight: 700;
line-height: 19px;
text-align: left;
color: #05000c;
}
.content-text {
font-size: 14px;
font-family: Arial, sans-serif;
font-weight: 400;
line-height: 22px;
text-align: left;
color: #05000c;
white-space: pre-line;
}
.resume-link {
font-size: 14px;
font-family: Arial, sans-serif;
font-weight: 400;
line-height: 19px;
text-align: left;
color: #96a7be;
text-decoration: underline;
word-break: break-all;
}
.resume-label {
font-size: 14px;
font-family: Arial, sans-serif;
font-weight: 400;
line-height: 19px;
text-align: left;
color: #96a7be;
}
/* Interactive components */
.score-badge {
font-size: 14px;
font-family: Arial, sans-serif;
font-weight: 700;
line-height: 19px;
text-align: left;
color: #ffffff;
background-color: #26cd58;
border-radius: 10px;
padding: 2px 16px;
display: inline-block;
}
.recommend-button {
font-size: 14px;
font-family: Arial, sans-serif;
font-weight: 700;
line-height: 19px;
text-align: center;
color: #ffffff;
background-color: #26cd58;
border-radius: 10px;
padding: 2px 34px;
border: none;
cursor: pointer;
width: 100%;
transition: all 0.3s ease;
}
.consider-button {
font-size: 14px;
font-family: Arial, sans-serif;
font-weight: 700;
line-height: 19px;
text-align: center;
color: #ffffff;
background-color: #faa61a;
border-radius: 10px;
padding: 2px 34px;
border: none;
cursor: pointer;
width: 100%;
}
.reject-button {
font-size: 14px;
font-family: Arial, sans-serif;
font-weight: 700;
line-height: 19px;
text-align: center;
color: #ffffff;
background-color: #ff3d40;
border-radius: 10px;
padding: 2px 34px;
border: none;
cursor: pointer;
width: 100%;
}
/* Table styles */
.evaluation-table {
width: 100%;
border-collapse: collapse;
margin-top: 16px;
}
.table-header {
background-color: #05000c;
border-radius: 12px 12px 0px 0px;
}
.table-header th {
font-size: 12px;
font-family: Arial, sans-serif;
font-weight: 700;
line-height: 17px;
text-align: left;
color: #ffffff;
padding: 8px 16px;
}
.table-row {
border-bottom: 1px solid #e5e7eb;
}
.table-cell {
font-size: 14px;
font-family: Arial, sans-serif;
font-weight: 400;
line-height: 22px;
text-align: left;
color: #05000c;
padding: 12px;
vertical-align: top;
}
.score-button {
font-size: 14px;
font-family: Arial, sans-serif;
font-weight: 700;
line-height: 19px;
text-align: center;
color: #ffffff;
border-radius: 10px;
padding: 6px 16px;
border: none;
width: 100%;
max-width: 80px;
}
.score-green {
background-color: #26cd58;
}
.score-orange {
background-color: #faa61a;
}
.score-red {
background-color: #ff3d40;
}
/* Red flags section */
.red-flags-section {
display: flex;
flex-direction: column;
gap: 12px;
justify-content: flex-start;
align-items: flex-start;
width: 100%;
margin-top: 48px;
}
.red-flags-list {
display: flex;
flex-direction: column;
gap: 12px;
justify-content: flex-start;
align-items: center;
width: 100%;
}
.flag-item {
display: flex;
flex-direction: row;
gap: 12px;
justify-content: flex-start;
align-items: flex-start;
width: 100%;
padding: 12px 0;
border-top: 1px solid #e5e7eb;
}
.flag-item:first-child {
border-top: 0px solid #e5e7eb;
}
.flag-item:nth-last-child(1) {
border-bottom: 0px solid #e5e7eb;
}
.flag-icon {
width: 24px;
height: 24px;
flex-shrink: 0;
}
.flag-text {
font-size: 14px;
font-family: Arial, sans-serif;
font-weight: 400;
line-height: 22px;
text-align: left;
color: #05000c;
flex: 1;
}
.footer-text {
font-size: 14px;
font-family: Arial, sans-serif;
font-weight: 400;
line-height: 19px;
text-align: left;
color: #96a7be;
margin-top: 118px;
}
.divider-line {
width: 100%;
height: 1px;
background-color: #b1bac6;
}
/* Responsive media queries */
@media (min-width: 640px) {
body {
padding: 24px;
}
.report-title {
font-size: 18px;
}
.candidate-name {
font-size: 40px;
line-height: 48px;
}
.section-title {
font-size: 20px;
}
.label-text, .value-text, .value-text-bold, .resume-link, .resume-label {
font-size: 15px;
}
.content-text, .flag-text {
font-size: 15px;
}
.info-analysis-row {
flex-direction: row;
gap: 32px;
}
.basic-info-section {
width: 45%;
}
.analysis-section {
width: 55%;
}
.info-grid {
flex-direction: row;
}
.info-labels {
width: 40%;
}
.info-values {
width: 60%;
}
.analysis-grid {
flex-direction: row;
}
.analysis-labels {
width: 25%;
gap: 60px;
}
.analysis-content {
width: 75%;
}
}
@media (min-width: 768px) {
body {
padding: 32px;
}
.candidate-name {
font-size: 48px;
line-height: 56px;
}
.label-text, .value-text, .value-text-bold, .resume-link, .resume-label {
font-size: 16px;
}
.content-text, .flag-text {
font-size: 16px;
}
.table-header th {
font-size: 14px;
}
.score-button {
font-size: 16px;
}
}
@media (min-width: 1024px) {
.info-analysis-row {
gap: 40px;
}
.basic-info-section {
width: 34%;
}
.analysis-section {
width: 66%;
}
.analysis-labels {
width: 18%;
gap: 78px;
}
.analysis-content {
width: 82%;
}
}
</style>
</head>
<body>
<main class="main-container">
<div class="content-wrapper">
<header class="header-section">
<h1 class="report-title">Отчет по собеседованию {{ report_id }}</h1>
<h2 class="candidate-name">{{ candidate_name }}</h2>
<div style="display: flex; flex-direction: row; gap: 8px; align-items: center; width: 100%;">
<span class="resume-label">Резюме:</span>
<a href="{{ resume_url }}" class="resume-link" target="_blank" rel="noopener noreferrer">{{ resume_url }}</a>
</div>
</header>
<section class="info-analysis-row">
<div class="basic-info-section">
<div class="section-title">Основная информация</div>
<div class="info-grid">
<div class="info-labels">
<span class="label-text">Кандидат:</span>
<span class="label-text">Позиция:</span>
<span class="label-text">Дата интервью:</span>
<span class="label-text">Общий балл:</span>
<span class="label-text">Рекомендация:</span>
</div>
<div class="info-values">
<span class="value-text-bold">{{ candidate_name }}</span>
<span class="value-text">{{ position }}</span>
<span class="value-text">{{ interview_date }}</span>
<span class="score-badge">{{ overall_score }}</span>
<button class="{{ recommendation_class }}">{{ recommendation_text }}</button>
</div>
</div>
</div>
<div class="analysis-section">
<div class="section-title">Анализ кандидата</div>
<div class="analysis-grid">
<div class="analysis-labels">
<span class="label-text">Сильные стороны:</span>
<span class="label-text">Области для развития:</span>
</div>
<div class="analysis-content">
<p class="content-text">{{ strengths }}</p>
<p class="content-text">{{ areas_for_development }}</p>
</div>
</div>
</div>
</section>
</div>
<section style="display: flex; flex-direction: column; justify-content: flex-start; align-items: flex-start; width: 100%; margin-bottom: 40px;">
<div style="display: flex; flex-direction: column; gap: 16px; justify-content: flex-start; align-items: flex-start; width: 100%;">
<h3 class="section-title" style="background: none; padding: 0;">Детальная оценка</h3>
{% if evaluation_criteria %}
<table class="evaluation-table">
<thead class="table-header">
<tr>
<th style="width: 28%;">Критерий</th>
<th style="width: 10%;">Балл</th>
<th style="width: 40%;">Обоснование</th>
<th style="width: 22%;">Риски</th>
</tr>
</thead>
<tbody>
{% for criterion in evaluation_criteria %}
<tr class="table-row">
<td class="table-cell">{{ criterion.name }}</td>
<td class="table-cell">
<button class="score-button {{ criterion.score_class }}">{{ criterion.score }}/100</button>
</td>
<td class="table-cell">{{ criterion.justification }}</td>
<td class="table-cell">{{ criterion.concerns }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
{% if red_flags %}
<div class="red-flags-section">
<div class="section-title">Красные флаги:</div>
<div class="red-flags-list">
{% for flag in red_flags %}
<div class="flag-item">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 5.00014C4.00002 4.86716 4.02656 4.73552 4.07807 4.61292C4.12959 4.49032 4.20503 4.37923 4.3 4.28614C5.38984 3.21778 6.84585 2.60488 8.37166 2.57218C9.89746 2.53948 11.3784 3.08945 12.513 4.11014L12.864 4.43815C13.5934 5.07724 14.5302 5.42957 15.5 5.42957C16.4698 5.42957 17.4066 5.07724 18.136 4.43815L18.385 4.21114C18.995 3.72814 19.912 4.11414 19.995 4.88714L20 5.00014V14.0001C20 14.1331 19.9734 14.2648 19.9219 14.3874C19.8704 14.51 19.795 14.6211 19.7 14.7141C18.6102 15.7825 17.1542 16.3954 15.6283 16.4281C14.1025 16.4608 12.6216 15.9108 11.487 14.8901L11.136 14.5621C10.4295 13.9431 9.52777 13.5925 8.58872 13.5717C7.64967 13.5508 6.73323 13.8611 6 14.4481V21.0001C5.99972 21.255 5.90212 21.5002 5.72715 21.6855C5.55218 21.8708 5.31305 21.9824 5.05861 21.9973C4.80416 22.0123 4.55362 21.9295 4.35817 21.7659C4.16271 21.6023 4.0371 21.3702 4.007 21.1171L4 21.0001V5.00014Z" fill="#FF3E41"/>
</svg>
<p class="flag-text">{{ flag }}</p>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<p class="footer-text">Отчет сгенерирован автоматически • {{ generation_date }}</p>
</section>
</main>
</body>
</html>

4350
uv.lock

File diff suppressed because it is too large Load Diff