Spaces:
Running
Running
quentinL52 commited on
Commit ·
1556508
1
Parent(s): 771c0b9
update
Browse files- agents_trace.log +0 -0
- src/config/agents.yaml +12 -17
- src/config/app_config.py +14 -14
- src/config/tasks.yaml +138 -129
- src/data/metiers.json +0 -0
- src/parser_flow/CV_agent_flow.py +159 -591
- src/scripts/embed_metiers.py +59 -0
- src/services/cv_service.py +15 -56
- src/services/metier_pre_filter.py +72 -0
- test_api.py +29 -0
- test_result.json +581 -0
agents_trace.log
DELETED
|
File without changes
|
src/config/agents.yaml
CHANGED
|
@@ -147,24 +147,19 @@ cv_quality_checker:
|
|
| 147 |
|
| 148 |
project_analyzer:
|
| 149 |
role: >
|
| 150 |
-
Analyste de Projets Techniques &
|
| 151 |
goal: >
|
| 152 |
-
Évaluer
|
| 153 |
-
et recommander quels projets mettre en avant pour le poste visé.
|
| 154 |
backstory: >
|
| 155 |
-
Tu es un directeur technique (CTO) qui
|
| 156 |
-
|
| 157 |
-
1.
|
| 158 |
-
2.
|
| 159 |
-
3.
|
| 160 |
-
4.
|
| 161 |
-
5.
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
démontrées dans les expériences non-tech (gestion, optimisation, leadership, communication).
|
| 166 |
-
Tu analyses aussi les EXPÉRIENCES PROFESSIONNELLES pour identifier les compétences
|
| 167 |
-
transférables et les relier au poste visé.
|
| 168 |
-
Tu donnes des conseils CONCRETS et ACTIONNABLES pour chaque projet.
|
| 169 |
verbose: false
|
| 170 |
|
|
|
|
| 147 |
|
| 148 |
project_analyzer:
|
| 149 |
role: >
|
| 150 |
+
Analyste de Projets Techniques & Evaluateur de Compétences
|
| 151 |
goal: >
|
| 152 |
+
Évaluer systématiquement les projets du CV selon 7 critères précis, avec une approche purement factuelle.
|
|
|
|
| 153 |
backstory: >
|
| 154 |
+
Tu es un directeur technique (CTO) exigeant qui déteste le bullshit et le name-dropping.
|
| 155 |
+
Tu evaluates chaque projet en examinant :
|
| 156 |
+
1. Pertinence & Alignement
|
| 157 |
+
2. Complexité Technique & Architecture
|
| 158 |
+
3. Stack & Maîtrise
|
| 159 |
+
4. Innovation & Originalité
|
| 160 |
+
5. Impact & Résultats Mesurables
|
| 161 |
+
6. Ownership & Exécution
|
| 162 |
+
7. Maturité Production & Qualité
|
| 163 |
+
Tu produis un avis CLAIR et CRITIQUE : tu es factuel d'abord (preuve du CV), tu donnes du positif spécifique, tu donnes de la critique constructive et honnête, tu évites le fanboying, et tu termines par un verdict tranché.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
verbose: false
|
| 165 |
|
src/config/app_config.py
CHANGED
|
@@ -18,6 +18,17 @@ def load_pdf(pdf_path: str) -> str:
|
|
| 18 |
return pymupdf4llm.to_markdown(pdf_path)
|
| 19 |
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
def load_pdf_first_page_text(pdf_path: str) -> str:
|
| 22 |
"""Extrait le texte brut de la première page en ordre de lecture (haut → bas, gauche → droite).
|
| 23 |
|
|
@@ -48,9 +59,8 @@ GROQ_API_KEY = os.getenv("GROQ_API_KEY")
|
|
| 48 |
def get_big_llm():
|
| 49 |
"""GPT-4o pour les tâches complexes — max_tokens élevé pour éviter la troncature JSON."""
|
| 50 |
return ChatOpenAI(
|
| 51 |
-
model="gpt-
|
| 52 |
temperature=0.0,
|
| 53 |
-
max_tokens=16384,
|
| 54 |
api_key=OPENAI_API_KEY
|
| 55 |
)
|
| 56 |
|
|
@@ -58,18 +68,8 @@ def get_big_llm():
|
|
| 58 |
def get_small_llm():
|
| 59 |
"""GPT-4o-mini pour l'extraction rapide."""
|
| 60 |
return ChatOpenAI(
|
| 61 |
-
model="gpt-4o
|
| 62 |
temperature=0.0,
|
| 63 |
max_tokens=1500,
|
| 64 |
api_key=OPENAI_API_KEY
|
| 65 |
-
)
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
def get_fast_llm():
|
| 69 |
-
"""Groq llama-3.1-8b - Le plus rapide."""
|
| 70 |
-
return ChatGroq(
|
| 71 |
-
model="groq/llama-3.1-8b-instant",
|
| 72 |
-
temperature=0.0,
|
| 73 |
-
max_tokens=1500,
|
| 74 |
-
groq_api_key=GROQ_API_KEY
|
| 75 |
-
)
|
|
|
|
| 18 |
return pymupdf4llm.to_markdown(pdf_path)
|
| 19 |
|
| 20 |
|
| 21 |
+
def get_pdf_page_count(pdf_path: str) -> int:
|
| 22 |
+
"""Retourne le nombre de pages du PDF."""
|
| 23 |
+
try:
|
| 24 |
+
doc = fitz.open(pdf_path)
|
| 25 |
+
count = doc.page_count
|
| 26 |
+
doc.close()
|
| 27 |
+
return count
|
| 28 |
+
except Exception:
|
| 29 |
+
return 1
|
| 30 |
+
|
| 31 |
+
|
| 32 |
def load_pdf_first_page_text(pdf_path: str) -> str:
|
| 33 |
"""Extrait le texte brut de la première page en ordre de lecture (haut → bas, gauche → droite).
|
| 34 |
|
|
|
|
| 59 |
def get_big_llm():
|
| 60 |
"""GPT-4o pour les tâches complexes — max_tokens élevé pour éviter la troncature JSON."""
|
| 61 |
return ChatOpenAI(
|
| 62 |
+
model="gpt-5.2",
|
| 63 |
temperature=0.0,
|
|
|
|
| 64 |
api_key=OPENAI_API_KEY
|
| 65 |
)
|
| 66 |
|
|
|
|
| 68 |
def get_small_llm():
|
| 69 |
"""GPT-4o-mini pour l'extraction rapide."""
|
| 70 |
return ChatOpenAI(
|
| 71 |
+
model="gpt-4o",
|
| 72 |
temperature=0.0,
|
| 73 |
max_tokens=1500,
|
| 74 |
api_key=OPENAI_API_KEY
|
| 75 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/config/tasks.yaml
CHANGED
|
@@ -4,12 +4,6 @@ split_cv_task:
|
|
| 4 |
description: >
|
| 5 |
Découpe le CV en sections JSON. Copie le texte brut sans reformuler ni résumer.
|
| 6 |
|
| 7 |
-
TEXTE PRINCIPAL DU CV (Markdown) :
|
| 8 |
-
"{cv_content}"
|
| 9 |
-
|
| 10 |
-
TEXTE BRUT PREMIÈRE PAGE (extraction directe, utilise-le si le texte principal manque le header) :
|
| 11 |
-
"{cv_raw_start}"
|
| 12 |
-
|
| 13 |
RÈGLES STRICTES :
|
| 14 |
1. "header" : Les premières lignes du CV — contient le NOM du candidat, son TITRE/POSTE, ses coordonnées (email, téléphone, LinkedIn, ville). Cherche en priorité dans le TEXTE BRUT PREMIÈRE PAGE car le Markdown peut mal ordonner le header.
|
| 15 |
2. "experiences" : Uniquement l'historique professionnel (Entreprise, Poste, Dates, missions).
|
|
@@ -17,6 +11,12 @@ split_cv_task:
|
|
| 17 |
4. "skills" : Listes de compétences, langages, outils.
|
| 18 |
5. "education" : Diplômes et formations.
|
| 19 |
6. "languages" : Langues mentionnées avec leur niveau (Français, Anglais, etc.).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
expected_output: >
|
| 21 |
Un objet JSON valide strictement structuré ainsi :
|
| 22 |
{{
|
|
@@ -32,18 +32,22 @@ split_cv_task:
|
|
| 32 |
|
| 33 |
skills_task:
|
| 34 |
description: >
|
| 35 |
-
Analyse les sections suivantes pour lister les compétences avec leur contexte d'utilisation.
|
| 36 |
-
Expériences : {experiences}
|
| 37 |
-
Projets : {projects}
|
| 38 |
-
academique : {education}
|
| 39 |
-
Skills Explicit : {skills}
|
| 40 |
-
|
| 41 |
RÈGLES DE CLASSIFICATION :
|
| 42 |
1. 'hard_skills' : Outils, langages, technos (ex: Python, SQL, Excel, React, FastAPI, LangChain).
|
| 43 |
IMPORTANT: Inclure TOUTES les technologies mentionnées dans les projets, expériences et académique.
|
| 44 |
2. 'soft_skills' : Qualités humaines (ex: Leadership, Communication, Rigueur).
|
| 45 |
3. NE PAS INVENTER. Si ce n'est pas écrit, ne l'ajoute pas.
|
| 46 |
-
4.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
expected_output: >
|
| 48 |
JSON : {{
|
| 49 |
"hard_skills": ["Python", "SQL", "FastAPI"],
|
|
@@ -56,8 +60,7 @@ skills_task:
|
|
| 56 |
|
| 57 |
experience_task:
|
| 58 |
description: >
|
| 59 |
-
Tu es un extracteur de données strict.
|
| 60 |
-
"{experiences}"
|
| 61 |
|
| 62 |
Pour CHAQUE poste identifié :
|
| 63 |
1. Titre du poste
|
|
@@ -66,13 +69,14 @@ experience_task:
|
|
| 66 |
4. Description (Liste des tâches/responsabilités)
|
| 67 |
|
| 68 |
RÈGLE : Ne confonds PAS les projets scolaires avec des expériences pro. Les stages et alternances SONT des expériences.
|
|
|
|
|
|
|
|
|
|
| 69 |
expected_output: >
|
| 70 |
Liste JSON : [{{"Poste": "...", "Entreprise": "...", "start_date": "...", "end_date": "...", "responsabilités": ["task 1", "task 2"]}}]
|
| 71 |
|
| 72 |
project_task:
|
| 73 |
description: >
|
| 74 |
-
Analyse ce texte de projets : "{projects}"
|
| 75 |
-
|
| 76 |
RÈGLES STRICTES DE STRUCTURE :
|
| 77 |
Chaque projet DOIT avoir exactement ces clés :
|
| 78 |
- "title" : Titre du projet
|
|
@@ -85,6 +89,10 @@ project_task:
|
|
| 85 |
- "personal" : Projets perso, Github, Hackathons, Écoles.
|
| 86 |
|
| 87 |
Si une liste est vide, renvoie [].
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
expected_output: >
|
| 89 |
JSON : {{
|
| 90 |
"professional": [
|
|
@@ -102,26 +110,30 @@ education_task:
|
|
| 102 |
|
| 103 |
reconversion_task:
|
| 104 |
description: >
|
| 105 |
-
Analyse les expériences : "{experiences}"
|
| 106 |
-
Et "{education}"
|
| 107 |
Le candidat est-il en reconversion ? (Changement majeur de domaine récent).
|
| 108 |
-
|
| 109 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
expected_output: >
|
| 111 |
-
JSON : {{"reconversion_analysis": {{"is_reconversion": true/false, "context": "..."}}
|
| 112 |
|
| 113 |
etudiant_task:
|
| 114 |
description: >
|
| 115 |
-
|
| 116 |
-
Nous sommes le {current_date}.
|
| 117 |
-
Le candidat est-il ACTUELLEMENT étuditant ?
|
| 118 |
CRITÈRES :
|
| 119 |
1. Regarde les dates de fin des formations.
|
| 120 |
2. Identifie la date de fin de la formation la plus récente.
|
| 121 |
-
3. Si cette date est FUTURE par rapport à
|
| 122 |
4. Récupère explicitement cette date sous le champ 'latest_education_end_date' (format YYYY-MM-DD ou MM/YYYY ou "Present").
|
| 123 |
-
5. indique aussi le niveau d'études (ex: bac+2, bac+5) du diplome le plus proche de
|
| 124 |
6. indique la spécialité exemple: ingenieur IA, data analyste, devellopeur frontend, etc.
|
|
|
|
|
|
|
|
|
|
| 125 |
expected_output: >
|
| 126 |
JSON : {{"etudiant_analysis": {{"is_etudiant": true/false, "niveau_etudes": "bac+5", "specialite": "data analyste", "latest_education_end_date": "YYYY-MM-DD"}}}}
|
| 127 |
|
|
@@ -129,15 +141,15 @@ language_task:
|
|
| 129 |
description: >
|
| 130 |
Identifie toutes les langues parlées par le candidat.
|
| 131 |
|
| 132 |
-
SECTION LANGUES (extraite) : "{languages}"
|
| 133 |
-
|
| 134 |
-
DÉBUT DU CV (pour détecter la langue de rédaction) : "{cv_raw_start}"
|
| 135 |
-
|
| 136 |
RÈGLES :
|
| 137 |
1. Extrais toutes les langues et niveaux présents dans la SECTION LANGUES.
|
| 138 |
2. Détecte la langue dans laquelle le CV est rédigé à partir du DÉBUT DU CV.
|
| 139 |
3. Si la langue du CV n'est PAS dans la SECTION LANGUES, ajoute-la avec le niveau "Natif" ou "Langue maternelle".
|
| 140 |
4. Ne jamais omettre la langue du CV.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
expected_output: >
|
| 142 |
JSON : {{"langues": [{{"langue": "Français", "niveau": "Natif"}}, {{"langue": "Anglais", "niveau": "B2"}}]}}
|
| 143 |
|
|
@@ -145,17 +157,17 @@ identity_task:
|
|
| 145 |
description: >
|
| 146 |
Extrais le prénom du candidat.
|
| 147 |
|
| 148 |
-
HEADER DU CV (nom, titre, contact) : "{header}"
|
| 149 |
-
|
| 150 |
-
TEXTE BRUT DÉBUT DU CV : "{cv_raw_start}"
|
| 151 |
-
|
| 152 |
-
NOM DU FICHIER (indice très fiable, souvent au format NOM_PRENOM_...) : "{file_name}"
|
| 153 |
-
|
| 154 |
RÈGLES :
|
| 155 |
1. Cherche le prénom dans le HEADER, puis dans le TEXTE BRUT DÉBUT DU CV.
|
| 156 |
2. Le NOM DU FICHIER est un indice fort : "ANISSA_KACEM_..." → prénom = "Anissa".
|
| 157 |
3. Ne jamais inventer. Formate avec majuscule initiale.
|
| 158 |
4. Si impossible à trouver, retourne null.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
expected_output: >
|
| 160 |
JSON : {{"first_name": "..."}}
|
| 161 |
|
|
@@ -163,15 +175,15 @@ poste_visé_task:
|
|
| 163 |
description: >
|
| 164 |
Extrais le titre de poste visé tel qu'il est écrit dans l'en-tête du CV.
|
| 165 |
|
| 166 |
-
HEADER DU CV (extrait par le splitter) : "{header}"
|
| 167 |
-
|
| 168 |
-
TEXTE BRUT DÉBUT DU CV (fallback si header vide) : "{cv_raw_start}"
|
| 169 |
-
|
| 170 |
RÈGLES :
|
| 171 |
1. Le titre de poste se trouve juste après le nom du candidat (ex: "Business Analyst", "Data Engineer").
|
| 172 |
2. Copie le titre EXACTEMENT tel qu'il est écrit, sans reformuler.
|
| 173 |
3. Si le header est vide, cherche dans le TEXTE BRUT DÉBUT DU CV.
|
| 174 |
4. Ne jamais inventer un titre.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
expected_output: >
|
| 176 |
JSON : {{
|
| 177 |
"poste_vise": "Le titre EXACT tel qu'écrit sur le CV",
|
|
@@ -184,35 +196,15 @@ metier_matching_task:
|
|
| 184 |
description: >
|
| 185 |
Compare le profil du candidat avec le référentiel de métiers pour recommander les 3 postes les plus adaptés.
|
| 186 |
|
| 187 |
-
POSTE VISÉ PAR LE CANDIDAT : "{poste_vise}"
|
| 188 |
-
|
| 189 |
-
COMPÉTENCES DU CANDIDAT :
|
| 190 |
-
Hard Skills : {hard_skills}
|
| 191 |
-
Soft Skills : {soft_skills}
|
| 192 |
-
|
| 193 |
-
DOMAINES DE COMPÉTENCES IDENTIFIÉS : {skill_domains}
|
| 194 |
-
|
| 195 |
-
MÉTHODOLOGIES DU CANDIDAT : {methodologies}
|
| 196 |
-
|
| 197 |
-
EXPÉRIENCES : {experiences_summary}
|
| 198 |
-
|
| 199 |
-
PROJETS : {projects_summary}
|
| 200 |
-
|
| 201 |
-
RECONVERSION : {reconversion_data}
|
| 202 |
-
|
| 203 |
-
RÉFÉRENTIEL DE MÉTIERS :
|
| 204 |
-
{metiers_reference}
|
| 205 |
-
|
| 206 |
RÈGLES D'ANALYSE :
|
| 207 |
-
IMPORTANT : Tu dois évaluer CHAQUE métier présent dans le RÉFÉRENTIEL DE MÉTIERS
|
| 208 |
sans en omettre aucun. Le top 3 final doit être basé sur l'évaluation exhaustive de tous
|
| 209 |
les métiers listés. Ne jamais présélectionner ou ignorer des métiers a priori.
|
| 210 |
|
| 211 |
1. Pour CHAQUE métier du référentiel, calcule un score de matching (0-100) basé sur :
|
| 212 |
-
- Couverture des compétences techniques requises (
|
| 213 |
-
- Couverture des outils/technologies (
|
| 214 |
- Adéquation des expériences et projets (20%)
|
| 215 |
-
- Maîtrise des méthodologies de travail : Agile, Scrum, DevOps, CI/CD, TDD, Design Thinking (10%)
|
| 216 |
- Cohérence avec le niveau d'études et l'expérience requise (10%)
|
| 217 |
2. Utilise le mapping de domaines pour comprendre les liens implicites (ex: Metabase → BI,
|
| 218 |
LangChain → LLM Engineering, Power BI → BI Analyst, Scikit-learn → Data Science).
|
|
@@ -221,9 +213,26 @@ metier_matching_task:
|
|
| 221 |
communication internationale → travail en équipe multiculturelle).
|
| 222 |
4. Recommande les 3 métiers avec le MEILLEUR score parmi l'ensemble du référentiel évalué.
|
| 223 |
5. PONDÉRATION TEMPORELLE (CRITIQUE) : Accorde un poids double (x2) aux technologies et compétences issues des expériences et projets les plus récents, ainsi qu'à la formation en cours. Le profil actuel d'un candidat est défini par ce qu'il fait aujourd'hui, pas par son historique lointain.
|
| 224 |
-
6. Pour chaque métier recommandé, liste les compétences matchées
|
| 225 |
7. Si le poste visé par le candidat ne fait pas partie du top 3, explique pourquoi.
|
| 226 |
8. Fournis une analyse détaillée de l'adéquation du poste visé avec le profil.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
expected_output: >
|
| 228 |
JSON : {{
|
| 229 |
"postes_recommandes": [
|
|
@@ -240,7 +249,6 @@ metier_matching_task:
|
|
| 240 |
}},
|
| 241 |
"competences_matchees": ["SQL", "Python", "Power BI"],
|
| 242 |
"competences_manquantes": ["Looker", "dbt"],
|
| 243 |
-
"methodologies_matchees": ["Agile", "Scrum"],
|
| 244 |
"justification": "Le profil couvre 85% des compétences requises..."
|
| 245 |
}}
|
| 246 |
],
|
|
@@ -253,22 +261,14 @@ cv_quality_task:
|
|
| 253 |
Évalue la qualité globale du CV en appliquant les critères de bonnes pratiques CV tech 2025,
|
| 254 |
adaptés au niveau de séniorité du candidat.
|
| 255 |
|
| 256 |
-
CV COMPLET (texte Markdown) : "{cv_full_text}"
|
| 257 |
-
|
| 258 |
-
TEXTE BRUT DU CV (première page, pour détecter les URLs et liens) : "{cv_raw_start}"
|
| 259 |
-
|
| 260 |
-
COMPÉTENCES EXTRAITES AVEC CONTEXTE : {skills_with_context}
|
| 261 |
-
EXPÉRIENCES : {experiences_summary}
|
| 262 |
-
PROJETS : {projects_summary}
|
| 263 |
-
NIVEAU DE SÉNIORITÉ : "{niveau_seniorite}"
|
| 264 |
-
RECONVERSION : {reconversion_data}
|
| 265 |
-
|
| 266 |
CRITÈRES D'ÉVALUATION (score sur 100 pour chaque) :
|
| 267 |
|
| 268 |
1. COMPATIBILITÉ ATS (20 points) :
|
| 269 |
-
-
|
| 270 |
-
-
|
| 271 |
-
-
|
|
|
|
|
|
|
| 272 |
|
| 273 |
2. QUANTIFICATION DES RÉSULTATS (25 points) :
|
| 274 |
- Les expériences mentionnent-elles des MÉTRIQUES TECHNIQUES SPÉCIFIQUES ?
|
|
@@ -281,11 +281,13 @@ cv_quality_task:
|
|
| 281 |
en fonction de ses expériences et projets RÉELS (pas des conseils génériques).
|
| 282 |
|
| 283 |
3. STRUCTURE ET LISIBILITÉ (15 points) :
|
| 284 |
-
-
|
| 285 |
-
-
|
| 286 |
-
-
|
| 287 |
-
|
| 288 |
-
|
|
|
|
|
|
|
| 289 |
|
| 290 |
4. PRÉSENTATION DES PROJETS (20 points) :
|
| 291 |
- Chaque projet a-t-il un titre, des technos, et des résultats ?
|
|
@@ -294,10 +296,9 @@ cv_quality_task:
|
|
| 294 |
|
| 295 |
5. PREUVES DE COMPÉTENCES (20 points) :
|
| 296 |
- RÈGLE CRITIQUE : Une compétence est considérée "sans preuve" UNIQUEMENT si elle
|
| 297 |
-
apparaît EXCLUSIVEMENT dans la section
|
| 298 |
-
les expériences OU les projets.
|
| 299 |
-
|
| 300 |
-
ne la signale pas. Ne signale que les skills dont le contexte est "sans contexte".
|
| 301 |
- DÉTECTION DES LIENS : Cherche les URLs dans le CV Markdown ET dans le texte brut.
|
| 302 |
Les liens peuvent apparaître sous forme de Markdown [texte](url), de texte brut
|
| 303 |
(github.com/..., linkedin.com/...) ou dans le header. Signale les liens PRÉSENTS,
|
|
@@ -305,7 +306,7 @@ cv_quality_task:
|
|
| 305 |
- Pour les RECONVERSIONS : les compétences transférables (management, optimisation,
|
| 306 |
communication, gestion budgétaire) sont-elles mises en valeur et reliées au nouveau domaine ?
|
| 307 |
|
| 308 |
-
ADAPTATION AU NIVEAU DE SÉNIORITÉ
|
| 309 |
- Si JUNIOR : valorise les projets personnels, formations, stages bien décrits.
|
| 310 |
- Si CONFIRMÉ : exige des résultats mesurables, progression, responsabilités.
|
| 311 |
- Si SENIOR/STAFF : vérifie la présence de choix architecturaux et compromis
|
|
@@ -313,11 +314,21 @@ cv_quality_task:
|
|
| 313 |
gestion de la scalabilité, impact organisationnel au-delà du code.
|
| 314 |
|
| 315 |
RED FLAGS À DÉTECTER :
|
| 316 |
-
-
|
| 317 |
- Trous inexpliqués dans le parcours
|
| 318 |
- Jargon excessif ou buzzwords sans substance
|
| 319 |
- Incohérence entre compétences listées et projets/expériences
|
| 320 |
- Section compétences en liste plate non catégorisée
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
expected_output: >
|
| 322 |
JSON : {{
|
| 323 |
"score_global": 72,
|
|
@@ -334,49 +345,47 @@ cv_quality_task:
|
|
| 334 |
|
| 335 |
project_analysis_task:
|
| 336 |
description: >
|
| 337 |
-
Évalue CHAQUE projet du CV et détermine
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
RÈGLES STRICTES :
|
| 359 |
-
- N'analyse QUE les projets listés dans PROJETS PROFESSIONNELS et PROJETS PERSONNELS.
|
| 360 |
-
- N'invente AUCUN projet à partir des expériences. Les expériences sont un contexte uniquement.
|
| 361 |
-
- Si PROJETS PROFESSIONNELS et PROJETS PERSONNELS sont vides, retourne "analyse_projets": [].
|
| 362 |
-
- Le nombre d'entrées dans "analyse_projets" doit correspondre EXACTEMENT au nombre de projets fournis.
|
| 363 |
-
- Retourne les projets TRIÉS par rang (rang 1 en premier).
|
| 364 |
expected_output: >
|
| 365 |
JSON : {{
|
| 366 |
"analyse_projets": [
|
| 367 |
{{
|
| 368 |
-
"titre": "
|
| 369 |
-
"
|
| 370 |
-
"
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
}}
|
| 376 |
-
]
|
| 377 |
-
"coherence_globale": {{
|
| 378 |
-
"score": 85,
|
| 379 |
-
"commentaire": "Ensemble de projets cohérent avec le poste visé"
|
| 380 |
-
}}
|
| 381 |
}}
|
| 382 |
|
|
|
|
| 4 |
description: >
|
| 5 |
Découpe le CV en sections JSON. Copie le texte brut sans reformuler ni résumer.
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
RÈGLES STRICTES :
|
| 8 |
1. "header" : Les premières lignes du CV — contient le NOM du candidat, son TITRE/POSTE, ses coordonnées (email, téléphone, LinkedIn, ville). Cherche en priorité dans le TEXTE BRUT PREMIÈRE PAGE car le Markdown peut mal ordonner le header.
|
| 9 |
2. "experiences" : Uniquement l'historique professionnel (Entreprise, Poste, Dates, missions).
|
|
|
|
| 11 |
4. "skills" : Listes de compétences, langages, outils.
|
| 12 |
5. "education" : Diplômes et formations.
|
| 13 |
6. "languages" : Langues mentionnées avec leur niveau (Français, Anglais, etc.).
|
| 14 |
+
|
| 15 |
+
TEXTE PRINCIPAL DU CV (Markdown) :
|
| 16 |
+
"{cv_content}"
|
| 17 |
+
|
| 18 |
+
TEXTE BRUT PREMIÈRE PAGE (extraction directe, utilise-le si le texte principal manque le header) :
|
| 19 |
+
"{cv_raw_start}"
|
| 20 |
expected_output: >
|
| 21 |
Un objet JSON valide strictement structuré ainsi :
|
| 22 |
{{
|
|
|
|
| 32 |
|
| 33 |
skills_task:
|
| 34 |
description: >
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
RÈGLES DE CLASSIFICATION :
|
| 36 |
1. 'hard_skills' : Outils, langages, technos (ex: Python, SQL, Excel, React, FastAPI, LangChain).
|
| 37 |
IMPORTANT: Inclure TOUTES les technologies mentionnées dans les projets, expériences et académique.
|
| 38 |
2. 'soft_skills' : Qualités humaines (ex: Leadership, Communication, Rigueur).
|
| 39 |
3. NE PAS INVENTER. Si ce n'est pas écrit, ne l'ajoute pas.
|
| 40 |
+
4. EXHAUSTIVITÉ ABSOLUE : Traque RIGOUREUSEMENT chaque logiciel, langage, bibliothèque ou concept technique (ex: BeautifulSoup4, Scraping, Pandas, etc.). Ne laisse RIEN passer.
|
| 41 |
+
5. 'skills_with_context' : Tu DOIS obligatoirement analyser la LISTE COMPLÈTE des 'hard_skills' extraites.
|
| 42 |
+
RÈGLE ABSOLUE : Pour CHAQUE compétence, tu dois EFFECTUER UNE RECHERCHE TEXTUELLE RIGOUREUSE (comme un CTRL+F) dans le texte brut des Expériences, des Projets et de l'Éducation.
|
| 43 |
+
De nombreuses compétences présentes dans les 'Expériences' sont souvent manquées. Prends le temps de lire chaque phrase des expériences !
|
| 44 |
+
Si la compétence est trouvée, précise son contexte (ex: "expérience", "projet", "académique", "expérience, projet").
|
| 45 |
+
Si et SEULEMENT SI tu as lu attentivement tous les autres textes et qu'elle n'est VRAIMENT mentionnée nulle part ailleurs que dans la section Compétences, alors attribue-lui le contexte "sans contexte". Ne mets JAMAIS "sans contexte" par erreur si la compétence se trouve dans l'expérience.
|
| 46 |
+
Analyse les sections suivantes pour lister les compétences avec leur contexte d'utilisation :
|
| 47 |
+
Expériences : {experiences}
|
| 48 |
+
Projets : {projects}
|
| 49 |
+
Education : {education}
|
| 50 |
+
Skills Explicites : {skills}
|
| 51 |
expected_output: >
|
| 52 |
JSON : {{
|
| 53 |
"hard_skills": ["Python", "SQL", "FastAPI"],
|
|
|
|
| 60 |
|
| 61 |
experience_task:
|
| 62 |
description: >
|
| 63 |
+
Tu es un extracteur de données strict.
|
|
|
|
| 64 |
|
| 65 |
Pour CHAQUE poste identifié :
|
| 66 |
1. Titre du poste
|
|
|
|
| 69 |
4. Description (Liste des tâches/responsabilités)
|
| 70 |
|
| 71 |
RÈGLE : Ne confonds PAS les projets scolaires avec des expériences pro. Les stages et alternances SONT des expériences.
|
| 72 |
+
|
| 73 |
+
Analyse ce texte d'expérience :
|
| 74 |
+
"{experiences}"
|
| 75 |
expected_output: >
|
| 76 |
Liste JSON : [{{"Poste": "...", "Entreprise": "...", "start_date": "...", "end_date": "...", "responsabilités": ["task 1", "task 2"]}}]
|
| 77 |
|
| 78 |
project_task:
|
| 79 |
description: >
|
|
|
|
|
|
|
| 80 |
RÈGLES STRICTES DE STRUCTURE :
|
| 81 |
Chaque projet DOIT avoir exactement ces clés :
|
| 82 |
- "title" : Titre du projet
|
|
|
|
| 89 |
- "personal" : Projets perso, Github, Hackathons, Écoles.
|
| 90 |
|
| 91 |
Si une liste est vide, renvoie [].
|
| 92 |
+
|
| 93 |
+
Analyse ce texte de projets : "{projects}"
|
| 94 |
+
|
| 95 |
+
Si une liste est vide, renvoie [].
|
| 96 |
expected_output: >
|
| 97 |
JSON : {{
|
| 98 |
"professional": [
|
|
|
|
| 110 |
|
| 111 |
reconversion_task:
|
| 112 |
description: >
|
|
|
|
|
|
|
| 113 |
Le candidat est-il en reconversion ? (Changement majeur de domaine récent).
|
| 114 |
+
Indique aussi le contexte (de quel domaine ou poste vient-il et quelle est sa réorientation).
|
| 115 |
+
RÈGLE ABSOLUE : Tu DOIS extraire la DATE de début de cette reconversion.
|
| 116 |
+
Cette date correspond au mois et à l'année de début de la formation de reconversion OU du premier emploi dans le nouveau domaine.
|
| 117 |
+
Pour le contexte de reconversion et la date, base-toi sur les expériences et l'éducation.
|
| 118 |
+
|
| 119 |
+
Analyse les expériences : "{experiences}"
|
| 120 |
+
Et l'éducation : "{education}"
|
| 121 |
expected_output: >
|
| 122 |
+
JSON : {{"reconversion_analysis": {{"is_reconversion": true/false, "context": "...", "date_reconversion": "YYYY-MM ou null"}}}}
|
| 123 |
|
| 124 |
etudiant_task:
|
| 125 |
description: >
|
| 126 |
+
Le candidat est-il ACTUELLEMENT étudiant ?
|
|
|
|
|
|
|
| 127 |
CRITÈRES :
|
| 128 |
1. Regarde les dates de fin des formations.
|
| 129 |
2. Identifie la date de fin de la formation la plus récente.
|
| 130 |
+
3. Si cette date est FUTURE par rapport à la date actuelle ou si c'est écrit "En cours" / "Présent", alors is_etudiant = true.
|
| 131 |
4. Récupère explicitement cette date sous le champ 'latest_education_end_date' (format YYYY-MM-DD ou MM/YYYY ou "Present").
|
| 132 |
+
5. indique aussi le niveau d'études (ex: bac+2, bac+5) du diplome le plus proche de la date actuelle.
|
| 133 |
6. indique la spécialité exemple: ingenieur IA, data analyste, devellopeur frontend, etc.
|
| 134 |
+
|
| 135 |
+
Nous sommes le {current_date}.
|
| 136 |
+
Analyse la section education : "{education}"
|
| 137 |
expected_output: >
|
| 138 |
JSON : {{"etudiant_analysis": {{"is_etudiant": true/false, "niveau_etudes": "bac+5", "specialite": "data analyste", "latest_education_end_date": "YYYY-MM-DD"}}}}
|
| 139 |
|
|
|
|
| 141 |
description: >
|
| 142 |
Identifie toutes les langues parlées par le candidat.
|
| 143 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
RÈGLES :
|
| 145 |
1. Extrais toutes les langues et niveaux présents dans la SECTION LANGUES.
|
| 146 |
2. Détecte la langue dans laquelle le CV est rédigé à partir du DÉBUT DU CV.
|
| 147 |
3. Si la langue du CV n'est PAS dans la SECTION LANGUES, ajoute-la avec le niveau "Natif" ou "Langue maternelle".
|
| 148 |
4. Ne jamais omettre la langue du CV.
|
| 149 |
+
|
| 150 |
+
SECTION LANGUES (extraite) : "{languages}"
|
| 151 |
+
|
| 152 |
+
DÉBUT DU CV (pour détecter la langue de rédaction) : "{cv_raw_start}"
|
| 153 |
expected_output: >
|
| 154 |
JSON : {{"langues": [{{"langue": "Français", "niveau": "Natif"}}, {{"langue": "Anglais", "niveau": "B2"}}]}}
|
| 155 |
|
|
|
|
| 157 |
description: >
|
| 158 |
Extrais le prénom du candidat.
|
| 159 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
RÈGLES :
|
| 161 |
1. Cherche le prénom dans le HEADER, puis dans le TEXTE BRUT DÉBUT DU CV.
|
| 162 |
2. Le NOM DU FICHIER est un indice fort : "ANISSA_KACEM_..." → prénom = "Anissa".
|
| 163 |
3. Ne jamais inventer. Formate avec majuscule initiale.
|
| 164 |
4. Si impossible à trouver, retourne null.
|
| 165 |
+
|
| 166 |
+
HEADER DU CV (nom, titre, contact) : "{header}"
|
| 167 |
+
|
| 168 |
+
TEXTE BRUT DÉBUT DU CV : "{cv_raw_start}"
|
| 169 |
+
|
| 170 |
+
NOM DU FICHIER (indice très fiable, souvent au format NOM_PRENOM_...) : "{file_name}"
|
| 171 |
expected_output: >
|
| 172 |
JSON : {{"first_name": "..."}}
|
| 173 |
|
|
|
|
| 175 |
description: >
|
| 176 |
Extrais le titre de poste visé tel qu'il est écrit dans l'en-tête du CV.
|
| 177 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
RÈGLES :
|
| 179 |
1. Le titre de poste se trouve juste après le nom du candidat (ex: "Business Analyst", "Data Engineer").
|
| 180 |
2. Copie le titre EXACTEMENT tel qu'il est écrit, sans reformuler.
|
| 181 |
3. Si le header est vide, cherche dans le TEXTE BRUT DÉBUT DU CV.
|
| 182 |
4. Ne jamais inventer un titre.
|
| 183 |
+
|
| 184 |
+
HEADER DU CV (extrait par le splitter) : "{header}"
|
| 185 |
+
|
| 186 |
+
TEXTE BRUT DÉBUT DU CV (fallback si header vide) : "{cv_raw_start}"
|
| 187 |
expected_output: >
|
| 188 |
JSON : {{
|
| 189 |
"poste_vise": "Le titre EXACT tel qu'écrit sur le CV",
|
|
|
|
| 196 |
description: >
|
| 197 |
Compare le profil du candidat avec le référentiel de métiers pour recommander les 3 postes les plus adaptés.
|
| 198 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
RÈGLES D'ANALYSE :
|
| 200 |
+
IMPORTANT : Tu dois évaluer CHAQUE métier présent dans le RÉFÉRENTIEL DE MÉTIERS,
|
| 201 |
sans en omettre aucun. Le top 3 final doit être basé sur l'évaluation exhaustive de tous
|
| 202 |
les métiers listés. Ne jamais présélectionner ou ignorer des métiers a priori.
|
| 203 |
|
| 204 |
1. Pour CHAQUE métier du référentiel, calcule un score de matching (0-100) basé sur :
|
| 205 |
+
- Couverture des compétences techniques requises (40%)
|
| 206 |
+
- Couverture des outils/technologies (30%)
|
| 207 |
- Adéquation des expériences et projets (20%)
|
|
|
|
| 208 |
- Cohérence avec le niveau d'études et l'expérience requise (10%)
|
| 209 |
2. Utilise le mapping de domaines pour comprendre les liens implicites (ex: Metabase → BI,
|
| 210 |
LangChain → LLM Engineering, Power BI → BI Analyst, Scikit-learn → Data Science).
|
|
|
|
| 213 |
communication internationale → travail en équipe multiculturelle).
|
| 214 |
4. Recommande les 3 métiers avec le MEILLEUR score parmi l'ensemble du référentiel évalué.
|
| 215 |
5. PONDÉRATION TEMPORELLE (CRITIQUE) : Accorde un poids double (x2) aux technologies et compétences issues des expériences et projets les plus récents, ainsi qu'à la formation en cours. Le profil actuel d'un candidat est défini par ce qu'il fait aujourd'hui, pas par son historique lointain.
|
| 216 |
+
6. Pour chaque métier recommandé, liste les compétences matchées et manquantes.
|
| 217 |
7. Si le poste visé par le candidat ne fait pas partie du top 3, explique pourquoi.
|
| 218 |
8. Fournis une analyse détaillée de l'adéquation du poste visé avec le profil.
|
| 219 |
+
|
| 220 |
+
RÉFÉRENTIEL DE MÉTIERS :
|
| 221 |
+
{metiers_reference}
|
| 222 |
+
|
| 223 |
+
EN-TÊTE DU CV (pour le poste visé) : "{header}"
|
| 224 |
+
|
| 225 |
+
SECTION COMPÉTENCES :
|
| 226 |
+
{skills}
|
| 227 |
+
|
| 228 |
+
SECTION EXPÉRIENCES :
|
| 229 |
+
{experiences}
|
| 230 |
+
|
| 231 |
+
SECTION PROJETS :
|
| 232 |
+
{projects}
|
| 233 |
+
|
| 234 |
+
SECTION FORMATION (utile pour détecter les reconversions):
|
| 235 |
+
{education}
|
| 236 |
expected_output: >
|
| 237 |
JSON : {{
|
| 238 |
"postes_recommandes": [
|
|
|
|
| 249 |
}},
|
| 250 |
"competences_matchees": ["SQL", "Python", "Power BI"],
|
| 251 |
"competences_manquantes": ["Looker", "dbt"],
|
|
|
|
| 252 |
"justification": "Le profil couvre 85% des compétences requises..."
|
| 253 |
}}
|
| 254 |
],
|
|
|
|
| 261 |
Évalue la qualité globale du CV en appliquant les critères de bonnes pratiques CV tech 2025,
|
| 262 |
adaptés au niveau de séniorité du candidat.
|
| 263 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
CRITÈRES D'ÉVALUATION (score sur 100 pour chaque) :
|
| 265 |
|
| 266 |
1. COMPATIBILITÉ ATS (20 points) :
|
| 267 |
+
- ÉVALUATION STRICTE ET CRITIQUE : Ne donne jamais un score parfait sans preuve.
|
| 268 |
+
- Les noms des sections principales sont-ils standards (Expériences, Formations, Projets, Compétences) ou atypiques ? Si atypiques, pénalise. (RÈGLE ABSOLUE : N'évalue PAS et ne pénalise JAMAIS les sections d'introduction courtes comme "Profil", "À propos", "Résumé" ou "Profil atypique". Ignore-les totalement pour ce critère).
|
| 269 |
+
- Les mots-clés techniques sont-ils contextuels ou simplement entassés de manière artificielle ?
|
| 270 |
+
- Indique EXPLICITEMENT ce qui ne va pas (ex: "Nom de section 'Mon Parcours' au lieu de 'Expériences'", "Mots-clés empilés sans lien avec les expériences").
|
| 271 |
+
- Fournis des points d'amélioration spécifiques et factuels.
|
| 272 |
|
| 273 |
2. QUANTIFICATION DES RÉSULTATS (25 points) :
|
| 274 |
- Les expériences mentionnent-elles des MÉTRIQUES TECHNIQUES SPÉCIFIQUES ?
|
|
|
|
| 281 |
en fonction de ses expériences et projets RÉELS (pas des conseils génériques).
|
| 282 |
|
| 283 |
3. STRUCTURE ET LISIBILITÉ (15 points) :
|
| 284 |
+
- ANALYSE CRITIQUE BASÉE SUR LE TEXTE : Ne suppose pas que le CV est beau. Évalue la structure à partir du texte extrait.
|
| 285 |
+
- RÈGLE ABSOLUE SUR LA FORME : N'évalue JAMAIS la présence ou l'absence de listes à puces/tirets. Le parsing supprime souvent la mise en forme, ne fais donc aucune remarque sur des "blocs de texte illisibles".
|
| 286 |
+
- L'ordre chronologique inverse est-il respecté dans les expériences et formations ? Si non, pénalise sévèrement.
|
| 287 |
+
- RÈGLE LONGUEUR : Utilise la donnée `NOMBRE DE PAGES`. Un profil < 7 ans d'expérience devrait tenir sur 1 page. Pénalise si > 2 pages.
|
| 288 |
+
- RÈGLE POSTE VISÉ (CRITIQUE) : Si `POSTE VISÉ` est "Non identifié", c'est une ERREUR MAJEURE. Baisse drastiquement la note de structure et signale-le comme un RED FLAG (le recruteur doit identifier le but du CV en 3 secondes).
|
| 289 |
+
- STRUCTURATION DES COMPÉTENCES : Les compétences sont-elles regroupées par catégories logiques (Langages, Frameworks, BDD, DevOps/Cloud) ou en liste plate ? Une structuration par catégories est fortement recommandée pour les filtres ATS.
|
| 290 |
+
- Fournis des conseils d'amélioration FACTUELS sur la structure.
|
| 291 |
|
| 292 |
4. PRÉSENTATION DES PROJETS (20 points) :
|
| 293 |
- Chaque projet a-t-il un titre, des technos, et des résultats ?
|
|
|
|
| 296 |
|
| 297 |
5. PREUVES DE COMPÉTENCES (20 points) :
|
| 298 |
- RÈGLE CRITIQUE : Une compétence est considérée "sans preuve" UNIQUEMENT si elle
|
| 299 |
+
apparaît EXCLUSIVEMENT dans la section Compétences sans aucune mention dans
|
| 300 |
+
les expériences OU les projets. Tu dois vérifier par toi-même (par analyse textuelle)
|
| 301 |
+
si les compétences listées apparaissent ailleurs. Ne signale que les skills qui n'ont "aucun contexte".
|
|
|
|
| 302 |
- DÉTECTION DES LIENS : Cherche les URLs dans le CV Markdown ET dans le texte brut.
|
| 303 |
Les liens peuvent apparaître sous forme de Markdown [texte](url), de texte brut
|
| 304 |
(github.com/..., linkedin.com/...) ou dans le header. Signale les liens PRÉSENTS,
|
|
|
|
| 306 |
- Pour les RECONVERSIONS : les compétences transférables (management, optimisation,
|
| 307 |
communication, gestion budgétaire) sont-elles mises en valeur et reliées au nouveau domaine ?
|
| 308 |
|
| 309 |
+
ADAPTATION AU NIVEAU DE SÉNIORITÉ :
|
| 310 |
- Si JUNIOR : valorise les projets personnels, formations, stages bien décrits.
|
| 311 |
- Si CONFIRMÉ : exige des résultats mesurables, progression, responsabilités.
|
| 312 |
- Si SENIOR/STAFF : vérifie la présence de choix architecturaux et compromis
|
|
|
|
| 314 |
gestion de la scalabilité, impact organisationnel au-delà du code.
|
| 315 |
|
| 316 |
RED FLAGS À DÉTECTER :
|
| 317 |
+
- DÉTECTION STRICTE DES COMPÉTENCES SANS PREUVE : Tu DOIS t'appuyer uniquement sur ton évaluation de "preuves_competences". S'il y a des compétences listées dans "skills_sans_preuve", ajoute un red flag. Sinon, n'ajoute pas de red flag pour ce sujet.
|
| 318 |
- Trous inexpliqués dans le parcours
|
| 319 |
- Jargon excessif ou buzzwords sans substance
|
| 320 |
- Incohérence entre compétences listées et projets/expériences
|
| 321 |
- Section compétences en liste plate non catégorisée
|
| 322 |
+
|
| 323 |
+
DONNÉES À ÉVALUER :
|
| 324 |
+
EN-TÊTE DU CV (pour le poste visé et niveau de séniorité) : "{header}"
|
| 325 |
+
NOMBRE DE PAGES : {page_count}
|
| 326 |
+
CV COMPLET (texte Markdown) : "{cv_full_text}"
|
| 327 |
+
TEXTE BRUT DU CV (première page, pour détecter les URLs et liens) : "{cv_raw_start}"
|
| 328 |
+
SECTION COMPÉTENCES : "{skills}"
|
| 329 |
+
SECTION EXPÉRIENCES : "{experiences}"
|
| 330 |
+
SECTION PROJETS : "{projects}"
|
| 331 |
+
SECTION FORMATIONS : "{education}"
|
| 332 |
expected_output: >
|
| 333 |
JSON : {{
|
| 334 |
"score_global": 72,
|
|
|
|
| 345 |
|
| 346 |
project_analysis_task:
|
| 347 |
description: >
|
| 348 |
+
Évalue CHAQUE projet du CV et détermine la qualité technique et l'impact.
|
| 349 |
+
|
| 350 |
+
Pour CHAQUE projet, tu dois scorer sur 10 avec une justification courte et factuelle (preuve tirée du CV) sur les 7 critères suivants :
|
| 351 |
+
1. Pertinence & Alignement : Correspondance avec le poste visé (multi-agents, RAG, recrutement tech, pipelines, GenAI).
|
| 352 |
+
2. Complexité Technique & Architecture : Niveau réel d'intégration (7 agents orchestrés ? pipeline complet ingestion→monitoring ? custom vs simple CrewAI/LangChain ?).
|
| 353 |
+
3. Stack & Maîtrise : Modernité, cohérence, diversité raisonnable vs "tech soup".
|
| 354 |
+
4. Innovation & Originalité : Valeur ajoutée réelle vs "j'ai collé des libs à la mode" (Ex : scoring multi-dimensionnel + détection fraude = fort ; simple wrapper RAG = faible).
|
| 355 |
+
5. Impact & Résultats Mesurables : Chiffres concrets (+40 %, 2000 offres, 30 % moins d'étapes...) et crédibilité de la mesure.
|
| 356 |
+
6. Ownership & Exécution : End-to-end (idea → business plan → dev → déploiement → monitoring). Méthodologies affichées (Design Thinking, Scrum...).
|
| 357 |
+
7. Maturité Production & Qualité : Monitoring, coût/latence, tests, sécurité, RGPD, scaling, users réels vs démo/prototype.
|
| 358 |
+
|
| 359 |
+
Méthode pour un avis CLAIR et CRITIQUE :
|
| 360 |
+
- Être factuel d'abord (preuve du CV).
|
| 361 |
+
- Donner du positif spécifique.
|
| 362 |
+
- Donner de la critique constructive et honnête (jamais méchant, toujours utile).
|
| 363 |
+
- Éviter le fanboying et le "tout est génial".
|
| 364 |
+
- Terminer par un verdict tranché.
|
| 365 |
+
|
| 366 |
+
DONNÉES DU CANDIDAT :
|
| 367 |
+
EN-TÊTE DU CV (pour déduire le poste visé) : "{header}"
|
| 368 |
+
PROJETS MENTIONNÉS (texte brut) : {projects}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
expected_output: >
|
| 370 |
JSON : {{
|
| 371 |
"analyse_projets": [
|
| 372 |
{{
|
| 373 |
+
"titre": "Nom exact du projet",
|
| 374 |
+
"resume": "Description ultra-concise + type (Side Project / SaaS / MVP)",
|
| 375 |
+
"evaluation": {{
|
| 376 |
+
"pertinence": {{"score": 8, "justification": "..."}},
|
| 377 |
+
"complexite": {{"score": 7, "justification": "..."}},
|
| 378 |
+
"stack": {{"score": 9, "justification": "..."}},
|
| 379 |
+
"innovation": {{"score": 6, "justification": "..."}},
|
| 380 |
+
"impact": {{"score": 5, "justification": "Aucun chiffre concret"}},
|
| 381 |
+
"ownership": {{"score": 8, "justification": "..."}},
|
| 382 |
+
"maturite": {{"score": 4, "justification": "Pas de mention de monitoring ou de tests"}}
|
| 383 |
+
}},
|
| 384 |
+
"points_forts": ["...", "..."],
|
| 385 |
+
"points_vigilance": ["Risque d'over-claim sur...", "Manque de visibilité sur tests"],
|
| 386 |
+
"note_globale": 67,
|
| 387 |
+
"verdict_recruteur": "Très bon projet qui démontre une vraie maîtrise agentique... Idéal pour un rôle IA intermédiaire, à creuser en entretien."
|
| 388 |
}}
|
| 389 |
+
]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
}}
|
| 391 |
|
src/data/metiers.json
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
src/parser_flow/CV_agent_flow.py
CHANGED
|
@@ -24,16 +24,6 @@ logger = logging.getLogger(__name__)
|
|
| 24 |
|
| 25 |
#_____________________________________________________________________________________
|
| 26 |
|
| 27 |
-
# Configuration du logger pour capturer la verbosité dans un fichier
|
| 28 |
-
verbose_logger = logging.getLogger("crewai_verbose")
|
| 29 |
-
verbose_logger.setLevel(logging.INFO)
|
| 30 |
-
|
| 31 |
-
# Création du fichier de log (écrase le précédent à chaque run avec 'w')
|
| 32 |
-
file_handler = logging.FileHandler("agents_trace.log", mode='w', encoding='utf-8')
|
| 33 |
-
formatter = logging.Formatter('%(asctime)s - %(message)s')
|
| 34 |
-
file_handler.setFormatter(formatter)
|
| 35 |
-
verbose_logger.addHandler(file_handler)
|
| 36 |
-
|
| 37 |
class CVAgentOrchestrator:
|
| 38 |
"""Orchestrateur multi-agents pour le parsing et l'analyse de CV."""
|
| 39 |
|
|
@@ -43,13 +33,8 @@ class CVAgentOrchestrator:
|
|
| 43 |
self.agents_config = self._load_yaml("agents.yaml")
|
| 44 |
self.tasks_config = self._load_yaml("tasks.yaml")
|
| 45 |
self.metiers_data = self._load_metiers()
|
| 46 |
-
self.skill_domain_map = self._load_skill_domain_map()
|
| 47 |
self._create_agents()
|
| 48 |
|
| 49 |
-
# ──────────────────────────────────────────────
|
| 50 |
-
# Chargement des configurations
|
| 51 |
-
# ──────────────────────────────────────────────
|
| 52 |
-
|
| 53 |
def _load_yaml(self, filename: str) -> Dict:
|
| 54 |
base_path = os.path.dirname(os.path.dirname(__file__))
|
| 55 |
config_path = os.path.join(base_path, "config", filename)
|
|
@@ -57,29 +42,12 @@ class CVAgentOrchestrator:
|
|
| 57 |
return yaml.safe_load(f)
|
| 58 |
|
| 59 |
def _load_metiers(self) -> List[Dict]:
|
| 60 |
-
"""Charge le référentiel de métiers (
|
| 61 |
base_path = os.path.dirname(os.path.dirname(__file__))
|
| 62 |
metiers_path = os.path.join(base_path, "data", "metiers.json")
|
| 63 |
with open(metiers_path, "r", encoding="utf-8") as f:
|
| 64 |
data = json.load(f)
|
| 65 |
-
metiers
|
| 66 |
-
for m in data.get("metiers", []):
|
| 67 |
-
clean = {k: v for k, v in m.items() if k != "embedding"}
|
| 68 |
-
metiers.append(clean)
|
| 69 |
-
return metiers
|
| 70 |
-
|
| 71 |
-
def _load_skill_domain_map(self) -> Dict[str, List[str]]:
|
| 72 |
-
"""Charge le mapping compétences -> domaines."""
|
| 73 |
-
base_path = os.path.dirname(os.path.dirname(__file__))
|
| 74 |
-
map_path = os.path.join(base_path, "config", "skill_domain_map.json")
|
| 75 |
-
with open(map_path, "r", encoding="utf-8") as f:
|
| 76 |
-
return json.load(f)
|
| 77 |
-
|
| 78 |
-
# ──────────────────────────────────────────────
|
| 79 |
-
# Création des agents
|
| 80 |
-
# ──────────────────────────────────────────────
|
| 81 |
-
|
| 82 |
-
|
| 83 |
|
| 84 |
def _create_agents(self):
|
| 85 |
def make_agent(name, llm_override=None):
|
|
@@ -90,12 +58,9 @@ class CVAgentOrchestrator:
|
|
| 90 |
verbose=True,
|
| 91 |
max_iter=1,
|
| 92 |
respect_context_window=True,
|
| 93 |
-
# logs callbackagent
|
| 94 |
-
step_callback=lambda step: verbose_logger.info(f"Agent {name} Step: {step}"),
|
| 95 |
)
|
| 96 |
|
| 97 |
-
|
| 98 |
-
self.cv_splitter = make_agent("cv_splitter", llm_override=self.big_llm)
|
| 99 |
self.skills_extractor = make_agent("skills_extractor")
|
| 100 |
self.experience_extractor = make_agent("experience_extractor")
|
| 101 |
self.project_extractor = make_agent("project_extractor")
|
|
@@ -105,11 +70,10 @@ class CVAgentOrchestrator:
|
|
| 105 |
self.etudiant_detector = make_agent("etudiant_detector")
|
| 106 |
self.identity_extractor = make_agent("identity_extractor")
|
| 107 |
|
| 108 |
-
|
| 109 |
-
self.
|
| 110 |
-
self.
|
| 111 |
-
self.
|
| 112 |
-
self.project_analyzer = make_agent("project_analyzer")
|
| 113 |
|
| 114 |
# ──────────────────────────────────────────────
|
| 115 |
# PHASE 1 : Découpage du CV en sections
|
|
@@ -138,25 +102,41 @@ class CVAgentOrchestrator:
|
|
| 138 |
return parsed
|
| 139 |
|
| 140 |
# ──────────────────────────────────────────────
|
| 141 |
-
# PHASE 2 : Extraction
|
| 142 |
# ──────────────────────────────────────────────
|
| 143 |
|
| 144 |
-
async def
|
| 145 |
-
self, sections: Dict[str, str], cv_raw_start: str = "", file_name: str = ""
|
| 146 |
) -> Dict[str, Any]:
|
| 147 |
-
"""Exécute les
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
def create_task_async(task_key, agent, **kwargs):
|
| 150 |
t_config = self.tasks_config[task_key].copy()
|
| 151 |
t_description = t_config["description"]
|
| 152 |
-
|
| 153 |
-
# Éviter les erreurs de formattage si des clés manquent ou sont mal échappées (ex: accolades dans le texte du CV)
|
| 154 |
try:
|
| 155 |
-
# Utiliser format_map pour plus de flexibilité si besoin, mais format() est standard
|
| 156 |
t_config["description"] = t_description.format(**kwargs)
|
| 157 |
except KeyError as e:
|
| 158 |
logger.warning(f"KeyError formatting task '{task_key}': {e}. Falling back to manual replace.")
|
| 159 |
-
# Fallback manuel sécurisé pour les clés présentes
|
| 160 |
desc = t_description
|
| 161 |
for k, v in kwargs.items():
|
| 162 |
placeholder = "{" + k + "}"
|
|
@@ -171,69 +151,40 @@ class CVAgentOrchestrator:
|
|
| 171 |
return (task_key, c.kickoff_async())
|
| 172 |
|
| 173 |
tasks_def = [
|
| 174 |
-
(
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
"
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
"
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
"
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
},
|
| 206 |
-
),
|
| 207 |
-
(
|
| 208 |
-
"language_task",
|
| 209 |
-
self.language_extractor,
|
| 210 |
-
{
|
| 211 |
-
"languages": sections.get("languages", ""),
|
| 212 |
-
"cv_raw_start": cv_raw_start[:500],
|
| 213 |
-
},
|
| 214 |
-
),
|
| 215 |
-
(
|
| 216 |
-
"etudiant_task",
|
| 217 |
-
self.etudiant_detector,
|
| 218 |
-
{
|
| 219 |
-
"education": sections.get("education", ""),
|
| 220 |
-
"current_date": datetime.now().strftime("%Y-%m-%d"),
|
| 221 |
-
},
|
| 222 |
-
),
|
| 223 |
-
(
|
| 224 |
-
"identity_task",
|
| 225 |
-
self.identity_extractor,
|
| 226 |
-
{
|
| 227 |
-
"header": sections.get("header", ""),
|
| 228 |
-
"cv_raw_start": cv_raw_start[:1500],
|
| 229 |
-
"file_name": file_name,
|
| 230 |
-
},
|
| 231 |
-
),
|
| 232 |
]
|
| 233 |
|
| 234 |
-
task_coroutines = [
|
| 235 |
-
create_task_async(key, agent, **kwargs) for key, agent, kwargs in tasks_def
|
| 236 |
-
]
|
| 237 |
keys = [t[0] for t in task_coroutines]
|
| 238 |
coroutines = [t[1] for t in task_coroutines]
|
| 239 |
results_list = await asyncio.gather(*coroutines, return_exceptions=True)
|
|
@@ -245,530 +196,147 @@ class CVAgentOrchestrator:
|
|
| 245 |
else:
|
| 246 |
results_map[key] = result
|
| 247 |
|
| 248 |
-
return self.
|
| 249 |
-
|
| 250 |
-
# ──────────────────────────────────────────────
|
| 251 |
-
# PHASE 3a : Analyse d'en-tête (indépendante, tourne en // avec Phase 2)
|
| 252 |
-
# ──────────────────────────────────────────────
|
| 253 |
-
|
| 254 |
-
async def run_header_analysis(
|
| 255 |
-
self,
|
| 256 |
-
sections: Dict[str, str],
|
| 257 |
-
cv_raw_start: str = "",
|
| 258 |
-
cv_full_text: str = "",
|
| 259 |
-
) -> Dict:
|
| 260 |
-
"""Extrait le poste visé depuis l'en-tête du CV.
|
| 261 |
-
|
| 262 |
-
Ne dépend que de Phase 1 (sections) → peut tourner en PARALLÈLE avec Phase 2.
|
| 263 |
-
"""
|
| 264 |
-
header_section = sections.get("header", "")
|
| 265 |
-
raw_for_header = cv_raw_start[:2000] if cv_raw_start else cv_full_text[:2000]
|
| 266 |
-
safe_cv_raw = raw_for_header.replace("{", "{{").replace("}", "}}")
|
| 267 |
-
safe_header = header_section.replace("{", "{{").replace("}", "}}")
|
| 268 |
-
|
| 269 |
-
header_data: Dict = {
|
| 270 |
-
"poste_vise": "Non identifié",
|
| 271 |
-
"niveau_seniorite": "non précisé",
|
| 272 |
-
"confiance": 0,
|
| 273 |
-
}
|
| 274 |
-
|
| 275 |
-
try:
|
| 276 |
-
t_config = self.tasks_config["poste_visé_task"].copy()
|
| 277 |
-
t_config["description"] = t_config["description"].format(
|
| 278 |
-
cv_raw_start=safe_cv_raw,
|
| 279 |
-
header=safe_header,
|
| 280 |
-
)
|
| 281 |
-
task = Task(config=t_config, agent=self.header_analyzer)
|
| 282 |
-
crew = Crew(agents=[self.header_analyzer], tasks=[task], verbose=False)
|
| 283 |
-
header_result = await crew.kickoff_async()
|
| 284 |
-
|
| 285 |
-
if header_result:
|
| 286 |
-
header_data = self._parse_json_output(
|
| 287 |
-
header_result,
|
| 288 |
-
{"poste_vise": "Non identifié", "niveau_seniorite": "non précisé", "confiance": 0},
|
| 289 |
-
)
|
| 290 |
-
logger.info(
|
| 291 |
-
f"Header analyzer : poste_vise='{header_data.get('poste_vise')}', "
|
| 292 |
-
f"confiance={header_data.get('confiance')}"
|
| 293 |
-
)
|
| 294 |
-
except Exception as e:
|
| 295 |
-
logger.error(f"Header analyzer failed: {e}", exc_info=True)
|
| 296 |
-
|
| 297 |
-
# Fallback programmatique si le LLM n'a pas trouvé le poste
|
| 298 |
-
if header_data.get("poste_vise", "Non identifié") == "Non identifié":
|
| 299 |
-
logger.warning("Header analyzer 'Non identifié' → fallback programmatique...")
|
| 300 |
-
fallback = self._fallback_extract_poste_vise(cv_full_text, header_section)
|
| 301 |
-
if fallback:
|
| 302 |
-
header_data["poste_vise"] = fallback
|
| 303 |
-
header_data["source_detection"] = "fallback_programmatique"
|
| 304 |
-
header_data["confiance"] = 70
|
| 305 |
-
logger.info(f"Fallback found poste_vise: '{fallback}'")
|
| 306 |
-
|
| 307 |
-
return header_data
|
| 308 |
-
|
| 309 |
-
# ───────────────────────────��──────────────────
|
| 310 |
-
# PHASE 3b : Analyse & Recommandation (3 agents parallèles)
|
| 311 |
-
# ──────────────────────────────────────────────
|
| 312 |
-
|
| 313 |
-
async def analyze_and_recommend(
|
| 314 |
-
self,
|
| 315 |
-
cv_full_text: str,
|
| 316 |
-
sections: Dict[str, str],
|
| 317 |
-
extraction: Dict[str, Any],
|
| 318 |
-
cv_raw_start: str = "",
|
| 319 |
-
header_data: Dict = None,
|
| 320 |
-
) -> Dict[str, Any]:
|
| 321 |
-
"""Exécute les 3 tâches d'analyse en parallèle.
|
| 322 |
-
|
| 323 |
-
header_data est pré-calculé par run_header_analysis (en // avec Phase 2).
|
| 324 |
-
"""
|
| 325 |
-
if header_data is None:
|
| 326 |
-
logger.warning("analyze_and_recommend sans header_data — valeurs par défaut utilisées.")
|
| 327 |
-
header_data = {"poste_vise": "Non identifié", "niveau_seniorite": "non précisé", "confiance": 0}
|
| 328 |
-
|
| 329 |
-
candidat = extraction.get("candidat", {})
|
| 330 |
-
competences = candidat.get("compétences", {})
|
| 331 |
-
hard_skills = competences.get("hard_skills", [])
|
| 332 |
-
soft_skills = competences.get("soft_skills", [])
|
| 333 |
-
skills_with_context = competences.get("skills_with_context", [])
|
| 334 |
-
reconversion = candidat.get("reconversion", {})
|
| 335 |
-
|
| 336 |
-
skill_domains = self._map_skills_to_domains(hard_skills)
|
| 337 |
-
methodologies = self._extract_methodologies(hard_skills, skill_domains)
|
| 338 |
-
|
| 339 |
-
experiences_summary = json.dumps(
|
| 340 |
-
candidat.get("expériences", []), ensure_ascii=False
|
| 341 |
-
)[:3000]
|
| 342 |
-
projets = candidat.get("projets", {})
|
| 343 |
-
professional_projects = json.dumps(
|
| 344 |
-
projets.get("professional", []), ensure_ascii=False
|
| 345 |
-
)[:2000]
|
| 346 |
-
personal_projects = json.dumps(
|
| 347 |
-
projets.get("personal", []), ensure_ascii=False
|
| 348 |
-
)[:2000]
|
| 349 |
-
projects_summary = f"Pro: {professional_projects}\nPerso: {personal_projects}"
|
| 350 |
-
reconversion_data = json.dumps(reconversion, ensure_ascii=False) if reconversion else "{}"
|
| 351 |
-
|
| 352 |
-
metiers_reference = self._prepare_metiers_for_prompt()
|
| 353 |
-
|
| 354 |
-
poste_vise = header_data.get("poste_vise", "Non identifié")
|
| 355 |
-
niveau_seniorite = header_data.get("niveau_seniorite", "non précisé")
|
| 356 |
-
metier_reference_detail = self._get_metier_reference_for_poste(poste_vise)
|
| 357 |
-
|
| 358 |
-
raw_for_header = cv_raw_start[:2000] if cv_raw_start else cv_full_text[:2000]
|
| 359 |
-
safe_cv_raw = raw_for_header.replace("{", "{{").replace("}", "}}")
|
| 360 |
-
|
| 361 |
-
def create_task_async(task_key, agent, **kwargs):
|
| 362 |
-
t_config = self.tasks_config[task_key].copy()
|
| 363 |
-
t_config["description"] = t_config["description"].format(**kwargs)
|
| 364 |
-
task = Task(config=t_config, agent=agent)
|
| 365 |
-
c = Crew(agents=[agent], tasks=[task], verbose=False)
|
| 366 |
-
return (task_key, c.kickoff_async())
|
| 367 |
-
|
| 368 |
-
# 3 agents en parallèle (quality + metier matching + project analysis)
|
| 369 |
-
parallel_tasks = [
|
| 370 |
-
(
|
| 371 |
-
"cv_quality_task",
|
| 372 |
-
self.cv_quality_checker,
|
| 373 |
-
{
|
| 374 |
-
"cv_full_text": cv_full_text[:6000],
|
| 375 |
-
"cv_raw_start": safe_cv_raw,
|
| 376 |
-
"skills_with_context": json.dumps(skills_with_context, ensure_ascii=False)[:2000],
|
| 377 |
-
"experiences_summary": experiences_summary,
|
| 378 |
-
"projects_summary": projects_summary[:2000],
|
| 379 |
-
"niveau_seniorite": niveau_seniorite,
|
| 380 |
-
"reconversion_data": reconversion_data,
|
| 381 |
-
},
|
| 382 |
-
),
|
| 383 |
-
(
|
| 384 |
-
"metier_matching_task",
|
| 385 |
-
self.metier_matcher,
|
| 386 |
-
{
|
| 387 |
-
"poste_vise": poste_vise,
|
| 388 |
-
"hard_skills": json.dumps(hard_skills, ensure_ascii=False),
|
| 389 |
-
"soft_skills": json.dumps(soft_skills, ensure_ascii=False),
|
| 390 |
-
"skill_domains": json.dumps(skill_domains, ensure_ascii=False),
|
| 391 |
-
"methodologies": json.dumps(methodologies, ensure_ascii=False),
|
| 392 |
-
"experiences_summary": experiences_summary,
|
| 393 |
-
"projects_summary": projects_summary[:2000],
|
| 394 |
-
"reconversion_data": reconversion_data,
|
| 395 |
-
"metiers_reference": metiers_reference,
|
| 396 |
-
},
|
| 397 |
-
),
|
| 398 |
-
(
|
| 399 |
-
"project_analysis_task",
|
| 400 |
-
self.project_analyzer,
|
| 401 |
-
{
|
| 402 |
-
"poste_vise": poste_vise,
|
| 403 |
-
"metier_reference_detail": metier_reference_detail,
|
| 404 |
-
"professional_projects": professional_projects,
|
| 405 |
-
"personal_projects": personal_projects,
|
| 406 |
-
"reconversion_data": reconversion_data,
|
| 407 |
-
},
|
| 408 |
-
),
|
| 409 |
-
]
|
| 410 |
-
|
| 411 |
-
task_coroutines = [
|
| 412 |
-
create_task_async(key, agent, **kwargs) for key, agent, kwargs in parallel_tasks
|
| 413 |
-
]
|
| 414 |
-
keys = [t[0] for t in task_coroutines]
|
| 415 |
-
coroutines = [t[1] for t in task_coroutines]
|
| 416 |
-
results_list = await asyncio.gather(*coroutines, return_exceptions=True)
|
| 417 |
-
|
| 418 |
-
analysis_results = {}
|
| 419 |
-
for key, result in zip(keys, results_list):
|
| 420 |
-
if isinstance(result, Exception):
|
| 421 |
-
logger.error(f"Analysis task '{key}' failed: {result}")
|
| 422 |
-
else:
|
| 423 |
-
analysis_results[key] = result
|
| 424 |
-
|
| 425 |
-
recommendations = self._aggregate_recommendations(analysis_results, header_data)
|
| 426 |
-
|
| 427 |
-
# ── Filtre dur : ne garder que les projets issus de la section projets ──
|
| 428 |
-
extracted_titles: set[str] = set()
|
| 429 |
-
for p in projets.get("professional", []):
|
| 430 |
-
if isinstance(p, dict) and p.get("title"):
|
| 431 |
-
extracted_titles.add(p["title"].strip().lower())
|
| 432 |
-
for p in projets.get("personal", []):
|
| 433 |
-
if isinstance(p, dict) and p.get("title"):
|
| 434 |
-
extracted_titles.add(p["title"].strip().lower())
|
| 435 |
-
|
| 436 |
-
if extracted_titles:
|
| 437 |
-
def _is_extracted_project(titre: str) -> bool:
|
| 438 |
-
t = titre.strip().lower()
|
| 439 |
-
if t in extracted_titles:
|
| 440 |
-
return True
|
| 441 |
-
return any(t in ref or ref in t for ref in extracted_titles)
|
| 442 |
-
|
| 443 |
-
recommendations["analyse_projets"] = [
|
| 444 |
-
p for p in recommendations.get("analyse_projets", [])
|
| 445 |
-
if isinstance(p, dict) and _is_extracted_project(p.get("titre", ""))
|
| 446 |
-
]
|
| 447 |
-
logger.info(
|
| 448 |
-
f"Filtre projets : {len(recommendations['analyse_projets'])} projets conservés "
|
| 449 |
-
f"sur {len(extracted_titles)} extraits."
|
| 450 |
-
)
|
| 451 |
-
|
| 452 |
-
return recommendations
|
| 453 |
-
|
| 454 |
-
# ──────────────────────────────────────────────
|
| 455 |
-
# Mapping compétences -> domaines
|
| 456 |
-
# ──────────────────────────────────────────────
|
| 457 |
-
|
| 458 |
-
def _map_skills_to_domains(self, hard_skills: List[str]) -> Dict[str, List[str]]:
|
| 459 |
-
"""Mappe les compétences du candidat à leurs domaines métier."""
|
| 460 |
-
result = {}
|
| 461 |
-
for skill in hard_skills:
|
| 462 |
-
skill_lower = skill.lower().strip()
|
| 463 |
-
for domain, domain_skills in self.skill_domain_map.items():
|
| 464 |
-
if skill_lower in domain_skills:
|
| 465 |
-
if domain not in result:
|
| 466 |
-
result[domain] = []
|
| 467 |
-
result[domain].append(skill)
|
| 468 |
-
break
|
| 469 |
-
return result
|
| 470 |
-
|
| 471 |
-
def _prepare_metiers_for_prompt(self) -> str:
|
| 472 |
-
"""Prépare le référentiel métiers COMPLET (30 métiers) pour le prompt."""
|
| 473 |
-
lines = []
|
| 474 |
-
for m in self.metiers_data:
|
| 475 |
-
mid = m.get("id", "?")
|
| 476 |
-
nom = m.get("nom", "?")
|
| 477 |
-
cat = m.get("categorie", "?")
|
| 478 |
-
comp = m.get("competences_techniques", [])
|
| 479 |
-
outils = m.get("outils_technologies", [])
|
| 480 |
-
soft = m.get("competences_soft", [])
|
| 481 |
-
niveau = m.get("niveau_etude", "?")
|
| 482 |
-
exp = m.get("experience_requise", "?")
|
| 483 |
-
lines.append(
|
| 484 |
-
f"[{mid}] {nom} ({cat})\n"
|
| 485 |
-
f" Compétences techniques: {', '.join(comp)}\n"
|
| 486 |
-
f" Outils: {', '.join(outils)}\n"
|
| 487 |
-
f" Soft skills: {', '.join(soft[:3])}\n"
|
| 488 |
-
f" Niveau: {niveau} | Expérience: {exp}"
|
| 489 |
-
)
|
| 490 |
-
return "\n\n".join(lines)
|
| 491 |
-
|
| 492 |
-
def _get_metier_reference_for_poste(self, poste_vise: str) -> str:
|
| 493 |
-
"""Trouve les métiers les plus proches du poste visé pour contextualiser l'analyse de projets."""
|
| 494 |
-
if not poste_vise or poste_vise == "Non identifié":
|
| 495 |
-
return "Aucun métier de référence spécifique. Analyser les projets selon leur qualité intrinsèque."
|
| 496 |
-
|
| 497 |
-
poste_lower = poste_vise.lower()
|
| 498 |
-
scored = []
|
| 499 |
-
|
| 500 |
-
for m in self.metiers_data:
|
| 501 |
-
nom_lower = m.get("nom", "").lower()
|
| 502 |
-
id_lower = m.get("id", "").lower()
|
| 503 |
-
desc_lower = m.get("description", "").lower()
|
| 504 |
-
score = 0
|
| 505 |
-
|
| 506 |
-
keywords = [w for w in poste_lower.replace("/", " ").replace("-", " ").split() if len(w) > 2]
|
| 507 |
-
for kw in keywords:
|
| 508 |
-
if kw in nom_lower:
|
| 509 |
-
score += 3
|
| 510 |
-
if kw in id_lower:
|
| 511 |
-
score += 2
|
| 512 |
-
if kw in desc_lower:
|
| 513 |
-
score += 1
|
| 514 |
-
|
| 515 |
-
nom_keywords = [w for w in nom_lower.replace("/", " ").replace("-", " ").split() if len(w) > 2]
|
| 516 |
-
for kw in nom_keywords:
|
| 517 |
-
if kw in poste_lower:
|
| 518 |
-
score += 3
|
| 519 |
-
|
| 520 |
-
if score > 0:
|
| 521 |
-
scored.append((score, m))
|
| 522 |
-
|
| 523 |
-
scored.sort(key=lambda x: -x[0])
|
| 524 |
-
|
| 525 |
-
if not scored:
|
| 526 |
-
return "Poste visé non trouvé dans le référentiel. Analyser les projets selon leur qualité intrinsèque."
|
| 527 |
-
|
| 528 |
-
lines = ["Métier(s) de référence les plus proches du poste visé :"]
|
| 529 |
-
for _, m in scored[:3]:
|
| 530 |
-
mid = m.get("id")
|
| 531 |
-
nom = m.get("nom")
|
| 532 |
-
comp = m.get("competences_techniques", [])
|
| 533 |
-
outils = m.get("outils_technologies", [])
|
| 534 |
-
missions = m.get("missions_principales", [])
|
| 535 |
-
lines.append(
|
| 536 |
-
f"\n[{mid}] {nom}\n"
|
| 537 |
-
f" Compétences attendues: {', '.join(comp)}\n"
|
| 538 |
-
f" Outils attendus: {', '.join(outils)}\n"
|
| 539 |
-
f" Missions principales: {'; '.join(missions[:3])}"
|
| 540 |
-
)
|
| 541 |
-
return "\n".join(lines)
|
| 542 |
-
|
| 543 |
-
def _extract_methodologies(self, hard_skills: List[str], skill_domains: Dict[str, List[str]]) -> List[str]:
|
| 544 |
-
"""Extrait les méthodologies de travail du candidat."""
|
| 545 |
-
methodology_keywords = {
|
| 546 |
-
"agile", "scrum", "kanban", "devops", "ci/cd", "cicd", "tdd", "bdd",
|
| 547 |
-
"design thinking", "lean", "safe", "xp", "pair programming",
|
| 548 |
-
"code review", "sprint", "product owner", "scrum master",
|
| 549 |
-
"rgpd", "rgaa",
|
| 550 |
-
}
|
| 551 |
-
|
| 552 |
-
methodologies = []
|
| 553 |
-
for skill in hard_skills:
|
| 554 |
-
if skill.lower().strip() in methodology_keywords:
|
| 555 |
-
methodologies.append(skill)
|
| 556 |
-
|
| 557 |
-
if "gestion_projet" in skill_domains:
|
| 558 |
-
for skill in skill_domains["gestion_projet"]:
|
| 559 |
-
if skill not in methodologies:
|
| 560 |
-
methodologies.append(skill)
|
| 561 |
-
|
| 562 |
-
if "devops" in skill_domains:
|
| 563 |
-
for skill in skill_domains["devops"]:
|
| 564 |
-
s = skill.lower()
|
| 565 |
-
if any(kw in s for kw in ["ci", "cd", "github actions", "gitlab ci"]):
|
| 566 |
-
if skill not in methodologies:
|
| 567 |
-
methodologies.append(skill)
|
| 568 |
-
|
| 569 |
-
return methodologies
|
| 570 |
-
|
| 571 |
-
# ──────────────────────────────────────────────
|
| 572 |
-
# Agrégation des résultats d'extraction (Phase 2)
|
| 573 |
-
# ──────────────────────────────────────────────
|
| 574 |
-
|
| 575 |
-
def _aggregate_extraction_results(self, results_map: Dict[str, Any]) -> Dict[str, Any]:
|
| 576 |
-
"""Agrège les résultats d'extraction (identique au module existant)."""
|
| 577 |
|
|
|
|
|
|
|
|
|
|
| 578 |
def get_parsed(key, default=None):
|
| 579 |
if key not in results_map:
|
| 580 |
return default
|
| 581 |
return self._parse_json_output(results_map[key], default)
|
| 582 |
|
|
|
|
| 583 |
competences = get_parsed("skills_task", {"hard_skills": [], "soft_skills": []})
|
| 584 |
experiences = get_parsed("experience_task", [])
|
| 585 |
projets = get_parsed("project_task", {"professional": [], "personal": []})
|
| 586 |
formations = get_parsed("education_task", [])
|
| 587 |
-
reconversion = get_parsed("reconversion_task", {}).get(
|
| 588 |
-
"reconversion_analysis", {}
|
| 589 |
-
)
|
| 590 |
etudiant_data = get_parsed("etudiant_task", {}).get("etudiant_analysis", {})
|
|
|
|
| 591 |
latest_end_date = etudiant_data.get("latest_education_end_date")
|
| 592 |
if latest_end_date:
|
| 593 |
-
|
| 594 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 595 |
|
| 596 |
langues_raw = get_parsed("language_task", {})
|
|
|
|
| 597 |
|
|
|
|
| 598 |
if isinstance(competences, dict):
|
| 599 |
raw_skills = competences.get("hard_skills", [])
|
| 600 |
seen = set()
|
| 601 |
unique_skills = []
|
| 602 |
for skill in raw_skills:
|
| 603 |
-
key = (
|
| 604 |
-
str(skill).lower()
|
| 605 |
-
if not isinstance(skill, str)
|
| 606 |
-
else skill.lower()
|
| 607 |
-
)
|
| 608 |
if key not in seen:
|
| 609 |
seen.add(key)
|
| 610 |
unique_skills.append(skill)
|
| 611 |
competences["hard_skills"] = unique_skills
|
| 612 |
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
"
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
"expériences": experiences,
|
| 624 |
-
"reconversion": reconversion,
|
| 625 |
-
"projets": projets,
|
| 626 |
-
"formations": formations,
|
| 627 |
-
"etudiant": etudiant_data,
|
| 628 |
-
"langues": (
|
| 629 |
-
langues_raw.get("langues", [])
|
| 630 |
-
if isinstance(langues_raw, dict)
|
| 631 |
-
else []
|
| 632 |
-
),
|
| 633 |
-
}
|
| 634 |
}
|
| 635 |
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
# ──────────────────────────────────────────────
|
| 639 |
-
|
| 640 |
-
def _aggregate_recommendations(
|
| 641 |
-
self,
|
| 642 |
-
analysis_results: Dict[str, Any],
|
| 643 |
-
header_data: Dict,
|
| 644 |
-
) -> Dict[str, Any]:
|
| 645 |
-
"""Agrège les résultats d'analyse en un objet recommandations structuré."""
|
| 646 |
-
|
| 647 |
-
def get_parsed(key, default=None):
|
| 648 |
-
if key not in analysis_results:
|
| 649 |
-
return default
|
| 650 |
-
return self._parse_json_output(analysis_results[key], default)
|
| 651 |
-
|
| 652 |
metier_data = get_parsed("metier_matching_task", {"postes_recommandes": []})
|
| 653 |
-
quality_data = get_parsed(
|
| 654 |
-
"cv_quality_task",
|
| 655 |
-
{"score_global": 0, "red_flags": [], "conseils_prioritaires": []},
|
| 656 |
-
)
|
| 657 |
project_data = get_parsed("project_analysis_task", {"analyse_projets": []})
|
| 658 |
|
| 659 |
-
# Conseils d'amélioration : uniquement les conseils qualité CV
|
| 660 |
conseils = []
|
| 661 |
if isinstance(quality_data, dict):
|
| 662 |
conseils.extend(quality_data.get("conseils_prioritaires", []))
|
| 663 |
|
| 664 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 665 |
"header_analysis": header_data,
|
| 666 |
-
"postes_recommandes": (
|
| 667 |
-
|
| 668 |
-
if isinstance(metier_data, dict)
|
| 669 |
-
else []
|
| 670 |
-
),
|
| 671 |
-
"analyse_poste_vise": (
|
| 672 |
-
metier_data.get("analyse_poste_vise", "")
|
| 673 |
-
if isinstance(metier_data, dict)
|
| 674 |
-
else ""
|
| 675 |
-
),
|
| 676 |
"qualite_cv": quality_data,
|
| 677 |
-
"analyse_projets":
|
| 678 |
-
|
| 679 |
-
if isinstance(project_data, dict)
|
| 680 |
-
else []
|
| 681 |
-
),
|
| 682 |
-
"coherence_globale_projets": (
|
| 683 |
-
project_data.get("coherence_globale", {})
|
| 684 |
-
if isinstance(project_data, dict)
|
| 685 |
-
else {}
|
| 686 |
-
),
|
| 687 |
"conseils_amelioration": conseils,
|
| 688 |
}
|
| 689 |
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
def _fallback_extract_poste_vise(
|
| 695 |
-
self, cv_full_text: str, header_section: str
|
| 696 |
-
) -> str:
|
| 697 |
-
"""Extraction programmatique du poste visé en fallback.
|
| 698 |
-
|
| 699 |
-
Cherche la ligne de titre dans l'en-tête du CV en filtrant les lignes
|
| 700 |
-
qui ne sont clairement PAS un titre de poste (email, téléphone, liens,
|
| 701 |
-
titres de section, compétences techniques).
|
| 702 |
-
"""
|
| 703 |
-
import re
|
| 704 |
-
|
| 705 |
-
# Patterns qui NE sont PAS un titre de poste
|
| 706 |
-
skip_patterns = [
|
| 707 |
-
r"^#{1,6}\s", # Titres markdown
|
| 708 |
-
r"@", # Email
|
| 709 |
-
r"^\+?\d[\d\s\-\.]{7,}", # Téléphone
|
| 710 |
-
r"^http|^www\.|linkedin|github", # URLs/liens
|
| 711 |
-
r"^\*{1,3}[A-Z]", # Bold section headers
|
| 712 |
-
r"^(CONTACT|LIENS|STACK|LANGUES|CENTRES|EXPERIENCE|FORMATION|PROJET|COMPÉTENCES|EDUCATION)", # Section headings
|
| 713 |
-
r"^(Python|SQL|JavaScript|React|FastAPI|Docker|AWS|Git|CI)", # Skills
|
| 714 |
-
r"^(Ile-de-France|Paris|Lyon|Marseille|France)", # Locations
|
| 715 |
-
r"^\d{2}\s?\d{2}\s?\d{2}", # Phone numbers
|
| 716 |
-
r"^(Français|Anglais|Portugais|Espagnol)", # Languages
|
| 717 |
-
r"^(Langages|Frameworks|Analytics|DevOps|Méthodologies|IA &|BI :)", # Skill categories
|
| 718 |
-
r"^(Blockchain|Jeux de rôle|Randonnée)", # Interests
|
| 719 |
-
r"^\s*$", # Empty lines
|
| 720 |
-
r"^[\*\-\|]", # List items and table separators
|
| 721 |
-
]
|
| 722 |
-
|
| 723 |
-
# Mots-clés qui INDIQUENT un titre de poste
|
| 724 |
-
title_indicators = [
|
| 725 |
-
"développeur", "developer", "ingénieur", "engineer", "chef de projet",
|
| 726 |
-
"data analyst", "data scientist", "data engineer", "consultant",
|
| 727 |
-
"architecte", "manager", "lead", "senior", "junior", "fullstack",
|
| 728 |
-
"full-stack", "full stack", "backend", "frontend", "devops",
|
| 729 |
-
"product", "project", "spécialiste", "expert", "analyste",
|
| 730 |
-
"mlops", "ai", "ia", "machine learning", "nlp", "deep learning",
|
| 731 |
-
]
|
| 732 |
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 742 |
|
| 743 |
-
def _is_likely_title(line: str) -> bool:
|
| 744 |
-
stripped = line.strip().strip("#*_ ")
|
| 745 |
-
if len(line.split()) > 10:
|
| 746 |
-
return False
|
| 747 |
-
for pattern in skip_patterns:
|
| 748 |
-
if re.match(pattern, stripped, re.IGNORECASE):
|
| 749 |
-
return False
|
| 750 |
-
return _has_title_indicator(stripped.lower())
|
| 751 |
-
|
| 752 |
-
# Chercher dans toutes les sources, par ordre de priorité
|
| 753 |
-
sources = [
|
| 754 |
-
("header", header_section),
|
| 755 |
-
("cv_text", cv_full_text[:3000]),
|
| 756 |
-
]
|
| 757 |
|
| 758 |
-
for source_name, text in sources:
|
| 759 |
-
if not text:
|
| 760 |
-
continue
|
| 761 |
-
lines = text.split("\n")
|
| 762 |
-
for line in lines:
|
| 763 |
-
if _is_likely_title(line):
|
| 764 |
-
clean = line.strip().strip("#*_ ")
|
| 765 |
-
logger.info(f"Fallback: found title in {source_name}: '{clean}'")
|
| 766 |
-
return clean
|
| 767 |
|
| 768 |
-
|
|
|
|
|
|
|
| 769 |
|
| 770 |
-
def
|
| 771 |
-
"""Détermine si
|
| 772 |
if not date_str:
|
| 773 |
return False
|
| 774 |
date_str = str(date_str).lower().strip()
|
|
|
|
| 24 |
|
| 25 |
#_____________________________________________________________________________________
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
class CVAgentOrchestrator:
|
| 28 |
"""Orchestrateur multi-agents pour le parsing et l'analyse de CV."""
|
| 29 |
|
|
|
|
| 33 |
self.agents_config = self._load_yaml("agents.yaml")
|
| 34 |
self.tasks_config = self._load_yaml("tasks.yaml")
|
| 35 |
self.metiers_data = self._load_metiers()
|
|
|
|
| 36 |
self._create_agents()
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
def _load_yaml(self, filename: str) -> Dict:
|
| 39 |
base_path = os.path.dirname(os.path.dirname(__file__))
|
| 40 |
config_path = os.path.join(base_path, "config", filename)
|
|
|
|
| 42 |
return yaml.safe_load(f)
|
| 43 |
|
| 44 |
def _load_metiers(self) -> List[Dict]:
|
| 45 |
+
"""Charge le référentiel de métiers (avec embeddings)."""
|
| 46 |
base_path = os.path.dirname(os.path.dirname(__file__))
|
| 47 |
metiers_path = os.path.join(base_path, "data", "metiers.json")
|
| 48 |
with open(metiers_path, "r", encoding="utf-8") as f:
|
| 49 |
data = json.load(f)
|
| 50 |
+
return data.get("metiers", [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
def _create_agents(self):
|
| 53 |
def make_agent(name, llm_override=None):
|
|
|
|
| 58 |
verbose=True,
|
| 59 |
max_iter=1,
|
| 60 |
respect_context_window=True,
|
|
|
|
|
|
|
| 61 |
)
|
| 62 |
|
| 63 |
+
self.cv_splitter = make_agent("cv_splitter")
|
|
|
|
| 64 |
self.skills_extractor = make_agent("skills_extractor")
|
| 65 |
self.experience_extractor = make_agent("experience_extractor")
|
| 66 |
self.project_extractor = make_agent("project_extractor")
|
|
|
|
| 70 |
self.etudiant_detector = make_agent("etudiant_detector")
|
| 71 |
self.identity_extractor = make_agent("identity_extractor")
|
| 72 |
|
| 73 |
+
self.header_analyzer = make_agent("header_analyzer")
|
| 74 |
+
self.metier_matcher = make_agent("metier_matcher")
|
| 75 |
+
self.cv_quality_checker = make_agent("cv_quality_checker", llm_override=self.big_llm)
|
| 76 |
+
self.project_analyzer = make_agent("project_analyzer", llm_override=self.big_llm)
|
|
|
|
| 77 |
|
| 78 |
# ──────────────────────────────────────────────
|
| 79 |
# PHASE 1 : Découpage du CV en sections
|
|
|
|
| 102 |
return parsed
|
| 103 |
|
| 104 |
# ──────────────────────────────────────────────
|
| 105 |
+
# PHASE 2 : Extraction et Analyse Parallèles
|
| 106 |
# ──────────────────────────────────────────────
|
| 107 |
|
| 108 |
+
async def run_all_agents(
|
| 109 |
+
self, sections: Dict[str, str], cv_raw_start: str = "", cv_full_text: str = "", file_name: str = "", page_count: int = 1
|
| 110 |
) -> Dict[str, Any]:
|
| 111 |
+
"""Exécute toutes les tâches d'extraction et d'analyse en parallèle."""
|
| 112 |
+
|
| 113 |
+
raw_header = sections.get("header", "")
|
| 114 |
+
raw_experiences = sections.get("experiences", "")
|
| 115 |
+
raw_projects = sections.get("projects", "")
|
| 116 |
+
raw_skills = sections.get("skills", "")
|
| 117 |
+
raw_education = sections.get("education", "")
|
| 118 |
+
raw_languages = sections.get("languages", "")
|
| 119 |
+
safe_cv_raw = cv_raw_start[:2000].replace("{", "{{").replace("}", "}}")
|
| 120 |
+
safe_header = raw_header.replace("{", "{{").replace("}", "}}")
|
| 121 |
+
|
| 122 |
+
from src.services.metier_pre_filter import get_top_k_metiers
|
| 123 |
+
top_metiers = get_top_k_metiers(
|
| 124 |
+
metiers_data=self.metiers_data,
|
| 125 |
+
experiences_summary=raw_experiences[:2000],
|
| 126 |
+
projects_summary=raw_projects[:2000],
|
| 127 |
+
hard_skills=raw_skills[:2000],
|
| 128 |
+
soft_skills="",
|
| 129 |
+
k=3
|
| 130 |
+
)
|
| 131 |
+
metiers_reference = self._prepare_metiers_for_prompt(top_metiers)
|
| 132 |
|
| 133 |
def create_task_async(task_key, agent, **kwargs):
|
| 134 |
t_config = self.tasks_config[task_key].copy()
|
| 135 |
t_description = t_config["description"]
|
|
|
|
|
|
|
| 136 |
try:
|
|
|
|
| 137 |
t_config["description"] = t_description.format(**kwargs)
|
| 138 |
except KeyError as e:
|
| 139 |
logger.warning(f"KeyError formatting task '{task_key}': {e}. Falling back to manual replace.")
|
|
|
|
| 140 |
desc = t_description
|
| 141 |
for k, v in kwargs.items():
|
| 142 |
placeholder = "{" + k + "}"
|
|
|
|
| 151 |
return (task_key, c.kickoff_async())
|
| 152 |
|
| 153 |
tasks_def = [
|
| 154 |
+
("skills_task", self.skills_extractor, {"experiences": raw_experiences, "projects": raw_projects, "skills": raw_skills, "education": raw_education}),
|
| 155 |
+
("experience_task", self.experience_extractor, {"experiences": raw_experiences}),
|
| 156 |
+
("project_task", self.project_extractor, {"projects": raw_projects}),
|
| 157 |
+
("education_task", self.education_extractor, {"education": raw_education}),
|
| 158 |
+
("reconversion_task", self.reconversion_detector, {"experiences": raw_experiences, "education": raw_education}),
|
| 159 |
+
("language_task", self.language_extractor, {"languages": raw_languages, "cv_raw_start": cv_raw_start[:500]}),
|
| 160 |
+
("etudiant_task", self.etudiant_detector, {"education": raw_education, "current_date": datetime.now().strftime("%Y-%m-%d")}),
|
| 161 |
+
("identity_task", self.identity_extractor, {"header": raw_header, "cv_raw_start": cv_raw_start[:1500], "file_name": file_name}),
|
| 162 |
+
("poste_visé_task", self.header_analyzer, {"header": safe_header, "cv_raw_start": safe_cv_raw}),
|
| 163 |
+
("cv_quality_task", self.cv_quality_checker, {
|
| 164 |
+
"header": safe_header,
|
| 165 |
+
"page_count": page_count,
|
| 166 |
+
"cv_full_text": cv_full_text[:6000],
|
| 167 |
+
"cv_raw_start": safe_cv_raw,
|
| 168 |
+
"skills": raw_skills[:2000],
|
| 169 |
+
"experiences": raw_experiences[:3000],
|
| 170 |
+
"projects": raw_projects[:2000],
|
| 171 |
+
"education": raw_education[:2000],
|
| 172 |
+
}),
|
| 173 |
+
("metier_matching_task", self.metier_matcher, {
|
| 174 |
+
"header": safe_header,
|
| 175 |
+
"skills": raw_skills[:2000],
|
| 176 |
+
"experiences": raw_experiences[:3000],
|
| 177 |
+
"projects": raw_projects[:2000],
|
| 178 |
+
"education": raw_education[:2000],
|
| 179 |
+
"metiers_reference": metiers_reference,
|
| 180 |
+
}),
|
| 181 |
+
("project_analysis_task", self.project_analyzer, {
|
| 182 |
+
"header": safe_header,
|
| 183 |
+
"projects": raw_projects[:3000],
|
| 184 |
+
}),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
]
|
| 186 |
|
| 187 |
+
task_coroutines = [create_task_async(key, agent, **kwargs) for key, agent, kwargs in tasks_def]
|
|
|
|
|
|
|
| 188 |
keys = [t[0] for t in task_coroutines]
|
| 189 |
coroutines = [t[1] for t in task_coroutines]
|
| 190 |
results_list = await asyncio.gather(*coroutines, return_exceptions=True)
|
|
|
|
| 196 |
else:
|
| 197 |
results_map[key] = result
|
| 198 |
|
| 199 |
+
return self._build_final_json(results_map)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
|
| 201 |
+
def _build_final_json(self, results_map: Dict[str, Any]) -> Dict[str, Any]:
|
| 202 |
+
"""Agrège les résultats de toutes les tâches en un JSON final."""
|
| 203 |
+
|
| 204 |
def get_parsed(key, default=None):
|
| 205 |
if key not in results_map:
|
| 206 |
return default
|
| 207 |
return self._parse_json_output(results_map[key], default)
|
| 208 |
|
| 209 |
+
# Extraction
|
| 210 |
competences = get_parsed("skills_task", {"hard_skills": [], "soft_skills": []})
|
| 211 |
experiences = get_parsed("experience_task", [])
|
| 212 |
projets = get_parsed("project_task", {"professional": [], "personal": []})
|
| 213 |
formations = get_parsed("education_task", [])
|
| 214 |
+
reconversion = get_parsed("reconversion_task", {}).get("reconversion_analysis", {})
|
|
|
|
|
|
|
| 215 |
etudiant_data = get_parsed("etudiant_task", {}).get("etudiant_analysis", {})
|
| 216 |
+
|
| 217 |
latest_end_date = etudiant_data.get("latest_education_end_date")
|
| 218 |
if latest_end_date:
|
| 219 |
+
etudiant_data["is_etudiant"] = self._is_ongoing_date(latest_end_date)
|
| 220 |
+
|
| 221 |
+
is_en_poste = False
|
| 222 |
+
if isinstance(experiences, list):
|
| 223 |
+
for exp in experiences:
|
| 224 |
+
end_date = exp.get("end_date")
|
| 225 |
+
if isinstance(exp, dict) and end_date:
|
| 226 |
+
if self._is_ongoing_date(end_date):
|
| 227 |
+
is_en_poste = True
|
| 228 |
+
break
|
| 229 |
|
| 230 |
langues_raw = get_parsed("language_task", {})
|
| 231 |
+
identity = get_parsed("identity_task", {})
|
| 232 |
|
| 233 |
+
# Nettoyage des doublons dans hard_skills (case-insensitive)
|
| 234 |
if isinstance(competences, dict):
|
| 235 |
raw_skills = competences.get("hard_skills", [])
|
| 236 |
seen = set()
|
| 237 |
unique_skills = []
|
| 238 |
for skill in raw_skills:
|
| 239 |
+
key = str(skill).lower() if not isinstance(skill, str) else skill.lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
if key not in seen:
|
| 241 |
seen.add(key)
|
| 242 |
unique_skills.append(skill)
|
| 243 |
competences["hard_skills"] = unique_skills
|
| 244 |
|
| 245 |
+
candidat = {
|
| 246 |
+
"first_name": identity.get("first_name") if isinstance(identity, dict) else None,
|
| 247 |
+
"langues": langues_raw.get("langues", []) if isinstance(langues_raw, dict) else [],
|
| 248 |
+
"compétences": competences,
|
| 249 |
+
"expériences": experiences,
|
| 250 |
+
"reconversion": reconversion,
|
| 251 |
+
"projets": projets,
|
| 252 |
+
"formations": formations,
|
| 253 |
+
"etudiant": etudiant_data,
|
| 254 |
+
"is_en_poste": is_en_poste,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
}
|
| 256 |
|
| 257 |
+
# Analyse
|
| 258 |
+
header_data = get_parsed("poste_visé_task", {"poste_vise": "Non identifié", "confiance": 0})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
metier_data = get_parsed("metier_matching_task", {"postes_recommandes": []})
|
| 260 |
+
quality_data = get_parsed("cv_quality_task", {"score_global": 0, "red_flags": [], "conseils_prioritaires": []})
|
|
|
|
|
|
|
|
|
|
| 261 |
project_data = get_parsed("project_analysis_task", {"analyse_projets": []})
|
| 262 |
|
|
|
|
| 263 |
conseils = []
|
| 264 |
if isinstance(quality_data, dict):
|
| 265 |
conseils.extend(quality_data.get("conseils_prioritaires", []))
|
| 266 |
|
| 267 |
+
# Filtre de sécurité : ne garder dans l'analyse de projets que ceux issus de l'extraction
|
| 268 |
+
extracted_titles: set[str] = set()
|
| 269 |
+
for p in (projets.get("professional", []) if isinstance(projets, dict) else []):
|
| 270 |
+
if isinstance(p, dict) and p.get("title"):
|
| 271 |
+
extracted_titles.add(p["title"].strip().lower())
|
| 272 |
+
for p in (projets.get("personal", []) if isinstance(projets, dict) else []):
|
| 273 |
+
if isinstance(p, dict) and p.get("title"):
|
| 274 |
+
extracted_titles.add(p["title"].strip().lower())
|
| 275 |
+
|
| 276 |
+
analyse_projets = project_data.get("analyse_projets", []) if isinstance(project_data, dict) else []
|
| 277 |
+
if extracted_titles and isinstance(analyse_projets, list):
|
| 278 |
+
def _is_extracted_project(titre: str) -> bool:
|
| 279 |
+
t = titre.strip().lower()
|
| 280 |
+
return t in extracted_titles or any(t in ref or ref in t for ref in extracted_titles)
|
| 281 |
+
|
| 282 |
+
analyse_projets = [p for p in analyse_projets if isinstance(p, dict) and _is_extracted_project(p.get("titre", ""))]
|
| 283 |
+
|
| 284 |
+
recommandations = {
|
| 285 |
"header_analysis": header_data,
|
| 286 |
+
"postes_recommandes": metier_data.get("postes_recommandes", []) if isinstance(metier_data, dict) else [],
|
| 287 |
+
"analyse_poste_vise": metier_data.get("analyse_poste_vise", "") if isinstance(metier_data, dict) else "",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
"qualite_cv": quality_data,
|
| 289 |
+
"analyse_projets": analyse_projets,
|
| 290 |
+
"coherence_globale_projets": project_data.get("coherence_globale", {}) if isinstance(project_data, dict) else {},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
"conseils_amelioration": conseils,
|
| 292 |
}
|
| 293 |
|
| 294 |
+
return {
|
| 295 |
+
"candidat": candidat,
|
| 296 |
+
"recommandations": recommandations
|
| 297 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
|
| 299 |
+
def _prepare_metiers_for_prompt(self, metiers: List[Dict] = None) -> str:
|
| 300 |
+
"""Prépare le référentiel métiers restreint pour le prompt."""
|
| 301 |
+
if metiers is None:
|
| 302 |
+
metiers = self.metiers_data
|
| 303 |
+
|
| 304 |
+
flat_list = []
|
| 305 |
+
def _flatten(job_list):
|
| 306 |
+
for job in job_list:
|
| 307 |
+
if "metiers" in job:
|
| 308 |
+
_flatten(job["metiers"])
|
| 309 |
+
elif "id" in job:
|
| 310 |
+
flat_list.append(job)
|
| 311 |
+
_flatten(metiers)
|
| 312 |
+
|
| 313 |
+
lines = []
|
| 314 |
+
for m in flat_list:
|
| 315 |
+
mid = m.get("id", "?")
|
| 316 |
+
nom = m.get("nom", "?")
|
| 317 |
+
cat = m.get("categorie", "?")
|
| 318 |
+
comp = m.get("competences_techniques", [])
|
| 319 |
+
outils = m.get("outils_technologies", [])
|
| 320 |
+
soft = m.get("competences_soft", [])
|
| 321 |
+
niveau = m.get("niveau_etude", "?")
|
| 322 |
+
exp = m.get("experience_requise", "?")
|
| 323 |
+
lines.append(
|
| 324 |
+
f"[{mid}] {nom} ({cat})\n"
|
| 325 |
+
f" Compétences techniques: {', '.join(comp)}\n"
|
| 326 |
+
f" Outils: {', '.join(outils)}\n"
|
| 327 |
+
f" Soft skills: {', '.join(soft[:3])}\n"
|
| 328 |
+
f" Niveau: {niveau} | Expérience: {exp}"
|
| 329 |
+
)
|
| 330 |
+
return "\n\n".join(lines)
|
| 331 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 332 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
|
| 334 |
+
# ──────────────────────────────────────────────
|
| 335 |
+
# Utilitaires
|
| 336 |
+
# ──────────────────────────────────────────────
|
| 337 |
|
| 338 |
+
def _is_ongoing_date(self, date_str: str) -> bool:
|
| 339 |
+
"""Détermine si une date (fin d'étude ou fin d'expérience) est dans le futur ou en cours."""
|
| 340 |
if not date_str:
|
| 341 |
return False
|
| 342 |
date_str = str(date_str).lower().strip()
|
src/scripts/embed_metiers.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
import sys
|
| 4 |
+
|
| 5 |
+
# Add src's parent directory to path so we can run this directly if needed
|
| 6 |
+
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
| 7 |
+
|
| 8 |
+
from langchain_openai import OpenAIEmbeddings
|
| 9 |
+
from dotenv import load_dotenv
|
| 10 |
+
|
| 11 |
+
load_dotenv()
|
| 12 |
+
|
| 13 |
+
def get_job_text(job):
|
| 14 |
+
"""
|
| 15 |
+
Formats job info using the "Miroir" strategy.
|
| 16 |
+
"""
|
| 17 |
+
missions = ", ".join(job.get("missions_principales", []))
|
| 18 |
+
tech = ", ".join(job.get("competences_techniques", []))
|
| 19 |
+
outils = ", ".join(job.get("outils_technologies", []))
|
| 20 |
+
soft = ", ".join(job.get("competences_soft", []))
|
| 21 |
+
|
| 22 |
+
text = f"MISSIONS: {missions}\n"
|
| 23 |
+
text += f"TECH_ET_OUTILS: {tech}, {outils}\n"
|
| 24 |
+
text += f"SOFT_SKILLS: {soft}"
|
| 25 |
+
return text
|
| 26 |
+
|
| 27 |
+
def embed_metiers_file():
|
| 28 |
+
base_path = os.path.dirname(os.path.dirname(__file__))
|
| 29 |
+
metiers_path = os.path.join(base_path, "data", "metiers.json")
|
| 30 |
+
|
| 31 |
+
print(f"Loading {metiers_path}...")
|
| 32 |
+
with open(metiers_path, "r", encoding="utf-8") as f:
|
| 33 |
+
data = json.load(f)
|
| 34 |
+
|
| 35 |
+
embeddings_model = OpenAIEmbeddings(model="text-embedding-3-small")
|
| 36 |
+
|
| 37 |
+
count = 0
|
| 38 |
+
def process_jobs(job_list):
|
| 39 |
+
nonlocal count
|
| 40 |
+
for job in job_list:
|
| 41 |
+
if "metiers" in job:
|
| 42 |
+
process_jobs(job["metiers"])
|
| 43 |
+
elif "id" in job:
|
| 44 |
+
print(f"Embedding {job.get('id')}...")
|
| 45 |
+
text = get_job_text(job)
|
| 46 |
+
emb = embeddings_model.embed_query(text)
|
| 47 |
+
job["embedding"] = emb
|
| 48 |
+
count += 1
|
| 49 |
+
|
| 50 |
+
process_jobs(data.get("metiers", []))
|
| 51 |
+
|
| 52 |
+
print(f"Writing {count} embeddings to {metiers_path}...")
|
| 53 |
+
with open(metiers_path, "w", encoding="utf-8") as f:
|
| 54 |
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
| 55 |
+
|
| 56 |
+
print("Done!")
|
| 57 |
+
|
| 58 |
+
if __name__ == "__main__":
|
| 59 |
+
embed_metiers_file()
|
src/services/cv_service.py
CHANGED
|
@@ -1,20 +1,16 @@
|
|
| 1 |
"""
|
| 2 |
Service de parsing et analyse de CV enrichi.
|
| 3 |
-
Pipeline optimisé :
|
| 4 |
-
Phase 1
|
| 5 |
-
Phase 2
|
| 6 |
-
Phase 3a : Analyse d'en-tête (header_analyzer) — en // avec Phase 2
|
| 7 |
-
Phase 3b : Analyse & Recommandation (3 agents parallèles)
|
| 8 |
|
| 9 |
-
|
| 10 |
-
Gain estimé : ~5-8 secondes vs pipeline séquentiel précédent.
|
| 11 |
"""
|
| 12 |
|
| 13 |
-
import asyncio
|
| 14 |
import logging
|
| 15 |
from typing import Dict, Any
|
| 16 |
|
| 17 |
-
from src.config.app_config import load_pdf, load_pdf_first_page_text
|
| 18 |
from src.parser_flow.CV_agent_flow import CVAgentOrchestrator
|
| 19 |
|
| 20 |
logger = logging.getLogger(__name__)
|
|
@@ -22,65 +18,28 @@ logger = logging.getLogger(__name__)
|
|
| 22 |
|
| 23 |
async def parse_cv(pdf_path: str, file_name: str = "") -> Dict[str, Any]:
|
| 24 |
"""
|
| 25 |
-
Parse un CV avec extraction + analyse complète.
|
| 26 |
|
| 27 |
Retourne un JSON en 2 parties :
|
| 28 |
-
- candidat : données parsées du CV (identité, compétences, expériences, projets
|
| 29 |
-
- recommandations : analyse critique, matching métiers, qualité CV
|
| 30 |
"""
|
| 31 |
orchestrator = CVAgentOrchestrator()
|
| 32 |
-
|
| 33 |
-
# Double extraction PDF :
|
| 34 |
-
# - cv_text : Markdown (bon pour la structure des sections)
|
| 35 |
-
# - cv_raw_start : texte brut ordonné par position (fiable pour le header/nom/titre)
|
| 36 |
cv_text = load_pdf(pdf_path)
|
| 37 |
cv_raw_start = load_pdf_first_page_text(pdf_path)
|
|
|
|
| 38 |
|
| 39 |
-
# ── Phase 1 : Découpage du CV en sections (séquentielle, nécessaire pour la suite) ──
|
| 40 |
logger.info("Phase 1 : Découpage du CV en sections...")
|
| 41 |
sections = await orchestrator.split_cv_sections(cv_text, cv_raw_start=cv_raw_start)
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
logger.info("Phase 2 + Phase 3a : Extraction et analyse d'en-tête en parallèle...")
|
| 47 |
-
extraction, header_data = await asyncio.gather(
|
| 48 |
-
orchestrator.extract_all_sections(
|
| 49 |
-
sections, cv_raw_start=cv_raw_start, file_name=file_name
|
| 50 |
-
),
|
| 51 |
-
orchestrator.run_header_analysis(
|
| 52 |
-
sections, cv_raw_start=cv_raw_start, cv_full_text=cv_text
|
| 53 |
-
),
|
| 54 |
-
)
|
| 55 |
-
|
| 56 |
-
# ── Phase 3b : 3 agents d'analyse en parallèle ───────────────────────────────────────
|
| 57 |
-
logger.info("Phase 3b : Analyse et recommandation...")
|
| 58 |
-
recommendations = await orchestrator.analyze_and_recommend(
|
| 59 |
-
cv_full_text=cv_text,
|
| 60 |
-
sections=sections,
|
| 61 |
-
extraction=extraction,
|
| 62 |
cv_raw_start=cv_raw_start,
|
| 63 |
-
|
|
|
|
|
|
|
| 64 |
)
|
| 65 |
|
| 66 |
-
candidat_raw = extraction.get("candidat", {})
|
| 67 |
-
|
| 68 |
-
# Assemblage ordonné : identité → langues → compétences → parcours
|
| 69 |
-
candidat = {
|
| 70 |
-
"first_name": candidat_raw.get("first_name"),
|
| 71 |
-
"langues": candidat_raw.get("langues", []),
|
| 72 |
-
"compétences": candidat_raw.get("compétences", {}),
|
| 73 |
-
"expériences": candidat_raw.get("expériences", []),
|
| 74 |
-
"projets": candidat_raw.get("projets", {}),
|
| 75 |
-
"formations": candidat_raw.get("formations", []),
|
| 76 |
-
"etudiant": candidat_raw.get("etudiant", {}),
|
| 77 |
-
"reconversion": candidat_raw.get("reconversion", {}),
|
| 78 |
-
}
|
| 79 |
-
|
| 80 |
-
result = {
|
| 81 |
-
"candidat": candidat,
|
| 82 |
-
"recommandations": recommendations,
|
| 83 |
-
}
|
| 84 |
-
|
| 85 |
logger.info("Parsing et analyse terminés.")
|
| 86 |
return result
|
|
|
|
| 1 |
"""
|
| 2 |
Service de parsing et analyse de CV enrichi.
|
| 3 |
+
Pipeline ultra-optimisé :
|
| 4 |
+
Phase 1 : Découpage en sections (cv_splitter)
|
| 5 |
+
Phase 2 : Extraction et Analyse en STRICT PARALLÈLE (11 agents)
|
|
|
|
|
|
|
| 6 |
|
| 7 |
+
Gain estimé : Temps de traitement grandement réduit.
|
|
|
|
| 8 |
"""
|
| 9 |
|
|
|
|
| 10 |
import logging
|
| 11 |
from typing import Dict, Any
|
| 12 |
|
| 13 |
+
from src.config.app_config import load_pdf, load_pdf_first_page_text, get_pdf_page_count
|
| 14 |
from src.parser_flow.CV_agent_flow import CVAgentOrchestrator
|
| 15 |
|
| 16 |
logger = logging.getLogger(__name__)
|
|
|
|
| 18 |
|
| 19 |
async def parse_cv(pdf_path: str, file_name: str = "") -> Dict[str, Any]:
|
| 20 |
"""
|
| 21 |
+
Parse un CV avec extraction + analyse complète en 2 phases.
|
| 22 |
|
| 23 |
Retourne un JSON en 2 parties :
|
| 24 |
+
- candidat : données parsées du CV (identité, compétences, expériences, projets...)
|
| 25 |
+
- recommandations : analyse critique (7 critères JSON), matching métiers, qualité CV
|
| 26 |
"""
|
| 27 |
orchestrator = CVAgentOrchestrator()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
cv_text = load_pdf(pdf_path)
|
| 29 |
cv_raw_start = load_pdf_first_page_text(pdf_path)
|
| 30 |
+
page_count = get_pdf_page_count(pdf_path)
|
| 31 |
|
|
|
|
| 32 |
logger.info("Phase 1 : Découpage du CV en sections...")
|
| 33 |
sections = await orchestrator.split_cv_sections(cv_text, cv_raw_start=cv_raw_start)
|
| 34 |
|
| 35 |
+
logger.info("Phase 2 : Extraction et Analyse en strict parallèle...")
|
| 36 |
+
result = await orchestrator.run_all_agents(
|
| 37 |
+
sections,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
cv_raw_start=cv_raw_start,
|
| 39 |
+
cv_full_text=cv_text,
|
| 40 |
+
file_name=file_name,
|
| 41 |
+
page_count=page_count
|
| 42 |
)
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
logger.info("Parsing et analyse terminés.")
|
| 45 |
return result
|
src/services/metier_pre_filter.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import math
|
| 2 |
+
import logging
|
| 3 |
+
from typing import Dict, List
|
| 4 |
+
from langchain_openai import OpenAIEmbeddings
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger(__name__)
|
| 7 |
+
|
| 8 |
+
def compute_cosine_similarity(vec1: List[float], vec2: List[float]) -> float:
|
| 9 |
+
"""Computes cosine similarity between two vectors without relying on numpy."""
|
| 10 |
+
numerator = sum(a * b for a, b in zip(vec1, vec2))
|
| 11 |
+
norm1 = math.sqrt(sum(a * a for a in vec1))
|
| 12 |
+
norm2 = math.sqrt(sum(b * b for b in vec2))
|
| 13 |
+
if norm1 == 0 or norm2 == 0:
|
| 14 |
+
return 0.0
|
| 15 |
+
return numerator / (norm1 * norm2)
|
| 16 |
+
|
| 17 |
+
def get_top_k_metiers(
|
| 18 |
+
metiers_data: List[Dict],
|
| 19 |
+
experiences_summary: str,
|
| 20 |
+
projects_summary: str,
|
| 21 |
+
hard_skills: str,
|
| 22 |
+
soft_skills: str,
|
| 23 |
+
k: int = 3
|
| 24 |
+
) -> List[Dict]:
|
| 25 |
+
"""
|
| 26 |
+
Filters the job profiles (métiers) by cosine similarity to the candidate's profile.
|
| 27 |
+
Returns the top K job profiles.
|
| 28 |
+
"""
|
| 29 |
+
# flatten list to extract nested metiers from the JSON dataset
|
| 30 |
+
flat_list = []
|
| 31 |
+
def _flatten(job_list):
|
| 32 |
+
for job in job_list:
|
| 33 |
+
if "metiers" in job:
|
| 34 |
+
_flatten(job["metiers"])
|
| 35 |
+
elif "id" in job:
|
| 36 |
+
flat_list.append(job)
|
| 37 |
+
|
| 38 |
+
_flatten(metiers_data)
|
| 39 |
+
|
| 40 |
+
try:
|
| 41 |
+
if not flat_list:
|
| 42 |
+
return []
|
| 43 |
+
|
| 44 |
+
embeddings_model = OpenAIEmbeddings(model="text-embedding-3-small")
|
| 45 |
+
|
| 46 |
+
# Stratégie Miroir
|
| 47 |
+
text = f"EXPERIENCES_ET_ACTIONS: {experiences_summary}\n"
|
| 48 |
+
text += f"PROJETS_ET_OUTCOMES: {projects_summary}\n"
|
| 49 |
+
text += f"COMPETENCES_TECH_ET_SOFT: {hard_skills}, {soft_skills}"
|
| 50 |
+
|
| 51 |
+
candidat_emb = embeddings_model.embed_query(text)
|
| 52 |
+
|
| 53 |
+
scored_metiers = []
|
| 54 |
+
for job in flat_list:
|
| 55 |
+
# En cas de manque d'embedding, on met un score de 0
|
| 56 |
+
if "embedding" in job and job["embedding"]:
|
| 57 |
+
sim = compute_cosine_similarity(candidat_emb, job["embedding"])
|
| 58 |
+
else:
|
| 59 |
+
sim = 0.0
|
| 60 |
+
scored_metiers.append((sim, job))
|
| 61 |
+
|
| 62 |
+
# Sort by similarity in descending order
|
| 63 |
+
scored_metiers.sort(key=lambda x: x[0], reverse=True)
|
| 64 |
+
top_k = [item[1] for item in scored_metiers[:k]]
|
| 65 |
+
|
| 66 |
+
logger.info(f"Top {k} métiers sélectionnés par embedding: {[m.get('id') for m in top_k]}")
|
| 67 |
+
return top_k
|
| 68 |
+
|
| 69 |
+
except Exception as e:
|
| 70 |
+
logger.error(f"Erreur lors du pré-filtrage des métiers: {e}", exc_info=True)
|
| 71 |
+
# En cas d'erreur de clé d'API ou autre, on renvoie une liste par défaut (fallback)
|
| 72 |
+
return flat_list[:k]
|
test_api.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import json
|
| 3 |
+
import traceback
|
| 4 |
+
from src.services.cv_service import parse_cv
|
| 5 |
+
|
| 6 |
+
async def main():
|
| 7 |
+
pdf_path = "c:\\Users\\quent\\Documents\\Projets\\devellopement_AIRH - Copie\\CV - Quentin Loumeau - 2026.pdf"
|
| 8 |
+
print(f"Testing CV Parser with file: {pdf_path}")
|
| 9 |
+
|
| 10 |
+
try:
|
| 11 |
+
# Define minimum metier data for the metier_matching_task
|
| 12 |
+
metiers_reference = {
|
| 13 |
+
"chef_projet_data_ia": {
|
| 14 |
+
"nom": "Chef de Projet Data / IA",
|
| 15 |
+
"competences": ["Python", "SQL", "Gestion de projet", "IA", "Machine Learning"]
|
| 16 |
+
}
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
result = await parse_cv(pdf_path, "Chef de Projet Data / IA")
|
| 20 |
+
print("Success! Output saved to test_result.json")
|
| 21 |
+
with open("test_result.json", "w", encoding="utf-8") as f:
|
| 22 |
+
json.dump(result, f, indent=2, ensure_ascii=False)
|
| 23 |
+
|
| 24 |
+
except Exception as e:
|
| 25 |
+
print(f"Error occurred: {e}")
|
| 26 |
+
traceback.print_exc()
|
| 27 |
+
|
| 28 |
+
if __name__ == "__main__":
|
| 29 |
+
asyncio.run(main())
|
test_result.json
ADDED
|
@@ -0,0 +1,581 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"candidat": {
|
| 3 |
+
"first_name": "Quentin",
|
| 4 |
+
"langues": [
|
| 5 |
+
{
|
| 6 |
+
"langue": "Français",
|
| 7 |
+
"niveau": "Natif"
|
| 8 |
+
},
|
| 9 |
+
{
|
| 10 |
+
"langue": "Anglais",
|
| 11 |
+
"niveau": "Courant (C1)"
|
| 12 |
+
},
|
| 13 |
+
{
|
| 14 |
+
"langue": "Portugais",
|
| 15 |
+
"niveau": "Courant"
|
| 16 |
+
}
|
| 17 |
+
],
|
| 18 |
+
"compétences": {
|
| 19 |
+
"hard_skills": [
|
| 20 |
+
"Python",
|
| 21 |
+
"SQL",
|
| 22 |
+
"Excel",
|
| 23 |
+
"React.js",
|
| 24 |
+
"FastAPI",
|
| 25 |
+
"LangChain",
|
| 26 |
+
"LangGraph",
|
| 27 |
+
"Scikit-learn",
|
| 28 |
+
"RAG",
|
| 29 |
+
"Pandas",
|
| 30 |
+
"PySpark",
|
| 31 |
+
"DBT",
|
| 32 |
+
"Dataiku",
|
| 33 |
+
"PostgreSQL",
|
| 34 |
+
"MongoDB",
|
| 35 |
+
"MinIO",
|
| 36 |
+
"Pinecone",
|
| 37 |
+
"Metabase",
|
| 38 |
+
"PowerBI",
|
| 39 |
+
"Mage.ai",
|
| 40 |
+
"AWS",
|
| 41 |
+
"Docker",
|
| 42 |
+
"Git",
|
| 43 |
+
"CI/CD",
|
| 44 |
+
"Selenium",
|
| 45 |
+
"BeautifulSoup4",
|
| 46 |
+
"N8n",
|
| 47 |
+
"Langflow",
|
| 48 |
+
"Gumloop"
|
| 49 |
+
],
|
| 50 |
+
"soft_skills": [
|
| 51 |
+
"Leadership",
|
| 52 |
+
"Communication",
|
| 53 |
+
"Adaptation",
|
| 54 |
+
"Rigueur"
|
| 55 |
+
],
|
| 56 |
+
"skills_with_context": [
|
| 57 |
+
{
|
| 58 |
+
"skill": "Python",
|
| 59 |
+
"context": "projet, académique"
|
| 60 |
+
},
|
| 61 |
+
{
|
| 62 |
+
"skill": "SQL",
|
| 63 |
+
"context": "académique"
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
"skill": "Excel",
|
| 67 |
+
"context": "sans contexte"
|
| 68 |
+
},
|
| 69 |
+
{
|
| 70 |
+
"skill": "React.js",
|
| 71 |
+
"context": "projet"
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
"skill": "FastAPI",
|
| 75 |
+
"context": "projet"
|
| 76 |
+
},
|
| 77 |
+
{
|
| 78 |
+
"skill": "LangChain",
|
| 79 |
+
"context": "projet"
|
| 80 |
+
},
|
| 81 |
+
{
|
| 82 |
+
"skill": "LangGraph",
|
| 83 |
+
"context": "projet"
|
| 84 |
+
},
|
| 85 |
+
{
|
| 86 |
+
"skill": "Scikit-learn",
|
| 87 |
+
"context": "académique"
|
| 88 |
+
},
|
| 89 |
+
{
|
| 90 |
+
"skill": "RAG",
|
| 91 |
+
"context": "expérience, projet"
|
| 92 |
+
},
|
| 93 |
+
{
|
| 94 |
+
"skill": "Pandas",
|
| 95 |
+
"context": "académique"
|
| 96 |
+
},
|
| 97 |
+
{
|
| 98 |
+
"skill": "PySpark",
|
| 99 |
+
"context": "certification"
|
| 100 |
+
},
|
| 101 |
+
{
|
| 102 |
+
"skill": "DBT",
|
| 103 |
+
"context": "certification"
|
| 104 |
+
},
|
| 105 |
+
{
|
| 106 |
+
"skill": "Dataiku",
|
| 107 |
+
"context": "expérience"
|
| 108 |
+
},
|
| 109 |
+
{
|
| 110 |
+
"skill": "PostgreSQL",
|
| 111 |
+
"context": "projet"
|
| 112 |
+
},
|
| 113 |
+
{
|
| 114 |
+
"skill": "MongoDB",
|
| 115 |
+
"context": "projet"
|
| 116 |
+
},
|
| 117 |
+
{
|
| 118 |
+
"skill": "MinIO",
|
| 119 |
+
"context": "projet"
|
| 120 |
+
},
|
| 121 |
+
{
|
| 122 |
+
"skill": "Pinecone",
|
| 123 |
+
"context": "projet"
|
| 124 |
+
},
|
| 125 |
+
{
|
| 126 |
+
"skill": "Metabase",
|
| 127 |
+
"context": "projet"
|
| 128 |
+
},
|
| 129 |
+
{
|
| 130 |
+
"skill": "PowerBI",
|
| 131 |
+
"context": "expérience, académique"
|
| 132 |
+
},
|
| 133 |
+
{
|
| 134 |
+
"skill": "Mage.ai",
|
| 135 |
+
"context": "projet"
|
| 136 |
+
},
|
| 137 |
+
{
|
| 138 |
+
"skill": "AWS",
|
| 139 |
+
"context": "sans contexte"
|
| 140 |
+
},
|
| 141 |
+
{
|
| 142 |
+
"skill": "Docker",
|
| 143 |
+
"context": "sans contexte"
|
| 144 |
+
},
|
| 145 |
+
{
|
| 146 |
+
"skill": "Git",
|
| 147 |
+
"context": "sans contexte"
|
| 148 |
+
},
|
| 149 |
+
{
|
| 150 |
+
"skill": "CI/CD",
|
| 151 |
+
"context": "sans contexte"
|
| 152 |
+
},
|
| 153 |
+
{
|
| 154 |
+
"skill": "Selenium",
|
| 155 |
+
"context": "projet"
|
| 156 |
+
},
|
| 157 |
+
{
|
| 158 |
+
"skill": "BeautifulSoup4",
|
| 159 |
+
"context": "sans contexte"
|
| 160 |
+
},
|
| 161 |
+
{
|
| 162 |
+
"skill": "N8n",
|
| 163 |
+
"context": "sans contexte"
|
| 164 |
+
},
|
| 165 |
+
{
|
| 166 |
+
"skill": "Langflow",
|
| 167 |
+
"context": "sans contexte"
|
| 168 |
+
},
|
| 169 |
+
{
|
| 170 |
+
"skill": "Gumloop",
|
| 171 |
+
"context": "sans contexte"
|
| 172 |
+
}
|
| 173 |
+
]
|
| 174 |
+
},
|
| 175 |
+
"expériences": [
|
| 176 |
+
{
|
| 177 |
+
"Poste": "Alternant Data Analyste / Chef de Projet IA",
|
| 178 |
+
"Entreprise": "Enedis - Direction Audit Contrôle Interne Risques (DACIR)",
|
| 179 |
+
"start_date": "Déc. 2024",
|
| 180 |
+
"end_date": "Déc. 2025",
|
| 181 |
+
"responsabilités": [
|
| 182 |
+
"Développement de 5 tableaux de bords PowerBI à destination des 150 collaborateurs du contrôle interne",
|
| 183 |
+
"Paramétrage d'une solution RAG en test pour le Risk manager, ingestion de 40 documents techniques, travail du prompt et du flow agentique, évaluation du MRR",
|
| 184 |
+
"Refactorisation et optimisation de 10 flows dataiku de traitement des données d’analyses, réduction de 30% des étapes de traitement et création de documentation des process",
|
| 185 |
+
"Cadrage des besoins d'analyse de fraude et d'anomalies sur 6 projets majeurs, en animant des réunions avec une dizaine de collaborateurs",
|
| 186 |
+
"Animation d’ateliers de montée en compétence data mining auprès de 150 auditeurs"
|
| 187 |
+
]
|
| 188 |
+
},
|
| 189 |
+
{
|
| 190 |
+
"Poste": "Pâtissier - Manager & Responsable Production",
|
| 191 |
+
"Entreprise": "Parcours international (UK, Suède, Australie)",
|
| 192 |
+
"start_date": "2009",
|
| 193 |
+
"end_date": "2023",
|
| 194 |
+
"responsabilités": [
|
| 195 |
+
"14 ans d’expérience : gestion d’équipes (jusqu’à 8 pers.), planification de production, relation client",
|
| 196 |
+
"Optimisation des processus de production, réduisant les pertes matières de ~20%",
|
| 197 |
+
"3 pays, 3 cultures : adaptation, communication en anglais au quotidien",
|
| 198 |
+
"Analyse des ventes et gestion budgétaire : suivi des marges, adaptation de la production en fonction des ventes"
|
| 199 |
+
]
|
| 200 |
+
}
|
| 201 |
+
],
|
| 202 |
+
"reconversion": {
|
| 203 |
+
"is_reconversion": true,
|
| 204 |
+
"context": "Le candidat a effectué une reconversion professionnelle en passant du domaine de la pâtisserie, où il a travaillé comme Manager & Responsable Production pendant 14 ans, à un domaine technologique en tant que Data Analyste et Chef de Projet IA. Il a suivi une formation en Data Analyst de février 2024 à juillet 2024, puis a poursuivi avec une formation Chef de Projet IA de novembre 2024 à décembre 2025.",
|
| 205 |
+
"date_reconversion": "2024-02"
|
| 206 |
+
},
|
| 207 |
+
"projets": {
|
| 208 |
+
"professional": [],
|
| 209 |
+
"personal": [
|
| 210 |
+
{
|
| 211 |
+
"title": "AIRH - Plateforme SaaS de recrutement augmenté (système multi-agents)",
|
| 212 |
+
"technologies": [
|
| 213 |
+
"React.js",
|
| 214 |
+
"FastAPI",
|
| 215 |
+
"CrewAI",
|
| 216 |
+
"LangGraph",
|
| 217 |
+
"PostgreSQL",
|
| 218 |
+
"MongoDB",
|
| 219 |
+
"MinIO",
|
| 220 |
+
"Metabase",
|
| 221 |
+
"Mage.ai"
|
| 222 |
+
],
|
| 223 |
+
"outcomes": [
|
| 224 |
+
"Architecture d'un système multi-agents orchestrant l'analyse automatisée de CV et la simulation d'entretien dynamique",
|
| 225 |
+
"Moteur de scoring multi-dimensionnel avec détection de fraude automatisée",
|
| 226 |
+
"Pipeline de données complet orchestré avec Mage.ai",
|
| 227 |
+
"Pilotage de A à Z : Design Thinking, business plan, développement full-stack, déploiement"
|
| 228 |
+
],
|
| 229 |
+
"domaine metier": "recrutement/RH"
|
| 230 |
+
},
|
| 231 |
+
{
|
| 232 |
+
"title": "FounderOS - Dashboard tout-en-un pour fondateurs de startups",
|
| 233 |
+
"technologies": [
|
| 234 |
+
"Next.js",
|
| 235 |
+
"TypeScript",
|
| 236 |
+
"Tailwind",
|
| 237 |
+
"API Gemini"
|
| 238 |
+
],
|
| 239 |
+
"outcomes": [
|
| 240 |
+
"Outil complet : Lean Canvas, CRM Lite, veille stratégique, gestion financière, pipeline de contenu",
|
| 241 |
+
"Module d'intelligence concurrentielle avec scoring automatisé"
|
| 242 |
+
],
|
| 243 |
+
"domaine metier": "entrepreneuriat/startups"
|
| 244 |
+
},
|
| 245 |
+
{
|
| 246 |
+
"title": "Data Jobs - Moteur de recommandation d'emploi RAG",
|
| 247 |
+
"technologies": [
|
| 248 |
+
"Python",
|
| 249 |
+
"LangChain",
|
| 250 |
+
"Pinecone",
|
| 251 |
+
"Selenium",
|
| 252 |
+
"API Groq"
|
| 253 |
+
],
|
| 254 |
+
"outcomes": [
|
| 255 |
+
"Scraping et vectorisation de +2 000 offres d'emploi tech",
|
| 256 |
+
"Pertinence des recommandations améliorée de +40% via Prompt Engineering iteratif",
|
| 257 |
+
"Pilotage des sprints en tant que Scrum Master"
|
| 258 |
+
],
|
| 259 |
+
"domaine metier": "recrutement/RH"
|
| 260 |
+
}
|
| 261 |
+
]
|
| 262 |
+
},
|
| 263 |
+
"formations": [
|
| 264 |
+
{
|
| 265 |
+
"degree": "Chef de Projet IA - RNCP Niveau 7 (Bac+5)",
|
| 266 |
+
"institution": "Wild Code School",
|
| 267 |
+
"start_date": "Nov. 2024",
|
| 268 |
+
"end_date": "Déc. 2025"
|
| 269 |
+
},
|
| 270 |
+
{
|
| 271 |
+
"degree": "Data Analyst - RNCP Niveau 6 (Bac+3)",
|
| 272 |
+
"institution": "Wild Code School",
|
| 273 |
+
"start_date": "Fév. 2024",
|
| 274 |
+
"end_date": "Juil. 2024"
|
| 275 |
+
}
|
| 276 |
+
],
|
| 277 |
+
"etudiant": {
|
| 278 |
+
"is_etudiant": false,
|
| 279 |
+
"niveau_etudes": "bac+5",
|
| 280 |
+
"specialite": "Chef de Projet IA",
|
| 281 |
+
"latest_education_end_date": "2025-12-31"
|
| 282 |
+
},
|
| 283 |
+
"is_en_poste": false
|
| 284 |
+
},
|
| 285 |
+
"recommandations": {
|
| 286 |
+
"header_analysis": {
|
| 287 |
+
"poste_vise": "Chef de Projet Data / IA - Spécialiste Systèmes Multi-Agents & Recrutement Tech",
|
| 288 |
+
"confiance": 90
|
| 289 |
+
},
|
| 290 |
+
"postes_recommandes": [
|
| 291 |
+
{
|
| 292 |
+
"metier_id": "data_analyst",
|
| 293 |
+
"nom": "Data Analyst",
|
| 294 |
+
"categorie": "Noyau data & analytique",
|
| 295 |
+
"score_matching": 88,
|
| 296 |
+
"detail_scores": {
|
| 297 |
+
"competences_techniques": 85,
|
| 298 |
+
"outils_technologies": 90,
|
| 299 |
+
"experiences_projets": 85,
|
| 300 |
+
"methodologies": 80
|
| 301 |
+
},
|
| 302 |
+
"competences_matchees": [
|
| 303 |
+
"SQL",
|
| 304 |
+
"Python",
|
| 305 |
+
"Power BI",
|
| 306 |
+
"Metabase"
|
| 307 |
+
],
|
| 308 |
+
"competences_manquantes": [
|
| 309 |
+
"Looker",
|
| 310 |
+
"Qlik"
|
| 311 |
+
],
|
| 312 |
+
"justification": "Le profil couvre 88% des compétences requises grâce à une solide maîtrise de SQL et Python, ainsi qu'une expérience significative avec Power BI et Metabase. Les méthodologies Agile et Scrum sont bien intégrées, ce qui est crucial pour ce rôle."
|
| 313 |
+
},
|
| 314 |
+
{
|
| 315 |
+
"metier_id": "data_engineer",
|
| 316 |
+
"nom": "Data Engineer",
|
| 317 |
+
"categorie": "Ingénierie data & IA",
|
| 318 |
+
"score_matching": 82,
|
| 319 |
+
"detail_scores": {
|
| 320 |
+
"competences_techniques": 80,
|
| 321 |
+
"outils_technologies": 85,
|
| 322 |
+
"experiences_projets": 80,
|
| 323 |
+
"methodologies": 75
|
| 324 |
+
},
|
| 325 |
+
"competences_matchees": [
|
| 326 |
+
"Python",
|
| 327 |
+
"SQL",
|
| 328 |
+
"PySpark",
|
| 329 |
+
"DBT",
|
| 330 |
+
"AWS",
|
| 331 |
+
"Docker"
|
| 332 |
+
],
|
| 333 |
+
"competences_manquantes": [
|
| 334 |
+
"Apache Kafka",
|
| 335 |
+
"Flink"
|
| 336 |
+
],
|
| 337 |
+
"justification": "Le candidat possède une bonne maîtrise des outils de transformation et de cloud, ainsi qu'une expérience en DevOps, ce qui est essentiel pour un Data Engineer. Cependant, l'expérience avec des outils de streaming comme Kafka est limitée."
|
| 338 |
+
},
|
| 339 |
+
{
|
| 340 |
+
"metier_id": "data_scientist",
|
| 341 |
+
"nom": "Data Scientist",
|
| 342 |
+
"categorie": "Noyau data & analytique",
|
| 343 |
+
"score_matching": 80,
|
| 344 |
+
"detail_scores": {
|
| 345 |
+
"competences_techniques": 75,
|
| 346 |
+
"outils_technologies": 80,
|
| 347 |
+
"experiences_projets": 85,
|
| 348 |
+
"methodologies": 70
|
| 349 |
+
},
|
| 350 |
+
"competences_matchees": [
|
| 351 |
+
"Python",
|
| 352 |
+
"Scikit-learn",
|
| 353 |
+
"LangChain",
|
| 354 |
+
"RAG"
|
| 355 |
+
],
|
| 356 |
+
"competences_manquantes": [
|
| 357 |
+
"Deep learning",
|
| 358 |
+
"Spark"
|
| 359 |
+
],
|
| 360 |
+
"justification": "Le profil est bien aligné avec les compétences en machine learning et en IA, notamment avec l'utilisation de LangChain et Scikit-learn. Cependant, une expérience plus approfondie en deep learning et Spark serait bénéfique."
|
| 361 |
+
}
|
| 362 |
+
],
|
| 363 |
+
"analyse_poste_vise": "Le poste de Data Analyst est bien aligné avec le profil du candidat, qui possède une forte expérience en SQL, Python, et Power BI, ainsi qu'une bonne compréhension des méthodologies Agile et Scrum. Les compétences manquantes comme Looker et Qlik peuvent être acquises rapidement grâce à l'expérience existante avec d'autres outils BI.",
|
| 364 |
+
"qualite_cv": {
|
| 365 |
+
"score_global": 76,
|
| 366 |
+
"compatibilite_ats": {
|
| 367 |
+
"score": 83,
|
| 368 |
+
"details": "Sections principales standard et bien nommées : « EXPERIENCE PROFESSIONNELLE », « PROJETS CLES », « FORMATION », « STACK TECHNIQUE », « LANGUES ». Bon point ATS. Les mots-clés techniques sont majoritairement contextualisés via les projets (AIRH/FounderOS/Data Jobs) et l’alternance (PowerBI, Dataiku, RAG). Points perfectibles : 1) « STACK TECHNIQUE » est OK mais certains ATS attendent « COMPETENCES » (mineur). 2) Présence de sous-intitulés atypiques « de base », « Transformation », « BI » qui peuvent être moins bien parsés selon ATS (mieux en listes/catégories simples). 3) Des outils très nombreux (Langflow, Gumloop, N8n, Langfuse…) peuvent être perçus comme empilement si non reliés à une expérience/projet précis (à relier explicitement). 4) Les liens sont présents mais libellés (LinkedIn / AIRH / FounderOS) sans URL en clair dans le texte brut : certains ATS ne cliquent pas/extraient mal les ancres (préférer afficher l’URL en toutes lettres)."
|
| 369 |
+
},
|
| 370 |
+
"quantification_resultats": {
|
| 371 |
+
"score": 62,
|
| 372 |
+
"details": "Bon niveau pour un profil en reconversion/early-confirmé Data : plusieurs chiffres concrets existent (5 dashboards, 150 collaborateurs, 40 documents, 10 flows Dataiku, -30% d’étapes, 6 projets, +2000 offres, +40% pertinence, 7 agents, équipe jusqu’à 8, -20% pertes matière). En revanche, il manque des métriques techniques ‘2025’ attendues côté Data/IA/Produit : latence/temps de réponse, volumétrie (Go, lignes, requêtes/jour), coûts, performance des pipelines, qualité modèle (précision, recall), temps de déploiement, monitoring, couverture de tests, SLA. Sur Enedis et AIRH, les résultats restent partiellement orientés ‘activité’ plutôt que ‘impact’ (gain de temps, adoption, fiabilité, incidents évités).",
|
| 373 |
+
"metriques_suggerees": [
|
| 374 |
+
"Enedis/PowerBI : temps moyen de chargement des dashboards (ex. -40% vs avant), nombre de vues mensuelles, taux d’adoption (% des 150 utilisateurs actifs), réduction du temps de production de reporting (heures/sem).",
|
| 375 |
+
"Enedis/RAG : taux de réponses utiles (ex. % évalué), précision@k / recall@k, taux d’hallucination, temps moyen de réponse (ms/s), coût par requête (€, tokens), nombre de requêtes/semaine en test.",
|
| 376 |
+
"Enedis/Dataiku : volume de données traité par flow (Go/jour, nb de tables), durée d’exécution avant/après (ex. 45 min → 28 min), taux d’échec des jobs (incidents/mois), dette technique réduite (nb de recettes/variables standardisées).",
|
| 377 |
+
"AIRH : temps de parsing CV (ms), throughput (CV/min), coût d’inférence par entretien simulé, latence end-to-end multi-agents, taux de succès des runs (sans erreurs), nombre de candidats/recruteurs beta testeurs.",
|
| 378 |
+
"AIRH : métriques qualité scoring (corrélation avec décisions humaines, calibration, taux de faux positifs ‘fraude’), couverture de tests backend (%), temps de déploiement (CI/CD) et fréquence de release.",
|
| 379 |
+
"Data Jobs : NDCG@k / MRR (si déjà mesuré), taux de clic ou satisfaction utilisateur, temps de vectorisation (2k offres en X min), coût Pinecone (€/mois) et optimisation (index, dimension, batch).",
|
| 380 |
+
"FounderOS : performances web (LCP, TTFB), taille bundle, temps de build/deploy, nombre d’utilisateurs actifs, réduction du churn ou du temps de préparation (Lean Canvas, veille) via l’outil."
|
| 381 |
+
]
|
| 382 |
+
},
|
| 383 |
+
"structure_lisibilite": {
|
| 384 |
+
"score": 78,
|
| 385 |
+
"details": "CV 1 page : conforme et très bon pour un profil en montée en compétence Data. Poste visé clairement indiqué dès l’en-tête (« Chef de Projet Data / IA ») : bon signal. Chronologie : expériences en ordre globalement inverse côté Data (2024-2025 puis 2009-2023) OK ; formations listées 2024-2025 puis 2024 OK. Point à améliorer : la section « LIENS » et « STACK TECHNIQUE » apparaissent aussi dans le texte brut au milieu des expériences (probable effet de mise en page/colonnes) : risque réel de parsing ATS et de lecture recruteur (les liens/skills semblent ‘couper’ l’expérience). Il faut sécuriser une structure linéaire : En-tête > Compétences > Expériences > Projets > Formation (ou Projets avant Expériences si objectif produit).",
|
| 386 |
+
"structuration_competences": "Compétences bien catégorisées (Langages/Frameworks, IA/ML, Analytics & Data Engineering, DevOps & Automatisation, Méthodologies). Recommandation : renommer « STACK TECHNIQUE » en « COMPETENCES TECHNIQUES » et éviter des micro-sous-blocs (« de base ») au profit de catégories ATS plus standards."
|
| 387 |
+
},
|
| 388 |
+
"presentation_projets": {
|
| 389 |
+
"score": 81,
|
| 390 |
+
"details": "Projets bien présentés : chaque projet a un nom, une stack, et des éléments concrets (AIRH : 7 agents, pipeline complet, scoring pondéré ; Data Jobs : +2000 offres, +40% pertinence ; FounderOS : modules identifiés). Très pertinent pour le poste visé (Data/IA + produit). À renforcer : ajouter systématiquement 1) le rôle exact (solo vs équipe), 2) l’état (MVP en prod ? beta ? utilisateurs ?), 3) des métriques d’exploitation (latence, coût, usage), 4) un lien direct par projet vers repo/démo (URL en clair) pour preuve immédiate. Sur AIRH, détailler les choix d’architecture (monolithe vs microservices, orchestrateur, storage, stratégie d’indexation vecteur, observabilité) et les compromis."
|
| 391 |
+
},
|
| 392 |
+
"preuves_competences": {
|
| 393 |
+
"score": 74,
|
| 394 |
+
"details": "Bon niveau de ‘preuves’ car beaucoup de compétences sont démontrées dans expériences/projets : PowerBI et Dataiku (Enedis), RAG/LangChain/Pinecone/Selenium (Data Jobs), React/FastAPI/PostgreSQL/MongoDB/MinIO/Metabase/Mage.ai (AIRH), Next.js/TypeScript/Tailwind/Gemini (FounderOS), Scrum Master mentionné (Data Jobs), management (pâtissier manager) utile pour Chef de Projet. Points faibles : plusieurs outils listés en compétences ne sont pas explicitement rattachés à un projet/expérience (risque de “skills shelf”). Aussi, AWS/Docker/Git/CI/CD sont cités mais sans exemple concret de pipeline, déploiement, registry, infra as code, etc.",
|
| 395 |
+
"skills_sans_preuve": [
|
| 396 |
+
"PySpark",
|
| 397 |
+
"DBT",
|
| 398 |
+
"AWS",
|
| 399 |
+
"Docker",
|
| 400 |
+
"CI/CD",
|
| 401 |
+
"BeautifulSoup4 (BS4)",
|
| 402 |
+
"N8n",
|
| 403 |
+
"Langflow",
|
| 404 |
+
"Gumloop",
|
| 405 |
+
"Langfuse",
|
| 406 |
+
"Design Thinking (usage mentionné mais sans livrables/atelier concret hors AIRH)",
|
| 407 |
+
"RGAA (mention conformité mais pas d’action vérifiable : audit, critères appliqués, corrections)"
|
| 408 |
+
],
|
| 409 |
+
"liens_detectes": [
|
| 410 |
+
"https://www.linkedin.com/in/loumeau-quentin/",
|
| 411 |
+
"https://www.airh.online/",
|
| 412 |
+
"https://founderdashboard.vercel.app/"
|
| 413 |
+
]
|
| 414 |
+
},
|
| 415 |
+
"red_flags": [
|
| 416 |
+
"Compétences listées sans preuve explicite dans expériences/projets : PySpark, DBT, AWS, Docker, CI/CD, BS4, N8n, Langflow, Gumloop, Langfuse, RGAA (à contextualiser ou retirer).",
|
| 417 |
+
"Risque de parsing/lecture : dans le texte brut, les sections LIENS/STACK TECHNIQUE semblent interrompre la section EXPÉRIENCE (probable mise en page en colonnes)."
|
| 418 |
+
],
|
| 419 |
+
"points_forts": [
|
| 420 |
+
"Positionnement clair et différenciant (Chef de Projet Data/IA + multi-agents + recrutement tech).",
|
| 421 |
+
"Projets très pertinents et actuels (RAG, multi-agents, vector DB, scraping, full-stack).",
|
| 422 |
+
"Déjà plusieurs quantifications utiles (utilisateurs, volumes, gains, nombre de flows, +40% pertinence).",
|
| 423 |
+
"Valorisation cohérente de la reconversion avec compétences transférables (management, optimisation, international, animation d’ateliers).",
|
| 424 |
+
"Liens vers LinkedIn et projets live présents (preuve produit)."
|
| 425 |
+
],
|
| 426 |
+
"conseils_prioritaires": [
|
| 427 |
+
"Sécuriser la lisibilité ATS : sortir d’une mise en page en colonnes et garantir un flux linéaire (les sections LIENS/COMPÉTENCES ne doivent pas apparaître au milieu de l’EXPERIENCE dans l’extraction texte). Si vous gardez les ancres, ajoutez aussi les URL en clair (ex. linkedin.com/in/… ; airh.online ; founderdashboard.vercel.app).",
|
| 428 |
+
"Ajouter 6–10 métriques techniques ‘signature’ sur Enedis + AIRH : temps d’exécution avant/après des flows Dataiku, volume de données, temps de réponse RAG, coût par requête, adoption (utilisateurs actifs), fiabilité (taux d’échec), et côté AIRH latence multi-agents + coût inference + taux de succès des runs.",
|
| 429 |
+
"Nettoyer la section compétences : soit retirer les outils non prouvés, soit ajouter une ligne de preuve par outil (ex. ‘DBT : modèle X, 12 tests, docs’, ‘Docker : image FastAPI, docker-compose’, ‘CI/CD : GitHub Actions avec lint+tests+deploy’). Objectif : zéro skill “hors contexte”.",
|
| 430 |
+
"Sur AIRH, expliciter 3–5 décisions d’architecture et leurs compromis (ex. choix Postgres vs Mongo, stockage MinIO, orchestration Mage.ai, stratégie d’indexation/search, observabilité/trace Langfuse) + ce que vous feriez différemment à l’échelle (10k CV/jour, multi-tenant, files/queues)."
|
| 431 |
+
],
|
| 432 |
+
"adaptation_seniorite": "Profil ‘reconversion vers Data/IA’ avec expérience pro longue mais expérience tech récente (alternance 2024–2025 + projets). Attentes calibrées : comme un junior/confirmé côté Data/IA, les projets sont un atout majeur et sont bien exploités. Pour viser ‘Chef de Projet Data/IA’ (confirmé), il manque encore des marqueurs d’impact mesurable (SLA, gains de temps/coûts, adoption, qualité IA) et des preuves de delivery (déploiement, run, monitoring, CI/CD). La partie compétences transférables (management, optimisation, animation) est bien présente ; elle gagnerait à être reliée à des livrables tech/projet (roadmap, RACI, KPI, gestion des risques, conduite du changement sur les 150 utilisateurs)."
|
| 433 |
+
},
|
| 434 |
+
"analyse_projets": [
|
| 435 |
+
{
|
| 436 |
+
"titre": "AIRH - Plateforme SaaS de recrutement augmenté (système multi-agents)",
|
| 437 |
+
"resume": "SaaS / Projet live de recrutement augmenté : multi-agents (7) pour analyse CV + simulation d’entretien + pipeline data end-to-end.",
|
| 438 |
+
"evaluation": {
|
| 439 |
+
"pertinence": {
|
| 440 |
+
"score": 10,
|
| 441 |
+
"justification": "Cœur du poste visé: 'système multi-agents (7 agents)', 'recrutement augmenté', scoring candidats, et pipeline data (ingestion→monitoring)."
|
| 442 |
+
},
|
| 443 |
+
"complexite": {
|
| 444 |
+
"score": 8,
|
| 445 |
+
"justification": "Architecture multi-agents annoncée (CrewAI + LangGraph) + pipeline complet (parsing, stockage objet MinIO, PostgreSQL+MongoDB, orchestration Mage.ai). Pas de détails sur patterns de résilience, gestion d’état, évaluation offline/online."
|
| 446 |
+
},
|
| 447 |
+
"stack": {
|
| 448 |
+
"score": 8,
|
| 449 |
+
"justification": "Stack cohérente SaaS data/IA: React, FastAPI, PostgreSQL/MongoDB, MinIO, Metabase, Mage.ai, + frameworks agents. L’ensemble est moderne mais assez large (double DB relationnel/NoSQL + orchestration + observabilité) sans justification explicite."
|
| 450 |
+
},
|
| 451 |
+
"innovation": {
|
| 452 |
+
"score": 8,
|
| 453 |
+
"justification": "Au-delà d’un wrapper RAG: scoring multi-dimensionnel pondéré (40/30/20/10) + 'détection de fraude automatisée' + 'simulation d’entretien dynamique'. Innovation plausible, mais le CV ne décrit pas la méthode de fraude (règles, ML, signaux) ni l’évaluation."
|
| 454 |
+
},
|
| 455 |
+
"impact": {
|
| 456 |
+
"score": 5,
|
| 457 |
+
"justification": "Aucun KPI chiffré (temps de recrutement, qualité shortlist, taux de conversion, coûts/latence). Projet 'live' mentionné, mais sans métriques d’adoption/usage."
|
| 458 |
+
},
|
| 459 |
+
"ownership": {
|
| 460 |
+
"score": 10,
|
| 461 |
+
"justification": "Ownership explicitement end-to-end: 'Pilotage de A à Z : Design Thinking, business plan, développement full-stack, déploiement' + mémoire de fin d’études sur le sujet."
|
| 462 |
+
},
|
| 463 |
+
"maturite": {
|
| 464 |
+
"score": 6,
|
| 465 |
+
"justification": "Mention de monitoring qualité via Metabase + orchestration Mage.ai. En revanche: pas de preuve de tests, CI/CD, sécurité, RGPD, gestion des prompts/versions, observabilité LLM (traces, coûts), SLA/latence, ou scalabilité."
|
| 466 |
+
}
|
| 467 |
+
},
|
| 468 |
+
"points_forts": [
|
| 469 |
+
"Alignement parfait avec multi-agents + recrutement tech: 7 agents orchestrés et simulation d’entretien.",
|
| 470 |
+
"Vrai scope produit: full-stack + data pipeline + stockage objet + monitoring (Metabase) + orchestration (Mage.ai).",
|
| 471 |
+
"Ownership rare sur un projet IA: business plan + déploiement + projet live (donc contrainte produit réelle)."
|
| 472 |
+
],
|
| 473 |
+
"points_vigilance": [
|
| 474 |
+
"Risque d’over-claim sur la 'détection de fraude' et la 'simulation dynamique' sans description de méthode ni métriques d’efficacité (FP/FN, protocoles d’évaluation).",
|
| 475 |
+
"Maturité production incomplète dans le CV: pas de mention de tests, CI/CD, sécurité, RGPD (données CV = sensibles), monitoring LLM (coût/latence), ni gestion des erreurs/timeout.",
|
| 476 |
+
"Complexité stack: PostgreSQL + MongoDB + MinIO + Mage + Metabase + CrewAI/LangGraph; il faut justifier les choix et la cohérence opérationnelle."
|
| 477 |
+
],
|
| 478 |
+
"note_globale": 79,
|
| 479 |
+
"verdict_recruteur": "Projet le plus solide et le plus aligné: vraie construction agentique + pipeline data + produit SaaS. Techniquement crédible, mais je le considère 'production-lean': pour valider un niveau senior/CTO-like, il faut des preuves chiffrées (impact) et des éléments de qualité prod (sécurité/RGPD, tests, observabilité LLM, perf/coûts). À creuser en entretien."
|
| 480 |
+
},
|
| 481 |
+
{
|
| 482 |
+
"titre": "FounderOS - Dashboard tout-en-un pour fondateurs de startups",
|
| 483 |
+
"resume": "Side project / MVP produit: dashboard Next.js pour tâches founder (Lean Canvas, CRM lite, veille, finance, contenu) + module d’intelligence concurrentielle scoré.",
|
| 484 |
+
"evaluation": {
|
| 485 |
+
"pertinence": {
|
| 486 |
+
"score": 5,
|
| 487 |
+
"justification": "Pertinent côté produit/PM et un peu GenAI (API Gemini), mais moins aligné sur multi-agents/RAG/recrutement/pipelines."
|
| 488 |
+
},
|
| 489 |
+
"complexite": {
|
| 490 |
+
"score": 4,
|
| 491 |
+
"justification": "Le CV décrit des modules fonctionnels et un scoring concurrentiel, mais pas d’architecture IA (agents, RAG, ingestion), ni complexité backend/data. Semble surtout front + appels API."
|
| 492 |
+
},
|
| 493 |
+
"stack": {
|
| 494 |
+
"score": 7,
|
| 495 |
+
"justification": "Next.js + TypeScript + Tailwind = stack moderne et cohérente. 'API Gemini' intégrée, mais absence d’info sur backend, persistance, auth, ou pipeline données."
|
| 496 |
+
},
|
| 497 |
+
"innovation": {
|
| 498 |
+
"score": 6,
|
| 499 |
+
"justification": "Module d’intelligence concurrentielle avec 'Health Score', 'Radar 6 axes', SWOT intelligente: idée intéressante. Mais on ne voit pas ce qui est réellement automatisé (sources, ingestion, critères) vs simple prompting."
|
| 500 |
+
},
|
| 501 |
+
"impact": {
|
| 502 |
+
"score": 2,
|
| 503 |
+
"justification": "Aucun chiffre (utilisateurs, adoption, gain de temps, taux d’activation), pas de preuve de projet live."
|
| 504 |
+
},
|
| 505 |
+
"ownership": {
|
| 506 |
+
"score": 6,
|
| 507 |
+
"justification": "On suppose une réalisation personnelle (projet listé), mais contrairement à AIRH, pas de mention explicite de déploiement, pilotage A→Z, ou méthodes."
|
| 508 |
+
},
|
| 509 |
+
"maturite": {
|
| 510 |
+
"score": 3,
|
| 511 |
+
"justification": "Aucune mention de monitoring, tests, sécurité, contrôle coût/latence Gemini, ni gestion des données. Semble MVP/démo."
|
| 512 |
+
}
|
| 513 |
+
},
|
| 514 |
+
"points_forts": [
|
| 515 |
+
"Bonne culture produit: regrouper des besoins founders (canvas, CRM, veille, finance) dans un même outil.",
|
| 516 |
+
"Stack front moderne (Next.js/TS) et intégration GenAI (Gemini) utile pour des analyses structurées."
|
| 517 |
+
],
|
| 518 |
+
"points_vigilance": [
|
| 519 |
+
"Projet peu probant pour évaluer la compétence 'multi-agents/RAG/pipeline' (pas décrit).",
|
| 520 |
+
"Innovation difficile à distinguer d’un 'prompt + UI' sans description de données, métriques, ou protocole d’évaluation.",
|
| 521 |
+
"Aucun signal de maturité production (auth, rôles, stockage, observabilité, coûts)."
|
| 522 |
+
],
|
| 523 |
+
"note_globale": 47,
|
| 524 |
+
"verdict_recruteur": "Bon projet produit/UI, mais techniquement trop peu étayé côté data/IA pour le poste visé. Je le vois comme un bonus (sens produit), pas comme une preuve de maîtrise agentique ou pipeline. À ne pas sur-vendre."
|
| 525 |
+
},
|
| 526 |
+
{
|
| 527 |
+
"titre": "Data Jobs - Moteur de recommandation d'emploi RAG",
|
| 528 |
+
"resume": "Projet IA / prototype RAG: scraping d’offres, vectorisation Pinecone, recommandation via LangChain + Groq, amélioration de pertinence annoncée.",
|
| 529 |
+
"evaluation": {
|
| 530 |
+
"pertinence": {
|
| 531 |
+
"score": 8,
|
| 532 |
+
"justification": "RAG + pipeline (scraping→vectorisation) appliqué à l’emploi tech (proche recrutement). Moins multi-agents mais très aligné GenAI/pipelines."
|
| 533 |
+
},
|
| 534 |
+
"complexite": {
|
| 535 |
+
"score": 6,
|
| 536 |
+
"justification": "Scraping Selenium + vector store Pinecone + LangChain + LLM Groq = pipeline RAG classique. Pas de mention d’ingestion robuste (dédup, scheduling), évaluation IR (precision@k), ni serving/monitoring."
|
| 537 |
+
},
|
| 538 |
+
"stack": {
|
| 539 |
+
"score": 7,
|
| 540 |
+
"justification": "Python + LangChain + Pinecone + Selenium + Groq: stack standard RAG. Cohérente, mais dépendante de services externes; pas d’info sur persistance, API, ou déploiement."
|
| 541 |
+
},
|
| 542 |
+
"innovation": {
|
| 543 |
+
"score": 5,
|
| 544 |
+
"justification": "Amélioration via 'Prompt Engineering itératif' = approche courante. Pas de signal d’innovation type reranking, hybrid search, feedback loop, ou évaluation systématique."
|
| 545 |
+
},
|
| 546 |
+
"impact": {
|
| 547 |
+
"score": 6,
|
| 548 |
+
"justification": "+2 000 offres scrapées et '+40% pertinence' annoncés. Mais la mesure n’est pas définie (metric, baseline, protocole, dataset de test), donc crédibilité partielle."
|
| 549 |
+
},
|
| 550 |
+
"ownership": {
|
| 551 |
+
"score": 7,
|
| 552 |
+
"justification": "Réalisation technique décrite + 'pilotage des sprints en tant que Scrum Master'. Ownership delivery/orga visible, mais pas explicitement end-to-end produit (déploiement, users)."
|
| 553 |
+
},
|
| 554 |
+
"maturite": {
|
| 555 |
+
"score": 4,
|
| 556 |
+
"justification": "Pas de mention de monitoring, tests, gestion anti-bot/robustesse scraping, conformité (TOS scraping), ni optimisation coût/latence. On est sur un prototype solide."
|
| 557 |
+
}
|
| 558 |
+
},
|
| 559 |
+
"points_forts": [
|
| 560 |
+
"Bon cas d’usage RAG appliqué au recrutement: ingestion (scraping) + indexation (Pinecone) + recommandation.",
|
| 561 |
+
"Chiffres minimaux présents (+2 000 offres, +40% pertinence), ce qui est rare dans un CV.",
|
| 562 |
+
"Dimension delivery: rôle Scrum Master mentionné (capacité à structurer l’exécution)."
|
| 563 |
+
],
|
| 564 |
+
"points_vigilance": [
|
| 565 |
+
"Le '+40% pertinence' est invérifiable sans métrique (NDCG@k, precision@k, taux de clic) ni protocole d’évaluation; à challenger en entretien.",
|
| 566 |
+
"RAG très 'standard LangChain + vector DB' sans éléments avancés (reranker, hybrid search, chunking strategy, eval harness, feedback loop).",
|
| 567 |
+
"Maturité et conformité: scraping Selenium sans mention de robustesse, légal/TOS, ni industrialisation (scheduler, retries, monitoring)."
|
| 568 |
+
],
|
| 569 |
+
"note_globale": 61,
|
| 570 |
+
"verdict_recruteur": "Bon projet RAG pragmatique et utile, mais techniquement assez standard. Je le valorise comme preuve de compréhension des briques RAG et d’un mini-pipeline, pas comme démonstration d’architecture avancée. Si le candidat peut expliquer clairement la métrique du '+40%' et une stratégie d’industrialisation, la note remonte nettement."
|
| 571 |
+
}
|
| 572 |
+
],
|
| 573 |
+
"coherence_globale_projets": {},
|
| 574 |
+
"conseils_amelioration": [
|
| 575 |
+
"Sécuriser la lisibilité ATS : sortir d’une mise en page en colonnes et garantir un flux linéaire (les sections LIENS/COMPÉTENCES ne doivent pas apparaître au milieu de l’EXPERIENCE dans l’extraction texte). Si vous gardez les ancres, ajoutez aussi les URL en clair (ex. linkedin.com/in/… ; airh.online ; founderdashboard.vercel.app).",
|
| 576 |
+
"Ajouter 6–10 métriques techniques ‘signature’ sur Enedis + AIRH : temps d’exécution avant/après des flows Dataiku, volume de données, temps de réponse RAG, coût par requête, adoption (utilisateurs actifs), fiabilité (taux d’échec), et côté AIRH latence multi-agents + coût inference + taux de succès des runs.",
|
| 577 |
+
"Nettoyer la section compétences : soit retirer les outils non prouvés, soit ajouter une ligne de preuve par outil (ex. ‘DBT : modèle X, 12 tests, docs’, ‘Docker : image FastAPI, docker-compose’, ‘CI/CD : GitHub Actions avec lint+tests+deploy’). Objectif : zéro skill “hors contexte”.",
|
| 578 |
+
"Sur AIRH, expliciter 3–5 décisions d’architecture et leurs compromis (ex. choix Postgres vs Mongo, stockage MinIO, orchestration Mage.ai, stratégie d’indexation/search, observabilité/trace Langfuse) + ce que vous feriez différemment à l’échelle (10k CV/jour, multi-tenant, files/queues)."
|
| 579 |
+
]
|
| 580 |
+
}
|
| 581 |
+
}
|