You've already forked RekomenciBackend
Merge branch 'ml'
This commit is contained in:
+61
-29
@@ -1,9 +1,14 @@
|
|||||||
# syntax=docker/dockerfile:1.20
|
# syntax=docker/dockerfile:1.20
|
||||||
|
|
||||||
ARG PY_IMAGE=python:3.13-alpine3.22
|
ARG PY_IMAGE=python:3.12-slim
|
||||||
|
|
||||||
# Stage 1: Builder
|
# Stage 1: Base Builder
|
||||||
FROM ${PY_IMAGE} AS builder
|
FROM ${PY_IMAGE} AS base-builder
|
||||||
|
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
curl git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||||
|
|
||||||
@@ -18,11 +23,28 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
|
|
||||||
COPY pyproject.toml uv.lock ./
|
COPY pyproject.toml uv.lock ./
|
||||||
|
|
||||||
RUN uv sync --frozen --no-dev --no-cache
|
# Stage 2: Backend Builder
|
||||||
|
FROM base-builder AS backend-builder
|
||||||
|
|
||||||
|
COPY ./src ./src
|
||||||
|
|
||||||
|
RUN uv sync --frozen --no-dev --no-cache --group backend
|
||||||
|
|
||||||
|
# Stage 3: ML Builder
|
||||||
|
FROM base-builder AS ml-builder
|
||||||
|
|
||||||
|
COPY ./src ./src
|
||||||
|
|
||||||
|
RUN uv sync --frozen --no-dev --no-cache --group ml
|
||||||
|
|
||||||
|
|
||||||
# Stage 2: Runtime
|
# Stage 4: Backend Runtime
|
||||||
FROM ${PY_IMAGE} AS runtime
|
FROM ${PY_IMAGE} AS backend
|
||||||
|
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
@@ -32,28 +54,22 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apk add --no-cache --virtual .runtime-deps \
|
COPY --from=backend-builder /opt/venv /opt/venv
|
||||||
curl && \
|
|
||||||
rm -rf /var/cache/apk/*
|
|
||||||
|
|
||||||
RUN adduser -D -g '' app && chown -R app:app /app
|
COPY ./src/ ./
|
||||||
|
|
||||||
USER app
|
|
||||||
|
|
||||||
COPY --from=builder --chown=app:app /opt/venv /opt/venv
|
|
||||||
|
|
||||||
COPY --chown=app:app ./src/ ./
|
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
CMD [ "/opt/venv/bin/web_api_cli", "/app/config.toml" ]
|
||||||
CMD curl -fsS http://localhost:8080/healthcheck || exit 1
|
|
||||||
|
|
||||||
CMD [ "web_api_cli", "/app/config.toml" ]
|
|
||||||
|
|
||||||
|
|
||||||
# Stage 3: Testing
|
# Stage 5: ML Runtime
|
||||||
FROM builder AS tests
|
FROM ${PY_IMAGE} AS ml
|
||||||
|
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
@@ -63,21 +79,39 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN uv sync --no-install-project --group tests --frozen --no-cache
|
COPY --from=ml-builder /opt/venv /opt/venv
|
||||||
|
|
||||||
|
COPY ./src/ ./
|
||||||
|
|
||||||
|
EXPOSE 8081
|
||||||
|
|
||||||
|
CMD [ "/opt/venv/bin/ml_api_cli", "/app/config.toml" ]
|
||||||
|
|
||||||
|
|
||||||
|
# Stage 6: Testing
|
||||||
|
FROM base-builder AS tests
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONOPTIMIZE=2 \
|
||||||
|
PATH="/opt/venv/bin:$PATH" \
|
||||||
|
PYTHONPATH="/app:$PYTHONPATH"
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
COPY ./src ./src
|
COPY ./src ./src
|
||||||
|
|
||||||
COPY ./tests ./tests
|
COPY ./tests ./tests
|
||||||
|
|
||||||
RUN uv pip install -e .
|
RUN uv sync --group tests --frozen --no-cache
|
||||||
|
|
||||||
RUN mkdir -p /app/cov && mkdir /app/cov/html
|
RUN mkdir -p /app/cov && mkdir /app/cov/html
|
||||||
|
|
||||||
CMD [ "sh", "-c", "coverage run --source=src -m pytest -v && coverage report > /app/cov/coverage.txt && coverage xml -o /app/cov/coverage.xml && coverage html -d /app/cov/html" ]
|
CMD [ "sh", "-c", "coverage run --source=src -m pytest -v && coverage report > /app/cov/coverage.txt && coverage xml -o /app/cov/coverage.xml && coverage html -d /app/cov/html" ]
|
||||||
|
|
||||||
|
|
||||||
# Stage 4: Migrations
|
# Stage 7: Migrations
|
||||||
FROM builder AS migrations
|
FROM base-builder AS migrations
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
@@ -89,14 +123,12 @@ WORKDIR /app
|
|||||||
|
|
||||||
RUN mkdir -p ./src/template_project
|
RUN mkdir -p ./src/template_project
|
||||||
|
|
||||||
RUN uv sync --no-install-project --group migrations --frozen --no-cache
|
|
||||||
|
|
||||||
COPY ./src ./src
|
COPY ./src ./src
|
||||||
|
|
||||||
COPY ./tests ./tests
|
COPY ./tests ./tests
|
||||||
|
|
||||||
COPY ./alembic.ini ./
|
COPY ./alembic.ini ./
|
||||||
|
|
||||||
RUN uv pip install -e .
|
RUN uv sync --group migrations --frozen --no-cache
|
||||||
|
|
||||||
CMD [ "alembic", "upgrade", "head" ]
|
CMD [ "alembic", "upgrade", "head" ]
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ services:
|
|||||||
backend:
|
backend:
|
||||||
image: "${REGISTRY_PREFIX}/backend:${IMAGE_TAG}"
|
image: "${REGISTRY_PREFIX}/backend:${IMAGE_TAG}"
|
||||||
|
|
||||||
|
ml:
|
||||||
|
image: "${REGISTRY_PREFIX}/ml:${IMAGE_TAG}"
|
||||||
|
|
||||||
tests:
|
tests:
|
||||||
image: "${REGISTRY_PREFIX}/backend-tests:${IMAGE_TAG}"
|
image: "${REGISTRY_PREFIX}/backend-tests:${IMAGE_TAG}"
|
||||||
|
|
||||||
|
|||||||
+37
-1
@@ -5,7 +5,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Containerfile
|
dockerfile: Containerfile
|
||||||
target: runtime
|
target: backend
|
||||||
tags:
|
tags:
|
||||||
- template-project-backend:latest
|
- template-project-backend:latest
|
||||||
pull: true
|
pull: true
|
||||||
@@ -61,6 +61,40 @@ services:
|
|||||||
bind:
|
bind:
|
||||||
selinux: z
|
selinux: z
|
||||||
|
|
||||||
|
ml:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Containerfile
|
||||||
|
target: ml
|
||||||
|
tags:
|
||||||
|
- template-project-ml:latest
|
||||||
|
pull: true
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD", "curl", "-fsS", "http://localhost:8081/healthcheck" ]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 4s
|
||||||
|
start_period: 5s
|
||||||
|
start_interval: 2s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
ports:
|
||||||
|
- name: web
|
||||||
|
target: 8081
|
||||||
|
published: 13562
|
||||||
|
host_ip: 127.0.0.1
|
||||||
|
protocol: tcp
|
||||||
|
app_protocol: http
|
||||||
|
restart: unless-stopped
|
||||||
|
shm_size: 4mb
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: ./infrastructure/configs/ml/config.toml
|
||||||
|
target: /app/config.toml
|
||||||
|
read_only: true
|
||||||
|
bind:
|
||||||
|
selinux: z
|
||||||
|
|
||||||
tests:
|
tests:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -185,6 +219,8 @@ services:
|
|||||||
- type: volume
|
- type: volume
|
||||||
source: postgres_data
|
source: postgres_data
|
||||||
target: /var/lib/postgresql/data
|
target: /var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
|
||||||
pgadmin:
|
pgadmin:
|
||||||
image: docker.io/dpage/pgadmin4:9
|
image: docker.io/dpage/pgadmin4:9
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
CONFIGURATION_PATH=/app/config.toml
|
||||||
+28
-6
@@ -5,11 +5,18 @@ description = "template project"
|
|||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"uuid_utils==0.11.1",
|
"uuid_utils==0.11.1",
|
||||||
"adaptix==3.0.0b11",
|
|
||||||
"fastapi==0.119.0",
|
"fastapi==0.119.0",
|
||||||
"uvicorn==0.37.0",
|
"uvicorn==0.37.0",
|
||||||
"sqlalchemy==2.0.44",
|
|
||||||
"dishka==1.7.2",
|
"dishka==1.7.2",
|
||||||
|
"pydantic[email]>=2.12.4",
|
||||||
|
"levenshtein>=0.27.3",
|
||||||
|
"adaptix==3.0.0b11",
|
||||||
|
"markupsafe",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
backend = [
|
||||||
|
"sqlalchemy==2.0.44",
|
||||||
"argon2_cffi==23.1.0",
|
"argon2_cffi==23.1.0",
|
||||||
"cryptography==46.0.3",
|
"cryptography==46.0.3",
|
||||||
"httpx==0.28.1",
|
"httpx==0.28.1",
|
||||||
@@ -18,12 +25,12 @@ dependencies = [
|
|||||||
"aioboto3==15.5.0",
|
"aioboto3==15.5.0",
|
||||||
"prometheus-fastapi-instrumentator>=7.1.0",
|
"prometheus-fastapi-instrumentator>=7.1.0",
|
||||||
"python-multipart>=0.0.20",
|
"python-multipart>=0.0.20",
|
||||||
"pydantic[email]>=2.12.4",
|
|
||||||
"levenshtein>=0.27.3",
|
|
||||||
"pgvector>=0.4.1",
|
"pgvector>=0.4.1",
|
||||||
]
|
]
|
||||||
|
ml = [
|
||||||
[dependency-groups]
|
"sentence-transformers>=5.1.2",
|
||||||
|
"torch",
|
||||||
|
]
|
||||||
types = [
|
types = [
|
||||||
"types-cachetools==6.2.0.20250827",
|
"types-cachetools==6.2.0.20250827",
|
||||||
]
|
]
|
||||||
@@ -51,6 +58,7 @@ dev = [
|
|||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
web_api_cli = "template_project.web_api.entry_point:main"
|
web_api_cli = "template_project.web_api.entry_point:main"
|
||||||
|
ml_api_cli = "template_project.ml.entry_point:main"
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
@@ -62,6 +70,20 @@ filterwarnings = [
|
|||||||
asyncio_default_test_loop_scope = "session"
|
asyncio_default_test_loop_scope = "session"
|
||||||
asyncio_default_fixture_loop_scope = "session"
|
asyncio_default_fixture_loop_scope = "session"
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
required-environments = [
|
||||||
|
"sys_platform == 'linux' and platform_machine == 'x86_64'"
|
||||||
|
]
|
||||||
|
|
||||||
|
[[tool.uv.index]]
|
||||||
|
name = "pytorch"
|
||||||
|
url = "https://download.pytorch.org/whl/cpu"
|
||||||
|
default = false
|
||||||
|
|
||||||
|
[tool.uv.sources]
|
||||||
|
torch = { index = "pytorch" }
|
||||||
|
markupsafe = { git = "https://github.com/pallets/markupsafe", rev = "3.0.2" }
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
strict = true
|
strict = true
|
||||||
strict_bytes = true
|
strict_bytes = true
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from typing import cast, override
|
||||||
|
|
||||||
|
from sentence_transformers import SentenceTransformer
|
||||||
|
|
||||||
|
from template_project.application.common.embedding import Embedder
|
||||||
|
|
||||||
|
|
||||||
|
class MiniLMEmbedder(Embedder):
|
||||||
|
def __init__(self, model: SentenceTransformer) -> None:
|
||||||
|
self._model = model
|
||||||
|
|
||||||
|
@override
|
||||||
|
async def encode(self, text: str) -> list[float]:
|
||||||
|
embedding = self._model.encode(text)
|
||||||
|
return cast(list[float], embedding.tolist())
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from abc import abstractmethod
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
|
||||||
|
class Embedder(Protocol):
|
||||||
|
@abstractmethod
|
||||||
|
async def encode(self, text: str) -> list[float]:
|
||||||
|
raise NotImplementedError
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from tomllib import loads
|
||||||
|
from typing import dataclass_transform
|
||||||
|
|
||||||
|
from adaptix import Retort
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass_transform(frozen_default=True)
|
||||||
|
def to_configuration[ClsT](cls: type[ClsT]) -> type[ClsT]:
|
||||||
|
return dataclass(frozen=True, slots=True, repr=False)(cls)
|
||||||
|
|
||||||
|
|
||||||
|
@to_configuration
|
||||||
|
class ServerConfiguration:
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
access_log: bool
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url(self) -> str:
|
||||||
|
return f"http://{self.host}:{self.port}"
|
||||||
|
|
||||||
|
|
||||||
|
@to_configuration
|
||||||
|
class Configuration:
|
||||||
|
server: ServerConfiguration
|
||||||
|
|
||||||
|
|
||||||
|
retort = Retort()
|
||||||
|
|
||||||
|
|
||||||
|
def load_configuration(path: Path) -> Configuration:
|
||||||
|
with path.open("r", encoding="utf-8") as config:
|
||||||
|
data = loads(config.read())
|
||||||
|
|
||||||
|
return retort.load(data, Configuration)
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
from dishka import AsyncContainer
|
||||||
|
from dishka.integrations.fastapi import setup_dishka
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from template_project.ml.configuration import load_configuration
|
||||||
|
from template_project.ml.ioc.make import make_ioc
|
||||||
|
from template_project.ml.routes import embedding, healthcheck, predict
|
||||||
|
|
||||||
|
LOG_CONFIG: Final = {
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"formatters": {
|
||||||
|
"default": {
|
||||||
|
"format": "%(asctime)s [%(levelname)s] [%(name)s] %(message)s",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"handlers": {
|
||||||
|
"console": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"formatter": "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"level": "DEBUG",
|
||||||
|
"handlers": ["console"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_asgi_application(
|
||||||
|
ioc: AsyncContainer,
|
||||||
|
) -> FastAPI:
|
||||||
|
app = FastAPI(
|
||||||
|
docs_url="/docs",
|
||||||
|
title="ML Service",
|
||||||
|
description="ML Service API",
|
||||||
|
version="1.0.0",
|
||||||
|
openapi_url="/openapi.json",
|
||||||
|
)
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
app.include_router(healthcheck.router)
|
||||||
|
app.include_router(embedding.router)
|
||||||
|
app.include_router(predict.router)
|
||||||
|
|
||||||
|
setup_dishka(container=ioc, app=app)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
async def _main(
|
||||||
|
configuration_path: Path,
|
||||||
|
) -> None:
|
||||||
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
configuration = load_configuration(configuration_path)
|
||||||
|
ioc = make_ioc(configuration)
|
||||||
|
asgi_application = make_asgi_application(ioc)
|
||||||
|
|
||||||
|
config = uvicorn.Config(
|
||||||
|
app=asgi_application,
|
||||||
|
host=configuration.server.host,
|
||||||
|
port=configuration.server.port,
|
||||||
|
log_config=LOG_CONFIG,
|
||||||
|
access_log=configuration.server.access_log,
|
||||||
|
)
|
||||||
|
server = uvicorn.Server(config)
|
||||||
|
try:
|
||||||
|
await server.serve()
|
||||||
|
finally:
|
||||||
|
await ioc.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||||
|
|
||||||
|
arg_parser = argparse.ArgumentParser()
|
||||||
|
arg_parser.add_argument("configuration", default=None)
|
||||||
|
|
||||||
|
args = arg_parser.parse_args()
|
||||||
|
configuration_path = args.configuration or os.getenv("CONFIGURATION_PATH")
|
||||||
|
|
||||||
|
if configuration_path is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"pass the path to the config or specify it in the environment variables `CONFIGURATION_PATH`",
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(_main(Path(configuration_path)))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from template_project.application.common.data_structure import to_data_structure
|
||||||
|
from template_project.application.common.interactor import to_interactor
|
||||||
|
from template_project.application.resume.entity import ResumeId
|
||||||
|
|
||||||
|
|
||||||
|
@to_data_structure
|
||||||
|
class VacancyInput:
|
||||||
|
vacancy_id: str
|
||||||
|
from_salary: Decimal
|
||||||
|
to_salary: Decimal
|
||||||
|
key_skills: list[str]
|
||||||
|
resume_similarity: float
|
||||||
|
|
||||||
|
|
||||||
|
@to_data_structure
|
||||||
|
class PredictSalaryRequest:
|
||||||
|
resume_id: ResumeId
|
||||||
|
key_skills: list[str]
|
||||||
|
vacancies: list[VacancyInput]
|
||||||
|
|
||||||
|
|
||||||
|
@to_data_structure
|
||||||
|
class PredictSalaryResponse:
|
||||||
|
salary_from: Decimal
|
||||||
|
salary_to: Decimal
|
||||||
|
recommended_skills: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
@to_interactor
|
||||||
|
class PredictSalaryInteractor:
|
||||||
|
async def execute(self, request: PredictSalaryRequest) -> PredictSalaryResponse:
|
||||||
|
return PredictSalaryResponse(
|
||||||
|
salary_from=Decimal("50000"),
|
||||||
|
salary_to=Decimal("80000"),
|
||||||
|
recommended_skills=["python", "django", "postgresql"],
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from dishka import BaseScope, Provider, Scope, provide
|
||||||
|
from sentence_transformers import SentenceTransformer
|
||||||
|
|
||||||
|
from template_project.adapters.embedding.minilm_embedder import MiniLMEmbedder
|
||||||
|
from template_project.application.common.embedding import Embedder
|
||||||
|
|
||||||
|
|
||||||
|
class EmbeddingProvider(Provider):
|
||||||
|
scope: BaseScope | None = Scope.APP
|
||||||
|
|
||||||
|
@provide(scope=Scope.APP)
|
||||||
|
def embedder(self, model: SentenceTransformer) -> Embedder:
|
||||||
|
return MiniLMEmbedder(model=model)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from dishka import BaseScope, Provider, Scope, provide_all
|
||||||
|
|
||||||
|
from template_project.ml.interactors.predict_salary import PredictSalaryInteractor
|
||||||
|
|
||||||
|
|
||||||
|
class InteractorProvider(Provider):
|
||||||
|
scope: BaseScope | None = Scope.REQUEST
|
||||||
|
|
||||||
|
interactors = provide_all(
|
||||||
|
PredictSalaryInteractor,
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from dishka import STRICT_VALIDATION, AsyncContainer, make_async_container
|
||||||
|
from dishka.integrations.fastapi import FastapiProvider
|
||||||
|
|
||||||
|
from template_project.ml.configuration import Configuration, ServerConfiguration
|
||||||
|
from template_project.ml.ioc.embedding import EmbeddingProvider
|
||||||
|
from template_project.ml.ioc.interactor import InteractorProvider
|
||||||
|
from template_project.ml.ioc.model import ModelProvider
|
||||||
|
|
||||||
|
|
||||||
|
def make_ioc(configuration: Configuration) -> AsyncContainer:
|
||||||
|
return make_async_container(
|
||||||
|
ModelProvider(),
|
||||||
|
EmbeddingProvider(),
|
||||||
|
InteractorProvider(),
|
||||||
|
FastapiProvider(),
|
||||||
|
validation_settings=STRICT_VALIDATION,
|
||||||
|
context={
|
||||||
|
ServerConfiguration: configuration.server,
|
||||||
|
Configuration: configuration,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from dishka import BaseScope, Provider, Scope, provide
|
||||||
|
from sentence_transformers import SentenceTransformer
|
||||||
|
|
||||||
|
|
||||||
|
class ModelProvider(Provider):
|
||||||
|
scope: BaseScope | None = Scope.APP
|
||||||
|
|
||||||
|
@provide(scope=Scope.APP)
|
||||||
|
def sentence_transformer_model(self) -> SentenceTransformer:
|
||||||
|
return SentenceTransformer("all-MiniLM-L6-v2")
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from dishka import FromDishka
|
||||||
|
from dishka.integrations.fastapi import DishkaRoute
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from template_project.application.common.embedding import Embedder
|
||||||
|
|
||||||
|
router = APIRouter(route_class=DishkaRoute, tags=["Embedding"])
|
||||||
|
|
||||||
|
|
||||||
|
class GetEmbeddingRequest(BaseModel):
|
||||||
|
text: str = Field(
|
||||||
|
..., min_length=1, description="Text to encode", examples=["python backend developer with django"]
|
||||||
|
)
|
||||||
|
|
||||||
|
model_config = {"json_schema_extra": {"example": {"text": "python backend developer with django"}}}
|
||||||
|
|
||||||
|
|
||||||
|
class GetEmbeddingResponse(BaseModel):
|
||||||
|
embedding: list[float] = Field(..., description="Embedding vector")
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"json_schema_extra": {
|
||||||
|
"example": {
|
||||||
|
"embedding": [0.1, 0.2, 0.3, 0.4, 0.5],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/get_embedding",
|
||||||
|
summary="Get embedding",
|
||||||
|
description="Encode text into embedding vector",
|
||||||
|
responses={
|
||||||
|
200: {"description": "Embedding generated successfully", "model": GetEmbeddingResponse},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def get_embedding(
|
||||||
|
request: GetEmbeddingRequest,
|
||||||
|
embedder: FromDishka[Embedder],
|
||||||
|
) -> GetEmbeddingResponse:
|
||||||
|
embedding = await embedder.encode(request.text)
|
||||||
|
return GetEmbeddingResponse(embedding=embedding)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from dishka.integrations.fastapi import DishkaRoute
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
router = APIRouter(route_class=DishkaRoute, tags=["Health"])
|
||||||
|
|
||||||
|
|
||||||
|
class HealthcheckResponse(BaseModel):
|
||||||
|
ok: bool = Field(description="Service health status")
|
||||||
|
|
||||||
|
model_config = {"json_schema_extra": {"example": {"ok": True}}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/healthcheck",
|
||||||
|
summary="Health check",
|
||||||
|
description="Check if the service is running and healthy",
|
||||||
|
responses={
|
||||||
|
200: {"description": "Service is healthy", "model": HealthcheckResponse},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def healthcheck() -> HealthcheckResponse:
|
||||||
|
return HealthcheckResponse(ok=True)
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from dishka import FromDishka
|
||||||
|
from dishka.integrations.fastapi import DishkaRoute
|
||||||
|
from fastapi import APIRouter, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from template_project.application.resume.entity import ResumeId
|
||||||
|
from template_project.ml.interactors.predict_salary import (
|
||||||
|
PredictSalaryInteractor,
|
||||||
|
PredictSalaryRequest,
|
||||||
|
PredictSalaryResponse,
|
||||||
|
VacancyInput,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(route_class=DishkaRoute, tags=["Prediction"])
|
||||||
|
|
||||||
|
|
||||||
|
class VacancyInputModel(BaseModel):
|
||||||
|
vacancy_id: str = Field(description="Vacancy ID", examples=["vacancy_123"])
|
||||||
|
from_salary: Decimal = Field(description="Minimum salary", examples=[Decimal(100000)])
|
||||||
|
to_salary: Decimal = Field(description="Maximum salary", examples=[Decimal(150000)])
|
||||||
|
key_skills: list[str] = Field(description="List of key skills", examples=[["Python", "FastAPI", "PostgreSQL"]])
|
||||||
|
resume_similarity: float = Field(
|
||||||
|
ge=0.0, le=1.0, description="Resume similarity score (0.0 to 1.0)", examples=[0.85]
|
||||||
|
)
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"json_schema_extra": {
|
||||||
|
"example": {
|
||||||
|
"vacancy_id": "vacancy_123",
|
||||||
|
"from_salary": "100000",
|
||||||
|
"to_salary": "150000",
|
||||||
|
"key_skills": ["Python", "FastAPI", "PostgreSQL"],
|
||||||
|
"resume_similarity": 0.85,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PredictSalaryRequestModel(BaseModel):
|
||||||
|
resume_id: ResumeId = Field(description="Resume ID", examples=["01234567-89ab-cdef-0123-456789abcdef"])
|
||||||
|
key_skills: list[str] = Field(
|
||||||
|
min_length=1, description="List of key skills from resume", examples=[["Python", "FastAPI", "PostgreSQL"]]
|
||||||
|
)
|
||||||
|
vacancies: list[VacancyInputModel] = Field(
|
||||||
|
min_length=1, description="List of relevant vacancies", examples=[[]]
|
||||||
|
)
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"json_schema_extra": {
|
||||||
|
"example": {
|
||||||
|
"resume_id": "01234567-89ab-cdef-0123-456789abcdef",
|
||||||
|
"key_skills": ["Python", "FastAPI", "PostgreSQL"],
|
||||||
|
"vacancies": [
|
||||||
|
{
|
||||||
|
"vacancy_id": "vacancy_123",
|
||||||
|
"from_salary": "100000",
|
||||||
|
"to_salary": "150000",
|
||||||
|
"key_skills": ["Python", "FastAPI", "PostgreSQL", "Docker"],
|
||||||
|
"resume_similarity": 0.85,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PredictSalaryResponseModel(BaseModel):
|
||||||
|
salary_from: Decimal = Field(description="Minimum predicted salary", examples=[Decimal(100000)])
|
||||||
|
salary_to: Decimal = Field(description="Maximum predicted salary", examples=[Decimal(150000)])
|
||||||
|
recommended_skills: list[str] = Field(
|
||||||
|
description="Top 3 recommended skills", examples=[["Kubernetes", "Redis", "Docker"]]
|
||||||
|
)
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"json_schema_extra": {
|
||||||
|
"example": {
|
||||||
|
"salary_from": "100000",
|
||||||
|
"salary_to": "150000",
|
||||||
|
"recommended_skills": ["Kubernetes", "Redis", "Docker"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/predict_salary",
|
||||||
|
summary="Predict salary",
|
||||||
|
description="Predict salary range and recommend skills based on resume and relevant vacancies",
|
||||||
|
responses={
|
||||||
|
200: {"description": "Salary prediction generated successfully", "model": PredictSalaryResponseModel},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def predict_salary(
|
||||||
|
request: PredictSalaryRequestModel,
|
||||||
|
interactor: FromDishka[PredictSalaryInteractor],
|
||||||
|
) -> PredictSalaryResponseModel:
|
||||||
|
vacancy_inputs = [
|
||||||
|
VacancyInput(
|
||||||
|
vacancy_id=vacancy.vacancy_id,
|
||||||
|
from_salary=vacancy.from_salary,
|
||||||
|
to_salary=vacancy.to_salary,
|
||||||
|
key_skills=vacancy.key_skills,
|
||||||
|
resume_similarity=vacancy.resume_similarity,
|
||||||
|
)
|
||||||
|
for vacancy in request.vacancies
|
||||||
|
]
|
||||||
|
|
||||||
|
predict_request = PredictSalaryRequest(
|
||||||
|
resume_id=request.resume_id,
|
||||||
|
key_skills=request.key_skills,
|
||||||
|
vacancies=vacancy_inputs,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await interactor.execute(predict_request)
|
||||||
|
|
||||||
|
return PredictSalaryResponseModel(
|
||||||
|
salary_from=response.salary_from,
|
||||||
|
salary_to=response.salary_to,
|
||||||
|
recommended_skills=response.recommended_skills,
|
||||||
|
)
|
||||||
|
|
||||||
Reference in New Issue
Block a user