def score_and_explain(self, project: scoring_base.ScoringProject) \ -> scoring_base.ExplainedScore: """Compute a score for the given ScoringProject.""" reasons: List[str] = [] # For now we just match for people willing to move to the whole country. # There might be cases where we should be able to recommend to people who want to move to # their own region, but it would add complexity to find them. is_not_ready_to_move = project.details.area_type < geo_pb2.COUNTRY is_not_single = project.user_profile.family_situation != user_pb2.SINGLE has_advanced_degree = project.user_profile.highest_degree >= job_pb2.LICENCE_MAITRISE is_not_young = project.get_user_age() > 30 looks_only_for_cdi = project.details.employment_types == [job_pb2.CDI] if (is_not_ready_to_move or is_not_young or is_not_single or has_advanced_degree or looks_only_for_cdi): return scoring_base.NULL_EXPLAINED_SCORE reasons.append( project.translate_string( 'vous nous avez dit être prêt%eFeminine à déménager')) reasons.append( project.translate_string('vous êtes disponible familialement')) if len(self._get_seasonal_departements(project).departement_stats) > 1: reasons.append( project.translate_string( "il y a plus d'offres saisonnières par habitants dans d'autres villes" )) return scoring_base.ExplainedScore(2, reasons) return scoring_base.NULL_EXPLAINED_SCORE
def score_and_explain(self, project: scoring_base.ScoringProject) \ -> scoring_base.ExplainedScore: if project.details.diagnostic.category_id == 'enhance-methods-to-interview': return scoring_base.ExplainedScore(3, []) reasons = [ project.translate_static_string( "vous nous avez dit avoir passé beaucoup d'entretiens sans succès" ) ] if project.details.total_interview_count < 0: num_interviews = 0 elif project.details.total_interview_count > 0: num_interviews = project.details.total_interview_count else: num_interviews = _NUM_INTERVIEWS.get( project.details.total_interviews_estimate, 0) job_search_length_months = project.get_search_length_at_creation() if job_search_length_months < 1: job_search_length_months = 1 num_monthly_interviews = num_interviews / job_search_length_months if num_monthly_interviews > _max_monthly_interviews(project): return scoring_base.ExplainedScore(3, reasons) # Whatever the number of month of search, trigger 3 if the user did more than 5 interviews: if num_interviews >= _NUM_INTERVIEWS[project_pb2.DECENT_AMOUNT]: return scoring_base.ExplainedScore(3, reasons) if project.details.diagnostic.category_id == 'bravo': return scoring_base.ExplainedScore(1, []) return scoring_base.NULL_EXPLAINED_SCORE
def score(self, project: scoring_base.ScoringProject) -> float: application_modes = project.job_group_info().application_modes.values() missing_fields: Set[str] = set() # User's job has no application modes info. if not application_modes: rome_id = project.details.target_job.job_group.rome_id missing_fields.add( f'data.job_group_info.{rome_id}.application_modes') raise scoring_base.NotEnoughDataException( "User's job has no application modes info. We cannot say whether this is blocking.", fields=missing_fields) user_application_mode = project.details.preferred_application_mode if not user_application_mode: missing_fields.add('projects.0.preferredApplicationMode') raise scoring_base.NotEnoughDataException( "Missing some information about user's application modes", fields=missing_fields) first_modes = project.get_fap_modes() second_modes = project.get_fap_modes(rank='second') if user_application_mode in first_modes: # User uses the correct application mode. return 0 if user_application_mode in second_modes: # User uses one of the best application modes. return 1 return 3
def _score_and_explain_after_filters(project: scoring_base.ScoringProject) \ -> scoring_base.ExplainedScore: """A helper function to give a score and an explanation for all advices in the module, once some prerequisite filters have been met. """ if project.user_profile.has_car_driving_license != boolean_pb2.FALSE: return scoring_base.NULL_EXPLAINED_SCORE reasons = [] license_required = next( (license.percent_required for license in project.job_group_info().requirements.driving_licenses if license.driving_license == job_pb2.CAR), 0) if license_required: reasons.append( project.translate_static_string( 'le permis est important dans votre métier')) score_modifier = 0 if _license_helps_mobility(project.details): reasons.append( project.translate_static_string( 'le permis augmenterait votre mobilité')) score_modifier = 1 if not reasons: return scoring_base.NULL_EXPLAINED_SCORE score = min( 3, score_modifier + ( # Example at 80% is civil engineer F1106. 3 if license_required > 80 else # Example at 67% is translator E1108. 2 if license_required > 67 else # Example at 50% is chiropractor J1408. 1 if license_required > 50 else 0)) return scoring_base.ExplainedScore(score, reasons)
def score_relevance(self, project: scoring_base.ScoringProject) \ -> diagnostic_pb2.CategoryRelevance: try: project.score('category-enhance-methods-to-interview') except scoring_base.NotEnoughDataException: return diagnostic_pb2.NEUTRAL_RELEVANCE return diagnostic_pb2.RELEVANT_AND_GOOD
def _find_best_departements(unused_: Any, project: scoring_base.ScoringProject) \ -> list[project_pb2.DepartementScore]: """Find which are the best departement to relocate for a given job group.""" own_departement_offers = project.imt_proto( ).yearly_avg_offers_per_10_candidates # If we do not have data about our own departement, we choose not to say anything. if not own_departement_offers: return [] best_departements = project.job_group_info().departement_scores result: list[project_pb2.DepartementScore] = [] for dep in itertools.islice(best_departements, 10): if dep.local_stats.imt.yearly_avg_offers_per_10_candidates <= own_departement_offers: return result offer_ratio = \ dep.local_stats.imt.yearly_avg_offers_per_10_candidates / own_departement_offers result.append( project_pb2.DepartementScore(name=project.translate_string( geo.get_departement_name(project.database, dep.departement_id)), offer_ratio=offer_ratio)) return result
def score_and_explain(self, project: scoring_base.ScoringProject) \ -> scoring_base.ExplainedScore: """Compute a score for the given ScoringProject.""" close_jobs = self.get_close_jobs(project) search_since_nb_months = round(project.get_search_length_now()) score_modifier = 0 reasons: List[str] = [] if len(close_jobs.close_jobs) + len(close_jobs.evolution_jobs) < 2: return scoring_base.NULL_EXPLAINED_SCORE # TODO(cyrille): Make this more robust. force_in_stuck_market = None # TODO(cyrille): Rather use market_stress to avoid depending on diagnostic to be computed. if project.details.diagnostic.category_id == 'stuck-market': force_in_stuck_market = scoring_base.ExplainedScore(1, reasons) if project.get_user_age() >= 45: return force_in_stuck_market or scoring_base.NULL_EXPLAINED_SCORE if project.details.passionate_level >= project_pb2.PASSIONATING_JOB: score_modifier = -1 else: reasons.append(project.translate_string( "vous n'êtes pas trop attaché à votre métier")) if project.details.job_search_has_not_started or search_since_nb_months <= 1: return scoring_base.ExplainedScore(2 + score_modifier, reasons) reasons = [ project.translate_string('vous cherchez depuis {} mois') .format(search_since_nb_months)] if search_since_nb_months >= 12: return scoring_base.ExplainedScore(3, reasons) if search_since_nb_months >= 9: return scoring_base.ExplainedScore(2, reasons) if search_since_nb_months >= 6: return scoring_base.ExplainedScore(1, reasons) return force_in_stuck_market or scoring_base.NULL_EXPLAINED_SCORE
def get_local_jobbing(self, project: scoring_base.ScoringProject) \ -> reorient_jobbing_pb2.JobbingReorientJobs: """Get the jobbing opportunities for the departement.""" recommended_jobs = reorient_jobbing_pb2.JobbingReorientJobs() departement_id = project.details.city.departement_id gender = project.user_profile.gender top_unqualified_jobs = self.list_reorient_jobbing_jobs(project) current_job_market_score = project.imt_proto( ).yearly_avg_offers_per_10_candidates if departement_id in top_unqualified_jobs: for job in top_unqualified_jobs[ departement_id].departement_job_stats.jobs: if current_job_market_score: if job.market_score / current_job_market_score < 1 and \ not project.features_enabled.all_modules: break offers_gain = ( job.market_score / current_job_market_score - 1) * 100 else: if job.market_score < 8: # User is in an unknown market, we only show jobbing jobs that have at least # some job offers. break offers_gain = 0 recommended_jobs.reorient_jobbing_jobs.add( name=project.translate_string( french.genderize_job(job, gender)), offers_percent_gain=offers_gain) return recommended_jobs
def score_and_explain(self, project: scoring_base.ScoringProject) \ -> scoring_base.ExplainedScore: """Compute a score for the given ScoringProject.""" local_jobbing = self.get_local_jobbing(project) if len(local_jobbing.reorient_jobbing_jobs) < 2: return scoring_base.NULL_EXPLAINED_SCORE score_modifier = 0 reasons: list[str] = [] if project.details.passionate_level == project_pb2.LIFE_GOAL_JOB: score_modifier = -2 if project.job_group_info().growth_2012_2022 < .1: score_modifier = -1 if score_modifier >= 0: reasons.append( project.translate_static_string( 'votre métier ne vous tient pas trop à cœur')) if project.user_profile.highest_degree <= job_pb2.CAP_BEP: return scoring_base.ExplainedScore(3 + score_modifier, reasons) if project.user_profile.highest_degree <= job_pb2.BAC_BACPRO: return scoring_base.ExplainedScore(max(2 + score_modifier, 1), reasons) if project.user_profile.highest_degree <= job_pb2.BTS_DUT_DEUG: return scoring_base.ExplainedScore(1, reasons) return scoring_base.NULL_EXPLAINED_SCORE
def score_to_hundred(self, project: scoring_base.ScoringProject) -> float: """Compute a score for the given ScoringProject.""" area_type = project.details.area_type if not area_type: raise scoring_base.NotEnoughDataException() if not project.imt_proto().yearly_avg_offers_per_10_candidates: raise scoring_base.NotEnoughDataException() num_better_departements = project.local_diagnosis( ).num_less_stressful_departements # User is already in one of the top 3 départements. if num_better_departements < 3: raise scoring_base.NotEnoughDataException() # Give a score centered around 50 corresponding to the user's mobility. # The worse the current département is, the more extreme we set the # score. score_range = min(num_better_departements * 1.5, 100) / 2 if area_type >= geo_pb2.COUNTRY: return 50 + score_range if area_type >= geo_pb2.REGION: return 50 if area_type >= geo_pb2.DEPARTEMENT: return 50 - score_range / 2 return 50 - score_range
def score_and_explain(self, project: scoring_base.ScoringProject) \ -> scoring_base.ExplainedScore: """Compute the score for a given project and explains it. Requirements are: - being between 16 and 30 y.o if having a handicap or between 16 and 25 otherwise - having low or no experience (intern maximum) """ age = project.get_user_age() seniority = project.details.seniority reasons: list[str] = [] if age < 16 or seniority > project_pb2.INTERN: return scoring_base.NULL_EXPLAINED_SCORE if project.user_profile.has_handicap and age <= 30: reasons = [ project.translate_static_string('vous avez entre 16 et 30 ans') ] if age <= 25: reasons = [ project.translate_static_string('vous avez entre 16 et 25 ans') ] if not reasons: return scoring_base.NULL_EXPLAINED_SCORE return scoring_base.ExplainedScore(2, reasons)
def score_and_explain(self, project: scoring_base.ScoringProject) \ -> scoring_base.ExplainedScore: """Compute a score for the given ScoringProject.""" associations = self.list_associations(project) search_length_reason = project.translate_static_string( "vous nous avez dit que vous êtes en recherche d'emploi " 'depuis %jobSearchLengthAtCreation') if not associations: return scoring_base.NULL_EXPLAINED_SCORE if user_profile_pb2.MOTIVATION in project.user_profile.frustrations: return scoring_base.ExplainedScore(3, [ project.translate_static_string( 'vous nous avez dit avoir du mal à garder votre ' + 'motivation au top') ]) if len(associations) >= 3 and project.get_search_length_at_creation( ) >= 6: return scoring_base.ExplainedScore(3, [search_length_reason]) if project.get_search_length_at_creation() >= 12: return scoring_base.ExplainedScore(3, [search_length_reason]) return scoring_base.ExplainedScore(2, [ project.translate_static_string( "l'accompagnement humain peut beaucoup vous apporter") ])
def _in_region(scoring_project: scoring_base.ScoringProject) -> str: region = scoring_project.get_region() if not region or not region.name: return scoring_project.translate_static_string('dans la région') if not region.prefix: return scoring_project.translate_static_string('en {region_name}')\ .format(region_name=region.name) return region.prefix + region.name
def _an_application_mode(scoring_project: scoring_base.ScoringProject) -> str: best_mode = scoring_project.get_best_application_mode() best_mode_enum = best_mode.mode if best_mode else job_pb2.PERSONAL_OR_PROFESSIONAL_CONTACTS try: application_mode_in_french = APPLICATION_MODES[best_mode_enum] except KeyError: application_mode_in_french = APPLICATION_MODES[ job_pb2.PERSONAL_OR_PROFESSIONAL_CONTACTS] return scoring_project.translate_static_string(application_mode_in_french)
def _a_required_diploma(scoring_project: scoring_base.ScoringProject) -> str: diplomas = scoring_project.requirements().diplomas if not diplomas: logging.warning( 'Trying to show required diplomas when there are none.\n%s', str(scoring_project)) return scoring_project.translate_static_string('un diplôme') return scoring_project.translate_static_string( 'un {diplomas} ou équivalent').format(diplomas=diplomas[0].name)
def score(self, project: scoring_base.ScoringProject) -> float: # TODO(cyrille): Find a way to make sure they're really desillusioned. if project.get_search_length_now() < 3: # User is probably not disillusioned yet. return 0 if project.details.seniority < project_pb2.CARREER: return 0 return max(0, min(3, project.get_user_age() - 50))
def score_and_explain(self, project: scoring_base.ScoringProject) \ -> scoring_base.ExplainedScore: """Compute a score for the given ScoringProject.""" frustration_reasons = list(self._get_frustrations_reasons(project)) its_easy = project.translate_static_string( "c'est plus facile à faire qu'on peut le croire") if frustration_reasons or project.get_search_length_now() > 3: return scoring_base.ExplainedScore( 2, frustration_reasons or [its_easy]) return scoring_base.ExplainedScore(1, [its_easy])
def score(self, project: scoring_base.ScoringProject) -> float: """Compute a score for the given ScoringProject.""" lang_requirements = _LANGUAGE_REQUIREMENTS.get(project.details.city.departement_id) if not lang_requirements: # Language is not relevant for this city. return 0 try: project.score('for-missing-language') except scoring_base.NotEnoughDataException: return 1 return 3
def score_and_explain(self, project: scoring_base.ScoringProject) \ -> scoring_base.ExplainedScore: """Compute a score for the given ScoringProject.""" if (self._num_interviews_increase(project) >= 2 and project.details.job_search_length_months <= 6): return scoring_base.ExplainedScore(3, [project.translate_string( "nous pensons qu'avec votre profil vous pourriez " "décrocher plus d'entretiens")]) if project.details.diagnostic.category_id == 'bravo' and \ user_pb2.RESUME in project.user_profile.frustrations: return scoring_base.ExplainedScore(1, [project.translate_string( 'vous nous avez dit avoir du mal à rédiger votre CV')]) return scoring_base.NULL_EXPLAINED_SCORE
def _num_interviews_increase(self, project: scoring_base.ScoringProject) -> float: """Compute the increase (in ratio) of # of interviews that one could hope for.""" if project.details.total_interviews_estimate >= project_pb2.A_LOT or \ project.details.total_interview_count > 20: return 0 job_search_length_weeks = project.get_search_length_at_creation() * 52 / 12 num_applicants_per_offer = project.market_stress() or 2.85 weekly_applications = _APPLICATION_PER_WEEK.get( project.details.weekly_applications_estimate, 0) num_applications = job_search_length_weeks * weekly_applications num_potential_interviews = num_applications / num_applicants_per_offer return num_potential_interviews / (self._num_interviews(project) or 1)
def score_and_explain(self, project: scoring_base.ScoringProject) \ -> scoring_base.ExplainedScore: """Compute a score for the given ScoringProject.""" if project.details.weekly_applications_estimate <= project_pb2.LESS_THAN_2 or \ project.details.job_search_length_months < 2: return scoring_base.ExplainedScore(3, [project.translate_string( 'vous nous avez dit que vous en êtes au début de ' 'vos candidatures')]) if project.details.diagnostic.category_id == 'bravo' and \ user_pb2.RESUME in project.user_profile.frustrations: return scoring_base.ExplainedScore(1, [project.translate_string( 'vous nous avez dit avoir du mal à rédiger votre CV')]) return scoring_base.NULL_EXPLAINED_SCORE
def get_expanded_card_data(self, project: scoring_base.ScoringProject) \ -> network_pb2.ContactLeads: """Retrieve data for the expanded card.""" contact_leads = self._list_contact_leads(project) sorted_leads = sorted(contact_leads, key=lambda l: (-len(l.filters), random.random())) return network_pb2.ContactLeads(leads=[ network_pb2.ContactLead( name=project.populate_template( project.translate_string(template.name)), email_example=project.populate_template( project.translate_string(template.email_template)), contact_tip=project.translate_string(template.contact_tip)) for template in sorted_leads ])
def score(self, project: scoring_base.ScoringProject) -> float: job_search_length = project.get_search_length_now() # User has not started their job search. if job_search_length <= 0: raise scoring_base.NotEnoughDataException( 'Search not started yet. We cannot say whether this is blocking.') missing_fields: Set[str] = set() if not project.details.total_interview_count: missing_fields.add('projects.0.totalInterviewCount') if not project.details.weekly_applications_estimate: missing_fields.add('projects.0.weeklyApplicationsEstimate') # Either negative or between 0 and 1. interview_score = 1 - max(0, project.details.total_interview_count) / job_search_length if interview_score <= 0: # User has at least one interview per month, they don't need more tips on getting # interviews. return 0 if missing_fields: raise scoring_base.NotEnoughDataException( 'Missing some information about applications', fields=missing_fields) # Varies between 0 and 3. application_score = _APPLICATION_PER_WEEK[project.details.weekly_applications_estimate] / 5 # Varies between 0 and 3. return interview_score * application_score
def _convert_to_reorient_jobs( self, database: mongo.NoPiiMongoDatabase, reorient_jobs: Iterable[job_pb2.RelatedJobGroup], market_score_source: float, project: scoring_base.ScoringProject ) -> Iterator[reorient_jobbing_pb2.ReorientJob]: for job in reorient_jobs: # Here the market score improvement # (job that the user is searching for vs recommended job) # is overly simplified as offers gain. # TODO(sil): Find a way to explain the market score improvement to the user. # TODO(cyrille): Replace offers_percent_gain by stress_percent_loss to simplify # client-side computations. offers_gain = 100 * ( job.local_stats.imt.yearly_avg_offers_per_10_candidates / market_score_source - 1) job_group_info = jobs.get_group_proto(database, job.job_group.rome_id, project.user_profile.locale) is_diploma_required = False if job_group_info: is_diploma_required = job_group_info.is_diploma_strictly_required yield reorient_jobbing_pb2.ReorientJob( name=job_group_info and job_group_info.name or project.translate_string(job.job_group.name), offers_percent_gain=offers_gain, is_diploma_strictly_required=is_diploma_required)
def get_close_jobs(self, project: scoring_base.ScoringProject) \ -> reorient_to_close_pb2.ReorientCloseJobs: """Get the jobs close to a job group.""" recommended_jobs = reorient_to_close_pb2.ReorientCloseJobs() local_diagnosis = project.local_diagnosis() market_score = local_diagnosis.imt.yearly_avg_offers_per_10_candidates if not market_score: return recommended_jobs reorientation_jobs = local_diagnosis.less_stressful_job_groups close_jobs = [ job for job in reorientation_jobs if job.mobility_type == job_pb2.CLOSE ] evolution_jobs = [ job for job in reorientation_jobs if job.mobility_type == job_pb2.EVOLUTION ] database = project.database recommended_jobs.close_jobs.extend( self._convert_to_reorient_jobs(database, close_jobs, market_score, project)) recommended_jobs.evolution_jobs.extend( self._convert_to_reorient_jobs(database, evolution_jobs, market_score, project)) return recommended_jobs
def _max_monthly_interviews(project: scoring_base.ScoringProject) -> int: """Maximum number of monthly interviews one should have.""" if project.job_group_info( ).application_complexity == job_pb2.COMPLEX_APPLICATION_PROCESS: return 5 return 3
def score_to_hundred(self, project: scoring_base.ScoringProject) -> float: market_stress = project.market_stress() if not market_stress: raise scoring_base.NotEnoughDataException() if market_stress == 1000: return 0 return 1 / market_stress * 100
def score_to_hundred(self, project: scoring_base.ScoringProject) -> float: """Compute a percentage score for the given ScoringProject.""" growth_2012_2022 = project.job_group_info().growth_2012_2022 if not growth_2012_2022: raise scoring_base.NotEnoughDataException() return _interpolate_points(growth_2012_2022, [(-.17, 0), (.07, 50), (.29, 100)])
def score_and_explain(self, project: scoring_base.ScoringProject) \ -> scoring_base.ExplainedScore: """Compute a score for the given ScoringProject.""" first_modes = project.get_fap_modes() first_modes.discard(job_pb2.UNDEFINED_APPLICATION_MODE) if first_modes == {job_pb2.PERSONAL_OR_PROFESSIONAL_CONTACTS}: return scoring_base.ExplainedScore(2, [ project.translate_static_string( 'les embauches se font surtout par les contacts personnels ou professionnels dans' ' votre métier') ]) return scoring_base.ExplainedScore(1, [ project.translate_static_string( "c'est un bon moyen d'étendre votre réseau") ])
def score_and_explain(self, project: scoring_base.ScoringProject) \ -> scoring_base.ExplainedScore: """Compute the score for a given project and explains it.""" age = project.get_user_age() if age < 16 or age >= 25: return scoring_base.NULL_EXPLAINED_SCORE return _score_and_explain_after_filters(project)