You've already forked RekomenciBackend
add resume
This commit is contained in:
@@ -12,5 +12,5 @@ class AccessTokenCryptographer(Protocol):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def decrypto(self, raw_access_token: RawAccessToken) -> AccessTokenId:
|
||||
def decrypto(self, raw_access_token: RawAccessToken) -> AccessTokenId | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class ExperienceType(StrEnum):
|
||||
NO_EXPERIENCE = "noExperience"
|
||||
BETWEEN_1_AND_3 = "between1And3"
|
||||
BETWEEN_3_AND_6 = "between3And6"
|
||||
MORE_THAN_6 = "moreThan6"
|
||||
@@ -0,0 +1,26 @@
|
||||
from abc import abstractmethod
|
||||
from collections.abc import Sequence
|
||||
from typing import Protocol
|
||||
|
||||
from template_project.application.resume.entity import (
|
||||
Resume,
|
||||
ResumeEmbeddingId,
|
||||
ResumeId,
|
||||
ResumePrediction,
|
||||
)
|
||||
|
||||
|
||||
class ResumeDataGateway(Protocol):
|
||||
@abstractmethod
|
||||
async def get_suitable_resumes(self, embedding_id: ResumeEmbeddingId) -> Sequence[Resume]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def load(self, resume_id: ResumeId) -> Resume:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class ResumePredictionDataGateway(Protocol):
|
||||
@abstractmethod
|
||||
async def load_by_resume_id(self, resume_id: ResumeId) -> ResumePrediction | None:
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,74 @@
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
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.enums import ExperienceType
|
||||
from template_project.application.user.entity import UserId
|
||||
|
||||
ResumeId = NewType("ResumeId", UUID)
|
||||
ResumeEmbeddingId = NewType("ResumeEmbeddingId", UUID)
|
||||
ResumePredictionId = NewType("ResumePredictionId", UUID)
|
||||
|
||||
|
||||
@to_entity
|
||||
class Resume(Entity[ResumeId]):
|
||||
user_id: UserId
|
||||
position: str
|
||||
# location: str
|
||||
about_me: str
|
||||
key_skills: list[str]
|
||||
experience_type: ExperienceType
|
||||
down_resume_id: ResumeId | None = None
|
||||
|
||||
@classmethod
|
||||
def factory(
|
||||
cls,
|
||||
user_id: UserId,
|
||||
position: str,
|
||||
about_me: str,
|
||||
key_skills: list[str],
|
||||
experience_type: ExperienceType,
|
||||
down_resume_id: ResumeId | None = None,
|
||||
) -> Self:
|
||||
return cls(
|
||||
id=ResumeId(uuid7()),
|
||||
created_at=datetime.now(tz=UTC),
|
||||
user_id=user_id,
|
||||
position=position,
|
||||
about_me=about_me,
|
||||
key_skills=key_skills,
|
||||
experience_type=experience_type,
|
||||
down_resume_id=down_resume_id,
|
||||
)
|
||||
|
||||
|
||||
@to_entity
|
||||
class ResumeEmbedding(Entity[ResumeEmbeddingId]):
|
||||
resume_id: ResumeId
|
||||
vector: list[float]
|
||||
|
||||
@classmethod
|
||||
def factory(
|
||||
cls,
|
||||
resume_id: ResumeId,
|
||||
vector: list[float],
|
||||
) -> Self:
|
||||
return cls(
|
||||
id=ResumeEmbeddingId(uuid7()),
|
||||
created_at=datetime.now(tz=UTC),
|
||||
resume_id=resume_id,
|
||||
vector=vector,
|
||||
)
|
||||
|
||||
|
||||
@to_entity
|
||||
class ResumePrediction(Entity[ResumePredictionId]):
|
||||
resume_id: ResumeId
|
||||
from_salary: Decimal
|
||||
to_salary: Decimal
|
||||
recommended_skills: list[str]
|
||||
# common_recommended: str # TODO
|
||||
@@ -0,0 +1,27 @@
|
||||
from typing import override
|
||||
|
||||
from template_project.application.common.errors import ApplicationError, to_error
|
||||
from template_project.application.resume.entity import ResumeId, ResumePredictionId
|
||||
|
||||
|
||||
@to_error
|
||||
class ResumeNotFoundError(ApplicationError):
|
||||
resume_id: ResumeId
|
||||
|
||||
@override
|
||||
def __str__(self) -> str:
|
||||
return f"Resume with {self.resume_id!r} not found"
|
||||
|
||||
|
||||
@to_error
|
||||
class ResumePredictionNotFoundError(ApplicationError):
|
||||
resume_prediction_id: ResumePredictionId
|
||||
|
||||
@override
|
||||
def __str__(self) -> str:
|
||||
return f"ResumePrediction with {self.resume_prediction_id!r} not found"
|
||||
|
||||
|
||||
@to_error
|
||||
class ResumeDoesBelongUserError(ApplicationError):
|
||||
pass
|
||||
@@ -0,0 +1,36 @@
|
||||
from template_project.application.common.enums import ExperienceType
|
||||
from template_project.application.common.identity_provider import IdentityProvider
|
||||
from template_project.application.common.interactor import to_interactor
|
||||
from template_project.application.common.unit_of_work import UnitOfWork
|
||||
from template_project.application.resume.entity import Resume, ResumeId
|
||||
|
||||
|
||||
@to_interactor
|
||||
class AddResumeInteractor:
|
||||
unit_of_work: UnitOfWork
|
||||
identity_provider: IdentityProvider
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
position: str,
|
||||
about_me: str,
|
||||
key_skills: list[str],
|
||||
experience_type: ExperienceType,
|
||||
) -> ResumeId:
|
||||
user = await self.identity_provider.get_current_user()
|
||||
|
||||
resume = Resume.factory(
|
||||
user_id=user.id,
|
||||
position=position,
|
||||
about_me=about_me,
|
||||
key_skills=key_skills,
|
||||
experience_type=experience_type,
|
||||
down_resume_id=None,
|
||||
)
|
||||
|
||||
# TODO: тут надо сделать запуск фоновой таски для вычитывания подходящей вакансии
|
||||
|
||||
await self.unit_of_work.add(resume)
|
||||
await self.unit_of_work.commit()
|
||||
|
||||
return resume.id
|
||||
@@ -0,0 +1,61 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from template_project.application.common.data_structure import to_data_structure
|
||||
from template_project.application.common.enums import ExperienceType
|
||||
from template_project.application.common.identity_provider import IdentityProvider
|
||||
from template_project.application.common.interactor import to_interactor
|
||||
from template_project.application.resume.data_gateway import ResumeDataGateway, ResumePredictionDataGateway
|
||||
from template_project.application.resume.entity import ResumeId
|
||||
from template_project.application.resume.errors import ResumeDoesBelongUserError
|
||||
|
||||
|
||||
@to_data_structure
|
||||
class ResumePredictionResponse:
|
||||
from_salary: Decimal
|
||||
to_salary: Decimal
|
||||
recommended_skills: list[str]
|
||||
|
||||
|
||||
@to_data_structure
|
||||
class _Response:
|
||||
position: str
|
||||
about_me: str
|
||||
key_skills: list[str]
|
||||
experience_type: ExperienceType
|
||||
prediction: ResumePredictionResponse | None
|
||||
|
||||
|
||||
@to_interactor
|
||||
class GetResumeInteractor:
|
||||
identity_provider: IdentityProvider
|
||||
resume_data_gateway: ResumeDataGateway
|
||||
resume_prediction_data_gateway: ResumePredictionDataGateway
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
resume_id: ResumeId,
|
||||
) -> _Response:
|
||||
user = await self.identity_provider.get_current_user()
|
||||
|
||||
resume = await self.resume_data_gateway.load(resume_id)
|
||||
|
||||
if resume.user_id != user.id:
|
||||
raise ResumeDoesBelongUserError
|
||||
|
||||
resume_prediction = await self.resume_prediction_data_gateway.load_by_resume_id(resume.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
|
||||
|
||||
return _Response(
|
||||
position=resume.position,
|
||||
about_me=resume.about_me,
|
||||
key_skills=resume.key_skills,
|
||||
experience_type=resume.experience_type,
|
||||
prediction=prediction,
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
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 ResumeEmbeddingPipline:
|
||||
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,13 @@
|
||||
from abc import abstractmethod
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class ResumeEmbeddingVectorGenerator(Protocol):
|
||||
@abstractmethod
|
||||
async def generate(
|
||||
self,
|
||||
position: str,
|
||||
about_me: str,
|
||||
key_skills: list[str],
|
||||
) -> list[float]:
|
||||
raise NotImplementedError
|
||||
@@ -1,5 +1,6 @@
|
||||
from typing import override
|
||||
|
||||
from template_project.application.auth_identity.errors import AuthError
|
||||
from template_project.application.common.errors import ApplicationError, to_error
|
||||
|
||||
|
||||
@@ -13,5 +14,5 @@ class UserWithEmailAlreadyExistsError(ApplicationError):
|
||||
|
||||
|
||||
@to_error
|
||||
class UserUnauthorizedError(ApplicationError):
|
||||
class UserUnauthorizedError(AuthError):
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from template_project.application.common.entity import Entity, to_entity
|
||||
from template_project.application.common.enums import ExperienceType
|
||||
|
||||
|
||||
@to_entity
|
||||
class Vacancy(Entity[Any]):
|
||||
position: str
|
||||
from_salary: Decimal
|
||||
to_salary: Decimal
|
||||
experience_type: ExperienceType
|
||||
description: str
|
||||
key_skills: list[str]
|
||||
|
||||
|
||||
@to_entity
|
||||
class VacancyEmbedding(Entity[Any]):
|
||||
vacancy_id: Any
|
||||
vector: list[float]
|
||||
@@ -0,0 +1,13 @@
|
||||
from abc import abstractmethod
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class VacancyEmbeddingVectorGenerator(Protocol):
|
||||
@abstractmethod
|
||||
async def generate(
|
||||
self,
|
||||
position: str,
|
||||
description: str,
|
||||
key_skills: list[str],
|
||||
) -> list[float]:
|
||||
raise NotImplementedError
|
||||
Reference in New Issue
Block a user