Skip to content

Commit 5cbe588

Browse files
authored
✨(candidate-usecase) add metiers in match cv to opportunities usecase (#637)
* ✨(candidate-usecase) add metiers in match cv to opportunities usecase * 🎨(ingestion) fix lint * ✅(candidate) update tests * ♻️(candidate) manage empty metiers in repository for dry
1 parent cd7534e commit 5cbe588

14 files changed

Lines changed: 78 additions & 37 deletions

File tree

src/ingestion/infrastructure/models/raw_offer.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ def _now() -> datetime:
1313
return datetime.now(tz=timezone.utc)
1414

1515

16-
class RawOfferModel(SQLModel, table=True):
16+
class RawOfferModel(SQLModel, table=True): # type: ignore[call-arg]
1717
__tablename__ = "raw_offers"
1818
__table_args__ = (
1919
UniqueConstraint(

src/web/application/candidate/usecases/get_opportunity_details.py

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,5 @@ def execute(
3232
return self.concours_repository.get_by_id(opportunity_id)
3333

3434
offer = self.offers_repository.get_by_id(opportunity_id)
35-
if offer.family_code is None:
36-
self.logger.warning(
37-
f"Offer with id {offer.id} has no family code"
38-
f"cannot fetch related metiers"
39-
)
40-
return offer, []
41-
metiers = self.metiers_repository.get_filtered(
42-
{"offer_family_code": offer.family_code}
43-
)
35+
metiers = self.metiers_repository.get_for_offer(offer)
4436
return offer, metiers

src/web/application/candidate/usecases/match_cv_to_opportunities.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
from domain.entities.concours import Concours
77
from domain.entities.cv_metadata import CVMetadata
88
from domain.entities.document import DocumentType
9+
from domain.entities.metier import Metier
910
from domain.entities.offer import Offer
1011
from domain.exceptions.cv_errors import CVProcessingFailedError
1112
from domain.repositories.concours_repository_interface import IConcoursRepository
1213
from domain.repositories.cv_metadata_repository_interface import ICVMetadataRepository
14+
from domain.repositories.metier_repository_interface import IMetierRepository
1315
from domain.repositories.offers_repository_interface import IOffersRepository
1416
from domain.repositories.vector_repository_interface import IFilters, IVectorRepository
1517
from domain.services.embedding_generator_interface import IEmbeddingGenerator
@@ -18,7 +20,7 @@
1820

1921

2022
class MatchCVToOpportunitiesUsecase(
21-
IUseCase[CVMetadata, List[Tuple[Concours | Offer, float]]],
23+
IUseCase[CVMetadata, List[Tuple[Concours | Tuple[Offer, list[Metier]], float]]],
2224
):
2325
def __init__(
2426
self,
@@ -27,21 +29,23 @@ def __init__(
2729
vector_repository: IVectorRepository,
2830
concours_repository: IConcoursRepository,
2931
offers_repository: IOffersRepository,
32+
metiers_repository: IMetierRepository,
3033
logger: ILogger,
3134
):
3235
self.cv_metadata_repository = cv_metadata_repository
3336
self.embedding_generator = embedding_generator
3437
self.vector_repository = vector_repository
3538
self.concours_repository = concours_repository
3639
self.offers_repository = offers_repository
40+
self.metiers_repository = metiers_repository
3741
self.logger = logger
3842

3943
def execute(
4044
self,
4145
cv_metadata: CVMetadata,
4246
filters: Optional[IFilters] | None = None,
4347
limit: int = 5,
44-
) -> List[Tuple[Concours | Offer, float]]:
48+
) -> List[Tuple[Concours | Tuple[Offer, list[Metier]], float]]:
4549
self.logger.info(
4650
"Starting opportunity matching for cv_uuid='%s', limit=%d",
4751
cv_metadata.id,
@@ -72,7 +76,7 @@ def execute(
7276
if result.document.document_type == DocumentType.OFFERS
7377
]
7478

75-
opportunities: List[Tuple[Concours | Offer, float]] = []
79+
opportunities: List[Tuple[Concours | Tuple[Offer, list[Metier]], float]] = []
7680

7781
concours_ids = [
7882
result.document.entity_id for result in concours_similarity_results
@@ -95,9 +99,9 @@ def execute(
9599
result.document.entity_id: result.score
96100
for result in offers_similarity_results
97101
}
98-
opportunities.extend(
99-
[(offer, offers_scores_by_id[offer.id]) for offer in offers_list]
100-
)
102+
for offer in offers_list:
103+
metiers = self.metiers_repository.get_for_offer(offer)
104+
opportunities.append(((offer, metiers), offers_scores_by_id[offer.id]))
101105

102106
self.logger.info("Returning %d opportunities", len(opportunities))
103107

src/web/domain/repositories/metier_repository_interface.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from domain.ddd.page_interface import IPage
44
from domain.entities.metier import Metier
5+
from domain.entities.offer import Offer
56
from domain.repositories.document_repository_interface import IUpsertResult
67

78
IPredicate = Dict[str, str]
@@ -20,6 +21,8 @@ def get_filtered(
2021
self, predicate: IPredicate
2122
) -> List[Metier]: ... # for example {"offer_family_code": "ERLOG005"}
2223

24+
def get_for_offer(self, offer: Offer) -> List[Metier]: ...
25+
2326
def get_pending_processing(self, limit: int = 1000) -> List[Metier]: ...
2427

2528
def mark_as_processed(self, metiers_list: List[Metier]) -> int: ...

src/web/infrastructure/di/candidate/candidate_container.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ class CandidateContainer(containers.DeclarativeContainer):
7676
vector_repository=vector_repository,
7777
concours_repository=concours_repository,
7878
offers_repository=offers_repository,
79+
metiers_repository=metiers_repository,
7980
logger=logger_service,
8081
)
8182

src/web/infrastructure/repositories/shared/postgres_metier_repository.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from domain.ddd.page_interface import IPage
99
from domain.entities.metier import Metier
10+
from domain.entities.offer import Offer
1011
from domain.exceptions.metiers_error import MetierDoesNotExist
1112
from domain.repositories.document_repository_interface import (
1213
IUpsertResult,
@@ -112,6 +113,15 @@ def get_filtered(
112113
metier_models = MetierModel.objects.filter(**predicate)[:limit]
113114
return [model.to_entity() for model in metier_models]
114115

116+
def get_for_offer(self, offer: Offer) -> List[Metier]:
117+
if offer.family_code is None:
118+
self.logger.warning(
119+
"Offer with id %s has no family code, cannot fetch related metiers",
120+
offer.id,
121+
)
122+
return []
123+
return self.get_filtered({"offer_family_code": offer.family_code})
124+
115125
@transaction.atomic
116126
def get_pending_processing(self, limit: int = 1000) -> List[Metier]:
117127
qs = (

src/web/presentation/candidate/views/cv_flow.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from django.views.generic import FormView, TemplateView
1111

1212
from domain.entities.concours import Concours
13+
from domain.entities.metier import Metier
1314
from domain.entities.offer import Offer
1415
from domain.exceptions.concours_errors import ConcoursDoesNotExist
1516
from domain.exceptions.offer_errors import OfferDoesNotExist
@@ -78,13 +79,19 @@ def __init__(self, *args, **kwargs) -> None:
7879
self._filters_mapper = ViewFiltersToUsecaseMapper()
7980
self._status: CVStatus = CVStatus.PENDING
8081
self._filename: str | None = None
81-
self._opportunities: Sequence[tuple[Concours | Offer, float]] = []
82+
self._opportunities: Sequence[
83+
tuple[Concours | tuple[Offer, list[Metier]], float]
84+
] = []
8285

8386
def dispatch(self, request, *args, **kwargs) -> HttpResponse:
8487
cv_uuid: UUID = kwargs["cv_uuid"]
8588
self._fetch_cv_data(request, cv_uuid)
8689

87-
presenter = OpportunityListPresenter(self._opportunities, request)
90+
flat_opportunities: list[tuple[Concours | Offer, float]] = [
91+
(entity, score) if isinstance(entity, Concours) else (entity[0], score)
92+
for entity, score in self._opportunities
93+
]
94+
presenter = OpportunityListPresenter(flat_opportunities, request)
8895
is_htmx = bool(request.headers.get("HX-Request"))
8996

9097
if is_htmx and request.GET.get("poll") and self._status != CVStatus.PENDING:

src/web/tests/candidate/integration/test_match_cv_to_opportunities.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@
1111
from infrastructure.di.candidate.candidate_container import CandidateContainer
1212
from infrastructure.di.shared.shared_container import SharedContainer
1313
from infrastructure.django_apps.shared.models.concours import ConcoursModel
14+
from infrastructure.django_apps.shared.models.metier import MetierModel
1415
from infrastructure.django_apps.shared.models.offer import OfferModel
1516
from infrastructure.gateways.shared.logger import LoggerService
1617
from tests.factories.concours_factory import ConcoursFactory
1718
from tests.factories.cv_metadata_factory import CVMetadataFactory
19+
from tests.factories.metier_factory import MetierFactory
1820
from tests.factories.offer_factory import OfferFactory
1921
from tests.factories.vectorized_document_factory import VectorizedDocumentFactory
2022
from tests.utils.mock_api_response_factory import MockApiResponseFactory
@@ -96,6 +98,8 @@ def test_execute_with_valid_cv_returns_opportunities(
9698

9799
concours = ConcoursFactory.create_model_batch(2)
98100
offers = OfferFactory.create_model_batch(3)
101+
metiers = MetierFactory.create_model_batch(3)
102+
99103
limit = len(offers) + len(concours) - 1
100104

101105
# Setup CV metadata in real DB
@@ -109,6 +113,9 @@ def test_execute_with_valid_cv_returns_opportunities(
109113
offers_repo = candidate_container.shared_container.offers_repository()
110114
offers_repo.upsert_batch([OfferModel.to_entity(offer) for offer in offers])
111115

116+
metiers_repo = candidate_container.shared_container.metiers_repository()
117+
metiers_repo.upsert_batch([MetierModel.to_entity(metier) for metier in metiers])
118+
112119
# Generate vectorized documents using VectorizedDocumentFactory
113120
vectorized_concours = []
114121
for concours_entity in concours_repo.get_all():

src/web/tests/candidate/presentation/e2e/test_cv_flow_keyboard.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ def test_user_completes_full_flow_with_keyboard_only(
4949
"application.candidate.usecases.match_cv_to_opportunities."
5050
"MatchCVToOpportunitiesUsecase.execute"
5151
) as mock_execute:
52-
mock_execute.return_value = [(offer_entity, 0.9)]
52+
mock_execute.return_value = [((offer_entity, []), 0.9)]
5353
CVMetadataModel.objects.filter(id=cv_uuid).update(
5454
status=CVStatus.COMPLETED.value, search_query="dev"
5555
)
@@ -93,8 +93,8 @@ def test_focus_returns_to_trigger_after_filter_then_drawer_escape(
9393

9494
def fake_execute(*, cv_metadata, filters, limit):
9595
if filters and "category" in filters:
96-
return [(offer_a, 0.9)]
97-
return [(offer_a, 0.9), (offer_b, 0.8)]
96+
return [((offer_a, []), 0.9)]
97+
return [((offer_a, []), 0.9), ((offer_b, []), 0.8)]
9898

9999
results_url = reverse(
100100
"candidate:cv_results", kwargs={"cv_uuid": cv_metadata.id}

src/web/tests/candidate/presentation/e2e/test_cv_upload_flow.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def test_user_sees_results_after_processing_completes(
3030
"MatchCVToOpportunitiesUsecase.execute"
3131
) as mock_execute:
3232
mock_execute.return_value = [
33-
(OfferFactory.create_entity(title="Offre e2e"), 0.9),
33+
((OfferFactory.create_entity(title="Offre e2e"), []), 0.9),
3434
]
3535
CVMetadataModel.objects.filter(id=cv_uuid).update(
3636
status=CVStatus.COMPLETED.value, search_query="dev"

0 commit comments

Comments
 (0)