From 71389d5d82b6e47ea1c43ed3c194893a3aaa7b01 Mon Sep 17 00:00:00 2001 From: gitgernit Date: Sat, 22 Nov 2025 14:26:39 +0300 Subject: [PATCH] fix(): adaptix is a common dependency due to it being used in domain --- pyproject.toml | 2 +- src/template_project/ml/entry_point.py | 3 +- .../ml/interactors/__init__.py | 0 .../ml/interactors/predict_salary.py | 39 ++++++ src/template_project/ml/ioc/interactor.py | 12 ++ src/template_project/ml/ioc/make.py | 2 + src/template_project/ml/routes/predict.py | 123 ++++++++++++++++++ uv.lock | 4 +- 8 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 src/template_project/ml/interactors/__init__.py create mode 100644 src/template_project/ml/interactors/predict_salary.py create mode 100644 src/template_project/ml/ioc/interactor.py create mode 100644 src/template_project/ml/routes/predict.py diff --git a/pyproject.toml b/pyproject.toml index fd4c9e1..1853af2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,12 +10,12 @@ dependencies = [ "dishka==1.7.2", "pydantic[email]>=2.12.4", "levenshtein>=0.27.3", + "adaptix==3.0.0b11", "markupsafe", ] [dependency-groups] backend = [ - "adaptix==3.0.0b11", "sqlalchemy==2.0.44", "argon2_cffi==23.1.0", "cryptography==46.0.3", diff --git a/src/template_project/ml/entry_point.py b/src/template_project/ml/entry_point.py index 64161cb..110ebf7 100644 --- a/src/template_project/ml/entry_point.py +++ b/src/template_project/ml/entry_point.py @@ -14,7 +14,7 @@ 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 +from template_project.ml.routes import embedding, healthcheck, predict LOG_CONFIG: Final = { "version": 1, @@ -56,6 +56,7 @@ def make_asgi_application( ) app.include_router(healthcheck.router) app.include_router(embedding.router) + app.include_router(predict.router) setup_dishka(container=ioc, app=app) diff --git a/src/template_project/ml/interactors/__init__.py b/src/template_project/ml/interactors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/template_project/ml/interactors/predict_salary.py b/src/template_project/ml/interactors/predict_salary.py new file mode 100644 index 0000000..8aab671 --- /dev/null +++ b/src/template_project/ml/interactors/predict_salary.py @@ -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"], + ) + diff --git a/src/template_project/ml/ioc/interactor.py b/src/template_project/ml/ioc/interactor.py new file mode 100644 index 0000000..6cffda7 --- /dev/null +++ b/src/template_project/ml/ioc/interactor.py @@ -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, + ) + diff --git a/src/template_project/ml/ioc/make.py b/src/template_project/ml/ioc/make.py index 06215f3..436791b 100644 --- a/src/template_project/ml/ioc/make.py +++ b/src/template_project/ml/ioc/make.py @@ -3,6 +3,7 @@ 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 @@ -10,6 +11,7 @@ def make_ioc(configuration: Configuration) -> AsyncContainer: return make_async_container( ModelProvider(), EmbeddingProvider(), + InteractorProvider(), FastapiProvider(), validation_settings=STRICT_VALIDATION, context={ diff --git a/src/template_project/ml/routes/predict.py b/src/template_project/ml/routes/predict.py new file mode 100644 index 0000000..1d63f9d --- /dev/null +++ b/src/template_project/ml/routes/predict.py @@ -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, + ) + diff --git a/uv.lock b/uv.lock index e12b1f3..1d0a05a 100644 --- a/uv.lock +++ b/uv.lock @@ -2416,6 +2416,7 @@ name = "template-project" version = "1.0.0" source = { editable = "." } dependencies = [ + { name = "adaptix" }, { name = "dishka" }, { name = "fastapi" }, { name = "levenshtein" }, @@ -2427,7 +2428,6 @@ dependencies = [ [package.dev-dependencies] backend = [ - { name = "adaptix" }, { name = "aioboto3" }, { name = "argon2-cffi" }, { name = "cryptography" }, @@ -2478,6 +2478,7 @@ types = [ [package.metadata] requires-dist = [ + { name = "adaptix", specifier = "==3.0.0b11" }, { name = "dishka", specifier = "==1.7.2" }, { name = "fastapi", specifier = "==0.119.0" }, { name = "levenshtein", specifier = ">=0.27.3" }, @@ -2489,7 +2490,6 @@ requires-dist = [ [package.metadata.requires-dev] backend = [ - { name = "adaptix", specifier = "==3.0.0b11" }, { name = "aioboto3", specifier = "==15.5.0" }, { name = "argon2-cffi", specifier = "==23.1.0" }, { name = "cryptography", specifier = "==46.0.3" },