rinogeek commited on
Commit
eff837a
·
1 Parent(s): 7374681

Add application file

Browse files
__pycache__/main.cpython-312.pyc ADDED
Binary file (10.3 kB). View file
 
api/__pycache__/main.cpython-312.pyc ADDED
Binary file (30.9 kB). View file
 
api/index.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from main import app
2
+ from mangum import Mangum # adaptateur ASGI -> AWS Lambda-like
3
+
4
+ handler = Mangum(app)
api/main.py ADDED
@@ -0,0 +1,746 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ from fastapi import FastAPI, HTTPException
3
+ from fastapi.middleware.cors import CORSMiddleware
4
+ from pydantic import BaseModel
5
+ import pandas as pd
6
+ import faiss
7
+ from sentence_transformers import SentenceTransformer
8
+ from contextlib import asynccontextmanager
9
+ import logging
10
+ import numpy as np
11
+ import re
12
+ from typing import List, Dict, Optional
13
+ from pathlib import Path
14
+
15
+ # Configuration du logging
16
+ logging.basicConfig(level=logging.INFO)
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # --- Modèles et Données ---
20
+ # Utiliser un dictionnaire pour stocker les modèles et données chargés
21
+ ml_models = {}
22
+
23
+ # --- Modèles Pydantic (définis avant les fonctions qui les utilisent) ---
24
+ class MatchExplanation(BaseModel):
25
+ strengths: List[str] # Points forts du candidat
26
+ weaknesses: List[str] # Points à améliorer / compétences manquantes
27
+ skills_match_score: float # Score de correspondance des compétences (0-1)
28
+ experience_match_score: float # Score de correspondance de l'expérience (0-1)
29
+
30
+ @asynccontextmanager
31
+ async def lifespan(app: FastAPI):
32
+ # Code exécuté au démarrage de l'application
33
+ logger.info("Chargement des modèles et des données...")
34
+ try:
35
+ # Résoudre les chemins relatifs par rapport à ce fichier
36
+ base_dir = Path(__file__).resolve().parent
37
+ profiles_path = base_dir / "profiles.csv"
38
+ # Fallback : si le fichier n'existe pas au même niveau, essayer ../profiles.csv (pour endpoint add_profile)
39
+ if not profiles_path.exists():
40
+ alt = base_dir.parent / "profiles.csv"
41
+ if alt.exists():
42
+ profiles_path = alt
43
+
44
+ df_profiles = pd.read_csv(profiles_path)
45
+ ml_models["profiles"] = df_profiles
46
+
47
+ # Charger la cartographie des métiers du numérique
48
+ try:
49
+ carto_path = base_dir.parent.parent / "cartographie-metiers-numeriques.csv"
50
+ # si non trouvé, essayer le repo root
51
+ if not carto_path.exists():
52
+ carto_path = Path(__file__).resolve().parents[3] / "cartographie-metiers-numeriques.csv"
53
+ df_metiers = pd.read_csv(carto_path, sep=';')
54
+ ml_models["metiers_digital"] = df_metiers
55
+ logger.info(f"✅ Cartographie des métiers chargée : {len(df_metiers)} métiers.")
56
+ except FileNotFoundError:
57
+ logger.warning("⚠️ Fichier cartographie-metiers-numeriques.csv non trouvé. Fonctionnalité métiers désactivée.")
58
+ ml_models["metiers_digital"] = pd.DataFrame()
59
+
60
+ model = SentenceTransformer("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
61
+ ml_models["model"] = model
62
+
63
+ profile_embeddings = model.encode(df_profiles["full_text"].tolist(), convert_to_numpy=True)
64
+ d = profile_embeddings.shape[1]
65
+
66
+ faiss.normalize_L2(profile_embeddings)
67
+
68
+ index = faiss.IndexFlatIP(d)
69
+ index.add(profile_embeddings)
70
+ ml_models["faiss_index"] = index
71
+
72
+ # Créer des embeddings séparés pour les compétences et l'expérience
73
+ skills_embeddings = model.encode(df_profiles["hard_skills"].tolist(), convert_to_numpy=True)
74
+ faiss.normalize_L2(skills_embeddings)
75
+ ml_models["skills_embeddings"] = skills_embeddings
76
+
77
+ logger.info(f"✅ Index FAISS construit avec {index.ntotal} profils.")
78
+ logger.info("Application démarrée avec succès.")
79
+ except Exception as e:
80
+ logger.error(f"Erreur lors du chargement des modèles : {e}")
81
+ # Vous pourriez vouloir arrêter l'application si les modèles ne se chargent pas
82
+ # raise HTTPException(status_code=500, detail="Impossible de charger les modèles de ML.")
83
+
84
+ yield
85
+
86
+ # Code exécuté à l'arrêt de l'application
87
+ logger.info("Nettoyage et arrêt de l'application...")
88
+ ml_models.clear()
89
+ logger.info("Application arrêtée.")
90
+
91
+ def normalize_skills(skills_text: str) -> List[str]:
92
+ """
93
+ Normalise les compétences en appliquant une taxonomie simple.
94
+ """
95
+ # Dictionnaire de normalisation des compétences
96
+ skills_mapping = {
97
+ 'js': 'javascript',
98
+ 'ts': 'typescript',
99
+ 'py': 'python',
100
+ 'reactjs': 'react',
101
+ 'vuejs': 'vue.js',
102
+ 'nodejs': 'node.js',
103
+ 'ml': 'machine learning',
104
+ 'ai': 'intelligence artificielle',
105
+ 'ia': 'intelligence artificielle',
106
+ 'dl': 'deep learning',
107
+ 'nlp': 'natural language processing',
108
+ 'cv': 'computer vision',
109
+ 'db': 'database',
110
+ 'sql': 'sql',
111
+ 'nosql': 'nosql',
112
+ 'aws': 'amazon web services',
113
+ 'gcp': 'google cloud platform',
114
+ 'k8s': 'kubernetes',
115
+ }
116
+
117
+ # Extraire les compétences (entre crochets ou séparées par virgules)
118
+ skills = []
119
+ if '[' in skills_text and ']' in skills_text:
120
+ # Format liste Python
121
+ skills_text = skills_text.strip('[]').replace("'", "").replace('"', '')
122
+
123
+ raw_skills = [s.strip().lower() for s in skills_text.split(',')]
124
+
125
+ # Normaliser chaque compétence
126
+ for skill in raw_skills:
127
+ normalized = skills_mapping.get(skill, skill)
128
+ if normalized and normalized not in skills:
129
+ skills.append(normalized)
130
+
131
+ return skills
132
+
133
+ def extract_skills_from_text(text: str) -> List[str]:
134
+ """
135
+ Extrait les compétences techniques d'un texte libre.
136
+ """
137
+ # Liste de compétences techniques courantes
138
+ common_skills = [
139
+ 'python', 'java', 'javascript', 'typescript', 'c++', 'c#', 'php', 'ruby', 'go', 'rust',
140
+ 'react', 'angular', 'vue.js', 'node.js', 'django', 'flask', 'spring', 'express',
141
+ 'sql', 'nosql', 'mongodb', 'postgresql', 'mysql', 'redis', 'elasticsearch',
142
+ 'docker', 'kubernetes', 'aws', 'azure', 'gcp', 'terraform', 'ansible',
143
+ 'machine learning', 'deep learning', 'tensorflow', 'pytorch', 'scikit-learn',
144
+ 'git', 'ci/cd', 'jenkins', 'gitlab', 'github',
145
+ 'agile', 'scrum', 'devops', 'microservices', 'api', 'rest', 'graphql'
146
+ ]
147
+
148
+ text_lower = text.lower()
149
+ found_skills = []
150
+
151
+ for skill in common_skills:
152
+ if skill in text_lower:
153
+ found_skills.append(skill)
154
+
155
+ return found_skills
156
+
157
+ def calculate_weighted_score(skills_score: float, exp_score: float,
158
+ skills_weight: float = 0.5, exp_weight: float = 0.5) -> float:
159
+ """
160
+ Calcule un score pondéré basé sur les compétences et l'expérience.
161
+ Par défaut : 50% compétences + 50% expérience
162
+ """
163
+ return (skills_score * skills_weight) + (exp_score * exp_weight)
164
+
165
+ def generate_explanation(offer_text: str, profile_row: pd.Series,
166
+ skills_score: float, exp_score: float) -> MatchExplanation:
167
+ """
168
+ Génère une explication détaillée du matching.
169
+ """
170
+ strengths = []
171
+ weaknesses = []
172
+
173
+ # Extraire les compétences demandées et celles du profil
174
+ required_skills = extract_skills_from_text(offer_text)
175
+ profile_skills = normalize_skills(profile_row["hard_skills"])
176
+
177
+ # NOTE: Logique de détection des compétences mise en pause.
178
+ # Si vous souhaitez réactiver, décommentez la section ci-dessous.
179
+ # Analyser les compétences
180
+ # matched_skills = [skill for skill in required_skills if any(ps in skill or skill in ps for ps in profile_skills)]
181
+ # missing_skills = [skill for skill in required_skills if not any(ps in skill or skill in ps for ps in profile_skills)]
182
+
183
+ # if matched_skills:
184
+ # strengths.append(f"Maîtrise de : {', '.join(matched_skills[:5])}")
185
+
186
+ # if missing_skills:
187
+ # weaknesses.append(f"Compétences à développer : {', '.join(missing_skills[:3])}")
188
+
189
+ # Analyser l'expérience
190
+ exp_years = int(profile_row["exp_years"])
191
+ if exp_years >= 5:
192
+ strengths.append(f"Expérience solide ({exp_years} ans)")
193
+ elif exp_years >= 3:
194
+ strengths.append(f"Bonne expérience ({exp_years} ans)")
195
+ else:
196
+ strengths.append(f"Profil junior ({exp_years} ans d'expérience)")
197
+
198
+ # Analyser la localisation
199
+ if "localisation" in offer_text.lower():
200
+ strengths.append(f"Localisation : {profile_row['localisation']}")
201
+
202
+ # Analyser la mobilité
203
+ if profile_row.get("mobilite") == "Mobile":
204
+ strengths.append("Ouvert à la mobilité")
205
+
206
+ # Analyser la disponibilité
207
+ if profile_row.get("disponibilite") == "Immédiate":
208
+ strengths.append("Disponibilité immédiate")
209
+
210
+ # Si peu de points forts, ajouter des éléments génériques
211
+ if len(strengths) < 2:
212
+ strengths.append("Profil correspondant aux critères généraux")
213
+
214
+ if len(weaknesses) == 0:
215
+ weaknesses.append("Profil très bien adapté à l'offre")
216
+
217
+ return MatchExplanation(
218
+ strengths=strengths[:5], # Limiter à 5 points forts
219
+ weaknesses=weaknesses[:3], # Limiter à 3 points faibles
220
+ skills_match_score=round(skills_score, 2),
221
+ experience_match_score=round(exp_score, 2)
222
+ )
223
+
224
+ def update_faiss_index(new_profile_text: str, new_skills_text: str):
225
+ """
226
+ Met à jour l'index FAISS avec un nouveau profil.
227
+ """
228
+ try:
229
+ if "model" not in ml_models or "faiss_index" not in ml_models:
230
+ logger.error("Modèle ou index FAISS non chargé")
231
+ return False
232
+
233
+ model = ml_models["model"]
234
+ index = ml_models["faiss_index"]
235
+
236
+ # Encoder le nouveau profil
237
+ new_embedding = model.encode([new_profile_text], convert_to_numpy=True)
238
+ faiss.normalize_L2(new_embedding)
239
+
240
+ # Ajouter au modèle FAISS
241
+ index.add(new_embedding)
242
+
243
+ # Mettre à jour les embeddings de compétences
244
+ new_skills_embedding = model.encode([new_skills_text], convert_to_numpy=True)
245
+ faiss.normalize_L2(new_skills_embedding)
246
+
247
+ if "skills_embeddings" in ml_models:
248
+ ml_models["skills_embeddings"] = np.vstack([ml_models["skills_embeddings"], new_skills_embedding])
249
+
250
+ logger.info("Nouveau profil ajouté à l'index FAISS")
251
+ return True
252
+ except Exception as e:
253
+ logger.error(f"Erreur lors de la mise à jour de l'index FAISS : {e}")
254
+ return False
255
+
256
+ app = FastAPI(lifespan=lifespan)
257
+
258
+ # --- Configuration CORS ---
259
+ app.add_middleware(
260
+ CORSMiddleware,
261
+ allow_origins=["*"], # Autorise toutes les origines (à ajuster en production)
262
+ allow_credentials=True,
263
+ allow_methods=["*"], # Autorise toutes les méthodes (GET, POST, etc.)
264
+ allow_headers=["*"], # Autorise tous les en-têtes
265
+ )
266
+
267
+
268
+ # --- Modèles Pydantic (pour la validation des requêtes) ---
269
+ class MatchRequest(BaseModel):
270
+ offer_text: str
271
+ top_k: int = 7
272
+
273
+ class ProfileResult(BaseModel):
274
+ id: int
275
+ score: float
276
+ # On peut ajouter d'autres champs du profil si nécessaire
277
+ exp_years: int
278
+ hard_skills: str # Gardé comme string pour la simplicité
279
+ localisation: str
280
+ full_text: str
281
+ explanation: Optional[MatchExplanation] = None # Explications du matching
282
+
283
+ class MatchResponse(BaseModel):
284
+ results: list[ProfileResult]
285
+
286
+ # --- Fonctions Métier ---
287
+ def match_offer_sync(offer_text: str, top_k: int = 7, with_explanation: bool = True):
288
+ """
289
+ Fonction de matching synchrone avec pondération (50% skills + 50% expérience).
290
+ """
291
+ if "model" not in ml_models or "faiss_index" not in ml_models or "profiles" not in ml_models:
292
+ raise HTTPException(status_code=503, detail="Les modèles ne sont pas encore prêts. Veuillez réessayer dans quelques instants.")
293
+
294
+ model = ml_models["model"]
295
+ index = ml_models["faiss_index"]
296
+ df_profiles = ml_models["profiles"]
297
+ skills_embeddings = ml_models.get("skills_embeddings")
298
+
299
+ # Extraire les compétences et l'expérience de l'offre
300
+ required_skills = extract_skills_from_text(offer_text)
301
+
302
+ # Heuristiques simples pour détecter des exigences explicites dans l'offre
303
+ def detect_requirements(text: str):
304
+ txt = text.lower()
305
+ # rôle / poste (exemples courants)
306
+ # Tenter de détecter un intitulé de poste plus précis en utilisant la cartographie des métiers
307
+ role = None
308
+ if "metiers_digital" in ml_models and not ml_models["metiers_digital"].empty:
309
+ try:
310
+ jobs = ml_models["metiers_digital"]["Poste"].astype(str).str.lower().unique().tolist()
311
+ for j in jobs:
312
+ if j in txt:
313
+ role = j
314
+ break
315
+ except Exception:
316
+ role = None
317
+
318
+ # Si la cartographie n'a rien trouvé, fallback sur des mots-clés simples
319
+ if not role:
320
+ role_keywords = ['dev', 'développeur', 'developer', 'web', 'frontend', 'backend', 'full stack', 'fullstack', 'data', 'engineer']
321
+ for r in role_keywords:
322
+ if r in txt:
323
+ role = r
324
+ break
325
+
326
+ # localisation (heuristique : chercher "à <ville>" ou "@ <ville>")
327
+ loc = None
328
+ m = re.search(r"\bà\s+([A-Za-zÀ-ÖØ-öø-ÿ\-']{2,})", text, flags=re.IGNORECASE)
329
+ if m:
330
+ loc = m.group(1).strip().lower()
331
+
332
+ # diplôme demandé (master, licence, phd, ingénieur...)
333
+ degree_keywords = ['master', 'licence', 'phd', 'doctorat', 'diplôme', 'ingénieur', "d'ingénieur"]
334
+ degree = None
335
+ for d in degree_keywords:
336
+ if d in txt:
337
+ degree = d
338
+ break
339
+
340
+ return {
341
+ 'role': role,
342
+ 'location': loc,
343
+ 'degree': degree,
344
+ 'required_skills': required_skills
345
+ }
346
+
347
+ reqs = detect_requirements(offer_text)
348
+
349
+ def profile_matches_requirements(row: pd.Series, reqs: dict) -> bool:
350
+ """Retourne True si le profil satisfait (heuristiquement) les exigences détectées dans l'offre."""
351
+ txt = str(row.get('full_text', '')).lower()
352
+ # role: accepter une correspondance si le titre du profil ou le champ 'poste_recherche' contient la valeur
353
+ if reqs['role']:
354
+ profile_title = str(row.get('poste_recherche', '')).lower()
355
+ if reqs['role'] not in txt and reqs['role'] not in profile_title:
356
+ return False
357
+ # location
358
+ if reqs['location']:
359
+ loc_field = str(row.get('localisation', '')).lower()
360
+ if reqs['location'] not in loc_field and reqs['location'] not in txt:
361
+ return False
362
+ # degree
363
+ if reqs['degree']:
364
+ dipl = str(row.get('diplomes', '')).lower()
365
+ if reqs['degree'] not in dipl and reqs['degree'] not in txt:
366
+ return False
367
+ # skills: si l'offre demande des skills explicites, vérifier qu'au moins un est présent
368
+ if reqs.get('required_skills'):
369
+ skills_ok = False
370
+ for s in reqs['required_skills']:
371
+ if s in txt:
372
+ skills_ok = True
373
+ break
374
+ if reqs['required_skills'] and not skills_ok:
375
+ return False
376
+
377
+ return True
378
+
379
+
380
+ # Extraire l'expérience requise (recherche de patterns comme "3 ans", "5 années")
381
+ exp_pattern = re.search(r'(\d+)\s*(ans?|années?|years?)', offer_text.lower())
382
+ required_exp = int(exp_pattern.group(1)) if exp_pattern else None # None si pas précisé
383
+
384
+ # Encoder l'offre complète pour le matching global
385
+ offer_emb = model.encode([offer_text], convert_to_numpy=True)
386
+ faiss.normalize_L2(offer_emb)
387
+
388
+ # Encoder les compétences de l'offre
389
+ skills_text = ", ".join(required_skills) if required_skills else offer_text
390
+ offer_skills_emb = model.encode([skills_text], convert_to_numpy=True)
391
+ faiss.normalize_L2(offer_skills_emb)
392
+
393
+ # Recherche FAISS élargie pour avoir plus de candidats à scorer
394
+ search_k = min(top_k * 5, len(df_profiles)) # Chercher plus large pool (5x top_k)
395
+ distances, indices = index.search(offer_emb, search_k)
396
+
397
+ # Calculer des attributs de matching pour chaque profil
398
+ candidates = []
399
+ for i, idx in enumerate(indices[0]):
400
+ row = df_profiles.iloc[idx]
401
+
402
+ # Compter combien des compétences requises apparaissent dans le texte du profil
403
+ txt = str(row.get('full_text', '')).lower()
404
+ skills_match_count = 0
405
+ for s in required_skills:
406
+ if s and s in txt:
407
+ skills_match_count += 1
408
+
409
+ # role/title match: vérifier titre profil (`poste_recherche`) + texte complet
410
+ role_match = False
411
+ if reqs['role']:
412
+ profile_title = str(row.get('poste_recherche', '')).lower()
413
+ if reqs['role'] in txt or reqs['role'] in profile_title:
414
+ role_match = True
415
+
416
+ # location match
417
+ location_match = False
418
+ if reqs['location']:
419
+ loc_field = str(row.get('localisation', '')).lower()
420
+ if reqs['location'] in loc_field or reqs['location'] in txt:
421
+ location_match = True
422
+
423
+ # expérience
424
+ profile_exp = int(row.get('exp_years', 0))
425
+
426
+ # Score compétences (pour information / fallback)
427
+ if skills_embeddings is not None:
428
+ try:
429
+ profile_skills_emb = skills_embeddings[idx].reshape(1, -1)
430
+ skills_similarity = np.dot(offer_skills_emb, profile_skills_emb.T)[0][0]
431
+ skills_score = max(0, min(1, skills_similarity))
432
+ except Exception:
433
+ skills_score = 0.0
434
+ else:
435
+ skills_score = 0.0
436
+
437
+ # Calculer un score d'expérience (toujours, utilisé pour le score final)
438
+ if required_exp is not None:
439
+ exp_diff = abs(profile_exp - required_exp)
440
+ exp_score = max(0, 1 - (exp_diff / 10))
441
+ else:
442
+ # Normaliser l'expérience en score 0-1 en supposant une borne haute raisonnable (20 ans)
443
+ exp_score = min(1.0, profile_exp / 20)
444
+
445
+ # Générer l'explication (optionnel)
446
+ explanation = None
447
+ if with_explanation:
448
+ explanation = generate_explanation(offer_text, row, skills_score, exp_score)
449
+
450
+ # Calculer un score de pertinence combiné (compétences + expérience)
451
+ try:
452
+ final_score = calculate_weighted_score(skills_score, exp_score)
453
+ except Exception:
454
+ final_score = 0.0
455
+
456
+ candidates.append({
457
+ 'profile': ProfileResult(
458
+ id=int(row['id']),
459
+ score=round(float(final_score), 4),
460
+ exp_years=profile_exp,
461
+ hard_skills=row['hard_skills'],
462
+ localisation=row['localisation'],
463
+ full_text=row['full_text'],
464
+ explanation=explanation
465
+ ),
466
+ 'skills_match_count': skills_match_count,
467
+ 'role_match': role_match,
468
+ 'location_match': location_match,
469
+ 'profile_exp': profile_exp
470
+ })
471
+
472
+ # Maintenant appliquer l'ordre demandé :
473
+ # 1) Prioriser profils qui ont au moins une compétence requise (skills_match_count>0), triés par skills_match_count desc
474
+ # 2) Ensuite, parmi eux, prioriser role_match True
475
+ # 3) Ensuite, prioriser location_match True
476
+ # 4) Ensuite trier par expérience décroissante. Si required_exp est présent dans l'offre, on place d'abord les profils
477
+ # avec profile_exp >= required_exp (triés desc), puis compléter avec les autres (triés desc)
478
+
479
+ # Filtrer candidats ayant des correspondances de compétences
480
+ with_skills = [c for c in candidates if c['skills_match_count'] > 0]
481
+ without_skills = [c for c in candidates if c['skills_match_count'] == 0]
482
+
483
+ # Trier ceux avec compétences : on applique les priorités successives
484
+ def sort_key(c):
485
+ # role_match True -> come first (-1), location_match True -> come first (-1)
486
+ return (
487
+ -c['skills_match_count'],
488
+ -int(c['role_match']),
489
+ -int(c['location_match']),
490
+ -c['profile_exp']
491
+ )
492
+
493
+ with_skills.sort(key=sort_key)
494
+
495
+ # S'assurer du comportement demandé par le recruteur : si l'offre précise un seuil d'expérience,
496
+ # prioriser profils >= seuil (triés par expérience décroissante), puis compléter par les autres.
497
+ ordered = []
498
+ if required_exp is not None:
499
+ meets_exp = [c for c in with_skills if c['profile_exp'] >= required_exp]
500
+ not_meets_exp = [c for c in with_skills if c['profile_exp'] < required_exp]
501
+ # Trier par : skills_match_count, role_match, location_match, puis expérience décroissante
502
+ meets_exp.sort(key=lambda c: (
503
+ -c['skills_match_count'],
504
+ -int(c['role_match']),
505
+ -int(c['location_match']),
506
+ -c['profile_exp']
507
+ ))
508
+ not_meets_exp.sort(key=lambda c: (
509
+ -c['skills_match_count'],
510
+ -int(c['role_match']),
511
+ -int(c['location_match']),
512
+ -c['profile_exp']
513
+ ))
514
+ ordered.extend(meets_exp)
515
+ ordered.extend(not_meets_exp)
516
+ else:
517
+ # Pas de seuil : on veut principalement trier par expérience décroissante, mais en privilégiant
518
+ # d'abord ceux qui matchent les compétences / rôle / localisation.
519
+ with_skills.sort(key=lambda c: (
520
+ -int(c['role_match']),
521
+ -int(c['location_match']),
522
+ -c['skills_match_count'],
523
+ -c['profile_exp']
524
+ ))
525
+ ordered.extend(with_skills)
526
+
527
+ # Si on manque de profils pour top_k, on complète avec candidats sans skills (triés par role/location/exp)
528
+ if len(ordered) < top_k:
529
+ without_skills.sort(key=sort_key)
530
+ ordered.extend(without_skills)
531
+
532
+ # Enfin retourner les top_k profils (convertis en ProfileResult)
533
+ return [c['profile'] for c in ordered[:top_k]]
534
+
535
+ # --- Endpoints de l'API ---
536
+ @app.get("/")
537
+ def read_root():
538
+ return {"message": "Bienvenue sur l'API de Matching IA"}
539
+
540
+ @app.post("/match", response_model=MatchResponse)
541
+ async def match_endpoint(request: MatchRequest):
542
+ """
543
+ Endpoint pour trouver les meilleurs profils correspondant à une offre.
544
+ """
545
+ try:
546
+ results = match_offer_sync(request.offer_text, request.top_k)
547
+ return MatchResponse(results=results)
548
+ except HTTPException as e:
549
+ # Propage l'exception HTTP si les modèles ne sont pas prêts
550
+ raise e
551
+ except Exception as e:
552
+ logger.error(f"Erreur lors du matching pour l'offre '{request.offer_text}': {e}")
553
+ raise HTTPException(status_code=500, detail="Une erreur interne est survenue lors du matching.")
554
+
555
+
556
+ @app.post("/match_debug")
557
+ async def match_debug_endpoint(request: MatchRequest):
558
+ """
559
+ Endpoint debug: renvoie pour les top_k candidats les métadonnées de tri permettant
560
+ de comprendre pourquoi un profil a été ordonné de cette manière.
561
+ """
562
+ try:
563
+ # On récupère les mêmes candidats mais sans transformer en ProfileResult
564
+ if "model" not in ml_models or "faiss_index" not in ml_models or "profiles" not in ml_models:
565
+ raise HTTPException(status_code=503, detail="Les modèles ne sont pas encore prêts.")
566
+
567
+ # Copier une version simplifiée de la logique de match_offer_sync mais en retournant
568
+ # les métadonnées (skills_match_count, role_match, location_match, profile_exp)
569
+ model = ml_models["model"]
570
+ index = ml_models["faiss_index"]
571
+ df_profiles = ml_models["profiles"]
572
+
573
+ offer_text = request.offer_text
574
+ top_k = request.top_k
575
+
576
+ # Reutiliser la fonction de matching existante, mais récupérer les candidats bruts
577
+ # Pour éviter duplication lourde, appeler match_offer_sync(with_explanation=True) et
578
+ # reconstruire les métadonnées à partir des explanations et profils retournés.
579
+ results = match_offer_sync(offer_text, top_k=top_k, with_explanation=True)
580
+
581
+ debug_list = []
582
+ for pr in results:
583
+ debug_list.append({
584
+ 'profile_id': pr.id,
585
+ 'exp_years': pr.exp_years,
586
+ 'localisation': pr.localisation,
587
+ 'skills': pr.hard_skills,
588
+ 'strengths': pr.explanation.strengths if pr.explanation else [],
589
+ 'weaknesses': pr.explanation.weaknesses if pr.explanation else [],
590
+ 'skills_match_score': pr.explanation.skills_match_score if pr.explanation else None,
591
+ 'experience_match_score': pr.explanation.experience_match_score if pr.explanation else None
592
+ })
593
+
594
+ return {'debug': debug_list}
595
+ except HTTPException:
596
+ raise
597
+ except Exception as e:
598
+ logger.error(f"Erreur match_debug: {e}")
599
+ raise HTTPException(status_code=500, detail="Erreur interne lors du debug du matching.")
600
+
601
+ # --- Nouveaux Endpoints pour la Recherche --
602
+ @app.get("/jobs")
603
+ def get_jobs():
604
+ """
605
+ Endpoint pour récupérer la liste des intitulés de poste uniques.
606
+ """
607
+ try:
608
+ # Utiliser les données chargées en mémoire si disponibles
609
+ if "metiers_digital" in ml_models and not ml_models["metiers_digital"].empty:
610
+ df_jobs = ml_models["metiers_digital"]
611
+ return {"jobs": df_jobs["Poste"].unique().tolist()}
612
+
613
+ # Sinon, essayer de charger le fichier
614
+ df_jobs = pd.read_csv("../../cartographie-metiers-numeriques.csv", sep=';')
615
+ return {"jobs": df_jobs["Poste"].unique().tolist()}
616
+ except FileNotFoundError:
617
+ logger.error("Le fichier cartographie-metiers-numeriques.csv est introuvable.")
618
+ raise HTTPException(status_code=404, detail="Fichier des métiers non trouvé. Fonctionnalité désactivée.")
619
+ except Exception as e:
620
+ logger.error(f"Erreur lors de la lecture du fichier des métiers : {e}")
621
+ raise HTTPException(status_code=500, detail="Erreur interne du serveur.")
622
+
623
+ class SearchRequest(BaseModel):
624
+ description: str | None = None
625
+ poste: str | None = None
626
+ competences: str | None = None
627
+ experience: str | None = None
628
+ localisation: str | None = None
629
+ type_de_contrat: str | None = None
630
+ salaire: str | None = None
631
+
632
+ class NewProfile(BaseModel):
633
+ exp_years: int
634
+ diplomes: str
635
+ certifications: str
636
+ hard_skills: list[str]
637
+ soft_skills: list[str]
638
+ langues: list[str]
639
+ localisation: str
640
+ mobilite: str
641
+ disponibilite: str
642
+ experiences: str
643
+ poste_recherche: str | None = None
644
+
645
+ @app.post("/search", response_model=MatchResponse)
646
+ def search_profiles(request: SearchRequest, top_k: int = 7):
647
+ """
648
+ Endpoint pour rechercher des profils avec pondération et explications.
649
+ """
650
+ if "model" not in ml_models or "faiss_index" not in ml_models or "profiles" not in ml_models:
651
+ raise HTTPException(status_code=503, detail="Les modèles ne sont pas encore prêts.")
652
+
653
+ query_text = ""
654
+ if request.description:
655
+ query_text = request.description
656
+ else:
657
+ parts = []
658
+ if request.poste:
659
+ parts.append(f"Poste: {request.poste}")
660
+ if request.competences:
661
+ parts.append(f"Compétences: {request.competences}")
662
+ if request.experience:
663
+ parts.append(f"Expérience: {request.experience}")
664
+ if request.localisation:
665
+ parts.append(f"Localisation: {request.localisation}")
666
+ if request.type_de_contrat:
667
+ parts.append(f"Type de contrat: {request.type_de_contrat}")
668
+ if request.salaire:
669
+ parts.append(f"Salaire: {request.salaire}")
670
+
671
+ if not parts:
672
+ raise HTTPException(status_code=400, detail="Veuillez fournir une description ou au moins un critère de recherche.")
673
+
674
+ query_text = " - ".join(parts)
675
+
676
+ if not query_text:
677
+ raise HTTPException(status_code=400, detail="La requête de recherche est vide.")
678
+
679
+ # Utiliser la fonction de matching améliorée
680
+ results = match_offer_sync(query_text, top_k, with_explanation=True)
681
+ return MatchResponse(results=results)
682
+
683
+
684
+ @app.post("/add_profile")
685
+ async def add_profile(profile: NewProfile):
686
+ """
687
+ Endpoint pour ajouter un nouveau profil au système.
688
+ """
689
+ try:
690
+ # Lire le fichier CSV existant
691
+ df_profiles = pd.read_csv("../profiles.csv")
692
+
693
+ # Générer un nouvel ID
694
+ new_id = df_profiles["id"].max() + 1 if not df_profiles.empty else 1
695
+
696
+ # Créer le texte complet pour la recherche sémantique
697
+ full_text = (
698
+ f"Expériences: {profile.experiences}. "
699
+ f"Diplômes: {profile.diplomes}. "
700
+ f"Certifications: {profile.certifications}. "
701
+ f"Compétences techniques: {', '.join(profile.hard_skills)}. "
702
+ f"Compétences comportementales: {', '.join(profile.soft_skills)}. "
703
+ f"Langues: {', '.join(profile.langues)}. "
704
+ f"Localisation: {profile.localisation}. "
705
+ f"Mobilité: {profile.mobilite}. "
706
+ f"Disponibilité: {profile.disponibilite}."
707
+ )
708
+
709
+ # Créer une nouvelle ligne pour le DataFrame
710
+ new_row = {
711
+ 'id': new_id,
712
+ 'exp_years': profile.exp_years,
713
+ 'diplomes': profile.diplomes,
714
+ 'certifications': profile.certifications,
715
+ 'hard_skills': str(profile.hard_skills),
716
+ 'soft_skills': str(profile.soft_skills),
717
+ 'langues': str(profile.langues),
718
+ 'localisation': profile.localisation,
719
+ 'mobilite': profile.mobilite,
720
+ 'disponibilite': profile.disponibilite,
721
+ 'full_text': full_text
722
+ }
723
+
724
+ # Ajouter la nouvelle ligne au DataFrame
725
+ df_profiles = pd.concat([df_profiles, pd.DataFrame([new_row])], ignore_index=True)
726
+
727
+ # Sauvegarder le DataFrame mis à jour
728
+ df_profiles.to_csv("../profiles.csv", index=False)
729
+
730
+ # Mettre à jour l'index FAISS avec le nouveau profil
731
+ skills_text = ', '.join(profile.hard_skills)
732
+ if update_faiss_index(full_text, skills_text):
733
+ # Mettre à jour le DataFrame en mémoire
734
+ ml_models["profiles"] = df_profiles
735
+ logger.info(f"Nouveau profil ajouté avec succès (ID: {new_id})")
736
+ return {"status": "success", "message": f"Profil ajouté avec succès (ID: {new_id})", "profile_id": int(new_id)}
737
+ else:
738
+ logger.warning("Le profil a été ajouté au CSV mais l'index FAISS n'a pas pu être mis à jour")
739
+ return {"status": "warning", "message": "Profil ajouté, mais l'index de recherche n'a pas pu être mis à jour immédiatement"}
740
+
741
+ except Exception as e:
742
+ logger.error(f"Erreur lors de l'ajout du profil : {e}")
743
+ raise HTTPException(status_code=500, detail=f"Erreur lors de l'ajout du profil : {str(e)}")
744
+
745
+ # --- Pour exécuter l'application localement ---
746
+ # Commande: uvicorn main:app --reload --port 8000
api/profiles.csv ADDED
The diff for this file is too large to render. See raw diff
 
requirements.txt ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ annotated-types==0.7.0
2
+ anyio==4.11.0
3
+ certifi==2025.8.3
4
+ charset-normalizer==3.4.3
5
+ click==8.3.0
6
+ faiss-cpu==1.12.0
7
+ fastapi==0.118.0
8
+ filelock==3.19.1
9
+ fsspec==2025.9.0
10
+ greenlet==3.2.4
11
+ h11==0.16.0
12
+ hf-xet==1.1.10
13
+ httptools==0.6.4
14
+ huggingface-hub==0.35.3
15
+ idna==3.10
16
+ Jinja2==3.1.6
17
+ joblib==1.5.2
18
+ mangum==0.19.0
19
+ MarkupSafe==3.0.3
20
+ mpmath==1.3.0
21
+ networkx==3.5
22
+ numpy==2.3.3
23
+ nvidia-cublas-cu12==12.8.4.1
24
+ nvidia-cuda-cupti-cu12==12.8.90
25
+ nvidia-cuda-nvrtc-cu12==12.8.93
26
+ nvidia-cuda-runtime-cu12==12.8.90
27
+ nvidia-cudnn-cu12==9.10.2.21
28
+ nvidia-cufft-cu12==11.3.3.83
29
+ nvidia-cufile-cu12==1.13.1.3
30
+ nvidia-curand-cu12==10.3.9.90
31
+ nvidia-cusolver-cu12==11.7.3.90
32
+ nvidia-cusparse-cu12==12.5.8.93
33
+ nvidia-cusparselt-cu12==0.7.1
34
+ nvidia-nccl-cu12==2.27.3
35
+ nvidia-nvjitlink-cu12==12.8.93
36
+ nvidia-nvtx-cu12==12.8.90
37
+ packaging==25.0
38
+ pandas==2.3.3
39
+ pillow==11.3.0
40
+ pydantic==2.11.9
41
+ pydantic_core==2.33.2
42
+ python-dateutil==2.9.0.post0
43
+ python-dotenv==1.1.1
44
+ pytz==2025.2
45
+ PyYAML==6.0.3
46
+ regex==2025.9.18
47
+ requests==2.32.5
48
+ safetensors==0.6.2
49
+ scikit-learn==1.7.2
50
+ scipy==1.16.2
51
+ sentence-transformers==5.1.1
52
+ setuptools==80.9.0
53
+ six==1.17.0
54
+ sniffio==1.3.1
55
+ SQLAlchemy==2.0.43
56
+ starlette==0.48.0
57
+ sympy==1.14.0
58
+ threadpoolctl==3.6.0
59
+ tokenizers==0.22.1
60
+ torch==2.8.0
61
+ tqdm==4.67.1
62
+ transformers==4.56.2
63
+ triton==3.4.0
64
+ typing-inspection==0.4.2
65
+ typing_extensions==4.15.0
66
+ tzdata==2025.2
67
+ urllib3==2.5.0
68
+ uvicorn==0.37.0
69
+ uvloop==0.21.0
70
+ watchfiles==1.1.0
71
+ websockets==15.0.1
72
+ wheel==0.45.1
vercel.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "functions": {
3
+ "api/index.py": {
4
+ "runtime": "python3.12"
5
+ }
6
+ }
7
+ }