Spaces:
Running
Running
quentinL52 commited on
Commit ·
771c0b9
1
Parent(s): f2cc0b6
update
Browse files- agents_trace.log +0 -0
- src/config/tasks.yaml +21 -37
- src/parser_flow/CV_agent_flow.py +111 -103
- src/services/cv_service.py +33 -17
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
|
| 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,
|
| 353 |
-
1.
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 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 |
-
-
|
| 369 |
-
-
|
| 370 |
-
-
|
|
|
|
|
|
|
| 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": "
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
}}
|
| 392 |
],
|
| 393 |
"coherence_globale": {{
|
| 394 |
"score": 85,
|
| 395 |
-
"commentaire": "
|
| 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
|
| 4 |
-
Phase 2
|
| 5 |
-
Phase
|
|
|
|
| 6 |
|
| 7 |
-
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
import json
|
|
@@ -246,7 +248,66 @@ class CVAgentOrchestrator:
|
|
| 246 |
return self._aggregate_extraction_results(results_map)
|
| 247 |
|
| 248 |
# ──────────────────────────────────────────────
|
| 249 |
-
# PHASE
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 260 |
|
| 261 |
-
|
| 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 |
-
|
| 295 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 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[:
|
| 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 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 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
|
| 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 |
-
#
|
| 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
|
| 4 |
-
1
|
| 5 |
-
2
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 38 |
-
extraction
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
)
|
| 41 |
|
| 42 |
-
|
|
|
|
| 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":
|
| 55 |
-
"langues":
|
| 56 |
-
"compétences":
|
| 57 |
-
"expériences":
|
| 58 |
-
"projets":
|
| 59 |
-
"formations":
|
| 60 |
-
"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 |
|