QuentinL52 commited on
Commit
8f14c45
·
verified ·
1 Parent(s): 10ca36a

Update src/scoring_engine.py

Browse files
Files changed (1) hide show
  1. src/scoring_engine.py +56 -267
src/scoring_engine.py CHANGED
@@ -2,48 +2,23 @@ import json
2
  import logging
3
  from datetime import datetime
4
  from collections import defaultdict
5
- import re
6
- from typing import Dict, List, Tuple, Any
7
 
8
  logger = logging.getLogger(__name__)
9
 
10
- class EnhancedContextualScoringEngine:
11
  """
12
- Moteur de scoring avancé qui analyse multiple dimensions pour évaluer
13
- le niveau de maîtrise d'une compétence : contexte, fréquence, profondeur,
14
- progression, et niveau d'expertise démontré.
15
  """
16
-
17
- # Pondérations pour la formule de scoring (total = 1.0)
18
- ALPHA = 0.35 # Poids du contexte (où la compétence est mentionnée)
19
- BETA = 0.20 # Poids de la fréquence (combien de fois elle apparaît)
20
- GAMMA = 0.25 # Poids de la profondeur (durée d'expérience)
21
- DELTA = 0.15 # Poids de l'expertise (niveau démontré)
22
- EPSILON = 0.05 # Poids de la progression (évolution dans le temps)
23
 
24
- # Valeurs des contextes (révisées pour plus de granularité)
 
 
 
25
  CONTEXT_VALUES = {
26
  "formations": 0.3,
27
- "projets_personnels": 0.5,
28
- "projets_professionnels": 0.7,
29
  "experiences_professionnelles": 0.8,
30
- "responsabilités_leadership": 0.9, # Nouveau : si mentionné comme leader/expert
31
- }
32
-
33
- # Mots-clés indiquant différents niveaux d'expertise
34
- EXPERTISE_KEYWORDS = {
35
- "débutant": ["initiation", "découverte", "apprentissage", "formation"],
36
- "intermédiaire": ["utilisation", "application", "développement", "mise en œuvre"],
37
- "avancé": ["maîtrise", "expertise", "optimisation", "architecture", "conception"],
38
- "expert": ["lead", "senior", "expert", "mentor", "formateur", "référent", "spécialiste"]
39
- }
40
-
41
- # Scores d'expertise
42
- EXPERTISE_SCORES = {
43
- "débutant": 0.2,
44
- "intermédiaire": 0.5,
45
- "avancé": 0.8,
46
- "expert": 1.0
47
  }
48
 
49
  def __init__(self, parsed_cv_data: dict):
@@ -51,310 +26,124 @@ class EnhancedContextualScoringEngine:
51
  if not self.cv_data:
52
  raise ValueError("Données du candidat non trouvées dans le CV parsé.")
53
 
54
- def _normalize_score(self, value: float, max_value: float = 10.0) -> float:
55
- """Normalise une valeur avec une fonction sigmoïde améliorée."""
56
- if value <= 0:
57
- return 0.0
58
- # Utilisation d'une courbe plus douce pour éviter la saturation trop rapide
59
- return min(1.0, value / (value + max_value))
60
 
61
  def _parse_date(self, date_str: str) -> datetime | None:
62
  """Parse une date de manière robuste."""
63
  if not date_str or not isinstance(date_str, str):
64
  return None
65
 
66
- date_str_clean = date_str.strip().lower()
67
- current_indicators = ["aujourd'hui", "maintenant", "en cours", "current", "présent"]
68
-
69
- if any(indicator in date_str_clean for indicator in current_indicators):
70
  return datetime.now()
71
 
72
- # Formats supportés étendus
73
- formats = ["%m/%Y", "%Y-%m", "%Y", "%m-%Y", "%B %Y", "%b %Y"]
74
- for fmt in formats:
75
  try:
76
- return datetime.strptime(date_str_clean, fmt)
77
  except ValueError:
78
  continue
79
  return None
80
 
81
  def _calculate_duration_in_years(self, start_date_str: str, end_date_str: str) -> float:
82
- """Calcule la durée avec gestion améliorée des cas limites."""
83
  start_date = self._parse_date(start_date_str)
84
  end_date = self._parse_date(end_date_str)
85
 
86
  if start_date and end_date:
87
  if end_date < start_date:
88
  return 0.0
89
- duration = (end_date - start_date).days / 365.25
90
- return max(0.0, duration)
91
- elif start_date: # Si seule la date de début est connue
92
- current = datetime.now()
93
- duration = (current - start_date).days / 365.25
94
- return max(0.0, min(duration, 10.0)) # Cap à 10 ans pour éviter les aberrations
95
  return 0.0
96
 
97
- def _detect_expertise_level(self, text: str, skill: str) -> str:
98
- """
99
- Détecte le niveau d'expertise d'une compétence basé sur le contexte.
100
- """
101
- text_lower = text.lower()
102
- skill_lower = skill.lower()
103
-
104
- # Chercher des patterns spécifiques autour de la compétence
105
- skill_context = ""
106
- skill_positions = [m.start() for m in re.finditer(re.escape(skill_lower), text_lower)]
107
-
108
- for pos in skill_positions:
109
- # Extraire 100 caractères avant et après la mention de la compétence
110
- start = max(0, pos - 100)
111
- end = min(len(text_lower), pos + len(skill_lower) + 100)
112
- skill_context += " " + text_lower[start:end]
113
-
114
- # Analyser le contexte pour déterminer le niveau
115
- for level in ["expert", "avancé", "intermédiaire", "débutant"]:
116
- for keyword in self.EXPERTISE_KEYWORDS[level]:
117
- if keyword in skill_context:
118
- return level
119
-
120
- # Si aucun indicateur spécifique, analyser les verbes d'action
121
- advanced_verbs = ["concevoir", "architecturer", "optimiser", "diriger", "superviser"]
122
- intermediate_verbs = ["développer", "implémenter", "créer", "réaliser"]
123
-
124
- if any(verb in skill_context for verb in advanced_verbs):
125
- return "avancé"
126
- elif any(verb in skill_context for verb in intermediate_verbs):
127
- return "intermédiaire"
128
-
129
- return "intermédiaire" # Valeur par défaut
130
-
131
- def _calculate_progression_score(self, skill_timeline: List[Tuple[datetime, str]]) -> float:
132
- """
133
- Calcule un score de progression basé sur l'évolution de la compétence dans le temps.
134
- """
135
- if len(skill_timeline) < 2:
136
- return 0.0
137
-
138
- # Trier par date
139
- skill_timeline.sort(key=lambda x: x[0])
140
-
141
- # Analyser la progression des niveaux
142
- levels = ["débutant", "intermédiaire", "avancé", "expert"]
143
- level_values = {level: i for i, level in enumerate(levels)}
144
-
145
- progression = 0.0
146
- for i in range(1, len(skill_timeline)):
147
- prev_level = level_values.get(skill_timeline[i-1][1], 1)
148
- curr_level = level_values.get(skill_timeline[i][1], 1)
149
- if curr_level > prev_level:
150
- progression += 0.3 # Bonus pour progression positive
151
-
152
- # Bonus pour utilisation récente (dans les 2 dernières années)
153
- recent_cutoff = datetime.now().replace(year=datetime.now().year - 2)
154
- if skill_timeline[-1][0] >= recent_cutoff:
155
- progression += 0.2
156
-
157
- return min(1.0, progression)
158
-
159
- def _analyze_skill_in_projects(self, skill: str, projects_data: Dict) -> Tuple[int, float, List[str]]:
160
- """
161
- Analyse spécifique des compétences dans les projets.
162
- Retourne: (fréquence, score_expertise_moyen, contextes)
163
- """
164
- frequency = 0
165
- expertise_scores = []
166
- contexts = []
167
-
168
- for project_type in ["professional", "personal"]:
169
- for project in projects_data.get(project_type, []):
170
- project_text = json.dumps(project, ensure_ascii=False).lower()
171
- if skill.lower() in project_text:
172
- contexts.append(f"projets_{project_type}")
173
- frequency += project_text.count(skill.lower())
174
-
175
- # Analyser le rôle pour déterminer l'expertise
176
- role = project.get("role", "").lower()
177
- if any(expert_word in role for expert_word in ["lead", "chef", "responsable", "senior"]):
178
- expertise_scores.append(self.EXPERTISE_SCORES["expert"])
179
- else:
180
- level = self._detect_expertise_level(project_text, skill)
181
- expertise_scores.append(self.EXPERTISE_SCORES[level])
182
-
183
- avg_expertise = sum(expertise_scores) / len(expertise_scores) if expertise_scores else 0.5
184
- return frequency, avg_expertise, contexts
185
-
186
  def calculate_scores(self) -> dict:
187
  """
188
- Calcule les scores pondérés avec analyse multi-dimensionnelle.
 
189
  """
190
- skills_list = self.cv_data.get("compétences", {})
191
- all_skills = []
192
 
193
- # Combiner hard et soft skills
194
- if isinstance(skills_list, dict):
195
- all_skills.extend(skills_list.get("hard_skills", []))
196
- all_skills.extend(skills_list.get("soft_skills", []))
197
- elif isinstance(skills_list, list):
198
- all_skills = [item.get("nom") for item in skills_list if item.get("nom")]
199
 
200
- if not all_skills:
201
  logger.warning("Aucune compétence à analyser dans le CV.")
202
  return {"analyse_competences": []}
203
 
204
- # Structure pour chaque compétence
205
  skill_metrics = {
206
  skill.lower(): {
207
  "original_name": skill,
208
  "contexts": set(),
209
  "frequency": 0,
210
- "max_duration": 0.0,
211
- "expertise_scores": [],
212
- "timeline": [], # Pour analyser la progression
213
- "project_involvement": {"count": 0, "leadership": False}
214
  }
215
- for skill in all_skills if skill
216
  }
217
 
218
- # 1. Analyse des expériences professionnelles
219
- for exp in self.cv_data.get("expériences", []):
220
- if not isinstance(exp, dict):
221
- continue
222
-
223
  exp_text = json.dumps(exp, ensure_ascii=False).lower()
224
  duration = self._calculate_duration_in_years(
225
- exp.get("start_date", ""), exp.get("end_date", "")
 
226
  )
227
 
228
- # Déterminer la date pour la timeline
229
- exp_date = self._parse_date(exp.get("start_date", ""))
230
-
231
  for skill in skill_metrics:
232
  if skill in exp_text:
233
  skill_metrics[skill]["contexts"].add("experiences_professionnelles")
234
  skill_metrics[skill]["frequency"] += exp_text.count(skill)
235
-
236
  if duration > skill_metrics[skill]["max_duration"]:
237
  skill_metrics[skill]["max_duration"] = duration
238
-
239
- # Analyser le niveau d'expertise
240
- expertise_level = self._detect_expertise_level(exp_text, skill)
241
- skill_metrics[skill]["expertise_scores"].append(
242
- self.EXPERTISE_SCORES[expertise_level]
243
- )
244
-
245
- # Ajouter à la timeline
246
- if exp_date:
247
- skill_metrics[skill]["timeline"].append((exp_date, expertise_level))
248
 
249
- # 2. Analyse des projets (avec analyse approfondie)
250
  projects_data = self.cv_data.get("projets", {})
251
- for skill in skill_metrics:
252
- proj_freq, proj_expertise, proj_contexts = self._analyze_skill_in_projects(
253
- skill, projects_data
254
- )
255
- skill_metrics[skill]["frequency"] += proj_freq
256
- skill_metrics[skill]["contexts"].update(proj_contexts)
257
- if proj_expertise > 0:
258
- skill_metrics[skill]["expertise_scores"].append(proj_expertise)
259
-
260
- # 3. Analyse des formations
261
  for formation in self.cv_data.get("formations", []):
262
- if not isinstance(formation, dict):
263
- continue
264
-
265
  formation_text = json.dumps(formation, ensure_ascii=False).lower()
266
- formation_date = self._parse_date(formation.get("start_date", ""))
267
-
268
  for skill in skill_metrics:
269
  if skill in formation_text:
270
  skill_metrics[skill]["contexts"].add("formations")
271
  skill_metrics[skill]["frequency"] += formation_text.count(skill)
272
- skill_metrics[skill]["expertise_scores"].append(
273
- self.EXPERTISE_SCORES["débutant"]
274
- )
275
-
276
- if formation_date:
277
- skill_metrics[skill]["timeline"].append((formation_date, "débutant"))
278
-
279
- # 4. Calcul final des scores
280
  final_scores = []
281
  for skill, metrics in skill_metrics.items():
282
- if metrics["frequency"] == 0: # Skip skills not found
283
  continue
284
-
285
- # Score de contexte (avec bonus multi-contexte)
286
- context_scores = [self.CONTEXT_VALUES.get(c, 0.1) for c in metrics["contexts"]]
287
- context_score = max(context_scores) if context_scores else 0.1
288
- if len(metrics["contexts"]) > 2:
289
- context_score = min(1.0, context_score * 1.2) # Bonus multi-contexte
290
-
291
- # Score d'expertise (moyenne pondérée)
292
- avg_expertise = (
293
- sum(metrics["expertise_scores"]) / len(metrics["expertise_scores"])
294
- if metrics["expertise_scores"] else 0.5
295
- )
296
-
297
- # Score de progression
298
- progression_score = self._calculate_progression_score(metrics["timeline"])
299
 
300
- # Normalisation
301
- normalized_frequency = self._normalize_score(metrics["frequency"], 5.0)
302
- normalized_depth = self._normalize_score(metrics["max_duration"], 3.0)
303
 
304
- # Formule finale améliorée
305
- final_score = (
306
- self.ALPHA * context_score +
307
- self.BETA * normalized_frequency +
308
- self.GAMMA * normalized_depth +
309
- self.DELTA * avg_expertise +
310
- self.EPSILON * progression_score
311
- )
312
 
313
  final_scores.append({
314
  "skill": metrics["original_name"],
315
- "score": round(final_score, 3),
316
- "niveau_estime": self._estimate_skill_level(final_score),
317
  "details": {
318
- "context_score": round(context_score, 3),
319
  "contexts_found": list(metrics["contexts"]),
320
  "frequency": metrics["frequency"],
321
- "max_duration_years": round(metrics["max_duration"], 1),
322
- "expertise_level": round(avg_expertise, 3),
323
- "progression_score": round(progression_score, 3),
324
- "timeline_points": len(metrics["timeline"])
325
  }
326
  })
327
 
328
- # Trier par score décroissant
329
  final_scores.sort(key=lambda x: x["score"], reverse=True)
 
330
 
331
- logger.info(f"Scoring avancé terminé pour {len(final_scores)} compétences.")
332
  return {"analyse_competences": final_scores}
333
-
334
- def _estimate_skill_level(self, score: float) -> str:
335
- """
336
- Convertit le score numérique en niveau de compétence lisible.
337
- """
338
- if score >= 0.8:
339
- return "Expert"
340
- elif score >= 0.6:
341
- return "Avancé"
342
- elif score >= 0.4:
343
- return "Intermédiaire"
344
- elif score >= 0.2:
345
- return "Débutant"
346
- else:
347
- return "Notions"
348
-
349
- def get_top_skills(self, n: int = 10, skill_type: str = None) -> List[Dict]:
350
- """
351
- Retourne les N meilleures compétences, optionnellement filtrées par type.
352
- """
353
- results = self.calculate_scores()
354
- skills = results.get("analyse_competences", [])
355
-
356
- if skill_type:
357
- # Filtrer par type si spécifié (nécessiterait une classification préalable)
358
- pass
359
-
360
- return skills[:n]
 
2
  import logging
3
  from datetime import datetime
4
  from collections import defaultdict
 
 
5
 
6
  logger = logging.getLogger(__name__)
7
 
8
+ class ContextualScoringEngine:
9
  """
10
+ Moteur de scoring qui maintient la compatibilité avec l'ancienne interface
11
+ tout en offrant les nouvelles fonctionnalités.
 
12
  """
 
 
 
 
 
 
 
13
 
14
+ ALPHA = 0.5
15
+ BETA = 0.3
16
+ GAMMA = 0.2
17
+
18
  CONTEXT_VALUES = {
19
  "formations": 0.3,
20
+ "projets": 0.6,
 
21
  "experiences_professionnelles": 0.8,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  }
23
 
24
  def __init__(self, parsed_cv_data: dict):
 
26
  if not self.cv_data:
27
  raise ValueError("Données du candidat non trouvées dans le CV parsé.")
28
 
29
+ def _normalize_score(self, value: float) -> float:
30
+ """Normalise une valeur sur une échelle de 0 à 1."""
31
+ return 1 - (1 / (1 + float(value)))
 
 
 
32
 
33
  def _parse_date(self, date_str: str) -> datetime | None:
34
  """Parse une date de manière robuste."""
35
  if not date_str or not isinstance(date_str, str):
36
  return None
37
 
38
+ date_str_lower = date_str.lower()
39
+ if date_str_lower in ["aujourd'hui", "maintenant", "en cours", "current"]:
 
 
40
  return datetime.now()
41
 
42
+ for fmt in ("%m/%Y", "%Y"):
 
 
43
  try:
44
+ return datetime.strptime(date_str, fmt)
45
  except ValueError:
46
  continue
47
  return None
48
 
49
  def _calculate_duration_in_years(self, start_date_str: str, end_date_str: str) -> float:
50
+ """Calcule la durée d'une expérience en années."""
51
  start_date = self._parse_date(start_date_str)
52
  end_date = self._parse_date(end_date_str)
53
 
54
  if start_date and end_date:
55
  if end_date < start_date:
56
  return 0.0
57
+ return (end_date - start_date).days / 365.25
 
 
 
 
 
58
  return 0.0
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  def calculate_scores(self) -> dict:
61
  """
62
+ Calcule les scores pour toutes les compétences.
63
+ Maintient la compatibilité avec l'ancienne interface.
64
  """
65
+ skills_data = self.cv_data.get("compétences", {})
66
+ skills_list = []
67
 
68
+ if isinstance(skills_data, dict):
69
+ skills_list.extend(skills_data.get("hard_skills", []))
70
+ skills_list.extend(skills_data.get("soft_skills", []))
71
+ elif isinstance(skills_data, list):
72
+ skills_list = [item.get("nom") for item in skills_data if item.get("nom")]
 
73
 
74
+ if not skills_list:
75
  logger.warning("Aucune compétence à analyser dans le CV.")
76
  return {"analyse_competences": []}
77
 
 
78
  skill_metrics = {
79
  skill.lower(): {
80
  "original_name": skill,
81
  "contexts": set(),
82
  "frequency": 0,
83
+ "max_duration": 0.0
 
 
 
84
  }
85
+ for skill in skills_list if skill
86
  }
87
 
88
+ experiences_key = "expériences" if "expériences" in self.cv_data else "experiences_professionnelles"
89
+ for exp in self.cv_data.get(experiences_key, []):
 
 
 
90
  exp_text = json.dumps(exp, ensure_ascii=False).lower()
91
  duration = self._calculate_duration_in_years(
92
+ exp.get("date_debut", exp.get("start_date", "")),
93
+ exp.get("date_fin", exp.get("end_date", ""))
94
  )
95
 
 
 
 
96
  for skill in skill_metrics:
97
  if skill in exp_text:
98
  skill_metrics[skill]["contexts"].add("experiences_professionnelles")
99
  skill_metrics[skill]["frequency"] += exp_text.count(skill)
 
100
  if duration > skill_metrics[skill]["max_duration"]:
101
  skill_metrics[skill]["max_duration"] = duration
 
 
 
 
 
 
 
 
 
 
102
 
 
103
  projects_data = self.cv_data.get("projets", {})
104
+ if isinstance(projects_data, dict):
105
+ for project_type in ["professional", "personal"]:
106
+ for project in projects_data.get(project_type, []):
107
+ project_text = json.dumps(project, ensure_ascii=False).lower()
108
+ for skill in skill_metrics:
109
+ if skill in project_text:
110
+ skill_metrics[skill]["contexts"].add("projets")
111
+ skill_metrics[skill]["frequency"] += project_text.count(skill)
 
 
112
  for formation in self.cv_data.get("formations", []):
 
 
 
113
  formation_text = json.dumps(formation, ensure_ascii=False).lower()
 
 
114
  for skill in skill_metrics:
115
  if skill in formation_text:
116
  skill_metrics[skill]["contexts"].add("formations")
117
  skill_metrics[skill]["frequency"] += formation_text.count(skill)
 
 
 
 
 
 
 
 
118
  final_scores = []
119
  for skill, metrics in skill_metrics.items():
120
+ if metrics["frequency"] == 0:
121
  continue
122
+
123
+ context_score = max((self.CONTEXT_VALUES.get(c, 0) for c in metrics["contexts"]), default=0.1)
124
+ if len(metrics["contexts"]) > 1:
125
+ context_score = 1.0
 
 
 
 
 
 
 
 
 
 
 
126
 
127
+ normalized_frequency = self._normalize_score(metrics["frequency"])
128
+ normalized_depth = self._normalize_score(metrics["max_duration"])
 
129
 
130
+ final_score = (self.ALPHA * context_score) + \
131
+ (self.BETA * normalized_frequency) + \
132
+ (self.GAMMA * normalized_depth)
 
 
 
 
 
133
 
134
  final_scores.append({
135
  "skill": metrics["original_name"],
136
+ "score": round(final_score, 2),
 
137
  "details": {
138
+ "context_score": round(context_score, 2),
139
  "contexts_found": list(metrics["contexts"]),
140
  "frequency": metrics["frequency"],
141
+ "max_duration_years": round(metrics["max_duration"], 1)
 
 
 
142
  }
143
  })
144
 
 
145
  final_scores.sort(key=lambda x: x["score"], reverse=True)
146
+ logger.info(f"Scoring terminé pour {len(final_scores)} compétences.")
147
 
 
148
  return {"analyse_competences": final_scores}
149
+ OptimizedContextualScoringEngine = ContextualScoringEngine