Compare commits

52 Commits

Author SHA1 Message Date
gitgernit de2707bf5a docs(): add diagrams to rfc 2025-11-23 15:16:37 +03:00
ITQ e375ccc208 a<type>(scope): <description>
[body]

[footer(s)]
2025-11-23 15:05:15 +03:00
ITQ 8f62c775e4 Merge branch 'main' of gitlab.prodcontest.com:team-39/backend
* 'main' of gitlab.prodcontest.com:team-39/backend:
  fix(): reset vectors similarity threshold
  chore(): update rfc
2025-11-23 15:04:13 +03:00
ITQ f281a764b0 a
<type>(scope): <description>

[body]

[footer(s)]
2025-11-23 15:03:31 +03:00
gitgernit 0e41a1210c fix(): reset vectors similarity threshold 2025-11-23 14:56:17 +03:00
gitgernit 11d9feb888 t pushMerge branch 'main' of gitlab.prodcontest.com:team-39/backend 2025-11-23 14:41:15 +03:00
gitgernit 875b330a98 chore(): update rfc 2025-11-23 14:41:07 +03:00
ITQ dd940f25af added creds 2025-11-23 14:06:16 +03:00
ITQ 2988f7c3b6 <type>(scope): <description>
[body]

[footer(s)]
2025-11-23 13:34:18 +03:00
ivankirpichnikov 8d10294e1f edit README.md 2025-11-23 12:58:20 +03:00
ITQ 5a9997c6f0 <type>(scope): <description>
[body]

[footer(s)]
2025-11-23 12:45:53 +03:00
ITQ a0a7e01f1c <type>(scope): <description>
[body]

[footer(s)]
2025-11-23 12:30:25 +03:00
ITQ 01e1f7425c <type>(scope): <description>
[body]

[footer(s)]
2025-11-23 12:24:37 +03:00
gitgernit 51087fa3b8 Merge branch 'main' of gitlab.prodcontest.com:team-39/backend 2025-11-23 12:19:27 +03:00
gitgernit da4c8f486d feat(): optimize queries by adding indexes, defining vector length and defining ef-search local parameter 2025-11-23 12:19:22 +03:00
ITQ d00b88448f <type>(scope): <description>
[body]

[footer(s)]
2025-11-23 12:12:59 +03:00
ITQ afd2337eaf Merge branch 'main' of gitlab.prodcontest.com:team-39/backend
* 'main' of gitlab.prodcontest.com:team-39/backend:
  fix(): fix e2e ml tests, handle  no entries for vacancies
2025-11-23 12:01:08 +03:00
ITQ 6474d97be2 <type>(scope): <description>
[body]

[footer(s)]
2025-11-23 11:59:37 +03:00
gitgernit b15282baef fix(): fix e2e ml tests, handle no entries for vacancies 2025-11-23 11:44:08 +03:00
ITQ a879da4ed5 <type>(scope): <description>
[body]

[footer(s)]
2025-11-23 11:13:28 +03:00
gitgernit fb8dd9accc Merge branch 'main' of gitlab.prodcontest.com:team-39/backend 2025-11-23 09:35:26 +03:00
gitgernit fbceb9fefe feat(): unit tests for domain entities and basic invariants 2025-11-23 09:34:09 +03:00
gitgernit 25b35a3ccd fix(): better resolution of relevant vacancies 2025-11-23 09:33:29 +03:00
ITQ a9ea9c65d6 <type>(scope): <description>
[body]

[footer(s)]
2025-11-23 08:23:23 +03:00
ivankirpichnikov c60a7d5973 edit README.md 2025-11-23 07:20:33 +03:00
ivankirpichnikov f07ef3ce60 fix merge conflict 2025-11-23 05:28:35 +03:00
ivankirpichnikov c2fe8a9d83 add readme.md 2025-11-23 05:26:57 +03:00
ivankirpichnikov 9060d23cba fix add resume 2025-11-23 04:53:34 +03:00
gitgernit f92e3d3372 fix(): update schemas 2025-11-23 04:25:59 +03:00
gitgernit d1c7641698 feat(): prediction pipeline 2025-11-23 04:11:52 +03:00
gitgernit 2e6214a5ec fix(): skip resume tests as they require ml service 2025-11-23 02:28:33 +03:00
ivankirpichnikov 9e7515631e add pipline 2025-11-23 01:45:05 +03:00
ivankirpichnikov ac32e89ada fix merge conflicts 2025-11-23 01:43:44 +03:00
ivankirpichnikov 040fc90c59 Merge branch 'main' of gitlab.prodcontest.com:team-39/backend 2025-11-23 01:42:49 +03:00
ivankirpichnikov 96e792b122 add pipline 2025-11-23 01:42:46 +03:00
gitgernit 4b66c2f6be feat(): load key skills and vacancies scripts 2025-11-23 01:41:48 +03:00
ivankirpichnikov 7f91b412b8 Merge branch 'main' of gitlab.prodcontest.com:team-39/backend 2025-11-23 01:36:36 +03:00
ITQ 2887b0a1e0 chore: added retries 2025-11-22 22:40:02 +03:00
ITQ b0f65dd828 <type>(scope): <description>
[body]

[footer(s)]
2025-11-22 21:55:13 +03:00
ITQ 866386859f Merge branch 'main' of gitlab.prodcontest.com:team-39/backend
* 'main' of gitlab.prodcontest.com:team-39/backend:
  fix(): reference real containerfiles in docker compose
  feat(): salary prediction
2025-11-22 21:25:56 +03:00
ITQ 6ca6c12401 <type>(scope): <description>
[body]

[footer(s)]
2025-11-22 21:25:42 +03:00
gitgernit e1f3ce6bcd Merge branch 'main' of gitlab.prodcontest.com:team-39/backend 2025-11-22 20:59:52 +03:00
gitgernit a65904cbf2 fix(): reference real containerfiles in docker compose 2025-11-22 20:59:48 +03:00
ITQ b521f4c0bf a
<type>(scope): <description>

[body]

[footer(s)]
2025-11-22 19:36:41 +03:00
ITQ d5bf1f7a68 aboba 2025-11-22 19:36:11 +03:00
gitgernit bbbb9fc493 Merge branch 'ml' 2025-11-22 19:06:57 +03:00
ivankirpichnikov 9988338d97 Merge branch 'main' of gitlab.prodcontest.com:team-39/backend 2025-11-22 18:53:31 +03:00
ITQ d7cff23205 fix 2025-11-22 18:43:33 +03:00
ITQ d2feee8eb4 Merge branch 'main' of gitlab.prodcontest.com:team-39/backend
* 'main' of gitlab.prodcontest.com:team-39/backend:
  fix(): update migrations
2025-11-22 18:26:35 +03:00
ITQ 420823f188 <type>(scope): <description>
[body]

[footer(s)]
2025-11-22 18:26:15 +03:00
ivankirpichnikov c868edebc9 не помнб 2025-11-22 18:18:23 +03:00
ivankirpichnikov effbcfbc2d не помнб 2025-11-22 18:15:29 +03:00
64 changed files with 2014 additions and 272 deletions
+3
View File
@@ -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
View File
@@ -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"
BIN
View File
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
View File
@@ -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" ]
+86
View File
@@ -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** - сыщик уязвимостей
+111
View File
@@ -0,0 +1,111 @@
# Rekomenci fluon RFC
## Архитектура. *(Всё как завещал дядюшка Боб...)*
Проект следует чистой архитектуре дядюшки Боба.
![Чистая архитектура](.images/clean_arch.png)
# 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**: регистрация устройств для уведомлений
![Абстрактная диаграмма домена](.images/abstract_domain.jpg)
### 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 поиска)
![ER-диаграмма базы данных](.images/ER.png)
## 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
View File
@@ -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
+3
View File
@@ -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"
+4
View File
@@ -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
View File
@@ -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',
] ]
-30
View File
@@ -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)
+43
View File
@@ -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()
+17
View File
@@ -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)"
+20
View File
@@ -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 "Импорт завершен!"
+44
View File
@@ -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())
+128
View File
@@ -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,19 +136,43 @@ 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 = []
for r in resumes:
resume_prediction = await self.resume_prediction_data_gateway.load_by_resume_id(r.id)
if resume_prediction is not None:
prediction = ResumePredictionResponse(
from_salary=resume_prediction.from_salary,
to_salary=resume_prediction.to_salary,
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( ResumeListItemResponse(
id=r.id, id=r.id,
position=r.position, position=r.position,
@@ -154,26 +180,74 @@ class GetResumeListInteractor:
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=[
ExperienceItemResponse(
place=exp.place,
description=exp.description,
months_duration=exp.months_duration,
) )
for r in resumes 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,
)
)
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 = []
for r in history:
resume_prediction = await self.resume_prediction_data_gateway.load_by_resume_id(r.id)
if resume_prediction is not None:
prediction = ResumePredictionResponse(
from_salary=resume_prediction.from_salary,
to_salary=resume_prediction.to_salary,
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( ResumeListItemResponse(
id=r.id, id=r.id,
position=r.position, position=r.position,
@@ -181,6 +255,32 @@ class GetResumeHistoryInteractor:
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=[
ExperienceItemResponse(
place=exp.place,
description=exp.description,
months_duration=exp.months_duration,
) )
for r in history 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,
)
)
return result
@@ -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 ###
+13 -3
View File
@@ -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:
+2 -2
View File
@@ -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,
) )
+5 -5
View File
@@ -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,
) )
+4
View File
@@ -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,
}, },
) )
+13
View File
@@ -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],
)
+170 -8
View File
@@ -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,
+93
View File
@@ -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
+36
View File
@@ -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
+44
View File
@@ -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
+229
View File
@@ -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
+12
View File
@@ -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
+95
View File
@@ -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
+1 -1
View File
@@ -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()}
+11 -3
View File
@@ -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=[],
),
) )
+4
View File
@@ -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,
}, },
) )
Generated
+33 -21
View File
@@ -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" },
] ]