quentinL52 commited on
Commit
4e9b744
·
0 Parent(s):

Initial commit

Browse files
.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."