add resume

This commit is contained in:
ivankirpichnikov
2025-11-22 02:17:18 +03:00
parent a207e5a217
commit d9a3c39980
33 changed files with 1157 additions and 97 deletions
@@ -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