quentinL52 commited on
Commit ·
4e9b744
0
Parent(s):
Initial commit
Browse files- .env.example +18 -0
- .gitignore +34 -0
- main.py +73 -0
- requirements.txt +21 -0
- src/celery_app.py +29 -0
- src/config.py +16 -0
- src/prompts/agent_auditeur.txt +31 -0
- src/prompts/agent_challenger.txt +14 -0
- src/prompts/agent_enqueteur.txt +29 -0
- src/prompts/agent_icebreaker.txt +28 -0
- src/prompts/agent_projecteur.txt +20 -0
- src/prompts/agent_stratege.txt +26 -0
- src/prompts/orchestrator_prompt.txt +31 -0
- src/schemas/__init__.py +1 -0
- src/schemas/feedback_schemas.py +84 -0
- src/services/analysis_service.py +160 -0
- src/services/config/agents.yaml +53 -0
- src/services/config/tasks.yaml +97 -0
- src/services/feedback_crew.py +130 -0
- src/services/graph_service.py +504 -0
- src/services/integrity_service.py +67 -0
- src/services/nlp_service.py +132 -0
- src/services/search_service.py +117 -0
- src/services/semantic_service.py +38 -0
- src/services/simulation/__init__.py +0 -0
- src/services/simulation/agents.py +185 -0
- src/services/simulation/schemas.py +87 -0
- src/services/simulation/scoring.py +167 -0
- src/tasks.py +27 -0
- src/tools/analysis_tools.py +62 -0
- tests/test_search_service.py +56 -0
- tests/test_simulation_flow.py +108 -0
- tools/__init__.py +0 -0
- tools/analysis_tools.py +60 -0
.env.example
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ========================
|
| 2 |
+
# LLM API KEYS
|
| 3 |
+
# ========================
|
| 4 |
+
OPENAI_API_KEY=your_openai_api_key
|
| 5 |
+
LANGTRACE_API_KEY=your_langtrace_api_key
|
| 6 |
+
|
| 7 |
+
# ========================
|
| 8 |
+
# REDIS
|
| 9 |
+
# ========================
|
| 10 |
+
REDIS_URL=redis://default:password@redis-host:port/0
|
| 11 |
+
|
| 12 |
+
# ========================
|
| 13 |
+
# BACKEND API
|
| 14 |
+
# ========================
|
| 15 |
+
# Development
|
| 16 |
+
BACKEND_API_URL=http://localhost:8000
|
| 17 |
+
# Production (uncomment for deployment)
|
| 18 |
+
# BACKEND_API_URL=https://api.yoursite.com
|
.gitignore
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Environnement
|
| 2 |
+
.env
|
| 3 |
+
.env.*
|
| 4 |
+
!.env.example
|
| 5 |
+
venv/
|
| 6 |
+
env/
|
| 7 |
+
ENV/
|
| 8 |
+
.venv/
|
| 9 |
+
|
| 10 |
+
# Python
|
| 11 |
+
__pycache__/
|
| 12 |
+
*.py[cod]
|
| 13 |
+
*$py.class
|
| 14 |
+
*.so
|
| 15 |
+
.Python
|
| 16 |
+
|
| 17 |
+
# Tests
|
| 18 |
+
.pytest_cache/
|
| 19 |
+
.coverage
|
| 20 |
+
htmlcov/
|
| 21 |
+
|
| 22 |
+
# IDE
|
| 23 |
+
.vscode/
|
| 24 |
+
.idea/
|
| 25 |
+
*.swp
|
| 26 |
+
|
| 27 |
+
# Distribution
|
| 28 |
+
dist/
|
| 29 |
+
build/
|
| 30 |
+
*.egg-info/
|
| 31 |
+
|
| 32 |
+
# OS
|
| 33 |
+
.DS_Store
|
| 34 |
+
Thumbs.db
|
main.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import logging
|
| 3 |
+
from fastapi import FastAPI, Request, HTTPException
|
| 4 |
+
from fastapi.responses import JSONResponse
|
| 5 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 6 |
+
from pydantic import BaseModel
|
| 7 |
+
from dotenv import load_dotenv
|
| 8 |
+
|
| 9 |
+
load_dotenv()
|
| 10 |
+
|
| 11 |
+
from src.services.graph_service import GraphInterviewProcessor
|
| 12 |
+
|
| 13 |
+
logging.basicConfig(level=logging.INFO)
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
app = FastAPI(
|
| 17 |
+
title="Interview Simulation API",
|
| 18 |
+
description="API for interview simulations.",
|
| 19 |
+
version="1.0.0",
|
| 20 |
+
docs_url="/docs",
|
| 21 |
+
redoc_url="/redoc"
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
ALLOWED_ORIGINS = os.getenv("CORS_ORIGINS", "http://localhost:8000").split(",")
|
| 25 |
+
|
| 26 |
+
app.add_middleware(
|
| 27 |
+
CORSMiddleware,
|
| 28 |
+
allow_origins=ALLOWED_ORIGINS,
|
| 29 |
+
allow_credentials=True,
|
| 30 |
+
allow_methods=["*"],
|
| 31 |
+
allow_headers=["*"],
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
class HealthCheck(BaseModel):
|
| 35 |
+
status: str = "ok"
|
| 36 |
+
|
| 37 |
+
@app.get("/", response_model=HealthCheck, tags=["Status"])
|
| 38 |
+
async def health_check():
|
| 39 |
+
return HealthCheck()
|
| 40 |
+
|
| 41 |
+
@app.post("/simulate-interview/")
|
| 42 |
+
async def simulate_interview(request: Request):
|
| 43 |
+
"""
|
| 44 |
+
This endpoint receives the interview data, instantiates the graph processor
|
| 45 |
+
and starts the conversation.
|
| 46 |
+
"""
|
| 47 |
+
try:
|
| 48 |
+
payload = await request.json()
|
| 49 |
+
|
| 50 |
+
if not all(k in payload for k in ["user_id", "job_offer_id", "cv_document", "job_offer"]):
|
| 51 |
+
raise HTTPException(status_code=400, detail="Missing data in payload (user_id, job_offer_id, cv_document, job_offer).")
|
| 52 |
+
|
| 53 |
+
logger.info(f"Starting simulation for user: {payload['user_id']}")
|
| 54 |
+
|
| 55 |
+
processor = GraphInterviewProcessor(payload)
|
| 56 |
+
result = processor.invoke(payload.get("messages", []), cheat_metrics=payload.get("cheat_metrics"))
|
| 57 |
+
|
| 58 |
+
return JSONResponse(content=result)
|
| 59 |
+
|
| 60 |
+
except ValueError as ve:
|
| 61 |
+
logger.error(f"Data validation error: {ve}", exc_info=True)
|
| 62 |
+
return JSONResponse(content={"error": str(ve)}, status_code=400)
|
| 63 |
+
except Exception as e:
|
| 64 |
+
logger.error(f"Internal error in simulate-interview endpoint: {e}", exc_info=True)
|
| 65 |
+
return JSONResponse(
|
| 66 |
+
content={"error": "An internal error occurred on the assistant's server."},
|
| 67 |
+
status_code=500
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
if __name__ == "__main__":
|
| 71 |
+
import uvicorn
|
| 72 |
+
port = int(os.getenv("PORT", 8002))
|
| 73 |
+
uvicorn.run(app, host="0.0.0.0", port=port)
|
requirements.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
langchain
|
| 2 |
+
langchain-openai
|
| 3 |
+
langgraph
|
| 4 |
+
langchain-core
|
| 5 |
+
pydantic
|
| 6 |
+
fastapi
|
| 7 |
+
uvicorn
|
| 8 |
+
python-dotenv
|
| 9 |
+
crewai
|
| 10 |
+
langtrace-python-sdk
|
| 11 |
+
celery
|
| 12 |
+
redis
|
| 13 |
+
langchain-community
|
| 14 |
+
transformers
|
| 15 |
+
torch --extra-index-url https://download.pytorch.org/whl/cpu
|
| 16 |
+
scikit-learn
|
| 17 |
+
textstat
|
| 18 |
+
chromadb
|
| 19 |
+
sentence-transformers
|
| 20 |
+
numpy
|
| 21 |
+
textblob
|
src/celery_app.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from celery import Celery
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
load_dotenv()
|
| 6 |
+
|
| 7 |
+
redis_url = os.getenv("REDIS_URL")
|
| 8 |
+
|
| 9 |
+
celery_app = Celery(
|
| 10 |
+
"interview_simulation_api",
|
| 11 |
+
broker=redis_url,
|
| 12 |
+
backend=redis_url,
|
| 13 |
+
include=['src.tasks']
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
celery_app.conf.update(
|
| 17 |
+
task_serializer="json",
|
| 18 |
+
accept_content=["json"],
|
| 19 |
+
result_serializer="json",
|
| 20 |
+
timezone="Europe/Paris",
|
| 21 |
+
enable_utc=True,
|
| 22 |
+
task_track_started=True,
|
| 23 |
+
broker_connection_retry_on_startup=True,
|
| 24 |
+
broker_transport_options={
|
| 25 |
+
"visibility_timeout": 3600,
|
| 26 |
+
"socket_timeout": 30, # Increase socket timeout
|
| 27 |
+
"socket_connect_timeout": 30
|
| 28 |
+
}
|
| 29 |
+
)
|
src/config.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from dotenv import load_dotenv
|
| 3 |
+
|
| 4 |
+
load_dotenv()
|
| 5 |
+
|
| 6 |
+
from langchain_openai import ChatOpenAI
|
| 7 |
+
|
| 8 |
+
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
| 9 |
+
|
| 10 |
+
def crew_openai():
|
| 11 |
+
"""Returns a ChatOpenAI instance configured for CrewAI feedback analysis."""
|
| 12 |
+
return ChatOpenAI(
|
| 13 |
+
model="gpt-4o-mini",
|
| 14 |
+
temperature=0.1,
|
| 15 |
+
api_key=OPENAI_API_KEY
|
| 16 |
+
)
|
src/prompts/agent_auditeur.txt
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
IDENTITY:
|
| 2 |
+
Tu es l'IA de recrutement (Focus Technique).
|
| 3 |
+
Ton style est curieux, précis et fluide. Tu ne lis PAS de script.
|
| 4 |
+
|
| 5 |
+
PHASE : PROJETS & HARD SKILLS (SOAR)
|
| 6 |
+
|
| 7 |
+
TA MISSION :
|
| 8 |
+
Engager une discussion technique naturelle sur les projets du candidat, en t'adaptant à son profil (Icebreaker).
|
| 9 |
+
|
| 10 |
+
1. **D'abord, REBONDIS sur le profil** :
|
| 11 |
+
* Utilise le **CONTEXTE ICEBREAKER** pour personnaliser ton approche.
|
| 12 |
+
* Si le candidat est en reconversion, sois encourageant mais vérifie les bases.
|
| 13 |
+
* Si le candidat est expérimenté, va directement au but sur des détails complexes.
|
| 14 |
+
* Exemple : "Avec votre background en [contexte], comment avez-vous abordé la technique sur ce projet ?"
|
| 15 |
+
|
| 16 |
+
2. **Choisis un PROJET** : Analyse le JSON du CV. Repère un projet complexe ou pertinent pour le poste ({poste}).
|
| 17 |
+
|
| 18 |
+
3. **Formule TA PROPRE question (SOAR)** : Demande-lui de raconter ce projet sous l'angle "Situation/Obstacle" ou "Architecture".
|
| 19 |
+
* Ne pose pas une question générique.
|
| 20 |
+
* Utilise les technos citées dans le CV pour rendre la question crédible.
|
| 21 |
+
|
| 22 |
+
4. **Si le candidat a déjà répondu** : Creuse un point précis (l'Obstacle technique ou le Résultat mesurable) en fonction de ce qu'il vient de dire.
|
| 23 |
+
|
| 24 |
+
RÈGLES DE STYLE :
|
| 25 |
+
- PAS de phrases types. Improvise comme un recruteur senior.
|
| 26 |
+
- PAS de "Passons à la suite". Fais une transition conversationnelle.
|
| 27 |
+
- Si le candidat reste vague, demande des précisions techniques (versions, librairies, contraintes).
|
| 28 |
+
|
| 29 |
+
CONTEXTE :
|
| 30 |
+
{user_id}
|
| 31 |
+
{job_description}
|
src/prompts/agent_challenger.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
You are the **Challenger Agent**.
|
| 2 |
+
|
| 3 |
+
**Context**: The candidate gave a vague, generic, or insufficient answer.
|
| 4 |
+
**Goal**: Drill down to get the truth without being aggressive.
|
| 5 |
+
|
| 6 |
+
**Tone**: "Critical Friend". Firm but fair. "Constructively Skeptical".
|
| 7 |
+
|
| 8 |
+
**Instructions**:
|
| 9 |
+
1. Reference the specific part of their answer that was vague.
|
| 10 |
+
2. Ask for a concrete example or specific detail.
|
| 11 |
+
- *Example*: "You mentioned 'handling the problem', but could you walk me through the specific steps *you* personally took?"
|
| 12 |
+
- *Example*: "That sounds like a standard approach. Did you consider any alternatives, and why did you reject them?"
|
| 13 |
+
|
| 14 |
+
**Constraint**: Do not be rude. The goal is to urge them to use the STAR/SOAR method (Situation, Obstacle, Action, Result).
|
src/prompts/agent_enqueteur.txt
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
IDENTITY:
|
| 2 |
+
Tu es l'IA de recrutement (Focus Humain & Collaboration).
|
| 3 |
+
Ton style est empathique mais perspicace.
|
| 4 |
+
|
| 5 |
+
PHASE : SOFT SKILLS (STAR)
|
| 6 |
+
|
| 7 |
+
TA MISSION :
|
| 8 |
+
Explorer la dimension humaine du candidat en tenant compte de son niveau technique.
|
| 9 |
+
|
| 10 |
+
1. **TRANSITION NATURELLE** : Rebondis sur le **BILAN TECHNIQUE**.
|
| 11 |
+
* Si le candidat a des lacunes techniques identifiées, interroge sa capacité d'apprentissage ou son humilité.
|
| 12 |
+
* Si le candidat est très fort techniquement, interroge son leadership ou sa capacité à mentorer.
|
| 13 |
+
* Exemple : "Vous avez une belle maîtrise de [skill], mais comment gérez-vous les désaccords techniques en équipe ?"
|
| 14 |
+
|
| 15 |
+
2. **Cible une QUALITÉ** : Regarde les "Soft Skills" du CV ou du poste ({poste}).
|
| 16 |
+
|
| 17 |
+
3. **Génère une mise en situation (STAR)** : Demande au candidat de raconter un moment clé lié au travail d'équipe (conflit, feedback, mentoring, adaptation).
|
| 18 |
+
* Ne dis pas "Donnez-moi une méthode STAR".
|
| 19 |
+
* Dis plutôt : "Racontez-moi une fois où vous avez dû gérer..."
|
| 20 |
+
|
| 21 |
+
4. **Creuse** : Si l'histoire manque de "Résultat" ou d'"Action personnelle", demande-lui simplement comment cela s'est fini pour lui.
|
| 22 |
+
|
| 23 |
+
RÈGLES DE STYLE :
|
| 24 |
+
- Parle naturellement. Pas de robotisme.
|
| 25 |
+
- Sois à l'écoute : Si le candidat a mentionné un contexte difficile avant, utilise-le.
|
| 26 |
+
|
| 27 |
+
CONTEXTE :
|
| 28 |
+
{user_id}
|
| 29 |
+
{job_description}
|
src/prompts/agent_icebreaker.txt
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Tu es l'Agent Icebreaker (RONI).
|
| 2 |
+
|
| 3 |
+
Ta mission : Mettre en confiance le candidat {first_name} (ID: {user_id}) pour le poste de {poste} chez {entreprise}.
|
| 4 |
+
Tu as le droit de poser exactement {nb_questions} questions au total pour cette phase.
|
| 5 |
+
Tu as accès à son contexte :
|
| 6 |
+
{context_str}
|
| 7 |
+
|
| 8 |
+
**TON OBJECTIF :**
|
| 9 |
+
Accueillir le candidat de manière personnalisée et aller droit au but pour comprendre ses motivations profondes (Storytelling).
|
| 10 |
+
|
| 11 |
+
**STRUCTURE OBLIGATOIRE DU PREMIER MESSAGE :**
|
| 12 |
+
1. **Salutation** : "Bonjour {first_name} !" (ou juste "Bonjour !" si prénom vide).
|
| 13 |
+
2. **Présentation & Déroulé** : "Je suis RONI, l'IA qui va vous accompagner. L'entretien se déroulera en 3 parties : nous ferons d'abord connaissance, puis nous parlerons technique, et enfin soft skills."
|
| 14 |
+
3. **Question d'ouverture** : Enchaîne **immédiatement** (dans le même message) sur la phase de découverte/motivation.
|
| 15 |
+
|
| 16 |
+
**PHASE DE DÉCOUVERTE (Storytelling & Motivation)**
|
| 17 |
+
- Au lieu d'un "Présentez-vous" classique, invite-le au récit.
|
| 18 |
+
- Exemple : "Au-delà du CV que j'ai sous les yeux, racontez-moi votre histoire : qu'est-ce qui vous a amené à postuler aujourd'hui ?"
|
| 19 |
+
- **ADAPTATION AU CONTEXTE** :
|
| 20 |
+
- Si **RECONVERSION = OUI** : Demande ce qui a déclenché ce changement de cap.
|
| 21 |
+
- Si **ÉTUDIANT = OUI** : Demande ce qui l'attire dans ce poste pour démarrer sa carrière.
|
| 22 |
+
- Sinon : Demande ce qui motive sa passion pour ce domaine.
|
| 23 |
+
|
| 24 |
+
**RÈGLES D'OR :**
|
| 25 |
+
1. PAS de questions "Bateau" sur la météo ou la journée ("Comment allez-vous ?").
|
| 26 |
+
2. PAS de questions sur les hobbies sauf si le candidat en parle spontanément.
|
| 27 |
+
3. Sois chaleureux mais professionnel et direct.
|
| 28 |
+
4. Tu dois absolument passer la main à l'agent suivant après {nb_questions} échanges.
|
src/prompts/agent_projecteur.txt
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
IDENTITY:
|
| 2 |
+
Tu es l'IA de recrutement (Focus Motivation & Avenir).
|
| 3 |
+
Ton style est ouvert et prospectif.
|
| 4 |
+
|
| 5 |
+
PHASE : PROJECTION
|
| 6 |
+
|
| 7 |
+
TA MISSION :
|
| 8 |
+
Vérifier si le candidat se voit vraiment dans CE poste.
|
| 9 |
+
1. **REBONDIS** : Valide la réponse précédente sur le dilemme (SJT) par un accusé de réception neutre mais courtois.
|
| 10 |
+
2. **PROJECTION** : Invite le candidat à parler de son avenir DANS L'ENTREPRISE ({entreprise}).
|
| 11 |
+
* Choisis un angle : Les compétences qu'il veut acquérir, le type de management qu'il aime, ou ce qui l'attire dans ce secteur spécifique.
|
| 12 |
+
* Exemple : "Si vous nous rejoignez demain, quel serait votre premier chantier prioritaire ?"
|
| 13 |
+
|
| 14 |
+
RÈGLES DE STYLE :
|
| 15 |
+
- Fais sentir au candidat qu'on s'intéresse à SES envies.
|
| 16 |
+
- Évite les questions trop banales type "Où vous voyez-vous dans 5 ans ?". Préfère le concret court-terme (6-12 mois).
|
| 17 |
+
|
| 18 |
+
CONTEXTE :
|
| 19 |
+
{user_id}
|
| 20 |
+
{job_description}
|
src/prompts/agent_stratege.txt
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
IDENTITY:
|
| 2 |
+
Tu es l'IA de recrutement (Focus Jugement & Éthique).
|
| 3 |
+
Ton style est un peu plus "challenger" mais reste professionnel.
|
| 4 |
+
|
| 5 |
+
PHASE : JUGEMENT SITUATIONNEL (SJT)
|
| 6 |
+
|
| 7 |
+
TA MISSION :
|
| 8 |
+
Tester la prise de décision du candidat face à un dilemme réaliste pour le poste ({poste}), en ciblant ses points faibles potentiels.
|
| 9 |
+
|
| 10 |
+
1. **ANALYSE LE BILAN COMPORTEMENTAL** : Regarde les "POINTS À INTÉGRER".
|
| 11 |
+
* Construis ton scénario pour tester spécifiquement ces points (ex: gestion du stress, communication, rigueur).
|
| 12 |
+
|
| 13 |
+
2. **IMAGINE UN SCÉNARIO** : Basé sur le métier (Data Scientist, Analyst, etc.) ET les points à tester.
|
| 14 |
+
* Exemples (à adapter) : Biais de données, pression des délais vs qualité, confidentialité, hallucination d'IA.
|
| 15 |
+
|
| 16 |
+
3. **LANCE LE DÉFI** : "Imaginons une situation : [Ton Scénario]. Que faites-vous ?"
|
| 17 |
+
|
| 18 |
+
4. **LE TWIST (Si 2ème échange)** : Si le candidat a donné une première réponse "idéale", ajoute une contrainte forte pour voir s'il tient bon. (Ex: "Votre chef refuse cette solution pour des raisons de budget. Vous faites quoi ?").
|
| 19 |
+
|
| 20 |
+
RÈGLES DE STYLE :
|
| 21 |
+
- Ne te présente pas.
|
| 22 |
+
- Utilise une transition fluide : "Changeons de perspective un instant..." ou "J'aimerais vous projeter dans une situation type du poste."
|
| 23 |
+
|
| 24 |
+
CONTEXTE :
|
| 25 |
+
{user_id}
|
| 26 |
+
{job_description}
|
src/prompts/orchestrator_prompt.txt
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Tu es l'Orchestrateur d'une simulation d'entretien structuré pour un profil Junior/Reconversion Data & IA.
|
| 2 |
+
|
| 3 |
+
**OBJECTIF** : Guider l'entretien à travers 6 étapes clés en analysant l'historique de la conversation.
|
| 4 |
+
|
| 5 |
+
**ÉTAPES DU FLOW** :
|
| 6 |
+
1. "icebreaker" : Accueil (RONI), synthèse du parcours, déclic reconversion.
|
| 7 |
+
2. "auditeur" : Hard Skills & Projets (Méthode SOAR).
|
| 8 |
+
3. "enqueteur" : Soft Skills & Collaboration (Méthode STAR).
|
| 9 |
+
4. "stratege" : Jugement Situationnel (SJT) + Twist (Pression).
|
| 10 |
+
5. "projecteur" : Motivation & Culture Add (Projection).
|
| 11 |
+
6. "cloture" : Questions inversées (Candidat pose des questions).
|
| 12 |
+
|
| 13 |
+
**RÈGLES DE DÉCISION (TRIGGERS)** :
|
| 14 |
+
Analyse la dernière réponse du candidat :
|
| 15 |
+
- Si l'étape est "icebreaker" et réponse < 30 mots ou évasive -> Reste et challenge (Max 1 relance).
|
| 16 |
+
- Si l'étape est "auditeur" et manque de justification ("Pourquoi") ou usage de "Nous" au lieu de "Je" -> Reste et challenge (Max 2 relances).
|
| 17 |
+
- Si l'étape est "enqueteur" et manque de Résultat chiffré ou Action précise (STAR) -> Reste et challenge (Max 2 relances).
|
| 18 |
+
- Si l'étape est "stratege" -> Systématiquement une relance (Stress Test).
|
| 19 |
+
- Si l'étape est "cloture" et que le candidat n'a plus de questions ou dit "C'est bon", "Non", "Merci" -> Renvoie "end_interview" IMMÉDIATEMENT.
|
| 20 |
+
- Si le candidat dit "Au revoir", "Merci", "C'est tout pour moi" ou indique qu'il a fini -> Renvoie "end_interview" IMMÉDIATEMENT.
|
| 21 |
+
- Si l'étape actuelle est terminée (réponse satisfaisante) -> Passe à l'étape SUIVANTE.
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
**ÉTAT ACTUEL** :
|
| 25 |
+
- Étape en cours : {section}
|
| 26 |
+
- Nombre d'échanges dans cette étape : {turn_count}
|
| 27 |
+
|
| 28 |
+
**SORTIE ATTENDUE** :
|
| 29 |
+
Retourne UNIQUEMENT une chaîne de caractères parmi :
|
| 30 |
+
"icebreaker", "auditeur", "enqueteur", "stratege", "projecteur", "cloture", "end_interview".
|
| 31 |
+
(Si tu dois challenger, renvoie le nom de l'étape actuelle. L'agent gérera le challenge).
|
src/schemas/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Empty file to mark as Python package
|
src/schemas/feedback_schemas.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic schemas for interview feedback output."""
|
| 2 |
+
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
from pydantic import BaseModel, Field
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class CandidatFeedback(BaseModel):
|
| 8 |
+
"""Feedback section for the candidate."""
|
| 9 |
+
points_forts: List[str] = Field(..., description="Liste des points forts démontrés")
|
| 10 |
+
points_a_ameliorer: List[str] = Field(..., description="Liste des axes d'amélioration identifiés")
|
| 11 |
+
conseils_apprentissage: List[str] = Field(..., description="Ressources ou actions concrètes pour progresser")
|
| 12 |
+
score_global: int = Field(..., description="Note globale sur 100")
|
| 13 |
+
feedback_constructif: str = Field(..., description="Message bienveillant et constructif adressé au candidat")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class MetricsBreakdown(BaseModel):
|
| 17 |
+
"""Detailed breakdown of scores."""
|
| 18 |
+
communication: float = Field(..., description="Score 0-10: Orthographe, grammaire, clarté")
|
| 19 |
+
technique: float = Field(..., description="Score 0-10: Compétence technique (SOAR)")
|
| 20 |
+
comportemental: float = Field(..., description="Score 0-10: Soft skills (STAR)")
|
| 21 |
+
fidelite_cv: float = Field(..., description="Score 0-10: Cohérence avec le CV")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class FraudDetectionMetrics(BaseModel):
|
| 25 |
+
"""Detailed fraud detection metrics (0-100)."""
|
| 26 |
+
vocab_score: int = Field(..., description="Usage de mots 'GPT' (delve, tapestry...)")
|
| 27 |
+
structure_score: int = Field(..., description="Patterns structurels (listes, longueur...)")
|
| 28 |
+
paste_score: int = Field(..., description="Basé sur l'événement copier-coller")
|
| 29 |
+
pattern_score: int = Field(..., description="Répétitions, questions en retour, proximité CV")
|
| 30 |
+
|
| 31 |
+
class FraudDetection(BaseModel):
|
| 32 |
+
"""Complete fraud analysis."""
|
| 33 |
+
score_global: int = Field(..., description="Probabilité globale d'usage IA (0-100)")
|
| 34 |
+
red_flag: bool = Field(False, description="Vrai si score > 70")
|
| 35 |
+
detected_keywords: List[str] = Field(..., description="Mots suspects identifiés")
|
| 36 |
+
resume: str = Field(..., description="Eplication courte")
|
| 37 |
+
details: FraudDetectionMetrics
|
| 38 |
+
|
| 39 |
+
class DashboardCompetences(BaseModel):
|
| 40 |
+
"""Score de similarité basé sur la triade."""
|
| 41 |
+
technique: float = Field(..., description="Adéquation Hard Skills (0-100)")
|
| 42 |
+
cognitive: float = Field(..., description="Raisonnement et structure (0-100)")
|
| 43 |
+
comportementale: float = Field(..., description="Soft Skills & Culture Fit (0-100)")
|
| 44 |
+
average_score: float = Field(..., description="Moyenne pondérée")
|
| 45 |
+
|
| 46 |
+
class DecisionStrategique(BaseModel):
|
| 47 |
+
"""Pilier décisionnel du rapport."""
|
| 48 |
+
recommendation: str = Field(..., description="RECRUTER, APPROFONDIR ou REJETER")
|
| 49 |
+
action_plan: str = Field(..., description="Recommandation d'action immédiate (ex: Tester sur SQL)")
|
| 50 |
+
so_what: str = Field(..., description="Impact business direct du candidat (Le 'Pourquoi' stratégique)")
|
| 51 |
+
|
| 52 |
+
class RolsPcdAnalysis(BaseModel):
|
| 53 |
+
"""Analyse structurée ROLS et PCD."""
|
| 54 |
+
rols_summary: str = Field(..., description="Résumé ROLS (Résumé, Objectifs, Localisation, Stratégie)")
|
| 55 |
+
pcd_analysis: str = Field(..., description="Analyse PCD (Produits, Clients, Distribution)")
|
| 56 |
+
|
| 57 |
+
class EntrepriseInsights(BaseModel):
|
| 58 |
+
"""Insights section for the recruiter/company."""
|
| 59 |
+
correspondance_profil_offre: str = Field(..., description="Analyse de l'adéquation CV/Poste")
|
| 60 |
+
|
| 61 |
+
# Dashboard
|
| 62 |
+
dashboard: DashboardCompetences = Field(..., description="Tableau de bord des compétences")
|
| 63 |
+
|
| 64 |
+
# Qualitative Analysis
|
| 65 |
+
qualitative_analysis: RolsPcdAnalysis = Field(..., description="Analyse structurée ROLS/PCD")
|
| 66 |
+
|
| 67 |
+
# Strategic Decision
|
| 68 |
+
decision: DecisionStrategique = Field(..., description="Aide à la décision")
|
| 69 |
+
|
| 70 |
+
# Fraud & Cheat Detection
|
| 71 |
+
fraud_detection: FraudDetection = Field(..., description="Analyse complète anti-triche")
|
| 72 |
+
|
| 73 |
+
# Legacy Metrics (kept for backward compatibility if needed, or mapped from dashboard)
|
| 74 |
+
metrics: MetricsBreakdown = Field(..., description="Détail des notes par catégorie")
|
| 75 |
+
|
| 76 |
+
# Global verification
|
| 77 |
+
red_flag_detected: bool = Field(False, description="True si un comportement inacceptable est détecté")
|
| 78 |
+
red_flag_reason: Optional[str] = Field(None, description="Raison du Veto si applicable")
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class FeedbackOutput(BaseModel):
|
| 82 |
+
"""Complete feedback output with candidate and company sections."""
|
| 83 |
+
candidat: CandidatFeedback
|
| 84 |
+
entreprise: EntrepriseInsights
|
src/services/analysis_service.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import json
|
| 3 |
+
from typing import Dict, List, Any, Optional
|
| 4 |
+
from src.services.feedback_crew import FeedbackCrew
|
| 5 |
+
from src.services.integrity_service import IntegrityService
|
| 6 |
+
from src.services.search_service import SearchService
|
| 7 |
+
from src.config import crew_openai
|
| 8 |
+
from langchain_core.messages import HumanMessage, SystemMessage
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _flatten_dict_values(d) -> list:
|
| 14 |
+
"""Recursively flatten all values from a nested dict/list structure into a list of strings."""
|
| 15 |
+
text = []
|
| 16 |
+
if not isinstance(d, dict):
|
| 17 |
+
return [str(d)]
|
| 18 |
+
for k, v in d.items():
|
| 19 |
+
if isinstance(v, dict):
|
| 20 |
+
text.extend(_flatten_dict_values(v))
|
| 21 |
+
elif isinstance(v, list):
|
| 22 |
+
for i in v:
|
| 23 |
+
if isinstance(i, dict):
|
| 24 |
+
text.extend(_flatten_dict_values(i))
|
| 25 |
+
else:
|
| 26 |
+
text.append(str(i))
|
| 27 |
+
else:
|
| 28 |
+
text.append(str(v))
|
| 29 |
+
return text
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class AnalysisService:
|
| 33 |
+
def __init__(self):
|
| 34 |
+
self.integrity_service = IntegrityService()
|
| 35 |
+
self.search_service = SearchService()
|
| 36 |
+
self.llm = crew_openai()
|
| 37 |
+
|
| 38 |
+
def run_analysis(self, conversation_history: List[Dict[str, Any]], job_description: str, cv_content: str, cheat_metrics: Dict[str, Any] = None, simulation_report: Dict[str, Any] = None) -> Dict[str, Any]:
|
| 39 |
+
"""
|
| 40 |
+
Runs the feedback analysis using CrewAI with enhanced pre-processing (Multi-Agent RAG).
|
| 41 |
+
If simulation_report is provided, it is used as the result, bypassing CrewAI.
|
| 42 |
+
"""
|
| 43 |
+
logger.info("Starting Interview Feedback Analysis...")
|
| 44 |
+
|
| 45 |
+
if simulation_report:
|
| 46 |
+
logger.info("Using pre-computed Simulation Report. Bypassing CrewAI.")
|
| 47 |
+
try:
|
| 48 |
+
# Run Integrity Analysis (Cheating detection)
|
| 49 |
+
transcript_text = " ".join([m.get('content', '') for m in conversation_history if m.get('role') == 'user'])
|
| 50 |
+
# Extract CV text (flatten values for stylometry)
|
| 51 |
+
cv_text = ". ".join(_flatten_dict_values(cv_content)) if isinstance(cv_content, dict) else str(cv_content)
|
| 52 |
+
|
| 53 |
+
integrity_report = self.integrity_service.analyze_integrity(
|
| 54 |
+
cv_text=cv_text,
|
| 55 |
+
interview_text=transcript_text,
|
| 56 |
+
existing_metrics=cheat_metrics
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
result = simulation_report.copy()
|
| 60 |
+
result["integrity_report"] = integrity_report
|
| 61 |
+
result["cheat_metrics"] = cheat_metrics
|
| 62 |
+
|
| 63 |
+
return result
|
| 64 |
+
|
| 65 |
+
except Exception as e:
|
| 66 |
+
logger.error(f"Error enriching simulation report: {e}")
|
| 67 |
+
return simulation_report
|
| 68 |
+
|
| 69 |
+
logger.info("No Simulation Report provided. Fallback to CrewAI.")
|
| 70 |
+
|
| 71 |
+
try:
|
| 72 |
+
# Parse inputs
|
| 73 |
+
if isinstance(job_description, str):
|
| 74 |
+
try:
|
| 75 |
+
job_offer_data = json.loads(job_description)
|
| 76 |
+
except json.JSONDecodeError:
|
| 77 |
+
job_offer_data = {"description": job_description}
|
| 78 |
+
else:
|
| 79 |
+
job_offer_data = job_description
|
| 80 |
+
|
| 81 |
+
# Prepare text for analysis
|
| 82 |
+
transcript_text = " ".join([m.get('content', '') for m in conversation_history if m.get('role') == 'user'])
|
| 83 |
+
|
| 84 |
+
# Extract CV text (flatten values for stylometry)
|
| 85 |
+
cv_text = ". ".join(_flatten_dict_values(cv_content)) if isinstance(cv_content, dict) else str(cv_content)
|
| 86 |
+
|
| 87 |
+
# Run RAG Gap Analysis (Search Agent Grounding)
|
| 88 |
+
job_mission = job_offer_data.get('mission', '') or job_offer_data.get('description', '')
|
| 89 |
+
gap_analysis = self.search_service.analyze_gap(cv_text, job_mission)
|
| 90 |
+
|
| 91 |
+
# Run Integrity Analysis
|
| 92 |
+
integrity_report = self.integrity_service.analyze_integrity(
|
| 93 |
+
cv_text=cv_text,
|
| 94 |
+
interview_text=transcript_text,
|
| 95 |
+
existing_metrics=cheat_metrics
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
# Detect Job Seniority (Robust LLM Method)
|
| 99 |
+
seniority = self._analyze_job_context_with_llm(job_offer_data)
|
| 100 |
+
|
| 101 |
+
# Merge Metrics
|
| 102 |
+
enhanced_metrics = cheat_metrics or {}
|
| 103 |
+
enhanced_metrics.update({
|
| 104 |
+
"integrity_report": integrity_report,
|
| 105 |
+
"semantic_score": gap_analysis.get('semantic_score', 0.0),
|
| 106 |
+
"required_seniority": seniority
|
| 107 |
+
})
|
| 108 |
+
|
| 109 |
+
logger.info(f"Enhanced Metrics: {enhanced_metrics}")
|
| 110 |
+
logger.info(f"Gap Analysis: {gap_analysis}")
|
| 111 |
+
|
| 112 |
+
crew = FeedbackCrew(
|
| 113 |
+
job_offer=job_offer_data,
|
| 114 |
+
cv_content=cv_content,
|
| 115 |
+
conversation_history=conversation_history,
|
| 116 |
+
cheat_metrics=enhanced_metrics,
|
| 117 |
+
gap_analysis=gap_analysis
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
result = crew.run()
|
| 121 |
+
logger.info("Feedback Analysis completed successfully.")
|
| 122 |
+
return result
|
| 123 |
+
|
| 124 |
+
except Exception as e:
|
| 125 |
+
logger.error(f"Error during feedback analysis: {e}", exc_info=True)
|
| 126 |
+
return {"error": str(e)}
|
| 127 |
+
|
| 128 |
+
def _analyze_job_context_with_llm(self, job_data: Dict[str, Any]) -> str:
|
| 129 |
+
"""
|
| 130 |
+
Uses LLM to detect seniority context, avoiding false positives (e.g. 'Reporting to Head of Data').
|
| 131 |
+
"""
|
| 132 |
+
try:
|
| 133 |
+
description = str(job_data)
|
| 134 |
+
prompt = f"""
|
| 135 |
+
ANALYSE LE CONTEXTE DE CETTE OFFRE D'EMPLOI :
|
| 136 |
+
{description}
|
| 137 |
+
|
| 138 |
+
TÂCHE : Détermine le niveau de séniorité requis pour le CANDIDAT.
|
| 139 |
+
|
| 140 |
+
RÈGLES CRITIQUES :
|
| 141 |
+
1. "STAGE", "ALTERNANCE", "APPRENTISSAGE" = TOUJOURS "JUNIOR".
|
| 142 |
+
2. Ignore les mentions de la hiérarchie (ex: "Sous la direction du Senior Manager" -> Le poste n'est PAS Senior).
|
| 143 |
+
3. "Débutant", "Junior", "0-2 ans", "Sortie d'école" = "JUNIOR".
|
| 144 |
+
4. "Lead", "Expert", "Manager", "+5 ans", "Architecte" = "SENIOR".
|
| 145 |
+
5. Sinon -> "MID".
|
| 146 |
+
|
| 147 |
+
Réponds UNIQUEMENT par un seul mot : JUNIOR, SENIOR ou MID.
|
| 148 |
+
"""
|
| 149 |
+
|
| 150 |
+
response = self.llm.invoke([HumanMessage(content=prompt)])
|
| 151 |
+
result = response.content.strip().upper()
|
| 152 |
+
|
| 153 |
+
if result not in ["JUNIOR", "SENIOR", "MID"]:
|
| 154 |
+
return "MID" # Fallback
|
| 155 |
+
|
| 156 |
+
return result
|
| 157 |
+
|
| 158 |
+
except Exception as e:
|
| 159 |
+
logger.error(f"Error in LLM seniority detection: {e}")
|
| 160 |
+
return "MID" # Safe fallback
|
src/services/config/agents.yaml
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
consistency_analyst:
|
| 2 |
+
role: "Analyste de Cohérence"
|
| 3 |
+
goal: "Vérifier la véracité et la cohérence des propos du candidat par rapport à son CV."
|
| 4 |
+
backstory: >
|
| 5 |
+
Expert en détection de fraude et de triche. Vous ne tolérez aucune déviation éthique.
|
| 6 |
+
Votre focus: Trolling, Usage IA générative, Copier-Coller.
|
| 7 |
+
Vous travaillez avec des données précises de "Gap Analysis".
|
| 8 |
+
verbose: true
|
| 9 |
+
allow_delegation: false
|
| 10 |
+
|
| 11 |
+
search_analyst:
|
| 12 |
+
role: "Analyste Grounding & RAG"
|
| 13 |
+
goal: "Identifier les écarts factuels (Hidden Skill Gaps) entre le CV et le poste."
|
| 14 |
+
backstory: >
|
| 15 |
+
Spécialiste de l'analyse sémantique et de la comparaison de données.
|
| 16 |
+
Vous détectez les profils en reconversion et valorisez leur potentiel d'apprentissage.
|
| 17 |
+
Vous vérifiez si le candidat possède les "Action Verbs" de production (Déployer, Monitorer).
|
| 18 |
+
verbose: true
|
| 19 |
+
allow_delegation: false
|
| 20 |
+
|
| 21 |
+
tech_expert:
|
| 22 |
+
role: "Expert Technique (Adapté au contexte)"
|
| 23 |
+
goal: "Valider les compétences techniques spécifiques au rôle ({job_type})."
|
| 24 |
+
backstory: >
|
| 25 |
+
CTO pragmatique. Vous adaptez vos exigences au type de poste :
|
| 26 |
+
- Pour DATA ANALYST : Vous cherchez SQL, PowerBI, la clarté des insights et l'automatisation simple.
|
| 27 |
+
- Pour DATA SCIENTIST : Vous cherchez la méthode scientifique (Métriques, Biais, Context Engineering).
|
| 28 |
+
- Pour DATA ENGINEER : Vous cherchez CI/CD, Docker, Cloud, Latence, Production.
|
| 29 |
+
|
| 30 |
+
IMPORTANT : Vous écoutez ce que dit le candidat. S'il mentionne des termes techniques précis ("SLM", "Vector DB", "Context Engineering"), vous VALIDEZ la compétence, même si elle n'est pas dans le CV.
|
| 31 |
+
verbose: true
|
| 32 |
+
allow_delegation: false
|
| 33 |
+
|
| 34 |
+
business_evaluator:
|
| 35 |
+
role: "Stratège Business (ROLS & PCD)"
|
| 36 |
+
goal: "Évaluer la capacité du candidat à résoudre des problèmes business complexes."
|
| 37 |
+
backstory: >
|
| 38 |
+
Consultant senior en stratégie. Vous évaluez la vision business du candidat.
|
| 39 |
+
Vous appliquez strictement les cadres ROLS (Résumé, Objectifs, Localisation, Stratégie)
|
| 40 |
+
et PCD (Produits, Clients, Distribution) pour structurer votre analyse.
|
| 41 |
+
Vous cherchez le "So What?" dans chaque réponse.
|
| 42 |
+
verbose: true
|
| 43 |
+
allow_delegation: false
|
| 44 |
+
|
| 45 |
+
final_reporter:
|
| 46 |
+
role: "Responsable Recrutement"
|
| 47 |
+
goal: "Synthétiser toutes les analyses pour une prise de décision stratégique."
|
| 48 |
+
backstory: >
|
| 49 |
+
Décideur final. Vous consolidez les rapports des experts (Tech, Business, Search).
|
| 50 |
+
Vous devez trancher : RECRUTER, APPROFONDIR ou REJETER.
|
| 51 |
+
Vous prenez en compte le statut "Reconversion" pour ajuster votre tolérance sur l'expérience.
|
| 52 |
+
verbose: true
|
| 53 |
+
allow_delegation: false
|
src/services/config/tasks.yaml
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
consistency_task:
|
| 2 |
+
description: >
|
| 3 |
+
Analyse la conversation pour détecter la fraude.
|
| 4 |
+
Données d'intégrité: {cheat_metrics}
|
| 5 |
+
|
| 6 |
+
Règles:
|
| 7 |
+
- Si 'ai_score' > 75 -> STOP/VETO.
|
| 8 |
+
- Si insultes/trolling -> STOP/VETO.
|
| 9 |
+
expected_output: "Statut Fraude (RAS, ALERTE, VETO) avec justification."
|
| 10 |
+
agent: consistency_analyst
|
| 11 |
+
|
| 12 |
+
search_task:
|
| 13 |
+
description: >
|
| 14 |
+
Analyse l'écart CV vs Offre (Gap Analysis).
|
| 15 |
+
Données Gap Analysis: {gap_analysis}
|
| 16 |
+
|
| 17 |
+
1. Confirme si c'est une RÉCONVERSION (basé sur 'is_reconversion').
|
| 18 |
+
2. Identifie les compétences manquantes (Hidden Skill Gaps).
|
| 19 |
+
3. Vérifie la présence des 'Action Verbs' de production.
|
| 20 |
+
expected_output: "Rapport de Gap Analysis : Confirm Reconversion (O/N), Liste des Gaps, Score Modernité."
|
| 21 |
+
agent: search_analyst
|
| 22 |
+
|
| 23 |
+
tech_task:
|
| 24 |
+
description: >
|
| 25 |
+
Évaluation Technique Approfondie (Methodologie SOAR) adaptée au poste : {job_type}.
|
| 26 |
+
|
| 27 |
+
CRITIQUE : Analyse les RÉPONSES du candidat dans conversation_history.
|
| 28 |
+
Ce que le candidat DIT prévaut sur ce qui est écrit dans le CV.
|
| 29 |
+
Si le candidat démontre une expertise (ex: parle de "Context Engineering", "SLM", "Vectors") -> VALORISE LE, même si absent du CV.
|
| 30 |
+
|
| 31 |
+
1. Si DATA ANALYST :
|
| 32 |
+
- Cherche : SQL complexe, Nettoyage de données, Visualisation, Storytelling.
|
| 33 |
+
- Ignore : CI/CD, Docker, Kubernetes.
|
| 34 |
+
2. Si DATA SCIENTIST :
|
| 35 |
+
- Cherche : Choix des modèles, Métriques d'évaluation, Feature Engineering.
|
| 36 |
+
3. Si DATA ENGINEER :
|
| 37 |
+
- Cherche : CI/CD, Code Quality, Scalabilité, Monitoring.
|
| 38 |
+
|
| 39 |
+
Utilise SOAR (Situation, Obstacle, Action, Résultat).
|
| 40 |
+
Note sur 10. NE Mets JAMAIS 0 si le candidat a les bases mais manque d'expérience pro.
|
| 41 |
+
expected_output: "Évaluation Tech détaillée + Note /100."
|
| 42 |
+
agent: tech_expert
|
| 43 |
+
|
| 44 |
+
business_task:
|
| 45 |
+
description: >
|
| 46 |
+
Évaluation Stratégique & Business (ROLS & PCD).
|
| 47 |
+
|
| 48 |
+
1. Applique ROLS si une étude de cas est présente :
|
| 49 |
+
- Résumé situation
|
| 50 |
+
- Objectifs posés
|
| 51 |
+
- Localisation du problème
|
| 52 |
+
- Stratégie proposée
|
| 53 |
+
|
| 54 |
+
2. Applique PCD pour l'analyse produit :
|
| 55 |
+
- Produit (Compréhension)
|
| 56 |
+
- Clients (Ciblage)
|
| 57 |
+
- Distribution (Go-to-market)
|
| 58 |
+
|
| 59 |
+
3. Cherche le "So What?" : Le candidat lie-t-il la tech au business ?
|
| 60 |
+
expected_output: "Analyse ROLS/PCD structurée + Note Business /100."
|
| 61 |
+
agent: business_evaluator
|
| 62 |
+
|
| 63 |
+
reporting_task:
|
| 64 |
+
description: >
|
| 65 |
+
Synthèse Décisionnelle Finale avec Scoring Dynamique.
|
| 66 |
+
|
| 67 |
+
CONTEXTE :
|
| 68 |
+
- Poste : {job_type}
|
| 69 |
+
- Reconversion : {gap_analysis}
|
| 70 |
+
- Fraude : consistency_task.output
|
| 71 |
+
|
| 72 |
+
RÈGLES DE SCORING STRICTES :
|
| 73 |
+
1. Si 'consistency_task' indique une FRAUDE ou un RED FLAG (trolling, insultes, incohérence majeure) :
|
| 74 |
+
-> SCORE FINAL DOIT ÊTRE < 40. REJET IMMÉDIAT.
|
| 75 |
+
2. Si Pas de Red Flag :
|
| 76 |
+
-> Utilisez toute l'échelle (10-90).
|
| 77 |
+
-> Un débutant motivé mérite ~50-60.
|
| 78 |
+
-> Un expert mérite > 80.
|
| 79 |
+
|
| 80 |
+
SCORING PONDÉRÉ :
|
| 81 |
+
- Tech: 40% (Expertise & Prod) -> SI ANALYST, Tech = SQL/Viz, pas Infra.
|
| 82 |
+
- Cognitive (Business/ROLS): 30%
|
| 83 |
+
- Comportementale (Soft): 30%
|
| 84 |
+
|
| 85 |
+
DECISION STRATEGIQUE :
|
| 86 |
+
- RECRUTER : Si Score > 75 et aucun Red Flag.
|
| 87 |
+
- APPROFONDIR : Si Score 50-75 ou doute sur un Gap.
|
| 88 |
+
- REJETER : Si Score < 50 ou Veto Fraude.
|
| 89 |
+
|
| 90 |
+
Génère le JSON final 'FeedbackOutput'.
|
| 91 |
+
expected_output: "JSON complet respectant le schéma FeedbackOutput."
|
| 92 |
+
agent: final_reporter
|
| 93 |
+
context:
|
| 94 |
+
- consistency_task
|
| 95 |
+
- search_task
|
| 96 |
+
- tech_task
|
| 97 |
+
- business_task
|
src/services/feedback_crew.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Feedback Crew for interview analysis using CrewAI."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import logging
|
| 5 |
+
from typing import Dict, Any, List
|
| 6 |
+
|
| 7 |
+
from crewai import Agent, Crew, Process, Task
|
| 8 |
+
from crewai.project import CrewBase, agent, task, crew
|
| 9 |
+
|
| 10 |
+
from src.config import crew_openai
|
| 11 |
+
from src.schemas.feedback_schemas import FeedbackOutput
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@CrewBase
|
| 17 |
+
class FeedbackCrew:
|
| 18 |
+
"""Crew for analyzing interview conversations and generating structured feedback."""
|
| 19 |
+
|
| 20 |
+
agents_config = "config/agents.yaml"
|
| 21 |
+
tasks_config = "config/tasks.yaml"
|
| 22 |
+
|
| 23 |
+
def __init__(self, job_offer: Dict[str, Any], cv_content: str, conversation_history: List[Dict[str, Any]], cheat_metrics: Dict[str, Any] = None, gap_analysis: Dict[str, Any] = None):
|
| 24 |
+
self.job_offer = json.dumps(job_offer, ensure_ascii=False)
|
| 25 |
+
self.cv_content = cv_content
|
| 26 |
+
self.conversation_history = json.dumps(conversation_history, ensure_ascii=False)
|
| 27 |
+
self.cheat_metrics = json.dumps(cheat_metrics or {}, ensure_ascii=False)
|
| 28 |
+
self.gap_analysis = json.dumps(gap_analysis or {}, ensure_ascii=False)
|
| 29 |
+
self._llm = crew_openai()
|
| 30 |
+
|
| 31 |
+
# --- Agents ---
|
| 32 |
+
|
| 33 |
+
@agent
|
| 34 |
+
def consistency_analyst(self) -> Agent:
|
| 35 |
+
return Agent(config=self.agents_config["consistency_analyst"], llm=self._llm)
|
| 36 |
+
|
| 37 |
+
@agent
|
| 38 |
+
def search_analyst(self) -> Agent:
|
| 39 |
+
return Agent(config=self.agents_config["search_analyst"], llm=self._llm)
|
| 40 |
+
|
| 41 |
+
@agent
|
| 42 |
+
def tech_expert(self) -> Agent:
|
| 43 |
+
return Agent(config=self.agents_config["tech_expert"], llm=self._llm)
|
| 44 |
+
|
| 45 |
+
@agent
|
| 46 |
+
def business_evaluator(self) -> Agent:
|
| 47 |
+
return Agent(config=self.agents_config["business_evaluator"], llm=self._llm)
|
| 48 |
+
|
| 49 |
+
@agent
|
| 50 |
+
def final_reporter(self) -> Agent:
|
| 51 |
+
return Agent(config=self.agents_config["final_reporter"], llm=self._llm)
|
| 52 |
+
|
| 53 |
+
# --- Tasks ---
|
| 54 |
+
|
| 55 |
+
@task
|
| 56 |
+
def consistency_task(self) -> Task:
|
| 57 |
+
return Task(config=self.tasks_config["consistency_task"])
|
| 58 |
+
|
| 59 |
+
@task
|
| 60 |
+
def search_task(self) -> Task:
|
| 61 |
+
return Task(config=self.tasks_config["search_task"])
|
| 62 |
+
|
| 63 |
+
@task
|
| 64 |
+
def tech_task(self) -> Task:
|
| 65 |
+
return Task(config=self.tasks_config["tech_task"])
|
| 66 |
+
|
| 67 |
+
@task
|
| 68 |
+
def business_task(self) -> Task:
|
| 69 |
+
return Task(config=self.tasks_config["business_task"])
|
| 70 |
+
|
| 71 |
+
@task
|
| 72 |
+
def reporting_task(self) -> Task:
|
| 73 |
+
return Task(
|
| 74 |
+
config=self.tasks_config["reporting_task"],
|
| 75 |
+
output_pydantic=FeedbackOutput
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
# --- Crew ---
|
| 79 |
+
|
| 80 |
+
@crew
|
| 81 |
+
def crew(self) -> Crew:
|
| 82 |
+
return Crew(
|
| 83 |
+
agents=self.agents,
|
| 84 |
+
tasks=self.tasks,
|
| 85 |
+
process=Process.sequential,
|
| 86 |
+
verbose=True
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
# --- Public API ---
|
| 90 |
+
|
| 91 |
+
def run(self) -> Dict[str, Any]:
|
| 92 |
+
"""Execute the feedback crew and return structured output."""
|
| 93 |
+
logger.info("Starting Feedback Crew analysis...")
|
| 94 |
+
|
| 95 |
+
# Safe extraction of job_type from gap_analysis string or dict
|
| 96 |
+
job_type = "GENERAL_TECH"
|
| 97 |
+
try:
|
| 98 |
+
ga_data = json.loads(self.gap_analysis) if isinstance(self.gap_analysis, str) else self.gap_analysis
|
| 99 |
+
job_type = ga_data.get("job_type", "GENERAL_TECH")
|
| 100 |
+
except:
|
| 101 |
+
pass
|
| 102 |
+
|
| 103 |
+
inputs = {
|
| 104 |
+
"job_offer": self.job_offer,
|
| 105 |
+
"cv_content": self.cv_content,
|
| 106 |
+
"conversation_history": self.conversation_history,
|
| 107 |
+
"cheat_metrics": self.cheat_metrics,
|
| 108 |
+
"gap_analysis": self.gap_analysis,
|
| 109 |
+
"job_type": job_type
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
result = self.crew().kickoff(inputs=inputs)
|
| 113 |
+
|
| 114 |
+
# Handle structured output
|
| 115 |
+
if result.pydantic:
|
| 116 |
+
return result.pydantic.model_dump()
|
| 117 |
+
elif result.json_dict:
|
| 118 |
+
return result.json_dict
|
| 119 |
+
else:
|
| 120 |
+
logger.warning("No structured output, parsing raw result")
|
| 121 |
+
try:
|
| 122 |
+
raw_str = str(result.raw)
|
| 123 |
+
start = raw_str.find('{')
|
| 124 |
+
end = raw_str.rfind('}') + 1
|
| 125 |
+
if start != -1 and end > start:
|
| 126 |
+
return json.loads(raw_str[start:end])
|
| 127 |
+
except json.JSONDecodeError as e:
|
| 128 |
+
logger.error(f"JSON parsing failed: {e}")
|
| 129 |
+
|
| 130 |
+
return {"error": "Could not parse output", "raw": str(result.raw)}
|
src/services/graph_service.py
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import logging
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import TypedDict, Annotated, Dict, Any, List, Optional
|
| 6 |
+
|
| 7 |
+
from langchain_openai import ChatOpenAI
|
| 8 |
+
from langchain_core.messages import BaseMessage, AIMessage, HumanMessage, SystemMessage
|
| 9 |
+
from langgraph.graph import StateGraph, END
|
| 10 |
+
from langgraph.graph.message import add_messages
|
| 11 |
+
from langtrace_python_sdk import langtrace
|
| 12 |
+
|
| 13 |
+
from src.services.simulation.agents import InterviewAgentExtractor
|
| 14 |
+
from src.services.simulation.schemas import (
|
| 15 |
+
IceBreakerOutput, TechnicalOutput, BehavioralOutput, SituationOutput, SimulationReport
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
langtrace.init(api_key=os.getenv("LANGTRACE_API_KEY"))
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
PROMPTS_DIR = Path(__file__).parent.parent / "prompts"
|
| 22 |
+
|
| 23 |
+
# Number of questions per agent
|
| 24 |
+
QUESTIONS_PER_AGENT = {
|
| 25 |
+
"icebreaker": 2,
|
| 26 |
+
"auditeur": 3,
|
| 27 |
+
"enqueteur": 2,
|
| 28 |
+
"stratege": 2,
|
| 29 |
+
"projecteur": 1
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
AGENT_ORDER = ["icebreaker", "auditeur", "enqueteur", "stratege", "projecteur"]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
EXIT_MESSAGE = """L'entretien est maintenant terminé.
|
| 36 |
+
|
| 37 |
+
Merci pour cet échange. Je finalise l'analyse de votre candidature, votre rapport sera disponible dans quelques instants."""
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class AgentState(TypedDict):
|
| 41 |
+
messages: Annotated[list[BaseMessage], add_messages]
|
| 42 |
+
user_id: str
|
| 43 |
+
job_offer_id: str
|
| 44 |
+
cv_data: Dict[str, Any]
|
| 45 |
+
job_data: Dict[str, Any]
|
| 46 |
+
section: str
|
| 47 |
+
turn_count: int
|
| 48 |
+
context: Dict[str, Any]
|
| 49 |
+
cheat_metrics: Dict[str, Any]
|
| 50 |
+
|
| 51 |
+
# Structured Data
|
| 52 |
+
icebreaker_data: Optional[IceBreakerOutput]
|
| 53 |
+
technical_data: Optional[TechnicalOutput]
|
| 54 |
+
behavioral_data: Optional[BehavioralOutput]
|
| 55 |
+
situation_data: Optional[SituationOutput]
|
| 56 |
+
simulation_report: Optional[SimulationReport]
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class GraphInterviewProcessor:
|
| 60 |
+
"""Strict interview simulation with per-agent question limits."""
|
| 61 |
+
|
| 62 |
+
def __init__(self, payload: Dict[str, Any]):
|
| 63 |
+
logging.info("Initialisation de RONI GraphInterviewProcessor...")
|
| 64 |
+
|
| 65 |
+
self.user_id = payload["user_id"]
|
| 66 |
+
self.job_offer_id = payload["job_offer_id"]
|
| 67 |
+
self.job_offer = payload["job_offer"]
|
| 68 |
+
self.cv_data = payload.get("cv_document", {}).get('candidat', {})
|
| 69 |
+
|
| 70 |
+
if not self.cv_data:
|
| 71 |
+
raise ValueError("Données du candidat non trouvées.")
|
| 72 |
+
|
| 73 |
+
self.prompts = self._load_all_prompts()
|
| 74 |
+
self.llm = ChatOpenAI(api_key=os.getenv("OPENAI_API_KEY"), model="gpt-4o-mini", temperature=0.7)
|
| 75 |
+
self.extractor = InterviewAgentExtractor(self.llm)
|
| 76 |
+
|
| 77 |
+
self.graph = self._build_graph()
|
| 78 |
+
logging.info("RONI Graph initialisé.")
|
| 79 |
+
|
| 80 |
+
def _load_all_prompts(self) -> Dict[str, str]:
|
| 81 |
+
prompts = {}
|
| 82 |
+
prompt_files = {
|
| 83 |
+
"icebreaker": PROMPTS_DIR / "agent_icebreaker.txt",
|
| 84 |
+
"auditeur": PROMPTS_DIR / "agent_auditeur.txt",
|
| 85 |
+
"enqueteur": PROMPTS_DIR / "agent_enqueteur.txt",
|
| 86 |
+
"stratege": PROMPTS_DIR / "agent_stratege.txt",
|
| 87 |
+
"projecteur": PROMPTS_DIR / "agent_projecteur.txt"
|
| 88 |
+
}
|
| 89 |
+
for key, path in prompt_files.items():
|
| 90 |
+
try:
|
| 91 |
+
prompts[key] = path.read_text(encoding='utf-8-sig')
|
| 92 |
+
except FileNotFoundError:
|
| 93 |
+
logger.error(f"Missing prompt: {path}")
|
| 94 |
+
prompts[key] = f"You are {key}."
|
| 95 |
+
except UnicodeDecodeError:
|
| 96 |
+
logger.warning(f"Encoding issue with {path}, trying latin-1")
|
| 97 |
+
prompts[key] = path.read_text(encoding='latin-1')
|
| 98 |
+
return prompts
|
| 99 |
+
|
| 100 |
+
def _count_user_messages(self, messages: List[BaseMessage]) -> int:
|
| 101 |
+
"""Count user messages from conversation history."""
|
| 102 |
+
return sum(1 for m in messages if isinstance(m, HumanMessage))
|
| 103 |
+
|
| 104 |
+
def _determine_section_and_status(self, user_msg_count: int) -> tuple[str, bool]:
|
| 105 |
+
"""
|
| 106 |
+
Determine current section based on user message count.
|
| 107 |
+
Returns: (section_name, should_end)
|
| 108 |
+
"""
|
| 109 |
+
cumulative = 0
|
| 110 |
+
|
| 111 |
+
for agent in AGENT_ORDER:
|
| 112 |
+
agent_questions = QUESTIONS_PER_AGENT[agent]
|
| 113 |
+
|
| 114 |
+
# Check if user is still within this agent's range
|
| 115 |
+
if user_msg_count < cumulative + agent_questions:
|
| 116 |
+
return agent, False
|
| 117 |
+
|
| 118 |
+
cumulative += agent_questions
|
| 119 |
+
|
| 120 |
+
# Past all agents -> END (projecteur completed)
|
| 121 |
+
return "end", True
|
| 122 |
+
|
| 123 |
+
# --- Context builders ---
|
| 124 |
+
|
| 125 |
+
def _get_icebreaker_context(self, state: AgentState) -> str:
|
| 126 |
+
candidat = state["cv_data"].get("info_personnelle", {})
|
| 127 |
+
job = state["job_data"]
|
| 128 |
+
|
| 129 |
+
# Extract Hobbies/Interests if available
|
| 130 |
+
hobbies = ", ".join(state["cv_data"].get("centres_interet", []))
|
| 131 |
+
if not hobbies:
|
| 132 |
+
hobbies = "Non spécifiés"
|
| 133 |
+
|
| 134 |
+
# Extract Reconversion Context
|
| 135 |
+
reconversion_data = state["cv_data"].get("reconversion", {})
|
| 136 |
+
is_reco = reconversion_data.get("is_reconversion", False)
|
| 137 |
+
reco_context = "OUI" if is_reco else "NON"
|
| 138 |
+
|
| 139 |
+
# Extract Student Context
|
| 140 |
+
etudiant_data = state["cv_data"].get("etudiant", {})
|
| 141 |
+
is_etudiant = etudiant_data.get("is_etudiant", False)
|
| 142 |
+
etudiant_context = f"OUI ({etudiant_data.get('niveau_etudes', '')})" if is_etudiant else "NON"
|
| 143 |
+
|
| 144 |
+
return f"""
|
| 145 |
+
=== CONTEXTE CANDIDAT ===
|
| 146 |
+
NOM: {candidat.get('nom', 'Candidat')} {candidat.get('prenom', '')}
|
| 147 |
+
POSTE VISÉ: {job.get('poste', 'Non spécifié')}
|
| 148 |
+
ENTREPRISE: {job.get('entreprise', 'Non spécifié')}
|
| 149 |
+
|
| 150 |
+
=== DOSSIER PERSONNEL ===
|
| 151 |
+
CENTRES D'INTÉRÊT: {hobbies}
|
| 152 |
+
EN RECONVERSION ?: {reco_context}
|
| 153 |
+
ÉTUDIANT ?: {etudiant_context}
|
| 154 |
+
"""
|
| 155 |
+
|
| 156 |
+
def _get_auditeur_context(self, state: AgentState) -> str:
|
| 157 |
+
cv = state["cv_data"]
|
| 158 |
+
job = state["job_data"]
|
| 159 |
+
experiences = json.dumps(cv.get("expériences", []), ensure_ascii=False)
|
| 160 |
+
|
| 161 |
+
# Format Projects with details
|
| 162 |
+
projets_list = cv.get("projets", {}).get("professional", []) + cv.get("projets", {}).get("personal", [])
|
| 163 |
+
projets_formatted = []
|
| 164 |
+
for p in projets_list:
|
| 165 |
+
techs = ", ".join(p.get("technologies", []))
|
| 166 |
+
outcomes = ", ".join(p.get("outcomes", []))
|
| 167 |
+
projets_formatted.append(f"- {p.get('title')}: {techs} (Résultats: {outcomes})")
|
| 168 |
+
projets_str = "\n".join(projets_formatted)
|
| 169 |
+
|
| 170 |
+
# Format Skills with Context
|
| 171 |
+
skills_ctx = cv.get("compétences", {}).get("skills_with_context", [])
|
| 172 |
+
skills_formatted = [f"{s.get('skill')} ({s.get('context')})" for s in skills_ctx]
|
| 173 |
+
skills_str = ", ".join(skills_formatted)
|
| 174 |
+
|
| 175 |
+
# Fallback to hard_skills if no context
|
| 176 |
+
if not skills_str:
|
| 177 |
+
skills_str = ", ".join(cv.get("compétences", {}).get("hard_skills", []))
|
| 178 |
+
|
| 179 |
+
# Add Icebreaker Data
|
| 180 |
+
ib_data = state.get("icebreaker_data")
|
| 181 |
+
ib_context = ""
|
| 182 |
+
if ib_data:
|
| 183 |
+
ib_context = f"""
|
| 184 |
+
=== PROFIL DÉTECTÉ (ICEBREAKER) ===
|
| 185 |
+
TYPE: {ib_data.profil_type}
|
| 186 |
+
EXPÉRIENCE DOMAINE: {ib_data.annees_experience_domaine}
|
| 187 |
+
MOTIVATION: {"OUI" if ib_data.motivation_detectee else "NON"}
|
| 188 |
+
CONTEXTE: {ib_data.contexte_specifique}
|
| 189 |
+
"""
|
| 190 |
+
|
| 191 |
+
return f"""
|
| 192 |
+
=== CONTEXTE TECHNIQUE ===
|
| 193 |
+
MISSION DU POSTE: {job.get('mission', '')}
|
| 194 |
+
EXPÉRIENCES CANDIDAT: {experiences}
|
| 195 |
+
PROJETS SIGNIFICATIFS:
|
| 196 |
+
{projets_str}
|
| 197 |
+
|
| 198 |
+
COMPÉTENCES ET CONTEXTE:
|
| 199 |
+
{skills_str}
|
| 200 |
+
{ib_context}
|
| 201 |
+
"""
|
| 202 |
+
|
| 203 |
+
def _get_enqueteur_context(self, state: AgentState) -> str:
|
| 204 |
+
cv = state["cv_data"]
|
| 205 |
+
soft_skills = ", ".join(cv.get("compétences", {}).get("soft_skills", []))
|
| 206 |
+
reconversion = cv.get("reconversion", {})
|
| 207 |
+
is_reco = reconversion.get("is_reconversion", False)
|
| 208 |
+
reco_txt = "OUI" if is_reco else "NON"
|
| 209 |
+
|
| 210 |
+
# Add Technical Data (Gaps)
|
| 211 |
+
tech_data = state.get("technical_data")
|
| 212 |
+
tech_context = ""
|
| 213 |
+
if tech_data:
|
| 214 |
+
lacunes = [f"- {l.skill} (Niveau {l.niveau_detecte})" for l in tech_data.lacunes_explorees]
|
| 215 |
+
tech_context = f"""
|
| 216 |
+
=== BILAN TECHNIQUE ===
|
| 217 |
+
SCORE GLOBAL: {tech_data.score_technique_global}/5
|
| 218 |
+
LACUNES IDENTIFIÉES:
|
| 219 |
+
{chr(10).join(lacunes)}
|
| 220 |
+
"""
|
| 221 |
+
|
| 222 |
+
return f"""
|
| 223 |
+
=== CONTEXTE COMPORTEMENTAL ===
|
| 224 |
+
SOFT SKILLS: {soft_skills}
|
| 225 |
+
{reco_txt}
|
| 226 |
+
{tech_context}
|
| 227 |
+
"""
|
| 228 |
+
|
| 229 |
+
def _get_stratege_context(self, state: AgentState) -> str:
|
| 230 |
+
job = state["job_data"]
|
| 231 |
+
|
| 232 |
+
# Add Behavioral Data
|
| 233 |
+
beh_data = state.get("behavioral_data")
|
| 234 |
+
beh_context = ""
|
| 235 |
+
if beh_data:
|
| 236 |
+
beh_context = f"""
|
| 237 |
+
=== BILAN COMPORTEMENTAL ===
|
| 238 |
+
SCORE GLOBAL: {beh_data.score_comportemental_global}/5
|
| 239 |
+
POINTS À INTÉGRER: {", ".join(beh_data.points_a_integrer_mise_en_situation)}
|
| 240 |
+
"""
|
| 241 |
+
|
| 242 |
+
return f"""
|
| 243 |
+
=== CONTEXTE SJT (MISE EN SITUATION) ===
|
| 244 |
+
MISSION: {job.get('mission', '')}
|
| 245 |
+
CULTURE/VALEURS: {job.get('profil_recherche', '')}
|
| 246 |
+
{beh_context}
|
| 247 |
+
"""
|
| 248 |
+
|
| 249 |
+
def _get_projecteur_context(self, state: AgentState) -> str:
|
| 250 |
+
job = state["job_data"]
|
| 251 |
+
return f"""
|
| 252 |
+
=== CONTEXTE PROJECTION ===
|
| 253 |
+
ENTREPRISE: {job.get('entreprise', '')}
|
| 254 |
+
DESCRIPTION POSTE: {job.get('description_poste', '')}
|
| 255 |
+
|
| 256 |
+
NOTE: C'est la dernière question de l'entretien.
|
| 257 |
+
"""
|
| 258 |
+
|
| 259 |
+
# --- Orchestrator (strict per-agent logic) ---
|
| 260 |
+
|
| 261 |
+
def _orchestrator_node(self, state: AgentState):
|
| 262 |
+
messages = state["messages"]
|
| 263 |
+
user_msg_count = self._count_user_messages(messages)
|
| 264 |
+
|
| 265 |
+
section, should_end = self._determine_section_and_status(user_msg_count)
|
| 266 |
+
|
| 267 |
+
logger.info(f"Orchestrator: {user_msg_count} user messages -> section={section}, end={should_end}")
|
| 268 |
+
|
| 269 |
+
context_updates = state.get("context", {}).copy()
|
| 270 |
+
next_dest = "end_interview" if should_end else section
|
| 271 |
+
context_updates["next_dest"] = next_dest
|
| 272 |
+
|
| 273 |
+
# Trigger Extraction based on transitions
|
| 274 |
+
extract_target = None
|
| 275 |
+
if section == "auditeur" and not state.get("icebreaker_data"):
|
| 276 |
+
extract_target = "icebreaker"
|
| 277 |
+
elif section == "enqueteur" and not state.get("technical_data"):
|
| 278 |
+
extract_target = "technical"
|
| 279 |
+
elif section == "stratege" and not state.get("behavioral_data"):
|
| 280 |
+
extract_target = "behavioral"
|
| 281 |
+
elif section == "projecteur" and not state.get("situation_data"):
|
| 282 |
+
extract_target = "situation"
|
| 283 |
+
|
| 284 |
+
# Check for Final Report extraction
|
| 285 |
+
if should_end:
|
| 286 |
+
# Check if we missed situation data (if flow ended abruptly?) - assuming linear flow for now.
|
| 287 |
+
# Only trigger if we haven't tried yet (flag in context)
|
| 288 |
+
if not state.get("situation_data") and not context_updates.get("situation_attempted"):
|
| 289 |
+
extract_target = "situation"
|
| 290 |
+
elif not state.get("simulation_report") and not context_updates.get("report_attempted"):
|
| 291 |
+
extract_target = "report"
|
| 292 |
+
|
| 293 |
+
if extract_target:
|
| 294 |
+
context_updates["extract_target"] = extract_target
|
| 295 |
+
logger.info(f"Triggering extraction for: {extract_target}")
|
| 296 |
+
|
| 297 |
+
return {"section": section, "context": context_updates}
|
| 298 |
+
|
| 299 |
+
def _extraction_node(self, state: AgentState):
|
| 300 |
+
target = state["context"].get("extract_target")
|
| 301 |
+
logger.info(f"Running extraction node for: {target}")
|
| 302 |
+
updates = {}
|
| 303 |
+
ctx = state["context"].copy()
|
| 304 |
+
|
| 305 |
+
try:
|
| 306 |
+
if target == "icebreaker":
|
| 307 |
+
updates["icebreaker_data"] = self.extractor.extract_icebreaker(state["messages"], state["cv_data"])
|
| 308 |
+
elif target == "technical":
|
| 309 |
+
updates["technical_data"] = self.extractor.extract_technical(state["messages"], state["job_data"])
|
| 310 |
+
elif target == "behavioral":
|
| 311 |
+
updates["behavioral_data"] = self.extractor.extract_behavioral(state["messages"])
|
| 312 |
+
elif target == "situation":
|
| 313 |
+
updates["situation_data"] = self.extractor.extract_situation(state["messages"])
|
| 314 |
+
elif target == "report":
|
| 315 |
+
updates["simulation_report"] = self.extractor.extract_simulation_report(
|
| 316 |
+
state["messages"],
|
| 317 |
+
state.get("icebreaker_data"),
|
| 318 |
+
state.get("technical_data"),
|
| 319 |
+
state.get("behavioral_data"),
|
| 320 |
+
state.get("situation_data")
|
| 321 |
+
)
|
| 322 |
+
except Exception as e:
|
| 323 |
+
logger.error(f"Extraction failed for {target}: {e}", exc_info=True)
|
| 324 |
+
|
| 325 |
+
# Mark attempt to avoid infinite loops
|
| 326 |
+
if target == "situation":
|
| 327 |
+
ctx["situation_attempted"] = True
|
| 328 |
+
elif target == "report":
|
| 329 |
+
ctx["report_attempted"] = True
|
| 330 |
+
|
| 331 |
+
# Clear extract_target safely
|
| 332 |
+
ctx.pop("extract_target", None)
|
| 333 |
+
|
| 334 |
+
return {**updates, "context": ctx}
|
| 335 |
+
|
| 336 |
+
# --- Agent runner ---
|
| 337 |
+
|
| 338 |
+
def _run_agent(self, state: AgentState, agent_key: str, context_str: str):
|
| 339 |
+
prompt_template = self.prompts[agent_key]
|
| 340 |
+
|
| 341 |
+
# Extract first name
|
| 342 |
+
cv_info = state["cv_data"].get("info_personnelle", {})
|
| 343 |
+
prenom = cv_info.get("prenom", "")
|
| 344 |
+
|
| 345 |
+
# Get question limit
|
| 346 |
+
nb_questions = QUESTIONS_PER_AGENT.get(agent_key, 2)
|
| 347 |
+
|
| 348 |
+
try:
|
| 349 |
+
instructions = prompt_template.format(
|
| 350 |
+
user_id=state["user_id"],
|
| 351 |
+
prenom=prenom,
|
| 352 |
+
nb_questions=nb_questions,
|
| 353 |
+
job_description=json.dumps(state["job_data"], ensure_ascii=False),
|
| 354 |
+
poste=state["job_data"].get("poste", "Poste non spécifié"),
|
| 355 |
+
entreprise=state["job_data"].get("entreprise", "Entreprise confidentielle")
|
| 356 |
+
)
|
| 357 |
+
except KeyError:
|
| 358 |
+
instructions = prompt_template
|
| 359 |
+
|
| 360 |
+
system_msg = f"{instructions}\n\n{context_str}"
|
| 361 |
+
messages = [SystemMessage(content=system_msg)] + list(state["messages"])
|
| 362 |
+
response = self.llm.invoke(messages)
|
| 363 |
+
|
| 364 |
+
return {"messages": [response]}
|
| 365 |
+
|
| 366 |
+
# --- Agent nodes ---
|
| 367 |
+
|
| 368 |
+
def _icebreaker_node(self, s): return self._run_agent(s, "icebreaker", self._get_icebreaker_context(s))
|
| 369 |
+
def _auditeur_node(self, s): return self._run_agent(s, "auditeur", self._get_auditeur_context(s))
|
| 370 |
+
def _enqueteur_node(self, s): return self._run_agent(s, "enqueteur", self._get_enqueteur_context(s))
|
| 371 |
+
def _stratege_node(self, s): return self._run_agent(s, "stratege", self._get_stratege_context(s))
|
| 372 |
+
def _projecteur_node(self, s): return self._run_agent(s, "projecteur", self._get_projecteur_context(s))
|
| 373 |
+
|
| 374 |
+
# --- Final analysis ---
|
| 375 |
+
|
| 376 |
+
def _final_analysis_node(self, state: AgentState):
|
| 377 |
+
"""Trigger background analysis and return exit message."""
|
| 378 |
+
from src.tasks import run_analysis_task
|
| 379 |
+
|
| 380 |
+
logger.info("=== FINAL ANALYSIS TRIGGERED ===")
|
| 381 |
+
|
| 382 |
+
try:
|
| 383 |
+
hist = [{"role": ("user" if isinstance(m, HumanMessage) else "assistant"), "content": m.content} for m in state["messages"]]
|
| 384 |
+
|
| 385 |
+
simulation_report_dict = None
|
| 386 |
+
if state.get("simulation_report"):
|
| 387 |
+
simulation_report_dict = state["simulation_report"].dict()
|
| 388 |
+
|
| 389 |
+
run_analysis_task.delay(
|
| 390 |
+
user_id=state['user_id'],
|
| 391 |
+
job_offer_id=state['job_offer_id'],
|
| 392 |
+
job_description=json.dumps(state['job_data'], ensure_ascii=False),
|
| 393 |
+
conversation_history=hist,
|
| 394 |
+
cv_content=json.dumps(state['cv_data'], ensure_ascii=False),
|
| 395 |
+
cheat_metrics=state.get('cheat_metrics', {}),
|
| 396 |
+
simulation_report=simulation_report_dict
|
| 397 |
+
)
|
| 398 |
+
logger.info("Background analysis task enqueued via Celery")
|
| 399 |
+
except Exception as e:
|
| 400 |
+
logger.error(f"Failed to enqueue analysis task: {e}")
|
| 401 |
+
|
| 402 |
+
# Generate contextual exit message
|
| 403 |
+
last_user_msg = state["messages"][-1].content if state["messages"] and isinstance(state["messages"][-1], HumanMessage) else ""
|
| 404 |
+
|
| 405 |
+
if last_user_msg:
|
| 406 |
+
closing_prompt = (
|
| 407 |
+
f"Tu es un recruteur professionnel (RONI). L'entretien est terminé.\n"
|
| 408 |
+
f"Le candidat vient de dire : \"{last_user_msg}\"\n"
|
| 409 |
+
f"Réponds-y brièvement et aimablement (une phrase max). Si c'est une question, donnes-y une réponse simple.\n"
|
| 410 |
+
f"Ne dis PAS au revoir tout de suite, contente-toi de répondre au dernier point soulevé."
|
| 411 |
+
)
|
| 412 |
+
try:
|
| 413 |
+
ai_response = self.llm.invoke([SystemMessage(content=closing_prompt)])
|
| 414 |
+
# Force append the exit message to guarantee trigger detection
|
| 415 |
+
final_content = f"{ai_response.content}\n\n{EXIT_MESSAGE}"
|
| 416 |
+
except Exception as e:
|
| 417 |
+
logger.error(f"Error generating closing message: {e}")
|
| 418 |
+
final_content = EXIT_MESSAGE
|
| 419 |
+
else:
|
| 420 |
+
final_content = EXIT_MESSAGE
|
| 421 |
+
|
| 422 |
+
return {"messages": [AIMessage(content=final_content)], "context": {"next_dest": "end_interview"}}
|
| 423 |
+
|
| 424 |
+
# --- Routing ---
|
| 425 |
+
|
| 426 |
+
def _route_next_step(self, state: AgentState) -> str:
|
| 427 |
+
# Check if extraction is needed
|
| 428 |
+
if state.get("context", {}).get("extract_target"):
|
| 429 |
+
return "extraction_node"
|
| 430 |
+
|
| 431 |
+
dest = state.get("context", {}).get("next_dest", "icebreaker")
|
| 432 |
+
if dest == "end_interview":
|
| 433 |
+
# Ensure we have the report before finishing. Loop back to orchestrator.
|
| 434 |
+
# Only loop back if we haven't attempted to extract the report yet.
|
| 435 |
+
if not state.get("simulation_report") and not state.get("context", {}).get("report_attempted"):
|
| 436 |
+
return "orchestrator"
|
| 437 |
+
return "final_analysis"
|
| 438 |
+
return f"{dest}_agent"
|
| 439 |
+
|
| 440 |
+
# --- Graph builder ---
|
| 441 |
+
|
| 442 |
+
def _build_graph(self) -> any:
|
| 443 |
+
graph = StateGraph(AgentState)
|
| 444 |
+
|
| 445 |
+
graph.add_node("orchestrator", self._orchestrator_node)
|
| 446 |
+
graph.add_node("extraction_node", self._extraction_node) # Add extraction node
|
| 447 |
+
graph.add_node("icebreaker_agent", self._icebreaker_node)
|
| 448 |
+
graph.add_node("auditeur_agent", self._auditeur_node)
|
| 449 |
+
graph.add_node("enqueteur_agent", self._enqueteur_node)
|
| 450 |
+
graph.add_node("stratege_agent", self._stratege_node)
|
| 451 |
+
graph.add_node("projecteur_agent", self._projecteur_node)
|
| 452 |
+
graph.add_node("final_analysis", self._final_analysis_node)
|
| 453 |
+
|
| 454 |
+
graph.set_entry_point("orchestrator")
|
| 455 |
+
|
| 456 |
+
routing_map = {
|
| 457 |
+
"extraction_node": "extraction_node",
|
| 458 |
+
"icebreaker_agent": "icebreaker_agent",
|
| 459 |
+
"auditeur_agent": "auditeur_agent",
|
| 460 |
+
"enqueteur_agent": "enqueteur_agent",
|
| 461 |
+
"stratege_agent": "stratege_agent",
|
| 462 |
+
"projecteur_agent": "projecteur_agent",
|
| 463 |
+
"final_analysis": "final_analysis",
|
| 464 |
+
"orchestrator": "orchestrator" # Added loopback
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
graph.add_conditional_edges("orchestrator", self._route_next_step, routing_map)
|
| 468 |
+
graph.add_conditional_edges("extraction_node", self._route_next_step, routing_map)
|
| 469 |
+
|
| 470 |
+
for node in ["icebreaker_agent", "auditeur_agent", "enqueteur_agent", "stratege_agent", "projecteur_agent", "final_analysis"]:
|
| 471 |
+
graph.add_edge(node, END)
|
| 472 |
+
|
| 473 |
+
return graph.compile()
|
| 474 |
+
|
| 475 |
+
# --- Public API ---
|
| 476 |
+
|
| 477 |
+
def invoke(self, messages: List[Dict[str, Any]], cheat_metrics: Dict[str, Any] = None):
|
| 478 |
+
langchain_messages = [HumanMessage(content=m["content"]) if m["role"] == "user" else AIMessage(content=m["content"]) for m in messages]
|
| 479 |
+
|
| 480 |
+
if not langchain_messages:
|
| 481 |
+
langchain_messages.append(HumanMessage(content="Bonjour"))
|
| 482 |
+
|
| 483 |
+
initial_state = {
|
| 484 |
+
"user_id": self.user_id,
|
| 485 |
+
"job_offer_id": self.job_offer_id,
|
| 486 |
+
"messages": langchain_messages,
|
| 487 |
+
"cv_data": self.cv_data,
|
| 488 |
+
"job_data": self.job_offer,
|
| 489 |
+
"section": "icebreaker",
|
| 490 |
+
"turn_count": 0,
|
| 491 |
+
"context": {},
|
| 492 |
+
"cheat_metrics": cheat_metrics or {}
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
final_state = self.graph.invoke(initial_state)
|
| 496 |
+
|
| 497 |
+
if not final_state or not final_state['messages']:
|
| 498 |
+
return {"response": "Erreur système.", "status": "finished"}
|
| 499 |
+
|
| 500 |
+
last_msg = final_state['messages'][-1]
|
| 501 |
+
is_finished = final_state.get("context", {}).get("next_dest") == "end_interview"
|
| 502 |
+
status = "interview_finished" if is_finished else "interviewing"
|
| 503 |
+
|
| 504 |
+
return {"response": last_msg.content, "status": status}
|
src/services/integrity_service.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from src.services.nlp_service import NLPService
|
| 3 |
+
from typing import Dict, Any
|
| 4 |
+
|
| 5 |
+
logger = logging.getLogger(__name__)
|
| 6 |
+
|
| 7 |
+
class IntegrityService:
|
| 8 |
+
def __init__(self):
|
| 9 |
+
self.nlp = NLPService()
|
| 10 |
+
|
| 11 |
+
def analyze_integrity(self, cv_text: str, interview_text: str, existing_metrics: Dict[str, Any] = None) -> Dict[str, Any]:
|
| 12 |
+
"""
|
| 13 |
+
Combines stylometric analysis and AI detection to produce an integrity report.
|
| 14 |
+
"""
|
| 15 |
+
logger.info("Starting Integrity Analysis...")
|
| 16 |
+
|
| 17 |
+
# 1. AI Detection on Interview Transcript
|
| 18 |
+
interview_metrics = self.nlp.compute_all_metrics(interview_text)
|
| 19 |
+
|
| 20 |
+
# 2. Stylometric Consistency (CV vs Interview)
|
| 21 |
+
# We only care about consistency if we have enough text
|
| 22 |
+
stylometric_flag = False
|
| 23 |
+
gap_details = ""
|
| 24 |
+
|
| 25 |
+
if cv_text and len(cv_text) > 100:
|
| 26 |
+
cv_metrics = self.nlp.compute_all_metrics(cv_text)
|
| 27 |
+
|
| 28 |
+
# Compare Readability (Flesch)
|
| 29 |
+
readability_gap = abs(interview_metrics["readability"] - cv_metrics["readability"])
|
| 30 |
+
if readability_gap > 30: # Huge gap in complexity
|
| 31 |
+
stylometric_flag = True
|
| 32 |
+
gap_details += f"Readability Gap ({readability_gap}); "
|
| 33 |
+
|
| 34 |
+
# Compare Vocabulary Richness
|
| 35 |
+
ttr_gap = abs(interview_metrics["lexical_diversity"] - cv_metrics["lexical_diversity"])
|
| 36 |
+
if ttr_gap > 0.2:
|
| 37 |
+
stylometric_flag = True
|
| 38 |
+
gap_details += f"Vocab Gap ({ttr_gap}); "
|
| 39 |
+
|
| 40 |
+
# 3. Rules for Red Flags
|
| 41 |
+
ai_suspicion_score = 0
|
| 42 |
+
reasons = []
|
| 43 |
+
|
| 44 |
+
# Low Perplexity = AI
|
| 45 |
+
if interview_metrics["perplexity"] < 25:
|
| 46 |
+
ai_suspicion_score += 40
|
| 47 |
+
reasons.append("Perplexity very low (Robotic)")
|
| 48 |
+
|
| 49 |
+
# Low Burstiness = AI
|
| 50 |
+
if interview_metrics["burstiness"] < 0.2:
|
| 51 |
+
ai_suspicion_score += 30
|
| 52 |
+
reasons.append("Low Burstiness (Monotone)")
|
| 53 |
+
|
| 54 |
+
# Stylometric Mismatch
|
| 55 |
+
if stylometric_flag:
|
| 56 |
+
ai_suspicion_score += 20
|
| 57 |
+
reasons.append(f"Style Mismatch with CV: {gap_details}")
|
| 58 |
+
|
| 59 |
+
final_score = min(100, ai_suspicion_score)
|
| 60 |
+
|
| 61 |
+
return {
|
| 62 |
+
"ai_score": final_score,
|
| 63 |
+
"stylometry_mismatch": stylometric_flag,
|
| 64 |
+
"metrics": interview_metrics,
|
| 65 |
+
"reasons": reasons,
|
| 66 |
+
"raw_gap": gap_details
|
| 67 |
+
}
|
src/services/nlp_service.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import math
|
| 3 |
+
import numpy as np
|
| 4 |
+
from textblob import TextBlob
|
| 5 |
+
import textstat
|
| 6 |
+
from transformers import GPT2LMHeadModel, GPT2TokenizerFast
|
| 7 |
+
import torch
|
| 8 |
+
import re
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
class NLPService:
|
| 13 |
+
_instance = None
|
| 14 |
+
_perplex_model = None
|
| 15 |
+
_perplex_tokenizer = None
|
| 16 |
+
|
| 17 |
+
def __new__(cls):
|
| 18 |
+
if cls._instance is None:
|
| 19 |
+
cls._instance = super(NLPService, cls).__new__(cls)
|
| 20 |
+
return cls._instance
|
| 21 |
+
|
| 22 |
+
def _load_model(self):
|
| 23 |
+
"""Lazy load the model to avoid huge startup time."""
|
| 24 |
+
if self._perplex_model is None:
|
| 25 |
+
logger.info("Loading NLP models (DistilGPT2)...")
|
| 26 |
+
try:
|
| 27 |
+
model_id = 'distilgpt2'
|
| 28 |
+
self._perplex_model = GPT2LMHeadModel.from_pretrained(model_id)
|
| 29 |
+
self._perplex_tokenizer = GPT2TokenizerFast.from_pretrained(model_id)
|
| 30 |
+
logger.info("NLP models loaded successfully.")
|
| 31 |
+
except Exception as e:
|
| 32 |
+
logger.error(f"Failed to load NLP models: {e}")
|
| 33 |
+
raise e
|
| 34 |
+
|
| 35 |
+
MAX_PERPLEXITY_CHARS = 50000
|
| 36 |
+
|
| 37 |
+
def calculate_perplexity(self, text: str) -> float:
|
| 38 |
+
"""
|
| 39 |
+
Calculate perplexity of the text using a small GPT-2 model.
|
| 40 |
+
Lower perplexity = more likely to be generated by AI (or very standard human text).
|
| 41 |
+
"""
|
| 42 |
+
if not text or len(text.strip()) < 10:
|
| 43 |
+
return 0.0
|
| 44 |
+
|
| 45 |
+
# Truncate to avoid memory overflow on very long inputs
|
| 46 |
+
if len(text) > self.MAX_PERPLEXITY_CHARS:
|
| 47 |
+
text = text[:self.MAX_PERPLEXITY_CHARS]
|
| 48 |
+
|
| 49 |
+
self._load_model()
|
| 50 |
+
|
| 51 |
+
encodings = self._perplex_tokenizer(text, return_tensors='pt')
|
| 52 |
+
max_length = self._perplex_model.config.n_positions
|
| 53 |
+
stride = 512
|
| 54 |
+
seq_len = encodings.input_ids.size(1)
|
| 55 |
+
|
| 56 |
+
nlls = []
|
| 57 |
+
prev_end_loc = 0
|
| 58 |
+
for begin_loc in range(0, seq_len, stride):
|
| 59 |
+
end_loc = min(begin_loc + max_length, seq_len)
|
| 60 |
+
trg_len = end_loc - prev_end_loc # may be different from stride on last loop
|
| 61 |
+
input_ids = encodings.input_ids[:, begin_loc:end_loc]
|
| 62 |
+
target_ids = input_ids.clone()
|
| 63 |
+
target_ids[:, :-trg_len] = -100
|
| 64 |
+
|
| 65 |
+
with torch.no_grad():
|
| 66 |
+
outputs = self._perplex_model(input_ids, labels=target_ids)
|
| 67 |
+
neg_log_likelihood = outputs.loss
|
| 68 |
+
|
| 69 |
+
nlls.append(neg_log_likelihood)
|
| 70 |
+
prev_end_loc = end_loc
|
| 71 |
+
if end_loc == seq_len:
|
| 72 |
+
break
|
| 73 |
+
|
| 74 |
+
if not nlls:
|
| 75 |
+
return 0.0
|
| 76 |
+
|
| 77 |
+
ppl = torch.exp(torch.stack(nlls).mean())
|
| 78 |
+
return float(ppl)
|
| 79 |
+
|
| 80 |
+
def analyze_sentiment(self, text: str) -> dict:
|
| 81 |
+
"""
|
| 82 |
+
Returns Polarity (-1 to 1) and Subjectivity (0 to 1).
|
| 83 |
+
"""
|
| 84 |
+
blob = TextBlob(text)
|
| 85 |
+
return {
|
| 86 |
+
"polarity": round(blob.sentiment.polarity, 2),
|
| 87 |
+
"subjectivity": round(blob.sentiment.subjectivity, 2)
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
def calculate_lexical_diversity(self, text: str) -> float:
|
| 91 |
+
"""
|
| 92 |
+
Type-Token Ratio (TTR).
|
| 93 |
+
Higher = richer vocabulary.
|
| 94 |
+
"""
|
| 95 |
+
if not text:
|
| 96 |
+
return 0.0
|
| 97 |
+
|
| 98 |
+
words = re.findall(r'\w+', text.lower())
|
| 99 |
+
if not words:
|
| 100 |
+
return 0.0
|
| 101 |
+
|
| 102 |
+
unique_words = set(words)
|
| 103 |
+
return round(len(unique_words) / len(words), 3)
|
| 104 |
+
|
| 105 |
+
def calculate_burstiness(self, text: str) -> float:
|
| 106 |
+
"""
|
| 107 |
+
Burstiness is usually defined by the variation in sentence length.
|
| 108 |
+
AI text tends to be more regular (low std dev), humans more chaotic.
|
| 109 |
+
"""
|
| 110 |
+
blob = TextBlob(text)
|
| 111 |
+
sentences = blob.sentences
|
| 112 |
+
if not sentences or len(sentences) < 2:
|
| 113 |
+
return 0.0
|
| 114 |
+
|
| 115 |
+
lengths = [len(s.words) for s in sentences]
|
| 116 |
+
std_dev = np.std(lengths)
|
| 117 |
+
mean = np.mean(lengths)
|
| 118 |
+
|
| 119 |
+
# Coefficient of variation can be a proxy for burstiness
|
| 120 |
+
if mean == 0:
|
| 121 |
+
return 0.0
|
| 122 |
+
|
| 123 |
+
return round(float(std_dev / mean), 3)
|
| 124 |
+
|
| 125 |
+
def compute_all_metrics(self, text: str) -> dict:
|
| 126 |
+
return {
|
| 127 |
+
"perplexity": self.calculate_perplexity(text),
|
| 128 |
+
"sentiment": self.analyze_sentiment(text),
|
| 129 |
+
"lexical_diversity": self.calculate_lexical_diversity(text),
|
| 130 |
+
"burstiness": self.calculate_burstiness(text),
|
| 131 |
+
"readability": textstat.flesch_reading_ease(text)
|
| 132 |
+
}
|
src/services/search_service.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import re
|
| 3 |
+
from typing import Dict, List, Any, Tuple
|
| 4 |
+
from src.services.semantic_service import SemanticService
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger(__name__)
|
| 7 |
+
|
| 8 |
+
class SearchService:
|
| 9 |
+
"""
|
| 10 |
+
Agent de Recherche (RAG & Grounding).
|
| 11 |
+
Responsable de l'analyse des écarts (Gap Analysis) et de la détection des profils (Reconversion).
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
JOB_TYPES = {
|
| 15 |
+
"DATA_ENGINEER": ["engineer", "ingénieur data", "mlops", "architecte", "platform"],
|
| 16 |
+
"DATA_SCIENTIST": ["scientist", "science", "nlp", "computer vision", "chercher", "research"],
|
| 17 |
+
"DATA_ANALYST": ["analyst", "analytics", "bi", "business intelligence", "dashboard"],
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
VERB_MAPPINGS = {
|
| 21 |
+
"DATA_ENGINEER": [
|
| 22 |
+
"optimiser", "déployer", "industrialiser", "automatiser", "architecturer",
|
| 23 |
+
"monitorer", "scaler", "refactorer", "migrer", "contraindre"
|
| 24 |
+
],
|
| 25 |
+
"DATA_SCIENTIST": [
|
| 26 |
+
"entraîner", "finetuner", "expérimenter", "évaluer", "modéliser",
|
| 27 |
+
"optimiser", "analyser", "comparer", "implémenter"
|
| 28 |
+
],
|
| 29 |
+
"DATA_ANALYST": [
|
| 30 |
+
"visualiser", "présenter", "identifier", "extraire", "recommander",
|
| 31 |
+
"analyser", "synthétiser", "automatiser", "reporter"
|
| 32 |
+
]
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
# Fallback to general verbs if no type detected
|
| 36 |
+
DEFAULT_VERBS = VERB_MAPPINGS["DATA_ENGINEER"] + VERB_MAPPINGS["DATA_SCIENTIST"]
|
| 37 |
+
|
| 38 |
+
def __init__(self):
|
| 39 |
+
self.semantic_service = SemanticService()
|
| 40 |
+
|
| 41 |
+
def analyze_gap(self, cv_text: str, job_description: str) -> Dict[str, Any]:
|
| 42 |
+
"""
|
| 43 |
+
Effectue une analyse des écarts entre le CV et l'offre.
|
| 44 |
+
Retourne un dictionnaire contenant les gaps, les verbes d'action, et le statut de reconversion.
|
| 45 |
+
"""
|
| 46 |
+
logger.info("Starting Gap Analysis...")
|
| 47 |
+
|
| 48 |
+
# 0. Job Type Detection
|
| 49 |
+
job_type = self._detect_job_type(job_description)
|
| 50 |
+
logger.info(f"Detected Job Type: {job_type}")
|
| 51 |
+
|
| 52 |
+
# 1. Action Verbs Extraction (Dynamic based on Job Type)
|
| 53 |
+
target_verbs = self.VERB_MAPPINGS.get(job_type, self.DEFAULT_VERBS)
|
| 54 |
+
found_verbs = self._extract_action_verbs(cv_text, target_verbs)
|
| 55 |
+
|
| 56 |
+
# Score normalized by a reasonable expectation (e.g. finding 3 distinct verbs is good)
|
| 57 |
+
production_score = min(1.0, len(found_verbs) / 4.0)
|
| 58 |
+
|
| 59 |
+
# 2. Semantic Grounding
|
| 60 |
+
semantic_score = self.semantic_service.compute_similarity(cv_text, job_description)
|
| 61 |
+
|
| 62 |
+
# 3. Reconversion Reporting
|
| 63 |
+
is_reconversion, reconversion_reason = self._detect_reconversion(cv_text, job_description)
|
| 64 |
+
|
| 65 |
+
return {
|
| 66 |
+
"job_type": job_type,
|
| 67 |
+
"semantic_score": semantic_score,
|
| 68 |
+
"production_verbs_found": found_verbs,
|
| 69 |
+
"production_mindset_score": production_score,
|
| 70 |
+
"is_reconversion": is_reconversion,
|
| 71 |
+
"reconversion_reason": reconversion_reason,
|
| 72 |
+
"hidden_skill_gaps": "Analyse à compléter par LLM"
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
def _detect_job_type(self, job_desc: str) -> str:
|
| 76 |
+
"""Détermine le type de poste (Engineer, Scientist, Analyst) d'après la description."""
|
| 77 |
+
text_lower = job_desc.lower()
|
| 78 |
+
|
| 79 |
+
scores = {k: 0 for k in self.JOB_TYPES.keys()}
|
| 80 |
+
|
| 81 |
+
for j_type, keywords in self.JOB_TYPES.items():
|
| 82 |
+
for kw in keywords:
|
| 83 |
+
if kw in text_lower:
|
| 84 |
+
scores[j_type] += 1
|
| 85 |
+
|
| 86 |
+
# Return key with max score, default to GENERAL if no matches or ties (logic simplified)
|
| 87 |
+
best_match = max(scores, key=scores.get)
|
| 88 |
+
if scores[best_match] == 0:
|
| 89 |
+
return "GENERAL_TECH"
|
| 90 |
+
|
| 91 |
+
return best_match
|
| 92 |
+
|
| 93 |
+
def _extract_action_verbs(self, text: str, target_verbs: List[str]) -> List[str]:
|
| 94 |
+
"""Extrait les verbes d'action clés présents dans le texte."""
|
| 95 |
+
text_lower = text.lower()
|
| 96 |
+
found = []
|
| 97 |
+
for verb in target_verbs:
|
| 98 |
+
# Simple word boundary check
|
| 99 |
+
if re.search(r'\b' + re.escape(verb) + r'\w*', text_lower):
|
| 100 |
+
found.append(verb)
|
| 101 |
+
return list(set(found))
|
| 102 |
+
|
| 103 |
+
def _detect_reconversion(self, cv_text: str, job_desc: str) -> Tuple[bool, str]:
|
| 104 |
+
"""
|
| 105 |
+
Détecte si le candidat est en reconversion.
|
| 106 |
+
Logique simple: Mots clés 'formation', 'bootcamp', 'reconversion' + manque d'xp longue durée dans le domaine cible.
|
| 107 |
+
"""
|
| 108 |
+
cv_lower = cv_text.lower()
|
| 109 |
+
|
| 110 |
+
reconversion_keywords = ["reconversion", "bootcamp", "formation intensive", "rncp", "transition professionnelle"]
|
| 111 |
+
for kw in reconversion_keywords:
|
| 112 |
+
if kw in cv_lower:
|
| 113 |
+
return True, f"Mot-clé détecté : '{kw}'"
|
| 114 |
+
|
| 115 |
+
# Note: A more robust check would involve parsing dates and titles,
|
| 116 |
+
# but this simple heuristic allows flagging potential profiles for the Agents to confirm.
|
| 117 |
+
return False, "Parcours classique apparent"
|
src/services/semantic_service.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from sentence_transformers import SentenceTransformer, util
|
| 3 |
+
|
| 4 |
+
logger = logging.getLogger(__name__)
|
| 5 |
+
|
| 6 |
+
class SemanticService:
|
| 7 |
+
_instance = None
|
| 8 |
+
_model = None
|
| 9 |
+
|
| 10 |
+
def __new__(cls):
|
| 11 |
+
if cls._instance is None:
|
| 12 |
+
cls._instance = super(SemanticService, cls).__new__(cls)
|
| 13 |
+
return cls._instance
|
| 14 |
+
|
| 15 |
+
def _load_model(self):
|
| 16 |
+
if self._model is None:
|
| 17 |
+
logger.info("Loading Semantic model (all-MiniLM-L6-v2)...")
|
| 18 |
+
try:
|
| 19 |
+
self._model = SentenceTransformer('all-MiniLM-L6-v2')
|
| 20 |
+
except Exception as e:
|
| 21 |
+
logger.error(f"Failed to load Semantic model: {e}")
|
| 22 |
+
raise e
|
| 23 |
+
|
| 24 |
+
def compute_similarity(self, text1: str, text2: str) -> float:
|
| 25 |
+
"""
|
| 26 |
+
Computes semantic similarity between two texts.
|
| 27 |
+
Returns a score between 0.0 and 1.0.
|
| 28 |
+
"""
|
| 29 |
+
if not text1 or not text2:
|
| 30 |
+
return 0.0
|
| 31 |
+
|
| 32 |
+
self._load_model()
|
| 33 |
+
|
| 34 |
+
embeddings1 = self._model.encode(text1, convert_to_tensor=True)
|
| 35 |
+
embeddings2 = self._model.encode(text2, convert_to_tensor=True)
|
| 36 |
+
|
| 37 |
+
cosine_scores = util.cos_sim(embeddings1, embeddings2)
|
| 38 |
+
return float(cosine_scores[0][0])
|
src/services/simulation/__init__.py
ADDED
|
File without changes
|
src/services/simulation/agents.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import logging
|
| 3 |
+
from typing import List, Dict, Any
|
| 4 |
+
from langchain_openai import ChatOpenAI
|
| 5 |
+
from langchain_core.messages import SystemMessage, HumanMessage, BaseMessage
|
| 6 |
+
from src.services.simulation.schemas import (
|
| 7 |
+
IceBreakerOutput, TechnicalOutput, BehavioralOutput, SituationOutput,
|
| 8 |
+
TechnicalSkillGap, ProjectTechUnderstanding, BehavioralCompetency,
|
| 9 |
+
SimulationReport
|
| 10 |
+
)
|
| 11 |
+
from src.services.simulation.scoring import (
|
| 12 |
+
calculate_technical_gap_score,
|
| 13 |
+
calculate_project_tech_understanding_score,
|
| 14 |
+
calculate_behavioral_score,
|
| 15 |
+
calculate_situation_score
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
class InterviewAgentExtractor:
|
| 21 |
+
def __init__(self, llm: ChatOpenAI):
|
| 22 |
+
self.llm = llm
|
| 23 |
+
|
| 24 |
+
def _get_history_text(self, messages: List[BaseMessage]) -> str:
|
| 25 |
+
return "\n".join([f"{m.type.upper()}: {m.content}" for m in messages])
|
| 26 |
+
|
| 27 |
+
def extract_icebreaker(self, messages: List[BaseMessage], cv_data: Dict[str, Any]) -> IceBreakerOutput:
|
| 28 |
+
logger.info("Extracting Ice Breaker data...")
|
| 29 |
+
history = self._get_history_text(messages)
|
| 30 |
+
|
| 31 |
+
prompt = f"""
|
| 32 |
+
Tu es un expert en analyse d'entretien. Analyse l'échange suivant (phase d'Ice Breaker) et extrais les informations structurées.
|
| 33 |
+
|
| 34 |
+
CONTEXTE CANDIDAT:
|
| 35 |
+
{json.dumps(cv_data.get('info_personnelle', {}), ensure_ascii=False)}
|
| 36 |
+
{json.dumps(cv_data.get('reconversion', {}), ensure_ascii=False)}
|
| 37 |
+
|
| 38 |
+
HISTORIQUE ECHANGE:
|
| 39 |
+
{history}
|
| 40 |
+
|
| 41 |
+
Tâche: Extraire le type de profil, l'expérience, la cohérence, la motivation, le contexte et les points à explorer.
|
| 42 |
+
"""
|
| 43 |
+
|
| 44 |
+
extractor = self.llm.with_structured_output(IceBreakerOutput)
|
| 45 |
+
return extractor.invoke([SystemMessage(content=prompt)])
|
| 46 |
+
|
| 47 |
+
def extract_technical(self, messages: List[BaseMessage], job_offer: Dict[str, Any]) -> TechnicalOutput:
|
| 48 |
+
logger.info("Extracting Technical data...")
|
| 49 |
+
history = self._get_history_text(messages)
|
| 50 |
+
|
| 51 |
+
prompt = f"""
|
| 52 |
+
Tu es un expert technique. Analyse l'échange suivant (phase Technique) et extrais les compétences validées, les lacunes et la compréhension des technos.
|
| 53 |
+
|
| 54 |
+
OFFRE:
|
| 55 |
+
{json.dumps(job_offer, ensure_ascii=False)}
|
| 56 |
+
|
| 57 |
+
HISTORIQUE ECHANGE:
|
| 58 |
+
{history}
|
| 59 |
+
|
| 60 |
+
Tâche: Remplir la grille d'évaluation technique. Pour les indicateurs binaires, sois strict : true seulement si le candidat l'a explicitement démontré.
|
| 61 |
+
"""
|
| 62 |
+
|
| 63 |
+
extractor = self.llm.with_structured_output(TechnicalOutput)
|
| 64 |
+
data = extractor.invoke([SystemMessage(content=prompt)])
|
| 65 |
+
|
| 66 |
+
# Calculate scores - normalize all to 0-5 scale
|
| 67 |
+
scores = []
|
| 68 |
+
for gap in data.lacunes_explorees:
|
| 69 |
+
gap.niveau_detecte = calculate_technical_gap_score(gap.indicateurs)
|
| 70 |
+
normalized = (gap.niveau_detecte / 4.0) * 5.0 # 0-4 -> 0-5
|
| 71 |
+
scores.append(normalized)
|
| 72 |
+
|
| 73 |
+
for tech in data.comprehension_technos_projets:
|
| 74 |
+
tech.score = calculate_project_tech_understanding_score(tech.indicateurs)
|
| 75 |
+
scores.append(float(tech.score)) # already 1-5
|
| 76 |
+
|
| 77 |
+
for val in data.competences_validees:
|
| 78 |
+
scores.append(float(val.score)) # already 1-5
|
| 79 |
+
|
| 80 |
+
if scores:
|
| 81 |
+
data.score_technique_global = round(sum(scores) / len(scores), 1)
|
| 82 |
+
else:
|
| 83 |
+
data.score_technique_global = 0.0
|
| 84 |
+
|
| 85 |
+
return data
|
| 86 |
+
|
| 87 |
+
def extract_behavioral(self, messages: List[BaseMessage]) -> BehavioralOutput:
|
| 88 |
+
logger.info("Extracting Behavioral data...")
|
| 89 |
+
history = self._get_history_text(messages)
|
| 90 |
+
|
| 91 |
+
prompt = f"""
|
| 92 |
+
Tu es un expert RH. Analyse l'échange suivant (phase Comportementale) et extrais l'évaluation des compétences.
|
| 93 |
+
|
| 94 |
+
HISTORIQUE ECHANGE:
|
| 95 |
+
{history}
|
| 96 |
+
|
| 97 |
+
Tâche: Evaluer chaque compétence comportementale abordée via la méthode STAR.
|
| 98 |
+
"""
|
| 99 |
+
|
| 100 |
+
extractor = self.llm.with_structured_output(BehavioralOutput)
|
| 101 |
+
data = extractor.invoke([SystemMessage(content=prompt)])
|
| 102 |
+
|
| 103 |
+
# Calculate scores
|
| 104 |
+
scores = []
|
| 105 |
+
for comp in data.competences_evaluees:
|
| 106 |
+
comp.score = calculate_behavioral_score(comp.competence, comp.indicateurs)
|
| 107 |
+
scores.append(comp.score)
|
| 108 |
+
|
| 109 |
+
for sjt in data.sjt_results:
|
| 110 |
+
if sjt.score_choix is not None and sjt.justification_score is not None:
|
| 111 |
+
sjt.score_sjt = round((sjt.score_choix * 0.6) + (sjt.justification_score * 0.4), 1)
|
| 112 |
+
scores.append(sjt.score_sjt)
|
| 113 |
+
|
| 114 |
+
if scores:
|
| 115 |
+
data.score_comportemental_global = round(sum(scores) / len(scores), 1)
|
| 116 |
+
else:
|
| 117 |
+
data.score_comportemental_global = 0.0
|
| 118 |
+
|
| 119 |
+
return data
|
| 120 |
+
|
| 121 |
+
def extract_situation(self, messages: List[BaseMessage]) -> SituationOutput:
|
| 122 |
+
logger.info("Extracting Situation data...")
|
| 123 |
+
history = self._get_history_text(messages)
|
| 124 |
+
|
| 125 |
+
prompt = f"""
|
| 126 |
+
Tu es un expert technique. Analyse l'échange suivant (phase Mise en Situation) et évalue la performance du candidat.
|
| 127 |
+
|
| 128 |
+
HISTORIQUE ECHANGE:
|
| 129 |
+
{history}
|
| 130 |
+
|
| 131 |
+
Tâche: Remplir la grille d'évaluation de la mise en situation.
|
| 132 |
+
"""
|
| 133 |
+
|
| 134 |
+
extractor = self.llm.with_structured_output(SituationOutput)
|
| 135 |
+
data = extractor.invoke([SystemMessage(content=prompt)])
|
| 136 |
+
|
| 137 |
+
# Calculate score
|
| 138 |
+
data.score_mise_en_situation = calculate_situation_score(data.indicateurs)
|
| 139 |
+
|
| 140 |
+
return data
|
| 141 |
+
|
| 142 |
+
def extract_simulation_report(self,
|
| 143 |
+
messages: List[BaseMessage],
|
| 144 |
+
icebreaker: IceBreakerOutput,
|
| 145 |
+
technical: TechnicalOutput,
|
| 146 |
+
behavioral: BehavioralOutput,
|
| 147 |
+
situation: SituationOutput) -> SimulationReport:
|
| 148 |
+
logger.info("Generating Final Simulation Report...")
|
| 149 |
+
|
| 150 |
+
# We don't necessarily need the whole history if we have structured data,
|
| 151 |
+
# but the LLM might need it for "Synthese".
|
| 152 |
+
# Let's provide a summary of structured data to save tokens.
|
| 153 |
+
|
| 154 |
+
context_data = {
|
| 155 |
+
"icebreaker": icebreaker.dict() if icebreaker else {},
|
| 156 |
+
"technical": technical.dict() if technical else {},
|
| 157 |
+
"behavioral": behavioral.dict() if behavioral else {},
|
| 158 |
+
"situation": situation.dict() if situation else {}
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
prompt = f"""
|
| 162 |
+
Tu es un Expert Recruteur Senior. Rédige le rapport final de l'entretien basé sur les données extraites.
|
| 163 |
+
|
| 164 |
+
DONNÉES STRUCTURÉES (SCORES & INDICATEURS):
|
| 165 |
+
{json.dumps(context_data, ensure_ascii=False)}
|
| 166 |
+
|
| 167 |
+
Tâche:
|
| 168 |
+
1. Calcule le score global (Moyenne pondérée : Technique 40%, Comportemental 30%, Situation 20%, Icebreaker/Soft 10% - ou use ton jugement expert).
|
| 169 |
+
2. Rédige une synthèse du candidat (2-3 phrases).
|
| 170 |
+
3. Liste les points forts et faibles.
|
| 171 |
+
4. Donne une recommandation claire (GO/NO GO).
|
| 172 |
+
5. Rédige un feedback pour le candidat (bienveillant et constructif).
|
| 173 |
+
"""
|
| 174 |
+
|
| 175 |
+
extractor = self.llm.with_structured_output(SimulationReport)
|
| 176 |
+
report = extractor.invoke([SystemMessage(content=prompt)])
|
| 177 |
+
|
| 178 |
+
# Inject the source objects back into the report (optional, as they are part of the model but null in extraction input)
|
| 179 |
+
# Actually LLM might return them null or empty. We should re-attach the real objects.
|
| 180 |
+
report.icebreaker = icebreaker
|
| 181 |
+
report.technical = technical
|
| 182 |
+
report.behavioral = behavioral
|
| 183 |
+
report.situation = situation
|
| 184 |
+
|
| 185 |
+
return report
|
src/services/simulation/schemas.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Optional, Dict, Any
|
| 2 |
+
from pydantic import BaseModel, Field
|
| 3 |
+
|
| 4 |
+
# --- Ice Breaker Agent ---
|
| 5 |
+
|
| 6 |
+
class IceBreakerOutput(BaseModel):
|
| 7 |
+
profil_type: str = Field(..., description="Type de profil (reconversion, étudiant, junior, expérimenté)")
|
| 8 |
+
annees_experience_domaine: str = Field(..., description="Années d'expérience dans le domaine cible (0-2, 2-5, 5+)")
|
| 9 |
+
coherence_parcours: str = Field(..., description="Cohérence parcours -> poste visé (forte, moyenne, faible)")
|
| 10 |
+
motivation_detectee: bool = Field(..., description="Motivation exprimée")
|
| 11 |
+
contexte_specifique: str = Field(..., description="Contexte spécifique du candidat")
|
| 12 |
+
points_a_explorer: List[str] = Field(default_factory=list, description="Points à explorer dans la suite")
|
| 13 |
+
|
| 14 |
+
# --- Technical Agent ---
|
| 15 |
+
|
| 16 |
+
class TechnicalSkillGap(BaseModel):
|
| 17 |
+
skill: str
|
| 18 |
+
niveau_detecte: int = Field(..., description="Niveau détecté (0-4)")
|
| 19 |
+
indicateurs: Dict[str, bool] = Field(..., description="Indicateurs binaires (concept_sous_jacent, experience_liee, outil_adjacent, cas_usage, strategie_montee)")
|
| 20 |
+
transferabilite: str = Field(..., description="Transférabilité (faible, moyenne, forte)")
|
| 21 |
+
questions_posees: List[str] = Field(default_factory=list)
|
| 22 |
+
|
| 23 |
+
class ProjectTechUnderstanding(BaseModel):
|
| 24 |
+
skill: str
|
| 25 |
+
source_projet: str
|
| 26 |
+
score: int = Field(..., description="Score (1-5)")
|
| 27 |
+
indicateurs: Dict[str, bool] = Field(..., description="Indicateurs binaires (justifie_choix, fonctionnement_interne, identifie_limites, propose_alternatives, quantifie_resultats, resolution_probleme)")
|
| 28 |
+
questions_posees: List[str] = Field(default_factory=list)
|
| 29 |
+
|
| 30 |
+
class ValidatedSkill(BaseModel):
|
| 31 |
+
skill: str
|
| 32 |
+
score: int
|
| 33 |
+
source: str
|
| 34 |
+
|
| 35 |
+
class TechnicalOutput(BaseModel):
|
| 36 |
+
competences_validees: List[ValidatedSkill] = Field(default_factory=list)
|
| 37 |
+
lacunes_explorees: List[TechnicalSkillGap] = Field(default_factory=list)
|
| 38 |
+
comprehension_technos_projets: List[ProjectTechUnderstanding] = Field(default_factory=list)
|
| 39 |
+
score_technique_global: float = Field(..., description="Score technique global calculé")
|
| 40 |
+
points_a_explorer_comportemental: List[str] = Field(default_factory=list)
|
| 41 |
+
|
| 42 |
+
# --- Behavioral Agent ---
|
| 43 |
+
|
| 44 |
+
class BehavioralCompetency(BaseModel):
|
| 45 |
+
competence: str
|
| 46 |
+
score: int = Field(..., description="Score (1-5)")
|
| 47 |
+
indicateurs: Dict[str, bool] = Field(..., description="Indicateurs binaires spécifiques à la compétence")
|
| 48 |
+
questions_posees: List[str] = Field(default_factory=list)
|
| 49 |
+
|
| 50 |
+
class SJTResult(BaseModel):
|
| 51 |
+
scenario_id: str
|
| 52 |
+
choix: str
|
| 53 |
+
score_choix: float
|
| 54 |
+
justification_score: float
|
| 55 |
+
score_sjt: float
|
| 56 |
+
|
| 57 |
+
class BehavioralOutput(BaseModel):
|
| 58 |
+
competences_evaluees: List[BehavioralCompetency] = Field(default_factory=list)
|
| 59 |
+
sjt_results: List[SJTResult] = Field(default_factory=list)
|
| 60 |
+
score_comportemental_global: float = Field(..., description="Score comportemental global calculé")
|
| 61 |
+
signaux_forts: List[str] = Field(default_factory=list)
|
| 62 |
+
signaux_faibles: List[str] = Field(default_factory=list)
|
| 63 |
+
points_a_integrer_mise_en_situation: List[str] = Field(default_factory=list)
|
| 64 |
+
|
| 65 |
+
# --- Situation Agent ---
|
| 66 |
+
|
| 67 |
+
class SituationOutput(BaseModel):
|
| 68 |
+
scenario_utilise: str
|
| 69 |
+
score_mise_en_situation: int = Field(..., description="Score sur 5")
|
| 70 |
+
indicateurs: Dict[str, bool] = Field(..., description="Indicateurs (comprehension_probleme, demarche_structuree, pertinence_technique, gestion_contraintes, communication_solution, identification_risques, proposition_alternatives)")
|
| 71 |
+
questions_posees: List[str] = Field(default_factory=list)
|
| 72 |
+
observations: str
|
| 73 |
+
|
| 74 |
+
# --- Full Report ---
|
| 75 |
+
|
| 76 |
+
class SimulationReport(BaseModel):
|
| 77 |
+
icebreaker: Optional[IceBreakerOutput] = None
|
| 78 |
+
technical: Optional[TechnicalOutput] = None
|
| 79 |
+
behavioral: Optional[BehavioralOutput] = None
|
| 80 |
+
situation: Optional[SituationOutput] = None
|
| 81 |
+
|
| 82 |
+
score_global: float = Field(..., description="Score global de l'entretien sur 5")
|
| 83 |
+
synthese_candidat: str = Field(..., description="Synthèse textuelle du profil")
|
| 84 |
+
points_forts: List[str] = Field(default_factory=list, description="Top 3 points forts")
|
| 85 |
+
points_faibles: List[str] = Field(default_factory=list, description="Top 3 points faibles")
|
| 86 |
+
recommandation: str = Field(..., description="GO / NO GO / A CREUSER")
|
| 87 |
+
feedback_candidat: str = Field(..., description="Feedback constructif adressé au candidat")
|
src/services/simulation/scoring.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Dict
|
| 2 |
+
|
| 3 |
+
def calculate_technical_gap_score(indicators: Dict[str, bool]) -> int:
|
| 4 |
+
"""
|
| 5 |
+
Calcule le niveau (0-4) pour une lacune technique basée sur les indicateurs binaires.
|
| 6 |
+
|
| 7 |
+
Poids:
|
| 8 |
+
- concept_sous_jacent: 2
|
| 9 |
+
- experience_liee: 1
|
| 10 |
+
- outil_adjacent: 1
|
| 11 |
+
- cas_usage: 1
|
| 12 |
+
- strategie_montee: 1
|
| 13 |
+
|
| 14 |
+
Mapping:
|
| 15 |
+
- 0 pts -> Niveau 0
|
| 16 |
+
- 1-2 pts -> Niveau 1
|
| 17 |
+
- 3 pts -> Niveau 2
|
| 18 |
+
- 4 pts -> Niveau 3
|
| 19 |
+
- 5-6 pts -> Niveau 4
|
| 20 |
+
"""
|
| 21 |
+
weights = {
|
| 22 |
+
"concept_sous_jacent": 2,
|
| 23 |
+
"experience_liee": 1,
|
| 24 |
+
"outil_adjacent": 1,
|
| 25 |
+
"cas_usage": 1,
|
| 26 |
+
"strategie_montee": 1
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
score = sum(weights.get(k, 0) for k, v in indicators.items() if v)
|
| 30 |
+
|
| 31 |
+
if score == 0: return 0
|
| 32 |
+
if score <= 2: return 1
|
| 33 |
+
if score == 3: return 2
|
| 34 |
+
if score == 4: return 3
|
| 35 |
+
return 4
|
| 36 |
+
|
| 37 |
+
def calculate_project_tech_understanding_score(indicators: Dict[str, bool]) -> int:
|
| 38 |
+
"""
|
| 39 |
+
Calcule le score (1-5) pour la compréhension d'une technologie projet.
|
| 40 |
+
|
| 41 |
+
Poids:
|
| 42 |
+
- justifie_choix: 1
|
| 43 |
+
- fonctionnement_interne: 3
|
| 44 |
+
- identifie_limites: 2
|
| 45 |
+
- propose_alternatives: 2
|
| 46 |
+
- quantifie_resultats: 1
|
| 47 |
+
- resolution_probleme: 2
|
| 48 |
+
|
| 49 |
+
Total max: 11
|
| 50 |
+
|
| 51 |
+
Mapping:
|
| 52 |
+
- 0-2 -> 1/5 (usage superficiel)
|
| 53 |
+
- 3-4 -> 2/5 (usage fonctionnel)
|
| 54 |
+
- 5-6 -> 3/5 (compréhension partielle)
|
| 55 |
+
- 7-9 -> 4/5 (bonne compréhension)
|
| 56 |
+
- 10-11 -> 5/5 (maîtrise démontrée)
|
| 57 |
+
"""
|
| 58 |
+
weights = {
|
| 59 |
+
"justifie_choix": 1,
|
| 60 |
+
"fonctionnement_interne": 3,
|
| 61 |
+
"identifie_limites": 2,
|
| 62 |
+
"propose_alternatives": 2,
|
| 63 |
+
"quantifie_resultats": 1,
|
| 64 |
+
"resolution_probleme": 2
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
score = sum(weights.get(k, 0) for k, v in indicators.items() if v)
|
| 68 |
+
|
| 69 |
+
if score <= 2: return 1
|
| 70 |
+
if score <= 4: return 2
|
| 71 |
+
if score <= 6: return 3
|
| 72 |
+
if score <= 9: return 4
|
| 73 |
+
return 5
|
| 74 |
+
|
| 75 |
+
def calculate_behavioral_score(competence: str, indicators: Dict[str, bool]) -> int:
|
| 76 |
+
"""
|
| 77 |
+
Calcule le score (1-5) pour une compétence comportementale.
|
| 78 |
+
Utilise une pondération spécifique par compétence si définie, sinon une générique.
|
| 79 |
+
Mapping proportionnel au max possible.
|
| 80 |
+
"""
|
| 81 |
+
# Exemples de poids par défaut si non spécifiés dans le code appelant
|
| 82 |
+
# Ici on suppose que les indicateurs passés correspondent à ceux attendus pour la compétence
|
| 83 |
+
# On va utiliser une heuristique simple: 1 point par indicateur si pas de poids spécifique,
|
| 84 |
+
# ou on code en dur les poids des exemples connus.
|
| 85 |
+
|
| 86 |
+
# Poids par défaut pour les exemples connus
|
| 87 |
+
weights_map = {
|
| 88 |
+
"Adaptabilité": {
|
| 89 |
+
"situation_changement": 2,
|
| 90 |
+
"actions_concretes": 2,
|
| 91 |
+
"apprentissage_ajustement": 2,
|
| 92 |
+
"resultat_quantifie": 1,
|
| 93 |
+
"limites_reconnues": 1,
|
| 94 |
+
"transfert_competence": 2
|
| 95 |
+
},
|
| 96 |
+
"Apprentissage autonome": {
|
| 97 |
+
"demarche_auto_formation": 2, # Mapping approximatif des clés JSON
|
| 98 |
+
"ressources_specifiques": 1,
|
| 99 |
+
"progression_mesurable": 2,
|
| 100 |
+
"application_concrete": 2,
|
| 101 |
+
"identifie_reste_a_apprendre": 1
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
# Si la compétence est connue, on utilise ses poids, sinon 1.
|
| 106 |
+
competence_weights = weights_map.get(competence, {})
|
| 107 |
+
|
| 108 |
+
total_score = 0
|
| 109 |
+
max_score = 0
|
| 110 |
+
|
| 111 |
+
for key, val in indicators.items():
|
| 112 |
+
weight = competence_weights.get(key, 1) # Default weight 1 if unknown key
|
| 113 |
+
if val:
|
| 114 |
+
total_score += weight
|
| 115 |
+
max_score += weight
|
| 116 |
+
|
| 117 |
+
if max_score == 0:
|
| 118 |
+
return 3 # Score neutre si aucun indicateur defini
|
| 119 |
+
|
| 120 |
+
ratio = total_score / max_score
|
| 121 |
+
|
| 122 |
+
# Mapping linéaire approximatif vers 1-5
|
| 123 |
+
if ratio <= 0.2: return 1
|
| 124 |
+
if ratio <= 0.4: return 2
|
| 125 |
+
if ratio <= 0.6: return 3
|
| 126 |
+
if ratio <= 0.8: return 4
|
| 127 |
+
return 5
|
| 128 |
+
|
| 129 |
+
def calculate_situation_score(indicators: Dict[str, bool]) -> int:
|
| 130 |
+
"""
|
| 131 |
+
Calcule le score (1-5) pour la mise en situation.
|
| 132 |
+
|
| 133 |
+
Poids:
|
| 134 |
+
- comprehension_probleme: 2
|
| 135 |
+
- demarche_structuree: 3
|
| 136 |
+
- pertinence_technique: 3
|
| 137 |
+
- gestion_contraintes: 2
|
| 138 |
+
- communication_solution: 1
|
| 139 |
+
- identification_risques: 2
|
| 140 |
+
- proposition_alternatives: 1
|
| 141 |
+
|
| 142 |
+
Total max: 14
|
| 143 |
+
|
| 144 |
+
Mapping:
|
| 145 |
+
- 0-3 -> 1/5
|
| 146 |
+
- 4-6 -> 2/5
|
| 147 |
+
- 7-9 -> 3/5
|
| 148 |
+
- 10-12 -> 4/5
|
| 149 |
+
- 13-14 -> 5/5
|
| 150 |
+
"""
|
| 151 |
+
weights = {
|
| 152 |
+
"comprehension_probleme": 2,
|
| 153 |
+
"demarche_structuree": 3,
|
| 154 |
+
"pertinence_technique": 3,
|
| 155 |
+
"gestion_contraintes": 2,
|
| 156 |
+
"communication_solution": 1,
|
| 157 |
+
"identification_risques": 2,
|
| 158 |
+
"proposition_alternatives": 1
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
score = sum(weights.get(k, 0) for k, v in indicators.items() if v)
|
| 162 |
+
|
| 163 |
+
if score <= 3: return 1
|
| 164 |
+
if score <= 6: return 2
|
| 165 |
+
if score <= 9: return 3
|
| 166 |
+
if score <= 12: return 4
|
| 167 |
+
return 5
|
src/tasks.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from src.celery_app import celery_app
|
| 2 |
+
from src.tools.analysis_tools import trigger_interview_analysis
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
logger = logging.getLogger(__name__)
|
| 6 |
+
|
| 7 |
+
@celery_app.task(bind=True, max_retries=3, default_retry_delay=30)
|
| 8 |
+
def run_analysis_task(self, user_id, job_offer_id, job_description, conversation_history, cv_content, cheat_metrics=None, simulation_report=None):
|
| 9 |
+
"""
|
| 10 |
+
Background task to run the feedback analysis.
|
| 11 |
+
"""
|
| 12 |
+
logger.info(f"Starting background analysis for job {job_offer_id}")
|
| 13 |
+
try:
|
| 14 |
+
result = trigger_interview_analysis.invoke({
|
| 15 |
+
"user_id": user_id,
|
| 16 |
+
"job_offer_id": job_offer_id,
|
| 17 |
+
"job_description": job_description,
|
| 18 |
+
"conversation_history": conversation_history,
|
| 19 |
+
"cv_content": cv_content,
|
| 20 |
+
"cheat_metrics": cheat_metrics or {},
|
| 21 |
+
"simulation_report": simulation_report
|
| 22 |
+
})
|
| 23 |
+
logger.info("Background analysis completed successfully")
|
| 24 |
+
return result
|
| 25 |
+
except Exception as e:
|
| 26 |
+
logger.error(f"Background analysis failed (attempt {self.request.retries + 1}): {e}")
|
| 27 |
+
raise self.retry(exc=e)
|
src/tools/analysis_tools.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 import BaseModel, Field
|
| 8 |
+
from typing import List, Dict, Any, Optional
|
| 9 |
+
import httpx
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
BACKEND_API_URL = os.getenv("BACKEND_API_URL", "http://localhost:8000")
|
| 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.")
|
| 18 |
+
job_offer_id: str = Field(..., description="The unique identifier for the job offer.")
|
| 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 |
+
cv_content: str = Field(..., description="The content of the candidate's CV (JSON string or text).")
|
| 22 |
+
cheat_metrics: Optional[Dict[str, Any]] = Field(default=None, description="Metrics related to copy-paste behavior.")
|
| 23 |
+
simulation_report: Optional[Dict[str, Any]] = Field(default=None, description="Pre-computed structured simulation report.")
|
| 24 |
+
|
| 25 |
+
@tool("trigger_interview_analysis", args_schema=InterviewAnalysisArgs)
|
| 26 |
+
def trigger_interview_analysis(user_id: str, job_offer_id: str, job_description: str, conversation_history: List[Dict[str, Any]], cv_content: str, cheat_metrics: Dict[str, Any] = None, simulation_report: Dict[str, Any] = None):
|
| 27 |
+
"""
|
| 28 |
+
Call this tool to end the interview and launch the final analysis.
|
| 29 |
+
Arguments: user_id, job_offer_id, job_description, conversation_history, cv_content.
|
| 30 |
+
"""
|
| 31 |
+
try:
|
| 32 |
+
logger.info(f"Tool 'trigger_interview_analysis' called for user_id: {user_id}")
|
| 33 |
+
|
| 34 |
+
analysis_service = AnalysisService()
|
| 35 |
+
|
| 36 |
+
feedback_data = analysis_service.run_analysis(
|
| 37 |
+
conversation_history=conversation_history,
|
| 38 |
+
job_description=job_description,
|
| 39 |
+
cv_content=cv_content,
|
| 40 |
+
cheat_metrics=cheat_metrics,
|
| 41 |
+
simulation_report=simulation_report
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
feedback_payload = {
|
| 45 |
+
"user_id": user_id,
|
| 46 |
+
"interview_id": job_offer_id,
|
| 47 |
+
"feedback_content": feedback_data,
|
| 48 |
+
"feedback_date": datetime.utcnow().isoformat()
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
try:
|
| 52 |
+
response = httpx.post(f"{BACKEND_API_URL}/api/v1/feedback/", json=feedback_payload, timeout=30.0)
|
| 53 |
+
response.raise_for_status()
|
| 54 |
+
logger.info("Feedback saved to Backend API successfully.")
|
| 55 |
+
except Exception as api_err:
|
| 56 |
+
logger.error(f"Failed to save feedback to API: {api_err}")
|
| 57 |
+
|
| 58 |
+
return "Analysis triggered and completed successfully."
|
| 59 |
+
|
| 60 |
+
except Exception as e:
|
| 61 |
+
logger.error(f"Error in analysis tool: {e}", exc_info=True)
|
| 62 |
+
return "An error occurred while launching the analysis."
|
tests/test_search_service.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import pytest
|
| 3 |
+
from src.services.search_service import SearchService
|
| 4 |
+
|
| 5 |
+
class TestSearchService:
|
| 6 |
+
def setup_method(self):
|
| 7 |
+
self.service = SearchService()
|
| 8 |
+
|
| 9 |
+
def test_detect_job_type(self):
|
| 10 |
+
assert self.service._detect_job_type("Cherche Senior Data Scientist NLP") == "DATA_SCIENTIST"
|
| 11 |
+
assert self.service._detect_job_type("Offre Data Analyst PowerBI") == "DATA_ANALYST"
|
| 12 |
+
assert self.service._detect_job_type("Besoin d'un ML Engineer pour la prod") == "DATA_ENGINEER"
|
| 13 |
+
assert self.service._detect_job_type("Développeur Python Backend") == "GENERAL_TECH"
|
| 14 |
+
|
| 15 |
+
def test_extract_action_verbs_found(self):
|
| 16 |
+
text = "J'ai pu optimiser les performances et déployer le modèle en production."
|
| 17 |
+
verbs = self.service._extract_action_verbs(text, self.service.VERB_MAPPINGS["DATA_ENGINEER"])
|
| 18 |
+
assert "optimiser" in verbs
|
| 19 |
+
assert "déployer" in verbs
|
| 20 |
+
assert len(verbs) >= 2
|
| 21 |
+
|
| 22 |
+
def test_extract_action_verbs_analyst(self):
|
| 23 |
+
text = "J'ai réalisé des visualisations et présenté des insights."
|
| 24 |
+
verbs = self.service._extract_action_verbs(text, self.service.VERB_MAPPINGS["DATA_ANALYST"])
|
| 25 |
+
assert "visualiser" in verbs or "présenter" in verbs
|
| 26 |
+
|
| 27 |
+
def test_extract_action_verbs_none(self):
|
| 28 |
+
text = "J'ai fait de la gestion de projet et de la rédaction."
|
| 29 |
+
verbs = self.service._extract_action_verbs(text, self.service.VERB_MAPPINGS["DATA_ENGINEER"])
|
| 30 |
+
assert len(verbs) == 0
|
| 31 |
+
|
| 32 |
+
def test_detect_reconversion_true(self):
|
| 33 |
+
cv_text = "Après un bootcamp intensif en Data Science, je cherche..."
|
| 34 |
+
job_desc = "Cherche Data Scientist junior."
|
| 35 |
+
is_reconversion, reason = self.service._detect_reconversion(cv_text, job_desc)
|
| 36 |
+
assert is_reconversion is True
|
| 37 |
+
assert "bootcamp" in reason
|
| 38 |
+
|
| 39 |
+
def test_detect_reconversion_false(self):
|
| 40 |
+
cv_text = "Ingénieur diplomé avec 5 ans d'expérience."
|
| 41 |
+
job_desc = "Cherche Senior Dev."
|
| 42 |
+
is_reconversion, reason = self.service._detect_reconversion(cv_text, job_desc)
|
| 43 |
+
assert is_reconversion is False
|
| 44 |
+
assert "Parcours classique" in reason
|
| 45 |
+
|
| 46 |
+
def test_analyze_gap_structure(self):
|
| 47 |
+
cv_text = "Développeur Python avec expérience en optimiser et monitorer."
|
| 48 |
+
job_desc = "Cherche expert Python pour optimiser le backend."
|
| 49 |
+
|
| 50 |
+
result = self.service.analyze_gap(cv_text, job_desc)
|
| 51 |
+
|
| 52 |
+
assert "semantic_score" in result
|
| 53 |
+
assert "production_mindset_score" in result
|
| 54 |
+
assert "is_reconversion" in result
|
| 55 |
+
assert "production_verbs_found" in result
|
| 56 |
+
assert "optimiser" in result["production_verbs_found"]
|
tests/test_simulation_flow.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import json
|
| 4 |
+
import logging
|
| 5 |
+
from dotenv import load_dotenv
|
| 6 |
+
|
| 7 |
+
# Add src to path
|
| 8 |
+
sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
|
| 9 |
+
|
| 10 |
+
from src.services.graph_service import GraphInterviewProcessor
|
| 11 |
+
|
| 12 |
+
# Force re-configuration of logging
|
| 13 |
+
for handler in logging.root.handlers[:]:
|
| 14 |
+
logging.root.removeHandler(handler)
|
| 15 |
+
|
| 16 |
+
logging.basicConfig(
|
| 17 |
+
level=logging.INFO,
|
| 18 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
| 19 |
+
filename='simulation_test.log',
|
| 20 |
+
filemode='w'
|
| 21 |
+
)
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
load_dotenv()
|
| 25 |
+
|
| 26 |
+
# Mock data
|
| 27 |
+
USER_ID = "test_user"
|
| 28 |
+
JOB_OFFER_ID = "test_job"
|
| 29 |
+
CV_DATA = {
|
| 30 |
+
"candidat": {
|
| 31 |
+
"info_personnelle": {"nom": "Doe", "prenom": "John"},
|
| 32 |
+
"reconversion": {"is_reconversion": False},
|
| 33 |
+
"etudiant": {
|
| 34 |
+
"is_etudiant": True,
|
| 35 |
+
"niveau_etudes": "Master 2",
|
| 36 |
+
"specialite": "IA",
|
| 37 |
+
"latest_education_end_date": "2025"
|
| 38 |
+
},
|
| 39 |
+
"expériences": [{"poste": "Dev", "entreprise": "TestCorp", "durée": "2 ans"}],
|
| 40 |
+
"compétences": {
|
| 41 |
+
"hard_skills": ["Python", "Docker"],
|
| 42 |
+
"soft_skills": ["Curiosité"],
|
| 43 |
+
"skills_with_context": [
|
| 44 |
+
{"skill": "Python", "context": "Projet académique"},
|
| 45 |
+
{"skill": "Docker", "context": "Stage"}
|
| 46 |
+
]
|
| 47 |
+
},
|
| 48 |
+
"projets": {
|
| 49 |
+
"professional": [
|
| 50 |
+
{
|
| 51 |
+
"title": "Projet A",
|
| 52 |
+
"technologies": ["Python", "Flask"],
|
| 53 |
+
"outcomes": ["API déployée", "User base +10%"]
|
| 54 |
+
}
|
| 55 |
+
],
|
| 56 |
+
"personal": []
|
| 57 |
+
},
|
| 58 |
+
"centres_interet": ["Football", "Voyage"]
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
JOB_OFFER = {
|
| 62 |
+
"poste": "Backend Developer",
|
| 63 |
+
"entreprise": "AIRH",
|
| 64 |
+
"mission": "Develop API",
|
| 65 |
+
"profil_recherche": "Passionné",
|
| 66 |
+
"competences": "Python, FastAPI"
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
PAYLOAD = {
|
| 70 |
+
"user_id": USER_ID,
|
| 71 |
+
"job_offer_id": JOB_OFFER_ID,
|
| 72 |
+
"cv_document": CV_DATA,
|
| 73 |
+
"job_offer": JOB_OFFER,
|
| 74 |
+
"messages": []
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
def run_simulation_start():
|
| 78 |
+
logger.info("Initializing Processor for START SCENARIO...")
|
| 79 |
+
processor = GraphInterviewProcessor(PAYLOAD)
|
| 80 |
+
|
| 81 |
+
# 0 messages -> Should trigger IceBreaker first message
|
| 82 |
+
output = processor.invoke([])
|
| 83 |
+
logger.info(f"\n=== START OF INTERVIEW ===")
|
| 84 |
+
logger.info(f"Agent Response: {output['response']}")
|
| 85 |
+
logger.info(f"Status: {output['status']}")
|
| 86 |
+
|
| 87 |
+
def run_simulation_end():
|
| 88 |
+
logger.info("Initializing Processor for END SCENARIO...")
|
| 89 |
+
processor = GraphInterviewProcessor(PAYLOAD)
|
| 90 |
+
|
| 91 |
+
# Simulate a history with 10 user messages (Completed flow)
|
| 92 |
+
# The Orchestrator counts only HUMAN messages.
|
| 93 |
+
conversation = []
|
| 94 |
+
for i in range(10):
|
| 95 |
+
conversation.append({"role": "user", "content": f"Response {i+1}"})
|
| 96 |
+
conversation.append({"role": "assistant", "content": f"Question {i+2}"})
|
| 97 |
+
|
| 98 |
+
logger.info(f"\n=== TRIGGERING END OF INTERVIEW (10 User Messages) ===")
|
| 99 |
+
output = processor.invoke(conversation)
|
| 100 |
+
logger.info(f"Final Agent Response: {output['response'][:100]}...")
|
| 101 |
+
logger.info(f"Final Status: {output['status']}")
|
| 102 |
+
|
| 103 |
+
if __name__ == "__main__":
|
| 104 |
+
try:
|
| 105 |
+
run_simulation_start()
|
| 106 |
+
# run_simulation_end()
|
| 107 |
+
except Exception as e:
|
| 108 |
+
logger.error(f"Simulation failed: {e}", exc_info=True)
|
tools/__init__.py
ADDED
|
File without changes
|
tools/analysis_tools.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 import BaseModel, Field
|
| 8 |
+
from typing import List, Dict, Any
|
| 9 |
+
import httpx
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
BACKEND_API_URL = os.getenv("BACKEND_API_URL", "http://localhost:8000")
|
| 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.")
|
| 18 |
+
job_offer_id: str = Field(..., description="The unique identifier for the job offer.")
|
| 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 |
+
cv_content: str = Field(..., description="The content of the candidate's CV (JSON string or text).")
|
| 22 |
+
cheat_metrics: Dict[str, Any] = Field(None, description="Metrics related to copy-paste behavior.")
|
| 23 |
+
|
| 24 |
+
@tool("trigger_interview_analysis", args_schema=InterviewAnalysisArgs)
|
| 25 |
+
def trigger_interview_analysis(user_id: str, job_offer_id: str, job_description: str, conversation_history: List[Dict[str, Any]], cv_content: str, cheat_metrics: Dict[str, Any] = None):
|
| 26 |
+
"""
|
| 27 |
+
Call this tool to end the interview and launch the final analysis.
|
| 28 |
+
Arguments: user_id, job_offer_id, job_description, conversation_history, cv_content.
|
| 29 |
+
"""
|
| 30 |
+
try:
|
| 31 |
+
logger.info(f"Tool 'trigger_interview_analysis' called for user_id: {user_id}")
|
| 32 |
+
|
| 33 |
+
analysis_service = AnalysisService()
|
| 34 |
+
|
| 35 |
+
feedback_data = analysis_service.run_analysis(
|
| 36 |
+
conversation_history=conversation_history,
|
| 37 |
+
job_description=job_description,
|
| 38 |
+
cv_content=cv_content,
|
| 39 |
+
cheat_metrics=cheat_metrics
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
feedback_payload = {
|
| 43 |
+
"user_id": user_id,
|
| 44 |
+
"interview_id": job_offer_id,
|
| 45 |
+
"feedback_content": feedback_data,
|
| 46 |
+
"feedback_date": datetime.utcnow().isoformat()
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
try:
|
| 50 |
+
response = httpx.post(f"{BACKEND_API_URL}/api/v1/feedback/", json=feedback_payload, timeout=30.0)
|
| 51 |
+
response.raise_for_status()
|
| 52 |
+
logger.info("Feedback saved to Backend API successfully.")
|
| 53 |
+
except Exception as api_err:
|
| 54 |
+
logger.error(f"Failed to save feedback to API: {api_err}")
|
| 55 |
+
|
| 56 |
+
return "Analysis triggered and completed successfully."
|
| 57 |
+
|
| 58 |
+
except Exception as e:
|
| 59 |
+
logger.error(f"Error in analysis tool: {e}", exc_info=True)
|
| 60 |
+
return "An error occurred while launching the analysis."
|