Spaces:
Sleeping
Sleeping
ai_interview
#1
by
QuentinL52
- opened
- Dockerfile +17 -12
- main.py +236 -39
- prompts/rag_prompt_old.txt +35 -89
- requirements.txt +1 -2
- src/agents/cv_agents.py +0 -251
- src/agents/scoring_agent.py +0 -183
- src/core/rag_handler.py +0 -130
- src/{agents → crew}/__init__.py +0 -0
- src/crew/__pycache__/__init__.cpython-311.pyc +0 -0
- src/crew/__pycache__/__init__.cpython-312.pyc +0 -0
- src/crew/__pycache__/__init__.cpython-312.pycZone.Identifier +2 -0
- src/crew/__pycache__/agents.cpython-311.pyc +0 -0
- src/crew/__pycache__/agents.cpython-312.pyc +0 -0
- src/crew/__pycache__/agents.cpython-312.pycZone.Identifier +2 -0
- src/crew/__pycache__/analysis_crew.cpython-312.pyc +0 -0
- src/crew/__pycache__/analysis_crew.cpython-312.pycZone.Identifier +2 -0
- src/crew/__pycache__/crew_pool.cpython-311.pyc +0 -0
- src/crew/__pycache__/crew_pool.cpython-312.pyc +0 -0
- src/crew/__pycache__/crew_pool.cpython-312.pycZone.Identifier +2 -0
- src/crew/__pycache__/tasks.cpython-312.pyc +0 -0
- src/crew/__pycache__/tasks.cpython-312.pycZone.Identifier +2 -0
- src/crew/agents.py +75 -0
- src/crew/crew_pool.py +81 -0
- src/crew/tasks.py +184 -0
- src/cv_parsing_agents.py +291 -0
- src/{core/deep_learning_analyzer.py → deep_learning_analyzer.py} +57 -56
- src/{core → interview_simulator}/__init__.py +0 -0
- src/interview_simulator/__pycache__/__init__.cpython-312.pyc +0 -0
- src/interview_simulator/__pycache__/entretient_version_prod.cpython-312.pyc +0 -0
- src/{services/interview_service.py → interview_simulator/entretient_version_prod.py} +42 -50
- src/models.py +0 -37
- src/rag_handler.py +202 -0
- src/scoring_engine.py +102 -0
- src/services/__init__.py +0 -0
- src/services/analysis_service.py +0 -97
- src/services/graph_service.py +0 -193
- tools/analysis_tools.py +0 -56
Dockerfile
CHANGED
|
@@ -1,39 +1,44 @@
|
|
| 1 |
FROM python:3.11-slim
|
| 2 |
|
|
|
|
| 3 |
ENV PYTHONUNBUFFERED=1 \
|
| 4 |
PYTHONDONTWRITEBYTECODE=1 \
|
| 5 |
PIP_NO_CACHE_DIR=1 \
|
| 6 |
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
| 7 |
-
PYTHONPATH=/app
|
| 8 |
-
|
| 9 |
-
|
|
|
|
| 10 |
TRANSFORMERS_CACHE=/tmp/cache \
|
| 11 |
HF_HUB_CACHE=/tmp/cache/hub \
|
| 12 |
-
SENTENCE_TRANSFORMERS_HOME=/tmp/cache/sentence_transformers
|
| 13 |
-
XDG_DATA_HOME="/app/.local/share" \
|
| 14 |
-
CREWAI_TELEMETRY_OPT_OUT=true
|
| 15 |
|
|
|
|
| 16 |
WORKDIR /app
|
| 17 |
|
|
|
|
| 18 |
RUN apt-get update && apt-get install -y \
|
| 19 |
curl \
|
| 20 |
&& rm -rf /var/lib/apt/lists/*
|
| 21 |
|
|
|
|
| 22 |
RUN pip install uv
|
| 23 |
|
|
|
|
| 24 |
COPY requirements.txt .
|
| 25 |
RUN uv pip install --system --no-cache -r requirements.txt
|
| 26 |
|
|
|
|
| 27 |
COPY . .
|
| 28 |
|
|
|
|
| 29 |
RUN mkdir -p /tmp/cache/hub \
|
| 30 |
/tmp/cache/sentence_transformers \
|
| 31 |
-
/tmp/vector_store \
|
| 32 |
-
|
| 33 |
-
/app/.chroma
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
RUN chmod -R 777 /tmp /app
|
| 37 |
|
|
|
|
| 38 |
EXPOSE 7860
|
|
|
|
|
|
|
| 39 |
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
|
|
|
| 1 |
FROM python:3.11-slim
|
| 2 |
|
| 3 |
+
# Variables d'environnement
|
| 4 |
ENV PYTHONUNBUFFERED=1 \
|
| 5 |
PYTHONDONTWRITEBYTECODE=1 \
|
| 6 |
PIP_NO_CACHE_DIR=1 \
|
| 7 |
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
| 8 |
+
PYTHONPATH=/app
|
| 9 |
+
|
| 10 |
+
# Variables pour les modèles ML - utilisation de /tmp (toujours writable)
|
| 11 |
+
ENV HF_HOME=/tmp/cache \
|
| 12 |
TRANSFORMERS_CACHE=/tmp/cache \
|
| 13 |
HF_HUB_CACHE=/tmp/cache/hub \
|
| 14 |
+
SENTENCE_TRANSFORMERS_HOME=/tmp/cache/sentence_transformers
|
|
|
|
|
|
|
| 15 |
|
| 16 |
+
# Répertoire de travail
|
| 17 |
WORKDIR /app
|
| 18 |
|
| 19 |
+
# Installer les dépendances système
|
| 20 |
RUN apt-get update && apt-get install -y \
|
| 21 |
curl \
|
| 22 |
&& rm -rf /var/lib/apt/lists/*
|
| 23 |
|
| 24 |
+
# Installer uv
|
| 25 |
RUN pip install uv
|
| 26 |
|
| 27 |
+
# Copier et installer les dépendances Python
|
| 28 |
COPY requirements.txt .
|
| 29 |
RUN uv pip install --system --no-cache -r requirements.txt
|
| 30 |
|
| 31 |
+
# Copier le code source
|
| 32 |
COPY . .
|
| 33 |
|
| 34 |
+
# Créer les répertoires de cache dans /tmp (toujours writable)
|
| 35 |
RUN mkdir -p /tmp/cache/hub \
|
| 36 |
/tmp/cache/sentence_transformers \
|
| 37 |
+
/tmp/vector_store && \
|
| 38 |
+
chmod -R 777 /tmp
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
+
# Exposer le port HF Spaces
|
| 41 |
EXPOSE 7860
|
| 42 |
+
|
| 43 |
+
# Commande de démarrage
|
| 44 |
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
main.py
CHANGED
|
@@ -1,27 +1,56 @@
|
|
|
|
|
|
|
|
| 1 |
import os
|
| 2 |
import logging
|
| 3 |
-
from fastapi import FastAPI,
|
| 4 |
-
from fastapi.
|
| 5 |
from fastapi.middleware.cors import CORSMiddleware
|
| 6 |
-
from pydantic import BaseModel
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
|
|
|
| 17 |
app = FastAPI(
|
| 18 |
-
title="Interview
|
| 19 |
-
description="API
|
| 20 |
-
version="1.
|
| 21 |
docs_url="/docs",
|
| 22 |
redoc_url="/redoc"
|
| 23 |
)
|
| 24 |
|
|
|
|
| 25 |
app.add_middleware(
|
| 26 |
CORSMiddleware,
|
| 27 |
allow_origins=["*"],
|
|
@@ -30,44 +59,212 @@ app.add_middleware(
|
|
| 30 |
allow_headers=["*"],
|
| 31 |
)
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
class HealthCheck(BaseModel):
|
| 34 |
status: str = "ok"
|
|
|
|
|
|
|
|
|
|
| 35 |
|
|
|
|
| 36 |
@app.get("/", response_model=HealthCheck, tags=["Status"])
|
| 37 |
async def health_check():
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
"""
|
| 43 |
-
This endpoint receives the interview data, instantiates the graph processor
|
| 44 |
-
and starts the conversation.
|
| 45 |
-
"""
|
| 46 |
-
logger = logging.getLogger(__name__)
|
| 47 |
try:
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
-
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
except Exception as e:
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
if __name__ == "__main__":
|
| 71 |
import uvicorn
|
| 72 |
-
|
| 73 |
-
uvicorn.run(app, host="0.0.0.0", port=port)
|
|
|
|
| 1 |
+
import tempfile
|
| 2 |
+
import requests
|
| 3 |
import os
|
| 4 |
import logging
|
| 5 |
+
from fastapi import FastAPI, UploadFile, File, HTTPException
|
| 6 |
+
from fastapi.concurrency import run_in_threadpool
|
| 7 |
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
+
from pydantic import BaseModel, Field
|
| 9 |
+
from typing import List, Dict, Any, Optional
|
| 10 |
+
os.environ['HOME'] = '/tmp'
|
| 11 |
+
# Configuration du logging
|
| 12 |
+
logging.basicConfig(level=logging.INFO)
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
|
| 15 |
+
# Imports avec gestion d'erreurs robuste
|
| 16 |
+
try:
|
| 17 |
+
from src.cv_parsing_agents import CvParserAgent, create_fallback_cv_data
|
| 18 |
+
CV_PARSING_AVAILABLE = True
|
| 19 |
+
logger.info("✅ CV Parsing disponible")
|
| 20 |
+
except Exception as e:
|
| 21 |
+
logger.error(f"❌ CV Parsing indisponible: {e}")
|
| 22 |
+
CV_PARSING_AVAILABLE = False
|
| 23 |
+
CvParserAgent = None
|
| 24 |
+
create_fallback_cv_data = None
|
| 25 |
|
| 26 |
+
try:
|
| 27 |
+
from src.interview_simulator.entretient_version_prod import InterviewProcessor
|
| 28 |
+
INTERVIEW_AVAILABLE = True
|
| 29 |
+
logger.info("✅ Interview Simulator disponible")
|
| 30 |
+
except Exception as e:
|
| 31 |
+
logger.error(f"❌ Interview Simulator indisponible: {e}")
|
| 32 |
+
INTERVIEW_AVAILABLE = False
|
| 33 |
+
InterviewProcessor = None
|
| 34 |
|
| 35 |
+
try:
|
| 36 |
+
from src.scoring_engine import ContextualScoringEngine
|
| 37 |
+
SCORING_AVAILABLE = True
|
| 38 |
+
logger.info("✅ Scoring Engine disponible")
|
| 39 |
+
except Exception as e:
|
| 40 |
+
logger.error(f"❌ Scoring Engine indisponible: {e}")
|
| 41 |
+
SCORING_AVAILABLE = False
|
| 42 |
+
ContextualScoringEngine = None
|
| 43 |
|
| 44 |
+
# Application FastAPI
|
| 45 |
app = FastAPI(
|
| 46 |
+
title="AIrh Interview Assistant",
|
| 47 |
+
description="API pour l'analyse de CV et la simulation d'entretiens d'embauche",
|
| 48 |
+
version="1.3.0",
|
| 49 |
docs_url="/docs",
|
| 50 |
redoc_url="/redoc"
|
| 51 |
)
|
| 52 |
|
| 53 |
+
# Configuration CORS pour HF Spaces
|
| 54 |
app.add_middleware(
|
| 55 |
CORSMiddleware,
|
| 56 |
allow_origins=["*"],
|
|
|
|
| 59 |
allow_headers=["*"],
|
| 60 |
)
|
| 61 |
|
| 62 |
+
# Configuration API Celery
|
| 63 |
+
CELERY_API_URL = os.getenv("CELERY_API_URL", "https://celery-7as1.onrender.com")
|
| 64 |
+
|
| 65 |
+
# Modèles Pydantic
|
| 66 |
+
class InterviewRequest(BaseModel):
|
| 67 |
+
user_id: str = Field(..., example="user_12345")
|
| 68 |
+
job_offer_id: str = Field(..., example="job_offer_abcde")
|
| 69 |
+
cv_document: Dict[str, Any]
|
| 70 |
+
job_offer: Dict[str, Any]
|
| 71 |
+
messages: List[Dict[str, Any]]
|
| 72 |
+
conversation_history: List[Dict[str, Any]]
|
| 73 |
+
|
| 74 |
+
class AnalysisRequest(BaseModel):
|
| 75 |
+
conversation_history: List[Dict[str, Any]]
|
| 76 |
+
job_description_text: str
|
| 77 |
+
candidate_id: Optional[str] = None
|
| 78 |
+
|
| 79 |
+
class TaskResponse(BaseModel):
|
| 80 |
+
task_id: str
|
| 81 |
+
status: str
|
| 82 |
+
result: Any = None
|
| 83 |
+
message: Optional[str] = None
|
| 84 |
+
|
| 85 |
class HealthCheck(BaseModel):
|
| 86 |
status: str = "ok"
|
| 87 |
+
celery_api_status: Optional[str] = None
|
| 88 |
+
services: Dict[str, bool] = Field(default_factory=dict)
|
| 89 |
+
message: str = "API AIrh fonctionnelle"
|
| 90 |
|
| 91 |
+
# Endpoints
|
| 92 |
@app.get("/", response_model=HealthCheck, tags=["Status"])
|
| 93 |
async def health_check():
|
| 94 |
+
"""Health check de l'API avec test de connectivité Celery."""
|
| 95 |
+
|
| 96 |
+
# Test connexion Celery
|
| 97 |
+
celery_status = "unknown"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
try:
|
| 99 |
+
response = requests.get(f"{CELERY_API_URL}/", timeout=5)
|
| 100 |
+
celery_status = "connected" if response.status_code == 200 else "error"
|
| 101 |
+
except Exception:
|
| 102 |
+
celery_status = "disconnected"
|
| 103 |
+
|
| 104 |
+
services = {
|
| 105 |
+
"cv_parsing": CV_PARSING_AVAILABLE,
|
| 106 |
+
"interview_simulation": INTERVIEW_AVAILABLE,
|
| 107 |
+
"scoring_engine": SCORING_AVAILABLE,
|
| 108 |
+
"celery_api": celery_status == "connected"
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
return HealthCheck(
|
| 112 |
+
celery_api_status=celery_status,
|
| 113 |
+
services=services
|
| 114 |
+
)
|
| 115 |
|
| 116 |
+
@app.post("/parse-cv/", tags=["CV Parsing"])
|
| 117 |
+
async def parse_cv(file: UploadFile = File(...)):
|
| 118 |
+
"""Analyse un CV PDF et extrait les informations structurées."""
|
| 119 |
+
|
| 120 |
+
if not CV_PARSING_AVAILABLE:
|
| 121 |
+
# Fallback si le parsing n'est pas disponible
|
| 122 |
+
return create_fallback_cv_data() if create_fallback_cv_data else {
|
| 123 |
+
"error": "Service de parsing de CV temporairement indisponible",
|
| 124 |
+
"candidat": {
|
| 125 |
+
"informations_personnelles": {"nom": "Test User"},
|
| 126 |
+
"compétences": {"hard_skills": [], "soft_skills": []}
|
| 127 |
+
}
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
if file.content_type != "application/pdf":
|
| 131 |
+
raise HTTPException(status_code=400, detail="Fichier PDF requis")
|
| 132 |
+
|
| 133 |
+
tmp_path = None
|
| 134 |
+
try:
|
| 135 |
+
# Sauvegarder le fichier temporairement
|
| 136 |
+
contents = await file.read()
|
| 137 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp:
|
| 138 |
+
tmp.write(contents)
|
| 139 |
+
tmp_path = tmp.name
|
| 140 |
+
|
| 141 |
+
# Traiter le CV
|
| 142 |
+
cv_agent = CvParserAgent(pdf_path=tmp_path)
|
| 143 |
+
parsed_data = await run_in_threadpool(cv_agent.process)
|
| 144 |
+
|
| 145 |
+
if not parsed_data and create_fallback_cv_data:
|
| 146 |
+
parsed_data = create_fallback_cv_data(tmp_path)
|
| 147 |
+
|
| 148 |
+
# Scoring si disponible
|
| 149 |
+
if SCORING_AVAILABLE and ContextualScoringEngine and parsed_data:
|
| 150 |
+
try:
|
| 151 |
+
scoring_engine = ContextualScoringEngine(parsed_data)
|
| 152 |
+
scored_data = await run_in_threadpool(scoring_engine.calculate_scores)
|
| 153 |
+
if parsed_data.get("candidat"):
|
| 154 |
+
parsed_data["candidat"].update(scored_data)
|
| 155 |
+
except Exception as e:
|
| 156 |
+
logger.warning(f"Scoring échoué: {e}")
|
| 157 |
+
|
| 158 |
+
return parsed_data
|
| 159 |
+
|
| 160 |
+
except Exception as e:
|
| 161 |
+
logger.error(f"Erreur parsing CV: {e}")
|
| 162 |
+
if create_fallback_cv_data:
|
| 163 |
+
return create_fallback_cv_data(tmp_path)
|
| 164 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 165 |
+
|
| 166 |
+
finally:
|
| 167 |
+
if tmp_path and os.path.exists(tmp_path):
|
| 168 |
+
try:
|
| 169 |
+
os.remove(tmp_path)
|
| 170 |
+
except Exception:
|
| 171 |
+
pass
|
| 172 |
|
| 173 |
+
@app.post("/simulate-interview/", tags=["Interview"])
|
| 174 |
+
async def simulate_interview(request: InterviewRequest):
|
| 175 |
+
"""Gère une conversation d'entretien d'embauche."""
|
| 176 |
+
|
| 177 |
+
if not INTERVIEW_AVAILABLE:
|
| 178 |
+
raise HTTPException(
|
| 179 |
+
status_code=503,
|
| 180 |
+
detail="Service de simulation d'entretien indisponible"
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
try:
|
| 184 |
+
processor = InterviewProcessor(
|
| 185 |
+
cv_document=request.cv_document,
|
| 186 |
+
job_offer=request.job_offer,
|
| 187 |
+
conversation_history=request.conversation_history
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
result = await run_in_threadpool(processor.run, messages=request.messages)
|
| 191 |
+
return {"response": result["messages"][-1].content}
|
| 192 |
+
|
| 193 |
+
except Exception as e:
|
| 194 |
+
logger.error(f"Erreur simulation entretien: {e}")
|
| 195 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 196 |
|
| 197 |
+
@app.post("/trigger-analysis/", response_model=TaskResponse, status_code=202, tags=["Analysis"])
|
| 198 |
+
async def trigger_analysis(request: AnalysisRequest):
|
| 199 |
+
"""Déclenche une analyse asynchrone via l'API Celery."""
|
| 200 |
+
|
| 201 |
+
try:
|
| 202 |
+
response = requests.post(
|
| 203 |
+
f"{CELERY_API_URL}/trigger-analysis",
|
| 204 |
+
json=request.dict(),
|
| 205 |
+
headers={"Content-Type": "application/json"},
|
| 206 |
+
timeout=30
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
if response.status_code == 202:
|
| 210 |
+
data = response.json()
|
| 211 |
+
return TaskResponse(
|
| 212 |
+
task_id=data["task_id"],
|
| 213 |
+
status=data["status"],
|
| 214 |
+
message="Analyse démarrée"
|
| 215 |
+
)
|
| 216 |
+
else:
|
| 217 |
+
raise HTTPException(status_code=503, detail="Service d'analyse indisponible")
|
| 218 |
+
|
| 219 |
+
except requests.RequestException:
|
| 220 |
+
raise HTTPException(status_code=503, detail="API Celery inaccessible")
|
| 221 |
+
except Exception as e:
|
| 222 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 223 |
|
| 224 |
+
@app.get("/analysis-status/{task_id}", response_model=TaskResponse, tags=["Analysis"])
|
| 225 |
+
async def get_analysis_status(task_id: str):
|
| 226 |
+
"""Récupère le statut d'une analyse."""
|
| 227 |
+
|
| 228 |
+
try:
|
| 229 |
+
response = requests.get(f"{CELERY_API_URL}/task-status/{task_id}", timeout=10)
|
| 230 |
+
|
| 231 |
+
if response.status_code == 200:
|
| 232 |
+
data = response.json()
|
| 233 |
+
return TaskResponse(
|
| 234 |
+
task_id=task_id,
|
| 235 |
+
status=data["status"],
|
| 236 |
+
result=data.get("result"),
|
| 237 |
+
message=data.get("progress", "Statut récupéré")
|
| 238 |
+
)
|
| 239 |
+
else:
|
| 240 |
+
raise HTTPException(status_code=503, detail="Service d'analyse indisponible")
|
| 241 |
+
|
| 242 |
+
except requests.RequestException:
|
| 243 |
+
raise HTTPException(status_code=503, detail="API Celery inaccessible")
|
| 244 |
except Exception as e:
|
| 245 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 246 |
+
|
| 247 |
+
# Endpoint de debug pour HF Spaces
|
| 248 |
+
@app.get("/debug", tags=["Debug"])
|
| 249 |
+
async def debug_info():
|
| 250 |
+
"""Informations de debug pour le déploiement."""
|
| 251 |
+
return {
|
| 252 |
+
"environment": {
|
| 253 |
+
"HF_HOME": os.getenv("HF_HOME"),
|
| 254 |
+
"CELERY_API_URL": CELERY_API_URL,
|
| 255 |
+
"PYTHONPATH": os.getenv("PYTHONPATH")
|
| 256 |
+
},
|
| 257 |
+
"services": {
|
| 258 |
+
"cv_parsing": CV_PARSING_AVAILABLE,
|
| 259 |
+
"interview_simulation": INTERVIEW_AVAILABLE,
|
| 260 |
+
"scoring_engine": SCORING_AVAILABLE
|
| 261 |
+
},
|
| 262 |
+
"cache_dirs": {
|
| 263 |
+
"/tmp/cache": os.path.exists("/tmp/cache"),
|
| 264 |
+
"/app/cache": os.path.exists("/app/cache")
|
| 265 |
+
}
|
| 266 |
+
}
|
| 267 |
|
| 268 |
if __name__ == "__main__":
|
| 269 |
import uvicorn
|
| 270 |
+
uvicorn.run(app, host="0.0.0.0", port=7860)
|
|
|
prompts/rag_prompt_old.txt
CHANGED
|
@@ -1,89 +1,35 @@
|
|
| 1 |
-
Tu es un recruteur expert, menant un premier entretien de qualification. Ton ton est professionnel mais engageant.
|
| 2 |
-
|
| 3 |
-
CONTEXTE DE L'ENTRETIEN
|
| 4 |
-
Tu dois baser ta conversation sur les informations suivantes :
|
| 5 |
-
|
| 6 |
-
1. Informations à utiliser activement dans la conversation :
|
| 7 |
-
Entreprise : {entreprise}
|
| 8 |
-
Intitulé du poste : {poste}
|
| 9 |
-
Équipe / Pôle : {pole}
|
| 10 |
-
Missions principales : {mission}
|
| 11 |
-
|
| 12 |
-
2. Informations pour guider tes questions :
|
| 13 |
-
Profil recherché : {profil_recherche}
|
| 14 |
-
Compétences clés attendues : {competences}
|
| 15 |
-
|
| 16 |
-
3. Informations sur le candidat :
|
| 17 |
-
Les données de son CV sont : {cv}
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
2. Prise en compte de la reconversion (si détectée) :
|
| 37 |
-
- Explore les motivations du changement de carrière
|
| 38 |
-
- Identifie les compétences transférables
|
| 39 |
-
- Évalue l'engagement dans la nouvelle voie
|
| 40 |
-
- Rassure sur la valorisation de l'expérience passée
|
| 41 |
-
|
| 42 |
-
3. Questions adaptées au contexte :
|
| 43 |
-
- Si le candidat a de l'expérience dans le domaine : Questions sur l'évolution, les projets marquants
|
| 44 |
-
- Si le candidat est en reconversion : Questions sur la transition, l'apprentissage, l'adaptation
|
| 45 |
-
|
| 46 |
-
4. Pour le candidat qui a des projets :
|
| 47 |
-
- Essaye de creuser les skills utilisé pour ce projet
|
| 48 |
-
- n'hésite pas a poser des questions technique sur les skills d'un projet
|
| 49 |
-
|
| 50 |
-
DÉROULEMENT DE L'ENTRETIEN
|
| 51 |
-
|
| 52 |
-
1. Déroulement de l'entretien :
|
| 53 |
-
Introduction : Commence par te présenter en utilisant le prénom Roni.
|
| 54 |
-
Présente l'entreprise ({entreprise}) et le contexte du recrutement en t'appuyant sur l'intitulé du poste ({poste}) et les missions ({mission}).
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
2. Présentation du candidat : Ta première question doit inviter le candidat à se présenter.
|
| 58 |
-
|
| 59 |
-
3. Questions adaptées au profil :
|
| 60 |
-
- Utilise l'analyse des compétences pour calibrer tes questions
|
| 61 |
-
- Creuse les compétences avec des questions techniques
|
| 62 |
-
- Explore les motivations si reconversion détectée
|
| 63 |
-
- Adapte ton niveau de questionnement au profil
|
| 64 |
-
|
| 65 |
-
4. Une question à la fois : Pose une seule question à la fois et attends la réponse complète.
|
| 66 |
-
|
| 67 |
-
STYLE ET COMPORTEMENT
|
| 68 |
-
|
| 69 |
-
- Personnalisation : Appelle toujours le candidat par son nom
|
| 70 |
-
- Langage Naturel : Évite le jargon RH, utilise des formulations fluides
|
| 71 |
-
- Écoute active : Montre que tu écoutes avec des relances appropriées
|
| 72 |
-
- Évaluation subtile : Adapte tes questions au niveau détecté sans le mentionner explicitement
|
| 73 |
-
- Conserve toujours une intéraction la plus naturelle et professionelle possible
|
| 74 |
-
|
| 75 |
-
--- CONTEXTE TECHNIQUE POUR L'AGENT (ne pas mentionner à l'utilisateur) ---
|
| 76 |
-
L'ID de l'utilisateur actuel est : {user_id}
|
| 77 |
-
L'ID de l'offre d'emploi actuelle est : {job_offer_id}
|
| 78 |
-
|
| 79 |
-
Quand tu appelleras l'outil 'trigger_interview_analysis', tu devras OBLIGATOIREMENT utiliser ces IDs exacts.
|
| 80 |
-
Tu devras aussi OBLIGATOIREMENT fournir l'argument 'conversation_history' en utilisant l'historique complet des messages de la conversation en cours.
|
| 81 |
-
--- FIN DU CONTEXTE TECHNIQUE ---
|
| 82 |
-
|
| 83 |
-
COMMANDE DE DÉVELOPPEMENT
|
| 84 |
-
Si le dernier message de l'utilisateur est EXACTEMENT la phrase "LANCEMENT_ANALYSE_DEV", tu dois ignorer toutes les autres instructions de conversation, conclure brièvement et immédiatement appeler l'outil `trigger_interview_analysis`.
|
| 85 |
-
|
| 86 |
-
CONCLUSION
|
| 87 |
-
|
| 88 |
-
Quand tu estimes avoir assez d'informations, conclus l'échange de manière positive.
|
| 89 |
-
Action finale OBLIGATOIRE : Appelle l'outil `trigger_interview_analysis` pour lancer l'analyse finale. C'est ta seule et dernière action. Ne réponds rien d'autre.
|
|
|
|
| 1 |
+
Tu es un recruteur expert, menant un premier entretien de qualification. Ton ton est professionnel mais engageant. Ta mission est d'évaluer l'adéquation d'un candidat pour un poste.
|
| 2 |
+
|
| 3 |
+
CONTEXTE DE L'ENTRETIEN
|
| 4 |
+
Tu dois baser ta conversation sur les informations suivantes :
|
| 5 |
+
|
| 6 |
+
1. Informations à utiliser activement dans la conversation :
|
| 7 |
+
Entreprise : {entreprise}
|
| 8 |
+
Intitulé du poste : {poste}
|
| 9 |
+
Équipe / Pôle : {pole}
|
| 10 |
+
Missions principales : {mission}
|
| 11 |
+
|
| 12 |
+
2. Informations pour guider tes questions (à ne PAS mentionner directement) :
|
| 13 |
+
Profil recherché : {profil_recherche}
|
| 14 |
+
Compétences clés attendues : {competences}
|
| 15 |
+
(Utilise ces deux points comme une grille d'analyse interne pour formuler des questions pertinentes. Tes questions doivent permettre de vérifier si le candidat possède ces compétences et correspond au profil.)
|
| 16 |
+
3. Informations sur le candidat :
|
| 17 |
+
Les données de son CV sont : {cv}
|
| 18 |
+
|
| 19 |
+
DIRECTIVES PRÉCISES
|
| 20 |
+
|
| 21 |
+
1. Déroulement de l'entretien :
|
| 22 |
+
Introduction : Commence par te présenter avec un prénom (ex: Camille, Thomas...). Présente l'entreprise ({entreprise}) et le contexte du recrutement en t'appuyant sur l'intitulé du poste ({poste}) et les missions ({mission}).
|
| 23 |
+
Présentation du candidat : Ta toute première question doit inviter le candidat à se présenter. Par exemple : "Pour commencer, parlez-moi un peu de votre parcours."
|
| 24 |
+
Questions ciblées : En te basant sur les compétences et le profil recherché (que tu gardes en tête), pose des questions ouvertes pour évaluer le candidat. Fais des liens entre ses expériences ({cv}) et les missions du poste ({mission}). Par exemple, si une compétence attendue est "l'analyse de données", demande au candidat de décrire un projet où il a dû analyser un ensemble de données complexe.
|
| 25 |
+
Une question à la fois : Pose une seule question à la fois et attends la réponse complète du candidat avant de poursuivre.
|
| 26 |
+
|
| 27 |
+
2. Style et Comportement :
|
| 28 |
+
Personnalisation : Appelle toujours le candidat par son nom (présent dans le CV).
|
| 29 |
+
Langage Naturel : Évite le jargon RH. Utilise des formulations fluides comme "J'ai noté dans votre CV que...", "Racontez-moi l'expérience chez...". Montre que tu écoutes avec des relances comme "D'accord, je vois.", "C'est intéressant.".
|
| 30 |
+
Évaluation subtile : Ne dis jamais "la compétence requise est...". À la place, évalue la compétence à travers des questions situationnelles ou comportementales.
|
| 31 |
+
|
| 32 |
+
3. Conclusion de l'entretien :
|
| 33 |
+
Quand tu estimes avoir assez d'informations, conclus l'échange de manière positive.
|
| 34 |
+
Termine par une phrase de politesse.
|
| 35 |
+
Action finale OBLIGATOIRE : Ta toute dernière phrase, après la politesse, doit être exactement : "nous allons maintenant passer a l'analyse". Juste après, tu dois utiliser l'outil interview_analyser.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
requirements.txt
CHANGED
|
@@ -18,9 +18,8 @@ sentencepiece
|
|
| 18 |
accelerate
|
| 19 |
pypdf
|
| 20 |
python-dotenv
|
| 21 |
-
|
| 22 |
requests
|
| 23 |
faiss-cpu
|
| 24 |
-
langtrace-python-sdk
|
| 25 |
|
| 26 |
httpx==0.28.1
|
|
|
|
| 18 |
accelerate
|
| 19 |
pypdf
|
| 20 |
python-dotenv
|
| 21 |
+
|
| 22 |
requests
|
| 23 |
faiss-cpu
|
|
|
|
| 24 |
|
| 25 |
httpx==0.28.1
|
src/agents/cv_agents.py
DELETED
|
@@ -1,251 +0,0 @@
|
|
| 1 |
-
import json
|
| 2 |
-
import logging
|
| 3 |
-
from typing import Dict, Any, List
|
| 4 |
-
from crewai import Agent, Task, Crew, Process
|
| 5 |
-
|
| 6 |
-
logger = logging.getLogger(__name__)
|
| 7 |
-
|
| 8 |
-
class CVAgentOrchestrator:
|
| 9 |
-
def __init__(self, llm):
|
| 10 |
-
self.llm = llm
|
| 11 |
-
self._create_agents()
|
| 12 |
-
|
| 13 |
-
def _create_agents(self):
|
| 14 |
-
self.section_splitter = Agent(
|
| 15 |
-
role="Analyseur de Structure de CV",
|
| 16 |
-
goal="Découper intelligemment un CV en sections thématiques",
|
| 17 |
-
backstory="Expert en analyse documentaire spécialisé dans la reconnaissance de structures de CV.",
|
| 18 |
-
verbose=False,
|
| 19 |
-
llm=self.llm
|
| 20 |
-
)
|
| 21 |
-
|
| 22 |
-
self.contact_extractor = Agent(
|
| 23 |
-
role="Extracteur d'informations de contact",
|
| 24 |
-
goal="Extraire les coordonnées du candidat",
|
| 25 |
-
backstory="Expert en extraction d'informations de contact avec précision.",
|
| 26 |
-
verbose=False,
|
| 27 |
-
llm=self.llm
|
| 28 |
-
)
|
| 29 |
-
|
| 30 |
-
self.skills_extractor = Agent(
|
| 31 |
-
role="Extracteur de compétences",
|
| 32 |
-
goal="Identifier hard skills et soft skills",
|
| 33 |
-
backstory="Spécialiste en identification de compétences techniques et comportementales.",
|
| 34 |
-
verbose=False,
|
| 35 |
-
llm=self.llm
|
| 36 |
-
)
|
| 37 |
-
|
| 38 |
-
self.experience_extractor = Agent(
|
| 39 |
-
role="Extracteur d'expériences",
|
| 40 |
-
goal="Extraire les expériences professionnelles",
|
| 41 |
-
backstory="Expert en analyse de parcours professionnels.",
|
| 42 |
-
verbose=False,
|
| 43 |
-
llm=self.llm
|
| 44 |
-
)
|
| 45 |
-
|
| 46 |
-
self.project_extractor = Agent(
|
| 47 |
-
role="Extracteur de projets",
|
| 48 |
-
goal="Identifier projets professionnels et personnels",
|
| 49 |
-
backstory="Spécialiste en identification de projets significatifs.",
|
| 50 |
-
verbose=False,
|
| 51 |
-
llm=self.llm
|
| 52 |
-
)
|
| 53 |
-
|
| 54 |
-
self.education_extractor = Agent(
|
| 55 |
-
role="Extracteur de formations",
|
| 56 |
-
goal="Extraire formations et diplômes",
|
| 57 |
-
backstory="Expert en analyse de parcours académiques.",
|
| 58 |
-
verbose=False,
|
| 59 |
-
llm=self.llm
|
| 60 |
-
)
|
| 61 |
-
|
| 62 |
-
self.reconversion_detector = Agent(
|
| 63 |
-
role="Détecteur de reconversion",
|
| 64 |
-
goal="Analyser les changements de carrière",
|
| 65 |
-
backstory="Conseiller d'orientation expert en transitions de carrière.",
|
| 66 |
-
verbose=False,
|
| 67 |
-
llm=self.llm
|
| 68 |
-
)
|
| 69 |
-
|
| 70 |
-
self.profile_builder = Agent(
|
| 71 |
-
role="Constructeur de profil",
|
| 72 |
-
goal="Assembler le profil candidat final",
|
| 73 |
-
backstory="Expert en structuration de données JSON.",
|
| 74 |
-
verbose=False,
|
| 75 |
-
llm=self.llm
|
| 76 |
-
)
|
| 77 |
-
|
| 78 |
-
def split_cv_sections(self, cv_content: str) -> Dict[str, str]:
|
| 79 |
-
task = Task(
|
| 80 |
-
description=f"Analyser ce CV et l'organiser en sections: {cv_content}",
|
| 81 |
-
expected_output="""JSON avec sections: contact, experiences, projects, education, skills, other""",
|
| 82 |
-
agent=self.section_splitter
|
| 83 |
-
)
|
| 84 |
-
|
| 85 |
-
crew = Crew(
|
| 86 |
-
agents=[self.section_splitter],
|
| 87 |
-
tasks=[task],
|
| 88 |
-
process=Process.sequential,
|
| 89 |
-
verbose=False,
|
| 90 |
-
telemetry=False
|
| 91 |
-
)
|
| 92 |
-
|
| 93 |
-
result = crew.kickoff()
|
| 94 |
-
return self._parse_sections_result(result)
|
| 95 |
-
|
| 96 |
-
def extract_all_sections(self, sections: Dict[str, str]) -> Dict[str, Any]:
|
| 97 |
-
# Créer les tâches avec les sections en input
|
| 98 |
-
tasks = self._create_extraction_tasks(sections)
|
| 99 |
-
|
| 100 |
-
crew = Crew(
|
| 101 |
-
agents=[
|
| 102 |
-
self.contact_extractor,
|
| 103 |
-
self.skills_extractor,
|
| 104 |
-
self.experience_extractor,
|
| 105 |
-
self.project_extractor,
|
| 106 |
-
self.education_extractor,
|
| 107 |
-
self.reconversion_detector,
|
| 108 |
-
self.profile_builder
|
| 109 |
-
],
|
| 110 |
-
tasks=tasks,
|
| 111 |
-
process=Process.sequential,
|
| 112 |
-
verbose=True, # Activer pour debug
|
| 113 |
-
telemetry=False
|
| 114 |
-
)
|
| 115 |
-
|
| 116 |
-
# Passer les sections comme inputs
|
| 117 |
-
inputs = {
|
| 118 |
-
"contact": sections.get("contact", ""),
|
| 119 |
-
"experiences": sections.get("experiences", ""),
|
| 120 |
-
"projects": sections.get("projects", ""),
|
| 121 |
-
"education": sections.get("education", ""),
|
| 122 |
-
"skills": sections.get("skills", ""),
|
| 123 |
-
"other": sections.get("other", "")
|
| 124 |
-
}
|
| 125 |
-
|
| 126 |
-
logger.info(f"Starting crew with inputs: {list(inputs.keys())}")
|
| 127 |
-
result = crew.kickoff(inputs=inputs)
|
| 128 |
-
logger.info(f"Crew completed. Raw result: {result.raw if hasattr(result, 'raw') else str(result)[:200]}...")
|
| 129 |
-
|
| 130 |
-
return self._parse_final_result(result)
|
| 131 |
-
|
| 132 |
-
def _create_extraction_tasks(self, sections: Dict[str, str]) -> List[Task]:
|
| 133 |
-
contact_task = Task(
|
| 134 |
-
description=(
|
| 135 |
-
"Voici la section contact du CV : {contact}\n"
|
| 136 |
-
"Extraire précisément le nom, email, téléphone et localisation du candidat."
|
| 137 |
-
),
|
| 138 |
-
expected_output='{"nom": "...", "email": "...", "numero_de_telephone": "...", "localisation": "..."}',
|
| 139 |
-
agent=self.contact_extractor
|
| 140 |
-
)
|
| 141 |
-
|
| 142 |
-
skills_task = Task(
|
| 143 |
-
description=(
|
| 144 |
-
"Voici les sections pertinentes du CV :\n"
|
| 145 |
-
"Expériences: {experiences}\n"
|
| 146 |
-
"Projets: {projects}\n"
|
| 147 |
-
"Compétences: {skills}\n"
|
| 148 |
-
"Extraire toutes les compétences techniques (hard skills) et comportementales (soft skills) mentionnées."
|
| 149 |
-
),
|
| 150 |
-
expected_output='{"hard_skills": ["compétence1", "compétence2"], "soft_skills": ["compétence1", "compétence2"]}',
|
| 151 |
-
agent=self.skills_extractor
|
| 152 |
-
)
|
| 153 |
-
|
| 154 |
-
experience_task = Task(
|
| 155 |
-
description=(
|
| 156 |
-
"Voici la section expériences du CV : {experiences}\n"
|
| 157 |
-
"Extraire toutes les expériences professionnelles avec poste, entreprise, dates et responsabilités."
|
| 158 |
-
),
|
| 159 |
-
expected_output='[{"Poste": "titre", "Entreprise": "nom", "start_date": "date", "end_date": "date", "responsabilités": ["resp1", "resp2"]}]',
|
| 160 |
-
agent=self.experience_extractor
|
| 161 |
-
)
|
| 162 |
-
|
| 163 |
-
project_task = Task(
|
| 164 |
-
description=(
|
| 165 |
-
"Voici les sections projets et expériences du CV :\n"
|
| 166 |
-
"Projets: {projects}\n"
|
| 167 |
-
"Identifier et extraire les projets professionnels et personnels distincts des responsabilités générales."
|
| 168 |
-
),
|
| 169 |
-
expected_output='{"professional": [{"title": "titre", "technologies": ["tech1"], "outcomes": ["résultat1"]}], "personal": []}',
|
| 170 |
-
agent=self.project_extractor
|
| 171 |
-
)
|
| 172 |
-
|
| 173 |
-
education_task = Task(
|
| 174 |
-
description=(
|
| 175 |
-
"Voici la section formations du CV : {education}\n"
|
| 176 |
-
"Extraire toutes les formations, diplômes et certifications avec institution et dates."
|
| 177 |
-
),
|
| 178 |
-
expected_output='[{"degree": "diplôme", "institution": "établissement", "start_date": "date", "end_date": "date"}]',
|
| 179 |
-
agent=self.education_extractor
|
| 180 |
-
)
|
| 181 |
-
|
| 182 |
-
reconversion_task = Task(
|
| 183 |
-
description=(
|
| 184 |
-
"En analysant les expériences extraites précédemment, déterminer si le candidat est en reconversion professionnelle. "
|
| 185 |
-
"Chercher des changements de secteur, de type de poste ou des transitions significatives."
|
| 186 |
-
),
|
| 187 |
-
expected_output='{"reconversion_analysis": {"is_reconversion": true, "analysis": "Explication détaillée..."}}',
|
| 188 |
-
agent=self.reconversion_detector,
|
| 189 |
-
context=[experience_task]
|
| 190 |
-
)
|
| 191 |
-
|
| 192 |
-
profile_task = Task(
|
| 193 |
-
description=(
|
| 194 |
-
"Assembler toutes les informations extraites des tâches précédentes en un profil candidat complet. "
|
| 195 |
-
"Créer un JSON valide avec une clé 'candidat' contenant toutes les sections."
|
| 196 |
-
),
|
| 197 |
-
expected_output=(
|
| 198 |
-
'{"candidat": {'
|
| 199 |
-
'"informations_personnelles": {...}, '
|
| 200 |
-
'"compétences": {...}, '
|
| 201 |
-
'"expériences": [...], '
|
| 202 |
-
'"projets": {...}, '
|
| 203 |
-
'"formations": [...], '
|
| 204 |
-
'"reconversion": {...}'
|
| 205 |
-
'}}'
|
| 206 |
-
),
|
| 207 |
-
agent=self.profile_builder,
|
| 208 |
-
context=[contact_task, skills_task, experience_task, project_task, education_task, reconversion_task]
|
| 209 |
-
)
|
| 210 |
-
|
| 211 |
-
return [contact_task, skills_task, experience_task, project_task, education_task, reconversion_task, profile_task]
|
| 212 |
-
|
| 213 |
-
def _parse_sections_result(self, result) -> Dict[str, str]:
|
| 214 |
-
result_str = result.raw if hasattr(result, 'raw') else str(result)
|
| 215 |
-
|
| 216 |
-
if '```json' in result_str:
|
| 217 |
-
result_str = result_str.split('```json')[1].split('```')[0].strip()
|
| 218 |
-
elif '```' in result_str:
|
| 219 |
-
parts = result_str.split('```')
|
| 220 |
-
if len(parts) >= 3:
|
| 221 |
-
result_str = parts[1].strip()
|
| 222 |
-
|
| 223 |
-
parsed = json.loads(result_str)
|
| 224 |
-
|
| 225 |
-
# Assurer que toutes les sections nécessaires existent
|
| 226 |
-
default_sections = {
|
| 227 |
-
"contact": "",
|
| 228 |
-
"experiences": "",
|
| 229 |
-
"projects": "",
|
| 230 |
-
"education": "",
|
| 231 |
-
"skills": "",
|
| 232 |
-
"other": ""
|
| 233 |
-
}
|
| 234 |
-
|
| 235 |
-
for key in default_sections:
|
| 236 |
-
if key not in parsed:
|
| 237 |
-
parsed[key] = default_sections[key]
|
| 238 |
-
|
| 239 |
-
return parsed
|
| 240 |
-
|
| 241 |
-
def _parse_final_result(self, result) -> Dict[str, Any]:
|
| 242 |
-
result_str = result.raw if hasattr(result, 'raw') else str(result)
|
| 243 |
-
|
| 244 |
-
if '```json' in result_str:
|
| 245 |
-
result_str = result_str.split('```json')[1].split('```')[0].strip()
|
| 246 |
-
elif '```' in result_str:
|
| 247 |
-
parts = result_str.split('```')
|
| 248 |
-
if len(parts) >= 3:
|
| 249 |
-
result_str = parts[1].strip()
|
| 250 |
-
|
| 251 |
-
return json.loads(result_str)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/agents/scoring_agent.py
DELETED
|
@@ -1,183 +0,0 @@
|
|
| 1 |
-
import json
|
| 2 |
-
import re
|
| 3 |
-
import logging
|
| 4 |
-
from datetime import datetime
|
| 5 |
-
from typing import Dict, List, Any
|
| 6 |
-
|
| 7 |
-
logger = logging.getLogger(__name__)
|
| 8 |
-
|
| 9 |
-
class SimpleScoringAgent:
|
| 10 |
-
|
| 11 |
-
def calculate_scores(self, candidat_data: Dict[str, Any]) -> Dict[str, List[Dict[str, Any]]]:
|
| 12 |
-
if not candidat_data or not isinstance(candidat_data, dict):
|
| 13 |
-
return {"analyse_competences": []}
|
| 14 |
-
|
| 15 |
-
skills_data = candidat_data.get("compétences", {})
|
| 16 |
-
skills_list = self._extract_skills_list(skills_data)
|
| 17 |
-
|
| 18 |
-
if not skills_list:
|
| 19 |
-
return {"analyse_competences": []}
|
| 20 |
-
|
| 21 |
-
skill_analysis = []
|
| 22 |
-
|
| 23 |
-
for skill in skills_list:
|
| 24 |
-
level = self._determine_skill_level(skill, candidat_data)
|
| 25 |
-
skill_analysis.append({
|
| 26 |
-
"skill": skill,
|
| 27 |
-
"level": level
|
| 28 |
-
})
|
| 29 |
-
|
| 30 |
-
return {"analyse_competences": skill_analysis}
|
| 31 |
-
|
| 32 |
-
def _extract_skills_list(self, skills_data: Dict[str, Any]) -> List[str]:
|
| 33 |
-
"""Extrait la liste des compétences"""
|
| 34 |
-
skills_list = []
|
| 35 |
-
|
| 36 |
-
if isinstance(skills_data, dict):
|
| 37 |
-
skills_list.extend(skills_data.get("hard_skills", []))
|
| 38 |
-
skills_list.extend(skills_data.get("soft_skills", []))
|
| 39 |
-
elif isinstance(skills_data, list):
|
| 40 |
-
skills_list = [item.get("nom") for item in skills_data if item.get("nom")]
|
| 41 |
-
|
| 42 |
-
return [skill for skill in skills_list if skill and isinstance(skill, str) and skill.strip()]
|
| 43 |
-
|
| 44 |
-
def _determine_skill_level(self, skill: str, candidat_data: Dict[str, Any]) -> str:
|
| 45 |
-
"""Détermine le niveau d'une compétence selon des règles simples"""
|
| 46 |
-
|
| 47 |
-
frequency = self._count_skill_mentions(skill, candidat_data)
|
| 48 |
-
max_duration = self._get_max_duration_for_skill(skill, candidat_data)
|
| 49 |
-
has_pro_experience = self._has_professional_experience(skill, candidat_data)
|
| 50 |
-
|
| 51 |
-
# Règles simples de classification
|
| 52 |
-
if has_pro_experience and max_duration >= 3.0:
|
| 53 |
-
return "expert"
|
| 54 |
-
elif has_pro_experience and max_duration >= 1.0:
|
| 55 |
-
return "avance"
|
| 56 |
-
elif frequency >= 3 or max_duration >= 0.5:
|
| 57 |
-
return "intermediaire"
|
| 58 |
-
else:
|
| 59 |
-
return "debutant"
|
| 60 |
-
|
| 61 |
-
def _count_skill_mentions(self, skill: str, candidat_data: Dict[str, Any]) -> int:
|
| 62 |
-
"""Compte le nombre de mentions de la compétence"""
|
| 63 |
-
skill_lower = skill.lower()
|
| 64 |
-
total_mentions = 0
|
| 65 |
-
|
| 66 |
-
# Recherche dans toutes les sections
|
| 67 |
-
all_text = self._get_all_text_content(candidat_data).lower()
|
| 68 |
-
total_mentions = all_text.count(skill_lower)
|
| 69 |
-
|
| 70 |
-
return total_mentions
|
| 71 |
-
|
| 72 |
-
def _get_max_duration_for_skill(self, skill: str, candidat_data: Dict[str, Any]) -> float:
|
| 73 |
-
"""Trouve la durée maximum d'utilisation de la compétence"""
|
| 74 |
-
skill_lower = skill.lower()
|
| 75 |
-
max_duration = 0.0
|
| 76 |
-
|
| 77 |
-
experiences_key = "expériences" if "expériences" in candidat_data else "experiences_professionnelles"
|
| 78 |
-
experiences = candidat_data.get(experiences_key, [])
|
| 79 |
-
|
| 80 |
-
if not isinstance(experiences, list):
|
| 81 |
-
return 0.0
|
| 82 |
-
|
| 83 |
-
for exp in experiences:
|
| 84 |
-
if not isinstance(exp, dict):
|
| 85 |
-
continue
|
| 86 |
-
|
| 87 |
-
exp_text = json.dumps(exp, ensure_ascii=False).lower()
|
| 88 |
-
|
| 89 |
-
if skill_lower in exp_text:
|
| 90 |
-
duration = self._calculate_experience_duration(exp)
|
| 91 |
-
max_duration = max(max_duration, duration)
|
| 92 |
-
|
| 93 |
-
return max_duration
|
| 94 |
-
|
| 95 |
-
def _has_professional_experience(self, skill: str, candidat_data: Dict[str, Any]) -> bool:
|
| 96 |
-
"""Vérifie si la compétence a été utilisée en contexte professionnel"""
|
| 97 |
-
skill_lower = skill.lower()
|
| 98 |
-
|
| 99 |
-
experiences_key = "expériences" if "expériences" in candidat_data else "experiences_professionnelles"
|
| 100 |
-
experiences = candidat_data.get(experiences_key, [])
|
| 101 |
-
|
| 102 |
-
if not isinstance(experiences, list):
|
| 103 |
-
return False
|
| 104 |
-
|
| 105 |
-
for exp in experiences:
|
| 106 |
-
if not isinstance(exp, dict):
|
| 107 |
-
continue
|
| 108 |
-
|
| 109 |
-
exp_text = json.dumps(exp, ensure_ascii=False).lower()
|
| 110 |
-
if skill_lower in exp_text:
|
| 111 |
-
return True
|
| 112 |
-
|
| 113 |
-
return False
|
| 114 |
-
|
| 115 |
-
def _get_all_text_content(self, candidat_data: Dict[str, Any]) -> str:
|
| 116 |
-
"""Récupère tout le contenu textuel du CV"""
|
| 117 |
-
all_content = []
|
| 118 |
-
|
| 119 |
-
# Expériences
|
| 120 |
-
experiences_key = "expériences" if "expériences" in candidat_data else "experiences_professionnelles"
|
| 121 |
-
for exp in candidat_data.get(experiences_key, []):
|
| 122 |
-
if isinstance(exp, dict):
|
| 123 |
-
all_content.append(json.dumps(exp, ensure_ascii=False))
|
| 124 |
-
|
| 125 |
-
# Projets
|
| 126 |
-
projects = candidat_data.get("projets", {})
|
| 127 |
-
if isinstance(projects, dict):
|
| 128 |
-
for project_type in ["professional", "personal"]:
|
| 129 |
-
for project in projects.get(project_type, []):
|
| 130 |
-
if isinstance(project, dict):
|
| 131 |
-
all_content.append(json.dumps(project, ensure_ascii=False))
|
| 132 |
-
|
| 133 |
-
# Formations
|
| 134 |
-
for formation in candidat_data.get("formations", []):
|
| 135 |
-
if isinstance(formation, dict):
|
| 136 |
-
all_content.append(json.dumps(formation, ensure_ascii=False))
|
| 137 |
-
|
| 138 |
-
return " ".join(all_content)
|
| 139 |
-
|
| 140 |
-
def _calculate_experience_duration(self, exp: Dict[str, Any]) -> float:
|
| 141 |
-
"""Calcule la durée d'une expérience en années"""
|
| 142 |
-
start_date_str = exp.get("date_debut", exp.get("start_date", ""))
|
| 143 |
-
end_date_str = exp.get("date_fin", exp.get("end_date", ""))
|
| 144 |
-
|
| 145 |
-
if not isinstance(start_date_str, str):
|
| 146 |
-
start_date_str = str(start_date_str) if start_date_str else ""
|
| 147 |
-
if not isinstance(end_date_str, str):
|
| 148 |
-
end_date_str = str(end_date_str) if end_date_str else ""
|
| 149 |
-
|
| 150 |
-
return self._calculate_duration_in_years(start_date_str, end_date_str)
|
| 151 |
-
|
| 152 |
-
def _calculate_duration_in_years(self, start_date_str: str, end_date_str: str) -> float:
|
| 153 |
-
"""Calcule la durée entre deux dates en années"""
|
| 154 |
-
start_date = self._parse_date(start_date_str)
|
| 155 |
-
end_date = self._parse_date(end_date_str)
|
| 156 |
-
|
| 157 |
-
if start_date and end_date:
|
| 158 |
-
if end_date < start_date:
|
| 159 |
-
return 0.0
|
| 160 |
-
return (end_date - start_date).days / 365.25
|
| 161 |
-
|
| 162 |
-
return 0.0
|
| 163 |
-
|
| 164 |
-
def _parse_date(self, date_str: str) -> datetime:
|
| 165 |
-
"""Parse une date de manière simple"""
|
| 166 |
-
if not date_str or not isinstance(date_str, str):
|
| 167 |
-
return None
|
| 168 |
-
|
| 169 |
-
date_str_lower = date_str.lower().strip()
|
| 170 |
-
if date_str_lower in ["aujourd'hui", "maintenant", "en cours", "current", "présent", "actuellement"]:
|
| 171 |
-
return datetime.now()
|
| 172 |
-
|
| 173 |
-
# Extraction simple de l'année
|
| 174 |
-
year_match = re.search(r'\b(20\d{2}|19\d{2})\b', date_str)
|
| 175 |
-
if year_match:
|
| 176 |
-
year = int(year_match.group(1))
|
| 177 |
-
return datetime(year, 1, 1)
|
| 178 |
-
|
| 179 |
-
return None
|
| 180 |
-
|
| 181 |
-
# Alias pour maintenir la compatibilité
|
| 182 |
-
ScoringAgent = SimpleScoringAgent
|
| 183 |
-
ImprovedScoringAgent = SimpleScoringAgent
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/core/rag_handler.py
DELETED
|
@@ -1,130 +0,0 @@
|
|
| 1 |
-
import os
|
| 2 |
-
import logging
|
| 3 |
-
from typing import Optional, List
|
| 4 |
-
from langchain_community.document_loaders import DirectoryLoader, TextLoader
|
| 5 |
-
from langchain_community.vectorstores import FAISS
|
| 6 |
-
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
| 7 |
-
|
| 8 |
-
logger = logging.getLogger(__name__)
|
| 9 |
-
|
| 10 |
-
_embeddings_model = None
|
| 11 |
-
_rag_handler_instance = None
|
| 12 |
-
|
| 13 |
-
VECTOR_STORE_PATH = "/tmp/vector_store"
|
| 14 |
-
|
| 15 |
-
def get_embeddings_model():
|
| 16 |
-
global _embeddings_model
|
| 17 |
-
if _embeddings_model is None:
|
| 18 |
-
from langchain_huggingface import HuggingFaceEmbeddings
|
| 19 |
-
logger.info("Initialisation du modèle d'embeddings...")
|
| 20 |
-
_embeddings_model = HuggingFaceEmbeddings(
|
| 21 |
-
model_name='sentence-transformers/all-MiniLM-L6-v2',
|
| 22 |
-
model_kwargs={'device': 'cpu'},
|
| 23 |
-
encode_kwargs={'normalize_embeddings': True}
|
| 24 |
-
)
|
| 25 |
-
logger.info("✅ Modèle d'embeddings initialisé avec succès")
|
| 26 |
-
return _embeddings_model
|
| 27 |
-
|
| 28 |
-
class RAGHandler:
|
| 29 |
-
def __init__(self, knowledge_base_path: str = "/app/knowledge_base", lazy_init: bool = True):
|
| 30 |
-
self.knowledge_base_path = knowledge_base_path
|
| 31 |
-
self.embeddings = None
|
| 32 |
-
self.vector_store = None
|
| 33 |
-
self._initialized = False
|
| 34 |
-
|
| 35 |
-
os.makedirs(VECTOR_STORE_PATH, exist_ok=True)
|
| 36 |
-
|
| 37 |
-
if not lazy_init:
|
| 38 |
-
self._initialize()
|
| 39 |
-
|
| 40 |
-
def _initialize(self):
|
| 41 |
-
if self._initialized:
|
| 42 |
-
return
|
| 43 |
-
|
| 44 |
-
logger.info("Initialisation du RAG Handler...")
|
| 45 |
-
self.embeddings = get_embeddings_model()
|
| 46 |
-
|
| 47 |
-
if self.embeddings is None:
|
| 48 |
-
logger.error("Impossible d'initialiser les embeddings")
|
| 49 |
-
return
|
| 50 |
-
|
| 51 |
-
self.vector_store = self._load_or_create_vector_store(self.knowledge_base_path)
|
| 52 |
-
self._initialized = True
|
| 53 |
-
logger.info("✅ RAG Handler initialisé avec succès")
|
| 54 |
-
|
| 55 |
-
def _load_documents(self, path: str) -> List:
|
| 56 |
-
if not os.path.exists(path):
|
| 57 |
-
logger.warning(f"Répertoire {path} non trouvé")
|
| 58 |
-
return []
|
| 59 |
-
|
| 60 |
-
loader = DirectoryLoader(
|
| 61 |
-
path,
|
| 62 |
-
glob="**/*.md",
|
| 63 |
-
loader_cls=TextLoader,
|
| 64 |
-
loader_kwargs={"encoding": "utf-8"}
|
| 65 |
-
)
|
| 66 |
-
logger.info(f"Chargement des documents depuis : {path}")
|
| 67 |
-
documents = loader.load()
|
| 68 |
-
logger.info(f"✅ {len(documents)} documents chargés")
|
| 69 |
-
return documents
|
| 70 |
-
|
| 71 |
-
def _create_vector_store(self, knowledge_base_path: str) -> Optional[FAISS]:
|
| 72 |
-
documents = self._load_documents(knowledge_base_path)
|
| 73 |
-
if not documents:
|
| 74 |
-
logger.warning("Aucun document trouvé - création d'un vector store vide")
|
| 75 |
-
from langchain.schema import Document
|
| 76 |
-
dummy_doc = Document(
|
| 77 |
-
page_content="Document de test pour initialiser le vector store",
|
| 78 |
-
metadata={"source": "dummy"}
|
| 79 |
-
)
|
| 80 |
-
documents = [dummy_doc]
|
| 81 |
-
|
| 82 |
-
logger.info(f"{len(documents)} documents chargés. Création des vecteurs...")
|
| 83 |
-
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=100)
|
| 84 |
-
texts = text_splitter.split_documents(documents)
|
| 85 |
-
|
| 86 |
-
vector_store = FAISS.from_documents(texts, self.embeddings)
|
| 87 |
-
|
| 88 |
-
vector_store.save_local(VECTOR_STORE_PATH)
|
| 89 |
-
logger.info(f"✅ Vector store créé et sauvegardé dans : {VECTOR_STORE_PATH}")
|
| 90 |
-
|
| 91 |
-
return vector_store
|
| 92 |
-
|
| 93 |
-
def _load_or_create_vector_store(self, knowledge_base_path: str) -> Optional[FAISS]:
|
| 94 |
-
index_path = os.path.join(VECTOR_STORE_PATH, "index.faiss")
|
| 95 |
-
if os.path.exists(index_path):
|
| 96 |
-
logger.info(f"Chargement du vector store existant depuis : {VECTOR_STORE_PATH}")
|
| 97 |
-
return FAISS.load_local(
|
| 98 |
-
VECTOR_STORE_PATH,
|
| 99 |
-
embeddings=self.embeddings,
|
| 100 |
-
allow_dangerous_deserialization=True
|
| 101 |
-
)
|
| 102 |
-
else:
|
| 103 |
-
logger.info("Aucun vector store trouvé. Création d'un nouveau...")
|
| 104 |
-
return self._create_vector_store(knowledge_base_path)
|
| 105 |
-
|
| 106 |
-
def get_relevant_feedback(self, query: str, k: int = 1) -> List[str]:
|
| 107 |
-
if not self._initialized:
|
| 108 |
-
self._initialize()
|
| 109 |
-
|
| 110 |
-
if not self.vector_store:
|
| 111 |
-
logger.warning("Vector store non disponible - retour de conseils génériques")
|
| 112 |
-
return [
|
| 113 |
-
"Préparez vos réponses aux questions comportementales",
|
| 114 |
-
"Montrez votre motivation pour le poste",
|
| 115 |
-
"Donnez des exemples concrets de vos réalisations"
|
| 116 |
-
]
|
| 117 |
-
|
| 118 |
-
results = self.vector_store.similarity_search(query, k=k)
|
| 119 |
-
feedback = [doc.page_content for doc in results if doc.page_content.strip()]
|
| 120 |
-
|
| 121 |
-
if not feedback:
|
| 122 |
-
return ["Conseil général: Préparez-vous bien pour les entretiens futurs."]
|
| 123 |
-
|
| 124 |
-
return feedback
|
| 125 |
-
|
| 126 |
-
def get_rag_handler() -> Optional[RAGHandler]:
|
| 127 |
-
global _rag_handler_instance
|
| 128 |
-
if _rag_handler_instance is None:
|
| 129 |
-
_rag_handler_instance = RAGHandler(lazy_init=True)
|
| 130 |
-
return _rag_handler_instance
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/{agents → crew}/__init__.py
RENAMED
|
File without changes
|
src/crew/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (137 Bytes). View file
|
|
|
src/crew/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (190 Bytes). View file
|
|
|
src/crew/__pycache__/__init__.cpython-312.pycZone.Identifier
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[ZoneTransfer]
|
| 2 |
+
ZoneId=3
|
src/crew/__pycache__/agents.cpython-311.pyc
ADDED
|
Binary file (3.57 kB). View file
|
|
|
src/crew/__pycache__/agents.cpython-312.pyc
ADDED
|
Binary file (3.43 kB). View file
|
|
|
src/crew/__pycache__/agents.cpython-312.pycZone.Identifier
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[ZoneTransfer]
|
| 2 |
+
ZoneId=3
|
src/crew/__pycache__/analysis_crew.cpython-312.pyc
ADDED
|
Binary file (1.08 kB). View file
|
|
|
src/crew/__pycache__/analysis_crew.cpython-312.pycZone.Identifier
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[ZoneTransfer]
|
| 2 |
+
ZoneId=3
|
src/crew/__pycache__/crew_pool.cpython-311.pyc
ADDED
|
Binary file (2.46 kB). View file
|
|
|
src/crew/__pycache__/crew_pool.cpython-312.pyc
ADDED
|
Binary file (2.18 kB). View file
|
|
|
src/crew/__pycache__/crew_pool.cpython-312.pycZone.Identifier
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[ZoneTransfer]
|
| 2 |
+
ZoneId=3
|
src/crew/__pycache__/tasks.cpython-312.pyc
ADDED
|
Binary file (8.39 kB). View file
|
|
|
src/crew/__pycache__/tasks.cpython-312.pycZone.Identifier
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[ZoneTransfer]
|
| 2 |
+
ZoneId=3
|
src/crew/agents.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from crewai import Agent
|
| 2 |
+
from crewai import LLM
|
| 3 |
+
from src.config import crew_openai
|
| 4 |
+
|
| 5 |
+
LLM_agent = crew_openai()
|
| 6 |
+
|
| 7 |
+
# Interview Simulation Agents
|
| 8 |
+
report_generator_agent = Agent(
|
| 9 |
+
role='Rédacteur de Rapports Synthétiques',
|
| 10 |
+
goal='Générer un feedback pertinent, a partir du deroulement de lentretient',
|
| 11 |
+
backstory=(
|
| 12 |
+
"Sepcialisé dans le recrutement et les ressources humaines, capable d'evaluer les candidats"
|
| 13 |
+
"sur la communication et la pertinences des reponses en fonction des questions posées, redige"
|
| 14 |
+
"en un rapport clair, un feedback détaillé sur le candidat."
|
| 15 |
+
),
|
| 16 |
+
allow_delegation=False,
|
| 17 |
+
verbose=False,
|
| 18 |
+
llm=LLM_agent
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
# CV Parsing Agents
|
| 22 |
+
skills_extractor_agent = Agent(
|
| 23 |
+
role="Spécialiste de l'extraction de compétences (hard & soft skills)",
|
| 24 |
+
goal="Identifier et extraire toutes les compétences pertinentes du CV.",
|
| 25 |
+
backstory="Vous êtes un spécialiste des compétences techniques et comportementales. Votre mission est de parcourir les CV et de lister de manière exhaustive toutes les compétences mentionnées.",
|
| 26 |
+
verbose=False,
|
| 27 |
+
llm=LLM_agent
|
| 28 |
+
)
|
| 29 |
+
experience_extractor_agent = Agent(
|
| 30 |
+
role="Expert en extraction d'expérience professionnelle",
|
| 31 |
+
goal="Extraire en détail l'expérience professionnelle du candidat.",
|
| 32 |
+
backstory="Vous êtes un expert en recrutement spécialisé dans l'analyse des parcours professionnels. Vous devez extraire chaque expérience de manière précise, en notant les rôles, les entreprises, les dates et les responsabilités.",
|
| 33 |
+
verbose=False,
|
| 34 |
+
llm=LLM_agent
|
| 35 |
+
)
|
| 36 |
+
project_extractor_agent = Agent(
|
| 37 |
+
role="Spécialiste de l'identification de projets (pro & perso)",
|
| 38 |
+
goal="Identifier et décrire les projets significatifs mentionnés.",
|
| 39 |
+
backstory="Vous êtes passionné par l'innovation et les réalisations. Votre rôle est de repérer et de décrire les projets professionnels et personnels qui mettent en lumière les compétences et l'initiative des candidats.",
|
| 40 |
+
verbose=False,
|
| 41 |
+
llm=LLM_agent
|
| 42 |
+
)
|
| 43 |
+
education_extractor_agent = Agent(
|
| 44 |
+
role="Expert en extraction d'informations sur la formation",
|
| 45 |
+
goal="Extraire les détails des études et des diplômes obtenus.",
|
| 46 |
+
backstory="Vous êtes un spécialiste des parcours académiques. Votre tâche est d'extraire avec précision les informations relatives aux études, aux diplômes et aux établissements fréquentés par les candidats.",
|
| 47 |
+
verbose=False,
|
| 48 |
+
llm=LLM_agent
|
| 49 |
+
)
|
| 50 |
+
informations_personnelle_agent = Agent(
|
| 51 |
+
role="Spécialiste de l'extraction des coordonnées",
|
| 52 |
+
goal="Identifier et extraire précisément les coordonnées du candidat.",
|
| 53 |
+
backstory="Vous êtes un expert en analyse de CV, particulièrement doué pour localiser et extraire les informations de contact. Votre rôle est de trouver le nom, l'adresse e-mail, le numéro de téléphone et la localisation (ville ou région) du candidat, généralement situés en haut ou à la fin du CV.",
|
| 54 |
+
verbose=False,
|
| 55 |
+
llm=LLM_agent
|
| 56 |
+
)
|
| 57 |
+
ProfileBuilderAgent = Agent(
|
| 58 |
+
role='Constructeur de Profil CV',
|
| 59 |
+
goal='Créer un profil JSON structuré et valide avec la clé candidat',
|
| 60 |
+
backstory=(
|
| 61 |
+
"Tu es un expert en structuration de données JSON. "
|
| 62 |
+
"Ta mission est de créer un profil candidat parfaitement formaté "
|
| 63 |
+
"en respectant scrupuleusement la structure JSON demandée."
|
| 64 |
+
),
|
| 65 |
+
verbose=True,
|
| 66 |
+
llm=LLM_agent
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
reconversion_detector_agent = Agent(
|
| 70 |
+
role="Détecteur de Reconversion Professionnelle",
|
| 71 |
+
goal="Analyser la chronologie des expériences pour identifier les changements de carrière significatifs.",
|
| 72 |
+
backstory="Vous êtes un conseiller d'orientation expert, capable de repérer les transitions de carrière, d'identifier les compétences transférables et de valoriser les parcours non linéaires. Votre analyse doit mettre en lumière les changements de secteur, de type de poste ou de niveau de responsabilité.",
|
| 73 |
+
verbose=False,
|
| 74 |
+
llm=LLM_agent
|
| 75 |
+
)
|
src/crew/crew_pool.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from crewai import Crew, Process
|
| 2 |
+
from langchain_core.tools import tool
|
| 3 |
+
import json
|
| 4 |
+
from pydantic import BaseModel, Field
|
| 5 |
+
from typing import Dict, List, Any, Type
|
| 6 |
+
from .agents import report_generator_agent, skills_extractor_agent, experience_extractor_agent, project_extractor_agent, education_extractor_agent, ProfileBuilderAgent, informations_personnelle_agent, reconversion_detector_agent
|
| 7 |
+
from .tasks import generate_report_task, task_extract_skills, task_extract_experience, task_extract_projects, task_extract_education, task_build_profile, task_extract_informations, task_detect_reconversion
|
| 8 |
+
from src.deep_learning_analyzer import MultiModelInterviewAnalyzer
|
| 9 |
+
from src.rag_handler import RAGHandler
|
| 10 |
+
from langchain_core.tools import BaseTool
|
| 11 |
+
|
| 12 |
+
@tool
|
| 13 |
+
def interview_analyser(conversation_history: list, job_description_text: list) -> str:
|
| 14 |
+
"""
|
| 15 |
+
Appelle cet outil à la toute fin d'un entretien d'embauche pour analyser
|
| 16 |
+
l'intégralité de la conversation et générer un rapport de feedback.
|
| 17 |
+
Ne l'utilise PAS pour répondre à une question normale, mais seulement pour conclure et analyser l'entretien.
|
| 18 |
+
"""
|
| 19 |
+
# 1. Analyse DL de la conversation
|
| 20 |
+
analyzer = MultiModelInterviewAnalyzer()
|
| 21 |
+
structured_analysis = analyzer.run_full_analysis(conversation_history, job_description_text)
|
| 22 |
+
|
| 23 |
+
# 2. Enrichissement avec RAG
|
| 24 |
+
rag_handler = RAGHandler()
|
| 25 |
+
rag_feedback = []
|
| 26 |
+
# Extraire les intentions et sentiments pour trouver des conseils pertinents
|
| 27 |
+
if structured_analysis.get("intent_analysis"):
|
| 28 |
+
for intent in structured_analysis["intent_analysis"]:
|
| 29 |
+
# Exemple de requête basée sur l'intention
|
| 30 |
+
query = f"Conseils pour un candidat qui cherche à {intent['labels'][0]}"
|
| 31 |
+
rag_feedback.extend(rag_handler.get_relevant_feedback(query))
|
| 32 |
+
|
| 33 |
+
if structured_analysis.get("sentiment_analysis"):
|
| 34 |
+
for sentiment_group in structured_analysis["sentiment_analysis"]:
|
| 35 |
+
for sentiment in sentiment_group:
|
| 36 |
+
if sentiment['label'] == 'stress' and sentiment['score'].item() > 0.6:
|
| 37 |
+
rag_feedback.extend(rag_handler.get_relevant_feedback("gestion du stress en entretien"))
|
| 38 |
+
unique_feedback = list(set(rag_feedback))
|
| 39 |
+
interview_crew = Crew(
|
| 40 |
+
agents=[report_generator_agent],
|
| 41 |
+
tasks=[generate_report_task],
|
| 42 |
+
process=Process.sequential,
|
| 43 |
+
verbose=False,
|
| 44 |
+
telemetry=False
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
final_report = interview_crew.kickoff(inputs={
|
| 48 |
+
'structured_analysis_data': json.dumps(structured_analysis, indent=2),
|
| 49 |
+
'rag_contextual_feedback': "\n".join(unique_feedback)
|
| 50 |
+
})
|
| 51 |
+
return final_report
|
| 52 |
+
|
| 53 |
+
def analyse_cv(cv_content: str) -> json:
|
| 54 |
+
crew = Crew(
|
| 55 |
+
agents=[
|
| 56 |
+
informations_personnelle_agent,
|
| 57 |
+
skills_extractor_agent,
|
| 58 |
+
experience_extractor_agent,
|
| 59 |
+
project_extractor_agent,
|
| 60 |
+
education_extractor_agent,
|
| 61 |
+
reconversion_detector_agent,
|
| 62 |
+
|
| 63 |
+
ProfileBuilderAgent
|
| 64 |
+
],
|
| 65 |
+
tasks=[
|
| 66 |
+
task_extract_informations,
|
| 67 |
+
task_extract_skills,
|
| 68 |
+
task_extract_experience,
|
| 69 |
+
task_extract_projects,
|
| 70 |
+
task_extract_education,
|
| 71 |
+
task_detect_reconversion,
|
| 72 |
+
task_build_profile
|
| 73 |
+
],
|
| 74 |
+
process=Process.sequential,
|
| 75 |
+
verbose=False,
|
| 76 |
+
telemetry=False
|
| 77 |
+
)
|
| 78 |
+
result = crew.kickoff(inputs={"cv_content": cv_content})
|
| 79 |
+
return result
|
| 80 |
+
|
| 81 |
+
|
src/crew/tasks.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from crewai import Task
|
| 2 |
+
from .agents import report_generator_agent, skills_extractor_agent, experience_extractor_agent, project_extractor_agent, education_extractor_agent, ProfileBuilderAgent, informations_personnelle_agent, reconversion_detector_agent
|
| 3 |
+
|
| 4 |
+
generate_report_task = Task(
|
| 5 |
+
description=(
|
| 6 |
+
"""Tu es un rédacteur expert en RH. Ta mission est de rédiger un rapport d'évaluation final.
|
| 7 |
+
Tu dois utiliser deux sources d'information principales :
|
| 8 |
+
1. Les données d'analyse structurées de l'entretien : '{structured_analysis_data}'.
|
| 9 |
+
2. Une liste de conseils et de feedback pertinents issus de notre base de connaissances : '{rag_contextual_feedback}'.
|
| 10 |
+
|
| 11 |
+
Ta tâche est de synthétiser ces informations en un rapport cohérent et actionnable."""
|
| 12 |
+
),
|
| 13 |
+
expected_output=(
|
| 14 |
+
"""Un rapport final exceptionnel basé sur l'analyse fournie. Le rapport doit être structuré comme suit:
|
| 15 |
+
1. **Résumé et Score d'Adéquation** : Synthétise le score de similarité sémantique et donne un aperçu global.
|
| 16 |
+
2. **Analyse Comportementale** : Interprète les résultats de l'analyse de sentiment et d'intention pour décrire le comportement du candidat.
|
| 17 |
+
3. **Adéquation Sémantique avec le Poste** : Explique ce que signifie le score de similarité.
|
| 18 |
+
4. **Points Forts & Axes d'Amélioration Personnalisés** : Utilise les données d'analyse pour identifier les points à améliorer. Ensuite, intègre de manière fluide et naturelle les conseils pertinents de '{rag_contextual_feedback}' pour proposer des pistes d'amélioration concrètes et personnalisées. Ne te contente pas de copier-coller le feedback, mais reformule-le pour qu'il s'intègre parfaitement au rapport.
|
| 19 |
+
5. **Recommandation Finale**."""
|
| 20 |
+
),
|
| 21 |
+
agent=report_generator_agent,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
task_extract_skills = Task(
|
| 25 |
+
description=(
|
| 26 |
+
"Voici le contenu du CV :\n\n{cv_content}\n\n"
|
| 27 |
+
"Extraire uniquement les compétences mentionnées explicitement dans le texte du CV. "
|
| 28 |
+
"Séparer les hard skills (techniques) et les soft skills (comportementales) en analysant les listes ou phrases les contenant. "
|
| 29 |
+
"Les hards skills doivent comprendre des compétences techniques, outils, langages de programmation, etc. "
|
| 30 |
+
"Ne rien inventer. Ne pas déduire de compétences à partir d'un poste ou d'une expérience implicite. "
|
| 31 |
+
"Identifie clairement les compétences, et n'en exclue aucune. "
|
| 32 |
+
"\n\n**CONTRAINTES JSON STRICTES:**\n"
|
| 33 |
+
"- Utiliser UNIQUEMENT des guillemets doubles (\") pour les chaînes\n"
|
| 34 |
+
"- Aucune virgule finale dans les listes ou objets\n"
|
| 35 |
+
"- Vérifier la syntaxe JSON avant de retourner le résultat\n"
|
| 36 |
+
"- Échapper correctement les caractères spéciaux (\\, \", \\n, etc.)"
|
| 37 |
+
),
|
| 38 |
+
agent=skills_extractor_agent,
|
| 39 |
+
input_keys=["cv_content"],
|
| 40 |
+
expected_output=(
|
| 41 |
+
"Un dictionnaire JSON VALIDE 'Compétences' avec deux clés : 'hard_skills' et 'soft_skills', "
|
| 42 |
+
"contenant uniquement des listes de compétences présentes dans le texte. "
|
| 43 |
+
"FORMAT EXACT: {\"hard_skills\": [\"compétence1\", \"compétence2\"], \"soft_skills\": [\"compétence1\", \"compétence2\"]}"
|
| 44 |
+
)
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
task_extract_experience = Task(
|
| 48 |
+
description=(
|
| 49 |
+
"Voici le contenu du CV :\n\n{cv_content}\n\n"
|
| 50 |
+
"""
|
| 51 |
+
Extrais toutes les expériences professionnelles du CV. Pour chaque expérience, tu DOIS fournir les informations suivantes :
|
| 52 |
+
- Poste: Le titre du poste.
|
| 53 |
+
- Entreprise: Le nom de l'entreprise.
|
| 54 |
+
- start_date: La date de début. Si non trouvée, retourne "Non spécifié".
|
| 55 |
+
- end_date: La date de fin. Si le poste est actuel, utilise "Aujourd'hui". Si non trouvée, retourne "Non spécifié".
|
| 56 |
+
- responsabilités: Une liste des tâches et missions.
|
| 57 |
+
|
| 58 |
+
RÈGLES STRICTES :
|
| 59 |
+
1. NE JAMAIS laisser un champ vide (""). Si une information est introuvable, utilise la valeur "Non spécifié".
|
| 60 |
+
2. Analyse attentivement les dates. "Depuis 2023" signifie que la date de fin est "Aujourd'hui".
|
| 61 |
+
"""
|
| 62 |
+
),
|
| 63 |
+
agent=experience_extractor_agent,
|
| 64 |
+
input_keys=["cv_content"],
|
| 65 |
+
expected_output=(
|
| 66 |
+
"Un tableau JSON VALIDE d'objets 'Expérience Professionnelle' avec 5 clés par expérience : "
|
| 67 |
+
"'Poste', 'Entreprise', 'start_date', 'end_date', 'responsabilités'. "
|
| 68 |
+
"FORMAT EXACT: [{\"Poste\": \"titre\", \"Entreprise\": \"nom\", \"start_date\": \"année\", \"end_date\": \"année\", \"responsabilités\": [\"resp1\", \"resp2\"]}]"
|
| 69 |
+
)
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
task_extract_projects = Task(
|
| 73 |
+
description=(
|
| 74 |
+
"Voici le contenu du CV :\n\n{cv_content}\n\n"
|
| 75 |
+
"""
|
| 76 |
+
Identifie et extrais les PROJETS SPÉCIFIQUES mentionnés dans le CV.
|
| 77 |
+
Un projet est distinct d'une expérience professionnelle générale. Il a un nom ou un objectif clair.
|
| 78 |
+
|
| 79 |
+
RÈGLES STRICTES :
|
| 80 |
+
1. NE PAS extraire les responsabilités générales d'un poste en tant que projet. Par exemple, si le CV dit "Alternant chez Enedis où j'ai mené le projet 'Simulateur IA'", alors extrais 'Simulateur IA' comme projet. Ne copie pas toutes les tâches de l'alternance.
|
| 81 |
+
2. Si un projet est clairement lié à une expérience professionnelle, essaie de le noter, mais le plus important est de décrire le projet lui-même.
|
| 82 |
+
"""
|
| 83 |
+
),
|
| 84 |
+
agent=project_extractor_agent,
|
| 85 |
+
input_keys=["cv_content"],
|
| 86 |
+
expected_output=(
|
| 87 |
+
"Un dictionnaire JSON VALIDE 'Projets' avec deux clés : 'professional' et 'personal'. "
|
| 88 |
+
"Chaque clé contient une liste de dictionnaires, chaque dictionnaire représentant un projet avec les clés 'title', 'role', 'technologies', et 'outcomes'. "
|
| 89 |
+
"FORMAT EXACT: {\"professional\": [{\"title\": \"titre\", \"role\": \"rôle\", \"technologies\": [\"tech1\"], \"outcomes\": [\"résultat1\"]}], \"personal\": []}"
|
| 90 |
+
)
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
task_extract_education = Task(
|
| 94 |
+
description=(
|
| 95 |
+
"Voici le contenu du CV :\n\n{cv_content}\n\n"
|
| 96 |
+
"""
|
| 97 |
+
Extrais le parcours de formation et les certifications. Fais une distinction claire entre les types de formation.
|
| 98 |
+
Pour chaque élément, fournis :
|
| 99 |
+
- degree: Le nom du diplôme, du titre (ex: 'Titre RNCP niveau 6') ou de la certification (ex: 'Core Designer Certification').
|
| 100 |
+
- institution: L'école, l'université ou la plateforme (ex: 'WILD CODE SCHOOL', 'DataIku', 'DataCamp').
|
| 101 |
+
- start_date: La date de début. Si non trouvée, retourne "Non spécifié".
|
| 102 |
+
- end_date: La date de fin. Si non trouvée, retourne "Non spécifié".
|
| 103 |
+
|
| 104 |
+
RÈGLES STRICTES :
|
| 105 |
+
1. Si tu vois une certification comme "DataIku (core designer)", le diplôme est "Core Designer" et l'institution est "DataIku". NE PAS les mélanger.
|
| 106 |
+
2. NE PAS extraire une simple compétence (ex: 'Python') comme une formation.
|
| 107 |
+
"""
|
| 108 |
+
),
|
| 109 |
+
agent=education_extractor_agent,
|
| 110 |
+
input_keys=["cv_content"],
|
| 111 |
+
expected_output=(
|
| 112 |
+
"Un tableau JSON VALIDE d'objets 'Formation' avec les clés : 'degree', 'institution', 'start_date', 'end_date'. "
|
| 113 |
+
"FORMAT EXACT: [{\"degree\": \"diplôme\", \"institution\": \"établissement\", \"start_date\": \"année\", \"end_date\": \"année\"]}"
|
| 114 |
+
)
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
task_extract_informations = Task(
|
| 118 |
+
description=(
|
| 119 |
+
"Voici le contenu du CV :\n\n{cv_content}\n\n"
|
| 120 |
+
"Votre tâche est d'extraire les informations de contact du candidat. Ces informations se trouvent généralement au début ou à la fin du CV, souvent sous une section intitulée 'CONTACT'.\n"
|
| 121 |
+
"Extrayez précisément :\n"
|
| 122 |
+
"- Le **Nom complet**.\n"
|
| 123 |
+
"- L'**Adresse e-mail**.\n"
|
| 124 |
+
"- Le **Numéro de téléphone**.\n"
|
| 125 |
+
"- La **Localisation** (ville ou région).\n"
|
| 126 |
+
"toutes les informations devront être normalisées, principalement le nom si il est en majuscule en titre. "
|
| 127 |
+
),
|
| 128 |
+
agent=informations_personnelle_agent,
|
| 129 |
+
input_keys=["cv_content"],
|
| 130 |
+
expected_output=(
|
| 131 |
+
"Un dictionnaire JSON VALIDE 'informations_personnelles' contenant le nom, l'email, le numéro de téléphone et la localisation du candidat. "
|
| 132 |
+
"FORMAT EXACT: {\"nom\": \"nom\", \"email\": \"email\", \"numero_de_telephone\": \"tel\", \"localisation\": \"lieu\"}"
|
| 133 |
+
)
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
task_detect_reconversion = Task(
|
| 137 |
+
description=(
|
| 138 |
+
"En te basant sur les données extraites de la tâche `task_extract_experience`, analyse la chronologie des expériences professionnelles. "
|
| 139 |
+
"Ton objectif est de déterminer si le candidat est en reconversion professionnelle. "
|
| 140 |
+
"Cherche des changements de secteur d'activité (ex: de la restauration à la tech), des changements de type de poste (ex: de commercial à développeur), ou des sauts de carrière importants. "
|
| 141 |
+
"Si une reconversion est détectée, identifie les compétences qui semblent avoir été transférées."
|
| 142 |
+
),
|
| 143 |
+
agent=reconversion_detector_agent,
|
| 144 |
+
context=[task_extract_experience],
|
| 145 |
+
expected_output=(
|
| 146 |
+
"Un dictionnaire JSON VALIDE avec une clé 'reconversion_analysis'. "
|
| 147 |
+
"Ce dictionnaire doit contenir deux clés : 'is_reconversion' (un booléen) et 'analysis' (une chaîne de caractères expliquant pourquoi, ou pourquoi pas, et listant les compétences transférables si applicable). "
|
| 148 |
+
"FORMAT EXACT: {\"reconversion_analysis\": {\"is_reconversion\": true, \"analysis\": \"Le candidat a changé de secteur...\"}}"
|
| 149 |
+
)
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
task_build_profile = Task(
|
| 153 |
+
description=(
|
| 154 |
+
"Ta mission est d'agir comme un architecte de données. En utilisant les extractions des tâches précédentes, "
|
| 155 |
+
"assemble un profil de candidat complet. "
|
| 156 |
+
"Le résultat final doit être un unique objet JSON, parfaitement valide."
|
| 157 |
+
),
|
| 158 |
+
agent=ProfileBuilderAgent,
|
| 159 |
+
context=[
|
| 160 |
+
task_extract_informations,
|
| 161 |
+
task_extract_skills,
|
| 162 |
+
task_extract_experience,
|
| 163 |
+
task_extract_projects,
|
| 164 |
+
task_extract_education,
|
| 165 |
+
task_detect_reconversion
|
| 166 |
+
],
|
| 167 |
+
expected_output=(
|
| 168 |
+
"Retourner un unique objet JSON valide. Cet objet doit avoir une seule clé à la racine : 'candidat'. "
|
| 169 |
+
"La valeur de cette clé sera un autre objet contenant toutes les informations assemblées. "
|
| 170 |
+
"Assure-toi que la syntaxe est parfaite, que tous les guillemets sont des guillemets doubles et qu'il n'y a aucune virgule finale. "
|
| 171 |
+
"Le JSON doit être immédiatement parsable par un programme.\n\n"
|
| 172 |
+
"FORMAT EXACT:\n"
|
| 173 |
+
"{\n"
|
| 174 |
+
" \"candidat\": {\n"
|
| 175 |
+
" \"informations_personnelles\": {\"nom\": \"...\", \"email\": \"...\", ...},\n"
|
| 176 |
+
" \"compétences\": {\"hard_skills\": [...], \"soft_skills\": [...]},\n"
|
| 177 |
+
" \"expériences\": [{\"Poste\": \"...\", ...}],\n"
|
| 178 |
+
" \"projets\": {\"professional\": [...], \"personal\": [...]},\n"
|
| 179 |
+
" \"formations\": [{\"degree\": \"...\", ...}],\n"
|
| 180 |
+
" \"reconversion\": {\"is_reconversion\": true, \"analysis\": \"...\"}\n"
|
| 181 |
+
" }\n"
|
| 182 |
+
"}"
|
| 183 |
+
)
|
| 184 |
+
)
|
src/cv_parsing_agents.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Module pour le parsing de CV avec CrewAI
|
| 3 |
+
"""
|
| 4 |
+
import os
|
| 5 |
+
import json
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
# Gestion des imports avec fallback
|
| 11 |
+
try:
|
| 12 |
+
from src.crew.crew_pool import analyse_cv
|
| 13 |
+
CREW_POOL_AVAILABLE = True
|
| 14 |
+
logger.info("✅ crew_pool importé avec succès")
|
| 15 |
+
except ImportError as e:
|
| 16 |
+
logger.error(f"❌ Erreur import crew_pool: {e}")
|
| 17 |
+
CREW_POOL_AVAILABLE = False
|
| 18 |
+
analyse_cv = None
|
| 19 |
+
|
| 20 |
+
try:
|
| 21 |
+
from src.config import load_pdf
|
| 22 |
+
CONFIG_AVAILABLE = True
|
| 23 |
+
logger.info("✅ config importé avec succès")
|
| 24 |
+
except ImportError as e:
|
| 25 |
+
logger.error(f"❌ Erreur import config: {e}")
|
| 26 |
+
CONFIG_AVAILABLE = False
|
| 27 |
+
load_pdf = None
|
| 28 |
+
|
| 29 |
+
def clean_dict_keys(data):
|
| 30 |
+
"""
|
| 31 |
+
Nettoie les clés d'un dictionnaire en les convertissant en string.
|
| 32 |
+
|
| 33 |
+
Args:
|
| 34 |
+
data: Données à nettoyer (dict, list, ou autre)
|
| 35 |
+
|
| 36 |
+
Returns:
|
| 37 |
+
Données nettoyées avec des clés string
|
| 38 |
+
"""
|
| 39 |
+
if isinstance(data, dict):
|
| 40 |
+
return {str(key): clean_dict_keys(value) for key, value in data.items()}
|
| 41 |
+
elif isinstance(data, list):
|
| 42 |
+
return [clean_dict_keys(element) for element in data]
|
| 43 |
+
else:
|
| 44 |
+
return data
|
| 45 |
+
|
| 46 |
+
class CvParserAgent:
|
| 47 |
+
"""
|
| 48 |
+
Agent de parsing de CV utilisant CrewAI.
|
| 49 |
+
|
| 50 |
+
Cette classe traite un fichier PDF de CV et en extrait les informations
|
| 51 |
+
structurées (compétences, expériences, formations, etc.)
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
def __init__(self, pdf_path: str):
|
| 55 |
+
"""
|
| 56 |
+
Initialise l'agent de parsing de CV.
|
| 57 |
+
|
| 58 |
+
Args:
|
| 59 |
+
pdf_path (str): Chemin vers le fichier PDF à traiter
|
| 60 |
+
|
| 61 |
+
Raises:
|
| 62 |
+
ValueError: Si le chemin du fichier est invalide
|
| 63 |
+
ImportError: Si les dépendances nécessaires ne sont pas disponibles
|
| 64 |
+
"""
|
| 65 |
+
if not pdf_path or not isinstance(pdf_path, str):
|
| 66 |
+
raise ValueError("Le chemin du fichier PDF doit être une chaîne non vide")
|
| 67 |
+
|
| 68 |
+
self.pdf_path = pdf_path
|
| 69 |
+
|
| 70 |
+
# Vérifier que les dépendances sont disponibles
|
| 71 |
+
if not CREW_POOL_AVAILABLE:
|
| 72 |
+
logger.warning("CrewAI crew_pool non disponible - mode dégradé")
|
| 73 |
+
if not CONFIG_AVAILABLE:
|
| 74 |
+
logger.warning("Module config non disponible - mode dégradé")
|
| 75 |
+
|
| 76 |
+
def process(self) -> dict:
|
| 77 |
+
"""
|
| 78 |
+
Traite le fichier PDF pour en extraire le contenu sous forme de JSON.
|
| 79 |
+
|
| 80 |
+
Returns:
|
| 81 |
+
dict: Dictionnaire contenant les données extraites du CV,
|
| 82 |
+
ou données de fallback en cas d'erreur
|
| 83 |
+
"""
|
| 84 |
+
logger.info(f"Début du traitement du CV : {self.pdf_path}")
|
| 85 |
+
|
| 86 |
+
# Vérifier que le fichier existe
|
| 87 |
+
if not os.path.exists(self.pdf_path):
|
| 88 |
+
logger.error(f"Fichier PDF non trouvé: {self.pdf_path}")
|
| 89 |
+
return self._create_fallback_data()
|
| 90 |
+
|
| 91 |
+
# Vérifier les dépendances
|
| 92 |
+
if not CREW_POOL_AVAILABLE or not CONFIG_AVAILABLE:
|
| 93 |
+
logger.error("Dépendances manquantes pour le traitement complet")
|
| 94 |
+
return self._create_fallback_data()
|
| 95 |
+
|
| 96 |
+
try:
|
| 97 |
+
# Charger le contenu du PDF
|
| 98 |
+
cv_text_content = load_pdf(self.pdf_path)
|
| 99 |
+
if not cv_text_content or not cv_text_content.strip():
|
| 100 |
+
logger.error("Le PDF semble vide ou illisible")
|
| 101 |
+
return self._create_fallback_data()
|
| 102 |
+
|
| 103 |
+
logger.info(f"PDF chargé, {len(cv_text_content)} caractères extraits")
|
| 104 |
+
|
| 105 |
+
# Analyser avec CrewAI
|
| 106 |
+
crew_output = analyse_cv(cv_text_content)
|
| 107 |
+
|
| 108 |
+
if not crew_output or not hasattr(crew_output, 'raw') or not crew_output.raw.strip():
|
| 109 |
+
logger.error("L'analyse par le crew n'a pas retourné de résultat.")
|
| 110 |
+
return self._create_fallback_data()
|
| 111 |
+
|
| 112 |
+
raw_string = crew_output.raw
|
| 113 |
+
logger.info(f"Résultat brut du crew: {raw_string[:200]}...")
|
| 114 |
+
|
| 115 |
+
# Nettoyer le JSON si nécessaire
|
| 116 |
+
json_string_cleaned = self._clean_json_string(raw_string)
|
| 117 |
+
|
| 118 |
+
# Parser le JSON
|
| 119 |
+
profile_data = json.loads(json_string_cleaned)
|
| 120 |
+
logger.info("Parsing JSON réussi")
|
| 121 |
+
|
| 122 |
+
return clean_dict_keys(profile_data)
|
| 123 |
+
|
| 124 |
+
except json.JSONDecodeError as e:
|
| 125 |
+
logger.error(f"Erreur de décodage JSON : {e}")
|
| 126 |
+
if 'crew_output' in locals():
|
| 127 |
+
logger.error(f"Données brutes reçues : {crew_output.raw}")
|
| 128 |
+
return self._create_fallback_data()
|
| 129 |
+
|
| 130 |
+
except Exception as e:
|
| 131 |
+
logger.error(f"Erreur inattendue dans CvParserAgent : {e}", exc_info=True)
|
| 132 |
+
return self._create_fallback_data()
|
| 133 |
+
|
| 134 |
+
def _clean_json_string(self, raw_string: str) -> str:
|
| 135 |
+
"""
|
| 136 |
+
Nettoie une chaîne JSON brute en supprimant les blocs de code markdown.
|
| 137 |
+
|
| 138 |
+
Args:
|
| 139 |
+
raw_string (str): Chaîne brute à nettoyer
|
| 140 |
+
|
| 141 |
+
Returns:
|
| 142 |
+
str: Chaîne JSON nettoyée
|
| 143 |
+
"""
|
| 144 |
+
json_string_cleaned = raw_string.strip()
|
| 145 |
+
|
| 146 |
+
# Supprimer les blocs de code markdown si présents
|
| 147 |
+
if '```' in raw_string:
|
| 148 |
+
try:
|
| 149 |
+
# Chercher le bloc json
|
| 150 |
+
if '```json' in raw_string:
|
| 151 |
+
json_part = raw_string.split('```json')[1].split('```')[0]
|
| 152 |
+
json_string_cleaned = json_part.strip()
|
| 153 |
+
else:
|
| 154 |
+
# Prendre le premier bloc de code
|
| 155 |
+
parts = raw_string.split('```')
|
| 156 |
+
if len(parts) >= 3:
|
| 157 |
+
json_string_cleaned = parts[1].strip()
|
| 158 |
+
except IndexError:
|
| 159 |
+
logger.warning("Format de code block détecté mais mal formé")
|
| 160 |
+
|
| 161 |
+
return json_string_cleaned
|
| 162 |
+
|
| 163 |
+
def _create_fallback_data(self) -> dict:
|
| 164 |
+
"""
|
| 165 |
+
Crée des données de CV de fallback en cas d'erreur de traitement.
|
| 166 |
+
|
| 167 |
+
Returns:
|
| 168 |
+
dict: Structure de données de CV par défaut
|
| 169 |
+
"""
|
| 170 |
+
logger.info("Création de données de fallback pour le CV")
|
| 171 |
+
return {
|
| 172 |
+
"candidat": {
|
| 173 |
+
"informations_personnelles": {
|
| 174 |
+
"nom": "Candidat Test",
|
| 175 |
+
"email": "test@example.com",
|
| 176 |
+
"numero_de_telephone": "Non spécifié",
|
| 177 |
+
"localisation": "Non spécifiée"
|
| 178 |
+
},
|
| 179 |
+
"compétences": {
|
| 180 |
+
"hard_skills": ["Python", "FastAPI", "Data Analysis"],
|
| 181 |
+
"soft_skills": ["Communication", "Travail d'équipe", "Adaptabilité"]
|
| 182 |
+
},
|
| 183 |
+
"expériences": [
|
| 184 |
+
{
|
| 185 |
+
"Poste": "Développeur",
|
| 186 |
+
"Entreprise": "Entreprise Test",
|
| 187 |
+
"start_date": "2022",
|
| 188 |
+
"end_date": "Aujourd'hui",
|
| 189 |
+
"responsabilités": ["Développement d'applications", "Maintenance du code"]
|
| 190 |
+
}
|
| 191 |
+
],
|
| 192 |
+
"projets": {
|
| 193 |
+
"professional": [
|
| 194 |
+
{
|
| 195 |
+
"title": "Projet Test",
|
| 196 |
+
"role": "Développeur principal",
|
| 197 |
+
"technologies": ["Python", "FastAPI"],
|
| 198 |
+
"outcomes": ["Application fonctionnelle"]
|
| 199 |
+
}
|
| 200 |
+
],
|
| 201 |
+
"personal": []
|
| 202 |
+
},
|
| 203 |
+
"formations": [
|
| 204 |
+
{
|
| 205 |
+
"degree": "Formation en Informatique",
|
| 206 |
+
"institution": "École Test",
|
| 207 |
+
"start_date": "2020",
|
| 208 |
+
"end_date": "2022"
|
| 209 |
+
}
|
| 210 |
+
],
|
| 211 |
+
"reconversion": {
|
| 212 |
+
"is_reconversion": False,
|
| 213 |
+
"analysis": "Pas de reconversion détectée - données de test"
|
| 214 |
+
}
|
| 215 |
+
}
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
# Fonction utilitaire pour créer des données de fallback
|
| 219 |
+
def create_fallback_cv_data(pdf_path: str = None) -> dict:
|
| 220 |
+
"""
|
| 221 |
+
Fonction utilitaire pour créer des données de CV de fallback.
|
| 222 |
+
|
| 223 |
+
Args:
|
| 224 |
+
pdf_path (str, optional): Chemin du fichier PDF (non utilisé dans le fallback)
|
| 225 |
+
|
| 226 |
+
Returns:
|
| 227 |
+
dict: Structure de données de CV par défaut
|
| 228 |
+
"""
|
| 229 |
+
return {
|
| 230 |
+
"candidat": {
|
| 231 |
+
"informations_personnelles": {
|
| 232 |
+
"nom": "Candidat Test",
|
| 233 |
+
"email": "test@example.com",
|
| 234 |
+
"numero_de_telephone": "Non spécifié",
|
| 235 |
+
"localisation": "Non spécifiée"
|
| 236 |
+
},
|
| 237 |
+
"compétences": {
|
| 238 |
+
"hard_skills": ["Python", "FastAPI", "Data Analysis"],
|
| 239 |
+
"soft_skills": ["Communication", "Travail d'équipe", "Adaptabilité"]
|
| 240 |
+
},
|
| 241 |
+
"expériences": [
|
| 242 |
+
{
|
| 243 |
+
"Poste": "Développeur",
|
| 244 |
+
"Entreprise": "Entreprise Test",
|
| 245 |
+
"start_date": "2022",
|
| 246 |
+
"end_date": "Aujourd'hui",
|
| 247 |
+
"responsabilités": ["Développement d'applications", "Maintenance du code"]
|
| 248 |
+
}
|
| 249 |
+
],
|
| 250 |
+
"projets": {
|
| 251 |
+
"professional": [
|
| 252 |
+
{
|
| 253 |
+
"title": "Projet Test",
|
| 254 |
+
"role": "Développeur principal",
|
| 255 |
+
"technologies": ["Python", "FastAPI"],
|
| 256 |
+
"outcomes": ["Application fonctionnelle"]
|
| 257 |
+
}
|
| 258 |
+
],
|
| 259 |
+
"personal": []
|
| 260 |
+
},
|
| 261 |
+
"formations": [
|
| 262 |
+
{
|
| 263 |
+
"degree": "Formation en Informatique",
|
| 264 |
+
"institution": "École Test",
|
| 265 |
+
"start_date": "2020",
|
| 266 |
+
"end_date": "2022"
|
| 267 |
+
}
|
| 268 |
+
],
|
| 269 |
+
"reconversion": {
|
| 270 |
+
"is_reconversion": False,
|
| 271 |
+
"analysis": "Pas de reconversion détectée - données de test"
|
| 272 |
+
}
|
| 273 |
+
}
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
# Test des imports au chargement du module
|
| 277 |
+
if __name__ == "__main__":
|
| 278 |
+
logger.info("Test du module cv_parsing_agents")
|
| 279 |
+
logger.info(f"CREW_POOL_AVAILABLE: {CREW_POOL_AVAILABLE}")
|
| 280 |
+
logger.info(f"CONFIG_AVAILABLE: {CONFIG_AVAILABLE}")
|
| 281 |
+
|
| 282 |
+
# Test de création d'une instance
|
| 283 |
+
try:
|
| 284 |
+
agent = CvParserAgent("/tmp/test.pdf")
|
| 285 |
+
logger.info("✅ CvParserAgent créé avec succès")
|
| 286 |
+
except Exception as e:
|
| 287 |
+
logger.error(f"❌ Erreur création CvParserAgent: {e}")
|
| 288 |
+
|
| 289 |
+
# Test des données de fallback
|
| 290 |
+
fallback_data = create_fallback_cv_data()
|
| 291 |
+
logger.info(f"✅ Données de fallback créées: {len(fallback_data)} clés")
|
src/{core/deep_learning_analyzer.py → deep_learning_analyzer.py}
RENAMED
|
@@ -1,56 +1,57 @@
|
|
| 1 |
-
import torch
|
| 2 |
-
from transformers import pipeline
|
| 3 |
-
from sentence_transformers import SentenceTransformer, util
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
"
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
)
|
| 14 |
-
self.
|
| 15 |
-
|
| 16 |
-
"
|
| 17 |
-
|
| 18 |
-
)
|
| 19 |
-
|
| 20 |
-
def analyze_sentiment(self, messages
|
| 21 |
-
user_messages = [msg['content'] for msg in messages if msg['role'] == 'user']
|
| 22 |
-
if not user_messages:
|
| 23 |
-
return []
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
if
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
candidate_labels = [
|
| 39 |
-
"parle de son expérience technique",
|
| 40 |
-
"exprime sa motivation",
|
| 41 |
-
"pose une question",
|
| 42 |
-
"exprime de l
|
| 43 |
-
]
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
"overall_similarity_score": round(similarity_score, 2),
|
| 53 |
-
"sentiment_analysis": sentiment_results,
|
| 54 |
-
"intent_analysis": intent_results,
|
| 55 |
-
"raw_transcript": conversation_history
|
| 56 |
-
}
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
from transformers import pipeline
|
| 3 |
+
from sentence_transformers import SentenceTransformer, util
|
| 4 |
+
|
| 5 |
+
class MultiModelInterviewAnalyzer:
|
| 6 |
+
def __init__(self):
|
| 7 |
+
self.sentiment_analyzer = pipeline(
|
| 8 |
+
"text-classification",
|
| 9 |
+
model="astrosbd/french_emotion_camembert",
|
| 10 |
+
return_all_scores=True,
|
| 11 |
+
device=0 if torch.cuda.is_available() else -1,
|
| 12 |
+
)
|
| 13 |
+
self.similarity_model = SentenceTransformer('all-MiniLM-L6-v2')
|
| 14 |
+
self.intent_classifier = pipeline(
|
| 15 |
+
"zero-shot-classification",
|
| 16 |
+
model="joeddav/xlm-roberta-large-xnli"
|
| 17 |
+
#device=0 if torch.cuda.is_available() else -1,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
def analyze_sentiment(self, messages):
|
| 21 |
+
user_messages = [msg['content'] for msg in messages if msg['role'] == 'user']
|
| 22 |
+
if not user_messages:
|
| 23 |
+
return []
|
| 24 |
+
sentiments = self.sentiment_analyzer(user_messages)
|
| 25 |
+
return sentiments
|
| 26 |
+
|
| 27 |
+
def compute_semantic_similarity(self, messages, job_requirements):
|
| 28 |
+
user_answers = " ".join([msg['content'] for msg in messages if msg['role'] == 'user'])
|
| 29 |
+
embedding_answers = self.similarity_model.encode(user_answers, convert_to_tensor=True)
|
| 30 |
+
embedding_requirements = self.similarity_model.encode(job_requirements, convert_to_tensor=True)
|
| 31 |
+
cosine_score = util.cos_sim(embedding_answers, embedding_requirements)
|
| 32 |
+
return cosine_score.item()
|
| 33 |
+
|
| 34 |
+
def classify_candidate_intent(self, messages):
|
| 35 |
+
user_answers = [msg['content'] for msg in messages if msg['role'] == 'user']
|
| 36 |
+
if not user_answers:
|
| 37 |
+
return []
|
| 38 |
+
candidate_labels = [
|
| 39 |
+
"parle de son expérience technique",
|
| 40 |
+
"exprime sa motivation",
|
| 41 |
+
"pose une question",
|
| 42 |
+
"exprime de l’incertitude ou du stress"
|
| 43 |
+
]
|
| 44 |
+
classifications = self.intent_classifier(user_answers, candidate_labels, multi_label=False)
|
| 45 |
+
return classifications
|
| 46 |
+
|
| 47 |
+
def run_full_analysis(self, conversation_history, job_requirements):
|
| 48 |
+
sentiment_results = self.analyze_sentiment(conversation_history)
|
| 49 |
+
similarity_score = self.compute_semantic_similarity(conversation_history, job_requirements)
|
| 50 |
+
intent_results = self.classify_candidate_intent(conversation_history)
|
| 51 |
+
analysis_output = {
|
| 52 |
+
"overall_similarity_score": round(similarity_score, 2),
|
| 53 |
+
"sentiment_analysis": sentiment_results,
|
| 54 |
+
"intent_analysis": intent_results,
|
| 55 |
+
"raw_transcript": conversation_history
|
| 56 |
+
}
|
| 57 |
+
return analysis_output
|
src/{core → interview_simulator}/__init__.py
RENAMED
|
File without changes
|
src/interview_simulator/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (191 Bytes). View file
|
|
|
src/interview_simulator/__pycache__/entretient_version_prod.cpython-312.pyc
ADDED
|
Binary file (5.44 kB). View file
|
|
|
src/{services/interview_service.py → interview_simulator/entretient_version_prod.py}
RENAMED
|
@@ -4,12 +4,16 @@ import json
|
|
| 4 |
from typing import Dict, List, Any, Annotated
|
| 5 |
from typing_extensions import TypedDict
|
| 6 |
|
| 7 |
-
from langchain_core.messages import AIMessage, SystemMessage, HumanMessage
|
|
|
|
| 8 |
from langgraph.graph import StateGraph, START, END
|
| 9 |
from langgraph.graph.message import add_messages
|
|
|
|
| 10 |
from langchain_openai import ChatOpenAI
|
| 11 |
|
| 12 |
from src.config import read_system_prompt, format_cv
|
|
|
|
|
|
|
| 13 |
|
| 14 |
class State(TypedDict):
|
| 15 |
messages: Annotated[list, add_messages]
|
|
@@ -24,7 +28,9 @@ class InterviewProcessor:
|
|
| 24 |
self.job_offer = job_offer
|
| 25 |
self.cv_data = cv_document['candidat']
|
| 26 |
self.conversation_history = conversation_history
|
|
|
|
| 27 |
self.llm = self._get_llm()
|
|
|
|
| 28 |
|
| 29 |
self.system_prompt_template = self._load_prompt_template()
|
| 30 |
self.graph = self._build_graph()
|
|
@@ -32,73 +38,59 @@ class InterviewProcessor:
|
|
| 32 |
def _get_llm(self) -> ChatOpenAI:
|
| 33 |
openai_api_key = os.getenv("OPENAI_API_KEY")
|
| 34 |
return ChatOpenAI(
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
|
| 40 |
def _load_prompt_template(self) -> str:
|
| 41 |
-
return read_system_prompt('prompts/rag_prompt_old.txt')
|
| 42 |
-
|
| 43 |
-
def _extract_skills_summary(self) -> str:
|
| 44 |
-
"""Extrait un résumé simple des compétences avec niveaux"""
|
| 45 |
-
competences = self.cv_data.get('analyse_competences', [])
|
| 46 |
-
if not competences:
|
| 47 |
-
return "Aucune analyse de compétences disponible."
|
| 48 |
-
|
| 49 |
-
summary = []
|
| 50 |
-
for comp in competences:
|
| 51 |
-
skill = comp.get('skill', '')
|
| 52 |
-
level = comp.get('level', 'débutant')
|
| 53 |
-
summary.append(f"{skill}: {level}")
|
| 54 |
-
|
| 55 |
-
return "Niveaux de compétences du candidat: " + " | ".join(summary)
|
| 56 |
-
|
| 57 |
-
def _extract_reconversion_info(self) -> str:
|
| 58 |
-
"""Extrait les infos de reconversion"""
|
| 59 |
-
reconversion = self.cv_data.get('reconversion', {})
|
| 60 |
-
if not reconversion:
|
| 61 |
-
return ""
|
| 62 |
-
|
| 63 |
-
is_reconversion = reconversion.get('is_reconversion', False)
|
| 64 |
-
if not is_reconversion:
|
| 65 |
-
return ""
|
| 66 |
-
|
| 67 |
-
analysis = reconversion.get('analysis', '')
|
| 68 |
-
return f"CANDIDAT EN RECONVERSION: {analysis}"
|
| 69 |
|
| 70 |
def _chatbot_node(self, state: State) -> dict:
|
|
|
|
|
|
|
|
|
|
| 71 |
messages = state["messages"]
|
| 72 |
formatted_cv_str = format_cv(self.cv_data)
|
| 73 |
-
|
| 74 |
-
# Extractions simples
|
| 75 |
-
skills_summary = self._extract_skills_summary()
|
| 76 |
-
reconversion_info = self._extract_reconversion_info()
|
| 77 |
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
| 79 |
system_prompt = self.system_prompt_template.format(
|
| 80 |
entreprise=self.job_offer.get('entreprise', 'notre entreprise'),
|
| 81 |
poste=self.job_offer.get('poste', 'ce poste'),
|
| 82 |
-
mission=
|
| 83 |
-
profil_recherche=
|
| 84 |
-
competences=
|
| 85 |
-
pole=
|
| 86 |
-
cv=formatted_cv_str
|
| 87 |
-
skills_analysis=skills_summary,
|
| 88 |
-
reconversion_analysis=reconversion_info
|
| 89 |
)
|
| 90 |
-
|
| 91 |
llm_messages = [SystemMessage(content=system_prompt)] + messages
|
| 92 |
-
response = self.
|
| 93 |
return {"messages": [response]}
|
| 94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
def _build_graph(self) -> any:
|
| 96 |
graph_builder = StateGraph(State)
|
| 97 |
|
| 98 |
graph_builder.add_node("chatbot", self._chatbot_node)
|
| 99 |
-
graph_builder.
|
| 100 |
-
graph_builder.add_edge("chatbot"
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
return graph_builder.compile()
|
| 103 |
|
| 104 |
def run(self, messages: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
|
|
| 4 |
from typing import Dict, List, Any, Annotated
|
| 5 |
from typing_extensions import TypedDict
|
| 6 |
|
| 7 |
+
from langchain_core.messages import AIMessage, SystemMessage, HumanMessage, ToolMessage
|
| 8 |
+
from langchain_groq import ChatGroq
|
| 9 |
from langgraph.graph import StateGraph, START, END
|
| 10 |
from langgraph.graph.message import add_messages
|
| 11 |
+
from langgraph.prebuilt import ToolNode
|
| 12 |
from langchain_openai import ChatOpenAI
|
| 13 |
|
| 14 |
from src.config import read_system_prompt, format_cv
|
| 15 |
+
from src.crew.crew_pool import interview_analyser
|
| 16 |
+
|
| 17 |
|
| 18 |
class State(TypedDict):
|
| 19 |
messages: Annotated[list, add_messages]
|
|
|
|
| 28 |
self.job_offer = job_offer
|
| 29 |
self.cv_data = cv_document['candidat']
|
| 30 |
self.conversation_history = conversation_history
|
| 31 |
+
self.tools = [interview_analyser]
|
| 32 |
self.llm = self._get_llm()
|
| 33 |
+
self.llm_with_tools = self.llm.bind_tools(self.tools)
|
| 34 |
|
| 35 |
self.system_prompt_template = self._load_prompt_template()
|
| 36 |
self.graph = self._build_graph()
|
|
|
|
| 38 |
def _get_llm(self) -> ChatOpenAI:
|
| 39 |
openai_api_key = os.getenv("OPENAI_API_KEY")
|
| 40 |
return ChatOpenAI(
|
| 41 |
+
temperature=0.6,
|
| 42 |
+
model_name="gpt-4o-mini",
|
| 43 |
+
api_key=openai_api_key
|
| 44 |
+
)
|
| 45 |
|
| 46 |
def _load_prompt_template(self) -> str:
|
| 47 |
+
return read_system_prompt('prompts/rag_prompt_old.txt')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
def _chatbot_node(self, state: State) -> dict:
|
| 50 |
+
if state["messages"] and isinstance(state["messages"][-1], ToolMessage):
|
| 51 |
+
tool_message = state["messages"][-1]
|
| 52 |
+
return {"messages": [AIMessage(content=tool_message.content)]}
|
| 53 |
messages = state["messages"]
|
| 54 |
formatted_cv_str = format_cv(self.cv_data)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
+
mission = self.job_offer.get('mission', 'Non spécifiée')
|
| 57 |
+
profil_recherche = self.job_offer.get('profil_recherche', 'Non spécifié')
|
| 58 |
+
competences = self.job_offer.get('competences', 'Non spécifiées')
|
| 59 |
+
pole = self.job_offer.get('pole', 'Non spécifié')
|
| 60 |
system_prompt = self.system_prompt_template.format(
|
| 61 |
entreprise=self.job_offer.get('entreprise', 'notre entreprise'),
|
| 62 |
poste=self.job_offer.get('poste', 'ce poste'),
|
| 63 |
+
mission=mission,
|
| 64 |
+
profil_recherche=profil_recherche,
|
| 65 |
+
competences=competences,
|
| 66 |
+
pole=pole,
|
| 67 |
+
cv=formatted_cv_str
|
|
|
|
|
|
|
| 68 |
)
|
|
|
|
| 69 |
llm_messages = [SystemMessage(content=system_prompt)] + messages
|
| 70 |
+
response = self.llm_with_tools.invoke(llm_messages)
|
| 71 |
return {"messages": [response]}
|
| 72 |
|
| 73 |
+
def _route_after_chatbot(self, state: State) -> str:
|
| 74 |
+
last_message = state["messages"][-1]
|
| 75 |
+
if last_message.tool_calls:
|
| 76 |
+
return "call_tool"
|
| 77 |
+
return END
|
| 78 |
+
|
| 79 |
def _build_graph(self) -> any:
|
| 80 |
graph_builder = StateGraph(State)
|
| 81 |
|
| 82 |
graph_builder.add_node("chatbot", self._chatbot_node)
|
| 83 |
+
graph_builder.add_node("call_tool", ToolNode(self.tools))
|
| 84 |
+
graph_builder.add_edge(START, "chatbot")
|
| 85 |
+
graph_builder.add_conditional_edges(
|
| 86 |
+
"chatbot",
|
| 87 |
+
self._route_after_chatbot,
|
| 88 |
+
{
|
| 89 |
+
"call_tool": "call_tool",
|
| 90 |
+
END: END
|
| 91 |
+
}
|
| 92 |
+
)
|
| 93 |
+
graph_builder.add_edge("call_tool", "chatbot")
|
| 94 |
return graph_builder.compile()
|
| 95 |
|
| 96 |
def run(self, messages: List[Dict[str, Any]]) -> Dict[str, Any]:
|
src/models.py
DELETED
|
@@ -1,37 +0,0 @@
|
|
| 1 |
-
import logging
|
| 2 |
-
from typing import Dict, Any, Optional
|
| 3 |
-
|
| 4 |
-
logger = logging.getLogger(__name__)
|
| 5 |
-
|
| 6 |
-
def load_all_models() -> Dict[str, Any]:
|
| 7 |
-
models = {
|
| 8 |
-
"status": False,
|
| 9 |
-
"deep_learning_analyzer": None,
|
| 10 |
-
"rag_handler": None,
|
| 11 |
-
"llm": None
|
| 12 |
-
}
|
| 13 |
-
|
| 14 |
-
try:
|
| 15 |
-
from src.core.deep_learning_analyzer import MultiModelInterviewAnalyzer
|
| 16 |
-
models["deep_learning_analyzer"] = MultiModelInterviewAnalyzer()
|
| 17 |
-
logger.info("✅ Deep Learning Analyzer chargé")
|
| 18 |
-
except Exception as e:
|
| 19 |
-
logger.error(f"❌ Erreur chargement Deep Learning Analyzer: {e}")
|
| 20 |
-
|
| 21 |
-
try:
|
| 22 |
-
from src.core.rag_handler import get_rag_handler
|
| 23 |
-
models["rag_handler"] = get_rag_handler()
|
| 24 |
-
logger.info("✅ RAG Handler chargé")
|
| 25 |
-
except Exception as e:
|
| 26 |
-
logger.error(f"❌ Erreur chargement RAG Handler: {e}")
|
| 27 |
-
|
| 28 |
-
try:
|
| 29 |
-
from src.config import crew_openai
|
| 30 |
-
models["llm"] = crew_openai()
|
| 31 |
-
logger.info("✅ LLM chargé")
|
| 32 |
-
except Exception as e:
|
| 33 |
-
logger.error(f"❌ Erreur chargement LLM: {e}")
|
| 34 |
-
|
| 35 |
-
models["status"] = all(v is not None for k, v in models.items() if k != "status")
|
| 36 |
-
|
| 37 |
-
return models
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/rag_handler.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import logging
|
| 3 |
+
from typing import Optional
|
| 4 |
+
from langchain_community.document_loaders import DirectoryLoader, TextLoader
|
| 5 |
+
from langchain_community.vectorstores import FAISS
|
| 6 |
+
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
# Variables globales pour l'initialisation différée
|
| 11 |
+
_embeddings_model = None
|
| 12 |
+
_rag_handler_instance = None
|
| 13 |
+
|
| 14 |
+
# Utiliser /tmp qui est toujours writable dans les conteneurs
|
| 15 |
+
VECTOR_STORE_PATH = "/tmp/vector_store"
|
| 16 |
+
|
| 17 |
+
def get_embeddings_model():
|
| 18 |
+
"""Obtient le modèle d'embeddings avec initialisation différée."""
|
| 19 |
+
global _embeddings_model
|
| 20 |
+
if _embeddings_model is None:
|
| 21 |
+
try:
|
| 22 |
+
from langchain_huggingface import HuggingFaceEmbeddings
|
| 23 |
+
logger.info("Initialisation du modèle d'embeddings...")
|
| 24 |
+
_embeddings_model = HuggingFaceEmbeddings(
|
| 25 |
+
model_name='sentence-transformers/all-MiniLM-L6-v2',
|
| 26 |
+
model_kwargs={'device': 'cpu'},
|
| 27 |
+
encode_kwargs={'normalize_embeddings': True}
|
| 28 |
+
)
|
| 29 |
+
logger.info("✅ Modèle d'embeddings initialisé avec succès")
|
| 30 |
+
except Exception as e:
|
| 31 |
+
logger.error(f"❌ Erreur lors de l'initialisation du modèle d'embeddings: {e}")
|
| 32 |
+
_embeddings_model = None
|
| 33 |
+
return _embeddings_model
|
| 34 |
+
|
| 35 |
+
class RAGHandler:
|
| 36 |
+
def __init__(self, knowledge_base_path: str = "/app/knowledge_base", lazy_init: bool = True):
|
| 37 |
+
"""
|
| 38 |
+
Initialise le RAG Handler.
|
| 39 |
+
|
| 40 |
+
Args:
|
| 41 |
+
knowledge_base_path (str): Le chemin vers le dossier contenant les documents de connaissances (.md).
|
| 42 |
+
lazy_init (bool): Si True, initialise le vector store seulement lors de la première utilisation.
|
| 43 |
+
"""
|
| 44 |
+
self.knowledge_base_path = knowledge_base_path
|
| 45 |
+
self.embeddings = None
|
| 46 |
+
self.vector_store = None
|
| 47 |
+
self._initialized = False
|
| 48 |
+
|
| 49 |
+
# S'assurer que le répertoire /tmp/vector_store existe
|
| 50 |
+
os.makedirs(VECTOR_STORE_PATH, exist_ok=True)
|
| 51 |
+
|
| 52 |
+
if not lazy_init:
|
| 53 |
+
self._initialize()
|
| 54 |
+
|
| 55 |
+
def _initialize(self):
|
| 56 |
+
"""Initialise le RAG Handler de manière différée."""
|
| 57 |
+
if self._initialized:
|
| 58 |
+
return
|
| 59 |
+
|
| 60 |
+
try:
|
| 61 |
+
logger.info("Initialisation du RAG Handler...")
|
| 62 |
+
self.embeddings = get_embeddings_model()
|
| 63 |
+
|
| 64 |
+
if self.embeddings is None:
|
| 65 |
+
logger.error("Impossible d'initialiser les embeddings")
|
| 66 |
+
return
|
| 67 |
+
|
| 68 |
+
self.vector_store = self._load_or_create_vector_store(self.knowledge_base_path)
|
| 69 |
+
self._initialized = True
|
| 70 |
+
logger.info("✅ RAG Handler initialisé avec succès")
|
| 71 |
+
|
| 72 |
+
except Exception as e:
|
| 73 |
+
logger.error(f"❌ Erreur lors de l'initialisation du RAG Handler: {e}")
|
| 74 |
+
self._initialized = False
|
| 75 |
+
|
| 76 |
+
def _load_documents(self, path: str) -> list:
|
| 77 |
+
"""Charge les documents depuis un chemin de répertoire spécifié."""
|
| 78 |
+
try:
|
| 79 |
+
if not os.path.exists(path):
|
| 80 |
+
logger.warning(f"Répertoire {path} non trouvé")
|
| 81 |
+
return []
|
| 82 |
+
|
| 83 |
+
loader = DirectoryLoader(
|
| 84 |
+
path,
|
| 85 |
+
glob="**/*.md",
|
| 86 |
+
loader_cls=TextLoader,
|
| 87 |
+
loader_kwargs={"encoding": "utf-8"}
|
| 88 |
+
)
|
| 89 |
+
logger.info(f"Chargement des documents depuis : {path}")
|
| 90 |
+
documents = loader.load()
|
| 91 |
+
logger.info(f"✅ {len(documents)} documents chargés")
|
| 92 |
+
return documents
|
| 93 |
+
except Exception as e:
|
| 94 |
+
logger.error(f"❌ Erreur lors du chargement des documents: {e}")
|
| 95 |
+
return []
|
| 96 |
+
|
| 97 |
+
def _create_vector_store(self, knowledge_base_path: str) -> Optional[FAISS]:
|
| 98 |
+
"""Crée et sauvegarde la base de données vectorielle à partir des documents."""
|
| 99 |
+
try:
|
| 100 |
+
documents = self._load_documents(knowledge_base_path)
|
| 101 |
+
if not documents:
|
| 102 |
+
logger.warning("Aucun document trouvé - création d'un vector store vide")
|
| 103 |
+
# Créer un document fictif pour initialiser le vector store
|
| 104 |
+
from langchain.schema import Document
|
| 105 |
+
dummy_doc = Document(
|
| 106 |
+
page_content="Document de test pour initialiser le vector store",
|
| 107 |
+
metadata={"source": "dummy"}
|
| 108 |
+
)
|
| 109 |
+
documents = [dummy_doc]
|
| 110 |
+
|
| 111 |
+
logger.info(f"{len(documents)} documents chargés. Création des vecteurs...")
|
| 112 |
+
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=100)
|
| 113 |
+
texts = text_splitter.split_documents(documents)
|
| 114 |
+
|
| 115 |
+
vector_store = FAISS.from_documents(texts, self.embeddings)
|
| 116 |
+
|
| 117 |
+
# Sauvegarder dans /tmp
|
| 118 |
+
try:
|
| 119 |
+
vector_store.save_local(VECTOR_STORE_PATH)
|
| 120 |
+
logger.info(f"✅ Vector store cr��é et sauvegardé dans : {VECTOR_STORE_PATH}")
|
| 121 |
+
except Exception as save_error:
|
| 122 |
+
logger.warning(f"⚠️ Impossible de sauvegarder le vector store: {save_error}")
|
| 123 |
+
# Continuer sans sauvegarde - le vector store reste en mémoire
|
| 124 |
+
|
| 125 |
+
return vector_store
|
| 126 |
+
|
| 127 |
+
except Exception as e:
|
| 128 |
+
logger.error(f"❌ Erreur lors de la création du vector store: {e}")
|
| 129 |
+
return None
|
| 130 |
+
|
| 131 |
+
def _load_or_create_vector_store(self, knowledge_base_path: str) -> Optional[FAISS]:
|
| 132 |
+
"""Charge le vector store s'il existe, sinon le crée."""
|
| 133 |
+
try:
|
| 134 |
+
index_path = os.path.join(VECTOR_STORE_PATH, "index.faiss")
|
| 135 |
+
if os.path.exists(index_path):
|
| 136 |
+
logger.info(f"Chargement du vector store existant depuis : {VECTOR_STORE_PATH}")
|
| 137 |
+
return FAISS.load_local(
|
| 138 |
+
VECTOR_STORE_PATH,
|
| 139 |
+
embeddings=self.embeddings,
|
| 140 |
+
allow_dangerous_deserialization=True
|
| 141 |
+
)
|
| 142 |
+
else:
|
| 143 |
+
logger.info("Aucun vector store trouvé. Création d'un nouveau...")
|
| 144 |
+
return self._create_vector_store(knowledge_base_path)
|
| 145 |
+
except Exception as e:
|
| 146 |
+
logger.error(f"❌ Erreur lors du chargement/création du vector store: {e}")
|
| 147 |
+
# En cas d'échec total, retourner None plutôt que planter
|
| 148 |
+
return None
|
| 149 |
+
|
| 150 |
+
def get_relevant_feedback(self, query: str, k: int = 1) -> list[str]:
|
| 151 |
+
"""Recherche les k conseils les plus pertinents pour une requête."""
|
| 152 |
+
# Initialisation différée si nécessaire
|
| 153 |
+
if not self._initialized:
|
| 154 |
+
self._initialize()
|
| 155 |
+
|
| 156 |
+
if not self.vector_store:
|
| 157 |
+
logger.warning("Vector store non disponible - retour de conseils génériques")
|
| 158 |
+
return [
|
| 159 |
+
"Préparez vos réponses aux questions comportementales",
|
| 160 |
+
"Montrez votre motivation pour le poste",
|
| 161 |
+
"Donnez des exemples concrets de vos réalisations"
|
| 162 |
+
]
|
| 163 |
+
|
| 164 |
+
try:
|
| 165 |
+
results = self.vector_store.similarity_search(query, k=k)
|
| 166 |
+
feedback = [doc.page_content for doc in results if doc.page_content.strip()]
|
| 167 |
+
|
| 168 |
+
# Fallback si pas de résultats pertinents
|
| 169 |
+
if not feedback:
|
| 170 |
+
return ["Conseil général: Préparez-vous bien pour les entretiens futurs."]
|
| 171 |
+
|
| 172 |
+
return feedback
|
| 173 |
+
except Exception as e:
|
| 174 |
+
logger.error(f"❌ Erreur lors de la recherche: {e}")
|
| 175 |
+
return ["Conseil général: Travaillez sur vos compétences de communication."]
|
| 176 |
+
|
| 177 |
+
# Fonction pour obtenir une instance partagée
|
| 178 |
+
def get_rag_handler() -> Optional[RAGHandler]:
|
| 179 |
+
"""Obtient une instance partagée du RAG Handler."""
|
| 180 |
+
global _rag_handler_instance
|
| 181 |
+
if _rag_handler_instance is None:
|
| 182 |
+
try:
|
| 183 |
+
_rag_handler_instance = RAGHandler(lazy_init=True)
|
| 184 |
+
except Exception as e:
|
| 185 |
+
logger.error(f"❌ Erreur lors de la création du RAG Handler: {e}")
|
| 186 |
+
_rag_handler_instance = None
|
| 187 |
+
return _rag_handler_instance
|
| 188 |
+
|
| 189 |
+
if __name__ == '__main__':
|
| 190 |
+
print("Test du RAG Handler avec /tmp vector store...")
|
| 191 |
+
handler = RAGHandler(knowledge_base_path="/app/knowledge_base", lazy_init=False)
|
| 192 |
+
|
| 193 |
+
test_query = "gestion du stress"
|
| 194 |
+
feedback = handler.get_relevant_feedback(test_query, k=2)
|
| 195 |
+
|
| 196 |
+
print(f"\nTest de recherche pour : '{test_query}'")
|
| 197 |
+
if feedback:
|
| 198 |
+
print("Feedback trouvé :")
|
| 199 |
+
for f in feedback:
|
| 200 |
+
print(f"- {f[:150]}...")
|
| 201 |
+
else:
|
| 202 |
+
print("Aucun feedback trouvé.")
|
src/scoring_engine.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
|
| 4 |
+
# Pondérations basées sur la fiche projet
|
| 5 |
+
CONTEXT_WEIGHTS = {
|
| 6 |
+
"formations": 0.3,
|
| 7 |
+
"projets": 0.6,
|
| 8 |
+
"expériences": 0.8,
|
| 9 |
+
"multiple": 1.0
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
# Facteurs pour la formule de scoring
|
| 13 |
+
ALPHA = 0.5 # Poids du contexte
|
| 14 |
+
BETA = 0.3 # Poids de la fréquence
|
| 15 |
+
GAMMA = 0.2 # Poids de la profondeur (durée)
|
| 16 |
+
|
| 17 |
+
class ContextualScoringEngine:
|
| 18 |
+
def __init__(self, cv_data: dict):
|
| 19 |
+
self.cv_data = cv_data.get("candidat", {})
|
| 20 |
+
self.full_text = self._get_full_text_from_cv()
|
| 21 |
+
|
| 22 |
+
def _get_full_text_from_cv(self) -> str:
|
| 23 |
+
"""Concatène tout le contenu textuel du CV pour le comptage de fréquence."""
|
| 24 |
+
return json.dumps(self.cv_data, ensure_ascii=False).lower()
|
| 25 |
+
|
| 26 |
+
def _parse_date(self, date_str: str) -> datetime:
|
| 27 |
+
"""Parse une date, en gérant les cas spéciaux comme 'Aujourd'hui'."""
|
| 28 |
+
if not date_str or date_str.lower() == "non spécifié":
|
| 29 |
+
return None
|
| 30 |
+
if date_str.lower() == "aujourd'hui":
|
| 31 |
+
return datetime.now()
|
| 32 |
+
try:
|
| 33 |
+
return datetime.strptime(date_str, "%Y")
|
| 34 |
+
except ValueError:
|
| 35 |
+
return None
|
| 36 |
+
|
| 37 |
+
def _calculate_duration_in_years(self, start_date_str: str, end_date_str: str) -> float:
|
| 38 |
+
"""Calcule la durée d'une expérience en années."""
|
| 39 |
+
start_date = self._parse_date(start_date_str)
|
| 40 |
+
end_date = self._parse_date(end_date_str)
|
| 41 |
+
if start_date and end_date:
|
| 42 |
+
return abs((end_date - start_date).days / 365.25)
|
| 43 |
+
return 0.5
|
| 44 |
+
|
| 45 |
+
def calculate_scores(self) -> dict:
|
| 46 |
+
"""Calcule les scores pondérés pour toutes les hard skills."""
|
| 47 |
+
skills = self.cv_data.get("compétences", {}).get("hard_skills", [])
|
| 48 |
+
if not skills:
|
| 49 |
+
return {}
|
| 50 |
+
|
| 51 |
+
scored_skills = []
|
| 52 |
+
for skill in skills:
|
| 53 |
+
skill_lower = skill.lower()
|
| 54 |
+
contexts = []
|
| 55 |
+
if skill_lower in json.dumps(self.cv_data.get("formations", []), ensure_ascii=False).lower():
|
| 56 |
+
contexts.append(CONTEXT_WEIGHTS["formations"])
|
| 57 |
+
if skill_lower in json.dumps(self.cv_data.get("projets", []), ensure_ascii=False).lower():
|
| 58 |
+
contexts.append(CONTEXT_WEIGHTS["projets"])
|
| 59 |
+
if skill_lower in json.dumps(self.cv_data.get("expériences", []), ensure_ascii=False).lower():
|
| 60 |
+
contexts.append(CONTEXT_WEIGHTS["expériences"])
|
| 61 |
+
|
| 62 |
+
if len(contexts) > 1:
|
| 63 |
+
context_score = CONTEXT_WEIGHTS["multiple"]
|
| 64 |
+
elif contexts:
|
| 65 |
+
context_score = contexts[0]
|
| 66 |
+
else:
|
| 67 |
+
context_score = 0.1
|
| 68 |
+
|
| 69 |
+
# 2. Fréquence de mention
|
| 70 |
+
frequency_score = self.full_text.count(skill_lower)
|
| 71 |
+
|
| 72 |
+
# 3. Profondeur d'utilisation (durée max en années)
|
| 73 |
+
max_duration = 0
|
| 74 |
+
for exp in self.cv_data.get("expériences", []):
|
| 75 |
+
if skill_lower in json.dumps(exp, ensure_ascii=False).lower():
|
| 76 |
+
duration = self._calculate_duration_in_years(exp.get("start_date"), exp.get("end_date"))
|
| 77 |
+
if duration > max_duration:
|
| 78 |
+
max_duration = duration
|
| 79 |
+
depth_score = max_duration
|
| 80 |
+
|
| 81 |
+
# Normalisation simple (peut être affinée)
|
| 82 |
+
normalized_frequency = 1 - (1 / (1 + frequency_score))
|
| 83 |
+
normalized_depth = 1 - (1 / (1 + depth_score))
|
| 84 |
+
|
| 85 |
+
# Calcul du score final
|
| 86 |
+
final_score = (ALPHA * context_score) + \
|
| 87 |
+
(BETA * normalized_frequency) + \
|
| 88 |
+
(GAMMA * normalized_depth)
|
| 89 |
+
|
| 90 |
+
scored_skills.append({
|
| 91 |
+
"skill": skill,
|
| 92 |
+
"score": round(final_score, 2),
|
| 93 |
+
"details": {
|
| 94 |
+
"context_score": context_score,
|
| 95 |
+
"frequency": frequency_score,
|
| 96 |
+
"max_duration_years": round(depth_score, 1)
|
| 97 |
+
}
|
| 98 |
+
})
|
| 99 |
+
|
| 100 |
+
# Trier par score décroissant
|
| 101 |
+
scored_skills.sort(key=lambda x: x["score"], reverse=True)
|
| 102 |
+
return {"analyse_competences": scored_skills}
|
src/services/__init__.py
DELETED
|
File without changes
|
src/services/analysis_service.py
DELETED
|
@@ -1,97 +0,0 @@
|
|
| 1 |
-
import json
|
| 2 |
-
import logging
|
| 3 |
-
from typing import Dict, List, Any
|
| 4 |
-
from crewai import Agent, Task, Crew, Process
|
| 5 |
-
|
| 6 |
-
logger = logging.getLogger(__name__)
|
| 7 |
-
|
| 8 |
-
class AnalysisService:
|
| 9 |
-
def __init__(self, models: Dict[str, Any]):
|
| 10 |
-
self.models = models
|
| 11 |
-
self.analyzer = models.get("deep_learning_analyzer")
|
| 12 |
-
self.rag_handler = models.get("rag_handler")
|
| 13 |
-
self.llm = models.get("llm")
|
| 14 |
-
self._create_report_agent()
|
| 15 |
-
|
| 16 |
-
def _create_report_agent(self):
|
| 17 |
-
self.report_agent = Agent(
|
| 18 |
-
role='Rédacteur de Rapports Synthétiques',
|
| 19 |
-
goal='Générer un feedback pertinent à partir du déroulement de l\'entretien',
|
| 20 |
-
backstory=(
|
| 21 |
-
"Spécialisé dans le recrutement et les ressources humaines, capable d'évaluer les candidats "
|
| 22 |
-
"sur la communication et la pertinence des réponses en fonction des questions posées, rédige "
|
| 23 |
-
"en un rapport clair, un feedback détaillé sur le candidat."
|
| 24 |
-
),
|
| 25 |
-
allow_delegation=False,
|
| 26 |
-
verbose=False,
|
| 27 |
-
llm=self.llm
|
| 28 |
-
)
|
| 29 |
-
|
| 30 |
-
def run_analysis(self, conversation_history: List[Dict[str, Any]], job_description: str) -> Dict[str, Any]:
|
| 31 |
-
if not self.analyzer:
|
| 32 |
-
return {"error": "Analyzer non disponible"}
|
| 33 |
-
|
| 34 |
-
structured_analysis = self.analyzer.run_full_analysis(conversation_history, job_description)
|
| 35 |
-
|
| 36 |
-
rag_feedback = []
|
| 37 |
-
if self.rag_handler:
|
| 38 |
-
rag_feedback = self._get_contextual_feedback(structured_analysis)
|
| 39 |
-
|
| 40 |
-
report = self._generate_final_report(structured_analysis, rag_feedback)
|
| 41 |
-
|
| 42 |
-
return report
|
| 43 |
-
|
| 44 |
-
def _get_contextual_feedback(self, structured_analysis: Dict[str, Any]) -> List[str]:
|
| 45 |
-
rag_feedback = []
|
| 46 |
-
|
| 47 |
-
if structured_analysis.get("intent_analysis"):
|
| 48 |
-
for intent in structured_analysis["intent_analysis"]:
|
| 49 |
-
query = f"Conseils pour un candidat qui cherche à {intent['labels'][0]}"
|
| 50 |
-
rag_feedback.extend(self.rag_handler.get_relevant_feedback(query))
|
| 51 |
-
|
| 52 |
-
if structured_analysis.get("sentiment_analysis"):
|
| 53 |
-
for sentiment_group in structured_analysis["sentiment_analysis"]:
|
| 54 |
-
for sentiment in sentiment_group:
|
| 55 |
-
if sentiment['label'] == 'stress' and sentiment['score'].item() > 0.6:
|
| 56 |
-
rag_feedback.extend(
|
| 57 |
-
self.rag_handler.get_relevant_feedback("gestion du stress en entretien")
|
| 58 |
-
)
|
| 59 |
-
|
| 60 |
-
return list(set(rag_feedback))
|
| 61 |
-
|
| 62 |
-
def _generate_final_report(
|
| 63 |
-
self,
|
| 64 |
-
structured_analysis: Dict[str, Any],
|
| 65 |
-
rag_feedback: List[str]
|
| 66 |
-
) -> Dict[str, Any]:
|
| 67 |
-
|
| 68 |
-
task = Task(
|
| 69 |
-
description=(
|
| 70 |
-
f"Tu es un rédacteur expert en RH. Ta mission est de rédiger un rapport d'évaluation final. "
|
| 71 |
-
f"Tu dois utiliser deux sources d'information principales : "
|
| 72 |
-
f"1. Les données d'analyse structurées de l'entretien : '{json.dumps(structured_analysis, indent=2)}'. "
|
| 73 |
-
f"2. Une liste de conseils et de feedback pertinents issus de notre base de connaissances : '{chr(10).join(rag_feedback)}'. "
|
| 74 |
-
f"Ta tâche est de synthétiser ces informations en un rapport cohérent et actionnable."
|
| 75 |
-
),
|
| 76 |
-
expected_output=(
|
| 77 |
-
"Un rapport final exceptionnel basé sur l'analyse fournie. Le rapport doit être structuré comme suit: "
|
| 78 |
-
"1. **Résumé et Score d'Adéquation** : Synthétise le score de similarité sémantique et donne un aperçu global. "
|
| 79 |
-
"2. **Analyse Comportementale** : Interprète les résultats de l'analyse de sentiment et d'intention pour décrire le comportement du candidat. "
|
| 80 |
-
"3. **Adéquation Sémantique avec le Poste** : Explique ce que signifie le score de similarité. "
|
| 81 |
-
"4. **Points Forts & Axes d'Amélioration Personnalisés** : Utilise les données d'analyse pour identifier les points à améliorer. "
|
| 82 |
-
"Ensuite, intègre de manière fluide et naturelle les conseils pertinents pour proposer des pistes d'amélioration concrètes et personnalisées. "
|
| 83 |
-
"5. **Recommandation Finale**."
|
| 84 |
-
),
|
| 85 |
-
agent=self.report_agent
|
| 86 |
-
)
|
| 87 |
-
|
| 88 |
-
crew = Crew(
|
| 89 |
-
agents=[self.report_agent],
|
| 90 |
-
tasks=[task],
|
| 91 |
-
process=Process.sequential,
|
| 92 |
-
verbose=False,
|
| 93 |
-
telemetry=False
|
| 94 |
-
)
|
| 95 |
-
|
| 96 |
-
result = crew.kickoff()
|
| 97 |
-
return {"analysis_report": result.raw if hasattr(result, 'raw') else str(result)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/services/graph_service.py
DELETED
|
@@ -1,193 +0,0 @@
|
|
| 1 |
-
import os
|
| 2 |
-
import logging
|
| 3 |
-
import json
|
| 4 |
-
from typing import TypedDict, Annotated, Sequence, Dict, Any, List
|
| 5 |
-
|
| 6 |
-
from langchain_openai import ChatOpenAI
|
| 7 |
-
from langchain_core.runnables import Runnable
|
| 8 |
-
from langchain_core.messages import BaseMessage, AIMessage, HumanMessage
|
| 9 |
-
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
| 10 |
-
from langgraph.graph import StateGraph, END
|
| 11 |
-
from langgraph.prebuilt import ToolNode
|
| 12 |
-
|
| 13 |
-
from tools.analysis_tools import trigger_interview_analysis
|
| 14 |
-
|
| 15 |
-
class AgentState(TypedDict):
|
| 16 |
-
messages: Annotated[Sequence[BaseMessage], lambda x, y: x + y]
|
| 17 |
-
user_id: str
|
| 18 |
-
job_offer_id: str
|
| 19 |
-
job_description: str
|
| 20 |
-
|
| 21 |
-
class GraphInterviewProcessor:
|
| 22 |
-
"""
|
| 23 |
-
Cette classe encapsule la logique d'un entretien en utilisant LangGraph.
|
| 24 |
-
Elle prépare toutes les données nécessaires à l'initialisation.
|
| 25 |
-
"""
|
| 26 |
-
def __init__(self, payload: Dict[str, Any]):
|
| 27 |
-
logging.info("Initialisation de GraphInterviewProcessor...")
|
| 28 |
-
|
| 29 |
-
self.user_id = payload["user_id"]
|
| 30 |
-
self.job_offer_id = payload["job_offer_id"]
|
| 31 |
-
self.job_offer = payload["job_offer"]
|
| 32 |
-
self.cv_data = payload.get("cv_document", {}).get('candidat', {})
|
| 33 |
-
|
| 34 |
-
if not self.cv_data:
|
| 35 |
-
raise ValueError("Données du candidat non trouvées dans le payload.")
|
| 36 |
-
|
| 37 |
-
self.system_prompt_template = self._load_prompt_template('prompts/rag_prompt_old.txt')
|
| 38 |
-
self.formatted_cv_str = self._format_cv_for_prompt()
|
| 39 |
-
self.skills_summary = self._extract_skills_summary()
|
| 40 |
-
self.reconversion_info = self._extract_reconversion_info()
|
| 41 |
-
|
| 42 |
-
self.agent_runnable = self._create_agent_runnable()
|
| 43 |
-
self.graph = self._build_graph()
|
| 44 |
-
logging.info("GraphInterviewProcessor initialisé avec succès.")
|
| 45 |
-
|
| 46 |
-
def _load_prompt_template(self, file_path: str) -> str:
|
| 47 |
-
try:
|
| 48 |
-
with open(file_path, 'r', encoding='utf-8') as f:
|
| 49 |
-
return f.read()
|
| 50 |
-
except FileNotFoundError:
|
| 51 |
-
logging.error(f"Fichier prompt introuvable: {file_path}")
|
| 52 |
-
return "Vous êtes un assistant RH."
|
| 53 |
-
|
| 54 |
-
def _format_cv_for_prompt(self) -> str:
|
| 55 |
-
return json.dumps(self.cv_data, indent=2, ensure_ascii=False)
|
| 56 |
-
|
| 57 |
-
def _extract_skills_summary(self) -> str:
|
| 58 |
-
competences = self.cv_data.get('analyse_competences', [])
|
| 59 |
-
if not competences: return "Aucune analyse de compétences disponible."
|
| 60 |
-
summary = [f"{comp.get('skill', '')}: {comp.get('level', 'débutant')}" for comp in competences]
|
| 61 |
-
return "Niveaux de compétences du candidat: " + " | ".join(summary)
|
| 62 |
-
|
| 63 |
-
def _extract_reconversion_info(self) -> str:
|
| 64 |
-
reconversion = self.cv_data.get('reconversion', {})
|
| 65 |
-
if reconversion.get('is_reconversion'):
|
| 66 |
-
return f"CANDIDAT EN RECONVERSION: {reconversion.get('analysis', '')}"
|
| 67 |
-
return "Le candidat n'est pas identifié comme étant en reconversion."
|
| 68 |
-
|
| 69 |
-
def _create_agent_runnable(self) -> Runnable:
|
| 70 |
-
"""Crée une chaîne (runnable) qui agit comme notre agent."""
|
| 71 |
-
prompt = ChatPromptTemplate.from_messages([
|
| 72 |
-
("system", "{system_prompt_content}"),
|
| 73 |
-
MessagesPlaceholder(variable_name="messages"),
|
| 74 |
-
])
|
| 75 |
-
llm = ChatOpenAI(api_key=os.getenv("OPENAI_API_KEY"), model="gpt-4o-mini", temperature=0.7)
|
| 76 |
-
tools = [trigger_interview_analysis]
|
| 77 |
-
llm_with_tools = llm.bind_tools(tools)
|
| 78 |
-
return prompt | llm_with_tools
|
| 79 |
-
|
| 80 |
-
def _agent_node(self, state: AgentState):
|
| 81 |
-
"""Prépare le prompt et appelle le runnable de l'agent."""
|
| 82 |
-
job_description_str = json.dumps(self.job_offer, ensure_ascii=False)
|
| 83 |
-
|
| 84 |
-
system_prompt_content = self.system_prompt_template.format(
|
| 85 |
-
user_id=state['user_id'],
|
| 86 |
-
job_offer_id=state['job_offer_id'],
|
| 87 |
-
entreprise=self.job_offer.get('entreprise', 'notre entreprise'),
|
| 88 |
-
poste=self.job_offer.get('poste', 'ce poste'),
|
| 89 |
-
mission=self.job_offer.get('mission', 'Non spécifiée'),
|
| 90 |
-
profil_recherche=self.job_offer.get('profil_recherche', 'Non spécifié'),
|
| 91 |
-
competences=self.job_offer.get('competences', 'Non spécifiées'),
|
| 92 |
-
pole=self.job_offer.get('pole', 'Non spécifié'),
|
| 93 |
-
cv=self.formatted_cv_str,
|
| 94 |
-
skills_analysis=self.skills_summary,
|
| 95 |
-
reconversion_analysis=self.reconversion_info,
|
| 96 |
-
job_description=job_description_str
|
| 97 |
-
)
|
| 98 |
-
|
| 99 |
-
response = self.agent_runnable.invoke({
|
| 100 |
-
"system_prompt_content": system_prompt_content,
|
| 101 |
-
"messages": state["messages"]
|
| 102 |
-
})
|
| 103 |
-
|
| 104 |
-
return {"messages": [response]}
|
| 105 |
-
|
| 106 |
-
def _router(self, state: AgentState) -> str:
|
| 107 |
-
"""Route le flux du graphe en fonction de la dernière réponse de l'agent."""
|
| 108 |
-
last_message = state["messages"][-1]
|
| 109 |
-
if hasattr(last_message, 'tool_calls') and last_message.tool_calls:
|
| 110 |
-
if any(tool_call.get('name') == 'trigger_interview_analysis' for tool_call in last_message.tool_calls):
|
| 111 |
-
return "call_final_tool"
|
| 112 |
-
return "call_tool"
|
| 113 |
-
return "end_turn"
|
| 114 |
-
|
| 115 |
-
def _final_analysis_node(self, state: AgentState):
|
| 116 |
-
"""
|
| 117 |
-
Appelle l'outil d'analyse finale. Construit les arguments manuellement
|
| 118 |
-
à partir de l'état du graphe pour garantir la fiabilité.
|
| 119 |
-
"""
|
| 120 |
-
conversation_history = []
|
| 121 |
-
for msg in state["messages"]:
|
| 122 |
-
if isinstance(msg, HumanMessage):
|
| 123 |
-
role = "user"
|
| 124 |
-
elif isinstance(msg, AIMessage):
|
| 125 |
-
role = "assistant"
|
| 126 |
-
else:
|
| 127 |
-
continue
|
| 128 |
-
conversation_history.append({"role": role, "content": msg.content})
|
| 129 |
-
|
| 130 |
-
tool_input = {
|
| 131 |
-
"user_id": state['user_id'],
|
| 132 |
-
"job_offer_id": state['job_offer_id'],
|
| 133 |
-
"job_description": state['job_description'],
|
| 134 |
-
"conversation_history": conversation_history
|
| 135 |
-
}
|
| 136 |
-
|
| 137 |
-
trigger_interview_analysis.invoke(tool_input)
|
| 138 |
-
return {}
|
| 139 |
-
|
| 140 |
-
def _build_graph(self) -> any:
|
| 141 |
-
"""Construit et compile le graphe d'états."""
|
| 142 |
-
tool_node = ToolNode([trigger_interview_analysis])
|
| 143 |
-
|
| 144 |
-
graph = StateGraph(AgentState)
|
| 145 |
-
graph.add_node("agent", self._agent_node)
|
| 146 |
-
graph.add_node("tools", tool_node)
|
| 147 |
-
graph.add_node("final_tool_node", self._final_analysis_node)
|
| 148 |
-
|
| 149 |
-
graph.set_entry_point("agent")
|
| 150 |
-
|
| 151 |
-
graph.add_conditional_edges(
|
| 152 |
-
"agent",
|
| 153 |
-
self._router,
|
| 154 |
-
{
|
| 155 |
-
"call_tool": "tools",
|
| 156 |
-
"call_final_tool": "final_tool_node",
|
| 157 |
-
"end_turn": END
|
| 158 |
-
}
|
| 159 |
-
)
|
| 160 |
-
|
| 161 |
-
graph.add_edge("tools", "agent")
|
| 162 |
-
graph.add_edge("final_tool_node", END)
|
| 163 |
-
|
| 164 |
-
return graph.compile()
|
| 165 |
-
|
| 166 |
-
def invoke(self, messages: List[Dict[str, Any]]):
|
| 167 |
-
"""Point d'entrée pour lancer une conversation dans le graphe."""
|
| 168 |
-
langchain_messages = [HumanMessage(content=m["content"]) if m["role"] == "user" else AIMessage(content=m["content"]) for m in messages]
|
| 169 |
-
|
| 170 |
-
if not langchain_messages:
|
| 171 |
-
logging.info("Historique de conversation vide. Ajout d'un message de démarrage interne.")
|
| 172 |
-
langchain_messages.append(HumanMessage(content="Bonjour, je suis prêt à commencer l'entretien."))
|
| 173 |
-
|
| 174 |
-
initial_state = {
|
| 175 |
-
"user_id": self.user_id,
|
| 176 |
-
"job_offer_id": self.job_offer_id,
|
| 177 |
-
"messages": langchain_messages,
|
| 178 |
-
"job_description": json.dumps(self.job_offer, ensure_ascii=False),
|
| 179 |
-
}
|
| 180 |
-
|
| 181 |
-
final_state = self.graph.invoke(initial_state)
|
| 182 |
-
|
| 183 |
-
if not final_state or not final_state.get('messages'):
|
| 184 |
-
logging.error("L'état final est vide ou ne contient pas de messages.")
|
| 185 |
-
return {"response": "Erreur: Impossible de générer une réponse.", "status": "finished"}
|
| 186 |
-
last_message = final_state['messages'][-1]
|
| 187 |
-
status = "finished" if hasattr(last_message, 'tool_calls') and last_message.tool_calls else "interviewing"
|
| 188 |
-
response_content = last_message.content
|
| 189 |
-
|
| 190 |
-
return {
|
| 191 |
-
"response": response_content,
|
| 192 |
-
"status": status
|
| 193 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tools/analysis_tools.py
DELETED
|
@@ -1,56 +0,0 @@
|
|
| 1 |
-
import logging
|
| 2 |
-
from langchain_core.tools import tool
|
| 3 |
-
from src.services.analysis_service import AnalysisService
|
| 4 |
-
import json
|
| 5 |
-
import os
|
| 6 |
-
from datetime import datetime
|
| 7 |
-
from pydantic.v1 import BaseModel, Field
|
| 8 |
-
from typing import List, Dict, Any
|
| 9 |
-
from src.models import load_all_models
|
| 10 |
-
from pymongo import MongoClient
|
| 11 |
-
|
| 12 |
-
logging.basicConfig(level=logging.INFO)
|
| 13 |
-
logger = logging.getLogger(__name__)
|
| 14 |
-
|
| 15 |
-
class InterviewAnalysisArgs(BaseModel):
|
| 16 |
-
"""Arguments for the trigger_interview_analysis tool."""
|
| 17 |
-
user_id: str = Field(..., description="The unique identifier for the user, provided in the system prompt.")
|
| 18 |
-
job_offer_id: str = Field(..., description="The unique identifier for the job offer, provided in the system prompt.")
|
| 19 |
-
job_description: str = Field(..., description="The full JSON string of the job offer description.")
|
| 20 |
-
conversation_history: List[Dict[str, Any]] = Field(..., description="The complete conversation history between the user and the agent.")
|
| 21 |
-
|
| 22 |
-
@tool("trigger_interview_analysis", args_schema=InterviewAnalysisArgs)
|
| 23 |
-
def trigger_interview_analysis(user_id: str, job_offer_id: str, job_description: str, conversation_history: List[Dict[str, Any]]):
|
| 24 |
-
"""
|
| 25 |
-
Call this tool to end the interview and launch the final analysis.
|
| 26 |
-
You MUST provide all arguments: user_id, job_offer_id, job_description, and the complete conversation_history.
|
| 27 |
-
"""
|
| 28 |
-
try:
|
| 29 |
-
logger.info(f"Outil 'trigger_interview_analysis' appelé pour user_id: {user_id} et job_offer_id: {job_offer_id}")
|
| 30 |
-
if '@' in user_id or ' ' in job_offer_id:
|
| 31 |
-
logger.error(f"Appel de l'outil avec des données invalides. User ID: {user_id}, Job Offer ID: {job_offer_id}")
|
| 32 |
-
return "Erreur: Appel de l'outil avec des paramètres invalides. L'analyse n'a pas pu être lancée."
|
| 33 |
-
mongo_client = MongoClient(os.getenv("MONGO_URI"))
|
| 34 |
-
db = mongo_client[os.getenv("MONGO_DB_NAME")]
|
| 35 |
-
collection = db[os.getenv("MONGO_FEEDBACK")]
|
| 36 |
-
|
| 37 |
-
models = load_all_models()
|
| 38 |
-
analysis_service = AnalysisService(models=models)
|
| 39 |
-
feedback_data = analysis_service.run_analysis(
|
| 40 |
-
conversation_history=conversation_history,
|
| 41 |
-
job_description=job_description
|
| 42 |
-
)
|
| 43 |
-
mongo_document = {
|
| 44 |
-
"user_id": user_id,
|
| 45 |
-
"job_offer_id": job_offer_id,
|
| 46 |
-
"feedback_data": feedback_data,
|
| 47 |
-
"updated_at": datetime.utcnow()
|
| 48 |
-
}
|
| 49 |
-
result = collection.insert_one(mongo_document)
|
| 50 |
-
logger.info(f"Analyse pour l'utilisateur {user_id} terminée et sauvegardée dans MongoDB avec l'ID: {result.inserted_id}")
|
| 51 |
-
|
| 52 |
-
return "L'analyse a été déclenchée et terminée avec succès."
|
| 53 |
-
|
| 54 |
-
except Exception as e:
|
| 55 |
-
logger.error(f"Erreur dans l'outil d'analyse : {e}", exc_info=True)
|
| 56 |
-
return "Une erreur est survenue lors du lancement de l'analyse."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|