QuentinL52 commited on
Commit
7b0113e
·
verified ·
1 Parent(s): a5eb957

Update src/scoring_engine.py

Browse files
Files changed (1) hide show
  1. src/scoring_engine.py +342 -84
src/scoring_engine.py CHANGED
@@ -1,102 +1,360 @@
1
  import json
 
2
  from datetime import datetime
 
 
 
3
 
4
- # Pondérations basées sur la fiche projet
5
- CONTEXT_WEIGHTS = {
6
- "formations": 0.3,
7
- "projets": 0.6,
8
- "expériences": 0.8,
9
- "multiple": 1.0
10
- }
11
-
12
- # Facteurs pour la formule de scoring
13
- ALPHA = 0.5 # Poids du contexte
14
- BETA = 0.3 # Poids de la fréquence
15
- GAMMA = 0.2 # Poids de la profondeur (durée)
16
-
17
- class ContextualScoringEngine:
18
- def __init__(self, cv_data: dict):
19
- self.cv_data = cv_data.get("candidat", {})
20
- self.full_text = self._get_full_text_from_cv()
21
-
22
- def _get_full_text_from_cv(self) -> str:
23
- """Concatène tout le contenu textuel du CV pour le comptage de fréquence."""
24
- return json.dumps(self.cv_data, ensure_ascii=False).lower()
25
-
26
- def _parse_date(self, date_str: str) -> datetime:
27
- """Parse une date, en gérant les cas spéciaux comme 'Aujourd'hui'."""
28
- if not date_str or date_str.lower() == "non spécifié":
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  return None
30
- if date_str.lower() == "aujourd'hui":
 
 
 
 
31
  return datetime.now()
32
- try:
33
- return datetime.strptime(date_str, "%Y")
34
- except ValueError:
35
- return None
 
 
 
 
 
36
 
37
  def _calculate_duration_in_years(self, start_date_str: str, end_date_str: str) -> float:
38
- """Calcule la durée d'une expérience en années."""
39
  start_date = self._parse_date(start_date_str)
40
  end_date = self._parse_date(end_date_str)
 
41
  if start_date and end_date:
42
- return abs((end_date - start_date).days / 365.25)
43
- return 0.5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
 
45
  def calculate_scores(self) -> dict:
46
- """Calcule les scores pondérés pour toutes les hard skills."""
47
- skills = self.cv_data.get("compétences", {}).get("hard_skills", [])
48
- if not skills:
49
- return {}
50
-
51
- scored_skills = []
52
- for skill in skills:
53
- skill_lower = skill.lower()
54
- contexts = []
55
- if skill_lower in json.dumps(self.cv_data.get("formations", []), ensure_ascii=False).lower():
56
- contexts.append(CONTEXT_WEIGHTS["formations"])
57
- if skill_lower in json.dumps(self.cv_data.get("projets", []), ensure_ascii=False).lower():
58
- contexts.append(CONTEXT_WEIGHTS["projets"])
59
- if skill_lower in json.dumps(self.cv_data.get("expériences", []), ensure_ascii=False).lower():
60
- contexts.append(CONTEXT_WEIGHTS["expériences"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
 
62
- if len(contexts) > 1:
63
- context_score = CONTEXT_WEIGHTS["multiple"]
64
- elif contexts:
65
- context_score = contexts[0]
66
- else:
67
- context_score = 0.1
68
-
69
- # 2. Fréquence de mention
70
- frequency_score = self.full_text.count(skill_lower)
71
-
72
- # 3. Profondeur d'utilisation (durée max en années)
73
- max_duration = 0
74
- for exp in self.cv_data.get("expériences", []):
75
- if skill_lower in json.dumps(exp, ensure_ascii=False).lower():
76
- duration = self._calculate_duration_in_years(exp.get("start_date"), exp.get("end_date"))
77
- if duration > max_duration:
78
- max_duration = duration
79
- depth_score = max_duration
80
-
81
- # Normalisation simple (peut être affinée)
82
- normalized_frequency = 1 - (1 / (1 + frequency_score))
83
- normalized_depth = 1 - (1 / (1 + depth_score))
84
-
85
- # Calcul du score final
86
- final_score = (ALPHA * context_score) + \
87
- (BETA * normalized_frequency) + \
88
- (GAMMA * normalized_depth)
 
 
 
 
 
 
 
 
 
89
 
90
- scored_skills.append({
91
- "skill": skill,
92
- "score": round(final_score, 2),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  "details": {
94
- "context_score": context_score,
95
- "frequency": frequency_score,
96
- "max_duration_years": round(depth_score, 1)
 
 
 
 
97
  }
98
  })
99
-
100
  # Trier par score décroissant
101
- scored_skills.sort(key=lambda x: x["score"], reverse=True)
102
- return {"analyse_competences": scored_skills}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  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):
50
+ self.cv_data = parsed_cv_data.get("candidat", {})
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]