quentinL52 commited on
Commit
771c0b9
·
1 Parent(s): f2cc0b6
agents_trace.log ADDED
File without changes
src/config/tasks.yaml CHANGED
@@ -334,65 +334,49 @@ cv_quality_task:
334
 
335
  project_analysis_task:
336
  description: >
337
- Évalue CHAQUE projet du CV, fournis une critique objective et complète,
338
- et recommande quels projets mettre en avant pour le poste visé.
339
 
340
  POSTE VISÉ : "{poste_vise}"
341
 
342
  RÉFÉRENTIEL DU MÉTIER VISÉ (compétences et outils attendus) :
343
  {metier_reference_detail}
344
 
345
- EXPÉRIENCES DU CANDIDAT : {experiences_summary}
346
-
347
  PROJETS PROFESSIONNELS : {professional_projects}
348
  PROJETS PERSONNELS : {personal_projects}
349
 
350
  RECONVERSION : {reconversion_data}
351
 
352
- Pour CHAQUE projet, analyse EN PROFONDEUR :
353
- 1. COHÉRENCE AVEC LE POSTE VISÉ : Le domaine et les technos sont-ils pertinents ?
354
- Compare avec les compétences et outils du référentiel métier ci-dessus.
355
- 2. QUALITÉ DE DESCRIPTION : Est-ce bien décrit ? Y a-t-il des résultats MESURABLES
356
- et des métriques techniques spécifiques (performance, volume, impact) ?
357
- 3. COMPLEXITÉ TECHNIQUE : Trivial vs ambitieux. Évalue l'architecture, les choix techniques.
358
- 4. IMPACT DÉMONTRÉ : Métriques, utilisateurs, déploiement en production ?
359
- 5. TECHNOLOGIES : Actuelles et recherchées pour le poste visé ?
360
-
361
- RECOMMANDATION DE MISE EN AVANT :
362
- - Classe les projets par ORDRE DE PRIORITÉ pour le poste visé.
363
- - Pour chaque projet, explique POURQUOI il devrait être mis en avant (ou pas) pour ce poste.
364
- - Donne des conseils CONCRETS pour améliorer la description de chaque projet
365
- (quelles métriques ajouter, quels aspects techniques détailler, quels résultats valoriser).
366
 
367
- RÈGLES :
368
- - Score de cohérence de 0 à 100 pour chaque projet.
369
- - Si un projet semble artificiel ou trop vague, signale-le.
370
- - Les projets doivent raconter une histoire cohérente avec le profil global.
 
 
371
  expected_output: >
372
  JSON : {{
373
  "analyse_projets": [
374
  {{
375
  "titre": "Dashboard RH",
376
- "type": "professional",
377
- "score_coherence": 90,
378
- "points_forts": ["Technologies pertinentes", "Impact mesurable"],
379
- "points_amelioration": ["Ajouter des métriques de performance spécifiques"],
380
- "coherence_avec_poste_vise": "Très cohérent - projet BI directement lié au poste",
381
- "technologies_pertinentes": true,
382
- "complexite": "moyenne",
383
- "conseils_description": ["Préciser le volume de données", "Ajouter le temps de génération"]
384
- }}
385
- ],
386
- "ordre_mise_en_avant": [
387
- {{
388
- "titre": "Projet X",
389
  "rang": 1,
390
- "raison": "Ce projet démontre directement les compétences clés du poste visé..."
 
 
 
 
391
  }}
392
  ],
393
  "coherence_globale": {{
394
  "score": 85,
395
- "commentaire": "Les projets racontent une histoire cohérente..."
396
  }}
397
  }}
398
 
 
334
 
335
  project_analysis_task:
336
  description: >
337
+ Évalue CHAQUE projet du CV et détermine leur pertinence pour le poste visé.
 
338
 
339
  POSTE VISÉ : "{poste_vise}"
340
 
341
  RÉFÉRENTIEL DU MÉTIER VISÉ (compétences et outils attendus) :
342
  {metier_reference_detail}
343
 
 
 
344
  PROJETS PROFESSIONNELS : {professional_projects}
345
  PROJETS PERSONNELS : {personal_projects}
346
 
347
  RECONVERSION : {reconversion_data}
348
 
349
+ Pour CHAQUE projet, fournis :
350
+ 1. score_coherence (0-100) : cohérence avec le poste visé et le référentiel métier
351
+ 2. rang : classement par ORDRE DE PERTINENCE pour le poste (1 = le plus pertinent pour ce poste)
352
+ 3. raison : explication CONCISE de pourquoi ce projet doit être mis en avant (ou pas) pour ce poste visé
353
+ 4. points_forts : atouts concrets (technologies démontrées, impact, qualité)
354
+ 5. points_amelioration : ce qui manque pour convaincre (métriques, résultats, détails techniques)
355
+ 6. conseils_description : conseils CONCRETS pour améliorer la description
356
+ (métriques à ajouter, aspects techniques à détailler, résultats à valoriser)
 
 
 
 
 
 
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": "Dashboard RH",
 
 
 
 
 
 
 
 
 
 
 
 
 
369
  "rang": 1,
370
+ "raison": "Projet BI directement aligné avec les outils et missions du poste visé",
371
+ "score_coherence": 90,
372
+ "points_forts": ["SQL et Power BI maîtrisés et démontrés", "Impact mesurable sur les décisions RH"],
373
+ "points_amelioration": ["Ajouter le volume de données traité", "Mentionner le temps de chargement"],
374
+ "conseils_description": ["Préciser le volume de données traité (ex: 500k lignes)", "Ajouter une métrique de performance"]
375
  }}
376
  ],
377
  "coherence_globale": {{
378
  "score": 85,
379
+ "commentaire": "Ensemble de projets cohérent avec le poste visé"
380
  }}
381
  }}
382
 
src/parser_flow/CV_agent_flow.py CHANGED
@@ -1,10 +1,12 @@
1
  """
2
  Orchestrateur CV enrichi avec 3 phases :
3
- Phase 1 : Découpage du CV en sections
4
- Phase 2 : Extraction parallèle (8 agents existants)
5
- Phase 3 : Analyse & Recommandation parallèle (5 nouveaux agents)
 
6
 
7
- Produit un JSON en 2 parties : informations + recommandations.
 
8
  """
9
 
10
  import json
@@ -246,7 +248,66 @@ class CVAgentOrchestrator:
246
  return self._aggregate_extraction_results(results_map)
247
 
248
  # ──────────────────────────────────────────────
249
- # PHASE 3 : Analyse & Recommandation (5 agents)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
  # ──────────────────────────────────────────────
251
 
252
  async def analyze_and_recommend(
@@ -255,12 +316,15 @@ class CVAgentOrchestrator:
255
  sections: Dict[str, str],
256
  extraction: Dict[str, Any],
257
  cv_raw_start: str = "",
 
258
  ) -> Dict[str, Any]:
259
- """Exécute les 4 tâches d'analyse en 2 étapes optimisées.
260
 
261
- Étape 3a : header_analyzer seul (rapide, nécessaire pour tous les autres)
262
- Étape 3b : 3 agents en parallèle (quality, metier, project)
263
  """
 
 
 
264
 
265
  candidat = extraction.get("candidat", {})
266
  competences = candidat.get("compétences", {})
@@ -269,11 +333,9 @@ class CVAgentOrchestrator:
269
  skills_with_context = competences.get("skills_with_context", [])
270
  reconversion = candidat.get("reconversion", {})
271
 
272
- # Identifier les domaines de compétences et méthodologies
273
  skill_domains = self._map_skills_to_domains(hard_skills)
274
  methodologies = self._extract_methodologies(hard_skills, skill_domains)
275
 
276
- # Préparer les résumés pour les prompts
277
  experiences_summary = json.dumps(
278
  candidat.get("expériences", []), ensure_ascii=False
279
  )[:3000]
@@ -285,14 +347,16 @@ class CVAgentOrchestrator:
285
  projets.get("personal", []), ensure_ascii=False
286
  )[:2000]
287
  projects_summary = f"Pro: {professional_projects}\nPerso: {personal_projects}"
288
-
289
  reconversion_data = json.dumps(reconversion, ensure_ascii=False) if reconversion else "{}"
290
 
291
- # Préparer le référentiel métiers complet (30 métiers)
292
  metiers_reference = self._prepare_metiers_for_prompt()
293
 
294
- # Skills résumé pour header analysis (fallback)
295
- skills_summary = ", ".join(hard_skills[:20]) if hard_skills else "Non identifiées"
 
 
 
 
296
 
297
  def create_task_async(task_key, agent, **kwargs):
298
  t_config = self.tasks_config[task_key].copy()
@@ -301,71 +365,15 @@ class CVAgentOrchestrator:
301
  c = Crew(agents=[agent], tasks=[task], verbose=False)
302
  return (task_key, c.kickoff_async())
303
 
304
- # Utilise le texte brut fitz si fourni, sinon fallback sur le début du Markdown
305
- raw_for_header = cv_raw_start[:2000] if cv_raw_start else cv_full_text[:2000]
306
- header_section = sections.get("header", "")
307
- safe_cv_raw = raw_for_header.replace("{", "{{").replace("}", "}}")
308
- safe_header = header_section.replace("{", "{{").replace("}", "}}")
309
- safe_skills = skills_summary.replace("{", "{{").replace("}", "}}")
310
- header_data = {
311
- "poste_vise": "Non identifié",
312
- "niveau_seniorite": "non précisé",
313
- "confiance": 0,
314
- }
315
-
316
- try:
317
- header_coroutine = create_task_async(
318
- "poste_visé_task",
319
- self.header_analyzer,
320
- cv_raw_start=safe_cv_raw,
321
- header=safe_header,
322
- skills_summary=safe_skills,
323
- )
324
- header_result = await header_coroutine[1]
325
-
326
- if header_result:
327
- header_data = self._parse_json_output(
328
- header_result,
329
- {
330
- "poste_vise": "Non identifié",
331
- "niveau_seniorite": "non précisé",
332
- "confiance": 0,
333
- },
334
- )
335
- logger.info(f"Header analyzer result: poste_vise='{header_data.get('poste_vise')}', confiance={header_data.get('confiance')}")
336
- except Exception as e:
337
- logger.error(f"Header analyzer failed: {e}", exc_info=True)
338
-
339
- poste_vise = header_data.get("poste_vise", "Non identifié")
340
- niveau_seniorite = header_data.get("niveau_seniorite", "non précisé")
341
-
342
- # --- Fallback programmatique si le LLM n'a pas trouvé le poste ---
343
- if poste_vise == "Non identifié":
344
- logger.warning("Header analyzer returned 'Non identifié', trying fallback extraction...")
345
- fallback = self._fallback_extract_poste_vise(
346
- cv_full_text, header_section
347
- )
348
- if fallback:
349
- poste_vise = fallback
350
- header_data["poste_vise"] = fallback
351
- header_data["source_detection"] = "fallback_programmatique"
352
- header_data["confiance"] = 70
353
- logger.info(f"Fallback found poste_vise: '{fallback}'")
354
-
355
- # Préparer le détail du métier pour le project_analyzer
356
- metier_reference_detail = self._get_metier_reference_for_poste(poste_vise)
357
-
358
- # --- Étape 3b : 3 agents en parallèle ---
359
  parallel_tasks = [
360
  (
361
  "cv_quality_task",
362
  self.cv_quality_checker,
363
  {
364
- "cv_full_text": cv_full_text[:8000],
365
  "cv_raw_start": safe_cv_raw,
366
- "skills_with_context": json.dumps(
367
- skills_with_context, ensure_ascii=False
368
- )[:2000],
369
  "experiences_summary": experiences_summary,
370
  "projects_summary": projects_summary[:2000],
371
  "niveau_seniorite": niveau_seniorite,
@@ -393,7 +401,6 @@ class CVAgentOrchestrator:
393
  {
394
  "poste_vise": poste_vise,
395
  "metier_reference_detail": metier_reference_detail,
396
- "experiences_summary": experiences_summary,
397
  "professional_projects": professional_projects,
398
  "personal_projects": personal_projects,
399
  "reconversion_data": reconversion_data,
@@ -415,11 +422,34 @@ class CVAgentOrchestrator:
415
  else:
416
  analysis_results[key] = result
417
 
418
- return self._aggregate_recommendations(
419
- analysis_results,
420
- header_data,
421
- poste_vise,
422
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
423
 
424
  # ──────────────────────────────────────────────
425
  # Mapping compétences -> domaines
@@ -611,9 +641,8 @@ class CVAgentOrchestrator:
611
  self,
612
  analysis_results: Dict[str, Any],
613
  header_data: Dict,
614
- poste_vise: str,
615
  ) -> Dict[str, Any]:
616
- """Agrège les résultats d'analyse avec des recommandations orientées projets."""
617
 
618
  def get_parsed(key, default=None):
619
  if key not in analysis_results:
@@ -627,22 +656,11 @@ class CVAgentOrchestrator:
627
  )
628
  project_data = get_parsed("project_analysis_task", {"analyse_projets": []})
629
 
630
- # ── Conseils d'amélioration ────────────────────────────────────────────
631
  conseils = []
632
-
633
- # 1. Conseils qualité CV
634
  if isinstance(quality_data, dict):
635
  conseils.extend(quality_data.get("conseils_prioritaires", []))
636
 
637
- # 2. Projets à mettre en avant
638
- if isinstance(project_data, dict):
639
- for item in (project_data.get("ordre_mise_en_avant", []) or [])[:3]:
640
- if isinstance(item, dict) and item.get("raison"):
641
- conseils.append(
642
- f"Projet prioritaire #{item.get('rang', '?')} à mettre en avant"
643
- f" - '{item.get('titre', '?')}' : {item['raison']}"
644
- )
645
-
646
  return {
647
  "header_analysis": header_data,
648
  "postes_recommandes": (
@@ -661,11 +679,6 @@ class CVAgentOrchestrator:
661
  if isinstance(project_data, dict)
662
  else []
663
  ),
664
- "ordre_mise_en_avant_projets": (
665
- project_data.get("ordre_mise_en_avant", [])
666
- if isinstance(project_data, dict)
667
- else []
668
- ),
669
  "coherence_globale_projets": (
670
  project_data.get("coherence_globale", {})
671
  if isinstance(project_data, dict)
@@ -824,14 +837,9 @@ class CVAgentOrchestrator:
824
  pass
825
  return None
826
 
827
- # Tentative 1 : parse du texte tel quel (gère "JSON : {...}" et JSON propre)
828
  result = _try_parse(raw)
829
  if result is not None:
830
  return result
831
-
832
- # Tentative 2 : le LLM a copié les {{ }} du expected_output YAML.
833
- # ⚠️ On ne remplace QUE si {{ est détecté — évite de casser un JSON
834
- # compact valide du type {"inner": {"key": "val"}} → {"inner": {"key": "val"}
835
  if "{{" in raw:
836
  cleaned = raw.replace("{{", "{").replace("}}", "}")
837
  result = _try_parse(cleaned)
 
1
  """
2
  Orchestrateur CV enrichi avec 3 phases :
3
+ Phase 1 : Découpage du CV en sections (cv_splitter)
4
+ Phase 2 : Extraction parallèle (8 agents)
5
+ Phase 3a : Analyse d'en-tête (run_header_analysis) tourne en // avec Phase 2
6
+ Phase 3b : Analyse & Recommandation — 3 agents en parallèle après Phase 2 + 3a
7
 
8
+ Flux optimisé : Phase 1 → (Phase 2 // Phase 3a) Phase 3b
9
+ Produit un JSON en 2 parties : candidat + recommandations.
10
  """
11
 
12
  import json
 
248
  return self._aggregate_extraction_results(results_map)
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(
 
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", {})
 
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]
 
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()
 
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,
 
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,
 
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
 
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:
 
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
  return {
665
  "header_analysis": header_data,
666
  "postes_recommandes": (
 
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)
 
837
  pass
838
  return None
839
 
 
840
  result = _try_parse(raw)
841
  if result is not None:
842
  return result
 
 
 
 
843
  if "{{" in raw:
844
  cleaned = raw.replace("{{", "{").replace("}}", "}")
845
  result = _try_parse(cleaned)
src/services/cv_service.py CHANGED
@@ -1,11 +1,16 @@
1
  """
2
  Service de parsing et analyse de CV enrichi.
3
- Pipeline en 3 phases :
4
- 1. Découpage en sections (avec extraction brute pour le header)
5
- 2. Extraction parallèle (compétences, expériences, projets, etc.)
6
- 3. Analyse et recommandation (poste visé, matching métiers, qualité CV, projets)
 
 
 
 
7
  """
8
 
 
9
  import logging
10
  from typing import Dict, Any
11
 
@@ -25,39 +30,50 @@ async def parse_cv(pdf_path: str, file_name: str = "") -> Dict[str, Any]:
25
  """
26
  orchestrator = CVAgentOrchestrator()
27
 
28
- # Double extraction :
29
- # - cv_text : Markdown (bon pour la structure des sections)
30
  # - cv_raw_start : texte brut ordonné par position (fiable pour le header/nom/titre)
31
  cv_text = load_pdf(pdf_path)
32
  cv_raw_start = load_pdf_first_page_text(pdf_path)
33
 
 
34
  logger.info("Phase 1 : Découpage du CV en sections...")
35
  sections = await orchestrator.split_cv_sections(cv_text, cv_raw_start=cv_raw_start)
36
 
37
- logger.info("Phase 2 : Extraction parallèle des données...")
38
- extraction = await orchestrator.extract_all_sections(
39
- sections, cv_raw_start=cv_raw_start, file_name=file_name
 
 
 
 
 
 
 
 
40
  )
41
 
42
- logger.info("Phase 3 : Analyse et recommandation...")
 
43
  recommendations = await orchestrator.analyze_and_recommend(
44
  cv_full_text=cv_text,
45
  sections=sections,
46
  extraction=extraction,
47
  cv_raw_start=cv_raw_start,
 
48
  )
49
 
50
  candidat_raw = extraction.get("candidat", {})
51
 
52
  # Assemblage ordonné : identité → langues → compétences → parcours
53
  candidat = {
54
- "first_name": candidat_raw.get("first_name"),
55
- "langues": candidat_raw.get("langues", []),
56
- "compétences": candidat_raw.get("compétences", {}),
57
- "expériences": candidat_raw.get("expériences", []),
58
- "projets": candidat_raw.get("projets", {}),
59
- "formations": candidat_raw.get("formations", []),
60
- "etudiant": candidat_raw.get("etudiant", {}),
61
  "reconversion": candidat_raw.get("reconversion", {}),
62
  }
63
 
 
1
  """
2
  Service de parsing et analyse de CV enrichi.
3
+ Pipeline optimisé :
4
+ Phase 1 : Découpage en sections
5
+ Phase 2 : Extraction parallèle (8 agents) en // avec Phase 3a
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
+ Flux : Phase 1 → asyncio.gather(Phase 2, Phase 3a) → Phase 3b
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
 
 
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
+ # ── Phase 2 + Phase 3a en PARALLÈLE ──────────────────────────────────────────────────
44
+ # Phase 2 : 8 agents d'extraction (skills, expériences, projets, etc.)
45
+ # Phase 3a : header_analyzer (poste visé) — ne dépend que de sections + cv_raw_start
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
+ header_data=header_data,
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