You've already forked RekomenciBackend
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de2707bf5a | |||
|
e375ccc208
|
|||
|
8f62c775e4
|
|||
|
f281a764b0
|
|||
| 0e41a1210c | |||
| 11d9feb888 | |||
| 875b330a98 | |||
|
dd940f25af
|
|||
|
2988f7c3b6
|
|||
| 8d10294e1f | |||
|
5a9997c6f0
|
|||
|
a0a7e01f1c
|
|||
|
01e1f7425c
|
|||
| 51087fa3b8 | |||
| da4c8f486d | |||
|
d00b88448f
|
|||
|
afd2337eaf
|
|||
|
6474d97be2
|
|||
| b15282baef | |||
|
a879da4ed5
|
|||
| fb8dd9accc | |||
| fbceb9fefe | |||
| 25b35a3ccd | |||
|
a9ea9c65d6
|
|||
| c60a7d5973 | |||
| f07ef3ce60 | |||
| c2fe8a9d83 | |||
| 9060d23cba | |||
| f92e3d3372 | |||
| d1c7641698 | |||
| 2e6214a5ec | |||
| 9e7515631e | |||
| ac32e89ada | |||
| 040fc90c59 | |||
| 96e792b122 | |||
| 4b66c2f6be | |||
| 7f91b412b8 | |||
|
2887b0a1e0
|
|||
|
b0f65dd828
|
|||
|
866386859f
|
|||
|
6ca6c12401
|
|||
| e1f3ce6bcd | |||
| a65904cbf2 | |||
|
b521f4c0bf
|
|||
|
d5bf1f7a68
|
|||
| bbbb9fc493 | |||
| 9988338d97 | |||
|
d7cff23205
|
|||
|
d2feee8eb4
|
|||
|
420823f188
|
|||
| c868edebc9 | |||
| effbcfbc2d |
@@ -3,6 +3,9 @@ config.toml
|
|||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
.idea
|
.idea
|
||||||
firebase.json
|
firebase.json
|
||||||
|
dumps
|
||||||
|
full_skills_unique.json
|
||||||
|
filtered_vacancies.csv
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
+47
-44
@@ -6,6 +6,9 @@ stages:
|
|||||||
- tag
|
- tag
|
||||||
- deploy
|
- deploy
|
||||||
|
|
||||||
|
default:
|
||||||
|
retry: 2
|
||||||
|
|
||||||
variables:
|
variables:
|
||||||
BASE_IMAGE_NAME: $CI_REGISTRY_IMAGE
|
BASE_IMAGE_NAME: $CI_REGISTRY_IMAGE
|
||||||
TRIVY_CACHE_DIR: .cache/trivy
|
TRIVY_CACHE_DIR: .cache/trivy
|
||||||
@@ -18,6 +21,8 @@ variables:
|
|||||||
UV_CACHE_DIR: .cache/uv
|
UV_CACHE_DIR: .cache/uv
|
||||||
BUILDAH_ISOLATION: oci
|
BUILDAH_ISOLATION: oci
|
||||||
STORAGE_DRIVER: vfs
|
STORAGE_DRIVER: vfs
|
||||||
|
DOCKER_HOST: "tcp://docker:2375"
|
||||||
|
DOCKER_TLS_CERTDIR: ""
|
||||||
|
|
||||||
cache:
|
cache:
|
||||||
key: "${CI_COMMIT_REF_SLUG}"
|
key: "${CI_COMMIT_REF_SLUG}"
|
||||||
@@ -27,6 +32,13 @@ cache:
|
|||||||
- $UV_PROJECT_ENVIRONMENT
|
- $UV_PROJECT_ENVIRONMENT
|
||||||
policy: pull-push
|
policy: pull-push
|
||||||
|
|
||||||
|
.docker-job: &docker-job
|
||||||
|
image: docker:28.5
|
||||||
|
services:
|
||||||
|
- docker:28.5-dind
|
||||||
|
before_script:
|
||||||
|
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
|
||||||
|
|
||||||
.buildah-job: &buildah-job
|
.buildah-job: &buildah-job
|
||||||
image: quay.io/containers/buildah:latest
|
image: quay.io/containers/buildah:latest
|
||||||
variables:
|
variables:
|
||||||
@@ -225,6 +237,7 @@ build-migrations:
|
|||||||
|
|
||||||
build-ml:
|
build-ml:
|
||||||
<<: *build-config
|
<<: *build-config
|
||||||
|
when: manual
|
||||||
variables:
|
variables:
|
||||||
IMAGE_NAME: $BASE_IMAGE_NAME/ml
|
IMAGE_NAME: $BASE_IMAGE_NAME/ml
|
||||||
CONTAINERFILE: Containerfile
|
CONTAINERFILE: Containerfile
|
||||||
@@ -245,64 +258,44 @@ lint:
|
|||||||
- if: $CI_COMMIT_TAG
|
- if: $CI_COMMIT_TAG
|
||||||
|
|
||||||
test:
|
test:
|
||||||
<<: *buildah-job
|
<<: *docker-job
|
||||||
stage: test
|
stage: test
|
||||||
|
tags:
|
||||||
|
- beta
|
||||||
variables:
|
variables:
|
||||||
COMPOSE_PROFILES: |
|
COMPOSE_PROFILES: |
|
||||||
--profile migrations
|
--profile migrations
|
||||||
--profile tests
|
--profile tests
|
||||||
PODMAN_IGNORE_CGROUPSV1_WARNING: True
|
--profile ml
|
||||||
tags:
|
|
||||||
- self-hosted
|
|
||||||
script:
|
script:
|
||||||
- dnf -y install podman podman-compose
|
- apk add --no-cache docker-compose
|
||||||
# - cp ./infrastructure/configs/podman/ci.conf /etc/containers/containers.conf
|
|
||||||
- cp "$TEST_STAGE_FIREBASE_CONF" ./infrastructure/configs/backend/firebase.json
|
|
||||||
- podman info --format '{{.Host.EventLogger}}'
|
|
||||||
- export PROFILES="$(printf '%s ' $COMPOSE_PROFILES)"
|
- export PROFILES="$(printf '%s ' $COMPOSE_PROFILES)"
|
||||||
|
- cp "$TEST_STAGE_FIREBASE_CONF" ./infrastructure/configs/backend/firebase.json
|
||||||
- |
|
- |
|
||||||
(
|
(
|
||||||
while true; do
|
while true; do
|
||||||
podman-compose -f compose.yaml $PROFILES logs 2>&1
|
docker compose -f compose.yaml $PROFILES logs -f 2>&1
|
||||||
sleep 30
|
sleep 1
|
||||||
done
|
done
|
||||||
) | grep "Error: no container" -v | tee -a compose.log &
|
) | tee -a compose.log &
|
||||||
- LOGS_PID=$!
|
- LOGS_PID=$!
|
||||||
- |
|
|
||||||
(
|
|
||||||
while true; do
|
|
||||||
echo "Containers $(date)"
|
|
||||||
podman ps -a
|
|
||||||
sleep 10
|
|
||||||
done
|
|
||||||
) &
|
|
||||||
- |
|
- |
|
||||||
REGISTRY_PREFIX=$CI_REGISTRY_IMAGE IMAGE_TAG=$CI_COMMIT_SHA \
|
REGISTRY_PREFIX=$CI_REGISTRY_IMAGE IMAGE_TAG=$CI_COMMIT_SHA \
|
||||||
podman-compose -f compose.yaml -f compose.prod.yaml \
|
docker compose -f compose.yaml -f compose.prod.yaml \
|
||||||
$PROFILES up -d --no-build --pull 2>&1 | tee compose.log
|
$PROFILES up -d --quiet-pull --quiet-build 2>&1 | tee compose.log
|
||||||
|
- |
|
||||||
|
TEST_CONTAINER_ID=$(docker compose -f compose.yaml $PROFILES ps -q tests -a)
|
||||||
|
timeout 600 docker wait $TEST_CONTAINER_ID
|
||||||
|
TEST_EXIT_CODE=$(docker inspect --format "{{.State.ExitCode}}" $TEST_CONTAINER_ID)
|
||||||
|
|
||||||
TEST_CONTAINER_ID=$(
|
if [ $TEST_EXIT_CODE -eq 0 ]; then
|
||||||
podman-compose ps --all --format json \
|
|
||||||
| jq -r '.[] | select(.Labels["io.podman.compose.service"] == "tests") | .Id'
|
|
||||||
)
|
|
||||||
|
|
||||||
if [ -z "$TEST_CONTAINER_ID" ]; then
|
|
||||||
echo "Tests container not found."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
timeout 600 podman wait "$TEST_CONTAINER_ID"
|
|
||||||
TEST_EXIT_CODE=$(podman inspect --format "{{.State.ExitCode}}" "$TEST_CONTAINER_ID")
|
|
||||||
|
|
||||||
if [ "$TEST_EXIT_CODE" -eq 0 ]; then
|
|
||||||
echo "Tests passed."
|
echo "Tests passed."
|
||||||
else
|
else
|
||||||
echo "Tests failed with exit code $TEST_EXIT_CODE."
|
echo "Tests failed with exit code $TEST_EXIT_CODE."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
- |
|
- |
|
||||||
podman-compose -f compose.yaml $PROFILES down
|
docker compose -f compose.yaml $PROFILES down
|
||||||
- cat .cov/coverage.txt
|
- cat .cov/coverage.txt
|
||||||
artifacts:
|
artifacts:
|
||||||
paths:
|
paths:
|
||||||
@@ -350,13 +343,13 @@ sast-image-migrations:
|
|||||||
dependencies:
|
dependencies:
|
||||||
- build-migrations
|
- build-migrations
|
||||||
|
|
||||||
sast-image-ml:
|
# sast-image-ml:
|
||||||
<<: *trivy-image-scan
|
# <<: *trivy-image-scan
|
||||||
variables:
|
# variables:
|
||||||
IMAGE_NAME: $BASE_IMAGE_NAME/ml
|
# IMAGE_NAME: $BASE_IMAGE_NAME/ml
|
||||||
IMAGE_TYPE: ml
|
# IMAGE_TYPE: ml
|
||||||
dependencies:
|
# dependencies:
|
||||||
- build-ml
|
# - build-ml
|
||||||
|
|
||||||
tag-runtime:
|
tag-runtime:
|
||||||
<<: *tag-config
|
<<: *tag-config
|
||||||
@@ -401,6 +394,16 @@ webhook-backend-deploy:
|
|||||||
- build-runtime
|
- build-runtime
|
||||||
- sast-image-runtime
|
- sast-image-runtime
|
||||||
|
|
||||||
|
webhook-ml-deploy:
|
||||||
|
<<: *webhook-config
|
||||||
|
stage: deploy
|
||||||
|
variables:
|
||||||
|
WEBHOOK_URL: $WEBHOOK_URL_ML
|
||||||
|
resource_group: staging
|
||||||
|
dependencies:
|
||||||
|
- build-ml
|
||||||
|
# - sast-image-ml
|
||||||
|
|
||||||
workflow:
|
workflow:
|
||||||
rules:
|
rules:
|
||||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 953 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 421 KiB |
+4
-7
@@ -16,7 +16,6 @@ WORKDIR /app
|
|||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
PYTHONOPTIMIZE=2 \
|
|
||||||
UV_COMPILE_BYTECODE=1 \
|
UV_COMPILE_BYTECODE=1 \
|
||||||
UV_LINK_MODE=copy \
|
UV_LINK_MODE=copy \
|
||||||
UV_PROJECT_ENVIRONMENT=/opt/venv
|
UV_PROJECT_ENVIRONMENT=/opt/venv
|
||||||
@@ -37,6 +36,8 @@ COPY ./src ./src
|
|||||||
|
|
||||||
RUN uv sync --frozen --no-dev --no-cache --group ml
|
RUN uv sync --frozen --no-dev --no-cache --group ml
|
||||||
|
|
||||||
|
RUN uv add prometheus-fastapi-instrumentator
|
||||||
|
|
||||||
|
|
||||||
# Stage 4: Backend Runtime
|
# Stage 4: Backend Runtime
|
||||||
FROM ${PY_IMAGE} AS runtime
|
FROM ${PY_IMAGE} AS runtime
|
||||||
@@ -48,7 +49,6 @@ RUN apt-get update && \
|
|||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
PYTHONOPTIMIZE=2 \
|
|
||||||
PATH="/opt/venv/bin:$PATH" \
|
PATH="/opt/venv/bin:$PATH" \
|
||||||
PYTHONPATH="/app:$PYTHONPATH"
|
PYTHONPATH="/app:$PYTHONPATH"
|
||||||
|
|
||||||
@@ -73,7 +73,6 @@ RUN apt-get update && \
|
|||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
PYTHONOPTIMIZE=2 \
|
|
||||||
PATH="/opt/venv/bin:$PATH" \
|
PATH="/opt/venv/bin:$PATH" \
|
||||||
PYTHONPATH="/app:$PYTHONPATH"
|
PYTHONPATH="/app:$PYTHONPATH"
|
||||||
|
|
||||||
@@ -93,7 +92,6 @@ FROM base-builder AS tests
|
|||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
PYTHONOPTIMIZE=2 \
|
|
||||||
PATH="/opt/venv/bin:$PATH" \
|
PATH="/opt/venv/bin:$PATH" \
|
||||||
PYTHONPATH="/app:$PYTHONPATH"
|
PYTHONPATH="/app:$PYTHONPATH"
|
||||||
|
|
||||||
@@ -103,7 +101,7 @@ COPY ./src ./src
|
|||||||
|
|
||||||
COPY ./tests ./tests
|
COPY ./tests ./tests
|
||||||
|
|
||||||
RUN uv sync --group backend --group tests --frozen --no-cache
|
RUN uv sync --group backend --group tests --frozen --no-cache --no-dev
|
||||||
|
|
||||||
RUN mkdir -p /app/cov && mkdir /app/cov/html
|
RUN mkdir -p /app/cov && mkdir /app/cov/html
|
||||||
|
|
||||||
@@ -115,7 +113,6 @@ FROM base-builder AS migrations
|
|||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
PYTHONOPTIMIZE=2 \
|
|
||||||
PATH="/opt/venv/bin:$PATH" \
|
PATH="/opt/venv/bin:$PATH" \
|
||||||
PYTHONPATH="/app:$PYTHONPATH"
|
PYTHONPATH="/app:$PYTHONPATH"
|
||||||
|
|
||||||
@@ -129,6 +126,6 @@ COPY ./tests ./tests
|
|||||||
|
|
||||||
COPY ./alembic.ini ./
|
COPY ./alembic.ini ./
|
||||||
|
|
||||||
RUN uv sync --group backend --group migrations --frozen --no-cache
|
RUN uv sync --group backend --group migrations --frozen --no-cache --no-dev
|
||||||
|
|
||||||
CMD [ "alembic", "upgrade", "head" ]
|
CMD [ "alembic", "upgrade", "head" ]
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Rekomenci fluon *(рэкоменси флюйон)*
|
||||||
|
|
||||||
|
## Креды
|
||||||
|
|
||||||
|
### Coolify (https://paas.hackaton.itqdev.xyz)
|
||||||
|
login: `expert@tbank.ru`
|
||||||
|
password: `#lt5aEEiab^JgBd`
|
||||||
|
|
||||||
|
### Grafana (https://grafana.hackaton.itqdev.xyz)
|
||||||
|
login: `admin`
|
||||||
|
password: `rFZVf9pCELm9fWqJ724pLMgNrjyInel3`
|
||||||
|
|
||||||
|
### Cadvisor (https://cadvisor.hackaton.itqdev.xyz)
|
||||||
|
|
||||||
|
### Prometheus (https://prometheus.hackaton.itqdev.xyz)
|
||||||
|
|
||||||
|
## **Кейс — сколько ты зарабатываешь?**
|
||||||
|
|
||||||
|
## Для продактов
|
||||||
|
|
||||||
|
### Проблема
|
||||||
|
В современных реалях человек не особо умеет составлять резюме, особоенно, когда он не имеет опыта работы.
|
||||||
|
|
||||||
|
Часто такие резюме плохо написаны, в них мало конкретики, нет нужных навыков или описано не явно.
|
||||||
|
|
||||||
|
Отсюда вытекает проблема о не знании своей ЗП и из за плохо составленного резюме клиент теряет размер ЗП.
|
||||||
|
|
||||||
|
|
||||||
|
### Решение
|
||||||
|
Решением является продукт, который на основне резюме пользователя проанализирует рыночные вакансии и выдаст его ЗП и рекомендации по изменению резюме для увеличения своего ЗП.
|
||||||
|
|
||||||
|
Наш продукт был разработан с целью решить данную проблему. Пользователь добавляет свою вакансию и на основне многочисленного датасета вакансий выдает его ЗП и предлагает рекомендации по улучшению.
|
||||||
|
|
||||||
|
### Ценность продукта
|
||||||
|
|
||||||
|
|
||||||
|
## Для разработчиков
|
||||||
|
|
||||||
|
[RFC проекта](RFC.md)
|
||||||
|
|
||||||
|
### Быстрый старт
|
||||||
|
|
||||||
|
1. Установка зависимостей
|
||||||
|
```shell
|
||||||
|
uv sync --group dev
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Запуск линтеров
|
||||||
|
```shell
|
||||||
|
just lint
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Запуск проекта
|
||||||
|
```shell
|
||||||
|
just build
|
||||||
|
just up
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Создание и применение миграций
|
||||||
|
```shell
|
||||||
|
just migrations-make "message"
|
||||||
|
just migrations-run
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Стек
|
||||||
|
|
||||||
|
#### Backend
|
||||||
|
|
||||||
|
+ **fastapi** - http server
|
||||||
|
+ **dishka** - IoC container
|
||||||
|
+ **sqlalchemy** - ORM и query builder
|
||||||
|
+ **adaptix** и **pydantic** - для моделей
|
||||||
|
+ **postgresql** - база данных
|
||||||
|
|
||||||
|
#### ML
|
||||||
|
|
||||||
|
+ **torch** - для машинного обучения
|
||||||
|
+ **sentence-transformers** - создание эмбеддингов
|
||||||
|
|
||||||
|
#### Linters
|
||||||
|
+ **mypy strict** - статический анализатор типов
|
||||||
|
+ **ruff** - статический линтер и форматтер кода
|
||||||
|
+ **codespell** - сыщик опечаток
|
||||||
|
+ **bandit** - сыщик уязвимостей
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Rekomenci fluon RFC
|
||||||
|
|
||||||
|
## Архитектура. *(Всё как завещал дядюшка Боб...)*
|
||||||
|
Проект следует чистой архитектуре дядюшки Боба.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
# RFC-PROOD: Архитектура Recomenci Fluon
|
||||||
|
|
||||||
|
Статус: PROOOD
|
||||||
|
Дата: 23 ноября 2025
|
||||||
|
Автор: prod 39
|
||||||
|
|
||||||
|
## 1. Краткое описание
|
||||||
|
### Цель.
|
||||||
|
Описать целевую архитектуру, границы ответственности компонентов, модель разработки и деплоя для проекта рекомендаций и предсказаний зарплат (далее — проект)
|
||||||
|
|
||||||
|
RFC определяет интерфейсы для API, операционные требования и переходный план
|
||||||
|
|
||||||
|
## 2. Мотивация
|
||||||
|
- Упорядочить архитектуру для масштабируемости, тестируемости и повторяемости ML-экспериментов
|
||||||
|
- Обеспечить безопасный и предсказуемый деплой
|
||||||
|
- Определить контракты между web/API, ML-сервисом и хранилищем данных
|
||||||
|
|
||||||
|
|
||||||
|
## 3. Область и не-включения
|
||||||
|
Включено:
|
||||||
|
- Архитектура сервисов (web_api, ml)
|
||||||
|
- Хранилища: PostgreSQL, S3(далее object storage)
|
||||||
|
- CI/CD, контейнеризация и инвентори infra: Docker Compose
|
||||||
|
- Метрики, логирование, мониторинг базовой обработки ошибок
|
||||||
|
|
||||||
|
Не включено:
|
||||||
|
- Подробный дизайн интерфейсов UI
|
||||||
|
- Исследовательские ML-эксперименты (детальные датасеты/фичи) — описан процесс интеграции
|
||||||
|
|
||||||
|
|
||||||
|
## 4. Цели и критерии приёмки
|
||||||
|
Цели:
|
||||||
|
- Стабильный REST/HTTP API для вакансий и предсказаний
|
||||||
|
- Стабильный и масштабируемый REST/HTTP API, утилизирующий ML алгоритмы
|
||||||
|
|
||||||
|
|
||||||
|
## 5. Высокоуровневая архитектура
|
||||||
|
- Web API (web_api): HTTP-сервис, инкапсулирующий бизнес логику. Интегрируется с ML-сервисом
|
||||||
|
- ML сервис (ml): HTTP-сервис, инкапсулирующий ML бизнес логику
|
||||||
|
- PostgreSQL + pgvector - основное хранилище данных
|
||||||
|
- Инфра: Docker Compose для локальной разработки; Coolify для CI/CD
|
||||||
|
|
||||||
|
Коммуникация:
|
||||||
|
- HTTP REST между web_api и ml; опционально gRPC / WebSocket в будущем
|
||||||
|
- Хранение данных в PostgreSQL + pgvector, хранение файловых данных в S3
|
||||||
|
|
||||||
|
Входные/выходные схемы должны быть описаны в формате OpenAPI
|
||||||
|
|
||||||
|
### 5.1. Домены приложения
|
||||||
|
- **Resume**: управление резюме пользователей, история версий, эмбеддинги, предикты зарплаты
|
||||||
|
- **Vacancy**: каталог вакансий с эмбеддингами для поиска похожих
|
||||||
|
- **User**: пользователи, профили, аутентификация
|
||||||
|
- **Auth Identity**: методы аутентификации (email/password)
|
||||||
|
- **Notification Device**: регистрация устройств для уведомлений
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 5.2. Флоу создания резюме и предикта
|
||||||
|
1. Пользователь создает резюме через Gateway (AddResumeInteractor)
|
||||||
|
2. Gateway сохраняет резюме в хранилище и возвращает ответ
|
||||||
|
3. В фоне запускается ResumePredictionInteractor:
|
||||||
|
- Генерирует эмбеддинг резюме через ML Service (модель эмбеддинга)
|
||||||
|
- Сохраняет эмбеддинг в хранилище
|
||||||
|
- Ищет подходящие вакансии по векторному сходству (HNSW индекс, cosine similarity >= 0.5)
|
||||||
|
- Фильтрует и сортирует до 50 наиболее релевантных вакансий
|
||||||
|
- Запрашивает предикт зарплаты и рекомендуемые навыки через ML Service (алгоритм предикта)
|
||||||
|
- Сохраняет предикт в хранилище
|
||||||
|
|
||||||
|
### 5.3. Структура хранилища
|
||||||
|
- **Users**: пользователи, профили
|
||||||
|
- **Resumes**: резюме с версионированием (up_resume_id, down_resume_id)
|
||||||
|
- **Resume Embeddings**: векторные представления резюме (384 измерения)
|
||||||
|
- **Resume Predictions**: предикты зарплаты и рекомендуемые навыки
|
||||||
|
- **Resume Experience/Education/Projects**: опыт, образование, проекты
|
||||||
|
- **Vacancies**: вакансии с зарплатами и требованиями
|
||||||
|
- **Vacancy Embeddings**: векторные представления вакансий (384 измерения)
|
||||||
|
- **Key Skills**: словарь навыков для автокомплита (GIN индекс с pg_trgm для ILIKE поиска)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 7. Деплой и CI/CD
|
||||||
|
- Локально: Docker Compose (just up/build)
|
||||||
|
- Staging/Prod: Coolify
|
||||||
|
- CI pipeline: lint → build images → full tests → push образ → deploy staging
|
||||||
|
- Резервное копирование: pg_dump + object storage snapshot.
|
||||||
|
|
||||||
|
## 8. Миграции данных и схем
|
||||||
|
- Использовать alembic для миграций схем PostgreSQL.
|
||||||
|
- Использовать вспомогательные скрипты для выгрузки датасета в хранилище
|
||||||
|
|
||||||
|
## 9. Безопасность и секреты
|
||||||
|
- Секреты в ENV (environment secrets в CI).
|
||||||
|
- Валидация входящих данных, шифрование конфиденциальных данных
|
||||||
|
|
||||||
|
## 10. Мониторинг и логирование
|
||||||
|
- Логирование через стандартный Python logging
|
||||||
|
- Базовый healthcheck endpoint
|
||||||
|
- Логи можно посмотреть в Coolify (см. креды в Readme.md)
|
||||||
|
- Доступны дашборды в графане с метриками контейнеров, бека, мль
|
||||||
|
|
||||||
|
## 11. Тестирование
|
||||||
|
- Unit tests - тестируют бизнес логику (entities, factories, invariants)
|
||||||
|
- E2E - тестируют весь user flow, а также интеграцию с ml
|
||||||
+9
-7
@@ -39,12 +39,14 @@ services:
|
|||||||
profiles:
|
profiles:
|
||||||
- backend
|
- backend
|
||||||
ports:
|
ports:
|
||||||
- name: web
|
- 8080:8080
|
||||||
target: 8080
|
# ports:
|
||||||
published: 13560
|
# - name: web
|
||||||
host_ip: 127.0.0.1
|
# target: 8080
|
||||||
protocol: tcp
|
# published: 13560
|
||||||
app_protocol: http
|
# host_ip: 127.0.0.1
|
||||||
|
# protocol: tcp
|
||||||
|
# app_protocol: http
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
shm_size: 4mb
|
shm_size: 4mb
|
||||||
volumes:
|
volumes:
|
||||||
@@ -64,7 +66,7 @@ services:
|
|||||||
ml:
|
ml:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Containerfile.ml
|
dockerfile: Containerfile
|
||||||
target: ml
|
target: ml
|
||||||
tags:
|
tags:
|
||||||
- template-project-ml:latest
|
- template-project-ml:latest
|
||||||
|
|||||||
@@ -22,3 +22,6 @@ bucket_name = ""
|
|||||||
endpoint_url = ""
|
endpoint_url = ""
|
||||||
access_key = ""
|
access_key = ""
|
||||||
secret_key = ""
|
secret_key = ""
|
||||||
|
|
||||||
|
[ml_api]
|
||||||
|
url = ""
|
||||||
|
|||||||
@@ -22,3 +22,6 @@ bucket_name = ""
|
|||||||
endpoint_url = ""
|
endpoint_url = ""
|
||||||
access_key = ""
|
access_key = ""
|
||||||
secret_key = ""
|
secret_key = ""
|
||||||
|
|
||||||
|
[ml_api]
|
||||||
|
url = "http://ml:8081"
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[server]
|
||||||
|
host = "0.0.0.0"
|
||||||
|
port = 8081
|
||||||
|
access_log = true
|
||||||
@@ -3,3 +3,4 @@ CREATE DATABASE app;
|
|||||||
\c app;
|
\c app;
|
||||||
|
|
||||||
CREATE EXTENSION IF NOT EXISTS vector;
|
CREATE EXTENSION IF NOT EXISTS vector;
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||||
|
|||||||
+9
-2
@@ -23,13 +23,14 @@ backend = [
|
|||||||
"psycopg[binary]>=3.2.12",
|
"psycopg[binary]>=3.2.12",
|
||||||
"firebase-admin>=7.1.0",
|
"firebase-admin>=7.1.0",
|
||||||
"aioboto3==15.5.0",
|
"aioboto3==15.5.0",
|
||||||
"prometheus-fastapi-instrumentator>=7.1.0",
|
|
||||||
"python-multipart>=0.0.20",
|
"python-multipart>=0.0.20",
|
||||||
"pgvector>=0.4.1",
|
"pgvector>=0.4.1",
|
||||||
|
"prometheus-fastapi-instrumentator>=7.1.0",
|
||||||
]
|
]
|
||||||
ml = [
|
ml = [
|
||||||
"sentence-transformers>=5.1.2",
|
"sentence-transformers>=5.1.2",
|
||||||
"torch",
|
"torch",
|
||||||
|
"prometheus-fastapi-instrumentator>=7.1.0",
|
||||||
]
|
]
|
||||||
types = [
|
types = [
|
||||||
"types-cachetools==6.2.0.20250827",
|
"types-cachetools==6.2.0.20250827",
|
||||||
@@ -54,6 +55,8 @@ dev = [
|
|||||||
{ include-group = "tests" },
|
{ include-group = "tests" },
|
||||||
{ include-group = "linters" },
|
{ include-group = "linters" },
|
||||||
{ include-group = "migrations" },
|
{ include-group = "migrations" },
|
||||||
|
{ include-group = "ml" },
|
||||||
|
{ include-group = "backend" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
@@ -164,6 +167,7 @@ select = [
|
|||||||
"YTT", # flake8-2020
|
"YTT", # flake8-2020
|
||||||
]
|
]
|
||||||
ignore = [
|
ignore = [
|
||||||
|
"PLR1702",
|
||||||
"A005", # allow to shadow stdlib and builtin module names
|
"A005", # allow to shadow stdlib and builtin module names
|
||||||
"COM812", # trailing comma, conflicts with `ruff format`
|
"COM812", # trailing comma, conflicts with `ruff format`
|
||||||
# Different doc rules that we don't really care about:
|
# Different doc rules that we don't really care about:
|
||||||
@@ -177,8 +181,8 @@ ignore = [
|
|||||||
"N813",
|
"N813",
|
||||||
"S106",
|
"S106",
|
||||||
"ERA",
|
"ERA",
|
||||||
"RUF",
|
|
||||||
"PT022",
|
"PT022",
|
||||||
|
"RUF"
|
||||||
]
|
]
|
||||||
external = ["WPS"]
|
external = ["WPS"]
|
||||||
|
|
||||||
@@ -229,4 +233,7 @@ omit = [
|
|||||||
'*/__about__.py',
|
'*/__about__.py',
|
||||||
'*/__main__.py',
|
'*/__main__.py',
|
||||||
'*/__init__.py',
|
'*/__init__.py',
|
||||||
|
'src/dataset/*',
|
||||||
|
'src/template_project/ml/*',
|
||||||
|
'src/template_project/application/resume/interactors/predict_model.py',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from adaptix import DebugTrail, NameStyle, Retort, name_mapping
|
|
||||||
|
|
||||||
from dataset.data_structures import DataSetLine, Salary
|
|
||||||
|
|
||||||
retort = Retort(
|
|
||||||
recipe=[
|
|
||||||
name_mapping(Salary, name_style=NameStyle.CAMEL),
|
|
||||||
],
|
|
||||||
debug_trail=DebugTrail.DISABLE,
|
|
||||||
strict_coercion=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
raw_lines = []
|
|
||||||
with Path("hh_ru_vacancies.jsonlines").open("r", encoding="utf-8") as f:
|
|
||||||
raw_lines = map(json.loads, f.readlines())
|
|
||||||
|
|
||||||
lines = retort.load(raw_lines, list[DataSetLine])
|
|
||||||
f = set()
|
|
||||||
c = 0
|
|
||||||
for line in lines:
|
|
||||||
if c == 1000:
|
|
||||||
break
|
|
||||||
if line.experience:
|
|
||||||
f.add(line.experience)
|
|
||||||
c += 0
|
|
||||||
|
|
||||||
print(f)
|
|
||||||
Executable
+43
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import subprocess # noqa: S404
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from template_project.web_api.configuration import load_configuration
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
config_path = Path("config.toml")
|
||||||
|
configuration = load_configuration(config_path)
|
||||||
|
|
||||||
|
db_url = str(configuration.database.url.get_value())
|
||||||
|
db_url = db_url.replace("postgresql+psycopg://", "postgresql://")
|
||||||
|
|
||||||
|
output_dir = Path("dumps")
|
||||||
|
output_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
output_file = output_dir / "data_dump.sql"
|
||||||
|
|
||||||
|
print("Создание дампа таблиц vacancy, vacancy_embedding, key_skills...")
|
||||||
|
|
||||||
|
subprocess.run( # noqa: S603
|
||||||
|
[ # noqa: S607
|
||||||
|
"pg_dump",
|
||||||
|
db_url,
|
||||||
|
"--table=vacancy",
|
||||||
|
"--table=vacancy_embedding",
|
||||||
|
"--table=key_skills",
|
||||||
|
"--data-only",
|
||||||
|
"--column-inserts",
|
||||||
|
f"--file={output_file}",
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"\nДамп создан: {output_file}")
|
||||||
|
print(f"Размер файла: {output_file.stat().st_size / 1024 / 1024:.2f} MB")
|
||||||
|
print("\nДля импорта на прод сервере выполните:")
|
||||||
|
print(f" psql <PROD_DB_URL> -f {output_file}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+17
@@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
DB_URL="${DATABASE_URL:-postgresql://user:password@localhost:5432/dbname}"
|
||||||
|
|
||||||
|
echo "Создание дампа таблиц vacancy, vacancy_embedding, key_skills..."
|
||||||
|
|
||||||
|
pg_dump "$DB_URL" \
|
||||||
|
--table=vacancy \
|
||||||
|
--table=vacancy_embedding \
|
||||||
|
--table=key_skills \
|
||||||
|
--data-only \
|
||||||
|
--column-inserts \
|
||||||
|
--file=dump_data.sql
|
||||||
|
|
||||||
|
echo "Дамп создан: dump_data.sql"
|
||||||
|
echo "Размер файла: $(du -h dump_data.sql | cut -f1)"
|
||||||
|
|
||||||
Executable
+20
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
DUMP_FILE="${1:-$PROJECT_ROOT/dumps/data_dump.sql}"
|
||||||
|
|
||||||
|
if [ ! -f "$DUMP_FILE" ]; then
|
||||||
|
echo "Ошибка: файл $DUMP_FILE не найден"
|
||||||
|
echo "Использование: $0 [путь_к_дампу]"
|
||||||
|
echo "По умолчанию: $PROJECT_ROOT/dumps/data_dump.sql"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
DB_URL="${DATABASE_URL:-postgresql://postgres:postgres@localhost:5432/app}"
|
||||||
|
|
||||||
|
echo "Импорт дампа из $DUMP_FILE в БД $DB_URL..."
|
||||||
|
|
||||||
|
psql "$DB_URL" -f "$DUMP_FILE"
|
||||||
|
|
||||||
|
echo "Импорт завершен!"
|
||||||
Executable
+44
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from template_project.adapters.data_gateways.key_skills import KeySkillsDataGateway
|
||||||
|
from template_project.adapters.unit_of_work import DefaultUnitOfWork
|
||||||
|
from template_project.web_api.configuration import load_configuration
|
||||||
|
from template_project.web_api.ioc.make import make_ioc
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
config_path = Path("config.toml")
|
||||||
|
configuration = load_configuration(config_path)
|
||||||
|
|
||||||
|
container = make_ioc(configuration)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with container() as request_container:
|
||||||
|
data_gateway = await request_container.get(KeySkillsDataGateway)
|
||||||
|
unit_of_work = await request_container.get(DefaultUnitOfWork)
|
||||||
|
|
||||||
|
json_path = Path("full_skills_unique.json")
|
||||||
|
with json_path.open("r", encoding="utf-8") as f:
|
||||||
|
all_skills: list[str] = json.load(f)
|
||||||
|
|
||||||
|
skills_to_load = all_skills[:50000]
|
||||||
|
|
||||||
|
print(f"Загружаю {len(skills_to_load)} скиллов в БД...")
|
||||||
|
|
||||||
|
batch_size = 100
|
||||||
|
for i in range(0, len(skills_to_load), batch_size):
|
||||||
|
batch = skills_to_load[i : i + batch_size]
|
||||||
|
await data_gateway.add_skills(batch)
|
||||||
|
await unit_of_work.commit()
|
||||||
|
print(f"Загружено {min(i + batch_size, len(skills_to_load))} / {len(skills_to_load)}")
|
||||||
|
|
||||||
|
print("Готово!")
|
||||||
|
finally:
|
||||||
|
await container.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
Executable
+128
@@ -0,0 +1,128 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import ast
|
||||||
|
import asyncio
|
||||||
|
import csv
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from template_project.adapters.unit_of_work import DefaultUnitOfWork
|
||||||
|
from template_project.application.common.embedding import Embedder
|
||||||
|
from template_project.application.common.enums import ExperienceType
|
||||||
|
from template_project.application.vacancy.entity import Vacancy, VacancyEmbedding
|
||||||
|
from template_project.ml.configuration import load_configuration as load_ml_configuration
|
||||||
|
from template_project.ml.ioc.make import make_ioc as make_ml_ioc
|
||||||
|
from template_project.web_api.configuration import load_configuration as load_backend_configuration
|
||||||
|
from template_project.web_api.ioc.make import make_ioc as make_backend_ioc
|
||||||
|
|
||||||
|
|
||||||
|
def parse_skills(skills_str: str) -> list[str]:
|
||||||
|
try:
|
||||||
|
skills = ast.literal_eval(skills_str)
|
||||||
|
if isinstance(skills, list):
|
||||||
|
return [str(skill) for skill in skills]
|
||||||
|
return [] # noqa
|
||||||
|
except (ValueError, SyntaxError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def compose_embedding_text(position: str, description: str, key_skills: list[str]) -> str:
|
||||||
|
skills_text = ", ".join(key_skills) if key_skills else ""
|
||||||
|
parts = [position, description, skills_text]
|
||||||
|
return " ".join(filter(None, parts))
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
backend_config_path = Path("config.toml")
|
||||||
|
backend_configuration = load_backend_configuration(backend_config_path)
|
||||||
|
backend_container = make_backend_ioc(backend_configuration)
|
||||||
|
|
||||||
|
ml_config_path = Path("infrastructure/configs/ml/config.toml")
|
||||||
|
ml_configuration = load_ml_configuration(ml_config_path)
|
||||||
|
ml_container = make_ml_ioc(ml_configuration)
|
||||||
|
|
||||||
|
csv_path = Path("filtered_vacancies.csv")
|
||||||
|
max_records = 100_000
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with backend_container() as backend_request_container, ml_container() as ml_request_container:
|
||||||
|
unit_of_work = await backend_request_container.get(DefaultUnitOfWork)
|
||||||
|
embedder = await ml_request_container.get(Embedder)
|
||||||
|
|
||||||
|
print(f"Загружаю первые {max_records} вакансий из {csv_path}...")
|
||||||
|
|
||||||
|
with csv_path.open("r", encoding="utf-8") as f:
|
||||||
|
reader = csv.DictReader(f)
|
||||||
|
batch_size = 50
|
||||||
|
batch = []
|
||||||
|
|
||||||
|
for idx, row in enumerate(reader):
|
||||||
|
if idx >= max_records:
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
vacancy_id_str = row.get("vacancy_id", "").strip()
|
||||||
|
if not vacancy_id_str:
|
||||||
|
continue
|
||||||
|
|
||||||
|
position = row.get("vacancy_nm", "").strip()
|
||||||
|
if not position:
|
||||||
|
continue
|
||||||
|
|
||||||
|
experience_str = row.get("experience", "").strip()
|
||||||
|
try:
|
||||||
|
experience_type = ExperienceType(experience_str)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
salary_from_str = row.get("salary_from", "").strip()
|
||||||
|
salary_to_str = row.get("salary_to", "").strip()
|
||||||
|
try:
|
||||||
|
salary_from = Decimal(salary_from_str) if salary_from_str else Decimal(0)
|
||||||
|
salary_to = Decimal(salary_to_str) if salary_to_str else Decimal(0)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
description = row.get("vacancy_description", "").strip()
|
||||||
|
key_skills = parse_skills(row.get("key_skills", "[]"))
|
||||||
|
|
||||||
|
vacancy = Vacancy.factory(
|
||||||
|
position=position,
|
||||||
|
from_salary=salary_from,
|
||||||
|
to_salary=salary_to,
|
||||||
|
experience_type=experience_type,
|
||||||
|
description=description,
|
||||||
|
key_skills=key_skills,
|
||||||
|
)
|
||||||
|
|
||||||
|
embedding_text = compose_embedding_text(position, description, key_skills)
|
||||||
|
embedding_vector = await embedder.encode(embedding_text)
|
||||||
|
|
||||||
|
embedding = VacancyEmbedding.factory(
|
||||||
|
vacancy_id=vacancy.id,
|
||||||
|
vector=embedding_vector,
|
||||||
|
)
|
||||||
|
|
||||||
|
await unit_of_work.add(vacancy, embedding)
|
||||||
|
batch.append((vacancy.id, position))
|
||||||
|
|
||||||
|
if len(batch) >= batch_size:
|
||||||
|
await unit_of_work.commit()
|
||||||
|
print(f"Загружено {len(batch)} вакансий (всего: {idx + 1})")
|
||||||
|
batch = []
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ошибка при обработке строки {idx + 1}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if batch:
|
||||||
|
await unit_of_work.commit()
|
||||||
|
print(f"Загружено {len(batch)} вакансий (всего: {idx + 1})")
|
||||||
|
|
||||||
|
print("Готово!")
|
||||||
|
finally:
|
||||||
|
await backend_container.close()
|
||||||
|
await ml_container.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -15,7 +15,7 @@ class KeySkillsDataGateway:
|
|||||||
async def query(self, query: str) -> Sequence[str]:
|
async def query(self, query: str) -> Sequence[str]:
|
||||||
statement = (
|
statement = (
|
||||||
select(key_skills_table.c.name)
|
select(key_skills_table.c.name)
|
||||||
.where(key_skills_table.c.name.ilike(f"{query}%"))
|
.where(key_skills_table.c.name.ilike(f"%{query}%"))
|
||||||
.order_by(key_skills_table.c.name)
|
.order_by(key_skills_table.c.name)
|
||||||
.limit(30)
|
.limit(30)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -21,13 +21,11 @@ from template_project.application.resume.data_gateway import (
|
|||||||
from template_project.application.resume.entity import (
|
from template_project.application.resume.entity import (
|
||||||
Resume,
|
Resume,
|
||||||
ResumeEducation,
|
ResumeEducation,
|
||||||
ResumeEmbeddingId,
|
|
||||||
ResumeExperience,
|
ResumeExperience,
|
||||||
ResumeId,
|
ResumeId,
|
||||||
ResumePrediction,
|
ResumePrediction,
|
||||||
ResumeProject,
|
ResumeProject,
|
||||||
)
|
)
|
||||||
from template_project.application.resume.errors import ResumeNotFoundError
|
|
||||||
from template_project.application.user.entity import UserId
|
from template_project.application.user.entity import UserId
|
||||||
|
|
||||||
|
|
||||||
@@ -36,16 +34,8 @@ class DefaultResumeDataGateway(ResumeDataGateway):
|
|||||||
self._session = session
|
self._session = session
|
||||||
|
|
||||||
@override
|
@override
|
||||||
async def get_suitable_resumes(self, embedding_id: ResumeEmbeddingId) -> Sequence[Resume]:
|
async def load_by_resume_id(self, resume_id: ResumeId) -> Resume | None:
|
||||||
raise NotImplementedError
|
return await self._session.get(Resume, resume_id)
|
||||||
|
|
||||||
@override
|
|
||||||
async def load(self, resume_id: ResumeId) -> Resume:
|
|
||||||
resume = await self._session.get(Resume, resume_id)
|
|
||||||
if resume is None:
|
|
||||||
raise ResumeNotFoundError(resume_id=resume_id)
|
|
||||||
|
|
||||||
return resume
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
async def list_by_user_id(self, user_id: UserId, limit: int, offset: int) -> Sequence[Resume]:
|
async def list_by_user_id(self, user_id: UserId, limit: int, offset: int) -> Sequence[Resume]:
|
||||||
@@ -69,11 +59,15 @@ class DefaultResumeDataGateway(ResumeDataGateway):
|
|||||||
async def get_history(self, resume_id: ResumeId) -> Sequence[Resume]:
|
async def get_history(self, resume_id: ResumeId) -> Sequence[Resume]:
|
||||||
# TODO: N+1
|
# TODO: N+1
|
||||||
history: list[Resume] = []
|
history: list[Resume] = []
|
||||||
current_resume = await self.load(resume_id)
|
current_resume = await self.load_by_resume_id(resume_id)
|
||||||
|
if current_resume is None:
|
||||||
|
return history
|
||||||
history.append(current_resume)
|
history.append(current_resume)
|
||||||
|
|
||||||
while current_resume.down_resume_id is not None:
|
while current_resume.down_resume_id is not None:
|
||||||
current_resume = await self.load(current_resume.down_resume_id)
|
current_resume = await self.load_by_resume_id(current_resume.down_resume_id)
|
||||||
|
if current_resume is None:
|
||||||
|
break
|
||||||
history.append(current_resume)
|
history.append(current_resume)
|
||||||
|
|
||||||
return history
|
return history
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from sqlalchemy import (
|
|||||||
DateTime,
|
DateTime,
|
||||||
Enum,
|
Enum,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
|
Index,
|
||||||
Integer,
|
Integer,
|
||||||
MetaData,
|
MetaData,
|
||||||
Numeric,
|
Numeric,
|
||||||
@@ -22,7 +23,7 @@ from sqlalchemy.orm import registry
|
|||||||
|
|
||||||
from template_project.application.access_token.entity import AccessToken
|
from template_project.application.access_token.entity import AccessToken
|
||||||
from template_project.application.auth_identity.entity import AuthIdentity, AuthMethod
|
from template_project.application.auth_identity.entity import AuthIdentity, AuthMethod
|
||||||
from template_project.application.common.enums import EducationGrade
|
from template_project.application.common.enums import EducationGrade, ExperienceType
|
||||||
from template_project.application.notification_device.entity import NotificationDevice
|
from template_project.application.notification_device.entity import NotificationDevice
|
||||||
from template_project.application.resume.entity import (
|
from template_project.application.resume.entity import (
|
||||||
Resume,
|
Resume,
|
||||||
@@ -34,6 +35,7 @@ from template_project.application.resume.entity import (
|
|||||||
)
|
)
|
||||||
from template_project.application.user.entity import User
|
from template_project.application.user.entity import User
|
||||||
from template_project.application.user.profile.entity import Profile
|
from template_project.application.user.profile.entity import Profile
|
||||||
|
from template_project.application.vacancy.entity import Vacancy, VacancyEmbedding
|
||||||
|
|
||||||
meta_data: Final = MetaData()
|
meta_data: Final = MetaData()
|
||||||
mapper_registry: Final = registry()
|
mapper_registry: Final = registry()
|
||||||
@@ -69,6 +71,34 @@ class StringArrayType(TypeDecorator[list[str]]):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class ExperienceTypeType(TypeDecorator[ExperienceType]):
|
||||||
|
impl: Any = String
|
||||||
|
cache_ok: bool | None = True
|
||||||
|
|
||||||
|
@override
|
||||||
|
def process_bind_param(self, value: Any, dialect: Any) -> Any:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, ExperienceType):
|
||||||
|
return value.value
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
@override
|
||||||
|
def process_result_value(self, value: Any, dialect: Any) -> ExperienceType:
|
||||||
|
if value is None:
|
||||||
|
raise ValueError("experience_type cannot be None")
|
||||||
|
if isinstance(value, ExperienceType):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
return ExperienceType(value)
|
||||||
|
except ValueError as err:
|
||||||
|
raise ValueError(f"Invalid experience_type value: {value}") from err
|
||||||
|
raise ValueError(f"Cannot convert {type(value)} to ExperienceType")
|
||||||
|
|
||||||
|
|
||||||
user_table: Final = Table(
|
user_table: Final = Table(
|
||||||
"users",
|
"users",
|
||||||
meta_data,
|
meta_data,
|
||||||
@@ -137,7 +167,7 @@ resume_table: Final = Table(
|
|||||||
Column("location", String, nullable=False),
|
Column("location", String, nullable=False),
|
||||||
Column("about_me", String, nullable=False),
|
Column("about_me", String, nullable=False),
|
||||||
Column("key_skills", StringArrayType(), nullable=False, server_default=text("'[]'::jsonb")),
|
Column("key_skills", StringArrayType(), nullable=False, server_default=text("'[]'::jsonb")),
|
||||||
Column("experience_type", String, nullable=False),
|
Column("experience_type", ExperienceTypeType(), nullable=False),
|
||||||
Column("down_resume_id", UUID, ForeignKey("resume.id", ondelete="CASCADE"), nullable=True, default=None),
|
Column("down_resume_id", UUID, ForeignKey("resume.id", ondelete="CASCADE"), nullable=True, default=None),
|
||||||
Column("up_resume_id", UUID, ForeignKey("resume.id", ondelete="CASCADE"), nullable=True, default=None),
|
Column("up_resume_id", UUID, ForeignKey("resume.id", ondelete="CASCADE"), nullable=True, default=None),
|
||||||
)
|
)
|
||||||
@@ -149,7 +179,7 @@ resume_embedding_table: Final = Table(
|
|||||||
Column("deleted_at", DateTime(timezone=True)),
|
Column("deleted_at", DateTime(timezone=True)),
|
||||||
Column("created_at", DateTime(timezone=True), nullable=False),
|
Column("created_at", DateTime(timezone=True), nullable=False),
|
||||||
Column("resume_id", UUID, ForeignKey("resume.id", ondelete="CASCADE"), nullable=False),
|
Column("resume_id", UUID, ForeignKey("resume.id", ondelete="CASCADE"), nullable=False),
|
||||||
Column("vector", Vector, nullable=False),
|
Column("vector", Vector(384), nullable=False),
|
||||||
)
|
)
|
||||||
resume_prediction_table: Final = Table(
|
resume_prediction_table: Final = Table(
|
||||||
"resume_prediction",
|
"resume_prediction",
|
||||||
@@ -205,6 +235,45 @@ resume_project_table: Final = Table(
|
|||||||
Column("description", String, nullable=False),
|
Column("description", String, nullable=False),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
vacancy_table: Final = Table(
|
||||||
|
"vacancy",
|
||||||
|
meta_data,
|
||||||
|
Column("id", UUID, primary_key=True),
|
||||||
|
Column("deleted_at", DateTime(timezone=True)),
|
||||||
|
Column("created_at", DateTime(timezone=True), nullable=False),
|
||||||
|
Column("position", String, nullable=False),
|
||||||
|
Column("from_salary", Numeric, nullable=False),
|
||||||
|
Column("to_salary", Numeric, nullable=False),
|
||||||
|
Column("experience_type", String, nullable=False),
|
||||||
|
Column("description", String, nullable=False),
|
||||||
|
Column("key_skills", StringArrayType(), nullable=False, server_default=text("'[]'::jsonb")),
|
||||||
|
)
|
||||||
|
|
||||||
|
vacancy_embedding_table: Final = Table(
|
||||||
|
"vacancy_embedding",
|
||||||
|
meta_data,
|
||||||
|
Column("id", UUID, primary_key=True),
|
||||||
|
Column("deleted_at", DateTime(timezone=True)),
|
||||||
|
Column("created_at", DateTime(timezone=True), nullable=False),
|
||||||
|
Column("vacancy_id", UUID, ForeignKey("vacancy.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
Column("vector", Vector(384), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
key_skills_name_trgm_index: Final = Index(
|
||||||
|
"ix_key_skills_name_trgm",
|
||||||
|
key_skills_table.c.name,
|
||||||
|
postgresql_using="gin",
|
||||||
|
postgresql_ops={"name": "gin_trgm_ops"},
|
||||||
|
)
|
||||||
|
|
||||||
|
vacancy_embedding_vector_cosine_index: Final = Index(
|
||||||
|
"ix_vacancy_embedding_vector_cosine",
|
||||||
|
vacancy_embedding_table.c.vector,
|
||||||
|
postgresql_using="hnsw",
|
||||||
|
postgresql_ops={"vector": "vector_cosine_ops"},
|
||||||
|
postgresql_with={"m": 32, "ef_construction": 256},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
mapper_registry.map_imperatively(User, user_table)
|
mapper_registry.map_imperatively(User, user_table)
|
||||||
mapper_registry.map_imperatively(AccessToken, access_token_table)
|
mapper_registry.map_imperatively(AccessToken, access_token_table)
|
||||||
@@ -216,6 +285,7 @@ mapper_registry.map_imperatively(
|
|||||||
resume_table,
|
resume_table,
|
||||||
properties={
|
properties={
|
||||||
"key_skills": resume_table.c.key_skills,
|
"key_skills": resume_table.c.key_skills,
|
||||||
|
"experience_type": resume_table.c.experience_type,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
mapper_registry.map_imperatively(ResumeEmbedding, resume_embedding_table)
|
mapper_registry.map_imperatively(ResumeEmbedding, resume_embedding_table)
|
||||||
@@ -229,3 +299,11 @@ mapper_registry.map_imperatively(
|
|||||||
mapper_registry.map_imperatively(ResumeExperience, resume_experience_table)
|
mapper_registry.map_imperatively(ResumeExperience, resume_experience_table)
|
||||||
mapper_registry.map_imperatively(ResumeEducation, resume_education_table)
|
mapper_registry.map_imperatively(ResumeEducation, resume_education_table)
|
||||||
mapper_registry.map_imperatively(ResumeProject, resume_project_table)
|
mapper_registry.map_imperatively(ResumeProject, resume_project_table)
|
||||||
|
mapper_registry.map_imperatively(
|
||||||
|
Vacancy,
|
||||||
|
vacancy_table,
|
||||||
|
properties={
|
||||||
|
"key_skills": vacancy_table.c.key_skills,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
mapper_registry.map_imperatively(VacancyEmbedding, vacancy_embedding_table)
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
from collections.abc import Sequence
|
||||||
|
from typing import override
|
||||||
|
|
||||||
|
from sqlalchemy import label, select, text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from template_project.adapters.data_gateways.tables import vacancy_embedding_table, vacancy_table
|
||||||
|
from template_project.application.vacancy.data_gateway import VacancyDataGateway
|
||||||
|
from template_project.application.vacancy.data_structure import SuitableVacancy
|
||||||
|
from template_project.application.vacancy.entity import Vacancy, VacancyEmbedding
|
||||||
|
|
||||||
|
|
||||||
|
class DefaultVacancyDataGateway(VacancyDataGateway):
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
@override
|
||||||
|
async def get_suitable(self, vector: list[float]) -> Sequence[SuitableVacancy]:
|
||||||
|
await self._session.execute(text("SET LOCAL hnsw.ef_search = 128"))
|
||||||
|
distance_expr = vacancy_embedding_table.c.vector.cosine_distance(vector)
|
||||||
|
similarity_expr = 1 - distance_expr
|
||||||
|
statement = (
|
||||||
|
select(Vacancy, label("resume_similarity", similarity_expr))
|
||||||
|
.join(VacancyEmbedding, vacancy_embedding_table.c.vacancy_id == vacancy_table.c.id)
|
||||||
|
.where(similarity_expr >= 0.0)
|
||||||
|
.order_by(distance_expr.asc())
|
||||||
|
.limit(100)
|
||||||
|
)
|
||||||
|
result = await self._session.execute(statement)
|
||||||
|
return [
|
||||||
|
SuitableVacancy(
|
||||||
|
vacancy=row[0],
|
||||||
|
resume_similarity=row[1],
|
||||||
|
)
|
||||||
|
for row in result.all()
|
||||||
|
]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from typing import override
|
||||||
|
|
||||||
|
from template_project.adapters.ml_api_gateway import MlApiGateway
|
||||||
|
from template_project.application.resume.vector_generator import ResumeEmbeddingVectorGenerator
|
||||||
|
|
||||||
|
|
||||||
|
class DefaultResumeEmbeddingVectorGenerator(ResumeEmbeddingVectorGenerator):
|
||||||
|
def __init__(self, ml_api_gateway: MlApiGateway) -> None:
|
||||||
|
self._ml_api_gateway = ml_api_gateway
|
||||||
|
|
||||||
|
@override
|
||||||
|
async def generate(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
) -> list[float]:
|
||||||
|
return await self._ml_api_gateway.generate_embedding(text)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
from collections.abc import Sequence
|
||||||
|
from typing import override
|
||||||
|
|
||||||
|
from template_project.adapters.ml_api_gateway import MlApiGateway, SuitableVacancyDs
|
||||||
|
from template_project.application.resume.entity import Resume, ResumePrediction
|
||||||
|
from template_project.application.resume.resume_prediction_generator import ResumePredictionGenerator
|
||||||
|
from template_project.application.vacancy.data_structure import SuitableVacancy
|
||||||
|
|
||||||
|
|
||||||
|
class DefaultResumePredictionGenerator(ResumePredictionGenerator):
|
||||||
|
def __init__(self, ml_api_gateway: MlApiGateway) -> None:
|
||||||
|
self._ml_api_gateway = ml_api_gateway
|
||||||
|
|
||||||
|
@override
|
||||||
|
async def generate(
|
||||||
|
self,
|
||||||
|
resume: Resume,
|
||||||
|
suitable_vacancies: Sequence[SuitableVacancy],
|
||||||
|
) -> ResumePrediction:
|
||||||
|
response = await self._ml_api_gateway.generate_resume_prediction(
|
||||||
|
resume_id=resume.id,
|
||||||
|
key_skills=resume.key_skills,
|
||||||
|
suitable_vacancies=[
|
||||||
|
SuitableVacancyDs(
|
||||||
|
vacancy_id=str(suitable_vacancy.vacancy.id),
|
||||||
|
from_salary=suitable_vacancy.vacancy.from_salary,
|
||||||
|
to_salary=suitable_vacancy.vacancy.to_salary,
|
||||||
|
key_skills=suitable_vacancy.vacancy.key_skills,
|
||||||
|
resume_similarity=suitable_vacancy.resume_similarity,
|
||||||
|
)
|
||||||
|
for suitable_vacancy in suitable_vacancies
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return ResumePrediction.factory(
|
||||||
|
resume_id=resume.id,
|
||||||
|
from_salary=response.salary_from,
|
||||||
|
to_salary=response.salary_to,
|
||||||
|
recommended_skills=response.recommended_skills,
|
||||||
|
)
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
from collections.abc import Sequence
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from template_project.application.common.data_structure import to_data_structure
|
||||||
|
from template_project.application.resume.entity import ResumeId
|
||||||
|
|
||||||
|
|
||||||
|
@to_data_structure
|
||||||
|
class SuitableVacancyDs:
|
||||||
|
vacancy_id: str
|
||||||
|
from_salary: Decimal
|
||||||
|
to_salary: Decimal
|
||||||
|
key_skills: list[str]
|
||||||
|
resume_similarity: float
|
||||||
|
|
||||||
|
|
||||||
|
@to_data_structure
|
||||||
|
class GenerateResumePredictionResponse:
|
||||||
|
salary_from: Decimal
|
||||||
|
salary_to: Decimal
|
||||||
|
recommended_skills: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class MlApiGateway:
|
||||||
|
def __init__(self, client: AsyncClient) -> None:
|
||||||
|
self._client = client
|
||||||
|
|
||||||
|
async def generate_embedding(self, text: str) -> list[float]:
|
||||||
|
response = await self._client.post("/get_embedding", json={"text": text}, timeout=100)
|
||||||
|
return cast(list[float], response.json()["embedding"])
|
||||||
|
|
||||||
|
async def generate_resume_prediction(
|
||||||
|
self,
|
||||||
|
resume_id: ResumeId,
|
||||||
|
key_skills: list[str],
|
||||||
|
suitable_vacancies: Sequence[SuitableVacancyDs],
|
||||||
|
) -> GenerateResumePredictionResponse:
|
||||||
|
response = await self._client.post(
|
||||||
|
"/predict",
|
||||||
|
json={
|
||||||
|
"resume_id": str(resume_id),
|
||||||
|
"key_skills": key_skills,
|
||||||
|
"vacancies": [
|
||||||
|
{
|
||||||
|
"vacancy_id": str(suitable_vacancy.vacancy_id),
|
||||||
|
"from_salary": str(suitable_vacancy.from_salary),
|
||||||
|
"to_salary": str(suitable_vacancy.to_salary),
|
||||||
|
"key_skills": suitable_vacancy.key_skills,
|
||||||
|
"resume_similarity": suitable_vacancy.resume_similarity,
|
||||||
|
}
|
||||||
|
for suitable_vacancy in suitable_vacancies
|
||||||
|
],
|
||||||
|
},
|
||||||
|
timeout=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
response_json = response.json()
|
||||||
|
return GenerateResumePredictionResponse(
|
||||||
|
salary_from=Decimal(str(response_json["salary_from"])),
|
||||||
|
salary_to=Decimal(str(response_json["salary_to"])),
|
||||||
|
recommended_skills=response_json["recommended_skills"],
|
||||||
|
)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
from collections.abc import Hashable
|
from collections.abc import Hashable
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Self, cast, dataclass_transform, override
|
from typing import cast, dataclass_transform, override
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from template_project.application.common.errors import EntityAlreadyDeletedError
|
from template_project.application.common.errors import EntityAlreadyDeletedError
|
||||||
@@ -22,9 +22,6 @@ class Entity[EntityId: UUID](Hashable):
|
|||||||
if self.deleted_at is not None:
|
if self.deleted_at is not None:
|
||||||
raise EntityAlreadyDeletedError(entity_name=self.__class__.__name__)
|
raise EntityAlreadyDeletedError(entity_name=self.__class__.__name__)
|
||||||
|
|
||||||
def __copy__(self) -> Self:
|
|
||||||
return replace(self)
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def __eq__(self, other: object) -> bool:
|
def __eq__(self, other: object) -> bool:
|
||||||
if isinstance(other, Entity):
|
if isinstance(other, Entity):
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from typing import Protocol
|
|||||||
from template_project.application.resume.entity import (
|
from template_project.application.resume.entity import (
|
||||||
Resume,
|
Resume,
|
||||||
ResumeEducation,
|
ResumeEducation,
|
||||||
ResumeEmbeddingId,
|
|
||||||
ResumeExperience,
|
ResumeExperience,
|
||||||
ResumeId,
|
ResumeId,
|
||||||
ResumePrediction,
|
ResumePrediction,
|
||||||
@@ -16,11 +15,7 @@ from template_project.application.user.entity import UserId
|
|||||||
|
|
||||||
class ResumeDataGateway(Protocol):
|
class ResumeDataGateway(Protocol):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_suitable_resumes(self, embedding_id: ResumeEmbeddingId) -> Sequence[Resume]:
|
async def load_by_resume_id(self, resume_id: ResumeId) -> Resume | None:
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def load(self, resume_id: ResumeId) -> Resume:
|
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|||||||
@@ -80,6 +80,23 @@ class ResumePrediction(Entity[ResumePredictionId]):
|
|||||||
to_salary: Decimal
|
to_salary: Decimal
|
||||||
recommended_skills: list[str]
|
recommended_skills: list[str]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def factory(
|
||||||
|
cls,
|
||||||
|
resume_id: ResumeId,
|
||||||
|
from_salary: Decimal,
|
||||||
|
to_salary: Decimal,
|
||||||
|
recommended_skills: list[str],
|
||||||
|
) -> Self:
|
||||||
|
return cls(
|
||||||
|
id=ResumePredictionId(uuid7()),
|
||||||
|
created_at=datetime.now(tz=UTC),
|
||||||
|
resume_id=resume_id,
|
||||||
|
from_salary=from_salary,
|
||||||
|
to_salary=to_salary,
|
||||||
|
recommended_skills=recommended_skills,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@to_entity
|
@to_entity
|
||||||
class ResumeExperience(Entity[ResumeExperienceId]):
|
class ResumeExperience(Entity[ResumeExperienceId]):
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from template_project.application.resume.entity import (
|
|||||||
ResumeId,
|
ResumeId,
|
||||||
ResumeProject,
|
ResumeProject,
|
||||||
)
|
)
|
||||||
from template_project.application.resume.errors import ResumeDoesBelongUserError
|
from template_project.application.resume.errors import ResumeDoesBelongUserError, ResumeNotFoundError
|
||||||
|
|
||||||
|
|
||||||
@to_data_structure
|
@to_data_structure
|
||||||
@@ -96,7 +96,9 @@ class EditResumeInteractor:
|
|||||||
projects: list[ProjectInput] | None = None,
|
projects: list[ProjectInput] | None = None,
|
||||||
) -> EditResumeResponse:
|
) -> EditResumeResponse:
|
||||||
user = await self.identity_provider.get_current_user()
|
user = await self.identity_provider.get_current_user()
|
||||||
old_resume = await self.resume_data_gateway.load(resume_id)
|
old_resume = await self.resume_data_gateway.load_by_resume_id(resume_id)
|
||||||
|
if old_resume is None:
|
||||||
|
raise ResumeNotFoundError(resume_id=resume_id)
|
||||||
if old_resume.user_id != user.id:
|
if old_resume.user_id != user.id:
|
||||||
raise ResumeDoesBelongUserError
|
raise ResumeDoesBelongUserError
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from template_project.application.resume.data_gateway import (
|
|||||||
ResumeProjectDataGateway,
|
ResumeProjectDataGateway,
|
||||||
)
|
)
|
||||||
from template_project.application.resume.entity import ResumeId
|
from template_project.application.resume.entity import ResumeId
|
||||||
from template_project.application.resume.errors import ResumeDoesBelongUserError
|
from template_project.application.resume.errors import ResumeDoesBelongUserError, ResumeNotFoundError
|
||||||
|
|
||||||
|
|
||||||
@to_data_structure
|
@to_data_structure
|
||||||
@@ -72,7 +72,9 @@ class GetResumeInteractor:
|
|||||||
) -> GetResumeResponse:
|
) -> GetResumeResponse:
|
||||||
user = await self.identity_provider.get_current_user()
|
user = await self.identity_provider.get_current_user()
|
||||||
|
|
||||||
resume = await self.resume_data_gateway.load(resume_id)
|
resume = await self.resume_data_gateway.load_by_resume_id(resume_id)
|
||||||
|
if resume is None:
|
||||||
|
raise ResumeNotFoundError(resume_id=resume_id)
|
||||||
|
|
||||||
if resume.user_id != user.id:
|
if resume.user_id != user.id:
|
||||||
raise ResumeDoesBelongUserError
|
raise ResumeDoesBelongUserError
|
||||||
@@ -134,53 +136,151 @@ class ResumeListItemResponse:
|
|||||||
about_me: str
|
about_me: str
|
||||||
key_skills: list[str]
|
key_skills: list[str]
|
||||||
experience_type: ExperienceType
|
experience_type: ExperienceType
|
||||||
|
experience: list[ExperienceItemResponse]
|
||||||
|
education: list[EducationItemResponse]
|
||||||
|
projects: list[ProjectItemResponse]
|
||||||
|
prediction: ResumePredictionResponse | None
|
||||||
|
|
||||||
|
|
||||||
@to_interactor
|
@to_interactor
|
||||||
class GetResumeListInteractor:
|
class GetResumeListInteractor:
|
||||||
identity_provider: IdentityProvider
|
identity_provider: IdentityProvider
|
||||||
resume_data_gateway: ResumeDataGateway
|
resume_data_gateway: ResumeDataGateway
|
||||||
|
resume_prediction_data_gateway: ResumePredictionDataGateway
|
||||||
|
resume_experience_data_gateway: ResumeExperienceDataGateway
|
||||||
|
resume_education_data_gateway: ResumeEducationDataGateway
|
||||||
|
resume_project_data_gateway: ResumeProjectDataGateway
|
||||||
|
|
||||||
async def execute(self, limit: int, offset: int) -> list[ResumeListItemResponse]:
|
async def execute(self, limit: int, offset: int) -> list[ResumeListItemResponse]:
|
||||||
user = await self.identity_provider.get_current_user()
|
user = await self.identity_provider.get_current_user()
|
||||||
|
|
||||||
resumes = await self.resume_data_gateway.list_latest_by_user_id(user.id, limit=limit, offset=offset)
|
resumes = await self.resume_data_gateway.list_latest_by_user_id(user.id, limit=limit, offset=offset)
|
||||||
|
|
||||||
return [
|
result = []
|
||||||
ResumeListItemResponse(
|
for r in resumes:
|
||||||
id=r.id,
|
resume_prediction = await self.resume_prediction_data_gateway.load_by_resume_id(r.id)
|
||||||
position=r.position,
|
if resume_prediction is not None:
|
||||||
location=r.location,
|
prediction = ResumePredictionResponse(
|
||||||
about_me=r.about_me,
|
from_salary=resume_prediction.from_salary,
|
||||||
key_skills=r.key_skills,
|
to_salary=resume_prediction.to_salary,
|
||||||
experience_type=r.experience_type,
|
recommended_skills=resume_prediction.recommended_skills,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
prediction = None
|
||||||
|
|
||||||
|
experiences = await self.resume_experience_data_gateway.load_by_resume_id(r.id)
|
||||||
|
educations = await self.resume_education_data_gateway.load_by_resume_id(r.id)
|
||||||
|
projects = await self.resume_project_data_gateway.load_by_resume_id(r.id)
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
ResumeListItemResponse(
|
||||||
|
id=r.id,
|
||||||
|
position=r.position,
|
||||||
|
location=r.location,
|
||||||
|
about_me=r.about_me,
|
||||||
|
key_skills=r.key_skills,
|
||||||
|
experience_type=r.experience_type,
|
||||||
|
experience=[
|
||||||
|
ExperienceItemResponse(
|
||||||
|
place=exp.place,
|
||||||
|
description=exp.description,
|
||||||
|
months_duration=exp.months_duration,
|
||||||
|
)
|
||||||
|
for exp in experiences
|
||||||
|
],
|
||||||
|
education=[
|
||||||
|
EducationItemResponse(
|
||||||
|
place=edu.place,
|
||||||
|
grade=edu.grade,
|
||||||
|
specialization=edu.specialization,
|
||||||
|
description=edu.description,
|
||||||
|
)
|
||||||
|
for edu in educations
|
||||||
|
],
|
||||||
|
projects=[
|
||||||
|
ProjectItemResponse(
|
||||||
|
name=proj.name,
|
||||||
|
description=proj.description,
|
||||||
|
)
|
||||||
|
for proj in projects
|
||||||
|
],
|
||||||
|
prediction=prediction,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
for r in resumes
|
|
||||||
]
|
return result
|
||||||
|
|
||||||
|
|
||||||
@to_interactor
|
@to_interactor
|
||||||
class GetResumeHistoryInteractor:
|
class GetResumeHistoryInteractor:
|
||||||
identity_provider: IdentityProvider
|
identity_provider: IdentityProvider
|
||||||
resume_data_gateway: ResumeDataGateway
|
resume_data_gateway: ResumeDataGateway
|
||||||
|
resume_prediction_data_gateway: ResumePredictionDataGateway
|
||||||
|
resume_experience_data_gateway: ResumeExperienceDataGateway
|
||||||
|
resume_education_data_gateway: ResumeEducationDataGateway
|
||||||
|
resume_project_data_gateway: ResumeProjectDataGateway
|
||||||
|
|
||||||
async def execute(self, resume_id: ResumeId) -> list[ResumeListItemResponse]:
|
async def execute(self, resume_id: ResumeId) -> list[ResumeListItemResponse]:
|
||||||
user = await self.identity_provider.get_current_user()
|
user = await self.identity_provider.get_current_user()
|
||||||
|
|
||||||
resume = await self.resume_data_gateway.load(resume_id)
|
resume = await self.resume_data_gateway.load_by_resume_id(resume_id)
|
||||||
|
if resume is None:
|
||||||
|
raise ResumeNotFoundError(resume_id=resume_id)
|
||||||
if resume.user_id != user.id:
|
if resume.user_id != user.id:
|
||||||
raise ResumeDoesBelongUserError
|
raise ResumeDoesBelongUserError
|
||||||
|
|
||||||
history = await self.resume_data_gateway.get_history(resume_id)
|
history = await self.resume_data_gateway.get_history(resume_id)
|
||||||
|
|
||||||
return [
|
result = []
|
||||||
ResumeListItemResponse(
|
for r in history:
|
||||||
id=r.id,
|
resume_prediction = await self.resume_prediction_data_gateway.load_by_resume_id(r.id)
|
||||||
position=r.position,
|
if resume_prediction is not None:
|
||||||
location=r.location,
|
prediction = ResumePredictionResponse(
|
||||||
about_me=r.about_me,
|
from_salary=resume_prediction.from_salary,
|
||||||
key_skills=r.key_skills,
|
to_salary=resume_prediction.to_salary,
|
||||||
experience_type=r.experience_type,
|
recommended_skills=resume_prediction.recommended_skills,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
prediction = None
|
||||||
|
|
||||||
|
experiences = await self.resume_experience_data_gateway.load_by_resume_id(r.id)
|
||||||
|
educations = await self.resume_education_data_gateway.load_by_resume_id(r.id)
|
||||||
|
projects = await self.resume_project_data_gateway.load_by_resume_id(r.id)
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
ResumeListItemResponse(
|
||||||
|
id=r.id,
|
||||||
|
position=r.position,
|
||||||
|
location=r.location,
|
||||||
|
about_me=r.about_me,
|
||||||
|
key_skills=r.key_skills,
|
||||||
|
experience_type=r.experience_type,
|
||||||
|
experience=[
|
||||||
|
ExperienceItemResponse(
|
||||||
|
place=exp.place,
|
||||||
|
description=exp.description,
|
||||||
|
months_duration=exp.months_duration,
|
||||||
|
)
|
||||||
|
for exp in experiences
|
||||||
|
],
|
||||||
|
education=[
|
||||||
|
EducationItemResponse(
|
||||||
|
place=edu.place,
|
||||||
|
grade=edu.grade,
|
||||||
|
specialization=edu.specialization,
|
||||||
|
description=edu.description,
|
||||||
|
)
|
||||||
|
for edu in educations
|
||||||
|
],
|
||||||
|
projects=[
|
||||||
|
ProjectItemResponse(
|
||||||
|
name=proj.name,
|
||||||
|
description=proj.description,
|
||||||
|
)
|
||||||
|
for proj in projects
|
||||||
|
],
|
||||||
|
prediction=prediction,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
for r in history
|
|
||||||
]
|
return result
|
||||||
|
|||||||
+6
-3
@@ -33,7 +33,7 @@ class PredictSalaryResponse:
|
|||||||
|
|
||||||
|
|
||||||
@to_interactor
|
@to_interactor
|
||||||
class PredictSalaryInteractor:
|
class PredictModelInteractor:
|
||||||
async def execute(self, request: PredictSalaryRequest) -> PredictSalaryResponse:
|
async def execute(self, request: PredictSalaryRequest) -> PredictSalaryResponse:
|
||||||
salary_from, salary_to = self._predict_salary(request.vacancies, request.key_skills)
|
salary_from, salary_to = self._predict_salary(request.vacancies, request.key_skills)
|
||||||
recommended_skills = self._recommend_skills(request.vacancies, request.key_skills)
|
recommended_skills = self._recommend_skills(request.vacancies, request.key_skills)
|
||||||
@@ -46,7 +46,7 @@ class PredictSalaryInteractor:
|
|||||||
|
|
||||||
def _predict_salary(self, vacancies: list[VacancyInput], resume_skills: list[str]) -> tuple[Decimal, Decimal]:
|
def _predict_salary(self, vacancies: list[VacancyInput], resume_skills: list[str]) -> tuple[Decimal, Decimal]:
|
||||||
if not vacancies:
|
if not vacancies:
|
||||||
return Decimal(50000), Decimal(80000)
|
return Decimal(0), Decimal(0)
|
||||||
|
|
||||||
vacancy_weights: list[float] = []
|
vacancy_weights: list[float] = []
|
||||||
for vacancy in vacancies:
|
for vacancy in vacancies:
|
||||||
@@ -56,7 +56,7 @@ class PredictSalaryInteractor:
|
|||||||
|
|
||||||
total_weight = sum(vacancy_weights)
|
total_weight = sum(vacancy_weights)
|
||||||
if total_weight == 0:
|
if total_weight == 0:
|
||||||
return Decimal(50000), Decimal(80000)
|
return Decimal(0), Decimal(0)
|
||||||
|
|
||||||
weighted_from_sum = Decimal(0)
|
weighted_from_sum = Decimal(0)
|
||||||
weighted_to_sum = Decimal(0)
|
weighted_to_sum = Decimal(0)
|
||||||
@@ -143,6 +143,9 @@ class PredictSalaryInteractor:
|
|||||||
if skill in candidate_skills
|
if skill in candidate_skills
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if not candidate_skills:
|
||||||
|
return []
|
||||||
|
|
||||||
frequencies = [skill_frequencies[skill] for skill in candidate_skills]
|
frequencies = [skill_frequencies[skill] for skill in candidate_skills]
|
||||||
avg_salaries = [float(skill_avg_salaries[skill]) for skill in candidate_skills]
|
avg_salaries = [float(skill_avg_salaries[skill]) for skill in candidate_skills]
|
||||||
|
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
from typing import Final
|
||||||
|
|
||||||
|
from Levenshtein import ratio
|
||||||
|
|
||||||
|
from template_project.application.common.data_structure import to_data_structure
|
||||||
|
from template_project.application.common.interactor import to_interactor
|
||||||
|
from template_project.application.common.unit_of_work import UnitOfWork
|
||||||
|
from template_project.application.resume.data_gateway import ResumeDataGateway
|
||||||
|
from template_project.application.resume.entity import Resume, ResumeEmbedding, ResumeId
|
||||||
|
from template_project.application.resume.resume_prediction_generator import ResumePredictionGenerator
|
||||||
|
from template_project.application.resume.vector_generator import ResumeEmbeddingVectorGenerator
|
||||||
|
from template_project.application.vacancy.data_gateway import VacancyDataGateway
|
||||||
|
from template_project.application.vacancy.data_structure import SuitableVacancy
|
||||||
|
|
||||||
|
EMBEDDING_TEXT_TEMPLATE: Final = """
|
||||||
|
Позиция: {position}
|
||||||
|
Опыт: {experience_type}
|
||||||
|
Ключевые навыки: {key_skills}
|
||||||
|
Описание: {about_me}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_skills_matching(resume_skills: list[str], vacancy_skills: list[str]) -> float:
|
||||||
|
count_skills = 0
|
||||||
|
ratio_skill_sum = 0.0
|
||||||
|
for resume_key_skill in resume_skills:
|
||||||
|
for vacancy_key_skill in vacancy_skills:
|
||||||
|
ratio_skill = ratio(resume_key_skill, vacancy_key_skill)
|
||||||
|
if ratio_skill != 0:
|
||||||
|
count_skills += 1
|
||||||
|
ratio_skill_sum += ratio_skill
|
||||||
|
|
||||||
|
try:
|
||||||
|
return ratio_skill_sum / count_skills
|
||||||
|
except ZeroDivisionError:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_and_sort_vacancies(
|
||||||
|
resume: Resume,
|
||||||
|
suitable_vacancies: list[SuitableVacancy],
|
||||||
|
limit: int = 50,
|
||||||
|
) -> list[SuitableVacancy]:
|
||||||
|
def calculate_priority(vacancy: SuitableVacancy) -> float:
|
||||||
|
priority = vacancy.resume_similarity
|
||||||
|
|
||||||
|
if resume.experience_type == vacancy.vacancy.experience_type:
|
||||||
|
priority += 0.1
|
||||||
|
|
||||||
|
if resume.key_skills:
|
||||||
|
skills_matching = _calculate_skills_matching(resume.key_skills, vacancy.vacancy.key_skills)
|
||||||
|
priority += skills_matching * 0.2
|
||||||
|
|
||||||
|
return priority
|
||||||
|
|
||||||
|
sorted_vacancies = sorted(suitable_vacancies, key=calculate_priority, reverse=True)
|
||||||
|
|
||||||
|
return sorted_vacancies[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
@to_data_structure
|
||||||
|
class PredictResumeRequest:
|
||||||
|
resume_id: ResumeId
|
||||||
|
|
||||||
|
|
||||||
|
@to_interactor
|
||||||
|
class ResumePredictionInteractor:
|
||||||
|
unit_of_work: UnitOfWork
|
||||||
|
resume_data_gateway: ResumeDataGateway
|
||||||
|
vacancy_data_gateway: VacancyDataGateway
|
||||||
|
vector_generator: ResumeEmbeddingVectorGenerator
|
||||||
|
resume_prediction_generator: ResumePredictionGenerator
|
||||||
|
|
||||||
|
async def execute(self, request: PredictResumeRequest) -> None:
|
||||||
|
resume = await self.resume_data_gateway.load_by_resume_id(request.resume_id)
|
||||||
|
if resume is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
embedding_text = EMBEDDING_TEXT_TEMPLATE.format_map({
|
||||||
|
"position": resume.position,
|
||||||
|
"experience_type": resume.experience_type.value,
|
||||||
|
"key_skills": ", ".join(resume.key_skills),
|
||||||
|
"about_me": resume.about_me,
|
||||||
|
})
|
||||||
|
vector = await self.vector_generator.generate(embedding_text)
|
||||||
|
resume_embedding = ResumeEmbedding.factory(
|
||||||
|
resume_id=resume.id,
|
||||||
|
vector=vector,
|
||||||
|
)
|
||||||
|
|
||||||
|
suitable_vacancies_list = list(await self.vacancy_data_gateway.get_suitable(resume_embedding.vector))
|
||||||
|
suitable_vacancies_filtered = _filter_and_sort_vacancies(resume, suitable_vacancies_list, limit=50)
|
||||||
|
|
||||||
|
resume_prediction = await self.resume_prediction_generator.generate(
|
||||||
|
resume=resume,
|
||||||
|
suitable_vacancies=suitable_vacancies_filtered,
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.unit_of_work.add(resume_embedding, resume_prediction)
|
||||||
|
await self.unit_of_work.commit()
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
from collections.abc import Callable
|
|
||||||
|
|
||||||
from Levenshtein import ratio
|
|
||||||
|
|
||||||
from template_project.application.common.unit_of_work import UnitOfWork
|
|
||||||
from template_project.application.resume.data_gateway import ResumeDataGateway
|
|
||||||
from template_project.application.resume.entity import Resume, ResumeEmbedding, ResumePrediction
|
|
||||||
from template_project.application.resume.vector_generator import ResumeEmbeddingVectorGenerator
|
|
||||||
|
|
||||||
|
|
||||||
def suitable_resumes_key(
|
|
||||||
resume: Resume,
|
|
||||||
) -> Callable[[Resume], bool]:
|
|
||||||
def wrapper(suitable_resume: Resume) -> bool:
|
|
||||||
count_skills = 0
|
|
||||||
ratio_skill_sum = 0.0
|
|
||||||
for resum_key_skill in resume.key_skills:
|
|
||||||
for suitable_resume_key_skill in suitable_resume.key_skills:
|
|
||||||
ratio_skill = ratio(resum_key_skill, suitable_resume_key_skill)
|
|
||||||
if ratio_skill != 0:
|
|
||||||
count_skills += 1
|
|
||||||
ratio_skill_sum += ratio_skill
|
|
||||||
|
|
||||||
try:
|
|
||||||
matching_skills = ratio_skill_sum / count_skills
|
|
||||||
except ZeroDivisionError:
|
|
||||||
matching_skills = 0
|
|
||||||
|
|
||||||
return resume.experience_type == suitable_resume.experience_type and matching_skills >= 50
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
|
|
||||||
class ResumeEmbeddingPipeline:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
unit_of_work: UnitOfWork,
|
|
||||||
resume_data_gateway: ResumeDataGateway,
|
|
||||||
vector_generator: ResumeEmbeddingVectorGenerator,
|
|
||||||
) -> None:
|
|
||||||
self.unit_of_work = unit_of_work
|
|
||||||
self.resume_data_gateway = resume_data_gateway
|
|
||||||
self.vector_generator = vector_generator
|
|
||||||
|
|
||||||
async def run(
|
|
||||||
self,
|
|
||||||
resume: Resume,
|
|
||||||
) -> ResumePrediction:
|
|
||||||
vector = await self.vector_generator.generate(
|
|
||||||
position=resume.position,
|
|
||||||
about_me=resume.about_me,
|
|
||||||
key_skills=resume.key_skills,
|
|
||||||
)
|
|
||||||
resume_embedding = ResumeEmbedding.factory(
|
|
||||||
resume_id=resume.id,
|
|
||||||
vector=vector,
|
|
||||||
)
|
|
||||||
|
|
||||||
suitable_resumes = await self.resume_data_gateway.get_suitable_resumes(resume_embedding.id)
|
|
||||||
suitable_resumes_filtered = sorted(
|
|
||||||
suitable_resumes,
|
|
||||||
key=suitable_resumes_key(resume),
|
|
||||||
)
|
|
||||||
suitable_resumes = suitable_resumes_filtered[:50]
|
|
||||||
|
|
||||||
# TODO: тут надо сделать отправку в ИИ
|
|
||||||
|
|
||||||
await self.unit_of_work.add(resume_embedding)
|
|
||||||
await self.unit_of_work.commit()
|
|
||||||
|
|
||||||
raise NotImplementedError
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from abc import abstractmethod
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from template_project.application.resume.entity import Resume, ResumePrediction
|
||||||
|
from template_project.application.vacancy.data_structure import SuitableVacancy
|
||||||
|
|
||||||
|
|
||||||
|
class ResumePredictionGenerator(Protocol):
|
||||||
|
@abstractmethod
|
||||||
|
async def generate(
|
||||||
|
self,
|
||||||
|
resume: Resume,
|
||||||
|
suitable_vacancies: Sequence[SuitableVacancy],
|
||||||
|
) -> ResumePrediction:
|
||||||
|
raise NotImplementedError
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
from abc import abstractmethod
|
from abc import abstractmethod
|
||||||
from typing import Protocol
|
|
||||||
|
|
||||||
|
|
||||||
class ResumeEmbeddingVectorGenerator(Protocol):
|
class ResumeEmbeddingVectorGenerator:
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def generate(
|
async def generate(
|
||||||
self,
|
self,
|
||||||
position: str,
|
text: str,
|
||||||
about_me: str,
|
|
||||||
key_skills: list[str],
|
|
||||||
) -> list[float]:
|
) -> list[float]:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from abc import abstractmethod
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from template_project.application.vacancy.data_structure import SuitableVacancy
|
||||||
|
|
||||||
|
|
||||||
|
class VacancyDataGateway(Protocol):
|
||||||
|
@abstractmethod
|
||||||
|
async def get_suitable(self, vector: list[float]) -> Sequence[SuitableVacancy]:
|
||||||
|
raise NotImplementedError
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from template_project.application.common.data_structure import to_data_structure
|
||||||
|
from template_project.application.vacancy.entity import Vacancy
|
||||||
|
|
||||||
|
|
||||||
|
@to_data_structure
|
||||||
|
class SuitableVacancy:
|
||||||
|
vacancy: Vacancy
|
||||||
|
resume_similarity: float
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
|
from datetime import UTC, datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any
|
from typing import NewType, Self
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from uuid_utils.compat import uuid7
|
||||||
|
|
||||||
from template_project.application.common.entity import Entity, to_entity
|
from template_project.application.common.entity import Entity, to_entity
|
||||||
from template_project.application.common.enums import ExperienceType
|
from template_project.application.common.enums import ExperienceType
|
||||||
|
|
||||||
|
VacancyId = NewType("VacancyId", UUID)
|
||||||
|
VacancyEmbeddingId = NewType("VacancyEmbeddingId", UUID)
|
||||||
|
|
||||||
|
|
||||||
@to_entity
|
@to_entity
|
||||||
class Vacancy(Entity[Any]):
|
class Vacancy(Entity[VacancyId]):
|
||||||
position: str
|
position: str
|
||||||
from_salary: Decimal
|
from_salary: Decimal
|
||||||
to_salary: Decimal
|
to_salary: Decimal
|
||||||
@@ -14,8 +21,42 @@ class Vacancy(Entity[Any]):
|
|||||||
description: str
|
description: str
|
||||||
key_skills: list[str]
|
key_skills: list[str]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def factory(
|
||||||
|
cls,
|
||||||
|
position: str,
|
||||||
|
from_salary: Decimal,
|
||||||
|
to_salary: Decimal,
|
||||||
|
experience_type: ExperienceType,
|
||||||
|
description: str,
|
||||||
|
key_skills: list[str],
|
||||||
|
) -> Self:
|
||||||
|
return cls(
|
||||||
|
id=VacancyId(uuid7()),
|
||||||
|
created_at=datetime.now(tz=UTC),
|
||||||
|
position=position,
|
||||||
|
from_salary=from_salary,
|
||||||
|
to_salary=to_salary,
|
||||||
|
experience_type=experience_type,
|
||||||
|
description=description,
|
||||||
|
key_skills=key_skills,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@to_entity
|
@to_entity
|
||||||
class VacancyEmbedding(Entity[Any]):
|
class VacancyEmbedding(Entity[VacancyEmbeddingId]):
|
||||||
vacancy_id: Any
|
vacancy_id: VacancyId
|
||||||
vector: list[float]
|
vector: list[float]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def factory(
|
||||||
|
cls,
|
||||||
|
vacancy_id: VacancyId,
|
||||||
|
vector: list[float],
|
||||||
|
) -> Self:
|
||||||
|
return cls(
|
||||||
|
id=VacancyEmbeddingId(uuid7()),
|
||||||
|
created_at=datetime.now(tz=UTC),
|
||||||
|
vacancy_id=vacancy_id,
|
||||||
|
vector=vector,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 2c99db38e99b
|
||||||
|
Revises: 9a32674539dd
|
||||||
|
Create Date: 2025-11-23 12:08:50.774495
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '2c99db38e99b'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '9a32674539dd'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
|
||||||
|
op.execute("ALTER TABLE vacancy_embedding ALTER COLUMN vector TYPE vector(384)")
|
||||||
|
op.execute("ALTER TABLE resume_embedding ALTER COLUMN vector TYPE vector(384)")
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_index('ix_key_skills_name_trgm', 'key_skills', ['name'], unique=False, postgresql_using='gin', postgresql_ops={'name': 'gin_trgm_ops'})
|
||||||
|
op.create_index('ix_vacancy_embedding_vector_cosine', 'vacancy_embedding', ['vector'], unique=False, postgresql_using='hnsw', postgresql_ops={'vector': 'vector_cosine_ops'}, postgresql_with={'m': 32, 'ef_construction': 256})
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index('ix_vacancy_embedding_vector_cosine', table_name='vacancy_embedding', postgresql_using='hnsw', postgresql_ops={'vector': 'vector_cosine_ops'}, postgresql_with={'m': 32, 'ef_construction': 256})
|
||||||
|
op.drop_index('ix_key_skills_name_trgm', table_name='key_skills', postgresql_using='gin', postgresql_ops={'name': 'gin_trgm_ops'})
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 9a32674539dd
|
||||||
|
Revises: 892aba57b356
|
||||||
|
Create Date: 2025-11-23 01:26:29.515334
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import pgvector.sqlalchemy
|
||||||
|
from sqlalchemy import Text
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
|
import template_project.adapters.data_gateways.tables
|
||||||
|
from template_project.adapters.data_gateways.tables import StringArrayType
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '9a32674539dd'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '892aba57b356'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('vacancy',
|
||||||
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('position', sa.String(), nullable=False),
|
||||||
|
sa.Column('from_salary', sa.Numeric(), nullable=False),
|
||||||
|
sa.Column('to_salary', sa.Numeric(), nullable=False),
|
||||||
|
sa.Column('experience_type', sa.String(), nullable=False),
|
||||||
|
sa.Column('description', sa.String(), nullable=False),
|
||||||
|
sa.Column('key_skills', template_project.adapters.data_gateways.tables.StringArrayType(astext_type=Text()), server_default=sa.text("'[]'::jsonb"), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('vacancy_embedding',
|
||||||
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('vacancy_id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('vector', pgvector.sqlalchemy.vector.VECTOR(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['vacancy_id'], ['vacancy.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('vacancy_embedding')
|
||||||
|
op.drop_table('vacancy')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -3,6 +3,8 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
@@ -11,6 +13,8 @@ from dishka import AsyncContainer
|
|||||||
from dishka.integrations.fastapi import setup_dishka
|
from dishka.integrations.fastapi import setup_dishka
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from prometheus_fastapi_instrumentator import Instrumentator
|
||||||
|
from sentence_transformers import SentenceTransformer
|
||||||
|
|
||||||
from template_project.ml.configuration import load_configuration
|
from template_project.ml.configuration import load_configuration
|
||||||
from template_project.ml.ioc.make import make_ioc
|
from template_project.ml.ioc.make import make_ioc
|
||||||
@@ -37,10 +41,18 @@ LOG_CONFIG: Final = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||||
|
await app.state.dishka_container.get(SentenceTransformer)
|
||||||
|
yield
|
||||||
|
await app.state.dishka_container.close()
|
||||||
|
|
||||||
|
|
||||||
def make_asgi_application(
|
def make_asgi_application(
|
||||||
ioc: AsyncContainer,
|
ioc: AsyncContainer,
|
||||||
) -> FastAPI:
|
) -> FastAPI:
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
|
lifespan=lifespan,
|
||||||
docs_url="/docs",
|
docs_url="/docs",
|
||||||
title="ML Service",
|
title="ML Service",
|
||||||
description="ML Service API",
|
description="ML Service API",
|
||||||
@@ -57,6 +69,7 @@ def make_asgi_application(
|
|||||||
app.include_router(healthcheck.router)
|
app.include_router(healthcheck.router)
|
||||||
app.include_router(embed.router)
|
app.include_router(embed.router)
|
||||||
app.include_router(predict.router)
|
app.include_router(predict.router)
|
||||||
|
Instrumentator().instrument(app).expose(app)
|
||||||
|
|
||||||
setup_dishka(container=ioc, app=app)
|
setup_dishka(container=ioc, app=app)
|
||||||
|
|
||||||
@@ -81,10 +94,7 @@ async def _main(
|
|||||||
access_log=configuration.server.access_log,
|
access_log=configuration.server.access_log,
|
||||||
)
|
)
|
||||||
server = uvicorn.Server(config)
|
server = uvicorn.Server(config)
|
||||||
try:
|
await server.serve()
|
||||||
await server.serve()
|
|
||||||
finally:
|
|
||||||
await ioc.close()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
from dishka import BaseScope, Provider, Scope, provide_all
|
from dishka import BaseScope, Provider, Scope, provide_all
|
||||||
|
|
||||||
from template_project.application.resume.interactors.predict_salary import PredictSalaryInteractor
|
from template_project.application.resume.interactors.predict_model import PredictModelInteractor
|
||||||
|
|
||||||
|
|
||||||
class InteractorProvider(Provider):
|
class InteractorProvider(Provider):
|
||||||
scope: BaseScope | None = Scope.REQUEST
|
scope: BaseScope | None = Scope.REQUEST
|
||||||
|
|
||||||
interactors = provide_all(
|
interactors = provide_all(
|
||||||
PredictSalaryInteractor,
|
PredictModelInteractor,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ from fastapi import APIRouter
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from template_project.application.resume.entity import ResumeId
|
from template_project.application.resume.entity import ResumeId
|
||||||
from template_project.application.resume.interactors.predict_salary import (
|
from template_project.application.resume.interactors.predict_model import (
|
||||||
PredictSalaryInteractor,
|
PredictModelInteractor,
|
||||||
PredictSalaryRequest,
|
PredictSalaryRequest,
|
||||||
VacancyInput,
|
VacancyInput,
|
||||||
)
|
)
|
||||||
@@ -40,9 +40,9 @@ class VacancyInputModel(BaseModel):
|
|||||||
class PredictSalaryRequestModel(BaseModel):
|
class PredictSalaryRequestModel(BaseModel):
|
||||||
resume_id: ResumeId = Field(description="Resume ID", examples=["01234567-89ab-cdef-0123-456789abcdef"])
|
resume_id: ResumeId = Field(description="Resume ID", examples=["01234567-89ab-cdef-0123-456789abcdef"])
|
||||||
key_skills: list[str] = Field(
|
key_skills: list[str] = Field(
|
||||||
min_length=1, description="List of key skills from resume", examples=[["Python", "FastAPI", "PostgreSQL"]]
|
description="List of key skills from resume", examples=[["Python", "FastAPI", "PostgreSQL"]]
|
||||||
)
|
)
|
||||||
vacancies: list[VacancyInputModel] = Field(min_length=1, description="List of relevant vacancies", examples=[[]])
|
vacancies: list[VacancyInputModel] = Field(description="List of relevant vacancies", examples=[[]], min_length=0)
|
||||||
|
|
||||||
model_config = {
|
model_config = {
|
||||||
"json_schema_extra": {
|
"json_schema_extra": {
|
||||||
@@ -94,7 +94,7 @@ class PredictSalaryResponseModel(BaseModel):
|
|||||||
)
|
)
|
||||||
async def predict(
|
async def predict(
|
||||||
request: PredictSalaryRequestModel,
|
request: PredictSalaryRequestModel,
|
||||||
interactor: FromDishka[PredictSalaryInteractor],
|
interactor: FromDishka[PredictModelInteractor],
|
||||||
) -> PredictSalaryResponseModel:
|
) -> PredictSalaryResponseModel:
|
||||||
vacancy_inputs = [
|
vacancy_inputs = [
|
||||||
VacancyInput(
|
VacancyInput(
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ class S3Config:
|
|||||||
secret_key: str
|
secret_key: str
|
||||||
|
|
||||||
|
|
||||||
|
@to_configuration
|
||||||
|
class MlApiConfiguration:
|
||||||
|
url: str
|
||||||
|
|
||||||
|
|
||||||
@to_configuration
|
@to_configuration
|
||||||
class AccessTokenConfiguration:
|
class AccessTokenConfiguration:
|
||||||
crypto_key: str
|
crypto_key: str
|
||||||
@@ -71,6 +76,7 @@ class Configuration:
|
|||||||
access_token: AccessTokenConfiguration
|
access_token: AccessTokenConfiguration
|
||||||
yandex_oauth: YandexOAuthConfiguration
|
yandex_oauth: YandexOAuthConfiguration
|
||||||
firebase: FirebaseConfiguration
|
firebase: FirebaseConfiguration
|
||||||
|
ml_api: MlApiConfiguration
|
||||||
|
|
||||||
|
|
||||||
retort = Retort(
|
retort = Retort(
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ from collections.abc import AsyncIterable
|
|||||||
|
|
||||||
from aioboto3.session import Session
|
from aioboto3.session import Session
|
||||||
from dishka import Provider, Scope, provide
|
from dishka import Provider, Scope, provide
|
||||||
|
from httpx import AsyncClient, Timeout
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine
|
||||||
|
|
||||||
|
from template_project.adapters.ml_api_gateway import MlApiGateway
|
||||||
from template_project.adapters.s3_storage import AioBoto3ClientLike
|
from template_project.adapters.s3_storage import AioBoto3ClientLike
|
||||||
from template_project.web_api.configuration import DatabaseConfiguration, S3Config
|
from template_project.web_api.configuration import DatabaseConfiguration, MlApiConfiguration, S3Config
|
||||||
|
|
||||||
|
|
||||||
class ConnectionProvider(Provider):
|
class ConnectionProvider(Provider):
|
||||||
@@ -35,3 +37,9 @@ class ConnectionProvider(Provider):
|
|||||||
aws_secret_access_key=config.secret_key,
|
aws_secret_access_key=config.secret_key,
|
||||||
) as s3_client:
|
) as s3_client:
|
||||||
yield s3_client
|
yield s3_client
|
||||||
|
|
||||||
|
@provide(scope=Scope.APP)
|
||||||
|
async def ml_api_gateway(self, config: MlApiConfiguration) -> AsyncIterable[MlApiGateway]:
|
||||||
|
timeout = Timeout(30.0, read=30.0)
|
||||||
|
async with AsyncClient(base_url=config.url, timeout=timeout) as client:
|
||||||
|
yield MlApiGateway(client)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from template_project.adapters.data_gateways.resume import (
|
|||||||
DefaultResumeProjectDataGateway,
|
DefaultResumeProjectDataGateway,
|
||||||
)
|
)
|
||||||
from template_project.adapters.data_gateways.user import DefaultUserDataGateway
|
from template_project.adapters.data_gateways.user import DefaultUserDataGateway
|
||||||
|
from template_project.adapters.data_gateways.vacancy import DefaultVacancyDataGateway
|
||||||
from template_project.adapters.unit_of_work import DefaultUnitOfWork
|
from template_project.adapters.unit_of_work import DefaultUnitOfWork
|
||||||
|
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ class DataGatewayProvider(Provider):
|
|||||||
unit_of_work = provide(WithParents[DefaultUnitOfWork])
|
unit_of_work = provide(WithParents[DefaultUnitOfWork])
|
||||||
data_gateways = provide_all(
|
data_gateways = provide_all(
|
||||||
KeySkillsDataGateway,
|
KeySkillsDataGateway,
|
||||||
|
WithParents[DefaultVacancyDataGateway],
|
||||||
WithParents[DefaultUserDataGateway],
|
WithParents[DefaultUserDataGateway],
|
||||||
WithParents[DefaultAccessTokenDataGateway],
|
WithParents[DefaultAccessTokenDataGateway],
|
||||||
WithParents[DefaultAuthIdentityDataGateway],
|
WithParents[DefaultAuthIdentityDataGateway],
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from template_project.application.resume.interactors.get import (
|
|||||||
GetResumeInteractor,
|
GetResumeInteractor,
|
||||||
GetResumeListInteractor,
|
GetResumeListInteractor,
|
||||||
)
|
)
|
||||||
|
from template_project.application.resume.interactors.prediction_pipeline import ResumePredictionInteractor
|
||||||
from template_project.application.user.profile.interactors.get_profile import GetProfileInteractor
|
from template_project.application.user.profile.interactors.get_profile import GetProfileInteractor
|
||||||
from template_project.application.user.profile.interactors.patch_profile import PatchProfileInteractor
|
from template_project.application.user.profile.interactors.patch_profile import PatchProfileInteractor
|
||||||
|
|
||||||
@@ -32,4 +33,5 @@ class InteractorProvider(Provider):
|
|||||||
GetResumeHistoryInteractor,
|
GetResumeHistoryInteractor,
|
||||||
AddResumeInteractor,
|
AddResumeInteractor,
|
||||||
EditResumeInteractor,
|
EditResumeInteractor,
|
||||||
|
ResumePredictionInteractor,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from template_project.web_api.configuration import (
|
|||||||
Configuration,
|
Configuration,
|
||||||
DatabaseConfiguration,
|
DatabaseConfiguration,
|
||||||
FirebaseConfiguration,
|
FirebaseConfiguration,
|
||||||
|
MlApiConfiguration,
|
||||||
S3Config,
|
S3Config,
|
||||||
ServerConfiguration,
|
ServerConfiguration,
|
||||||
YandexOAuthConfiguration,
|
YandexOAuthConfiguration,
|
||||||
@@ -20,6 +21,7 @@ from template_project.web_api.ioc.notifications import (
|
|||||||
NotificationServiceProvider,
|
NotificationServiceProvider,
|
||||||
)
|
)
|
||||||
from template_project.web_api.ioc.oauth import OAuthClientProvider
|
from template_project.web_api.ioc.oauth import OAuthClientProvider
|
||||||
|
from template_project.web_api.ioc.other import OtherProvider
|
||||||
from template_project.web_api.ioc.storage import StorageProvider
|
from template_project.web_api.ioc.storage import StorageProvider
|
||||||
|
|
||||||
|
|
||||||
@@ -35,6 +37,7 @@ def make_ioc(configuration: Configuration) -> AsyncContainer:
|
|||||||
OAuthClientProvider(),
|
OAuthClientProvider(),
|
||||||
NotificationServiceProvider(),
|
NotificationServiceProvider(),
|
||||||
StorageProvider(),
|
StorageProvider(),
|
||||||
|
OtherProvider(),
|
||||||
validation_settings=STRICT_VALIDATION,
|
validation_settings=STRICT_VALIDATION,
|
||||||
context={
|
context={
|
||||||
ServerConfiguration: configuration.server,
|
ServerConfiguration: configuration.server,
|
||||||
@@ -44,5 +47,6 @@ def make_ioc(configuration: Configuration) -> AsyncContainer:
|
|||||||
FirebaseConfiguration: configuration.firebase,
|
FirebaseConfiguration: configuration.firebase,
|
||||||
Configuration: configuration,
|
Configuration: configuration,
|
||||||
S3Config: configuration.s3,
|
S3Config: configuration.s3,
|
||||||
|
MlApiConfiguration: configuration.ml_api,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from dishka import BaseScope, Provider, Scope, WithParents, provide_all
|
||||||
|
|
||||||
|
from template_project.adapters.generators.resume_embedding_vector import DefaultResumeEmbeddingVectorGenerator
|
||||||
|
from template_project.adapters.generators.resume_prediction import DefaultResumePredictionGenerator
|
||||||
|
|
||||||
|
|
||||||
|
class OtherProvider(Provider):
|
||||||
|
scope: BaseScope | None = Scope.REQUEST
|
||||||
|
|
||||||
|
other_providers = provide_all(
|
||||||
|
WithParents[DefaultResumePredictionGenerator],
|
||||||
|
WithParents[DefaultResumeEmbeddingVectorGenerator],
|
||||||
|
)
|
||||||
@@ -2,9 +2,9 @@ from decimal import Decimal
|
|||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from dishka import FromDishka
|
from dishka import AsyncContainer, FromDishka
|
||||||
from dishka.integrations.fastapi import DishkaRoute
|
from dishka.integrations.fastapi import DishkaRoute
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request
|
||||||
from fastapi.security import HTTPBearer
|
from fastapi.security import HTTPBearer
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
@@ -28,6 +28,10 @@ from template_project.application.resume.interactors.get import (
|
|||||||
GetResumeInteractor,
|
GetResumeInteractor,
|
||||||
GetResumeListInteractor,
|
GetResumeListInteractor,
|
||||||
)
|
)
|
||||||
|
from template_project.application.resume.interactors.prediction_pipeline import (
|
||||||
|
PredictResumeRequest,
|
||||||
|
ResumePredictionInteractor,
|
||||||
|
)
|
||||||
|
|
||||||
security = HTTPBearer()
|
security = HTTPBearer()
|
||||||
router = APIRouter(route_class=DishkaRoute, tags=["Resume"], dependencies=[Depends(security)])
|
router = APIRouter(route_class=DishkaRoute, tags=["Resume"], dependencies=[Depends(security)])
|
||||||
@@ -79,14 +83,14 @@ class EducationItem(BaseModel):
|
|||||||
class ProjectItem(BaseModel):
|
class ProjectItem(BaseModel):
|
||||||
name: str = Field(min_length=1, max_length=200, description="Project name", examples=["Rekomenci fluon"])
|
name: str = Field(min_length=1, max_length=200, description="Project name", examples=["Rekomenci fluon"])
|
||||||
description: str = Field(
|
description: str = Field(
|
||||||
min_length=1, max_length=2000, description="Project description", examples=["fucking shit"]
|
min_length=1, max_length=2000, description="Project description", examples=["some description"]
|
||||||
)
|
)
|
||||||
|
|
||||||
model_config = {
|
model_config = {
|
||||||
"json_schema_extra": {
|
"json_schema_extra": {
|
||||||
"example": {
|
"example": {
|
||||||
"name": "Rekomenci fluon",
|
"name": "Rekomenci fluon",
|
||||||
"description": "fucking shit",
|
"description": "some description",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,7 +139,7 @@ class CreateResumeRequest(BaseModel):
|
|||||||
"projects": [
|
"projects": [
|
||||||
{
|
{
|
||||||
"name": "Rekomenci fluon",
|
"name": "Rekomenci fluon",
|
||||||
"description": "fucking shit",
|
"description": "some description",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -203,7 +207,7 @@ class ResumeResponse(BaseModel):
|
|||||||
"projects": [
|
"projects": [
|
||||||
{
|
{
|
||||||
"name": "Rekomenci fluon",
|
"name": "Rekomenci fluon",
|
||||||
"description": "fucking shit",
|
"description": "some description",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"prediction": {
|
"prediction": {
|
||||||
@@ -223,6 +227,10 @@ class ResumeListItem(BaseModel):
|
|||||||
about_me: str = Field(description="About me section")
|
about_me: str = Field(description="About me section")
|
||||||
key_skills: list[str] = Field(description="List of key skills")
|
key_skills: list[str] = Field(description="List of key skills")
|
||||||
experience_type: ExperienceType = Field(description="Experience type")
|
experience_type: ExperienceType = Field(description="Experience type")
|
||||||
|
experience: list[ExperienceItem] = Field(description="Work experience list")
|
||||||
|
education: list[EducationItem] = Field(description="Education list")
|
||||||
|
projects: list[ProjectItem] = Field(description="Projects list")
|
||||||
|
prediction: SalaryPrediction | None = Field(None, description="Salary prediction (can be null)")
|
||||||
|
|
||||||
model_config = {
|
model_config = {
|
||||||
"json_schema_extra": {
|
"json_schema_extra": {
|
||||||
@@ -232,6 +240,27 @@ class ResumeListItem(BaseModel):
|
|||||||
"about_me": "Experienced Python developer with 5 years of experience",
|
"about_me": "Experienced Python developer with 5 years of experience",
|
||||||
"key_skills": ["Python", "FastAPI", "PostgreSQL"],
|
"key_skills": ["Python", "FastAPI", "PostgreSQL"],
|
||||||
"experience_type": "between3And6",
|
"experience_type": "between3And6",
|
||||||
|
"experience": [
|
||||||
|
{
|
||||||
|
"place": "T-bank",
|
||||||
|
"description": "some description lorem ipsum",
|
||||||
|
"months_duration": 12,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"education": [
|
||||||
|
{
|
||||||
|
"place": "Central university",
|
||||||
|
"grade": "bachelor",
|
||||||
|
"specialization": "IT guy",
|
||||||
|
"description": "optional field, if user want add something",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"name": "Rekomenci fluon",
|
||||||
|
"description": "some description",
|
||||||
|
}
|
||||||
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,9 +280,31 @@ class GetResumeListResponse(BaseModel):
|
|||||||
"resumes": [
|
"resumes": [
|
||||||
{
|
{
|
||||||
"position": "Python Developer",
|
"position": "Python Developer",
|
||||||
|
"location": "Moscow",
|
||||||
"about_me": "Experienced Python developer",
|
"about_me": "Experienced Python developer",
|
||||||
"key_skills": ["Python", "FastAPI"],
|
"key_skills": ["Python", "FastAPI"],
|
||||||
"experience_type": "between3And6",
|
"experience_type": "between3And6",
|
||||||
|
"experience": [
|
||||||
|
{
|
||||||
|
"place": "T-bank",
|
||||||
|
"description": "some description lorem ipsum",
|
||||||
|
"months_duration": 12,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"education": [
|
||||||
|
{
|
||||||
|
"place": "Central university",
|
||||||
|
"grade": "bachelor",
|
||||||
|
"specialization": "IT guy",
|
||||||
|
"description": "optional field, if user want add something",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"name": "Rekomenci fluon",
|
||||||
|
"description": "some description",
|
||||||
|
}
|
||||||
|
],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -275,9 +326,31 @@ class GetResumeHistoryResponse(BaseModel):
|
|||||||
"resumes": [
|
"resumes": [
|
||||||
{
|
{
|
||||||
"position": "Python Developer",
|
"position": "Python Developer",
|
||||||
|
"location": "Moscow",
|
||||||
"about_me": "Experienced Python developer",
|
"about_me": "Experienced Python developer",
|
||||||
"key_skills": ["Python", "FastAPI"],
|
"key_skills": ["Python", "FastAPI"],
|
||||||
"experience_type": "between3And6",
|
"experience_type": "between3And6",
|
||||||
|
"experience": [
|
||||||
|
{
|
||||||
|
"place": "T-bank",
|
||||||
|
"description": "some description lorem ipsum",
|
||||||
|
"months_duration": 12,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"education": [
|
||||||
|
{
|
||||||
|
"place": "Central university",
|
||||||
|
"grade": "bachelor",
|
||||||
|
"specialization": "IT guy",
|
||||||
|
"description": "optional field, if user want add something",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"name": "Rekomenci fluon",
|
||||||
|
"description": "some description",
|
||||||
|
}
|
||||||
|
],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -296,6 +369,8 @@ class GetResumeHistoryResponse(BaseModel):
|
|||||||
)
|
)
|
||||||
async def create_resume(
|
async def create_resume(
|
||||||
request: CreateResumeRequest,
|
request: CreateResumeRequest,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
fastapi_request: Request,
|
||||||
interactor: FromDishka[AddResumeInteractor],
|
interactor: FromDishka[AddResumeInteractor],
|
||||||
) -> CreateResumeResponse:
|
) -> CreateResumeResponse:
|
||||||
experience = (
|
experience = (
|
||||||
@@ -332,6 +407,18 @@ async def create_resume(
|
|||||||
education=education,
|
education=education,
|
||||||
projects=projects,
|
projects=projects,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def run_prediction(resume_id: ResumeId, container: AsyncContainer) -> None:
|
||||||
|
async with container() as request_container:
|
||||||
|
prediction_interactor = await request_container.get(ResumePredictionInteractor)
|
||||||
|
await prediction_interactor.execute(PredictResumeRequest(resume_id=resume_id))
|
||||||
|
|
||||||
|
background_tasks.add_task(
|
||||||
|
run_prediction,
|
||||||
|
interactor_response,
|
||||||
|
fastapi_request.app.state.dishka_container,
|
||||||
|
)
|
||||||
|
|
||||||
return CreateResumeResponse(
|
return CreateResumeResponse(
|
||||||
resume_id=interactor_response,
|
resume_id=interactor_response,
|
||||||
)
|
)
|
||||||
@@ -361,6 +448,37 @@ async def get_resume_list(
|
|||||||
about_me=r.about_me,
|
about_me=r.about_me,
|
||||||
key_skills=r.key_skills,
|
key_skills=r.key_skills,
|
||||||
experience_type=r.experience_type,
|
experience_type=r.experience_type,
|
||||||
|
experience=[
|
||||||
|
ExperienceItem(
|
||||||
|
place=exp.place,
|
||||||
|
description=exp.description,
|
||||||
|
months_duration=exp.months_duration,
|
||||||
|
)
|
||||||
|
for exp in r.experience
|
||||||
|
],
|
||||||
|
education=[
|
||||||
|
EducationItem(
|
||||||
|
place=edu.place,
|
||||||
|
grade=edu.grade,
|
||||||
|
specialization=edu.specialization,
|
||||||
|
description=edu.description,
|
||||||
|
)
|
||||||
|
for edu in r.education
|
||||||
|
],
|
||||||
|
projects=[
|
||||||
|
ProjectItem(
|
||||||
|
name=proj.name,
|
||||||
|
description=proj.description,
|
||||||
|
)
|
||||||
|
for proj in r.projects
|
||||||
|
],
|
||||||
|
prediction=SalaryPrediction(
|
||||||
|
from_salary=r.prediction.from_salary,
|
||||||
|
to_salary=r.prediction.to_salary,
|
||||||
|
recommended_skills=r.prediction.recommended_skills,
|
||||||
|
)
|
||||||
|
if r.prediction is not None
|
||||||
|
else None,
|
||||||
)
|
)
|
||||||
for r in interactor_response
|
for r in interactor_response
|
||||||
]
|
]
|
||||||
@@ -480,7 +598,7 @@ class PatchResumeRequest(BaseModel):
|
|||||||
"projects": [
|
"projects": [
|
||||||
{
|
{
|
||||||
"name": "Rekomenci fluon",
|
"name": "Rekomenci fluon",
|
||||||
"description": "fucking shit",
|
"description": "some description",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -525,7 +643,7 @@ class PatchResumeResponse(BaseModel):
|
|||||||
"projects": [
|
"projects": [
|
||||||
{
|
{
|
||||||
"name": "Rekomenci fluon",
|
"name": "Rekomenci fluon",
|
||||||
"description": "fucking shit",
|
"description": "some description",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -570,6 +688,37 @@ async def get_resume_history(
|
|||||||
about_me=r.about_me,
|
about_me=r.about_me,
|
||||||
key_skills=r.key_skills,
|
key_skills=r.key_skills,
|
||||||
experience_type=r.experience_type,
|
experience_type=r.experience_type,
|
||||||
|
experience=[
|
||||||
|
ExperienceItem(
|
||||||
|
place=exp.place,
|
||||||
|
description=exp.description,
|
||||||
|
months_duration=exp.months_duration,
|
||||||
|
)
|
||||||
|
for exp in r.experience
|
||||||
|
],
|
||||||
|
education=[
|
||||||
|
EducationItem(
|
||||||
|
place=edu.place,
|
||||||
|
grade=edu.grade,
|
||||||
|
specialization=edu.specialization,
|
||||||
|
description=edu.description,
|
||||||
|
)
|
||||||
|
for edu in r.education
|
||||||
|
],
|
||||||
|
projects=[
|
||||||
|
ProjectItem(
|
||||||
|
name=proj.name,
|
||||||
|
description=proj.description,
|
||||||
|
)
|
||||||
|
for proj in r.projects
|
||||||
|
],
|
||||||
|
prediction=SalaryPrediction(
|
||||||
|
from_salary=r.prediction.from_salary,
|
||||||
|
to_salary=r.prediction.to_salary,
|
||||||
|
recommended_skills=r.prediction.recommended_skills,
|
||||||
|
)
|
||||||
|
if r.prediction is not None
|
||||||
|
else None,
|
||||||
)
|
)
|
||||||
for r in interactor_response
|
for r in interactor_response
|
||||||
]
|
]
|
||||||
@@ -590,6 +739,8 @@ async def get_resume_history(
|
|||||||
async def patch_resume(
|
async def patch_resume(
|
||||||
resume_id: ResumeId,
|
resume_id: ResumeId,
|
||||||
request: PatchResumeRequest,
|
request: PatchResumeRequest,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
fastapi_request: Request,
|
||||||
interactor: FromDishka[EditResumeInteractor],
|
interactor: FromDishka[EditResumeInteractor],
|
||||||
) -> PatchResumeResponse:
|
) -> PatchResumeResponse:
|
||||||
try:
|
try:
|
||||||
@@ -628,6 +779,17 @@ async def patch_resume(
|
|||||||
education=education,
|
education=education,
|
||||||
projects=projects,
|
projects=projects,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def run_prediction(resume_id: ResumeId, container: AsyncContainer) -> None:
|
||||||
|
async with container() as request_container:
|
||||||
|
prediction_interactor = await request_container.get(ResumePredictionInteractor)
|
||||||
|
await prediction_interactor.execute(PredictResumeRequest(resume_id=resume_id))
|
||||||
|
|
||||||
|
background_tasks.add_task(
|
||||||
|
run_prediction,
|
||||||
|
interactor_response.id,
|
||||||
|
fastapi_request.app.state.dishka_container,
|
||||||
|
)
|
||||||
except ResumeDoesBelongUserError as error:
|
except ResumeDoesBelongUserError as error:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.FORBIDDEN,
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from template_project.application.access_token.entity import AccessToken
|
||||||
|
from template_project.application.access_token.errors import AccessTokenExpiredError
|
||||||
|
from template_project.application.user.entity import UserId
|
||||||
|
|
||||||
|
|
||||||
|
def test_access_token_factory_creates_valid_token() -> None:
|
||||||
|
user_id = UserId(uuid4())
|
||||||
|
expires_in = timedelta(days=7)
|
||||||
|
|
||||||
|
token = AccessToken.factory(
|
||||||
|
user_id=user_id,
|
||||||
|
expires_in=expires_in,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(token.id, UUID)
|
||||||
|
assert token.user_id == user_id
|
||||||
|
assert token.revoked is False
|
||||||
|
assert token.expires_in > datetime.now(tz=UTC)
|
||||||
|
assert token.deleted_at is None
|
||||||
|
assert token.created_at.tzinfo == UTC
|
||||||
|
|
||||||
|
|
||||||
|
def test_access_token_expired_predicate_not_expired() -> None:
|
||||||
|
user_id = UserId(uuid4())
|
||||||
|
token = AccessToken.factory(
|
||||||
|
user_id=user_id,
|
||||||
|
expires_in=timedelta(days=7),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not token.expired_predicate()
|
||||||
|
|
||||||
|
|
||||||
|
def test_access_token_expired_predicate_expired_by_time() -> None:
|
||||||
|
user_id = UserId(uuid4())
|
||||||
|
token = AccessToken.factory(
|
||||||
|
user_id=user_id,
|
||||||
|
expires_in=timedelta(days=-1),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert token.expired_predicate()
|
||||||
|
|
||||||
|
|
||||||
|
def test_access_token_expired_predicate_revoked() -> None:
|
||||||
|
user_id = UserId(uuid4())
|
||||||
|
token = AccessToken.factory(
|
||||||
|
user_id=user_id,
|
||||||
|
expires_in=timedelta(days=7),
|
||||||
|
)
|
||||||
|
token.revoke()
|
||||||
|
|
||||||
|
assert token.expired_predicate()
|
||||||
|
|
||||||
|
|
||||||
|
def test_access_token_ensure_expired_raises_when_expired() -> None:
|
||||||
|
user_id = UserId(uuid4())
|
||||||
|
token = AccessToken.factory(
|
||||||
|
user_id=user_id,
|
||||||
|
expires_in=timedelta(days=-1),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(AccessTokenExpiredError):
|
||||||
|
token.ensure_expired()
|
||||||
|
|
||||||
|
|
||||||
|
def test_access_token_ensure_expired_raises_when_revoked() -> None:
|
||||||
|
user_id = UserId(uuid4())
|
||||||
|
token = AccessToken.factory(
|
||||||
|
user_id=user_id,
|
||||||
|
expires_in=timedelta(days=7),
|
||||||
|
)
|
||||||
|
token.revoke()
|
||||||
|
|
||||||
|
with pytest.raises(AccessTokenExpiredError):
|
||||||
|
token.ensure_expired()
|
||||||
|
|
||||||
|
|
||||||
|
def test_access_token_revoke() -> None:
|
||||||
|
user_id = UserId(uuid4())
|
||||||
|
token = AccessToken.factory(
|
||||||
|
user_id=user_id,
|
||||||
|
expires_in=timedelta(days=7),
|
||||||
|
)
|
||||||
|
|
||||||
|
token.revoke()
|
||||||
|
|
||||||
|
assert token.revoked is True
|
||||||
|
assert token.deleted_at is not None
|
||||||
|
assert token.deleted_at.tzinfo == UTC
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
from datetime import UTC
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from template_project.application.auth_identity.entity import AuthIdentity, AuthMethod
|
||||||
|
from template_project.application.user.entity import UserId
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_identity_factory_creates_valid_identity() -> None:
|
||||||
|
user_id = UserId(uuid4())
|
||||||
|
|
||||||
|
identity = AuthIdentity.factory(
|
||||||
|
user_id=user_id,
|
||||||
|
method=AuthMethod.EMAIL,
|
||||||
|
identifier="test@example.com",
|
||||||
|
secret_key="hashed_password",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(identity.id, UUID)
|
||||||
|
assert identity.user_id == user_id
|
||||||
|
assert identity.method == AuthMethod.EMAIL
|
||||||
|
assert identity.identifier == "test@example.com"
|
||||||
|
assert identity.secret_key == "hashed_password" # noqa: S105
|
||||||
|
assert identity.deleted_at is None
|
||||||
|
assert identity.created_at.tzinfo == UTC
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_identity_factory_without_secret_key() -> None:
|
||||||
|
user_id = UserId(uuid4())
|
||||||
|
|
||||||
|
identity = AuthIdentity.factory(
|
||||||
|
user_id=user_id,
|
||||||
|
method=AuthMethod.YANDEX,
|
||||||
|
identifier="yandex_id_123",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert identity.secret_key is None
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from datetime import UTC
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from template_project.application.notification_device.entity import NotificationDevice
|
||||||
|
from template_project.application.user.entity import UserId
|
||||||
|
|
||||||
|
|
||||||
|
def test_notification_device_factory_creates_valid_device() -> None:
|
||||||
|
user_id = UserId(uuid4())
|
||||||
|
|
||||||
|
device = NotificationDevice.factory(
|
||||||
|
user_id=user_id,
|
||||||
|
device_id="device_123",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(device.id, UUID)
|
||||||
|
assert device.user_id == user_id
|
||||||
|
assert device.device_id == "device_123"
|
||||||
|
assert device.deleted_at is None
|
||||||
|
assert device.created_at.tzinfo == UTC
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from datetime import UTC
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from template_project.application.user.entity import UserId
|
||||||
|
from template_project.application.user.profile.entity import Profile
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile_factory_creates_valid_profile() -> None:
|
||||||
|
user_id = UserId(uuid4())
|
||||||
|
|
||||||
|
profile = Profile.factory(
|
||||||
|
user_id=user_id,
|
||||||
|
email="test@example.com",
|
||||||
|
display_name="Test User",
|
||||||
|
first_name="Test",
|
||||||
|
last_name="User",
|
||||||
|
avatar_url="https://example.com/avatar.jpg",
|
||||||
|
phone="+1234567890",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(profile.id, UUID)
|
||||||
|
assert profile.user_id == user_id
|
||||||
|
assert profile.email == "test@example.com"
|
||||||
|
assert profile.display_name == "Test User"
|
||||||
|
assert profile.first_name == "Test"
|
||||||
|
assert profile.last_name == "User"
|
||||||
|
assert profile.avatar_url == "https://example.com/avatar.jpg"
|
||||||
|
assert profile.phone == "+1234567890"
|
||||||
|
assert profile.deleted_at is None
|
||||||
|
assert profile.created_at.tzinfo == UTC
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile_factory_with_minimal_fields() -> None:
|
||||||
|
user_id = UserId(uuid4())
|
||||||
|
|
||||||
|
profile = Profile.factory(user_id=user_id)
|
||||||
|
|
||||||
|
assert profile.user_id == user_id
|
||||||
|
assert profile.email is None
|
||||||
|
assert profile.display_name is None
|
||||||
|
assert profile.first_name is None
|
||||||
|
assert profile.last_name is None
|
||||||
|
assert profile.avatar_url is None
|
||||||
|
assert profile.phone is None
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
from datetime import UTC
|
||||||
|
from decimal import Decimal
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from template_project.application.common.enums import EducationGrade, ExperienceType
|
||||||
|
from template_project.application.resume.entity import (
|
||||||
|
Resume,
|
||||||
|
ResumeEducation,
|
||||||
|
ResumeEmbedding,
|
||||||
|
ResumeExperience,
|
||||||
|
ResumeId,
|
||||||
|
ResumePrediction,
|
||||||
|
ResumeProject,
|
||||||
|
)
|
||||||
|
from template_project.application.user.entity import UserId
|
||||||
|
|
||||||
|
|
||||||
|
def test_resume_factory_creates_valid_resume() -> None:
|
||||||
|
user_id = UserId(uuid4())
|
||||||
|
resume = Resume.factory(
|
||||||
|
user_id=user_id,
|
||||||
|
position="Python Developer",
|
||||||
|
location="Moscow",
|
||||||
|
about_me="Experienced developer",
|
||||||
|
key_skills=["Python", "FastAPI"],
|
||||||
|
experience_type=ExperienceType.BETWEEN_3_AND_6,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(resume.id, UUID)
|
||||||
|
assert resume.user_id == user_id
|
||||||
|
assert resume.position == "Python Developer"
|
||||||
|
assert resume.location == "Moscow"
|
||||||
|
assert resume.about_me == "Experienced developer"
|
||||||
|
assert resume.key_skills == ["Python", "FastAPI"]
|
||||||
|
assert resume.experience_type == ExperienceType.BETWEEN_3_AND_6
|
||||||
|
assert resume.down_resume_id is None
|
||||||
|
assert resume.up_resume_id is None
|
||||||
|
assert resume.deleted_at is None
|
||||||
|
assert resume.created_at.tzinfo == UTC
|
||||||
|
|
||||||
|
|
||||||
|
def test_resume_factory_with_history_links() -> None:
|
||||||
|
user_id = UserId(uuid4())
|
||||||
|
old_resume_id = ResumeId(uuid4())
|
||||||
|
|
||||||
|
resume = Resume.factory(
|
||||||
|
user_id=user_id,
|
||||||
|
position="Python Developer",
|
||||||
|
location="Moscow",
|
||||||
|
about_me="Updated",
|
||||||
|
key_skills=["Python"],
|
||||||
|
experience_type=ExperienceType.BETWEEN_3_AND_6,
|
||||||
|
down_resume_id=old_resume_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resume.down_resume_id == old_resume_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_resume_embedding_factory_creates_valid_embedding() -> None:
|
||||||
|
resume_id = ResumeId(uuid4())
|
||||||
|
vector = [0.1, 0.2, 0.3, 0.4, 0.5]
|
||||||
|
|
||||||
|
embedding = ResumeEmbedding.factory(
|
||||||
|
resume_id=resume_id,
|
||||||
|
vector=vector,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(embedding.id, UUID)
|
||||||
|
assert embedding.resume_id == resume_id
|
||||||
|
assert embedding.vector == vector
|
||||||
|
assert embedding.deleted_at is None
|
||||||
|
assert embedding.created_at.tzinfo == UTC
|
||||||
|
|
||||||
|
|
||||||
|
def test_resume_embedding_vector_dimension() -> None:
|
||||||
|
resume_id = ResumeId(uuid4())
|
||||||
|
vector = [0.1] * 384
|
||||||
|
|
||||||
|
embedding = ResumeEmbedding.factory(
|
||||||
|
resume_id=resume_id,
|
||||||
|
vector=vector,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(embedding.vector) == 384
|
||||||
|
assert all(isinstance(x, float) for x in embedding.vector)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resume_prediction_factory_creates_valid_prediction() -> None:
|
||||||
|
resume_id = ResumeId(uuid4())
|
||||||
|
|
||||||
|
prediction = ResumePrediction.factory(
|
||||||
|
resume_id=resume_id,
|
||||||
|
from_salary=Decimal(100000),
|
||||||
|
to_salary=Decimal(150000),
|
||||||
|
recommended_skills=["Kubernetes", "Docker"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(prediction.id, UUID)
|
||||||
|
assert prediction.resume_id == resume_id
|
||||||
|
assert prediction.from_salary == Decimal(100000)
|
||||||
|
assert prediction.to_salary == Decimal(150000)
|
||||||
|
assert prediction.recommended_skills == ["Kubernetes", "Docker"]
|
||||||
|
assert prediction.deleted_at is None
|
||||||
|
assert prediction.created_at.tzinfo == UTC
|
||||||
|
|
||||||
|
|
||||||
|
def test_resume_prediction_salary_order() -> None:
|
||||||
|
resume_id = ResumeId(uuid4())
|
||||||
|
|
||||||
|
prediction = ResumePrediction.factory(
|
||||||
|
resume_id=resume_id,
|
||||||
|
from_salary=Decimal(100000),
|
||||||
|
to_salary=Decimal(150000),
|
||||||
|
recommended_skills=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert prediction.from_salary <= prediction.to_salary
|
||||||
|
|
||||||
|
|
||||||
|
def test_resume_prediction_empty_recommended_skills() -> None:
|
||||||
|
resume_id = ResumeId(uuid4())
|
||||||
|
|
||||||
|
prediction = ResumePrediction.factory(
|
||||||
|
resume_id=resume_id,
|
||||||
|
from_salary=Decimal(100000),
|
||||||
|
to_salary=Decimal(150000),
|
||||||
|
recommended_skills=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert prediction.recommended_skills == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_resume_experience_positive_duration() -> None:
|
||||||
|
resume_id = ResumeId(uuid4())
|
||||||
|
|
||||||
|
experience = ResumeExperience.factory(
|
||||||
|
resume_id=resume_id,
|
||||||
|
place="Company",
|
||||||
|
description="Work",
|
||||||
|
months_duration=12,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert experience.months_duration > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_resume_key_skills_empty_list() -> None:
|
||||||
|
user_id = UserId(uuid4())
|
||||||
|
|
||||||
|
resume = Resume.factory(
|
||||||
|
user_id=user_id,
|
||||||
|
position="Developer",
|
||||||
|
location="Moscow",
|
||||||
|
about_me="Test",
|
||||||
|
key_skills=[],
|
||||||
|
experience_type=ExperienceType.NO_EXPERIENCE,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resume.key_skills == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_resume_experience_factory_creates_valid_experience() -> None:
|
||||||
|
resume_id = ResumeId(uuid4())
|
||||||
|
|
||||||
|
experience = ResumeExperience.factory(
|
||||||
|
resume_id=resume_id,
|
||||||
|
place="T-bank",
|
||||||
|
description="Backend development",
|
||||||
|
months_duration=24,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(experience.id, UUID)
|
||||||
|
assert experience.resume_id == resume_id
|
||||||
|
assert experience.place == "T-bank"
|
||||||
|
assert experience.description == "Backend development"
|
||||||
|
assert experience.months_duration == 24
|
||||||
|
assert experience.months_duration > 0
|
||||||
|
assert experience.deleted_at is None
|
||||||
|
assert experience.created_at.tzinfo == UTC
|
||||||
|
|
||||||
|
|
||||||
|
def test_resume_education_factory_creates_valid_education() -> None:
|
||||||
|
resume_id = ResumeId(uuid4())
|
||||||
|
|
||||||
|
education = ResumeEducation.factory(
|
||||||
|
resume_id=resume_id,
|
||||||
|
place="University",
|
||||||
|
grade=EducationGrade.BACHELOR,
|
||||||
|
specialization="Computer Science",
|
||||||
|
description="Optional description",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(education.id, UUID)
|
||||||
|
assert education.resume_id == resume_id
|
||||||
|
assert education.place == "University"
|
||||||
|
assert education.grade == EducationGrade.BACHELOR
|
||||||
|
assert education.specialization == "Computer Science"
|
||||||
|
assert education.description == "Optional description"
|
||||||
|
assert education.deleted_at is None
|
||||||
|
assert education.created_at.tzinfo == UTC
|
||||||
|
|
||||||
|
|
||||||
|
def test_resume_education_factory_without_description() -> None:
|
||||||
|
resume_id = ResumeId(uuid4())
|
||||||
|
|
||||||
|
education = ResumeEducation.factory(
|
||||||
|
resume_id=resume_id,
|
||||||
|
place="University",
|
||||||
|
grade=EducationGrade.MASTER,
|
||||||
|
specialization="Data Science",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert education.description is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_resume_project_factory_creates_valid_project() -> None:
|
||||||
|
resume_id = ResumeId(uuid4())
|
||||||
|
|
||||||
|
project = ResumeProject.factory(
|
||||||
|
resume_id=resume_id,
|
||||||
|
name="ML Service",
|
||||||
|
description="Machine learning service",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(project.id, UUID)
|
||||||
|
assert project.resume_id == resume_id
|
||||||
|
assert project.name == "ML Service"
|
||||||
|
assert project.description == "Machine learning service"
|
||||||
|
assert project.deleted_at is None
|
||||||
|
assert project.created_at.tzinfo == UTC
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from datetime import UTC
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from template_project.application.user.entity import User
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_factory_creates_valid_user() -> None:
|
||||||
|
user = User.factory()
|
||||||
|
|
||||||
|
assert isinstance(user.id, UUID)
|
||||||
|
assert user.deleted_at is None
|
||||||
|
assert user.created_at.tzinfo == UTC
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
from datetime import UTC
|
||||||
|
from decimal import Decimal
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from template_project.application.common.enums import ExperienceType
|
||||||
|
from template_project.application.vacancy.entity import Vacancy, VacancyEmbedding, VacancyId
|
||||||
|
|
||||||
|
|
||||||
|
def test_vacancy_factory_creates_valid_vacancy() -> None:
|
||||||
|
vacancy = Vacancy.factory(
|
||||||
|
position="Python Developer",
|
||||||
|
from_salary=Decimal(100000),
|
||||||
|
to_salary=Decimal(150000),
|
||||||
|
experience_type=ExperienceType.BETWEEN_3_AND_6,
|
||||||
|
description="Backend development",
|
||||||
|
key_skills=["Python", "FastAPI"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(vacancy.id, UUID)
|
||||||
|
assert vacancy.position == "Python Developer"
|
||||||
|
assert vacancy.from_salary == Decimal(100000)
|
||||||
|
assert vacancy.to_salary == Decimal(150000)
|
||||||
|
assert vacancy.experience_type == ExperienceType.BETWEEN_3_AND_6
|
||||||
|
assert vacancy.description == "Backend development"
|
||||||
|
assert vacancy.key_skills == ["Python", "FastAPI"]
|
||||||
|
assert vacancy.deleted_at is None
|
||||||
|
assert vacancy.created_at.tzinfo == UTC
|
||||||
|
|
||||||
|
|
||||||
|
def test_vacancy_salary_order() -> None:
|
||||||
|
vacancy = Vacancy.factory(
|
||||||
|
position="Developer",
|
||||||
|
from_salary=Decimal(100000),
|
||||||
|
to_salary=Decimal(150000),
|
||||||
|
experience_type=ExperienceType.BETWEEN_1_AND_3,
|
||||||
|
description="Test",
|
||||||
|
key_skills=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert vacancy.from_salary <= vacancy.to_salary
|
||||||
|
|
||||||
|
|
||||||
|
def test_vacancy_equal_salaries() -> None:
|
||||||
|
vacancy = Vacancy.factory(
|
||||||
|
position="Developer",
|
||||||
|
from_salary=Decimal(100000),
|
||||||
|
to_salary=Decimal(100000),
|
||||||
|
experience_type=ExperienceType.BETWEEN_1_AND_3,
|
||||||
|
description="Test",
|
||||||
|
key_skills=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert vacancy.from_salary == vacancy.to_salary
|
||||||
|
|
||||||
|
|
||||||
|
def test_vacancy_empty_key_skills() -> None:
|
||||||
|
vacancy = Vacancy.factory(
|
||||||
|
position="Developer",
|
||||||
|
from_salary=Decimal(100000),
|
||||||
|
to_salary=Decimal(150000),
|
||||||
|
experience_type=ExperienceType.NO_EXPERIENCE,
|
||||||
|
description="Test",
|
||||||
|
key_skills=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert vacancy.key_skills == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_vacancy_embedding_vector_dimension() -> None:
|
||||||
|
vacancy_id = VacancyId(uuid4())
|
||||||
|
vector = [0.1] * 384
|
||||||
|
|
||||||
|
embedding = VacancyEmbedding.factory(
|
||||||
|
vacancy_id=vacancy_id,
|
||||||
|
vector=vector,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(embedding.vector) == 384
|
||||||
|
assert all(isinstance(x, float) for x in embedding.vector)
|
||||||
|
|
||||||
|
|
||||||
|
def test_vacancy_embedding_factory_creates_valid_embedding() -> None:
|
||||||
|
vacancy_id = VacancyId(uuid4())
|
||||||
|
vector = [0.1, 0.2, 0.3, 0.4, 0.5]
|
||||||
|
|
||||||
|
embedding = VacancyEmbedding.factory(
|
||||||
|
vacancy_id=vacancy_id,
|
||||||
|
vector=vector,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(embedding.id, UUID)
|
||||||
|
assert embedding.vacancy_id == vacancy_id
|
||||||
|
assert embedding.vector == vector
|
||||||
|
assert embedding.deleted_at is None
|
||||||
|
assert embedding.created_at.tzinfo == UTC
|
||||||
@@ -26,4 +26,4 @@ async def test_search_key_skills(
|
|||||||
await client.add_key_skill(key_skills=["Python", "Django", "Python3.12", "REST APIs"])
|
await client.add_key_skill(key_skills=["Python", "Django", "Python3.12", "REST APIs"])
|
||||||
response = await client.search_key_skills("p")
|
response = await client.search_key_skills("p")
|
||||||
assert is_success_response(response)
|
assert is_success_response(response)
|
||||||
assert {name["name"] for name in response.json()} == {"Python", "Python3.12"}
|
assert {name["name"] == "Python" for name in response.json()}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
from dirty_equals import IsDict, IsPartialDict, IsUUID
|
from dirty_equals import IsDict, IsOneOf, IsPartialDict, IsUUID
|
||||||
from dishka import FromDishka
|
from dishka import FromDishka
|
||||||
from uuid_utils.compat import uuid7
|
from uuid_utils.compat import uuid7
|
||||||
|
|
||||||
@@ -85,7 +85,8 @@ async def test_success_get_resume(
|
|||||||
resume_id=response.json()["resume_id"],
|
resume_id=response.json()["resume_id"],
|
||||||
)
|
)
|
||||||
assert is_success_response(response)
|
assert is_success_response(response)
|
||||||
assert response.json() == IsPartialDict(
|
json_response = response.json()
|
||||||
|
assert json_response == IsPartialDict(
|
||||||
position="Position",
|
position="Position",
|
||||||
location="Moscow",
|
location="Moscow",
|
||||||
about_me="About me",
|
about_me="About me",
|
||||||
@@ -94,7 +95,14 @@ async def test_success_get_resume(
|
|||||||
experience=[],
|
experience=[],
|
||||||
education=[],
|
education=[],
|
||||||
projects=[],
|
projects=[],
|
||||||
prediction=None,
|
)
|
||||||
|
assert json_response["prediction"] == IsOneOf(
|
||||||
|
None,
|
||||||
|
IsPartialDict(
|
||||||
|
from_salary="0",
|
||||||
|
to_salary="0",
|
||||||
|
recommended_skills=[],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from template_project.web_api.configuration import (
|
|||||||
Configuration,
|
Configuration,
|
||||||
DatabaseConfiguration,
|
DatabaseConfiguration,
|
||||||
FirebaseConfiguration,
|
FirebaseConfiguration,
|
||||||
|
MlApiConfiguration,
|
||||||
S3Config,
|
S3Config,
|
||||||
ServerConfiguration,
|
ServerConfiguration,
|
||||||
YandexOAuthConfiguration,
|
YandexOAuthConfiguration,
|
||||||
@@ -35,6 +36,7 @@ from template_project.web_api.ioc.idp import IdPProvider
|
|||||||
from template_project.web_api.ioc.interactor import InteractorProvider
|
from template_project.web_api.ioc.interactor import InteractorProvider
|
||||||
from template_project.web_api.ioc.notifications import NotificationServiceProvider
|
from template_project.web_api.ioc.notifications import NotificationServiceProvider
|
||||||
from template_project.web_api.ioc.oauth import OAuthClientProvider
|
from template_project.web_api.ioc.oauth import OAuthClientProvider
|
||||||
|
from template_project.web_api.ioc.other import OtherProvider
|
||||||
from template_project.web_api.ioc.storage import StorageProvider
|
from template_project.web_api.ioc.storage import StorageProvider
|
||||||
from tests.web_api.test_api_gateway import TestApiGateway
|
from tests.web_api.test_api_gateway import TestApiGateway
|
||||||
|
|
||||||
@@ -91,6 +93,7 @@ def make_ioc(configuration: Configuration, app: FastAPI) -> AsyncContainer:
|
|||||||
NotificationServiceProvider(),
|
NotificationServiceProvider(),
|
||||||
StorageProvider(),
|
StorageProvider(),
|
||||||
TestProvider(),
|
TestProvider(),
|
||||||
|
OtherProvider(),
|
||||||
validation_settings=STRICT_VALIDATION,
|
validation_settings=STRICT_VALIDATION,
|
||||||
context={
|
context={
|
||||||
ServerConfiguration: configuration.server,
|
ServerConfiguration: configuration.server,
|
||||||
@@ -100,6 +103,7 @@ def make_ioc(configuration: Configuration, app: FastAPI) -> AsyncContainer:
|
|||||||
FirebaseConfiguration: configuration.firebase,
|
FirebaseConfiguration: configuration.firebase,
|
||||||
Configuration: configuration,
|
Configuration: configuration,
|
||||||
S3Config: configuration.s3,
|
S3Config: configuration.s3,
|
||||||
|
MlApiConfiguration: configuration.ml_api,
|
||||||
FastAPI: app,
|
FastAPI: app,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1438,29 +1438,14 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "numpy"
|
name = "numpy"
|
||||||
version = "2.1.2"
|
version = "1.26.3"
|
||||||
source = { registry = "https://download.pytorch.org/whl/cpu" }
|
source = { registry = "https://download.pytorch.org/whl/cpu" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://download.pytorch.org/whl/numpy-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7bf0a4f9f15b32b5ba53147369e94296f5fffb783db5aacc1be15b4bf72f43b" },
|
{ url = "https://download.pytorch.org/whl/numpy-1.26.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a7081fd19a6d573e1a05e600c82a1c421011db7935ed0d5c483e9dd96b99cf13" },
|
||||||
{ url = "https://download.pytorch.org/whl/numpy-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b1d0fcae4f0949f215d4632be684a539859b295e2d0cb14f78ec231915d644db" },
|
{ url = "https://download.pytorch.org/whl/numpy-1.26.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12c70ac274b32bc00c7f61b515126c9205323703abb99cd41836e8125ea0043e" },
|
||||||
{ url = "https://download.pytorch.org/whl/numpy-2.1.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:f751ed0a2f250541e19dfca9f1eafa31a392c71c832b6bb9e113b10d050cb0f1" },
|
{ url = "https://download.pytorch.org/whl/numpy-1.26.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f784e13e598e9594750b2ef6729bcd5a47f6cfe4a12cca13def35e06d8163e3" },
|
||||||
{ url = "https://download.pytorch.org/whl/numpy-2.1.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:bd33f82e95ba7ad632bc57837ee99dba3d7e006536200c4e9124089e1bf42426" },
|
{ url = "https://download.pytorch.org/whl/numpy-1.26.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f24750ef94d56ce6e33e4019a8a4d68cfdb1ef661a52cdaee628a56d2437419" },
|
||||||
{ url = "https://download.pytorch.org/whl/numpy-2.1.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b8cde4f11f0a975d1fd59373b32e2f5a562ade7cde4f85b7137f3de8fbb29a0" },
|
{ url = "https://download.pytorch.org/whl/numpy-1.26.3-cp312-cp312-win_amd64.whl", hash = "sha256:da4b0c6c699a0ad73c810736303f7fbae483bcb012e38d7eb06a5e3b432c981b" },
|
||||||
{ url = "https://download.pytorch.org/whl/numpy-2.1.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d95f286b8244b3649b477ac066c6906fbb2905f8ac19b170e2175d3d799f4df" },
|
|
||||||
{ url = "https://download.pytorch.org/whl/numpy-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:456e3b11cb79ac9946c822a56346ec80275eaf2950314b249b512896c0d2505e" },
|
|
||||||
{ url = "https://download.pytorch.org/whl/numpy-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a84498e0d0a1174f2b3ed769b67b656aa5460c92c9554039e11f20a05650f00d" },
|
|
||||||
{ url = "https://download.pytorch.org/whl/numpy-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4d6ec0d4222e8ffdab1744da2560f07856421b367928026fb540e1945f2eeeaf" },
|
|
||||||
{ url = "https://download.pytorch.org/whl/numpy-2.1.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:259ec80d54999cc34cd1eb8ded513cb053c3bf4829152a2e00de2371bd406f5e" },
|
|
||||||
{ url = "https://download.pytorch.org/whl/numpy-2.1.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:675c741d4739af2dc20cd6c6a5c4b7355c728167845e3c6b0e824e4e5d36a6c3" },
|
|
||||||
{ url = "https://download.pytorch.org/whl/numpy-2.1.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05b2d4e667895cc55e3ff2b56077e4c8a5604361fc21a042845ea3ad67465aa8" },
|
|
||||||
{ url = "https://download.pytorch.org/whl/numpy-2.1.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43cca367bf94a14aca50b89e9bc2061683116cfe864e56740e083392f533ce7a" },
|
|
||||||
{ url = "https://download.pytorch.org/whl/numpy-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f2ded8d9b6f68cc26f8425eda5d3877b47343e68ca23d0d0846f4d312ecaa445" },
|
|
||||||
{ url = "https://download.pytorch.org/whl/numpy-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2ffef621c14ebb0188a8633348504a35c13680d6da93ab5cb86f4e54b7e922b5" },
|
|
||||||
{ url = "https://download.pytorch.org/whl/numpy-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ad369ed238b1959dfbade9018a740fb9392c5ac4f9b5173f420bd4f37ba1f7a0" },
|
|
||||||
{ url = "https://download.pytorch.org/whl/numpy-2.1.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d82075752f40c0ddf57e6e02673a17f6cb0f8eb3f587f63ca1eaab5594da5b17" },
|
|
||||||
{ url = "https://download.pytorch.org/whl/numpy-2.1.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:1600068c262af1ca9580a527d43dc9d959b0b1d8e56f8a05d830eea39b7c8af6" },
|
|
||||||
{ url = "https://download.pytorch.org/whl/numpy-2.1.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a26ae94658d3ba3781d5e103ac07a876b3e9b29db53f68ed7df432fd033358a8" },
|
|
||||||
{ url = "https://download.pytorch.org/whl/numpy-2.1.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13311c2db4c5f7609b462bc0f43d3c465424d25c626d95040f073e30f7570e35" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2440,15 +2425,28 @@ backend = [
|
|||||||
{ name = "sqlalchemy" },
|
{ name = "sqlalchemy" },
|
||||||
]
|
]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "aioboto3" },
|
||||||
{ name = "alembic" },
|
{ name = "alembic" },
|
||||||
|
{ name = "argon2-cffi" },
|
||||||
{ name = "bandit" },
|
{ name = "bandit" },
|
||||||
{ name = "codespell" },
|
{ name = "codespell" },
|
||||||
{ name = "coverage" },
|
{ name = "coverage" },
|
||||||
|
{ name = "cryptography" },
|
||||||
{ name = "dirty-equals" },
|
{ name = "dirty-equals" },
|
||||||
|
{ name = "firebase-admin" },
|
||||||
|
{ name = "httpx" },
|
||||||
{ name = "mypy" },
|
{ name = "mypy" },
|
||||||
|
{ name = "pgvector" },
|
||||||
|
{ name = "prometheus-fastapi-instrumentator" },
|
||||||
|
{ name = "psycopg", extra = ["binary"] },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-asyncio" },
|
{ name = "pytest-asyncio" },
|
||||||
|
{ name = "python-multipart" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
|
{ name = "sentence-transformers" },
|
||||||
|
{ name = "sqlalchemy" },
|
||||||
|
{ name = "torch", version = "2.2.2", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(python_full_version < '3.13' and platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'darwin')" },
|
||||||
|
{ name = "torch", version = "2.2.2+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(python_full_version >= '3.13' and sys_platform == 'darwin') or (python_full_version >= '3.13' and sys_platform == 'linux') or (platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_python_implementation != 'CPython' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||||
{ name = "types-cachetools" },
|
{ name = "types-cachetools" },
|
||||||
]
|
]
|
||||||
linters = [
|
linters = [
|
||||||
@@ -2462,6 +2460,7 @@ migrations = [
|
|||||||
{ name = "alembic" },
|
{ name = "alembic" },
|
||||||
]
|
]
|
||||||
ml = [
|
ml = [
|
||||||
|
{ name = "prometheus-fastapi-instrumentator" },
|
||||||
{ name = "sentence-transformers" },
|
{ name = "sentence-transformers" },
|
||||||
{ name = "torch", version = "2.2.2", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(python_full_version < '3.13' and platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'darwin')" },
|
{ name = "torch", version = "2.2.2", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(python_full_version < '3.13' and platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'darwin')" },
|
||||||
{ name = "torch", version = "2.2.2+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(python_full_version >= '3.13' and sys_platform == 'darwin') or (python_full_version >= '3.13' and sys_platform == 'linux') or (platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_python_implementation != 'CPython' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
{ name = "torch", version = "2.2.2+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(python_full_version >= '3.13' and sys_platform == 'darwin') or (python_full_version >= '3.13' and sys_platform == 'linux') or (platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_python_implementation != 'CPython' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||||
@@ -2502,15 +2501,27 @@ backend = [
|
|||||||
{ name = "sqlalchemy", specifier = "==2.0.44" },
|
{ name = "sqlalchemy", specifier = "==2.0.44" },
|
||||||
]
|
]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "aioboto3", specifier = "==15.5.0" },
|
||||||
{ name = "alembic", specifier = "==1.17.0" },
|
{ name = "alembic", specifier = "==1.17.0" },
|
||||||
|
{ name = "argon2-cffi", specifier = "==23.1.0" },
|
||||||
{ name = "bandit", specifier = "==1.8.6" },
|
{ name = "bandit", specifier = "==1.8.6" },
|
||||||
{ name = "codespell", specifier = "==2.4.1" },
|
{ name = "codespell", specifier = "==2.4.1" },
|
||||||
{ name = "coverage", specifier = "==7.11.0" },
|
{ name = "coverage", specifier = "==7.11.0" },
|
||||||
|
{ name = "cryptography", specifier = "==46.0.3" },
|
||||||
{ name = "dirty-equals", specifier = ">=0.11" },
|
{ name = "dirty-equals", specifier = ">=0.11" },
|
||||||
|
{ name = "firebase-admin", specifier = ">=7.1.0" },
|
||||||
|
{ name = "httpx", specifier = "==0.28.1" },
|
||||||
{ name = "mypy", specifier = "==1.18.1" },
|
{ name = "mypy", specifier = "==1.18.1" },
|
||||||
|
{ name = "pgvector", specifier = ">=0.4.1" },
|
||||||
|
{ name = "prometheus-fastapi-instrumentator", specifier = ">=7.1.0" },
|
||||||
|
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2.12" },
|
||||||
{ name = "pytest", specifier = "==8.4.0" },
|
{ name = "pytest", specifier = "==8.4.0" },
|
||||||
{ name = "pytest-asyncio", specifier = "==1.2.0" },
|
{ name = "pytest-asyncio", specifier = "==1.2.0" },
|
||||||
|
{ name = "python-multipart", specifier = ">=0.0.20" },
|
||||||
{ name = "ruff", specifier = "==0.12.11" },
|
{ name = "ruff", specifier = "==0.12.11" },
|
||||||
|
{ name = "sentence-transformers", specifier = ">=5.1.2" },
|
||||||
|
{ name = "sqlalchemy", specifier = "==2.0.44" },
|
||||||
|
{ name = "torch", index = "https://download.pytorch.org/whl/cpu" },
|
||||||
{ name = "types-cachetools", specifier = "==6.2.0.20250827" },
|
{ name = "types-cachetools", specifier = "==6.2.0.20250827" },
|
||||||
]
|
]
|
||||||
linters = [
|
linters = [
|
||||||
@@ -2522,6 +2533,7 @@ linters = [
|
|||||||
]
|
]
|
||||||
migrations = [{ name = "alembic", specifier = "==1.17.0" }]
|
migrations = [{ name = "alembic", specifier = "==1.17.0" }]
|
||||||
ml = [
|
ml = [
|
||||||
|
{ name = "prometheus-fastapi-instrumentator", specifier = ">=7.1.0" },
|
||||||
{ name = "sentence-transformers", specifier = ">=5.1.2" },
|
{ name = "sentence-transformers", specifier = ">=5.1.2" },
|
||||||
{ name = "torch", index = "https://download.pytorch.org/whl/cpu" },
|
{ name = "torch", index = "https://download.pytorch.org/whl/cpu" },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user