You've already forked RekomenciBackend
add resume
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
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)
|
||||
@@ -0,0 +1,48 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class Salary:
|
||||
from_: int | None = None
|
||||
to: int | None = None
|
||||
currency_code: str | None = None
|
||||
gross: bool | None = None
|
||||
per_mode_from: int | None = None
|
||||
per_mode_to: int | None = None
|
||||
mode: str | None = None
|
||||
frequency: str | None = None
|
||||
no_compensation: Any = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimilarIds:
|
||||
company_id: int | None = None
|
||||
vacancy_id: int | None = None
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class DataSetLine:
|
||||
company_id: int | None = None
|
||||
vacancy_id: int
|
||||
company_nm: str
|
||||
vacancy_nm: str
|
||||
experience: str
|
||||
schedule: str | None
|
||||
work_hours: str | None
|
||||
publication_dt: datetime
|
||||
salary: Salary
|
||||
location: str
|
||||
vacancy_description: str
|
||||
key_skills: list[Any]
|
||||
accept_temporary: bool
|
||||
similar_ids: list[SimilarIds]
|
||||
current_view_count: int
|
||||
scraped_timestamp: int
|
||||
metro_line_nm: str | None
|
||||
metro_station_nm: str | None
|
||||
location_lat: float | None
|
||||
location_lon: float | None
|
||||
profession_id: int
|
||||
accept_incomplete_resumes: bool
|
||||
@@ -1,7 +1,7 @@
|
||||
from typing import override
|
||||
from uuid import UUID
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from template_project.application.access_token.cryptographer import AccessTokenCryptographer
|
||||
from template_project.application.access_token.entity import AccessTokenId
|
||||
@@ -20,9 +20,12 @@ class FernetAccessTokenCryptographer(AccessTokenCryptographer):
|
||||
).decode("utf-8")
|
||||
|
||||
@override
|
||||
def decrypto(self, raw_access_token: RawAccessToken) -> AccessTokenId:
|
||||
return AccessTokenId(
|
||||
UUID(
|
||||
self._fernet.decrypt(raw_access_token).decode("utf-8"),
|
||||
),
|
||||
)
|
||||
def decrypto(self, raw_access_token: RawAccessToken) -> AccessTokenId | None:
|
||||
try:
|
||||
return AccessTokenId(
|
||||
UUID(
|
||||
self._fernet.decrypt(raw_access_token).decode("utf-8"),
|
||||
),
|
||||
)
|
||||
except InvalidToken:
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import override
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from template_project.adapters.data_gateways.tables import resume_prediction_table
|
||||
from template_project.application.resume.data_gateway import ResumeDataGateway, ResumePredictionDataGateway
|
||||
from template_project.application.resume.entity import Resume, ResumeEmbeddingId, ResumeId, ResumePrediction
|
||||
from template_project.application.resume.errors import ResumeNotFoundError
|
||||
|
||||
|
||||
class DefaultResumeDataGateway(ResumeDataGateway):
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
@override
|
||||
async def get_suitable_resumes(self, embedding_id: ResumeEmbeddingId) -> Sequence[Resume]:
|
||||
raise NotImplementedError
|
||||
|
||||
@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
|
||||
|
||||
|
||||
class DefaultResumePredictionDataGateway(ResumePredictionDataGateway):
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
@override
|
||||
async def load_by_resume_id(self, resume_id: ResumeId) -> ResumePrediction | None:
|
||||
statement = select(ResumePrediction).where(resume_prediction_table.c.resume_id == resume_id)
|
||||
result = await self._session.execute(statement)
|
||||
return result.scalar()
|
||||
@@ -1,10 +1,15 @@
|
||||
from typing import Final
|
||||
|
||||
from pgvector.sqlalchemy import Vector
|
||||
from sqlalchemy import (
|
||||
ARRAY,
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
Enum,
|
||||
ForeignKey,
|
||||
MetaData,
|
||||
Numeric,
|
||||
String,
|
||||
Table,
|
||||
UniqueConstraint,
|
||||
@@ -15,13 +20,14 @@ from sqlalchemy.orm import registry
|
||||
from template_project.application.access_token.entity import AccessToken
|
||||
from template_project.application.auth_identity.entity import AuthIdentity, AuthMethod
|
||||
from template_project.application.notification_device.entity import NotificationDevice
|
||||
from template_project.application.resume.entity import Resume, ResumeEmbedding, ResumePrediction
|
||||
from template_project.application.user.entity import User
|
||||
from template_project.application.user.profile.entity import Profile
|
||||
|
||||
meta_data = MetaData()
|
||||
mapper_registry = registry()
|
||||
meta_data: Final = MetaData()
|
||||
mapper_registry: Final = registry()
|
||||
|
||||
user_table = Table(
|
||||
user_table: Final = Table(
|
||||
"users",
|
||||
meta_data,
|
||||
Column("id", UUID, primary_key=True),
|
||||
@@ -29,7 +35,7 @@ user_table = Table(
|
||||
Column("created_at", DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
|
||||
access_token_table = Table(
|
||||
access_token_table: Final = Table(
|
||||
"access_token",
|
||||
meta_data,
|
||||
Column("id", UUID, primary_key=True),
|
||||
@@ -40,7 +46,7 @@ access_token_table = Table(
|
||||
Column("created_at", DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
|
||||
auth_identity_table = Table(
|
||||
auth_identity_table: Final = Table(
|
||||
"auth_identities",
|
||||
meta_data,
|
||||
Column("id", UUID, primary_key=True),
|
||||
@@ -52,7 +58,7 @@ auth_identity_table = Table(
|
||||
UniqueConstraint("method", "identifier", name="uq_auth_method_identifier"),
|
||||
)
|
||||
|
||||
profile_table = Table(
|
||||
profile_table: Final = Table(
|
||||
"profiles",
|
||||
meta_data,
|
||||
Column("id", UUID, primary_key=True),
|
||||
@@ -67,7 +73,7 @@ profile_table = Table(
|
||||
Column("created_at", DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
|
||||
notification_device_table = Table(
|
||||
notification_device_table: Final = Table(
|
||||
"notification_devices",
|
||||
meta_data,
|
||||
Column("id", UUID, primary_key=True),
|
||||
@@ -78,8 +84,47 @@ notification_device_table = Table(
|
||||
UniqueConstraint("user_id", "device_id", name="uq_user_device"),
|
||||
)
|
||||
|
||||
resume_table: Final = Table(
|
||||
"resume",
|
||||
meta_data,
|
||||
Column("id", UUID, primary_key=True),
|
||||
Column("deleted_at", DateTime(timezone=True)),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False),
|
||||
Column("user_id", UUID, ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
Column("position", String, nullable=False),
|
||||
Column("about_me", String, nullable=False),
|
||||
Column("key_skills", ARRAY(String, as_tuple=True), nullable=False),
|
||||
Column("experience_type", String, nullable=False),
|
||||
Column("down_resume_id", UUID, ForeignKey("resume.id", ondelete="CASCADE"), nullable=True, default=None),
|
||||
)
|
||||
|
||||
resume_embedding_table: Final = Table(
|
||||
"resume_embedding",
|
||||
meta_data,
|
||||
Column("id", UUID, primary_key=True),
|
||||
Column("deleted_at", DateTime(timezone=True)),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False),
|
||||
Column("resume_id", UUID, ForeignKey("resume.id", ondelete="CASCADE"), nullable=False),
|
||||
Column("vector", Vector, nullable=False),
|
||||
)
|
||||
resume_prediction_table: Final = Table(
|
||||
"resume_prediction",
|
||||
meta_data,
|
||||
Column("id", UUID, primary_key=True),
|
||||
Column("deleted_at", DateTime(timezone=True)),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False),
|
||||
Column("resume_id", UUID, ForeignKey("resume.id", ondelete="CASCADE"), nullable=False),
|
||||
Column("from_salary", Numeric, nullable=False),
|
||||
Column("to_salary", Numeric, nullable=False),
|
||||
Column("recommended_skills", ARRAY(String, as_tuple=True), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
mapper_registry.map_imperatively(User, user_table)
|
||||
mapper_registry.map_imperatively(AccessToken, access_token_table)
|
||||
mapper_registry.map_imperatively(AuthIdentity, auth_identity_table)
|
||||
mapper_registry.map_imperatively(Profile, profile_table)
|
||||
mapper_registry.map_imperatively(NotificationDevice, notification_device_table)
|
||||
mapper_registry.map_imperatively(Resume, resume_table)
|
||||
mapper_registry.map_imperatively(ResumeEmbedding, resume_embedding_table)
|
||||
mapper_registry.map_imperatively(ResumePrediction, resume_prediction_table)
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,67 @@
|
||||
"""add resume
|
||||
|
||||
Revision ID: 0d2e7755303c
|
||||
Revises: 9140c6824ab8
|
||||
Create Date: 2025-11-22 00:08:04.421819
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '0d2e7755303c'
|
||||
down_revision: Union[str, Sequence[str], None] = '9140c6824ab8'
|
||||
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('resume',
|
||||
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('user_id', sa.UUID(), nullable=False),
|
||||
sa.Column('position', sa.String(), nullable=False),
|
||||
sa.Column('about_me', sa.String(), nullable=False),
|
||||
sa.Column('key_skills', sa.String(), nullable=False),
|
||||
sa.Column('experience_type', sa.String(), nullable=False),
|
||||
sa.Column('down_resume_id', sa.UUID(), nullable=True, default=None),
|
||||
sa.ForeignKeyConstraint(['down_resume_id'], ['resume.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('resume_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('resume_id', sa.UUID(), nullable=False),
|
||||
sa.Column('vector', sa.ARRAY(sa.Numeric()), nullable=False),
|
||||
sa.ForeignKeyConstraint(['resume_id'], ['resume.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('resume_prediction',
|
||||
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('resume_id', sa.UUID(), nullable=False),
|
||||
sa.Column('from_salary', sa.Numeric(), nullable=False),
|
||||
sa.Column('to_salary', sa.Numeric(), nullable=False),
|
||||
sa.Column('recommended_skills', sa.ARRAY(sa.Numeric()), nullable=False),
|
||||
sa.ForeignKeyConstraint(['resume_id'], ['resume.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('resume_prediction')
|
||||
op.drop_table('resume_embedding')
|
||||
op.drop_table('resume')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,47 @@
|
||||
"""fix resume types
|
||||
|
||||
Revision ID: 5b7a1ca1f06b
|
||||
Revises: 0d2e7755303c
|
||||
Create Date: 2025-11-22 01:12:18.097168
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from pgvector.sqlalchemy import Vector
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '5b7a1ca1f06b'
|
||||
down_revision: Union[str, Sequence[str], None] = '0d2e7755303c'
|
||||
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.alter_column('resume_embedding', 'vector',
|
||||
existing_type=postgresql.ARRAY(sa.NUMERIC()),
|
||||
type_=Vector(),
|
||||
existing_nullable=False)
|
||||
op.alter_column('resume_prediction', 'recommended_skills',
|
||||
existing_type=postgresql.ARRAY(sa.NUMERIC()),
|
||||
type_=sa.ARRAY(sa.String(), as_tuple=True),
|
||||
existing_nullable=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.alter_column('resume_prediction', 'recommended_skills',
|
||||
existing_type=sa.ARRAY(sa.String(), as_tuple=True),
|
||||
type_=postgresql.ARRAY(sa.NUMERIC()),
|
||||
existing_nullable=False)
|
||||
op.alter_column('resume_embedding', 'vector',
|
||||
existing_type=pgvector.sqlalchemy.vector.VECTOR(),
|
||||
type_=postgresql.ARRAY(sa.NUMERIC()),
|
||||
existing_nullable=False)
|
||||
# ### end Alembic commands ###
|
||||
@@ -5,6 +5,7 @@ import os
|
||||
import sys
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
@@ -12,11 +13,12 @@ import firebase_admin # type: ignore[import-untyped]
|
||||
import uvicorn
|
||||
from dishka import AsyncContainer
|
||||
from dishka.integrations.fastapi import setup_dishka
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from firebase_admin import credentials
|
||||
from prometheus_fastapi_instrumentator import Instrumentator
|
||||
|
||||
from template_project.application.auth_identity.errors import AuthError
|
||||
from template_project.web_api.configuration import Configuration, load_configuration
|
||||
from template_project.web_api.ioc.make import make_ioc
|
||||
from template_project.web_api.routes import auth, healthcheck, notification, profile, resume, storage
|
||||
@@ -51,6 +53,13 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
await app.state.dishka_container.close()
|
||||
|
||||
|
||||
async def auth_error_exception_handler(
|
||||
request: Request,
|
||||
exception: AuthError,
|
||||
) -> Response:
|
||||
return Response(status_code=HTTPStatus.UNAUTHORIZED, content="Authentication failed")
|
||||
|
||||
|
||||
def make_asgi_application(
|
||||
ioc: AsyncContainer,
|
||||
) -> FastAPI:
|
||||
@@ -69,6 +78,7 @@ def make_asgi_application(
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.add_exception_handler(AuthError, auth_error_exception_handler) # type: ignore[arg-type]
|
||||
app.include_router(auth.router)
|
||||
app.include_router(healthcheck.router)
|
||||
app.include_router(profile.router)
|
||||
@@ -82,13 +92,7 @@ def make_asgi_application(
|
||||
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)
|
||||
def make_server(configuration: Configuration) -> uvicorn.Server:
|
||||
ioc = make_ioc(configuration)
|
||||
asgi_application = make_asgi_application(ioc)
|
||||
|
||||
@@ -99,7 +103,17 @@ async def _main(
|
||||
log_config=LOG_CONFIG,
|
||||
access_log=configuration.server.access_log,
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
return uvicorn.Server(config)
|
||||
|
||||
|
||||
async def run_server(
|
||||
configuration_path: Path,
|
||||
) -> None:
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
|
||||
configuration = load_configuration(configuration_path)
|
||||
server = make_server(configuration)
|
||||
await server.serve()
|
||||
|
||||
|
||||
@@ -118,7 +132,7 @@ def main() -> None:
|
||||
"pass the path to the config or specify it in the environment variables `CONFIGURATION_PATH`",
|
||||
)
|
||||
|
||||
asyncio.run(_main(Path(configuration_path)))
|
||||
asyncio.run(run_server(Path(configuration_path)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -4,6 +4,7 @@ from template_project.adapters.data_gateways.access_token import DefaultAccessTo
|
||||
from template_project.adapters.data_gateways.auth_identity import DefaultAuthIdentityDataGateway
|
||||
from template_project.adapters.data_gateways.notification_device import DefaultNotificationDeviceDataGateway
|
||||
from template_project.adapters.data_gateways.profile import DefaultProfileDataGateway
|
||||
from template_project.adapters.data_gateways.resume import DefaultResumeDataGateway, DefaultResumePredictionDataGateway
|
||||
from template_project.adapters.data_gateways.user import DefaultUserDataGateway
|
||||
from template_project.adapters.unit_of_work import DefaultUnitOfWork
|
||||
|
||||
@@ -18,4 +19,6 @@ class DataGatewayProvider(Provider):
|
||||
WithParents[DefaultAuthIdentityDataGateway],
|
||||
WithParents[DefaultProfileDataGateway],
|
||||
WithParents[DefaultNotificationDeviceDataGateway],
|
||||
WithParents[DefaultResumeDataGateway],
|
||||
WithParents[DefaultResumePredictionDataGateway],
|
||||
)
|
||||
|
||||
@@ -6,6 +6,8 @@ from template_project.application.notification_device.interactors.register_devic
|
||||
RegisterNotificationDeviceInteractor,
|
||||
)
|
||||
from template_project.application.notification_device.interactors.send_notification import NotificationInteractor
|
||||
from template_project.application.resume.interactors.add import AddResumeInteractor
|
||||
from template_project.application.resume.interactors.get import GetResumeInteractor
|
||||
from template_project.application.user.profile.interactors.get_profile import GetProfileInteractor
|
||||
from template_project.application.user.profile.interactors.patch_profile import PatchProfileInteractor
|
||||
|
||||
@@ -20,4 +22,6 @@ class InteractorProvider(Provider):
|
||||
PatchProfileInteractor,
|
||||
NotificationInteractor,
|
||||
RegisterNotificationDeviceInteractor,
|
||||
GetResumeInteractor,
|
||||
AddResumeInteractor,
|
||||
)
|
||||
|
||||
@@ -1,39 +1,35 @@
|
||||
from decimal import Decimal
|
||||
from enum import StrEnum
|
||||
from http import HTTPStatus
|
||||
from typing import Annotated
|
||||
|
||||
from dishka import FromDishka
|
||||
from dishka.integrations.fastapi import DishkaRoute
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.security import HTTPBearer
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from template_project.application.common.enums import ExperienceType
|
||||
from template_project.application.resume.entity import ResumeId
|
||||
from template_project.application.resume.errors import ResumeDoesBelongUserError, ResumeNotFoundError
|
||||
from template_project.application.resume.interactors.add import AddResumeInteractor
|
||||
from template_project.application.resume.interactors.get import GetResumeInteractor
|
||||
|
||||
security = HTTPBearer()
|
||||
router = APIRouter(route_class=DishkaRoute, tags=["Resume"], dependencies=[Depends(security)])
|
||||
|
||||
|
||||
class ExperienceTypeEnum(StrEnum):
|
||||
NO_EXPERIENCE = "noExperience"
|
||||
BETWEEN_1_AND_3 = "between1And3"
|
||||
BETWEEN_3_AND_6 = "between3And6"
|
||||
MORE_THAN_6 = "moreThan6"
|
||||
|
||||
|
||||
class CreateResumeRequest(BaseModel):
|
||||
position: str = Field(..., min_length=1, max_length=200, description="Job position", examples=["Python Developer"])
|
||||
position: str = Field(min_length=1, max_length=200, description="Job position", examples=["Python Developer"])
|
||||
about_me: str = Field(
|
||||
..., min_length=1, max_length=2000, description="About me section", examples=["Experienced Python developer"]
|
||||
min_length=1,
|
||||
max_length=2000,
|
||||
description="About me section",
|
||||
examples=["Experienced Python developer"],
|
||||
)
|
||||
key_skills: list[str] = Field(
|
||||
..., min_length=1, description="List of key skills", examples=[["Python", "FastAPI", "PostgreSQL"]]
|
||||
)
|
||||
experience_type: ExperienceTypeEnum = Field(
|
||||
..., description="Experience type", examples=[ExperienceTypeEnum.BETWEEN_3_AND_6]
|
||||
)
|
||||
parent_resume_id: str | None = Field(
|
||||
None,
|
||||
description="ID of parent resume (for creating resume from existing one)",
|
||||
examples=["01234567-89ab-cdef-0123-456789abcdef"],
|
||||
min_length=1, description="List of key skills", examples=[["Python", "FastAPI", "PostgreSQL"]]
|
||||
)
|
||||
experience_type: ExperienceType = Field(description="Experience type", examples=[ExperienceType.BETWEEN_3_AND_6])
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
@@ -42,24 +38,21 @@ class CreateResumeRequest(BaseModel):
|
||||
"about_me": "Experienced Python developer with 5 years of experience",
|
||||
"key_skills": ["Python", "FastAPI", "PostgreSQL", "Docker"],
|
||||
"experience_type": "between3And6",
|
||||
"parent_resume_id": None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class CreateResumeResponse(BaseModel):
|
||||
resume_id: str = Field(..., description="Created resume ID")
|
||||
resume_id: ResumeId = Field(description="Created resume ID")
|
||||
|
||||
model_config = {"json_schema_extra": {"example": {"resume_id": "01234567-89ab-cdef-0123-456789abcdef"}}}
|
||||
|
||||
|
||||
class SalaryPrediction(BaseModel):
|
||||
from_salary: Decimal = Field(..., description="Minimum predicted salary", examples=[Decimal(100000)])
|
||||
to_salary: Decimal = Field(..., description="Maximum predicted salary", examples=[Decimal(150000)])
|
||||
recommended_skills: list[str] = Field(
|
||||
..., description="Recommended skills to add", examples=[["Kubernetes", "Redis"]]
|
||||
)
|
||||
from_salary: Decimal = Field(description="Minimum predicted salary", examples=[Decimal(100000)])
|
||||
to_salary: Decimal = Field(description="Maximum predicted salary", examples=[Decimal(150000)])
|
||||
recommended_skills: list[str] = Field(description="Recommended skills to add", examples=[["Kubernetes", "Redis"]])
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
@@ -73,10 +66,10 @@ class SalaryPrediction(BaseModel):
|
||||
|
||||
|
||||
class ResumeResponse(BaseModel):
|
||||
position: str = Field(..., description="Job position")
|
||||
about_me: str = Field(..., description="About me section")
|
||||
key_skills: list[str] = Field(..., description="List of key skills")
|
||||
experience_type: ExperienceTypeEnum = Field(..., description="Experience type")
|
||||
position: str = Field(description="Job position")
|
||||
about_me: str = Field(description="About me section")
|
||||
key_skills: list[str] = Field(description="List of key skills")
|
||||
experience_type: ExperienceType = Field(description="Experience type")
|
||||
prediction: SalaryPrediction | None = Field(None, description="Salary prediction (can be null)")
|
||||
|
||||
model_config = {
|
||||
@@ -97,10 +90,10 @@ class ResumeResponse(BaseModel):
|
||||
|
||||
|
||||
class ResumeListItem(BaseModel):
|
||||
position: str = Field(..., description="Job position")
|
||||
about_me: str = Field(..., description="About me section")
|
||||
key_skills: list[str] = Field(..., description="List of key skills")
|
||||
experience_type: ExperienceTypeEnum = Field(..., description="Experience type")
|
||||
position: str = Field(description="Job position")
|
||||
about_me: str = Field(description="About me section")
|
||||
key_skills: list[str] = Field(description="List of key skills")
|
||||
experience_type: ExperienceType = Field(description="Experience type")
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
@@ -115,12 +108,12 @@ class ResumeListItem(BaseModel):
|
||||
|
||||
|
||||
class GetResumeListRequest(BaseModel):
|
||||
limit: int = Field(..., ge=1, le=100, description="Number of resumes to return", examples=[10])
|
||||
offset: int = Field(..., ge=0, description="Number of resumes to skip", examples=[0])
|
||||
limit: int = Field(ge=1, le=100, description="Number of resumes to return", examples=[10])
|
||||
offset: int = Field(ge=0, description="Number of resumes to skip", examples=[0])
|
||||
|
||||
|
||||
class GetResumeListResponse(BaseModel):
|
||||
resumes: list[ResumeListItem] = Field(..., description="List of resumes")
|
||||
resumes: list[ResumeListItem] = Field(description="List of resumes")
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
@@ -139,12 +132,12 @@ class GetResumeListResponse(BaseModel):
|
||||
|
||||
|
||||
class GetResumeHistoryRequest(BaseModel):
|
||||
limit: int = Field(..., ge=1, le=100, description="Number of resumes to return", examples=[10])
|
||||
offset: int = Field(..., ge=0, description="Number of resumes to skip", examples=[0])
|
||||
limit: int = Field(ge=1, le=100, description="Number of resumes to return", examples=[10])
|
||||
offset: int = Field(ge=0, description="Number of resumes to skip", examples=[0])
|
||||
|
||||
|
||||
class GetResumeHistoryResponse(BaseModel):
|
||||
resumes: list[ResumeListItem] = Field(..., description="List of resume history items")
|
||||
resumes: list[ResumeListItem] = Field(description="List of resume history items")
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
@@ -173,9 +166,17 @@ class GetResumeHistoryResponse(BaseModel):
|
||||
)
|
||||
async def create_resume(
|
||||
request: CreateResumeRequest,
|
||||
interactor: FromDishka[AddResumeInteractor],
|
||||
) -> CreateResumeResponse:
|
||||
# TODO: Implement resume creation
|
||||
raise NotImplementedError
|
||||
interactor_response = await interactor.execute(
|
||||
position=request.position,
|
||||
about_me=request.about_me,
|
||||
key_skills=request.key_skills,
|
||||
experience_type=request.experience_type,
|
||||
)
|
||||
return CreateResumeResponse(
|
||||
resume_id=interactor_response,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -186,13 +187,39 @@ async def create_resume(
|
||||
200: {"description": "Resume retrieved successfully", "model": ResumeResponse},
|
||||
401: {"description": "Unauthorized - invalid or missing token"},
|
||||
404: {"description": "Resume not found"},
|
||||
403: {"descriptipn": "The resume does not belong to you"},
|
||||
},
|
||||
)
|
||||
async def get_resume(
|
||||
resume_id: str,
|
||||
resume_id: ResumeId,
|
||||
interactor: FromDishka[GetResumeInteractor],
|
||||
) -> ResumeResponse:
|
||||
# TODO: Implement resume retrieval
|
||||
raise NotImplementedError
|
||||
try:
|
||||
interactor_response = await interactor.execute(resume_id)
|
||||
except ResumeNotFoundError as error:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="Resume not found",
|
||||
) from error
|
||||
except ResumeDoesBelongUserError as error:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="The resume does not belong to you",
|
||||
) from error
|
||||
|
||||
return ResumeResponse(
|
||||
position=interactor_response.position,
|
||||
about_me=interactor_response.about_me,
|
||||
key_skills=interactor_response.key_skills,
|
||||
experience_type=interactor_response.experience_type,
|
||||
prediction=SalaryPrediction(
|
||||
from_salary=interactor_response.prediction.from_salary,
|
||||
to_salary=interactor_response.prediction.to_salary,
|
||||
recommended_skills=interactor_response.prediction.recommended_skills,
|
||||
)
|
||||
if interactor_response.prediction is not None
|
||||
else None,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -222,8 +249,8 @@ class PatchResumeRequest(BaseModel):
|
||||
key_skills: list[str] | None = Field(
|
||||
None, min_length=1, description="List of key skills", examples=[["Python", "FastAPI", "PostgreSQL"]]
|
||||
)
|
||||
experience_type: ExperienceTypeEnum | None = Field(
|
||||
None, description="Experience type", examples=[ExperienceTypeEnum.BETWEEN_3_AND_6]
|
||||
experience_type: ExperienceType | None = Field(
|
||||
None, description="Experience type", examples=[ExperienceType.BETWEEN_3_AND_6]
|
||||
)
|
||||
|
||||
model_config = {
|
||||
@@ -239,10 +266,10 @@ class PatchResumeRequest(BaseModel):
|
||||
|
||||
|
||||
class PatchResumeResponse(BaseModel):
|
||||
position: str = Field(..., description="Job position")
|
||||
about_me: str = Field(..., description="About me section")
|
||||
key_skills: list[str] = Field(..., description="List of key skills")
|
||||
experience_type: ExperienceTypeEnum = Field(..., description="Experience type")
|
||||
position: str = Field(description="Job position")
|
||||
about_me: str = Field(description="About me section")
|
||||
key_skills: list[str] = Field(description="List of key skills")
|
||||
experience_type: ExperienceType = Field(description="Experience type")
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
|
||||
Reference in New Issue
Block a user