diff --git a/.env.example b/.env.example deleted file mode 100644 index f761de68f5216e458b023389cefbce37f61d0d3c..0000000000000000000000000000000000000000 --- a/.env.example +++ /dev/null @@ -1,18 +0,0 @@ -# ======================== -# LLM API KEYS -# ======================== -OPENAI_API_KEY=your_openai_api_key -LANGTRACE_API_KEY=your_langtrace_api_key - -# ======================== -# REDIS -# ======================== -REDIS_URL=redis://default:password@redis-host:port/0 - -# ======================== -# BACKEND API -# ======================== -# Development -BACKEND_API_URL=http://localhost:8000 -# Production (uncomment for deployment) -# BACKEND_API_URL=https://api.yoursite.com diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..a6344aac8c09253b3b630fb776ae94478aa0275b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,35 @@ +*.7z filter=lfs diff=lfs merge=lfs -text +*.arrow filter=lfs diff=lfs merge=lfs -text +*.bin filter=lfs diff=lfs merge=lfs -text +*.bz2 filter=lfs diff=lfs merge=lfs -text +*.ckpt filter=lfs diff=lfs merge=lfs -text +*.ftz filter=lfs diff=lfs merge=lfs -text +*.gz filter=lfs diff=lfs merge=lfs -text +*.h5 filter=lfs diff=lfs merge=lfs -text +*.joblib filter=lfs diff=lfs merge=lfs -text +*.lfs.* filter=lfs diff=lfs merge=lfs -text +*.mlmodel filter=lfs diff=lfs merge=lfs -text +*.model filter=lfs diff=lfs merge=lfs -text +*.msgpack filter=lfs diff=lfs merge=lfs -text +*.npy filter=lfs diff=lfs merge=lfs -text +*.npz filter=lfs diff=lfs merge=lfs -text +*.onnx filter=lfs diff=lfs merge=lfs -text +*.ot filter=lfs diff=lfs merge=lfs -text +*.parquet filter=lfs diff=lfs merge=lfs -text +*.pb filter=lfs diff=lfs merge=lfs -text +*.pickle filter=lfs diff=lfs merge=lfs -text +*.pkl filter=lfs diff=lfs merge=lfs -text +*.pt filter=lfs diff=lfs merge=lfs -text +*.pth filter=lfs diff=lfs merge=lfs -text +*.rar filter=lfs diff=lfs merge=lfs -text +*.safetensors filter=lfs diff=lfs merge=lfs -text +saved_model/**/* filter=lfs diff=lfs merge=lfs -text +*.tar.* filter=lfs diff=lfs merge=lfs -text +*.tar filter=lfs diff=lfs merge=lfs -text +*.tflite filter=lfs diff=lfs merge=lfs -text +*.tgz filter=lfs diff=lfs merge=lfs -text +*.wasm filter=lfs diff=lfs merge=lfs -text +*.xz filter=lfs diff=lfs merge=lfs -text +*.zip filter=lfs diff=lfs merge=lfs -text +*.zst filter=lfs diff=lfs merge=lfs -text +*tfevents* filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore deleted file mode 100644 index faf0856d78b190f9b37f2b9cf4783b80979e46f5..0000000000000000000000000000000000000000 --- a/.gitignore +++ /dev/null @@ -1,34 +0,0 @@ -# Environnement -.env -.env.* -!.env.example -venv/ -env/ -ENV/ -.venv/ - -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python - -# Tests -.pytest_cache/ -.coverage -htmlcov/ - -# IDE -.vscode/ -.idea/ -*.swp - -# Distribution -dist/ -build/ -*.egg-info/ - -# OS -.DS_Store -Thumbs.db diff --git a/Dockerfile b/Dockerfile index a24a0f06ec0b679cc3464591eb3b6fb3109a0c2e..1bbad4abbbfae743d6d14dd104a081ea6f896198 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,22 +1,44 @@ FROM python:3.11-slim -RUN useradd -m -u 1000 user -USER user -ENV PATH="/home/user/.local/bin:$PATH" +# Variables d'environnement +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PYTHONPATH=/app -ENV NLTK_DATA="/home/user/nltk_data" +# Variables pour les modèles ML - utilisation de /tmp (toujours writable) +ENV HF_HOME=/tmp/cache \ + TRANSFORMERS_CACHE=/tmp/cache \ + HF_HUB_CACHE=/tmp/cache/hub \ + SENTENCE_TRANSFORMERS_HOME=/tmp/cache/sentence_transformers +# Répertoire de travail WORKDIR /app -COPY --chown=user ./requirements.txt requirements.txt -RUN pip install --no-cache-dir --upgrade -r requirements.txt +# Installer les dépendances système +RUN apt-get update && apt-get install -y \ + curl \ + && rm -rf /var/lib/apt/lists/* -RUN mkdir -p /home/user/nltk_data && \ - python -m textblob.download_corpora && \ - python -m nltk.downloader punkt_tab +# Installer uv +RUN pip install uv -COPY --chown=user . /app -RUN chmod +x /app/start.sh +# Copier et installer les dépendances Python +COPY requirements.txt . +RUN uv pip install --system --no-cache -r requirements.txt +# Copier le code source +COPY . . + +# Créer les répertoires de cache dans /tmp (toujours writable) +RUN mkdir -p /tmp/cache/hub \ + /tmp/cache/sentence_transformers \ + /tmp/vector_store && \ + chmod -R 777 /tmp + +# Exposer le port HF Spaces EXPOSE 7860 -CMD ["./start.sh"] \ No newline at end of file + +# Commande de démarrage +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"] \ No newline at end of file diff --git a/README.md b/README.md index 7294a01748fcbcb6b5ff98a8b474921be5ab5631..d21000f9a29a163a9895502316a2b7ee2820f07d 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,10 @@ --- -title: Interview Agents API -emoji: 🎤 -colorFrom: blue -colorTo: purple +title: Interview Agents Api +emoji: 🏃 +colorFrom: green +colorTo: pink sdk: docker -app_file: main.py pinned: false --- -# Interview Agents API - -API de simulation d'entretiens avec agents IA utilisant LangGraph. - -## Description - -Cette API permet de simuler des entretiens d'embauche avec des agents IA intelligents. - -## Configuration - -Assurez-vous de configurer vos variables d'environnement dans les Settings du Space Hugging Face. - -## Documentation - -Une documentation détaillée de l'API (points d'entrée, schémas de données et exemples) est disponible dans le fichier [API_DOCUMENTATION.md](API_DOCUMENTATION.md). \ No newline at end of file +Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference diff --git a/documentation/API_DOCUMENTATION.md b/documentation/API_DOCUMENTATION.md deleted file mode 100644 index 9b13e7304eab0c485454d5068a4afc2caf2ad0ba..0000000000000000000000000000000000000000 --- a/documentation/API_DOCUMENTATION.md +++ /dev/null @@ -1,126 +0,0 @@ -# Interview Simulation API Documentation - -Cette documentation détaille l'utilisation de l'API de simulation d'entretiens, motorisée par LangGraph et FastAPI. - -## Introduction - -L'**Interview Simulation API** permet de mener des simulations d'entretiens d'embauche réalistes. Elle utilise un système d'agents spécialisés (Icebreaker, Auditeur, Enquêteur, Stratège, Projecteur) pour évaluer un candidat par rapport à une offre d'emploi spécifique. - -**Base URL:** `http://localhost:7860` (ou URL de déploiement) -**Version:** `1.0.0` - -## Configuration & Authentification - -L'API utilise des variables d'environnement pour la configuration : -- `OPENAI_API_KEY`: Requis pour les agents LLM. -- `LANGTRACE_API_KEY`: Pour l'observabilité (Langtrace). -- `CORS_ORIGINS`: Liste des origines autorisées (défaut: `*`). - -## Endpoints - -### 1. Health Check -Vérifie que l'API est opérationnelle. - -- **URL:** `/` -- **Method:** `GET` -- **Success Response (200 OK):** - ```json - { - "status": "ok" - } - ``` - -### 2. Simulate Interview -Déclenche ou poursuit une simulation d'entretien. - -- **URL:** `/simulate-interview/` -- **Method:** `POST` -- **Request Body:** - ```json - { - "user_id": "string", // ID unique de l'utilisateur - "job_offer_id": "string", // ID de l'offre d'emploi - "cv_document": { // Données extraites du CV - "candidat": { ... } // Voir section "Data Models" - }, - "job_offer": { // Détails de l'offre - "poste": "string", - "entreprise": "string", - "mission": "string", - ... - }, - "messages": [ // Historique de la conversation (optionnel) - { - "role": "user", - "content": "Bonjour" - }, - ... - ], - "cheat_metrics": { ... } // Métriques anti-triche (optionnel) - } - ``` - -- **Success Response (200 OK):** - ```json - { - "response": "Texte généré par l'agent IA", - "status": "interviewing" | "interview_finished" - } - ``` - -- **Error Responses:** - - `400 Bad Request`: Payload incomplet ou données invalides. - - `500 Internal Server Error`: Erreur lors de l'exécution du graph LangGraph. - -## Data Models (Schemas) - -### Feedback Output (Output final de l'analyse) -Une fois l'entretien terminé, un rapport complet est généré (via Celery) suivant cette structure : - -- **CandidatFeedback**: Points forts, axes d'amélioration, conseils, score global. -- **EntrepriseInsights**: - - **Dashboard**: Technique, Cognitive, Comportementale (0-100). - - **Decision**: RECRUTER, APPROFONDIR ou REJETER. - - **Fraud Detection**: Score global d'usage d'IA, mots-clés détectés, alertes (red flags). - -## Exemples d'Usage - -### Exemple cURL -```bash -curl -X POST http://localhost:7860/simulate-interview/ \ - -H "Content-Type: application/json" \ - -d '{ - "user_id": "user_123", - "job_offer_id": "job_456", - "cv_document": { "candidat": { "first_name": "Jean", "expériences": [] } }, - "job_offer": { "poste": "Développeur Python", "entreprise": "TechCorp" }, - "messages": [] - }' -``` - -### Exemple Python (Requests) -```python -import requests - -url = "http://localhost:7860/simulate-interview/" -payload = { - "user_id": "user_123", - "job_offer_id": "job_456", - "cv_document": { "candidat": { "first_name": "Jean", "expériences": [] } }, - "job_offer": { "poste": "Développeur Python", "entreprise": "TechCorp" }, - "messages": [{"role": "user", "content": "Je suis prêt."}] -} - -response = requests.post(url, json=payload) -print(response.json()) -``` - -## Error Handling - -Toutes les erreurs renvoient un format JSON standardisé : -```json -{ - "error": "Description de l'erreur" -} -``` - diff --git a/documentation/cv_structure.json b/documentation/cv_structure.json deleted file mode 100644 index 1c758b8f3776ad0b84bd95ff3f013bcc368b5f2f..0000000000000000000000000000000000000000 --- a/documentation/cv_structure.json +++ /dev/null @@ -1,121 +0,0 @@ -{ - "_id": { - "$oid": "69946f80c8bcf8b152b53a6a" - }, - "user_id": "user_...", - "parsed_data": { - "candidat": { - "first_name": "...", - "compétences": { - "hard_skills": [ - "...", - "..." - ], - "soft_skills": [ - "...", - "..." - ], - "skills_with_context": [ - { - "skill": "...", - "context": "entreprise" - }, - { - "skill": "...", - "context": "projet" - }, - { - "skill": "...", - "context": "academique" - }, - { - "skill": "...", - "context": "sans contexte" - } - ] - }, - "expériences": [ - { - "Poste": "...", - "Entreprise": "...", - "start_date": "Déc. 2024", - "end_date": "Déc. 2025", - "responsabilités": [ - "..." - ] - }, - { - "Poste": "...", - "Entreprise": "...", - "start_date": "2010", - "end_date": "2023", - "responsabilités": [ - "...", - "..." - ] - } - ], - "reconversion": { - "is_reconversion": true, - "context": "..." - }, - "projets": { - "professional": [ - { - "title": "...", - "technologies": [ - "...", - "..." - ], - "outcomes": [ - "...", - "..." - ], - "domaine metier": "..." - }, - { - "title": "...", - "technologies": [ - "...", - "..." - ], - "outcomes": [ - "..." - ], - "domaine metier": "..." - } - ], - "personal": [] - }, - "formations": [ - { - "degree": "...", - "institution": "...", - "start_date": "Nov. 2024", - "end_date": "Déc. 2025" - }, - { - "degree": "...", - "institution": "...", - "start_date": "Fév. 2024", - "end_date": "Juil. 2024" - } - ], - "etudiant": { - "is_etudiant": false, - "niveau_etudes": "bac+3", - "specialite": "...", - "latest_education_end_date": "2025-12-01" - }, - "langues": [ - { - "langue": "Français" - }, - { - "langue": "Anglais" - } - ] - } - }, - "upload_date": "2026-02-17T13:39:12.984629" -} \ No newline at end of file diff --git a/documentation/job_offer_structure.json b/documentation/job_offer_structure.json deleted file mode 100644 index 9d0c8feee782fe814a8e8b5430063db746d3264e..0000000000000000000000000000000000000000 --- a/documentation/job_offer_structure.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "entreprise": "Carrefour", - "ville": "Massy", - "poste": "Data Scientist Assortiment Personnalisé", - "contrat": "Stage", - "description_poste": "Nos atouts pour y parvenir ?\n\nUn réseau multi format, multi métiers avec des collaborateurs passionnés, qui s'engagent, pour réussir la transition alimentaire pour tous.\n\nStage Data Scientist - Assortiment Personnalisé (F/H)\n\nL'utilisation de la data et le développement des capacités analytiques sont un axe majeur du plan stratégique du Groupe Carrefour à horizon 2027. Directement rattachée au COMEX France, l'Analytics Factory de Carrefour France est le pilier de cette transformation analytique, au service des clients et de l'ensemble des équipes métier de Carrefour France - Marketing, Marchandises, Exploitation, E-commerce, Supply Chain, Services Financiers, etc...\n\nNous utilisons des approches de machine learning pour résoudre des défis commerciaux et opérationnels comme les ruptures en magasin, la prévision des ventes promotionnelles, l'optimisation de l'assortiment des produits et les recommandations personnalisées sur le site carrefour.fr\n\nVos Missions\n\nAu cours de ces dernières années, l'équipe Assortiment de l'Analytics Factory a développé des solutions robustes pour la gestion de l'assortiment de produits, tant au niveau national pour la gestion des négociations annuelles avec les fournisseurs, qu'au niveau local de façon à décliner de façon optimale l'assortiment de produits proposés par chaque magasin.\n\nCette déclinaison s'opère en termes de nombre de références pour chaque catégorie de produits avec l'objectif de maximiser le chiffre d'affaires des magasins ; elle est réalisée une fois par an.\n\nAujourd'hui, notre réflexion s'oriente vers une personnalisation plus fine de l'assortiment, en cherchant à aligner les préférences clients propres à chaque magasin avec les caractéristiques produits. Nous souhaitons également introduire davantage d'agilité dans la gestion locale de l'offre, en permettant une optimisation continue tout au long de l'année.\n\nLe stage de fin d'études (d'une durée de 4 à 6 mois) que nous proposons consiste à \nexplorer les modèles de choix discrets, une approche économétrique de la modélisation des choix les plus probables que peuvent faire un ensemble d'individus parmi un nombre fini de possibilités.\n\nCette approche repose sur la définition d'une fonction d'utilité associée à chaque produit, combinant des attributs produits (prix, format, marque, catégorie, etc.) et des variables contextuelles (profil du magasin, saisonnalité, environnement concurrentiel...).\n\nLes paramètres de ces fonctions seront estimés par maximum de vraisemblance, \npermettant d'interpréter les sensibilités des clients aux différents attributs et d'identifier les leviers de préférence par segment ou par typologie de magasin.\n\nDifférentes spécifications pourront être testées - logit multinomial, nested logit, ou modèles mixtes - afin d'évaluer le compromis entre interprétabilité et performance prédictive.\n\nVous serez intégré·e à une équipe dynamique, innovatrice, aux profils diversifiés et \ncomplémentaires, fondée sur l'entraide, le partage de connaissances, la prise d'initiatives ; vous serez co-encadré·e par deux data scientists, qui vous guideront tout au long de votre \nstage.\n\nVotre Profil\n\nÉtudiant en Master 2 Data Science / Intelligence Artificielle / Statistiques, passionné par l'exploitation des données pour la prise de décision et la création de modèles prédictifs. Vous recherchez un stage de fin d'étude de 4 à 6 mois.\n\nÀ l'issue de vos années de formation et à travers vos différents projets, vous avez développé de solides connaissances théoriques et pratiques sur les algorithmes d'apprentissage statistique.\n\nVous aimez aborder de nouveaux défis de machine learning dans différents domaines, lire des publications scientifiques. Et vous appréciez tout autant l'implémentation et l'optimisation des solutions. La qualité et la simplicité du code vous tiennent à coeur.\n\nEnfin, vous appréciez de travailler avec des collaborateurs d'horizons différents, et il est agréable de travailler avec vous au jour le jour !\n\nNotre environnement technique \nGCP, Python, SQL, BigQuery, dbt, Kubernetes, Terraform, Airflow\n\nVos petits plus \nAppétence pour l'exploration et l'expérimentation scientifique, rigueur dans les analyses et la modélisation, souci de produire un code optimal tant en termes de performance que de maintenabilité, intérêt pour l'utilisation raisonnée d'un assistant de code.\n\nInformations complémentaires\n\n- Date de début : Mars 2026\n- Durée: 6 mois\n- Localisation: Massy (91) - RER B/RER C Massy-Palaiseau\n- Avantages : 50% du titre de transport pris en charge par Carrefour\n- Autres avantages propres au campus/site : parking, restauration, salle de sport, conciergerie...\n\nChez Carrefour, nous avons à coeur de ne passer à côté d'aucun talent et sommes fiers de compter des équipes représentatives de la société dans son ensemble. Nous encourageons ainsi tous types de profils à postuler à cette offre et garantissons un processus de recrutement dénué de toutes formes de discriminations.", - "publication": "23/10/2025", - "lien": "https://www.hellowork.com/fr-fr/emplois/71884111.html", - "id": "001abed6-0ab6-4a8c-8bf8-bd1dc1d6e862", - "description_nettoyee": "Stage de fin d’études (4 à 6 mois) de Data Scientist au sein de l’Analytics Factory de Carrefour France, à Massy (91). Le stagiaire travaillera sur la personnalisation de l’assortiment produit en développant et testant des modèles de choix discrets (logit multinomial, nested logit, modèles mixtes) afin d’estimer les fonctions d’utilité, d’interpréter les sensibilités clients et d’optimiser l’offre magasin tout au long de l’année.", - "mission": "- Explorer et implémenter des modèles de choix discrets pour la personnalisation de l’assortiment.\n- Définir les fonctions d’utilité en combinant attributs produits et variables contextuelles.\n- Estimer les paramètres par maximum de vraisemblance et analyser les sensibilités par segment ou typologie de magasin.\n- Tester différentes spécifications (logit multinomial, nested logit, modèles mixtes) et évaluer le compromis interprétabilité/performance.\n- Interpréter les résultats pour identifier les leviers de préférence et proposer des recommandations d’optimisation continue.\n- Collaborer avec deux data scientists et l’équipe Analytics Factory, en partageant connaissances et bonnes pratiques.", - "profil_recherche": "- Étudiant(e) en Master 2 Data Science, Intelligence Artificielle ou Statistiques.\n- Solides connaissances théoriques et pratiques des algorithmes d’apprentissage statistique.\n- Intérêt pour le machine learning, la lecture de publications scientifiques et l’expérimentation.\n- Souci de la qualité, de la simplicité et de la maintenabilité du code.\n- Capacité à travailler en équipe avec des profils divers et à prendre des initiatives.", - "competences": "- Langages et outils : Python, SQL, GCP, BigQuery, dbt, Kubernetes, Terraform, Airflow.\n- Modélisation : modèles de choix discrets, logit multinomial, nested logit, modèles mixtes, estimation par maximum de vraisemblance.\n- Analyse statistique et machine learning.\n- Rigueur scientifique, expérimentation, optimisation du code.", - "pole": "Analytics Factory – pôle d’excellence analytique de Carrefour France, dédié à la transformation data‑driven au service des métiers (marketing, marchandises, e‑commerce, supply chain, etc.)." -} \ No newline at end of file diff --git a/knowledge_base/soft_skills_feedback.md b/knowledge_base/soft_skills_feedback.md new file mode 100644 index 0000000000000000000000000000000000000000..1984295a84f36dd97e863f658c4308b6bb53f150 --- /dev/null +++ b/knowledge_base/soft_skills_feedback.md @@ -0,0 +1,87 @@ +# Guide pour le Feedback sur les Soft Skills + +Ce guide fournit des observations courantes et des conseils constructifs pour aider les candidats à améliorer leurs soft skills lors des entretiens d'embauche. + +## Communication + +**Observation** : Le candidat a du mal à structurer ses réponses ou semble décousu. + +**Conseil à fournir** : "Pour mieux mettre en valeur votre expérience, essayez de structurer vos réponses avec la méthode STAR (Situation, Tâche, Action, Résultat). Par exemple, lorsque vous parlez d'un projet, commencez par décrire le contexte, puis votre rôle, les actions que vous avez menées, et enfin les résultats que vous avez obtenus. Cela rendra votre discours plus clair et percutant." + +**Observation** : Le candidat utilise un langage trop technique ou du jargon. + +**Conseil à fournir** : "Votre expertise technique est évidente. Pour vous assurer que tous vos interlocuteurs comprennent bien l'impact de votre travail, pensez à vulgariser certains concepts. Expliquer un projet complexe en des termes simples est une compétence très appréciée. Entraînez-vous à expliquer votre travail à quelqu'un qui n'est pas du même domaine." + +**Observation** : Le candidat ne pose pas de questions ou ne montre pas de curiosité. + +**Conseil à fournir** : "Un entretien est aussi une opportunité pour vous de poser des questions sur le poste, l'équipe ou la culture d'entreprise. Cela démontre votre intérêt et votre proactivité. Préparez quelques questions en amont pour la fin de l'entretien." + +## Esprit d'équipe + +**Observation** : Le candidat utilise beaucoup le "je" et mentionne peu ses collaborateurs. + +**Conseil à fournir** : "N'hésitez pas à mentionner comment vous avez collaboré avec les autres membres de votre équipe. Parler des succès collectifs et de votre rôle au sein du groupe montre que vous savez travailler en équipe, une qualité essentielle dans la plupart des entreprises. Mettez en avant les synergies et les contributions mutuelles." + +**Observation** : Le candidat ne parle pas de situations de conflit ou de désaccord en équipe. + +**Conseil à fournir** : "Les désaccords font partie de la vie d'équipe. Expliquer comment vous avez géré une situation de conflit ou de désaccord avec un collègue, et comment vous avez contribué à trouver une solution, peut démontrer votre maturité et vos compétences en résolution de problèmes interpersonnels." + +## Proactivité et Prise d'initiative + +**Observation** : Le candidat décrit ses tâches de manière passive, sans mentionner de contributions personnelles. + +**Conseil à fournir** : "Pensez à des moments où vous avez pris une initiative, même petite. Avez-vous suggéré une amélioration ? Avez-vous identifié un problème avant qu'il ne devienne critique ? Mettre en avant ces situations démontre votre proactivité et votre engagement. Montrez que vous êtes force de proposition." + +**Observation** : Le candidat attend d'être sollicité pour agir. + +**Conseil à fournir** : "Les recruteurs apprécient les profils qui ne se contentent pas d'exécuter. Décrivez des situations où vous avez anticipé un besoin, proposé une solution avant qu'on ne vous le demande, ou pris des responsabilités supplémentaires. Cela illustre votre autonomie et votre sens des responsabilités." + +## Gestion du Stress + +**Observation** : Le candidat semble visiblement stressé, ce qui affecte ses réponses. + +**Conseil à fournir** : "Il est tout à fait normal de ressentir du stress en entretien. Pour vous aider, n'hésitez pas à prendre une seconde pour respirer avant de répondre. Si une question vous surprend, vous pouvez dire 'C'est une excellente question, laissez-moi un instant pour y réfléchir'. Cela montre que vous prenez le temps de construire une réponse pertinente et que vous gérez la pression." + +**Observation** : Le candidat perd ses moyens face à une question inattendue ou difficile. + +**Conseil à fournir** : "Face à une question déstabilisante, il est préférable de demander des précisions ou de reformuler la question pour s'assurer de bien la comprendre. Vous pouvez aussi demander un court instant pour organiser vos idées. Cela est perçu positivement et montre votre capacité à gérer l'incertitude." + +## Adaptabilité + +**Observation** : Le candidat a du mal à parler de changements ou de situations imprévues. + +**Conseil à fournir** : "Le monde du travail évolue rapidement. Mettre en avant des situations où vous avez dû vous adapter à de nouvelles technologies, de nouvelles méthodes de travail, ou des changements d'équipe, démontre votre flexibilité. Expliquez comment vous avez géré ces transitions et ce que vous en avez appris." + +**Observation** : Le candidat semble rigide dans ses approches ou ses idées. + +**Conseil à fournir** : "Montrez que vous êtes ouvert aux nouvelles idées et aux retours. Décrivez une situation où vous avez dû changer d'avis ou modifier votre approche suite à de nouvelles informations ou un feedback. Cela prouve votre capacité à évoluer et à apprendre." + +## Pensée Critique / Résolution de Problèmes + +**Observation** : Le candidat décrit des problèmes sans détailler sa démarche de résolution. + +**Conseil à fournir** : "Lorsque vous parlez d'un problème rencontré, ne vous contentez pas de décrire le problème et la solution. Expliquez votre processus de réflexion : comment avez-vous analysé la situation ? Quelles options avez-vous envisagées ? Pourquoi avez-vous choisi cette solution spécifique ? Quels ont été les résultats ? Cela met en lumière votre pensée critique." + +**Observation** : Le candidat ne semble pas analyser les causes profondes des problèmes. + +**Conseil à fournir** : "Les recruteurs recherchent des personnes capables d'aller au-delà des symptômes. Lorsque vous décrivez un défi, essayez d'identifier la cause racine du problème et comment votre solution y a remédié durablement. Cela montre une approche plus stratégique." + +## Leadership + +**Observation** : Le candidat parle de son rôle dans un projet sans mentionner comment il a influencé ou guidé les autres. + +**Conseil à fournir** : "Le leadership ne se limite pas à un titre. Pensez à des moments où vous avez inspiré vos collègues, résolu des blocages pour l'équipe, ou pris la responsabilité d'un livrable important. Même sans être manager, vous pouvez démontrer des qualités de leader en montrant comment vous avez contribué à faire avancer le groupe." + +**Observation** : Le candidat ne mentionne pas de situations où il a dû prendre des décisions difficiles. + +**Conseil à fournir** : "Les leaders sont souvent confrontés à des choix complexes. Décrivez une situation où vous avez dû prendre une décision difficile, expliquez le contexte, les options, votre raisonnement et l'impact de cette décision. Cela met en évidence votre capacité à assumer des responsabilités." + +## Gestion du Temps / Organisation + +**Observation** : Le candidat semble désorganisé dans ses réponses ou ne mentionne pas de méthodes de travail. + +**Conseil à fournir** : "Parlez de la manière dont vous organisez votre travail, gérez vos priorités et respectez les délais. Mentionnez des outils ou des méthodes (ex: to-do lists, gestion de projet agile) que vous utilisez. Cela rassure sur votre capacité à être efficace et autonome." + +**Observation** : Le candidat a du mal à gérer plusieurs tâches ou projets simultanément. + +**Conseil à fournir** : "Décrivez une situation où vous avez dû jongler avec plusieurs responsabilités. Expliquez comment vous avez priorisé, délégué si possible, et maintenu la qualité de votre travail. Cela démontre votre capacité à gérer la charge de travail et à rester performant sous pression." \ No newline at end of file diff --git a/main.py b/main.py index 66cb569c6e19d1e4eadb1cfba83c3101e3d7c79a..5ee65aa177e96b1ef95f56ee4cb3fb35610992ab 100644 --- a/main.py +++ b/main.py @@ -1,82 +1,270 @@ +import tempfile +import requests import os import logging -from fastapi import FastAPI, Request, HTTPException -from fastapi.responses import JSONResponse +from fastapi import FastAPI, UploadFile, File, HTTPException +from fastapi.concurrency import run_in_threadpool from fastapi.middleware.cors import CORSMiddleware -from slowapi import Limiter, _rate_limit_exceeded_handler -from slowapi.util import get_remote_address -from slowapi.errors import RateLimitExceeded -from pydantic import BaseModel -from dotenv import load_dotenv +from pydantic import BaseModel, Field +from typing import List, Dict, Any, Optional +os.environ['HOME'] = '/tmp' +# Configuration du logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) -load_dotenv() +# Imports avec gestion d'erreurs robuste +try: + from src.cv_parsing_agents import CvParserAgent, create_fallback_cv_data + CV_PARSING_AVAILABLE = True + logger.info("✅ CV Parsing disponible") +except Exception as e: + logger.error(f"❌ CV Parsing indisponible: {e}") + CV_PARSING_AVAILABLE = False + CvParserAgent = None + create_fallback_cv_data = None -from src.services.graph_service import GraphInterviewProcessor +try: + from src.interview_simulator.entretient_version_prod import InterviewProcessor + INTERVIEW_AVAILABLE = True + logger.info("✅ Interview Simulator disponible") +except Exception as e: + logger.error(f"❌ Interview Simulator indisponible: {e}") + INTERVIEW_AVAILABLE = False + InterviewProcessor = None -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) +try: + from src.scoring_engine import ContextualScoringEngine + SCORING_AVAILABLE = True + logger.info("✅ Scoring Engine disponible") +except Exception as e: + logger.error(f"❌ Scoring Engine indisponible: {e}") + SCORING_AVAILABLE = False + ContextualScoringEngine = None +# Application FastAPI app = FastAPI( - title="Interview Simulation API", - description="API for interview simulations.", - version="1.0.0", + title="AIrh Interview Assistant", + description="API pour l'analyse de CV et la simulation d'entretiens d'embauche", + version="1.3.0", docs_url="/docs", - redoc_url="/redoc", - redirect_slashes=True, + redoc_url="/redoc" ) -ALLOWED_ORIGINS = os.getenv("CORS_ORIGINS", "http://localhost:3000,http://localhost:5173,http://localhost:8000").split(",") - +# Configuration CORS pour HF Spaces app.add_middleware( CORSMiddleware, - allow_origins=ALLOWED_ORIGINS, + allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) -limiter = Limiter(key_func=get_remote_address) -app.state.limiter = limiter -app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) +# Configuration API Celery +CELERY_API_URL = os.getenv("CELERY_API_URL", "https://celery-7as1.onrender.com") + +# Modèles Pydantic +class InterviewRequest(BaseModel): + user_id: str = Field(..., example="user_12345") + job_offer_id: str = Field(..., example="job_offer_abcde") + cv_document: Dict[str, Any] + job_offer: Dict[str, Any] + messages: List[Dict[str, Any]] + conversation_history: List[Dict[str, Any]] + +class AnalysisRequest(BaseModel): + conversation_history: List[Dict[str, Any]] + job_description_text: str + candidate_id: Optional[str] = None + +class TaskResponse(BaseModel): + task_id: str + status: str + result: Any = None + message: Optional[str] = None class HealthCheck(BaseModel): status: str = "ok" + celery_api_status: Optional[str] = None + services: Dict[str, bool] = Field(default_factory=dict) + message: str = "API AIrh fonctionnelle" +# Endpoints @app.get("/", response_model=HealthCheck, tags=["Status"]) async def health_check(): - return HealthCheck() - -@app.post("/simulate-interview/") -@limiter.limit("5/minute") -async def simulate_interview(request: Request): - """ - This endpoint receives the interview data, instantiates the graph processor - and starts the conversation. - """ + """Health check de l'API avec test de connectivité Celery.""" + + # Test connexion Celery + celery_status = "unknown" try: - payload = await request.json() - - if not all(k in payload for k in ["user_id", "job_offer_id", "cv_document", "job_offer"]): - raise HTTPException(status_code=400, detail="Missing data in payload (user_id, job_offer_id, cv_document, job_offer).") + response = requests.get(f"{CELERY_API_URL}/", timeout=5) + celery_status = "connected" if response.status_code == 200 else "error" + except Exception: + celery_status = "disconnected" + + services = { + "cv_parsing": CV_PARSING_AVAILABLE, + "interview_simulation": INTERVIEW_AVAILABLE, + "scoring_engine": SCORING_AVAILABLE, + "celery_api": celery_status == "connected" + } + + return HealthCheck( + celery_api_status=celery_status, + services=services + ) - logger.info(f"Starting simulation for user: {payload['user_id']}") +@app.post("/parse-cv/", tags=["CV Parsing"]) +async def parse_cv(file: UploadFile = File(...)): + """Analyse un CV PDF et extrait les informations structurées.""" + + if not CV_PARSING_AVAILABLE: + # Fallback si le parsing n'est pas disponible + return create_fallback_cv_data() if create_fallback_cv_data else { + "error": "Service de parsing de CV temporairement indisponible", + "candidat": { + "informations_personnelles": {"nom": "Test User"}, + "compétences": {"hard_skills": [], "soft_skills": []} + } + } + + if file.content_type != "application/pdf": + raise HTTPException(status_code=400, detail="Fichier PDF requis") + + tmp_path = None + try: + # Sauvegarder le fichier temporairement + contents = await file.read() + with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp: + tmp.write(contents) + tmp_path = tmp.name + + # Traiter le CV + cv_agent = CvParserAgent(pdf_path=tmp_path) + parsed_data = await run_in_threadpool(cv_agent.process) + + if not parsed_data and create_fallback_cv_data: + parsed_data = create_fallback_cv_data(tmp_path) + + # Scoring si disponible + if SCORING_AVAILABLE and ContextualScoringEngine and parsed_data: + try: + scoring_engine = ContextualScoringEngine(parsed_data) + scored_data = await run_in_threadpool(scoring_engine.calculate_scores) + if parsed_data.get("candidat"): + parsed_data["candidat"].update(scored_data) + except Exception as e: + logger.warning(f"Scoring échoué: {e}") + + return parsed_data + + except Exception as e: + logger.error(f"Erreur parsing CV: {e}") + if create_fallback_cv_data: + return create_fallback_cv_data(tmp_path) + raise HTTPException(status_code=500, detail=str(e)) + + finally: + if tmp_path and os.path.exists(tmp_path): + try: + os.remove(tmp_path) + except Exception: + pass - processor = GraphInterviewProcessor(payload) - result = processor.invoke(payload.get("messages", []), cheat_metrics=payload.get("cheat_metrics")) +@app.post("/simulate-interview/", tags=["Interview"]) +async def simulate_interview(request: InterviewRequest): + """Gère une conversation d'entretien d'embauche.""" + + if not INTERVIEW_AVAILABLE: + raise HTTPException( + status_code=503, + detail="Service de simulation d'entretien indisponible" + ) + + try: + processor = InterviewProcessor( + cv_document=request.cv_document, + job_offer=request.job_offer, + conversation_history=request.conversation_history + ) + + result = await run_in_threadpool(processor.run, messages=request.messages) + return {"response": result["messages"][-1].content} + + except Exception as e: + logger.error(f"Erreur simulation entretien: {e}") + raise HTTPException(status_code=500, detail=str(e)) - return JSONResponse(content=result) +@app.post("/trigger-analysis/", response_model=TaskResponse, status_code=202, tags=["Analysis"]) +async def trigger_analysis(request: AnalysisRequest): + """Déclenche une analyse asynchrone via l'API Celery.""" + + try: + response = requests.post( + f"{CELERY_API_URL}/trigger-analysis", + json=request.dict(), + headers={"Content-Type": "application/json"}, + timeout=30 + ) + + if response.status_code == 202: + data = response.json() + return TaskResponse( + task_id=data["task_id"], + status=data["status"], + message="Analyse démarrée" + ) + else: + raise HTTPException(status_code=503, detail="Service d'analyse indisponible") + + except requests.RequestException: + raise HTTPException(status_code=503, detail="API Celery inaccessible") + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) - except ValueError as ve: - logger.error(f"Data validation error: {ve}", exc_info=True) - return JSONResponse(content={"error": str(ve)}, status_code=400) +@app.get("/analysis-status/{task_id}", response_model=TaskResponse, tags=["Analysis"]) +async def get_analysis_status(task_id: str): + """Récupère le statut d'une analyse.""" + + try: + response = requests.get(f"{CELERY_API_URL}/task-status/{task_id}", timeout=10) + + if response.status_code == 200: + data = response.json() + return TaskResponse( + task_id=task_id, + status=data["status"], + result=data.get("result"), + message=data.get("progress", "Statut récupéré") + ) + else: + raise HTTPException(status_code=503, detail="Service d'analyse indisponible") + + except requests.RequestException: + raise HTTPException(status_code=503, detail="API Celery inaccessible") except Exception as e: - logger.error(f"Internal error in simulate-interview endpoint: {e}", exc_info=True) - return JSONResponse( - content={"error": "An internal error occurred on the assistant's server."}, - status_code=500 - ) + raise HTTPException(status_code=500, detail=str(e)) + +# Endpoint de debug pour HF Spaces +@app.get("/debug", tags=["Debug"]) +async def debug_info(): + """Informations de debug pour le déploiement.""" + return { + "environment": { + "HF_HOME": os.getenv("HF_HOME"), + "CELERY_API_URL": CELERY_API_URL, + "PYTHONPATH": os.getenv("PYTHONPATH") + }, + "services": { + "cv_parsing": CV_PARSING_AVAILABLE, + "interview_simulation": INTERVIEW_AVAILABLE, + "scoring_engine": SCORING_AVAILABLE + }, + "cache_dirs": { + "/tmp/cache": os.path.exists("/tmp/cache"), + "/app/cache": os.path.exists("/app/cache") + } + } if __name__ == "__main__": import uvicorn - port = int(os.getenv("PORT", 7860)) - uvicorn.run(app, host="0.0.0.0", port=port) + uvicorn.run(app, host="0.0.0.0", port=7860) \ No newline at end of file diff --git a/prompts/rag_prompt.txt b/prompts/rag_prompt.txt new file mode 100644 index 0000000000000000000000000000000000000000..67dd4e2d533995c1fc065d44bd2c194cb6fafb3b --- /dev/null +++ b/prompts/rag_prompt.txt @@ -0,0 +1,39 @@ +Tu es un assistant RH expert qui aide à l'analyse d'offres d'emploi et à la préparation d'entretiens. +Ton rôle est de te comporter comme dans un entretien pour un poste. + +Tu as accès aux informations suivantes sur le poste actuel : + entreprise : {entreprise} + poste : {poste} + description : {description} + +Les informations sur le candidat sont : + cv : {cv} + + +Tu as accès au CV d'un candidat appelle-le toujours par son nom et utilise les informations de son CV {cv} pour lui poser des questions +ou avoir des précisions si nécessaire. +Identifie clairement experience professionnelle et projet, et ne confond pas les 2. +Essaye d'evaluer les compétences et skills d'un candidat en fonction de ses projets, si par exemple le candidat a simplement travaillé sur un dashboard +powerBi ne considére pas cela comme une experience solide. +À partir des informations de {description}, tu devras élaborer une série de questions pour le candidat. +Pose exactement les questions une par une. +Attends la réponse du candidat avant de poser la question suivante. + +Commence l'entretien par te présenter avec une formule de politesse. +Tu devras te présenter avec un nom choisi aléatoirement, présenter l'entreprise et introduire la mission. +Introduis les besoins de l'entreprise en analysant les informations contenues dans {poste}. +Évite d'introduire les questions en parlant de 'questions' maintient toujours une conversation le plus naturelle possible. +Après ta présentation demande toujours dans un premier temps au candidat de se présenter et de présenter son parcours. + +Tu dois toujours te mettre dans la situation d'un recruteur et adapter ton langage selon si c'est une femme ou un homme. +Introduis toujours les informations de {description} comme si tu représentais l'entreprise et tu étais déjà au courant de ces infos. +N'oublie pas de varier la structure de tes phrases et utilise des expressions comme 'D'accord', 'Je vois', 'C'est intéressant' pour montrer que tu écoutes activement. +Adopte un ton décontracté et évite le jargon RH trop formel. +Au lieu de dire 'Pouvez-vous me parler de...', essaye plutôt 'Racontez-moi un peu...' ou 'J'aimerais en savoir plus sur... +Tu devras poser les questions et communiquer de la manière la plus humaine possible. +Tu devras adapter l'entretien au profil du candidat. + +Quand tu estimes que l'entretien est terminé et que tu as assez d'informations, utilise l'outil `interview_analyser` pour conclure et lancer l'analyse du feedback. +Termine toujours l'entretien par une phrase de politesse, positive. +Ne fais pas d'analyse, elle est faite par une équipe d'agents, contente-toi seulement d'occuper ton rôle de recruteur. +**À la fin de l'entretien, après ta dernière phrase de politesse, conclus toujours par : nous allons maintenant passer a l'analyse ** \ No newline at end of file diff --git a/prompts/rag_prompt_old.txt b/prompts/rag_prompt_old.txt new file mode 100644 index 0000000000000000000000000000000000000000..32f0201ace1f4bd00049664d0f5a8fb23d6859ae --- /dev/null +++ b/prompts/rag_prompt_old.txt @@ -0,0 +1,35 @@ +Tu es un recruteur expert, menant un premier entretien de qualification. Ton ton est professionnel mais engageant. Ta mission est d'évaluer l'adéquation d'un candidat pour un poste. + +CONTEXTE DE L'ENTRETIEN +Tu dois baser ta conversation sur les informations suivantes : + +1. Informations à utiliser activement dans la conversation : +Entreprise : {entreprise} +Intitulé du poste : {poste} +Équipe / Pôle : {pole} +Missions principales : {mission} + +2. Informations pour guider tes questions (à ne PAS mentionner directement) : +Profil recherché : {profil_recherche} +Compétences clés attendues : {competences} +(Utilise ces deux points comme une grille d'analyse interne pour formuler des questions pertinentes. Tes questions doivent permettre de vérifier si le candidat possède ces compétences et correspond au profil.) +3. Informations sur le candidat : +Les données de son CV sont : {cv} + +DIRECTIVES PRÉCISES + +1. Déroulement de l'entretien : +Introduction : Commence par te présenter avec un prénom (ex: Camille, Thomas...). Présente l'entreprise ({entreprise}) et le contexte du recrutement en t'appuyant sur l'intitulé du poste ({poste}) et les missions ({mission}). +Présentation du candidat : Ta toute première question doit inviter le candidat à se présenter. Par exemple : "Pour commencer, parlez-moi un peu de votre parcours." +Questions ciblées : En te basant sur les compétences et le profil recherché (que tu gardes en tête), pose des questions ouvertes pour évaluer le candidat. Fais des liens entre ses expériences ({cv}) et les missions du poste ({mission}). Par exemple, si une compétence attendue est "l'analyse de données", demande au candidat de décrire un projet où il a dû analyser un ensemble de données complexe. +Une question à la fois : Pose une seule question à la fois et attends la réponse complète du candidat avant de poursuivre. + +2. Style et Comportement : +Personnalisation : Appelle toujours le candidat par son nom (présent dans le CV). +Langage Naturel : Évite le jargon RH. Utilise des formulations fluides comme "J'ai noté dans votre CV que...", "Racontez-moi l'expérience chez...". Montre que tu écoutes avec des relances comme "D'accord, je vois.", "C'est intéressant.". +Évaluation subtile : Ne dis jamais "la compétence requise est...". À la place, évalue la compétence à travers des questions situationnelles ou comportementales. + +3. Conclusion de l'entretien : +Quand tu estimes avoir assez d'informations, conclus l'échange de manière positive. +Termine par une phrase de politesse. +Action finale OBLIGATOIRE : Ta toute dernière phrase, après la politesse, doit être exactement : "nous allons maintenant passer a l'analyse". Juste après, tu dois utiliser l'outil interview_analyser. \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 5b2dfc8afce42ffcbc3333996f9d3e6c6d4b8da7..c4bf10209b1e5acca644dbd389bc7a7e29f05d15 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,22 +1,25 @@ -langchain +fastapi +uvicorn[standard] +pydantic +python-multipart + +langchain-core +langchain-community langchain-openai +langchain_groq +langchain-huggingface langgraph -langchain-core -pydantic -fastapi -uvicorn -python-dotenv crewai -langtrace-python-sdk -celery -redis -langchain-community +crewai-tools +sentence_transformers +torch transformers -torch --extra-index-url https://download.pytorch.org/whl/cpu -scikit-learn -textstat -chromadb -sentence-transformers -numpy -textblob -slowapi \ No newline at end of file +sentencepiece +accelerate +pypdf +python-dotenv + +requests +faiss-cpu + +httpx==0.28.1 \ No newline at end of file diff --git a/src/services/simulation/__init__.py b/src/__init__.py similarity index 100% rename from src/services/simulation/__init__.py rename to src/__init__.py diff --git a/src/__pycache__/__init__.cpython-312.pyc b/src/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..195e78871ba69e5d1fc2be11fd230e35d0a8e502 Binary files /dev/null and b/src/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/__pycache__/config.cpython-312.pyc b/src/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1fe5bd2019eefd35fbfd5aead1dbe119c32318fb Binary files /dev/null and b/src/__pycache__/config.cpython-312.pyc differ diff --git a/src/__pycache__/cv_parsing_agents.cpython-312.pyc b/src/__pycache__/cv_parsing_agents.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..67983ed1eb2f672e5733831627704eb6f6d01917 Binary files /dev/null and b/src/__pycache__/cv_parsing_agents.cpython-312.pyc differ diff --git a/src/__pycache__/deep_learning_analyzer.cpython-312.pyc b/src/__pycache__/deep_learning_analyzer.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..99476ae740836bac6e7359551a53d2c64c3607d8 Binary files /dev/null and b/src/__pycache__/deep_learning_analyzer.cpython-312.pyc differ diff --git a/src/celery_app.py b/src/celery_app.py deleted file mode 100644 index 4a8e298bbcc8c6f16ac92350bcb24c7df6f0dc2b..0000000000000000000000000000000000000000 --- a/src/celery_app.py +++ /dev/null @@ -1,29 +0,0 @@ -import os -from celery import Celery -from dotenv import load_dotenv - -load_dotenv() - -redis_url = os.getenv("REDIS_URL") - -celery_app = Celery( - "interview_simulation_api", - broker=redis_url, - backend=redis_url, - include=['src.tasks'] -) - -celery_app.conf.update( - task_serializer="json", - accept_content=["json"], - result_serializer="json", - timezone="Europe/Paris", - enable_utc=True, - task_track_started=True, - broker_connection_retry_on_startup=True, - broker_transport_options={ - "visibility_timeout": 3600, - "socket_timeout": 30, # Increase socket timeout - "socket_connect_timeout": 30 - } -) diff --git a/src/config.py b/src/config.py index 4350c6aaf04f2b082e40fc6c3183ac4036b91844..d93b93b2070ee935de1b59b8199df70715e12ade 100644 --- a/src/config.py +++ b/src/config.py @@ -1,16 +1,74 @@ -import os -from dotenv import load_dotenv - -load_dotenv() - -from langchain_openai import ChatOpenAI - -OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") - -def crew_openai(): - """Returns a ChatOpenAI instance configured for CrewAI feedback analysis.""" - return ChatOpenAI( - model="gpt-4o-mini", - temperature=0.1, - api_key=OPENAI_API_KEY - ) +import os +from dotenv import load_dotenv +load_dotenv() +from langchain_groq import ChatGroq +from langchain_community.document_loaders import PyPDFLoader +from langchain_openai import ChatOpenAI +from typing import Dict, List, Any, Tuple, Optional, Type +from crewai import LLM +######################################################################################################### +# formatage du json +def format_cv(document): + def format_section(title, data, indent=0): + prefix = " " * indent + lines = [f"{title}:"] + if isinstance(data, dict): + for k, v in data.items(): + if isinstance(v, (dict, list)): + lines.append(f"{prefix}- {k.capitalize()}:") + lines.extend(format_section("", v, indent + 1)) + else: + lines.append(f"{prefix}- {k.capitalize()}: {v}") + elif isinstance(data, list): + for i, item in enumerate(data): + lines.append(f"{prefix}- Élément {i + 1}:") + lines.extend(format_section("", item, indent + 1)) + else: + lines.append(f"{prefix}- {data}") + return lines + sections = [] + for section_name, content in document.items(): + title = section_name.replace("_", " ").capitalize() + sections.extend(format_section(title, content)) + sections.append("") + return "\n".join(sections) + + +def read_system_prompt(file_path): + with open(file_path, 'r', encoding='utf-8') as file: + return file.read() + +def load_pdf(pdf_path): + loader = PyPDFLoader(pdf_path) + pages = loader.load_and_split() + cv_text = "" + for page in pages: + cv_text += page.page_content + "\n\n" + return cv_text + +######################################################################################################### +# modéles + +"""GEMINI_API_KEY = os.getenv("GOOGLE_API_KEY") +model_google = "gemini/gemma-3-27b-it" +def chat_gemini(): + llm = ChatGoogleGenerativeAI("gemini/gemma-3-27b-it")""" + +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") +model_openai = "gpt-4o" + +def crew_openai(): + llm = ChatOpenAI( + model="gpt-4o-mini", + temperature=0.1, + api_key=OPENAI_API_KEY + ) + return llm + +def chat_openai(): + llm = ChatOpenAI( + model="gpt-4o", + temperature=0.6, + api_key=OPENAI_API_KEY + ) + return llm diff --git a/tools/__init__.py b/src/crew/__init__.py similarity index 100% rename from tools/__init__.py rename to src/crew/__init__.py diff --git a/src/crew/__pycache__/__init__.cpython-311.pyc b/src/crew/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8f2fa709d8dfdc50e1bdf6759842913184578aaa Binary files /dev/null and b/src/crew/__pycache__/__init__.cpython-311.pyc differ diff --git a/src/crew/__pycache__/__init__.cpython-312.pyc b/src/crew/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..992c816ae6c8081084a7edb0e8495cf99e143a3b Binary files /dev/null and b/src/crew/__pycache__/__init__.cpython-312.pyc differ diff --git "a/src/crew/__pycache__/__init__.cpython-312.pyc\357\200\272Zone.Identifier" "b/src/crew/__pycache__/__init__.cpython-312.pyc\357\200\272Zone.Identifier" new file mode 100644 index 0000000000000000000000000000000000000000..a45e1ac4c0c673681b8767bd60e9ab3cdb7dd8d7 --- /dev/null +++ "b/src/crew/__pycache__/__init__.cpython-312.pyc\357\200\272Zone.Identifier" @@ -0,0 +1,2 @@ +[ZoneTransfer] +ZoneId=3 diff --git a/src/crew/__pycache__/agents.cpython-311.pyc b/src/crew/__pycache__/agents.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ec7ba4f356f460d1acd1496ca3d6d2837901961e Binary files /dev/null and b/src/crew/__pycache__/agents.cpython-311.pyc differ diff --git a/src/crew/__pycache__/agents.cpython-312.pyc b/src/crew/__pycache__/agents.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..02c971a6c5e5457b1f6bbc767169dbb6c35f7cf9 Binary files /dev/null and b/src/crew/__pycache__/agents.cpython-312.pyc differ diff --git "a/src/crew/__pycache__/agents.cpython-312.pyc\357\200\272Zone.Identifier" "b/src/crew/__pycache__/agents.cpython-312.pyc\357\200\272Zone.Identifier" new file mode 100644 index 0000000000000000000000000000000000000000..a45e1ac4c0c673681b8767bd60e9ab3cdb7dd8d7 --- /dev/null +++ "b/src/crew/__pycache__/agents.cpython-312.pyc\357\200\272Zone.Identifier" @@ -0,0 +1,2 @@ +[ZoneTransfer] +ZoneId=3 diff --git a/src/crew/__pycache__/analysis_crew.cpython-312.pyc b/src/crew/__pycache__/analysis_crew.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..43c92f06f9c1470195a8dd6ac7b7a88ff01c60a3 Binary files /dev/null and b/src/crew/__pycache__/analysis_crew.cpython-312.pyc differ diff --git "a/src/crew/__pycache__/analysis_crew.cpython-312.pyc\357\200\272Zone.Identifier" "b/src/crew/__pycache__/analysis_crew.cpython-312.pyc\357\200\272Zone.Identifier" new file mode 100644 index 0000000000000000000000000000000000000000..a45e1ac4c0c673681b8767bd60e9ab3cdb7dd8d7 --- /dev/null +++ "b/src/crew/__pycache__/analysis_crew.cpython-312.pyc\357\200\272Zone.Identifier" @@ -0,0 +1,2 @@ +[ZoneTransfer] +ZoneId=3 diff --git a/src/crew/__pycache__/crew_pool.cpython-311.pyc b/src/crew/__pycache__/crew_pool.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8b99f68649fd01564c08d3efce718c7dc1090ab7 Binary files /dev/null and b/src/crew/__pycache__/crew_pool.cpython-311.pyc differ diff --git a/src/crew/__pycache__/crew_pool.cpython-312.pyc b/src/crew/__pycache__/crew_pool.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e8c036cfd7e49c27c89ba14fab0d787ed12f67c9 Binary files /dev/null and b/src/crew/__pycache__/crew_pool.cpython-312.pyc differ diff --git "a/src/crew/__pycache__/crew_pool.cpython-312.pyc\357\200\272Zone.Identifier" "b/src/crew/__pycache__/crew_pool.cpython-312.pyc\357\200\272Zone.Identifier" new file mode 100644 index 0000000000000000000000000000000000000000..a45e1ac4c0c673681b8767bd60e9ab3cdb7dd8d7 --- /dev/null +++ "b/src/crew/__pycache__/crew_pool.cpython-312.pyc\357\200\272Zone.Identifier" @@ -0,0 +1,2 @@ +[ZoneTransfer] +ZoneId=3 diff --git a/src/crew/__pycache__/tasks.cpython-312.pyc b/src/crew/__pycache__/tasks.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..35f171b077c597b61f1ea55ce5eebbd70014e9fb Binary files /dev/null and b/src/crew/__pycache__/tasks.cpython-312.pyc differ diff --git "a/src/crew/__pycache__/tasks.cpython-312.pyc\357\200\272Zone.Identifier" "b/src/crew/__pycache__/tasks.cpython-312.pyc\357\200\272Zone.Identifier" new file mode 100644 index 0000000000000000000000000000000000000000..a45e1ac4c0c673681b8767bd60e9ab3cdb7dd8d7 --- /dev/null +++ "b/src/crew/__pycache__/tasks.cpython-312.pyc\357\200\272Zone.Identifier" @@ -0,0 +1,2 @@ +[ZoneTransfer] +ZoneId=3 diff --git a/src/crew/agents.py b/src/crew/agents.py new file mode 100644 index 0000000000000000000000000000000000000000..1a840018341b3e44a2022f95931b640cd379592f --- /dev/null +++ b/src/crew/agents.py @@ -0,0 +1,75 @@ +from crewai import Agent +from crewai import LLM +from src.config import crew_openai + +LLM_agent = crew_openai() + +# Interview Simulation Agents +report_generator_agent = Agent( + role='Rédacteur de Rapports Synthétiques', + goal='Générer un feedback pertinent, a partir du deroulement de lentretient', + backstory=( + "Sepcialisé dans le recrutement et les ressources humaines, capable d'evaluer les candidats" + "sur la communication et la pertinences des reponses en fonction des questions posées, redige" + "en un rapport clair, un feedback détaillé sur le candidat." + ), + allow_delegation=False, + verbose=False, + llm=LLM_agent +) + +# CV Parsing Agents +skills_extractor_agent = Agent( + role="Spécialiste de l'extraction de compétences (hard & soft skills)", + goal="Identifier et extraire toutes les compétences pertinentes du CV.", + backstory="Vous êtes un spécialiste des compétences techniques et comportementales. Votre mission est de parcourir les CV et de lister de manière exhaustive toutes les compétences mentionnées.", + verbose=False, + llm=LLM_agent +) +experience_extractor_agent = Agent( + role="Expert en extraction d'expérience professionnelle", + goal="Extraire en détail l'expérience professionnelle du candidat.", + backstory="Vous êtes un expert en recrutement spécialisé dans l'analyse des parcours professionnels. Vous devez extraire chaque expérience de manière précise, en notant les rôles, les entreprises, les dates et les responsabilités.", + verbose=False, + llm=LLM_agent +) +project_extractor_agent = Agent( + role="Spécialiste de l'identification de projets (pro & perso)", + goal="Identifier et décrire les projets significatifs mentionnés.", + backstory="Vous êtes passionné par l'innovation et les réalisations. Votre rôle est de repérer et de décrire les projets professionnels et personnels qui mettent en lumière les compétences et l'initiative des candidats.", + verbose=False, + llm=LLM_agent +) +education_extractor_agent = Agent( + role="Expert en extraction d'informations sur la formation", + goal="Extraire les détails des études et des diplômes obtenus.", + backstory="Vous êtes un spécialiste des parcours académiques. Votre tâche est d'extraire avec précision les informations relatives aux études, aux diplômes et aux établissements fréquentés par les candidats.", + verbose=False, + llm=LLM_agent +) +informations_personnelle_agent = Agent( + role="Spécialiste de l'extraction des coordonnées", + goal="Identifier et extraire précisément les coordonnées du candidat.", + backstory="Vous êtes un expert en analyse de CV, particulièrement doué pour localiser et extraire les informations de contact. Votre rôle est de trouver le nom, l'adresse e-mail, le numéro de téléphone et la localisation (ville ou région) du candidat, généralement situés en haut ou à la fin du CV.", + verbose=False, + llm=LLM_agent +) +ProfileBuilderAgent = Agent( + role='Constructeur de Profil CV', + goal='Créer un profil JSON structuré et valide avec la clé candidat', + backstory=( + "Tu es un expert en structuration de données JSON. " + "Ta mission est de créer un profil candidat parfaitement formaté " + "en respectant scrupuleusement la structure JSON demandée." + ), + verbose=True, + llm=LLM_agent +) + +reconversion_detector_agent = Agent( + role="Détecteur de Reconversion Professionnelle", + goal="Analyser la chronologie des expériences pour identifier les changements de carrière significatifs.", + backstory="Vous êtes un conseiller d'orientation expert, capable de repérer les transitions de carrière, d'identifier les compétences transférables et de valoriser les parcours non linéaires. Votre analyse doit mettre en lumière les changements de secteur, de type de poste ou de niveau de responsabilité.", + verbose=False, + llm=LLM_agent +) diff --git a/src/crew/crew_pool.py b/src/crew/crew_pool.py new file mode 100644 index 0000000000000000000000000000000000000000..69038c82884608725d71952c20f1774e85920ddd --- /dev/null +++ b/src/crew/crew_pool.py @@ -0,0 +1,81 @@ +from crewai import Crew, Process +from langchain_core.tools import tool +import json +from pydantic import BaseModel, Field +from typing import Dict, List, Any, Type +from .agents import report_generator_agent, skills_extractor_agent, experience_extractor_agent, project_extractor_agent, education_extractor_agent, ProfileBuilderAgent, informations_personnelle_agent, reconversion_detector_agent +from .tasks import generate_report_task, task_extract_skills, task_extract_experience, task_extract_projects, task_extract_education, task_build_profile, task_extract_informations, task_detect_reconversion +from src.deep_learning_analyzer import MultiModelInterviewAnalyzer +from src.rag_handler import RAGHandler +from langchain_core.tools import BaseTool + +@tool +def interview_analyser(conversation_history: list, job_description_text: list) -> str: + """ + Appelle cet outil à la toute fin d'un entretien d'embauche pour analyser + l'intégralité de la conversation et générer un rapport de feedback. + Ne l'utilise PAS pour répondre à une question normale, mais seulement pour conclure et analyser l'entretien. + """ + # 1. Analyse DL de la conversation + analyzer = MultiModelInterviewAnalyzer() + structured_analysis = analyzer.run_full_analysis(conversation_history, job_description_text) + + # 2. Enrichissement avec RAG + rag_handler = RAGHandler() + rag_feedback = [] + # Extraire les intentions et sentiments pour trouver des conseils pertinents + if structured_analysis.get("intent_analysis"): + for intent in structured_analysis["intent_analysis"]: + # Exemple de requête basée sur l'intention + query = f"Conseils pour un candidat qui cherche à {intent['labels'][0]}" + rag_feedback.extend(rag_handler.get_relevant_feedback(query)) + + if structured_analysis.get("sentiment_analysis"): + for sentiment_group in structured_analysis["sentiment_analysis"]: + for sentiment in sentiment_group: + if sentiment['label'] == 'stress' and sentiment['score'].item() > 0.6: + rag_feedback.extend(rag_handler.get_relevant_feedback("gestion du stress en entretien")) + unique_feedback = list(set(rag_feedback)) + interview_crew = Crew( + agents=[report_generator_agent], + tasks=[generate_report_task], + process=Process.sequential, + verbose=False, + telemetry=False + ) + + final_report = interview_crew.kickoff(inputs={ + 'structured_analysis_data': json.dumps(structured_analysis, indent=2), + 'rag_contextual_feedback': "\n".join(unique_feedback) + }) + return final_report + +def analyse_cv(cv_content: str) -> json: + crew = Crew( + agents=[ + informations_personnelle_agent, + skills_extractor_agent, + experience_extractor_agent, + project_extractor_agent, + education_extractor_agent, + reconversion_detector_agent, + + ProfileBuilderAgent + ], + tasks=[ + task_extract_informations, + task_extract_skills, + task_extract_experience, + task_extract_projects, + task_extract_education, + task_detect_reconversion, + task_build_profile + ], + process=Process.sequential, + verbose=False, + telemetry=False + ) + result = crew.kickoff(inputs={"cv_content": cv_content}) + return result + + \ No newline at end of file diff --git a/src/crew/tasks.py b/src/crew/tasks.py new file mode 100644 index 0000000000000000000000000000000000000000..963e19023851a479cf09eebf53e9dd51da866ec4 --- /dev/null +++ b/src/crew/tasks.py @@ -0,0 +1,184 @@ +from crewai import Task +from .agents import report_generator_agent, skills_extractor_agent, experience_extractor_agent, project_extractor_agent, education_extractor_agent, ProfileBuilderAgent, informations_personnelle_agent, reconversion_detector_agent + +generate_report_task = Task( + description=( + """Tu es un rédacteur expert en RH. Ta mission est de rédiger un rapport d'évaluation final. + Tu dois utiliser deux sources d'information principales : + 1. Les données d'analyse structurées de l'entretien : '{structured_analysis_data}'. + 2. Une liste de conseils et de feedback pertinents issus de notre base de connaissances : '{rag_contextual_feedback}'. + + Ta tâche est de synthétiser ces informations en un rapport cohérent et actionnable.""" + ), + expected_output=( + """Un rapport final exceptionnel basé sur l'analyse fournie. Le rapport doit être structuré comme suit: + 1. **Résumé et Score d'Adéquation** : Synthétise le score de similarité sémantique et donne un aperçu global. + 2. **Analyse Comportementale** : Interprète les résultats de l'analyse de sentiment et d'intention pour décrire le comportement du candidat. + 3. **Adéquation Sémantique avec le Poste** : Explique ce que signifie le score de similarité. + 4. **Points Forts & Axes d'Amélioration Personnalisés** : Utilise les données d'analyse pour identifier les points à améliorer. Ensuite, intègre de manière fluide et naturelle les conseils pertinents de '{rag_contextual_feedback}' pour proposer des pistes d'amélioration concrètes et personnalisées. Ne te contente pas de copier-coller le feedback, mais reformule-le pour qu'il s'intègre parfaitement au rapport. + 5. **Recommandation Finale**.""" + ), + agent=report_generator_agent, +) + +task_extract_skills = Task( + description=( + "Voici le contenu du CV :\n\n{cv_content}\n\n" + "Extraire uniquement les compétences mentionnées explicitement dans le texte du CV. " + "Séparer les hard skills (techniques) et les soft skills (comportementales) en analysant les listes ou phrases les contenant. " + "Les hards skills doivent comprendre des compétences techniques, outils, langages de programmation, etc. " + "Ne rien inventer. Ne pas déduire de compétences à partir d'un poste ou d'une expérience implicite. " + "Identifie clairement les compétences, et n'en exclue aucune. " + "\n\n**CONTRAINTES JSON STRICTES:**\n" + "- Utiliser UNIQUEMENT des guillemets doubles (\") pour les chaînes\n" + "- Aucune virgule finale dans les listes ou objets\n" + "- Vérifier la syntaxe JSON avant de retourner le résultat\n" + "- Échapper correctement les caractères spéciaux (\\, \", \\n, etc.)" + ), + agent=skills_extractor_agent, + input_keys=["cv_content"], + expected_output=( + "Un dictionnaire JSON VALIDE 'Compétences' avec deux clés : 'hard_skills' et 'soft_skills', " + "contenant uniquement des listes de compétences présentes dans le texte. " + "FORMAT EXACT: {\"hard_skills\": [\"compétence1\", \"compétence2\"], \"soft_skills\": [\"compétence1\", \"compétence2\"]}" + ) +) + +task_extract_experience = Task( + description=( + "Voici le contenu du CV :\n\n{cv_content}\n\n" + """ + Extrais toutes les expériences professionnelles du CV. Pour chaque expérience, tu DOIS fournir les informations suivantes : + - Poste: Le titre du poste. + - Entreprise: Le nom de l'entreprise. + - start_date: La date de début. Si non trouvée, retourne "Non spécifié". + - end_date: La date de fin. Si le poste est actuel, utilise "Aujourd'hui". Si non trouvée, retourne "Non spécifié". + - responsabilités: Une liste des tâches et missions. + + RÈGLES STRICTES : + 1. NE JAMAIS laisser un champ vide (""). Si une information est introuvable, utilise la valeur "Non spécifié". + 2. Analyse attentivement les dates. "Depuis 2023" signifie que la date de fin est "Aujourd'hui". + """ + ), + agent=experience_extractor_agent, + input_keys=["cv_content"], + expected_output=( + "Un tableau JSON VALIDE d'objets 'Expérience Professionnelle' avec 5 clés par expérience : " + "'Poste', 'Entreprise', 'start_date', 'end_date', 'responsabilités'. " + "FORMAT EXACT: [{\"Poste\": \"titre\", \"Entreprise\": \"nom\", \"start_date\": \"année\", \"end_date\": \"année\", \"responsabilités\": [\"resp1\", \"resp2\"]}]" + ) +) + +task_extract_projects = Task( + description=( + "Voici le contenu du CV :\n\n{cv_content}\n\n" + """ + Identifie et extrais les PROJETS SPÉCIFIQUES mentionnés dans le CV. + Un projet est distinct d'une expérience professionnelle générale. Il a un nom ou un objectif clair. + + RÈGLES STRICTES : + 1. NE PAS extraire les responsabilités générales d'un poste en tant que projet. Par exemple, si le CV dit "Alternant chez Enedis où j'ai mené le projet 'Simulateur IA'", alors extrais 'Simulateur IA' comme projet. Ne copie pas toutes les tâches de l'alternance. + 2. Si un projet est clairement lié à une expérience professionnelle, essaie de le noter, mais le plus important est de décrire le projet lui-même. + """ + ), + agent=project_extractor_agent, + input_keys=["cv_content"], + expected_output=( + "Un dictionnaire JSON VALIDE 'Projets' avec deux clés : 'professional' et 'personal'. " + "Chaque clé contient une liste de dictionnaires, chaque dictionnaire représentant un projet avec les clés 'title', 'role', 'technologies', et 'outcomes'. " + "FORMAT EXACT: {\"professional\": [{\"title\": \"titre\", \"role\": \"rôle\", \"technologies\": [\"tech1\"], \"outcomes\": [\"résultat1\"]}], \"personal\": []}" + ) +) + +task_extract_education = Task( + description=( + "Voici le contenu du CV :\n\n{cv_content}\n\n" + """ + Extrais le parcours de formation et les certifications. Fais une distinction claire entre les types de formation. + Pour chaque élément, fournis : + - degree: Le nom du diplôme, du titre (ex: 'Titre RNCP niveau 6') ou de la certification (ex: 'Core Designer Certification'). + - institution: L'école, l'université ou la plateforme (ex: 'WILD CODE SCHOOL', 'DataIku', 'DataCamp'). + - start_date: La date de début. Si non trouvée, retourne "Non spécifié". + - end_date: La date de fin. Si non trouvée, retourne "Non spécifié". + + RÈGLES STRICTES : + 1. Si tu vois une certification comme "DataIku (core designer)", le diplôme est "Core Designer" et l'institution est "DataIku". NE PAS les mélanger. + 2. NE PAS extraire une simple compétence (ex: 'Python') comme une formation. + """ + ), + agent=education_extractor_agent, + input_keys=["cv_content"], + expected_output=( + "Un tableau JSON VALIDE d'objets 'Formation' avec les clés : 'degree', 'institution', 'start_date', 'end_date'. " + "FORMAT EXACT: [{\"degree\": \"diplôme\", \"institution\": \"établissement\", \"start_date\": \"année\", \"end_date\": \"année\"]}" + ) +) + +task_extract_informations = Task( + description=( + "Voici le contenu du CV :\n\n{cv_content}\n\n" + "Votre tâche est d'extraire les informations de contact du candidat. Ces informations se trouvent généralement au début ou à la fin du CV, souvent sous une section intitulée 'CONTACT'.\n" + "Extrayez précisément :\n" + "- Le **Nom complet**.\n" + "- L'**Adresse e-mail**.\n" + "- Le **Numéro de téléphone**.\n" + "- La **Localisation** (ville ou région).\n" + "toutes les informations devront être normalisées, principalement le nom si il est en majuscule en titre. " + ), + agent=informations_personnelle_agent, + input_keys=["cv_content"], + expected_output=( + "Un dictionnaire JSON VALIDE 'informations_personnelles' contenant le nom, l'email, le numéro de téléphone et la localisation du candidat. " + "FORMAT EXACT: {\"nom\": \"nom\", \"email\": \"email\", \"numero_de_telephone\": \"tel\", \"localisation\": \"lieu\"}" + ) +) + +task_detect_reconversion = Task( + description=( + "En te basant sur les données extraites de la tâche `task_extract_experience`, analyse la chronologie des expériences professionnelles. " + "Ton objectif est de déterminer si le candidat est en reconversion professionnelle. " + "Cherche des changements de secteur d'activité (ex: de la restauration à la tech), des changements de type de poste (ex: de commercial à développeur), ou des sauts de carrière importants. " + "Si une reconversion est détectée, identifie les compétences qui semblent avoir été transférées." + ), + agent=reconversion_detector_agent, + context=[task_extract_experience], + expected_output=( + "Un dictionnaire JSON VALIDE avec une clé 'reconversion_analysis'. " + "Ce dictionnaire doit contenir deux clés : 'is_reconversion' (un booléen) et 'analysis' (une chaîne de caractères expliquant pourquoi, ou pourquoi pas, et listant les compétences transférables si applicable). " + "FORMAT EXACT: {\"reconversion_analysis\": {\"is_reconversion\": true, \"analysis\": \"Le candidat a changé de secteur...\"}}" + ) +) + +task_build_profile = Task( + description=( + "Ta mission est d'agir comme un architecte de données. En utilisant les extractions des tâches précédentes, " + "assemble un profil de candidat complet. " + "Le résultat final doit être un unique objet JSON, parfaitement valide." + ), + agent=ProfileBuilderAgent, + context=[ + task_extract_informations, + task_extract_skills, + task_extract_experience, + task_extract_projects, + task_extract_education, + task_detect_reconversion + ], + expected_output=( + "Retourner un unique objet JSON valide. Cet objet doit avoir une seule clé à la racine : 'candidat'. " + "La valeur de cette clé sera un autre objet contenant toutes les informations assemblées. " + "Assure-toi que la syntaxe est parfaite, que tous les guillemets sont des guillemets doubles et qu'il n'y a aucune virgule finale. " + "Le JSON doit être immédiatement parsable par un programme.\n\n" + "FORMAT EXACT:\n" + "{\n" + " \"candidat\": {\n" + " \"informations_personnelles\": {\"nom\": \"...\", \"email\": \"...\", ...},\n" + " \"compétences\": {\"hard_skills\": [...], \"soft_skills\": [...]},\n" + " \"expériences\": [{\"Poste\": \"...\", ...}],\n" + " \"projets\": {\"professional\": [...], \"personal\": [...]},\n" + " \"formations\": [{\"degree\": \"...\", ...}],\n" + " \"reconversion\": {\"is_reconversion\": true, \"analysis\": \"...\"}\n" + " }\n" + "}" + ) +) diff --git a/src/cv_parsing_agents.py b/src/cv_parsing_agents.py new file mode 100644 index 0000000000000000000000000000000000000000..1c569a07ea70ca7f3f6b24aa9593d80056b15f49 --- /dev/null +++ b/src/cv_parsing_agents.py @@ -0,0 +1,291 @@ +""" +Module pour le parsing de CV avec CrewAI +""" +import os +import json +import logging + +logger = logging.getLogger(__name__) + +# Gestion des imports avec fallback +try: + from src.crew.crew_pool import analyse_cv + CREW_POOL_AVAILABLE = True + logger.info("✅ crew_pool importé avec succès") +except ImportError as e: + logger.error(f"❌ Erreur import crew_pool: {e}") + CREW_POOL_AVAILABLE = False + analyse_cv = None + +try: + from src.config import load_pdf + CONFIG_AVAILABLE = True + logger.info("✅ config importé avec succès") +except ImportError as e: + logger.error(f"❌ Erreur import config: {e}") + CONFIG_AVAILABLE = False + load_pdf = None + +def clean_dict_keys(data): + """ + Nettoie les clés d'un dictionnaire en les convertissant en string. + + Args: + data: Données à nettoyer (dict, list, ou autre) + + Returns: + Données nettoyées avec des clés string + """ + if isinstance(data, dict): + return {str(key): clean_dict_keys(value) for key, value in data.items()} + elif isinstance(data, list): + return [clean_dict_keys(element) for element in data] + else: + return data + +class CvParserAgent: + """ + Agent de parsing de CV utilisant CrewAI. + + Cette classe traite un fichier PDF de CV et en extrait les informations + structurées (compétences, expériences, formations, etc.) + """ + + def __init__(self, pdf_path: str): + """ + Initialise l'agent de parsing de CV. + + Args: + pdf_path (str): Chemin vers le fichier PDF à traiter + + Raises: + ValueError: Si le chemin du fichier est invalide + ImportError: Si les dépendances nécessaires ne sont pas disponibles + """ + if not pdf_path or not isinstance(pdf_path, str): + raise ValueError("Le chemin du fichier PDF doit être une chaîne non vide") + + self.pdf_path = pdf_path + + # Vérifier que les dépendances sont disponibles + if not CREW_POOL_AVAILABLE: + logger.warning("CrewAI crew_pool non disponible - mode dégradé") + if not CONFIG_AVAILABLE: + logger.warning("Module config non disponible - mode dégradé") + + def process(self) -> dict: + """ + Traite le fichier PDF pour en extraire le contenu sous forme de JSON. + + Returns: + dict: Dictionnaire contenant les données extraites du CV, + ou données de fallback en cas d'erreur + """ + logger.info(f"Début du traitement du CV : {self.pdf_path}") + + # Vérifier que le fichier existe + if not os.path.exists(self.pdf_path): + logger.error(f"Fichier PDF non trouvé: {self.pdf_path}") + return self._create_fallback_data() + + # Vérifier les dépendances + if not CREW_POOL_AVAILABLE or not CONFIG_AVAILABLE: + logger.error("Dépendances manquantes pour le traitement complet") + return self._create_fallback_data() + + try: + # Charger le contenu du PDF + cv_text_content = load_pdf(self.pdf_path) + if not cv_text_content or not cv_text_content.strip(): + logger.error("Le PDF semble vide ou illisible") + return self._create_fallback_data() + + logger.info(f"PDF chargé, {len(cv_text_content)} caractères extraits") + + # Analyser avec CrewAI + crew_output = analyse_cv(cv_text_content) + + if not crew_output or not hasattr(crew_output, 'raw') or not crew_output.raw.strip(): + logger.error("L'analyse par le crew n'a pas retourné de résultat.") + return self._create_fallback_data() + + raw_string = crew_output.raw + logger.info(f"Résultat brut du crew: {raw_string[:200]}...") + + # Nettoyer le JSON si nécessaire + json_string_cleaned = self._clean_json_string(raw_string) + + # Parser le JSON + profile_data = json.loads(json_string_cleaned) + logger.info("Parsing JSON réussi") + + return clean_dict_keys(profile_data) + + except json.JSONDecodeError as e: + logger.error(f"Erreur de décodage JSON : {e}") + if 'crew_output' in locals(): + logger.error(f"Données brutes reçues : {crew_output.raw}") + return self._create_fallback_data() + + except Exception as e: + logger.error(f"Erreur inattendue dans CvParserAgent : {e}", exc_info=True) + return self._create_fallback_data() + + def _clean_json_string(self, raw_string: str) -> str: + """ + Nettoie une chaîne JSON brute en supprimant les blocs de code markdown. + + Args: + raw_string (str): Chaîne brute à nettoyer + + Returns: + str: Chaîne JSON nettoyée + """ + json_string_cleaned = raw_string.strip() + + # Supprimer les blocs de code markdown si présents + if '```' in raw_string: + try: + # Chercher le bloc json + if '```json' in raw_string: + json_part = raw_string.split('```json')[1].split('```')[0] + json_string_cleaned = json_part.strip() + else: + # Prendre le premier bloc de code + parts = raw_string.split('```') + if len(parts) >= 3: + json_string_cleaned = parts[1].strip() + except IndexError: + logger.warning("Format de code block détecté mais mal formé") + + return json_string_cleaned + + def _create_fallback_data(self) -> dict: + """ + Crée des données de CV de fallback en cas d'erreur de traitement. + + Returns: + dict: Structure de données de CV par défaut + """ + logger.info("Création de données de fallback pour le CV") + return { + "candidat": { + "informations_personnelles": { + "nom": "Candidat Test", + "email": "test@example.com", + "numero_de_telephone": "Non spécifié", + "localisation": "Non spécifiée" + }, + "compétences": { + "hard_skills": ["Python", "FastAPI", "Data Analysis"], + "soft_skills": ["Communication", "Travail d'équipe", "Adaptabilité"] + }, + "expériences": [ + { + "Poste": "Développeur", + "Entreprise": "Entreprise Test", + "start_date": "2022", + "end_date": "Aujourd'hui", + "responsabilités": ["Développement d'applications", "Maintenance du code"] + } + ], + "projets": { + "professional": [ + { + "title": "Projet Test", + "role": "Développeur principal", + "technologies": ["Python", "FastAPI"], + "outcomes": ["Application fonctionnelle"] + } + ], + "personal": [] + }, + "formations": [ + { + "degree": "Formation en Informatique", + "institution": "École Test", + "start_date": "2020", + "end_date": "2022" + } + ], + "reconversion": { + "is_reconversion": False, + "analysis": "Pas de reconversion détectée - données de test" + } + } + } + +# Fonction utilitaire pour créer des données de fallback +def create_fallback_cv_data(pdf_path: str = None) -> dict: + """ + Fonction utilitaire pour créer des données de CV de fallback. + + Args: + pdf_path (str, optional): Chemin du fichier PDF (non utilisé dans le fallback) + + Returns: + dict: Structure de données de CV par défaut + """ + return { + "candidat": { + "informations_personnelles": { + "nom": "Candidat Test", + "email": "test@example.com", + "numero_de_telephone": "Non spécifié", + "localisation": "Non spécifiée" + }, + "compétences": { + "hard_skills": ["Python", "FastAPI", "Data Analysis"], + "soft_skills": ["Communication", "Travail d'équipe", "Adaptabilité"] + }, + "expériences": [ + { + "Poste": "Développeur", + "Entreprise": "Entreprise Test", + "start_date": "2022", + "end_date": "Aujourd'hui", + "responsabilités": ["Développement d'applications", "Maintenance du code"] + } + ], + "projets": { + "professional": [ + { + "title": "Projet Test", + "role": "Développeur principal", + "technologies": ["Python", "FastAPI"], + "outcomes": ["Application fonctionnelle"] + } + ], + "personal": [] + }, + "formations": [ + { + "degree": "Formation en Informatique", + "institution": "École Test", + "start_date": "2020", + "end_date": "2022" + } + ], + "reconversion": { + "is_reconversion": False, + "analysis": "Pas de reconversion détectée - données de test" + } + } + } + +# Test des imports au chargement du module +if __name__ == "__main__": + logger.info("Test du module cv_parsing_agents") + logger.info(f"CREW_POOL_AVAILABLE: {CREW_POOL_AVAILABLE}") + logger.info(f"CONFIG_AVAILABLE: {CONFIG_AVAILABLE}") + + # Test de création d'une instance + try: + agent = CvParserAgent("/tmp/test.pdf") + logger.info("✅ CvParserAgent créé avec succès") + except Exception as e: + logger.error(f"❌ Erreur création CvParserAgent: {e}") + + # Test des données de fallback + fallback_data = create_fallback_cv_data() + logger.info(f"✅ Données de fallback créées: {len(fallback_data)} clés") \ No newline at end of file diff --git a/src/deep_learning_analyzer.py b/src/deep_learning_analyzer.py new file mode 100644 index 0000000000000000000000000000000000000000..2754da4a80845756570d59328fcf201977249b18 --- /dev/null +++ b/src/deep_learning_analyzer.py @@ -0,0 +1,57 @@ +import torch +from transformers import pipeline +from sentence_transformers import SentenceTransformer, util + +class MultiModelInterviewAnalyzer: + def __init__(self): + self.sentiment_analyzer = pipeline( + "text-classification", + model="astrosbd/french_emotion_camembert", + return_all_scores=True, + device=0 if torch.cuda.is_available() else -1, + ) + self.similarity_model = SentenceTransformer('all-MiniLM-L6-v2') + self.intent_classifier = pipeline( + "zero-shot-classification", + model="joeddav/xlm-roberta-large-xnli" + #device=0 if torch.cuda.is_available() else -1, + ) + + def analyze_sentiment(self, messages): + user_messages = [msg['content'] for msg in messages if msg['role'] == 'user'] + if not user_messages: + return [] + sentiments = self.sentiment_analyzer(user_messages) + return sentiments + + def compute_semantic_similarity(self, messages, job_requirements): + user_answers = " ".join([msg['content'] for msg in messages if msg['role'] == 'user']) + embedding_answers = self.similarity_model.encode(user_answers, convert_to_tensor=True) + embedding_requirements = self.similarity_model.encode(job_requirements, convert_to_tensor=True) + cosine_score = util.cos_sim(embedding_answers, embedding_requirements) + return cosine_score.item() + + def classify_candidate_intent(self, messages): + user_answers = [msg['content'] for msg in messages if msg['role'] == 'user'] + if not user_answers: + return [] + candidate_labels = [ + "parle de son expérience technique", + "exprime sa motivation", + "pose une question", + "exprime de l’incertitude ou du stress" + ] + classifications = self.intent_classifier(user_answers, candidate_labels, multi_label=False) + return classifications + + def run_full_analysis(self, conversation_history, job_requirements): + sentiment_results = self.analyze_sentiment(conversation_history) + similarity_score = self.compute_semantic_similarity(conversation_history, job_requirements) + intent_results = self.classify_candidate_intent(conversation_history) + analysis_output = { + "overall_similarity_score": round(similarity_score, 2), + "sentiment_analysis": sentiment_results, + "intent_analysis": intent_results, + "raw_transcript": conversation_history + } + return analysis_output \ No newline at end of file diff --git a/src/interview_simulator/__init__.py b/src/interview_simulator/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/interview_simulator/__pycache__/__init__.cpython-312.pyc b/src/interview_simulator/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d10818a482dfc9eb2f7e64167ee08082d3ce2f87 Binary files /dev/null and b/src/interview_simulator/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/interview_simulator/__pycache__/entretient_version_prod.cpython-312.pyc b/src/interview_simulator/__pycache__/entretient_version_prod.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d5e62937e3f6da4328aeb0c6c4d311a2adf1291b Binary files /dev/null and b/src/interview_simulator/__pycache__/entretient_version_prod.cpython-312.pyc differ diff --git a/src/interview_simulator/entretient_version_prod.py b/src/interview_simulator/entretient_version_prod.py new file mode 100644 index 0000000000000000000000000000000000000000..d7ebfac1cdce6aea38cf48b7040b8d720de8f5db --- /dev/null +++ b/src/interview_simulator/entretient_version_prod.py @@ -0,0 +1,98 @@ +import os +import sys +import json +from typing import Dict, List, Any, Annotated +from typing_extensions import TypedDict + +from langchain_core.messages import AIMessage, SystemMessage, HumanMessage, ToolMessage +from langchain_groq import ChatGroq +from langgraph.graph import StateGraph, START, END +from langgraph.graph.message import add_messages +from langgraph.prebuilt import ToolNode +from langchain_openai import ChatOpenAI + +from src.config import read_system_prompt, format_cv +from src.crew.crew_pool import interview_analyser + + +class State(TypedDict): + messages: Annotated[list, add_messages] + +class InterviewProcessor: + def __init__(self, cv_document: Dict[str, Any], job_offer: Dict[str, Any], conversation_history: List[Dict[str, Any]]): + if not cv_document or 'candidat' not in cv_document: + raise ValueError("Document CV invalide fourni.") + if not job_offer: + raise ValueError("Données de l'offre d'emploi non fournies.") + + self.job_offer = job_offer + self.cv_data = cv_document['candidat'] + self.conversation_history = conversation_history + self.tools = [interview_analyser] + self.llm = self._get_llm() + self.llm_with_tools = self.llm.bind_tools(self.tools) + + self.system_prompt_template = self._load_prompt_template() + self.graph = self._build_graph() + + def _get_llm(self) -> ChatOpenAI: + openai_api_key = os.getenv("OPENAI_API_KEY") + return ChatOpenAI( + temperature=0.6, + model_name="gpt-4o-mini", + api_key=openai_api_key + ) + + def _load_prompt_template(self) -> str: + return read_system_prompt('prompts/rag_prompt_old.txt') + + def _chatbot_node(self, state: State) -> dict: + if state["messages"] and isinstance(state["messages"][-1], ToolMessage): + tool_message = state["messages"][-1] + return {"messages": [AIMessage(content=tool_message.content)]} + messages = state["messages"] + formatted_cv_str = format_cv(self.cv_data) + + mission = self.job_offer.get('mission', 'Non spécifiée') + profil_recherche = self.job_offer.get('profil_recherche', 'Non spécifié') + competences = self.job_offer.get('competences', 'Non spécifiées') + pole = self.job_offer.get('pole', 'Non spécifié') + system_prompt = self.system_prompt_template.format( + entreprise=self.job_offer.get('entreprise', 'notre entreprise'), + poste=self.job_offer.get('poste', 'ce poste'), + mission=mission, + profil_recherche=profil_recherche, + competences=competences, + pole=pole, + cv=formatted_cv_str + ) + llm_messages = [SystemMessage(content=system_prompt)] + messages + response = self.llm_with_tools.invoke(llm_messages) + return {"messages": [response]} + + def _route_after_chatbot(self, state: State) -> str: + last_message = state["messages"][-1] + if last_message.tool_calls: + return "call_tool" + return END + + def _build_graph(self) -> any: + graph_builder = StateGraph(State) + + graph_builder.add_node("chatbot", self._chatbot_node) + graph_builder.add_node("call_tool", ToolNode(self.tools)) + graph_builder.add_edge(START, "chatbot") + graph_builder.add_conditional_edges( + "chatbot", + self._route_after_chatbot, + { + "call_tool": "call_tool", + END: END + } + ) + graph_builder.add_edge("call_tool", "chatbot") + return graph_builder.compile() + + def run(self, messages: List[Dict[str, Any]]) -> Dict[str, Any]: + initial_state = self.conversation_history + messages + return self.graph.invoke({"messages": initial_state}) \ No newline at end of file diff --git a/src/prompts/agent_auditeur.txt b/src/prompts/agent_auditeur.txt deleted file mode 100644 index eb12a63edd0b6d54ba2e8b2428d8a4c8b05766a5..0000000000000000000000000000000000000000 --- a/src/prompts/agent_auditeur.txt +++ /dev/null @@ -1,31 +0,0 @@ -IDENTITY: -Tu es l'IA de recrutement (Focus Technique). -Ton style est curieux, précis et fluide. Tu ne lis PAS de script. - -PHASE : PROJETS & HARD SKILLS (SOAR) - -TA MISSION : -Engager une discussion technique naturelle sur les projets du candidat, en t'adaptant à son profil (Icebreaker). - -1. **D'abord, REBONDIS sur le profil** : - * Utilise le **CONTEXTE ICEBREAKER** pour personnaliser ton approche. - * Si le candidat est en reconversion, sois encourageant mais vérifie les bases. - * Si le candidat est expérimenté, va directement au but sur des détails complexes. - * Exemple : "Avec votre background en [contexte], comment avez-vous abordé la technique sur ce projet ?" - -2. **Choisis un PROJET** : Analyse le JSON du CV. Repère un projet complexe ou pertinent pour le poste ({poste}). - -3. **Formule TA PROPRE question (SOAR)** : Demande-lui de raconter ce projet sous l'angle "Situation/Obstacle" ou "Architecture". - * Ne pose pas une question générique. - * Utilise les technos citées dans le CV pour rendre la question crédible. - -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. - -RÈGLES DE STYLE : -- PAS de phrases types. Improvise comme un recruteur senior. -- PAS de "Passons à la suite". Fais une transition conversationnelle. -- Si le candidat reste vague, demande des précisions techniques (versions, librairies, contraintes). - -CONTEXTE : -{user_id} - diff --git a/src/prompts/agent_challenger.txt b/src/prompts/agent_challenger.txt deleted file mode 100644 index 79caf5bace5d3f9b01911cc9948e30145bcfe37b..0000000000000000000000000000000000000000 --- a/src/prompts/agent_challenger.txt +++ /dev/null @@ -1,14 +0,0 @@ -You are the **Challenger Agent**. - -**Context**: The candidate gave a vague, generic, or insufficient answer. -**Goal**: Drill down to get the truth without being aggressive. - -**Tone**: "Critical Friend". Firm but fair. "Constructively Skeptical". - -**Instructions**: -1. Reference the specific part of their answer that was vague. -2. Ask for a concrete example or specific detail. - - *Example*: "You mentioned 'handling the problem', but could you walk me through the specific steps *you* personally took?" - - *Example*: "That sounds like a standard approach. Did you consider any alternatives, and why did you reject them?" - -**Constraint**: Do not be rude. The goal is to urge them to use the STAR/SOAR method (Situation, Obstacle, Action, Result). diff --git a/src/prompts/agent_enqueteur.txt b/src/prompts/agent_enqueteur.txt deleted file mode 100644 index e2084460003a2b6b96be52fd93170d58f36f2433..0000000000000000000000000000000000000000 --- a/src/prompts/agent_enqueteur.txt +++ /dev/null @@ -1,29 +0,0 @@ -IDENTITY: -Tu es l'IA de recrutement (Focus Humain & Collaboration). -Ton style est empathique mais perspicace. - -PHASE : SOFT SKILLS (STAR) - -TA MISSION : -Explorer la dimension humaine du candidat en tenant compte de son niveau technique. - -1. **TRANSITION NATURELLE** : Rebondis sur le **BILAN TECHNIQUE**. - * Si le candidat a des lacunes techniques identifiées, interroge sa capacité d'apprentissage ou son humilité. - * Si le candidat est très fort techniquement, interroge son leadership ou sa capacité à mentorer. - * Exemple : "Vous avez une belle maîtrise de [skill], mais comment gérez-vous les désaccords techniques en équipe ?" - -2. **Cible une QUALITÉ** : Regarde les "Soft Skills" du CV ou du poste ({poste}). - -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). - * Ne dis pas "Donnez-moi une méthode STAR". - * Dis plutôt : "Racontez-moi une fois où vous avez dû gérer..." - -4. **Creuse** : Si l'histoire manque de "Résultat" ou d'"Action personnelle", demande-lui simplement comment cela s'est fini pour lui. - -RÈGLES DE STYLE : -- Parle naturellement. Pas de robotisme. -- Sois à l'écoute : Si le candidat a mentionné un contexte difficile avant, utilise-le. - -CONTEXTE : -{user_id} - diff --git a/src/prompts/agent_icebreaker.txt b/src/prompts/agent_icebreaker.txt deleted file mode 100644 index 336010529a509dbc7e93f07d7458875226011d59..0000000000000000000000000000000000000000 --- a/src/prompts/agent_icebreaker.txt +++ /dev/null @@ -1,47 +0,0 @@ -Tu es l'Agent Icebreaker (RONI). - -Ta mission : Mettre en confiance le candidat {first_name} (ID: {user_id}) pour le poste de {poste} chez {entreprise}. -Tu as le droit de poser exactement {nb_questions} questions au total pour cette phase. -Tu as accès à son contexte : -{context_str} - -**TON OBJECTIF :** -Accueillir le candidat de manière personnalisée et aller droit au but pour comprendre ses motivations profondes (Storytelling). - -**OBJECTIFS DU PREMIER MESSAGE (DYNAMICITÉ & NATUREL) :** -Tu dois couvrir ces 3 points, mais **TU DOIS IMPÉRATIVEMENT VARIER LA FORMULATION** à chaque fois pour ne pas ressembler à un script robotique. -1. **Saluer** le candidat chaleureusement ({first_name}). -2. **Te présenter (RONI)** et annoncer le plan (3 étapes : échange, technique, soft skills). Fais court et fluide. -3. **Enchaîner naturellement** avec ta question d'ouverture (FOCUS PRIORITAIRE). - -**Exemple de ce qu'il ne faut PAS faire (Trop rigide) :** -"Bonjour Quentin ! Je suis RONI... L'entretien se déroulera en 3 parties..." - -**Ce que tu dois faire (Variations naturelles) :** -- "Bonjour {first_name}, ravi de vous accueillir. Je suis RONI. Pour notre échange aujourd'hui, nous allons naviguer entre votre parcours, vos compétences techniques et votre personnalité. D'ailleurs, je vois que..." -- "Bienvenue {first_name} ! Je m'appelle RONI et je serai votre recruteur IA. Nous allons structurer cet entretien en trois temps forts : connaissance, tech et humain. Commençons par..." - -**RÈGLES D'ADAPTATION AU CONTEXTE (FOCUS PRIORITAIRE) :** - -* **CAS 1 : RECONVERSION (Focus = RECONVERSION)** - * Ton but est de comprendre le "Pourquoi". - * Cite explicitement son ancien métier (voir Contexte). - * Exemple : "Je vois que vous avez fait une transition de [Ancien Métier] vers la Data. Qu'est-ce qui a motivé ce changement de cap ?" - -* **CAS 2 : ÉTUDIANT (Focus = ETUDIANT)** - * Ton but est de valider la cohérence avec le poste (Stage/Alternance). - * Vérifie si son niveau d'études et sa spécialité (voir Contexte) correspondent aux attentes du poste. - * Exemple : "Vous êtes actuellement en [Niveau] spécialisé en [Spécialité]. Qu'est-ce qui vous attire spécifiquement dans ce poste pour cette étape de votre parcours ?" - -* **CAS 3 : STANDARD / PARCOURS LINEAIRE (Focus = STANDARD)** - * Ton but est de vérifier la cohérence et la motivation. - * Si le **DERNIER POSTE OCCUPÉ** semble très différent du poste visé (Background Mismatch), interroge-le là-dessus. - * Sinon, demande ce qui le motive pour *ce* poste spécifique. - * Exemple : "Votre parcours en tant que [Dernier Poste] est intéressant. Qu'est-ce qui vous pousse à postuler chez [Entreprise] aujourd'hui ?" - -**INTERDICTIONS & RÈGLES DE STYLE :** -1. **BANNI** : Les phrases génériques du type "Au-delà du CV que j'ai sous les yeux...". C'est interdit. -2. **BANNI** : "Parlez-moi de vous" (trop vague). -3. **OBLIGATOIRE** : Cite toujours un élément précis du CV (Ancien job, École, ou Compétence) pour montrer que tu as lu le dossier. -4. **OBLIGATOIRE** : Si le poste est un Stage/Alternance (voir Contexte), assure-toi que le candidat est bien dans cette démarche si ce n'est pas clair. -5. Tu dois absolument passer la main à l'agent suivant après {nb_questions} échanges. diff --git a/src/prompts/agent_projecteur.txt b/src/prompts/agent_projecteur.txt deleted file mode 100644 index 5975f01f7a5acd3283e8c5b60db114b9b1ab196b..0000000000000000000000000000000000000000 --- a/src/prompts/agent_projecteur.txt +++ /dev/null @@ -1,20 +0,0 @@ -IDENTITY: -Tu es l'IA de recrutement (Focus Motivation & Avenir). -Ton style est ouvert et prospectif. - -PHASE : PROJECTION - -TA MISSION : -Vérifier si le candidat se voit vraiment dans CE poste. -1. **REBONDIS** : Valide la réponse précédente sur le dilemme (SJT) par un accusé de réception neutre mais courtois. -2. **PROJECTION** : Invite le candidat à parler de son avenir DANS L'ENTREPRISE ({entreprise}). - * 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. - * Exemple : "Si vous nous rejoignez demain, quel serait votre premier chantier prioritaire ?" - -RÈGLES DE STYLE : -- Fais sentir au candidat qu'on s'intéresse à SES envies. -- Évite les questions trop banales type "Où vous voyez-vous dans 5 ans ?". Préfère le concret court-terme (6-12 mois). - -CONTEXTE : -{user_id} - diff --git a/src/prompts/agent_stratege.txt b/src/prompts/agent_stratege.txt deleted file mode 100644 index 7a3657d8444c7a32435286142f1c8db11ad83c39..0000000000000000000000000000000000000000 --- a/src/prompts/agent_stratege.txt +++ /dev/null @@ -1,26 +0,0 @@ -IDENTITY: -Tu es l'IA de recrutement (Focus Jugement & Éthique). -Ton style est un peu plus "challenger" mais reste professionnel. - -PHASE : JUGEMENT SITUATIONNEL (SJT) - -TA MISSION : -Tester la prise de décision du candidat face à un dilemme réaliste pour le poste ({poste}), en ciblant ses points faibles potentiels. - -1. **ANALYSE LE BILAN COMPORTEMENTAL** : Regarde les "POINTS À INTÉGRER". - * Construis ton scénario pour tester spécifiquement ces points (ex: gestion du stress, communication, rigueur). - -2. **IMAGINE UN SCÉNARIO** : Basé sur le métier (Data Scientist, Analyst, etc.) ET les points à tester. - * Exemples (à adapter) : Biais de données, pression des délais vs qualité, confidentialité, hallucination d'IA. - -3. **LANCE LE DÉFI** : "Imaginons une situation : [Ton Scénario]. Que faites-vous ?" - -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 ?"). - -RÈGLES DE STYLE : -- Ne te présente pas. -- Utilise une transition fluide : "Changeons de perspective un instant..." ou "J'aimerais vous projeter dans une situation type du poste." - -CONTEXTE : -{user_id} - diff --git a/src/prompts/orchestrator_prompt.txt b/src/prompts/orchestrator_prompt.txt deleted file mode 100644 index c7461930a6e05c1c24157f1bfa2035396cbe395b..0000000000000000000000000000000000000000 --- a/src/prompts/orchestrator_prompt.txt +++ /dev/null @@ -1,31 +0,0 @@ -Tu es l'Orchestrateur d'une simulation d'entretien structuré pour un profil Junior/Reconversion Data & IA. - -**OBJECTIF** : Guider l'entretien à travers 6 étapes clés en analysant l'historique de la conversation. - -**ÉTAPES DU FLOW** : -1. "icebreaker" : Accueil (RONI), synthèse du parcours, déclic reconversion. -2. "auditeur" : Hard Skills & Projets (Méthode SOAR). -3. "enqueteur" : Soft Skills & Collaboration (Méthode STAR). -4. "stratege" : Jugement Situationnel (SJT) + Twist (Pression). -5. "projecteur" : Motivation & Culture Add (Projection). -6. "cloture" : Questions inversées (Candidat pose des questions). - -**RÈGLES DE DÉCISION (TRIGGERS)** : -Analyse la dernière réponse du candidat : -- Si l'étape est "icebreaker" et réponse < 30 mots ou évasive -> Reste et challenge (Max 1 relance). -- Si l'étape est "auditeur" et manque de justification ("Pourquoi") ou usage de "Nous" au lieu de "Je" -> Reste et challenge (Max 2 relances). -- Si l'étape est "enqueteur" et manque de Résultat chiffré ou Action précise (STAR) -> Reste et challenge (Max 2 relances). -- Si l'étape est "stratege" -> Systématiquement une relance (Stress Test). -- 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. -- Si le candidat dit "Au revoir", "Merci", "C'est tout pour moi" ou indique qu'il a fini -> Renvoie "end_interview" IMMÉDIATEMENT. -- Si l'étape actuelle est terminée (réponse satisfaisante) -> Passe à l'étape SUIVANTE. - - -**ÉTAT ACTUEL** : -- Étape en cours : {section} -- Nombre d'échanges dans cette étape : {turn_count} - -**SORTIE ATTENDUE** : -Retourne UNIQUEMENT une chaîne de caractères parmi : -"icebreaker", "auditeur", "enqueteur", "stratege", "projecteur", "cloture", "end_interview". -(Si tu dois challenger, renvoie le nom de l'étape actuelle. L'agent gérera le challenge). diff --git a/src/rag_handler.py b/src/rag_handler.py new file mode 100644 index 0000000000000000000000000000000000000000..306f09092324a398ded6f6769de0da8946f0f254 --- /dev/null +++ b/src/rag_handler.py @@ -0,0 +1,202 @@ +import os +import logging +from typing import Optional +from langchain_community.document_loaders import DirectoryLoader, TextLoader +from langchain_community.vectorstores import FAISS +from langchain_text_splitters import RecursiveCharacterTextSplitter + +logger = logging.getLogger(__name__) + +# Variables globales pour l'initialisation différée +_embeddings_model = None +_rag_handler_instance = None + +# Utiliser /tmp qui est toujours writable dans les conteneurs +VECTOR_STORE_PATH = "/tmp/vector_store" + +def get_embeddings_model(): + """Obtient le modèle d'embeddings avec initialisation différée.""" + global _embeddings_model + if _embeddings_model is None: + try: + from langchain_huggingface import HuggingFaceEmbeddings + logger.info("Initialisation du modèle d'embeddings...") + _embeddings_model = HuggingFaceEmbeddings( + model_name='sentence-transformers/all-MiniLM-L6-v2', + model_kwargs={'device': 'cpu'}, + encode_kwargs={'normalize_embeddings': True} + ) + logger.info("✅ Modèle d'embeddings initialisé avec succès") + except Exception as e: + logger.error(f"❌ Erreur lors de l'initialisation du modèle d'embeddings: {e}") + _embeddings_model = None + return _embeddings_model + +class RAGHandler: + def __init__(self, knowledge_base_path: str = "/app/knowledge_base", lazy_init: bool = True): + """ + Initialise le RAG Handler. + + Args: + knowledge_base_path (str): Le chemin vers le dossier contenant les documents de connaissances (.md). + lazy_init (bool): Si True, initialise le vector store seulement lors de la première utilisation. + """ + self.knowledge_base_path = knowledge_base_path + self.embeddings = None + self.vector_store = None + self._initialized = False + + # S'assurer que le répertoire /tmp/vector_store existe + os.makedirs(VECTOR_STORE_PATH, exist_ok=True) + + if not lazy_init: + self._initialize() + + def _initialize(self): + """Initialise le RAG Handler de manière différée.""" + if self._initialized: + return + + try: + logger.info("Initialisation du RAG Handler...") + self.embeddings = get_embeddings_model() + + if self.embeddings is None: + logger.error("Impossible d'initialiser les embeddings") + return + + self.vector_store = self._load_or_create_vector_store(self.knowledge_base_path) + self._initialized = True + logger.info("✅ RAG Handler initialisé avec succès") + + except Exception as e: + logger.error(f"❌ Erreur lors de l'initialisation du RAG Handler: {e}") + self._initialized = False + + def _load_documents(self, path: str) -> list: + """Charge les documents depuis un chemin de répertoire spécifié.""" + try: + if not os.path.exists(path): + logger.warning(f"Répertoire {path} non trouvé") + return [] + + loader = DirectoryLoader( + path, + glob="**/*.md", + loader_cls=TextLoader, + loader_kwargs={"encoding": "utf-8"} + ) + logger.info(f"Chargement des documents depuis : {path}") + documents = loader.load() + logger.info(f"✅ {len(documents)} documents chargés") + return documents + except Exception as e: + logger.error(f"❌ Erreur lors du chargement des documents: {e}") + return [] + + def _create_vector_store(self, knowledge_base_path: str) -> Optional[FAISS]: + """Crée et sauvegarde la base de données vectorielle à partir des documents.""" + try: + documents = self._load_documents(knowledge_base_path) + if not documents: + logger.warning("Aucun document trouvé - création d'un vector store vide") + # Créer un document fictif pour initialiser le vector store + from langchain.schema import Document + dummy_doc = Document( + page_content="Document de test pour initialiser le vector store", + metadata={"source": "dummy"} + ) + documents = [dummy_doc] + + logger.info(f"{len(documents)} documents chargés. Création des vecteurs...") + text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=100) + texts = text_splitter.split_documents(documents) + + vector_store = FAISS.from_documents(texts, self.embeddings) + + # Sauvegarder dans /tmp + try: + vector_store.save_local(VECTOR_STORE_PATH) + logger.info(f"✅ Vector store créé et sauvegardé dans : {VECTOR_STORE_PATH}") + except Exception as save_error: + logger.warning(f"⚠️ Impossible de sauvegarder le vector store: {save_error}") + # Continuer sans sauvegarde - le vector store reste en mémoire + + return vector_store + + except Exception as e: + logger.error(f"❌ Erreur lors de la création du vector store: {e}") + return None + + def _load_or_create_vector_store(self, knowledge_base_path: str) -> Optional[FAISS]: + """Charge le vector store s'il existe, sinon le crée.""" + try: + index_path = os.path.join(VECTOR_STORE_PATH, "index.faiss") + if os.path.exists(index_path): + logger.info(f"Chargement du vector store existant depuis : {VECTOR_STORE_PATH}") + return FAISS.load_local( + VECTOR_STORE_PATH, + embeddings=self.embeddings, + allow_dangerous_deserialization=True + ) + else: + logger.info("Aucun vector store trouvé. Création d'un nouveau...") + return self._create_vector_store(knowledge_base_path) + except Exception as e: + logger.error(f"❌ Erreur lors du chargement/création du vector store: {e}") + # En cas d'échec total, retourner None plutôt que planter + return None + + def get_relevant_feedback(self, query: str, k: int = 1) -> list[str]: + """Recherche les k conseils les plus pertinents pour une requête.""" + # Initialisation différée si nécessaire + if not self._initialized: + self._initialize() + + if not self.vector_store: + logger.warning("Vector store non disponible - retour de conseils génériques") + return [ + "Préparez vos réponses aux questions comportementales", + "Montrez votre motivation pour le poste", + "Donnez des exemples concrets de vos réalisations" + ] + + try: + results = self.vector_store.similarity_search(query, k=k) + feedback = [doc.page_content for doc in results if doc.page_content.strip()] + + # Fallback si pas de résultats pertinents + if not feedback: + return ["Conseil général: Préparez-vous bien pour les entretiens futurs."] + + return feedback + except Exception as e: + logger.error(f"❌ Erreur lors de la recherche: {e}") + return ["Conseil général: Travaillez sur vos compétences de communication."] + +# Fonction pour obtenir une instance partagée +def get_rag_handler() -> Optional[RAGHandler]: + """Obtient une instance partagée du RAG Handler.""" + global _rag_handler_instance + if _rag_handler_instance is None: + try: + _rag_handler_instance = RAGHandler(lazy_init=True) + except Exception as e: + logger.error(f"❌ Erreur lors de la création du RAG Handler: {e}") + _rag_handler_instance = None + return _rag_handler_instance + +if __name__ == '__main__': + print("Test du RAG Handler avec /tmp vector store...") + handler = RAGHandler(knowledge_base_path="/app/knowledge_base", lazy_init=False) + + test_query = "gestion du stress" + feedback = handler.get_relevant_feedback(test_query, k=2) + + print(f"\nTest de recherche pour : '{test_query}'") + if feedback: + print("Feedback trouvé :") + for f in feedback: + print(f"- {f[:150]}...") + else: + print("Aucun feedback trouvé.") \ No newline at end of file diff --git a/src/schemas/__init__.py b/src/schemas/__init__.py deleted file mode 100644 index ae3c6007c1265472202a4c217f37ba58a75d05ff..0000000000000000000000000000000000000000 --- a/src/schemas/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Empty file to mark as Python package diff --git a/src/schemas/feedback_schemas.py b/src/schemas/feedback_schemas.py deleted file mode 100644 index 42be0b098eae8d51bde582426d6cf1af1adc315d..0000000000000000000000000000000000000000 --- a/src/schemas/feedback_schemas.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Pydantic schemas for interview feedback output.""" - -from typing import List, Optional -from pydantic import BaseModel, Field - - -class CandidatFeedback(BaseModel): - """Feedback section for the candidate.""" - points_forts: List[str] = Field(..., description="Liste des points forts démontrés") - points_a_ameliorer: List[str] = Field(..., description="Liste des axes d'amélioration identifiés") - conseils_apprentissage: List[str] = Field(..., description="Ressources ou actions concrètes pour progresser") - score_global: int = Field(..., description="Note globale sur 100") - feedback_constructif: str = Field(..., description="Message bienveillant et constructif adressé au candidat") - - -class MetricsBreakdown(BaseModel): - """Detailed breakdown of scores.""" - communication: float = Field(..., description="Score 0-10: Orthographe, grammaire, clarté") - technique: float = Field(..., description="Score 0-10: Compétence technique (SOAR)") - comportemental: float = Field(..., description="Score 0-10: Soft skills (STAR)") - fidelite_cv: float = Field(..., description="Score 0-10: Cohérence avec le CV") - - -class FraudDetectionMetrics(BaseModel): - """Detailed fraud detection metrics (0-100).""" - vocab_score: int = Field(..., description="Usage de mots 'GPT' (delve, tapestry...)") - structure_score: int = Field(..., description="Patterns structurels (listes, longueur...)") - paste_score: int = Field(..., description="Basé sur l'événement copier-coller") - pattern_score: int = Field(..., description="Répétitions, questions en retour, proximité CV") - -class FraudDetection(BaseModel): - """Complete fraud analysis.""" - score_global: int = Field(..., description="Probabilité globale d'usage IA (0-100)") - red_flag: bool = Field(False, description="Vrai si score > 70") - detected_keywords: List[str] = Field(..., description="Mots suspects identifiés") - resume: str = Field(..., description="Eplication courte") - details: FraudDetectionMetrics - -class DashboardCompetences(BaseModel): - """Score de similarité basé sur la triade.""" - technique: float = Field(..., description="Adéquation Hard Skills (0-100)") - cognitive: float = Field(..., description="Raisonnement et structure (0-100)") - comportementale: float = Field(..., description="Soft Skills & Culture Fit (0-100)") - average_score: float = Field(..., description="Moyenne pondérée") - -class DecisionStrategique(BaseModel): - """Pilier décisionnel du rapport.""" - recommendation: str = Field(..., description="RECRUTER, APPROFONDIR ou REJETER") - action_plan: str = Field(..., description="Recommandation d'action immédiate (ex: Tester sur SQL)") - so_what: str = Field(..., description="Impact business direct du candidat (Le 'Pourquoi' stratégique)") - -class RolsPcdAnalysis(BaseModel): - """Analyse structurée ROLS et PCD.""" - rols_summary: str = Field(..., description="Résumé ROLS (Résumé, Objectifs, Localisation, Stratégie)") - pcd_analysis: str = Field(..., description="Analyse PCD (Produits, Clients, Distribution)") - -class EntrepriseInsights(BaseModel): - """Insights section for the recruiter/company.""" - correspondance_profil_offre: str = Field(..., description="Analyse de l'adéquation CV/Poste") - - # Dashboard - dashboard: DashboardCompetences = Field(..., description="Tableau de bord des compétences") - - # Qualitative Analysis - qualitative_analysis: RolsPcdAnalysis = Field(..., description="Analyse structurée ROLS/PCD") - - # Strategic Decision - decision: DecisionStrategique = Field(..., description="Aide à la décision") - - # Fraud & Cheat Detection - fraud_detection: FraudDetection = Field(..., description="Analyse complète anti-triche") - - # Legacy Metrics (kept for backward compatibility if needed, or mapped from dashboard) - metrics: MetricsBreakdown = Field(..., description="Détail des notes par catégorie") - - # Global verification - red_flag_detected: bool = Field(False, description="True si un comportement inacceptable est détecté") - red_flag_reason: Optional[str] = Field(None, description="Raison du Veto si applicable") - - -class FeedbackOutput(BaseModel): - """Complete feedback output with candidate and company sections.""" - candidat: CandidatFeedback - entreprise: EntrepriseInsights diff --git a/src/scoring_engine.py b/src/scoring_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..2d08c42d23244581e6576e0512da6e65c39626ae --- /dev/null +++ b/src/scoring_engine.py @@ -0,0 +1,102 @@ +import json +from datetime import datetime + +# Pondérations basées sur la fiche projet +CONTEXT_WEIGHTS = { + "formations": 0.3, + "projets": 0.6, + "expériences": 0.8, + "multiple": 1.0 +} + +# Facteurs pour la formule de scoring +ALPHA = 0.5 # Poids du contexte +BETA = 0.3 # Poids de la fréquence +GAMMA = 0.2 # Poids de la profondeur (durée) + +class ContextualScoringEngine: + def __init__(self, cv_data: dict): + self.cv_data = cv_data.get("candidat", {}) + self.full_text = self._get_full_text_from_cv() + + def _get_full_text_from_cv(self) -> str: + """Concatène tout le contenu textuel du CV pour le comptage de fréquence.""" + return json.dumps(self.cv_data, ensure_ascii=False).lower() + + def _parse_date(self, date_str: str) -> datetime: + """Parse une date, en gérant les cas spéciaux comme 'Aujourd'hui'.""" + if not date_str or date_str.lower() == "non spécifié": + return None + if date_str.lower() == "aujourd'hui": + return datetime.now() + try: + return datetime.strptime(date_str, "%Y") + except ValueError: + return None + + def _calculate_duration_in_years(self, start_date_str: str, end_date_str: str) -> float: + """Calcule la durée d'une expérience en années.""" + start_date = self._parse_date(start_date_str) + end_date = self._parse_date(end_date_str) + if start_date and end_date: + return abs((end_date - start_date).days / 365.25) + return 0.5 + + def calculate_scores(self) -> dict: + """Calcule les scores pondérés pour toutes les hard skills.""" + skills = self.cv_data.get("compétences", {}).get("hard_skills", []) + if not skills: + return {} + + scored_skills = [] + for skill in skills: + skill_lower = skill.lower() + contexts = [] + if skill_lower in json.dumps(self.cv_data.get("formations", []), ensure_ascii=False).lower(): + contexts.append(CONTEXT_WEIGHTS["formations"]) + if skill_lower in json.dumps(self.cv_data.get("projets", []), ensure_ascii=False).lower(): + contexts.append(CONTEXT_WEIGHTS["projets"]) + if skill_lower in json.dumps(self.cv_data.get("expériences", []), ensure_ascii=False).lower(): + contexts.append(CONTEXT_WEIGHTS["expériences"]) + + if len(contexts) > 1: + context_score = CONTEXT_WEIGHTS["multiple"] + elif contexts: + context_score = contexts[0] + else: + context_score = 0.1 + + # 2. Fréquence de mention + frequency_score = self.full_text.count(skill_lower) + + # 3. Profondeur d'utilisation (durée max en années) + max_duration = 0 + for exp in self.cv_data.get("expériences", []): + if skill_lower in json.dumps(exp, ensure_ascii=False).lower(): + duration = self._calculate_duration_in_years(exp.get("start_date"), exp.get("end_date")) + if duration > max_duration: + max_duration = duration + depth_score = max_duration + + # Normalisation simple (peut être affinée) + normalized_frequency = 1 - (1 / (1 + frequency_score)) + normalized_depth = 1 - (1 / (1 + depth_score)) + + # Calcul du score final + final_score = (ALPHA * context_score) + \ + (BETA * normalized_frequency) + \ + (GAMMA * normalized_depth) + + scored_skills.append({ + "skill": skill, + "score": round(final_score, 2), + "details": { + "context_score": context_score, + "frequency": frequency_score, + "max_duration_years": round(depth_score, 1) + } + }) + + # Trier par score décroissant + scored_skills.sort(key=lambda x: x["score"], reverse=True) + return {"analyse_competences": scored_skills} diff --git a/src/services/analysis_service.py b/src/services/analysis_service.py deleted file mode 100644 index 099fad2d89a2c7028e9c00b9e972135b6fe67a64..0000000000000000000000000000000000000000 --- a/src/services/analysis_service.py +++ /dev/null @@ -1,160 +0,0 @@ -import logging -import json -from typing import Dict, List, Any, Optional -from src.services.feedback_crew import FeedbackCrew -from src.services.integrity_service import IntegrityService -from src.services.search_service import SearchService -from src.config import crew_openai -from langchain_core.messages import HumanMessage, SystemMessage - -logger = logging.getLogger(__name__) - - -def _flatten_dict_values(d) -> list: - """Recursively flatten all values from a nested dict/list structure into a list of strings.""" - text = [] - if not isinstance(d, dict): - return [str(d)] - for k, v in d.items(): - if isinstance(v, dict): - text.extend(_flatten_dict_values(v)) - elif isinstance(v, list): - for i in v: - if isinstance(i, dict): - text.extend(_flatten_dict_values(i)) - else: - text.append(str(i)) - else: - text.append(str(v)) - return text - - -class AnalysisService: - def __init__(self): - self.integrity_service = IntegrityService() - self.search_service = SearchService() - self.llm = crew_openai() - - 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]: - """ - Runs the feedback analysis using CrewAI with enhanced pre-processing (Multi-Agent RAG). - If simulation_report is provided, it is used as the result, bypassing CrewAI. - """ - logger.info("Starting Interview Feedback Analysis...") - - if simulation_report: - logger.info("Using pre-computed Simulation Report. Bypassing CrewAI.") - try: - # Run Integrity Analysis (Cheating detection) - transcript_text = " ".join([m.get('content', '') for m in conversation_history if m.get('role') == 'user']) - # Extract CV text (flatten values for stylometry) - cv_text = ". ".join(_flatten_dict_values(cv_content)) if isinstance(cv_content, dict) else str(cv_content) - - integrity_report = self.integrity_service.analyze_integrity( - cv_text=cv_text, - interview_text=transcript_text, - existing_metrics=cheat_metrics - ) - - result = simulation_report.copy() - result["integrity_report"] = integrity_report - result["cheat_metrics"] = cheat_metrics - - return result - - except Exception as e: - logger.error(f"Error enriching simulation report: {e}") - return simulation_report - - logger.info("No Simulation Report provided. Fallback to CrewAI.") - - try: - # Parse inputs - if isinstance(job_description, str): - try: - job_offer_data = json.loads(job_description) - except json.JSONDecodeError: - job_offer_data = {"description": job_description} - else: - job_offer_data = job_description - - # Prepare text for analysis - transcript_text = " ".join([m.get('content', '') for m in conversation_history if m.get('role') == 'user']) - - # Extract CV text (flatten values for stylometry) - cv_text = ". ".join(_flatten_dict_values(cv_content)) if isinstance(cv_content, dict) else str(cv_content) - - # Run RAG Gap Analysis (Search Agent Grounding) - job_mission = job_offer_data.get('mission', '') or job_offer_data.get('description', '') - gap_analysis = self.search_service.analyze_gap(cv_text, job_mission) - - # Run Integrity Analysis - integrity_report = self.integrity_service.analyze_integrity( - cv_text=cv_text, - interview_text=transcript_text, - existing_metrics=cheat_metrics - ) - - # Detect Job Seniority (Robust LLM Method) - seniority = self._analyze_job_context_with_llm(job_offer_data) - - # Merge Metrics - enhanced_metrics = cheat_metrics or {} - enhanced_metrics.update({ - "integrity_report": integrity_report, - "semantic_score": gap_analysis.get('semantic_score', 0.0), - "required_seniority": seniority - }) - - logger.info(f"Enhanced Metrics: {enhanced_metrics}") - logger.info(f"Gap Analysis: {gap_analysis}") - - crew = FeedbackCrew( - job_offer=job_offer_data, - cv_content=cv_content, - conversation_history=conversation_history, - cheat_metrics=enhanced_metrics, - gap_analysis=gap_analysis - ) - - result = crew.run() - logger.info("Feedback Analysis completed successfully.") - return result - - except Exception as e: - logger.error(f"Error during feedback analysis: {e}", exc_info=True) - return {"error": str(e)} - - def _analyze_job_context_with_llm(self, job_data: Dict[str, Any]) -> str: - """ - Uses LLM to detect seniority context, avoiding false positives (e.g. 'Reporting to Head of Data'). - """ - try: - description = str(job_data) - prompt = f""" - ANALYSE LE CONTEXTE DE CETTE OFFRE D'EMPLOI : - {description} - - TÂCHE : Détermine le niveau de séniorité requis pour le CANDIDAT. - - RÈGLES CRITIQUES : - 1. "STAGE", "ALTERNANCE", "APPRENTISSAGE" = TOUJOURS "JUNIOR". - 2. Ignore les mentions de la hiérarchie (ex: "Sous la direction du Senior Manager" -> Le poste n'est PAS Senior). - 3. "Débutant", "Junior", "0-2 ans", "Sortie d'école" = "JUNIOR". - 4. "Lead", "Expert", "Manager", "+5 ans", "Architecte" = "SENIOR". - 5. Sinon -> "MID". - - Réponds UNIQUEMENT par un seul mot : JUNIOR, SENIOR ou MID. - """ - - response = self.llm.invoke([HumanMessage(content=prompt)]) - result = response.content.strip().upper() - - if result not in ["JUNIOR", "SENIOR", "MID"]: - return "MID" # Fallback - - return result - - except Exception as e: - logger.error(f"Error in LLM seniority detection: {e}") - return "MID" # Safe fallback \ No newline at end of file diff --git a/src/services/config/agents.yaml b/src/services/config/agents.yaml deleted file mode 100644 index 34a7f09797dd2da26c14c5d638e5f1f8344a6d50..0000000000000000000000000000000000000000 --- a/src/services/config/agents.yaml +++ /dev/null @@ -1,53 +0,0 @@ -consistency_analyst: - role: "Analyste de Cohérence" - goal: "Vérifier la véracité et la cohérence des propos du candidat par rapport à son CV." - backstory: > - Expert en détection de fraude et de triche. Vous ne tolérez aucune déviation éthique. - Votre focus: Trolling, Usage IA générative, Copier-Coller. - Vous travaillez avec des données précises de "Gap Analysis". - verbose: true - allow_delegation: false - -search_analyst: - role: "Analyste Grounding & RAG" - goal: "Identifier les écarts factuels (Hidden Skill Gaps) entre le CV et le poste." - backstory: > - Spécialiste de l'analyse sémantique et de la comparaison de données. - Vous détectez les profils en reconversion et valorisez leur potentiel d'apprentissage. - Vous vérifiez si le candidat possède les "Action Verbs" de production (Déployer, Monitorer). - verbose: true - allow_delegation: false - -tech_expert: - role: "Expert Technique (Adapté au contexte)" - goal: "Valider les compétences techniques spécifiques au rôle ({job_type})." - backstory: > - CTO pragmatique. Vous adaptez vos exigences au type de poste : - - Pour DATA ANALYST : Vous cherchez SQL, PowerBI, la clarté des insights et l'automatisation simple. - - Pour DATA SCIENTIST : Vous cherchez la méthode scientifique (Métriques, Biais, Context Engineering). - - Pour DATA ENGINEER : Vous cherchez CI/CD, Docker, Cloud, Latence, Production. - - 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. - verbose: true - allow_delegation: false - -business_evaluator: - role: "Stratège Business (ROLS & PCD)" - goal: "Évaluer la capacité du candidat à résoudre des problèmes business complexes." - backstory: > - Consultant senior en stratégie. Vous évaluez la vision business du candidat. - Vous appliquez strictement les cadres ROLS (Résumé, Objectifs, Localisation, Stratégie) - et PCD (Produits, Clients, Distribution) pour structurer votre analyse. - Vous cherchez le "So What?" dans chaque réponse. - verbose: true - allow_delegation: false - -final_reporter: - role: "Responsable Recrutement" - goal: "Synthétiser toutes les analyses pour une prise de décision stratégique." - backstory: > - Décideur final. Vous consolidez les rapports des experts (Tech, Business, Search). - Vous devez trancher : RECRUTER, APPROFONDIR ou REJETER. - Vous prenez en compte le statut "Reconversion" pour ajuster votre tolérance sur l'expérience. - verbose: true - allow_delegation: false diff --git a/src/services/config/tasks.yaml b/src/services/config/tasks.yaml deleted file mode 100644 index 31f9e82d9b8457ce23333d808e2e9e0369fcc3a6..0000000000000000000000000000000000000000 --- a/src/services/config/tasks.yaml +++ /dev/null @@ -1,97 +0,0 @@ -consistency_task: - description: > - Analyse la conversation pour détecter la fraude. - Données d'intégrité: {cheat_metrics} - - Règles: - - Si 'ai_score' > 75 -> STOP/VETO. - - Si insultes/trolling -> STOP/VETO. - expected_output: "Statut Fraude (RAS, ALERTE, VETO) avec justification." - agent: consistency_analyst - -search_task: - description: > - Analyse l'écart CV vs Offre (Gap Analysis). - Données Gap Analysis: {gap_analysis} - - 1. Confirme si c'est une RÉCONVERSION (basé sur 'is_reconversion'). - 2. Identifie les compétences manquantes (Hidden Skill Gaps). - 3. Vérifie la présence des 'Action Verbs' de production. - expected_output: "Rapport de Gap Analysis : Confirm Reconversion (O/N), Liste des Gaps, Score Modernité." - agent: search_analyst - -tech_task: - description: > - Évaluation Technique Approfondie (Methodologie SOAR) adaptée au poste : {job_type}. - - CRITIQUE : Analyse les RÉPONSES du candidat dans conversation_history. - Ce que le candidat DIT prévaut sur ce qui est écrit dans le CV. - Si le candidat démontre une expertise (ex: parle de "Context Engineering", "SLM", "Vectors") -> VALORISE LE, même si absent du CV. - - 1. Si DATA ANALYST : - - Cherche : SQL complexe, Nettoyage de données, Visualisation, Storytelling. - - Ignore : CI/CD, Docker, Kubernetes. - 2. Si DATA SCIENTIST : - - Cherche : Choix des modèles, Métriques d'évaluation, Feature Engineering. - 3. Si DATA ENGINEER : - - Cherche : CI/CD, Code Quality, Scalabilité, Monitoring. - - Utilise SOAR (Situation, Obstacle, Action, Résultat). - Note sur 10. NE Mets JAMAIS 0 si le candidat a les bases mais manque d'expérience pro. - expected_output: "Évaluation Tech détaillée + Note /100." - agent: tech_expert - -business_task: - description: > - Évaluation Stratégique & Business (ROLS & PCD). - - 1. Applique ROLS si une étude de cas est présente : - - Résumé situation - - Objectifs posés - - Localisation du problème - - Stratégie proposée - - 2. Applique PCD pour l'analyse produit : - - Produit (Compréhension) - - Clients (Ciblage) - - Distribution (Go-to-market) - - 3. Cherche le "So What?" : Le candidat lie-t-il la tech au business ? - expected_output: "Analyse ROLS/PCD structurée + Note Business /100." - agent: business_evaluator - -reporting_task: - description: > - Synthèse Décisionnelle Finale avec Scoring Dynamique. - - CONTEXTE : - - Poste : {job_type} - - Reconversion : {gap_analysis} - - Fraude : consistency_task.output - - RÈGLES DE SCORING STRICTES : - 1. Si 'consistency_task' indique une FRAUDE ou un RED FLAG (trolling, insultes, incohérence majeure) : - -> SCORE FINAL DOIT ÊTRE < 40. REJET IMMÉDIAT. - 2. Si Pas de Red Flag : - -> Utilisez toute l'échelle (10-90). - -> Un débutant motivé mérite ~50-60. - -> Un expert mérite > 80. - - SCORING PONDÉRÉ : - - Tech: 40% (Expertise & Prod) -> SI ANALYST, Tech = SQL/Viz, pas Infra. - - Cognitive (Business/ROLS): 30% - - Comportementale (Soft): 30% - - DECISION STRATEGIQUE : - - RECRUTER : Si Score > 75 et aucun Red Flag. - - APPROFONDIR : Si Score 50-75 ou doute sur un Gap. - - REJETER : Si Score < 50 ou Veto Fraude. - - Génère le JSON final 'FeedbackOutput'. - expected_output: "JSON complet respectant le schéma FeedbackOutput." - agent: final_reporter - context: - - consistency_task - - search_task - - tech_task - - business_task diff --git a/src/services/feedback_crew.py b/src/services/feedback_crew.py deleted file mode 100644 index 0241be5230675a59de7ac194ee4ef1f7d26e6851..0000000000000000000000000000000000000000 --- a/src/services/feedback_crew.py +++ /dev/null @@ -1,130 +0,0 @@ -"""Feedback Crew for interview analysis using CrewAI.""" - -import json -import logging -from typing import Dict, Any, List - -from crewai import Agent, Crew, Process, Task -from crewai.project import CrewBase, agent, task, crew - -from src.config import crew_openai -from src.schemas.feedback_schemas import FeedbackOutput - -logger = logging.getLogger(__name__) - - -@CrewBase -class FeedbackCrew: - """Crew for analyzing interview conversations and generating structured feedback.""" - - agents_config = "config/agents.yaml" - tasks_config = "config/tasks.yaml" - - 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): - self.job_offer = json.dumps(job_offer, ensure_ascii=False) - self.cv_content = cv_content - self.conversation_history = json.dumps(conversation_history, ensure_ascii=False) - self.cheat_metrics = json.dumps(cheat_metrics or {}, ensure_ascii=False) - self.gap_analysis = json.dumps(gap_analysis or {}, ensure_ascii=False) - self._llm = crew_openai() - - # --- Agents --- - - @agent - def consistency_analyst(self) -> Agent: - return Agent(config=self.agents_config["consistency_analyst"], llm=self._llm) - - @agent - def search_analyst(self) -> Agent: - return Agent(config=self.agents_config["search_analyst"], llm=self._llm) - - @agent - def tech_expert(self) -> Agent: - return Agent(config=self.agents_config["tech_expert"], llm=self._llm) - - @agent - def business_evaluator(self) -> Agent: - return Agent(config=self.agents_config["business_evaluator"], llm=self._llm) - - @agent - def final_reporter(self) -> Agent: - return Agent(config=self.agents_config["final_reporter"], llm=self._llm) - - # --- Tasks --- - - @task - def consistency_task(self) -> Task: - return Task(config=self.tasks_config["consistency_task"]) - - @task - def search_task(self) -> Task: - return Task(config=self.tasks_config["search_task"]) - - @task - def tech_task(self) -> Task: - return Task(config=self.tasks_config["tech_task"]) - - @task - def business_task(self) -> Task: - return Task(config=self.tasks_config["business_task"]) - - @task - def reporting_task(self) -> Task: - return Task( - config=self.tasks_config["reporting_task"], - output_pydantic=FeedbackOutput - ) - - # --- Crew --- - - @crew - def crew(self) -> Crew: - return Crew( - agents=self.agents, - tasks=self.tasks, - process=Process.sequential, - verbose=True - ) - - # --- Public API --- - - def run(self) -> Dict[str, Any]: - """Execute the feedback crew and return structured output.""" - logger.info("Starting Feedback Crew analysis...") - - # Safe extraction of job_type from gap_analysis string or dict - job_type = "GENERAL_TECH" - try: - ga_data = json.loads(self.gap_analysis) if isinstance(self.gap_analysis, str) else self.gap_analysis - job_type = ga_data.get("job_type", "GENERAL_TECH") - except: - pass - - inputs = { - "job_offer": self.job_offer, - "cv_content": self.cv_content, - "conversation_history": self.conversation_history, - "cheat_metrics": self.cheat_metrics, - "gap_analysis": self.gap_analysis, - "job_type": job_type - } - - result = self.crew().kickoff(inputs=inputs) - - # Handle structured output - if result.pydantic: - return result.pydantic.model_dump() - elif result.json_dict: - return result.json_dict - else: - logger.warning("No structured output, parsing raw result") - try: - raw_str = str(result.raw) - start = raw_str.find('{') - end = raw_str.rfind('}') + 1 - if start != -1 and end > start: - return json.loads(raw_str[start:end]) - except json.JSONDecodeError as e: - logger.error(f"JSON parsing failed: {e}") - - return {"error": "Could not parse output", "raw": str(result.raw)} diff --git a/src/services/graph_service.py b/src/services/graph_service.py deleted file mode 100644 index 15b8b4095152716396d07f19e41b4747603c9909..0000000000000000000000000000000000000000 --- a/src/services/graph_service.py +++ /dev/null @@ -1,598 +0,0 @@ -import os -import logging -import json -from pathlib import Path -from typing import TypedDict, Annotated, Dict, Any, List, Optional - -import redis -from langchain_openai import ChatOpenAI -from langchain_core.messages import BaseMessage, AIMessage, HumanMessage, SystemMessage -from langgraph.graph import StateGraph, END -from langgraph.graph.message import add_messages -from langtrace_python_sdk import langtrace - -from src.services.simulation.agents import InterviewAgentExtractor -from src.services.simulation.schemas import ( - IceBreakerOutput, TechnicalOutput, BehavioralOutput, SituationOutput, SimulationReport -) - -langtrace.init(api_key=os.getenv("LANGTRACE_API_KEY")) - -logger = logging.getLogger(__name__) -PROMPTS_DIR = Path(__file__).parent.parent / "prompts" - -# Number of questions per agent -QUESTIONS_PER_AGENT = { - "icebreaker": 3, - "auditeur": 3, - "enqueteur": 2, - "stratege": 2, - "projecteur": 1 -} - -AGENT_ORDER = ["icebreaker", "auditeur", "enqueteur", "stratege", "projecteur"] - - - - -class AgentState(TypedDict): - messages: Annotated[list[BaseMessage], add_messages] - user_id: str - job_offer_id: str - cv_data: Dict[str, Any] - job_data: Dict[str, Any] - section: str - turn_count: int - context: Dict[str, Any] - cheat_metrics: Dict[str, Any] - - # Structured Data - icebreaker_data: Optional[IceBreakerOutput] - technical_data: Optional[TechnicalOutput] - behavioral_data: Optional[BehavioralOutput] - situation_data: Optional[SituationOutput] - simulation_report: Optional[SimulationReport] - - -class GraphInterviewProcessor: - """Strict interview simulation with per-agent question limits.""" - - def __init__(self, payload: Dict[str, Any]): - logging.info("Initialisation de RONI GraphInterviewProcessor...") - - self.user_id = payload["user_id"] - self.job_offer_id = payload["job_offer_id"] - self.job_offer = payload["job_offer"] - self.cv_data = payload.get("cv_document", {}).get('candidat', {}) - - if not self.cv_data: - raise ValueError("Données du candidat non trouvées.") - - self.prompts = self._load_all_prompts() - self.llm = ChatOpenAI(api_key=os.getenv("OPENAI_API_KEY"), model="gpt-4o-mini", temperature=0.7) - self.extractor = InterviewAgentExtractor(self.llm) - - redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0") - self.redis_client = redis.Redis.from_url(redis_url, decode_responses=True) - - self.graph = self._build_graph() - logging.info("RONI Graph initialisé.") - - def _load_all_prompts(self) -> Dict[str, str]: - prompts = {} - prompt_files = { - "icebreaker": PROMPTS_DIR / "agent_icebreaker.txt", - "auditeur": PROMPTS_DIR / "agent_auditeur.txt", - "enqueteur": PROMPTS_DIR / "agent_enqueteur.txt", - "stratege": PROMPTS_DIR / "agent_stratege.txt", - "projecteur": PROMPTS_DIR / "agent_projecteur.txt" - } - for key, path in prompt_files.items(): - try: - prompts[key] = path.read_text(encoding='utf-8-sig') - except FileNotFoundError: - logger.error(f"Missing prompt: {path}") - prompts[key] = f"You are {key}." - except UnicodeDecodeError: - logger.warning(f"Encoding issue with {path}, trying latin-1") - prompts[key] = path.read_text(encoding='latin-1') - return prompts - - def _count_user_messages(self, messages: List[BaseMessage]) -> int: - """Count user messages from conversation history.""" - return sum(1 for m in messages if isinstance(m, HumanMessage)) - - def _determine_section_and_status(self, user_msg_count: int) -> tuple[str, bool]: - """ - Determine current section based on user message count. - Returns: (section_name, should_end) - """ - cumulative = 0 - - for agent in AGENT_ORDER: - agent_questions = QUESTIONS_PER_AGENT[agent] - if user_msg_count < cumulative + agent_questions: - return agent, False - - cumulative += agent_questions - return "end", True - - # --- Context builders --- - - # --- Context builders --- - - def _get_icebreaker_context(self, state: AgentState) -> str: - cv = state["cv_data"] - prenom = cv.get("first_name", "Candidat") - job = state["job_data"] - - # Extract Hobbies/Interests if available - hobbies = ", ".join(cv.get("centres_interet", [])) - if not hobbies: - hobbies = "Non spécifiés" - - # --- Extract Reconversion Context --- - reconversion_data = cv.get("reconversion", {}) - is_reco = reconversion_data.get("is_reconversion", False) - reco_context_str = "NON" - if is_reco: - original_job = reconversion_data.get("context", "Ancien métier non spécifié") - reco_context_str = f"OUI. Contexte transition: {original_job}" - - # --- Extract Student Context --- - etudiant_data = cv.get("etudiant", {}) - is_etudiant = etudiant_data.get("is_etudiant", False) - - niveau_etudes = etudiant_data.get("niveau_etudes", "Non spécifié") - specialite = etudiant_data.get("specialite", "Non spécifiée") - - etudiant_context_str = "NON" - if is_etudiant: - etudiant_context_str = f"OUI. Niveau: {niveau_etudes}, Spécialité: {specialite}" - - # --- Internship / Alternance Detection --- - job_title = job.get('poste', '').lower() - contract_type = job.get('contrat', '').lower() - is_internship_or_alternance = any(k in job_title or k in contract_type for k in ['stage', 'alternance', 'apprentissage', 'contrat pro']) - - type_contrat_str = "CDI/CDD Standard" - if is_internship_or_alternance: - type_contrat_str = "STAGE / ALTERNANCE" - - # --- Priority Logic (Student vs Reconversion) --- - focus_point = "STANDARD" - if is_reco: - focus_point = "RECONVERSION" - elif is_etudiant: - focus_point = "ETUDIANT" - - # --- Background Mismatch Detection (if not student/reco) --- - last_exp_title = "" - experiences = cv.get("expériences", []) - if experiences: - last_exp_title = experiences[0].get("Poste", "") - - return f""" - === CONTEXTE CANDIDAT === - PRENOM: {prenom} - POSTE VISÉ: {job.get('poste', 'Non spécifié')} - ENTREPRISE: {job.get('entreprise', 'Non spécifié')} - TYPE DE CONTRAT DÉTECTÉ: {type_contrat_str} - - === DOSSIER PERSONNEL === - CENTRES D'INTÉRÊT: {hobbies} - - === ANALYSE PROFIL === - IS_RECONVERSION: {reco_context_str} - IS_ETUDIANT: {etudiant_context_str} - DERNIER POSTE OCCUPÉ: {last_exp_title} - - === FOCUS PRIORITAIRE === - POINT D'ENTRÉE SUGGÉRÉ: {focus_point} - (Si RECONVERSION -> Creuser motivation transition. Si ETUDIANT -> Valider niveau/spécialité. Si STANDARD -> Vérifier cohérence parcours/poste) - """ - - def _get_auditeur_context(self, state: AgentState) -> str: - cv = state["cv_data"] - job = state["job_data"] - experiences = json.dumps(cv.get("expériences", []), ensure_ascii=False) - - # Format Projects with details - projets_list = cv.get("projets", {}).get("professional", []) + cv.get("projets", {}).get("personal", []) - projets_formatted = [] - for p in projets_list: - techs = ", ".join(p.get("technologies", [])) - outcomes = ", ".join(p.get("outcomes", [])) - projets_formatted.append(f"- {p.get('title')}: {techs} (Résultats: {outcomes})") - projets_str = "\n".join(projets_formatted) - - # Format Skills with Context - skills_ctx = cv.get("compétences", {}).get("skills_with_context", []) - skills_formatted = [f"{s.get('skill')} ({s.get('context')})" for s in skills_ctx] - skills_str = ", ".join(skills_formatted) - - # Fallback to hard_skills if no context - if not skills_str: - skills_str = ", ".join(cv.get("compétences", {}).get("hard_skills", [])) - - # Add Icebreaker Data - ib_data = state.get("icebreaker_data") - ib_context = "" - if ib_data: - ib_context = f""" - === PROFIL DÉTECTÉ (ICEBREAKER) === - TYPE: {ib_data.profil_type} - EXPÉRIENCE DOMAINE: {ib_data.annees_experience_domaine} - MOTIVATION: {"OUI" if ib_data.motivation_detectee else "NON"} - CONTEXTE: {ib_data.contexte_specifique} - """ - - return f""" - === CONTEXTE POSTE (TECHNIQUE) === - MISSION: {job.get('mission', '')} - DESCRIPTION SYNTHÉTIQUE: {job.get('description_nettoyee', '')} - COMPÉTENCES REQUISES: {job.get('competences', '')} - PÔLE: {job.get('pole', '')} - - === CONTEXTE TECHNIQUE CANDIDAT === - EXPÉRIENCES: {experiences} - PROJETS SIGNIFICATIFS: - {projets_str} - - COMPÉTENCES ET CONTEXTE: - {skills_str} - {ib_context} - """ - - def _get_enqueteur_context(self, state: AgentState) -> str: - cv = state["cv_data"] - job = state["job_data"] - soft_skills = ", ".join(cv.get("compétences", {}).get("soft_skills", [])) - reconversion = cv.get("reconversion", {}) - is_reco = reconversion.get("is_reconversion", False) - reco_txt = "OUI" if is_reco else "NON" - - # Add Technical Data (Gaps) - tech_data = state.get("technical_data") - tech_context = "" - if tech_data: - lacunes = [f"- {l.skill} (Niveau {l.niveau_detecte})" for l in tech_data.lacunes_explorees] - tech_context = f""" - === BILAN TECHNIQUE === - SCORE GLOBAL: {tech_data.score_technique_global}/5 - LACUNES IDENTIFIÉES: - {chr(10).join(lacunes)} - """ - - return f""" - === CONTEXTE POSTE (HUMAIN) === - PROFIL RECHERCHÉ: {job.get('profil_recherche', '')} - CULTURE/VALEURS (PÔLE): {job.get('pole', '')} - - === CONTEXTE COMPORTEMENTAL CANDIDAT === - SOFT SKILLS: {soft_skills} - RECONVERSION: {reco_txt} - {tech_context} - """ - - def _get_stratege_context(self, state: AgentState) -> str: - job = state["job_data"] - - # Add Behavioral Data - beh_data = state.get("behavioral_data") - beh_context = "" - if beh_data: - beh_context = f""" - === BILAN COMPORTEMENTAL === - SCORE GLOBAL: {beh_data.score_comportemental_global}/5 - POINTS À INTÉGRER: {", ".join(beh_data.points_a_integrer_mise_en_situation)} - """ - - return f""" - === CONTEXTE SJT (MISE EN SITUATION) === - MISSION: {job.get('mission', '')} - PROFIL RECHERCHÉ: {job.get('profil_recherche', '')} - {beh_context} - """ - - def _get_projecteur_context(self, state: AgentState) -> str: - job = state["job_data"] - return f""" - === CONTEXTE PROJECTION === - ENTREPRISE: {job.get('entreprise', '')} - DESCRIPTION POSTE (NETTOYÉE): {job.get('description_nettoyee', '')} - PÔLE: {job.get('pole', '')} - - NOTE: C'est la dernière question de l'entretien. - """ - - # --- Orchestrator (strict per-agent logic) --- - - def _orchestrator_node(self, state: AgentState): - messages = state["messages"] - user_msg_count = self._count_user_messages(messages) - - section, should_end = self._determine_section_and_status(user_msg_count) - - logger.info(f"Orchestrator: {user_msg_count} user messages -> section={section}, end={should_end}") - - context_updates = state.get("context", {}).copy() - next_dest = "end_interview" if should_end else section - context_updates["next_dest"] = next_dest - - # Trigger Extraction based on transitions - extract_target = None - if section == "auditeur" and not state.get("icebreaker_data"): - extract_target = "icebreaker" - elif section == "enqueteur" and not state.get("technical_data"): - extract_target = "technical" - elif section == "stratege" and not state.get("behavioral_data"): - extract_target = "behavioral" - elif section == "projecteur" and not state.get("situation_data"): - extract_target = "situation" - - # Check for Final Report extraction - if should_end: - if not state.get("situation_data") and not context_updates.get("situation_attempted"): - extract_target = "situation" - elif not state.get("simulation_report") and not context_updates.get("report_attempted"): - extract_target = "report" - - if extract_target: - context_updates["extract_target"] = extract_target - logger.info(f"Triggering extraction for: {extract_target}") - - return {"section": section, "context": context_updates} - - def _extraction_node(self, state: AgentState): - target = state["context"].get("extract_target") - logger.info(f"Running extraction node for: {target}") - updates = {} - ctx = state["context"].copy() - - try: - if target == "icebreaker": - updates["icebreaker_data"] = self.extractor.extract_icebreaker(state["messages"], state["cv_data"]) - elif target == "technical": - updates["technical_data"] = self.extractor.extract_technical(state["messages"], state["job_data"]) - elif target == "behavioral": - updates["behavioral_data"] = self.extractor.extract_behavioral(state["messages"]) - elif target == "situation": - updates["situation_data"] = self.extractor.extract_situation(state["messages"]) - elif target == "report": - updates["simulation_report"] = self.extractor.extract_simulation_report( - state["messages"], - state.get("icebreaker_data"), - state.get("technical_data"), - state.get("behavioral_data"), - state.get("situation_data") - ) - except Exception as e: - logger.error(f"Extraction failed for {target}: {e}", exc_info=True) - - # Mark attempt to avoid infinite loops - if target == "situation": - ctx["situation_attempted"] = True - elif target == "report": - ctx["report_attempted"] = True - - # Clear extract_target safely - ctx.pop("extract_target", None) - - return {**updates, "context": ctx} - - # --- Agent runner --- - - def _run_agent(self, state: AgentState, agent_key: str, context_str: str): - prompt_template = self.prompts[agent_key] - - # Extract first name - cv_info = state["cv_data"].get("info_personnelle", {}) - prenom = cv_info.get("first_name", "") - - # Get question limit - nb_questions = QUESTIONS_PER_AGENT.get(agent_key, 2) - - try: - instructions = prompt_template.format( - user_id=state["user_id"], - first_name=prenom, - nb_questions=nb_questions, - poste=state["job_data"].get("poste", "Poste non spécifié"), - entreprise=state["job_data"].get("entreprise", "Entreprise confidentielle") - ) - except KeyError: - instructions = prompt_template - - system_msg = f"{instructions}\n\n{context_str}" - messages = [SystemMessage(content=system_msg)] + list(state["messages"]) - response = self.llm.invoke(messages) - - return {"messages": [response]} - - # --- Agent nodes --- - - def _icebreaker_node(self, s): return self._run_agent(s, "icebreaker", self._get_icebreaker_context(s)) - def _auditeur_node(self, s): return self._run_agent(s, "auditeur", self._get_auditeur_context(s)) - def _enqueteur_node(self, s): return self._run_agent(s, "enqueteur", self._get_enqueteur_context(s)) - def _stratege_node(self, s): return self._run_agent(s, "stratege", self._get_stratege_context(s)) - def _projecteur_node(self, s): return self._run_agent(s, "projecteur", self._get_projecteur_context(s)) - - # --- Final analysis --- - - def _final_analysis_node(self, state: AgentState): - """Trigger background analysis and return exit message.""" - from src.tasks import run_analysis_task - - logger.info("=== FINAL ANALYSIS TRIGGERED ===") - - try: - hist = [{"role": ("user" if isinstance(m, HumanMessage) else "assistant"), "content": m.content} for m in state["messages"]] - - simulation_report_dict = None - if state.get("simulation_report"): - simulation_report_dict = state["simulation_report"].dict() - - run_analysis_task.delay( - user_id=state['user_id'], - job_offer_id=state['job_offer_id'], - job_description=json.dumps(state['job_data'], ensure_ascii=False), - conversation_history=hist, - cv_content=json.dumps(state['cv_data'], ensure_ascii=False), - cheat_metrics=state.get('cheat_metrics', {}), - simulation_report=simulation_report_dict - ) - logger.info("Background analysis task enqueued via Celery") - except Exception as e: - logger.error(f"Failed to enqueue analysis task: {e}") - - # Generate contextual dynamic exit message - last_user_msg = state["messages"][-1].content if state["messages"] and isinstance(state["messages"][-1], HumanMessage) else "" - - cv = state.get("cv_data", {}) - prenom = cv.get("info_personnelle", {}).get("first_name", "Candidat") - job = state.get("job_data", {}) - poste = job.get("poste", "le poste visé") - entreprise = job.get("entreprise", "notre entreprise") - - closing_prompt = ( - f"Tu es RONI, un recruteur expert en IA. L'entretien pour le poste de {poste} chez {entreprise} est terminé.\n" - f"Le candidat s'appelle {prenom}.\n\n" - f"Dernier message du candidat : \"{last_user_msg}\"\n\n" - f"Tâche : Rédige le message de clôture de l'entretien.\n" - f"Consignes obligatoires :\n" - f"1. Si le candidat a posé une question ou fait une remarque finale, réponds-y brièvement et aimablement.\n" - f"2. Remercie {prenom} pour cet échange.\n" - f"3. Informe-le que l'analyse complète de l'entretien est en cours et que son rapport détaillé sera disponible dans quelques instants.\n" - f"4. Adopte un ton chaleureux, professionnel et encourageant.\n" - f"5. IMPORTANT : NE POSE AUCUNE QUESTION. Ce message marque la fin définitive de la discussion.\n" - f"6. Fais varier la formulation pour ne pas être robotique." - ) - - try: - ai_response = self.llm.invoke([SystemMessage(content=closing_prompt)]) - final_content = ai_response.content - except Exception as e: - logger.error(f"Error generating closing message: {e}") - final_content = "Merci pour cet échange. Votre rapport d'analyse sera disponible dans quelques instants." - - return {"messages": [AIMessage(content=final_content)], "context": {"next_dest": "end_interview"}} - - # --- Routing --- - - def _route_next_step(self, state: AgentState) -> str: - if state.get("context", {}).get("extract_target"): - return "extraction_node" - - dest = state.get("context", {}).get("next_dest", "icebreaker") - if dest == "end_interview": - if not state.get("simulation_report") and not state.get("context", {}).get("report_attempted"): - return "orchestrator" - return "final_analysis" - return f"{dest}_agent" - - # --- Graph builder --- - - def _build_graph(self) -> any: - graph = StateGraph(AgentState) - - graph.add_node("orchestrator", self._orchestrator_node) - graph.add_node("extraction_node", self._extraction_node) - graph.add_node("icebreaker_agent", self._icebreaker_node) - graph.add_node("auditeur_agent", self._auditeur_node) - graph.add_node("enqueteur_agent", self._enqueteur_node) - graph.add_node("stratege_agent", self._stratege_node) - graph.add_node("projecteur_agent", self._projecteur_node) - graph.add_node("final_analysis", self._final_analysis_node) - - graph.set_entry_point("orchestrator") - - routing_map = { - "extraction_node": "extraction_node", - "icebreaker_agent": "icebreaker_agent", - "auditeur_agent": "auditeur_agent", - "enqueteur_agent": "enqueteur_agent", - "stratege_agent": "stratege_agent", - "projecteur_agent": "projecteur_agent", - "final_analysis": "final_analysis", - "orchestrator": "orchestrator" - } - - graph.add_conditional_edges("orchestrator", self._route_next_step, routing_map) - graph.add_conditional_edges("extraction_node", self._route_next_step, routing_map) - - for node in ["icebreaker_agent", "auditeur_agent", "enqueteur_agent", "stratege_agent", "projecteur_agent", "final_analysis"]: - graph.add_edge(node, END) - - return graph.compile() - - # --- Public API --- - - def invoke(self, messages: List[Dict[str, Any]], cheat_metrics: Dict[str, Any] = None): - langchain_messages = [HumanMessage(content=m["content"]) if m["role"] == "user" else AIMessage(content=m["content"]) for m in messages] - - if not langchain_messages: - langchain_messages.append(HumanMessage(content="Bonjour")) - - initial_state = { - "user_id": self.user_id, - "job_offer_id": self.job_offer_id, - "messages": langchain_messages, - "cv_data": self.cv_data, - "job_data": self.job_offer, - "section": "icebreaker", - "turn_count": 0, - "context": {}, - "cheat_metrics": cheat_metrics or {} - } - - # Load state from Redis - redis_key = f"interview_state:{self.user_id}" - saved_state_json = self.redis_client.get(redis_key) - if saved_state_json: - try: - saved_state = json.loads(saved_state_json) - initial_state["context"] = saved_state.get("context", {}) - if saved_state.get("icebreaker_data"): - initial_state["icebreaker_data"] = IceBreakerOutput(**saved_state["icebreaker_data"]) - if saved_state.get("technical_data"): - initial_state["technical_data"] = TechnicalOutput(**saved_state["technical_data"]) - if saved_state.get("behavioral_data"): - initial_state["behavioral_data"] = BehavioralOutput(**saved_state["behavioral_data"]) - if saved_state.get("situation_data"): - initial_state["situation_data"] = SituationOutput(**saved_state["situation_data"]) - if saved_state.get("simulation_report"): - initial_state["simulation_report"] = SimulationReport(**saved_state["simulation_report"]) - except Exception as e: - logger.error(f"Failed to load state from Redis: {e}") - - final_state = self.graph.invoke(initial_state) - - # Save updated state to Redis - try: - state_to_save = { - "context": final_state.get("context", {}), - } - if final_state.get("icebreaker_data"): - state_to_save["icebreaker_data"] = final_state["icebreaker_data"].dict() - if final_state.get("technical_data"): - state_to_save["technical_data"] = final_state["technical_data"].dict() - if final_state.get("behavioral_data"): - state_to_save["behavioral_data"] = final_state["behavioral_data"].dict() - if final_state.get("situation_data"): - state_to_save["situation_data"] = final_state["situation_data"].dict() - if final_state.get("simulation_report"): - state_to_save["simulation_report"] = final_state["simulation_report"].dict() - - self.redis_client.setex(redis_key, 86400, json.dumps(state_to_save)) # Expires in 24 hours - except Exception as e: - logger.error(f"Failed to save state to Redis: {e}") - - if not final_state or not final_state['messages']: - return {"response": "Erreur système.", "status": "finished"} - - last_msg = final_state['messages'][-1] - is_finished = final_state.get("context", {}).get("next_dest") == "end_interview" - status = "interview_finished" if is_finished else "interviewing" - - return {"response": last_msg.content, "status": status} \ No newline at end of file diff --git a/src/services/integrity_service.py b/src/services/integrity_service.py deleted file mode 100644 index af0d9f822533adcd028efd857074eec2be54ac38..0000000000000000000000000000000000000000 --- a/src/services/integrity_service.py +++ /dev/null @@ -1,67 +0,0 @@ -import logging -from src.services.nlp_service import NLPService -from typing import Dict, Any - -logger = logging.getLogger(__name__) - -class IntegrityService: - def __init__(self): - self.nlp = NLPService() - - def analyze_integrity(self, cv_text: str, interview_text: str, existing_metrics: Dict[str, Any] = None) -> Dict[str, Any]: - """ - Combines stylometric analysis and AI detection to produce an integrity report. - """ - logger.info("Starting Integrity Analysis...") - - # 1. AI Detection on Interview Transcript - interview_metrics = self.nlp.compute_all_metrics(interview_text) - - # 2. Stylometric Consistency (CV vs Interview) - # We only care about consistency if we have enough text - stylometric_flag = False - gap_details = "" - - if cv_text and len(cv_text) > 100: - cv_metrics = self.nlp.compute_all_metrics(cv_text) - - # Compare Readability (Flesch) - readability_gap = abs(interview_metrics["readability"] - cv_metrics["readability"]) - if readability_gap > 30: # Huge gap in complexity - stylometric_flag = True - gap_details += f"Readability Gap ({readability_gap}); " - - # Compare Vocabulary Richness - ttr_gap = abs(interview_metrics["lexical_diversity"] - cv_metrics["lexical_diversity"]) - if ttr_gap > 0.2: - stylometric_flag = True - gap_details += f"Vocab Gap ({ttr_gap}); " - - # 3. Rules for Red Flags - ai_suspicion_score = 0 - reasons = [] - - # Low Perplexity = AI - if interview_metrics["perplexity"] < 25: - ai_suspicion_score += 40 - reasons.append("Perplexity very low (Robotic)") - - # Low Burstiness = AI - if interview_metrics["burstiness"] < 0.2: - ai_suspicion_score += 30 - reasons.append("Low Burstiness (Monotone)") - - # Stylometric Mismatch - if stylometric_flag: - ai_suspicion_score += 20 - reasons.append(f"Style Mismatch with CV: {gap_details}") - - final_score = min(100, ai_suspicion_score) - - return { - "ai_score": final_score, - "stylometry_mismatch": stylometric_flag, - "metrics": interview_metrics, - "reasons": reasons, - "raw_gap": gap_details - } diff --git a/src/services/nlp_service.py b/src/services/nlp_service.py deleted file mode 100644 index 08b5f2f9102901fdbf0ab3ec2e6d41202375adc1..0000000000000000000000000000000000000000 --- a/src/services/nlp_service.py +++ /dev/null @@ -1,140 +0,0 @@ -import logging -import math -import numpy as np -from textblob import TextBlob -import textstat -from transformers import GPT2LMHeadModel, GPT2TokenizerFast -import torch -import re - -logger = logging.getLogger(__name__) - -class NLPService: - _instance = None - _perplex_model = None - _perplex_tokenizer = None - - def __new__(cls): - if cls._instance is None: - cls._instance = super(NLPService, cls).__new__(cls) - return cls._instance - - def _load_model(self): - """Lazy load the model to avoid huge startup time.""" - if self._perplex_model is None: - logger.info("Loading NLP models (DistilGPT2)...") - try: - model_id = 'distilgpt2' - self._perplex_model = GPT2LMHeadModel.from_pretrained(model_id) - self._perplex_tokenizer = GPT2TokenizerFast.from_pretrained(model_id) - logger.info("NLP models loaded successfully.") - except Exception as e: - logger.error(f"Failed to load NLP models: {e}") - raise e - - MAX_PERPLEXITY_CHARS = 50000 - - def calculate_perplexity(self, text: str) -> float: - """ - Calculate perplexity of the text using a small GPT-2 model. - Lower perplexity = more likely to be generated by AI. - """ - if not text or len(text.strip()) < 10: - return 0.0 - - if len(text) > self.MAX_PERPLEXITY_CHARS: - text = text[:self.MAX_PERPLEXITY_CHARS] - - self._load_model() - encodings = self._perplex_tokenizer( - text, - return_tensors='pt', - truncation=True, - max_length=self.MAX_PERPLEXITY_CHARS - ) - - max_length = self._perplex_model.config.n_positions - stride = 512 - seq_len = encodings.input_ids.size(1) - - nlls = [] - prev_end_loc = 0 - - for begin_loc in range(0, seq_len, stride): - end_loc = min(begin_loc + max_length, seq_len) - trg_len = end_loc - prev_end_loc - - input_ids = encodings.input_ids[:, begin_loc:end_loc] - - # Sécurité supplémentaire pour ne jamais dépasser la fenêtre du modèle - if input_ids.size(1) > max_length: - input_ids = input_ids[:, :max_length] - - target_ids = input_ids.clone() - target_ids[:, :-trg_len] = -100 - - with torch.no_grad(): - outputs = self._perplex_model(input_ids, labels=target_ids) - neg_log_likelihood = outputs.loss - - nlls.append(neg_log_likelihood) - prev_end_loc = end_loc - if end_loc == seq_len: - break - - if not nlls: - return 0.0 - - ppl = torch.exp(torch.stack(nlls).mean()) - return round(float(ppl), 2) - - def analyze_sentiment(self, text: str) -> dict: - """Returns Polarity (-1 to 1) and Subjectivity (0 to 1).""" - blob = TextBlob(text) - return { - "polarity": round(blob.sentiment.polarity, 2), - "subjectivity": round(blob.sentiment.subjectivity, 2) - } - - def calculate_lexical_diversity(self, text: str) -> float: - """Type-Token Ratio (TTR). Higher = richer vocabulary.""" - if not text: - return 0.0 - - words = re.findall(r'\w+', text.lower()) - if not words: - return 0.0 - - unique_words = set(words) - return round(len(unique_words) / len(words), 3) - - def calculate_burstiness(self, text: str) -> float: - """Variation in sentence length. proxy for AI detection.""" - blob = TextBlob(text) - # Utilisation sécurisée de blob.sentences (nécessite punkt_tab) - try: - sentences = blob.sentences - except Exception as e: - logger.error(f"TextBlob/NLTK error: {e}") - return 0.0 - - if not sentences or len(sentences) < 2: - return 0.0 - - lengths = [len(s.words) for s in sentences] - std_dev = np.std(lengths) - mean = np.mean(lengths) - - if mean == 0: - return 0.0 - - return round(float(std_dev / mean), 3) - - def compute_all_metrics(self, text: str) -> dict: - return { - "perplexity": self.calculate_perplexity(text), - "sentiment": self.analyze_sentiment(text), - "lexical_diversity": self.calculate_lexical_diversity(text), - "burstiness": self.calculate_burstiness(text), - "readability": textstat.flesch_reading_ease(text) - } \ No newline at end of file diff --git a/src/services/search_service.py b/src/services/search_service.py deleted file mode 100644 index 345bb3cf4376d312a8e1c47157d09d9bf3d306f3..0000000000000000000000000000000000000000 --- a/src/services/search_service.py +++ /dev/null @@ -1,117 +0,0 @@ -import logging -import re -from typing import Dict, List, Any, Tuple -from src.services.semantic_service import SemanticService - -logger = logging.getLogger(__name__) - -class SearchService: - """ - Agent de Recherche (RAG & Grounding). - Responsable de l'analyse des écarts (Gap Analysis) et de la détection des profils (Reconversion). - """ - - JOB_TYPES = { - "DATA_ENGINEER": ["engineer", "ingénieur data", "mlops", "architecte", "platform"], - "DATA_SCIENTIST": ["scientist", "science", "nlp", "computer vision", "chercher", "research"], - "DATA_ANALYST": ["analyst", "analytics", "bi", "business intelligence", "dashboard"], - } - - VERB_MAPPINGS = { - "DATA_ENGINEER": [ - "optimiser", "déployer", "industrialiser", "automatiser", "architecturer", - "monitorer", "scaler", "refactorer", "migrer", "contraindre" - ], - "DATA_SCIENTIST": [ - "entraîner", "finetuner", "expérimenter", "évaluer", "modéliser", - "optimiser", "analyser", "comparer", "implémenter" - ], - "DATA_ANALYST": [ - "visualiser", "présenter", "identifier", "extraire", "recommander", - "analyser", "synthétiser", "automatiser", "reporter" - ] - } - - # Fallback to general verbs if no type detected - DEFAULT_VERBS = VERB_MAPPINGS["DATA_ENGINEER"] + VERB_MAPPINGS["DATA_SCIENTIST"] - - def __init__(self): - self.semantic_service = SemanticService() - - def analyze_gap(self, cv_text: str, job_description: str) -> Dict[str, Any]: - """ - Effectue une analyse des écarts entre le CV et l'offre. - Retourne un dictionnaire contenant les gaps, les verbes d'action, et le statut de reconversion. - """ - logger.info("Starting Gap Analysis...") - - # 0. Job Type Detection - job_type = self._detect_job_type(job_description) - logger.info(f"Detected Job Type: {job_type}") - - # 1. Action Verbs Extraction (Dynamic based on Job Type) - target_verbs = self.VERB_MAPPINGS.get(job_type, self.DEFAULT_VERBS) - found_verbs = self._extract_action_verbs(cv_text, target_verbs) - - # Score normalized by a reasonable expectation (e.g. finding 3 distinct verbs is good) - production_score = min(1.0, len(found_verbs) / 4.0) - - # 2. Semantic Grounding - semantic_score = self.semantic_service.compute_similarity(cv_text, job_description) - - # 3. Reconversion Reporting - is_reconversion, reconversion_reason = self._detect_reconversion(cv_text, job_description) - - return { - "job_type": job_type, - "semantic_score": semantic_score, - "production_verbs_found": found_verbs, - "production_mindset_score": production_score, - "is_reconversion": is_reconversion, - "reconversion_reason": reconversion_reason, - "hidden_skill_gaps": "Analyse à compléter par LLM" - } - - def _detect_job_type(self, job_desc: str) -> str: - """Détermine le type de poste (Engineer, Scientist, Analyst) d'après la description.""" - text_lower = job_desc.lower() - - scores = {k: 0 for k in self.JOB_TYPES.keys()} - - for j_type, keywords in self.JOB_TYPES.items(): - for kw in keywords: - if kw in text_lower: - scores[j_type] += 1 - - # Return key with max score, default to GENERAL if no matches or ties (logic simplified) - best_match = max(scores, key=scores.get) - if scores[best_match] == 0: - return "GENERAL_TECH" - - return best_match - - def _extract_action_verbs(self, text: str, target_verbs: List[str]) -> List[str]: - """Extrait les verbes d'action clés présents dans le texte.""" - text_lower = text.lower() - found = [] - for verb in target_verbs: - # Simple word boundary check - if re.search(r'\b' + re.escape(verb) + r'\w*', text_lower): - found.append(verb) - return list(set(found)) - - def _detect_reconversion(self, cv_text: str, job_desc: str) -> Tuple[bool, str]: - """ - Détecte si le candidat est en reconversion. - Logique simple: Mots clés 'formation', 'bootcamp', 'reconversion' + manque d'xp longue durée dans le domaine cible. - """ - cv_lower = cv_text.lower() - - reconversion_keywords = ["reconversion", "bootcamp", "formation intensive", "rncp", "transition professionnelle"] - for kw in reconversion_keywords: - if kw in cv_lower: - return True, f"Mot-clé détecté : '{kw}'" - - # Note: A more robust check would involve parsing dates and titles, - # but this simple heuristic allows flagging potential profiles for the Agents to confirm. - return False, "Parcours classique apparent" diff --git a/src/services/semantic_service.py b/src/services/semantic_service.py deleted file mode 100644 index c3014c684039728ec24760f3280b59c9e4079e16..0000000000000000000000000000000000000000 --- a/src/services/semantic_service.py +++ /dev/null @@ -1,38 +0,0 @@ -import logging -from sentence_transformers import SentenceTransformer, util - -logger = logging.getLogger(__name__) - -class SemanticService: - _instance = None - _model = None - - def __new__(cls): - if cls._instance is None: - cls._instance = super(SemanticService, cls).__new__(cls) - return cls._instance - - def _load_model(self): - if self._model is None: - logger.info("Loading Semantic model (all-MiniLM-L6-v2)...") - try: - self._model = SentenceTransformer('all-MiniLM-L6-v2') - except Exception as e: - logger.error(f"Failed to load Semantic model: {e}") - raise e - - def compute_similarity(self, text1: str, text2: str) -> float: - """ - Computes semantic similarity between two texts. - Returns a score between 0.0 and 1.0. - """ - if not text1 or not text2: - return 0.0 - - self._load_model() - - embeddings1 = self._model.encode(text1, convert_to_tensor=True) - embeddings2 = self._model.encode(text2, convert_to_tensor=True) - - cosine_scores = util.cos_sim(embeddings1, embeddings2) - return float(cosine_scores[0][0]) diff --git a/src/services/simulation/agents.py b/src/services/simulation/agents.py deleted file mode 100644 index 77b9b38930355457a60cbce9ee9f05167a616661..0000000000000000000000000000000000000000 --- a/src/services/simulation/agents.py +++ /dev/null @@ -1,185 +0,0 @@ -import json -import logging -from typing import List, Dict, Any -from langchain_openai import ChatOpenAI -from langchain_core.messages import SystemMessage, HumanMessage, BaseMessage -from src.services.simulation.schemas import ( - IceBreakerOutput, TechnicalOutput, BehavioralOutput, SituationOutput, - TechnicalSkillGap, ProjectTechUnderstanding, BehavioralCompetency, - SimulationReport -) -from src.services.simulation.scoring import ( - calculate_technical_gap_score, - calculate_project_tech_understanding_score, - calculate_behavioral_score, - calculate_situation_score -) - -logger = logging.getLogger(__name__) - -class InterviewAgentExtractor: - def __init__(self, llm: ChatOpenAI): - self.llm = llm - - def _get_history_text(self, messages: List[BaseMessage]) -> str: - return "\n".join([f"{m.type.upper()}: {m.content}" for m in messages]) - - def extract_icebreaker(self, messages: List[BaseMessage], cv_data: Dict[str, Any]) -> IceBreakerOutput: - logger.info("Extracting Ice Breaker data...") - history = self._get_history_text(messages) - - prompt = f""" - Tu es un expert en analyse d'entretien. Analyse l'échange suivant (phase d'Ice Breaker) et extrais les informations structurées. - - CONTEXTE CANDIDAT: - {json.dumps(cv_data.get('info_personnelle', {}), ensure_ascii=False)} - {json.dumps(cv_data.get('reconversion', {}), ensure_ascii=False)} - - HISTORIQUE ECHANGE: - {history} - - Tâche: Extraire le type de profil, l'expérience, la cohérence, la motivation, le contexte et les points à explorer. - """ - - extractor = self.llm.with_structured_output(IceBreakerOutput) - return extractor.invoke([SystemMessage(content=prompt)]) - - def extract_technical(self, messages: List[BaseMessage], job_offer: Dict[str, Any]) -> TechnicalOutput: - logger.info("Extracting Technical data...") - history = self._get_history_text(messages) - - prompt = f""" - 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. - - OFFRE: - {json.dumps(job_offer, ensure_ascii=False)} - - HISTORIQUE ECHANGE: - {history} - - Tâche: Remplir la grille d'évaluation technique. Pour les indicateurs binaires, sois strict : true seulement si le candidat l'a explicitement démontré. - """ - - extractor = self.llm.with_structured_output(TechnicalOutput) - data = extractor.invoke([SystemMessage(content=prompt)]) - - # Calculate scores - normalize all to 0-5 scale - scores = [] - for gap in data.lacunes_explorees: - gap.niveau_detecte = calculate_technical_gap_score(gap.indicateurs) - normalized = (gap.niveau_detecte / 4.0) * 5.0 # 0-4 -> 0-5 - scores.append(normalized) - - for tech in data.comprehension_technos_projets: - tech.score = calculate_project_tech_understanding_score(tech.indicateurs) - scores.append(float(tech.score)) # already 1-5 - - for val in data.competences_validees: - scores.append(float(val.score)) # already 1-5 - - if scores: - data.score_technique_global = round(sum(scores) / len(scores), 1) - else: - data.score_technique_global = 0.0 - - return data - - def extract_behavioral(self, messages: List[BaseMessage]) -> BehavioralOutput: - logger.info("Extracting Behavioral data...") - history = self._get_history_text(messages) - - prompt = f""" - Tu es un expert RH. Analyse l'échange suivant (phase Comportementale) et extrais l'évaluation des compétences. - - HISTORIQUE ECHANGE: - {history} - - Tâche: Evaluer chaque compétence comportementale abordée via la méthode STAR. - """ - - extractor = self.llm.with_structured_output(BehavioralOutput) - data = extractor.invoke([SystemMessage(content=prompt)]) - - # Calculate scores - scores = [] - for comp in data.competences_evaluees: - comp.score = calculate_behavioral_score(comp.competence, comp.indicateurs) - scores.append(comp.score) - - for sjt in data.sjt_results: - if sjt.score_choix is not None and sjt.justification_score is not None: - sjt.score_sjt = round((sjt.score_choix * 0.6) + (sjt.justification_score * 0.4), 1) - scores.append(sjt.score_sjt) - - if scores: - data.score_comportemental_global = round(sum(scores) / len(scores), 1) - else: - data.score_comportemental_global = 0.0 - - return data - - def extract_situation(self, messages: List[BaseMessage]) -> SituationOutput: - logger.info("Extracting Situation data...") - history = self._get_history_text(messages) - - prompt = f""" - Tu es un expert technique. Analyse l'échange suivant (phase Mise en Situation) et évalue la performance du candidat. - - HISTORIQUE ECHANGE: - {history} - - Tâche: Remplir la grille d'évaluation de la mise en situation. - """ - - extractor = self.llm.with_structured_output(SituationOutput) - data = extractor.invoke([SystemMessage(content=prompt)]) - - # Calculate score - data.score_mise_en_situation = calculate_situation_score(data.indicateurs) - - return data - - def extract_simulation_report(self, - messages: List[BaseMessage], - icebreaker: IceBreakerOutput, - technical: TechnicalOutput, - behavioral: BehavioralOutput, - situation: SituationOutput) -> SimulationReport: - logger.info("Generating Final Simulation Report...") - - # We don't necessarily need the whole history if we have structured data, - # but the LLM might need it for "Synthese". - # Let's provide a summary of structured data to save tokens. - - context_data = { - "icebreaker": icebreaker.dict() if icebreaker else {}, - "technical": technical.dict() if technical else {}, - "behavioral": behavioral.dict() if behavioral else {}, - "situation": situation.dict() if situation else {} - } - - prompt = f""" - Tu es un Expert Recruteur Senior. Rédige le rapport final de l'entretien basé sur les données extraites. - - DONNÉES STRUCTURÉES (SCORES & INDICATEURS): - {json.dumps(context_data, ensure_ascii=False)} - - Tâche: - 1. Calcule le score global (Moyenne pondérée : Technique 40%, Comportemental 30%, Situation 20%, Icebreaker/Soft 10% - ou use ton jugement expert). - 2. Rédige une synthèse du candidat (2-3 phrases). - 3. Liste les points forts et faibles. - 4. Donne une recommandation claire (GO/NO GO). - 5. Rédige un feedback pour le candidat (bienveillant et constructif). - """ - - extractor = self.llm.with_structured_output(SimulationReport) - report = extractor.invoke([SystemMessage(content=prompt)]) - - # Inject the source objects back into the report (optional, as they are part of the model but null in extraction input) - # Actually LLM might return them null or empty. We should re-attach the real objects. - report.icebreaker = icebreaker - report.technical = technical - report.behavioral = behavioral - report.situation = situation - - return report diff --git a/src/services/simulation/schemas.py b/src/services/simulation/schemas.py deleted file mode 100644 index f1b9401f6f2e069e249f644afe9e9a50466d4574..0000000000000000000000000000000000000000 --- a/src/services/simulation/schemas.py +++ /dev/null @@ -1,87 +0,0 @@ -from typing import List, Optional, Dict, Any -from pydantic import BaseModel, Field - -# --- Ice Breaker Agent --- - -class IceBreakerOutput(BaseModel): - profil_type: str = Field(..., description="Type de profil (reconversion, étudiant, junior, expérimenté)") - annees_experience_domaine: str = Field(..., description="Années d'expérience dans le domaine cible (0-2, 2-5, 5+)") - coherence_parcours: str = Field(..., description="Cohérence parcours -> poste visé (forte, moyenne, faible)") - motivation_detectee: bool = Field(..., description="Motivation exprimée") - contexte_specifique: str = Field(..., description="Contexte spécifique du candidat") - points_a_explorer: List[str] = Field(default_factory=list, description="Points à explorer dans la suite") - -# --- Technical Agent --- - -class TechnicalSkillGap(BaseModel): - skill: str - niveau_detecte: int = Field(..., description="Niveau détecté (0-4)") - indicateurs: Dict[str, bool] = Field(..., description="Indicateurs binaires (concept_sous_jacent, experience_liee, outil_adjacent, cas_usage, strategie_montee)") - transferabilite: str = Field(..., description="Transférabilité (faible, moyenne, forte)") - questions_posees: List[str] = Field(default_factory=list) - -class ProjectTechUnderstanding(BaseModel): - skill: str - source_projet: str - score: int = Field(..., description="Score (1-5)") - indicateurs: Dict[str, bool] = Field(..., description="Indicateurs binaires (justifie_choix, fonctionnement_interne, identifie_limites, propose_alternatives, quantifie_resultats, resolution_probleme)") - questions_posees: List[str] = Field(default_factory=list) - -class ValidatedSkill(BaseModel): - skill: str - score: int - source: str - -class TechnicalOutput(BaseModel): - competences_validees: List[ValidatedSkill] = Field(default_factory=list) - lacunes_explorees: List[TechnicalSkillGap] = Field(default_factory=list) - comprehension_technos_projets: List[ProjectTechUnderstanding] = Field(default_factory=list) - score_technique_global: float = Field(..., description="Score technique global calculé") - points_a_explorer_comportemental: List[str] = Field(default_factory=list) - -# --- Behavioral Agent --- - -class BehavioralCompetency(BaseModel): - competence: str - score: int = Field(..., description="Score (1-5)") - indicateurs: Dict[str, bool] = Field(..., description="Indicateurs binaires spécifiques à la compétence") - questions_posees: List[str] = Field(default_factory=list) - -class SJTResult(BaseModel): - scenario_id: str - choix: str - score_choix: float - justification_score: float - score_sjt: float - -class BehavioralOutput(BaseModel): - competences_evaluees: List[BehavioralCompetency] = Field(default_factory=list) - sjt_results: List[SJTResult] = Field(default_factory=list) - score_comportemental_global: float = Field(..., description="Score comportemental global calculé") - signaux_forts: List[str] = Field(default_factory=list) - signaux_faibles: List[str] = Field(default_factory=list) - points_a_integrer_mise_en_situation: List[str] = Field(default_factory=list) - -# --- Situation Agent --- - -class SituationOutput(BaseModel): - scenario_utilise: str - score_mise_en_situation: int = Field(..., description="Score sur 5") - indicateurs: Dict[str, bool] = Field(..., description="Indicateurs (comprehension_probleme, demarche_structuree, pertinence_technique, gestion_contraintes, communication_solution, identification_risques, proposition_alternatives)") - questions_posees: List[str] = Field(default_factory=list) - observations: str - -# --- Full Report --- - -class SimulationReport(BaseModel): - icebreaker: Optional[IceBreakerOutput] = None - technical: Optional[TechnicalOutput] = None - behavioral: Optional[BehavioralOutput] = None - situation: Optional[SituationOutput] = None - - score_global: float = Field(..., description="Score global de l'entretien sur 5") - synthese_candidat: str = Field(..., description="Synthèse textuelle du profil") - points_forts: List[str] = Field(default_factory=list, description="Top 3 points forts") - points_faibles: List[str] = Field(default_factory=list, description="Top 3 points faibles") - recommandation: str = Field(..., description="GO / NO GO / A CREUSER") - feedback_candidat: str = Field(..., description="Feedback constructif adressé au candidat") diff --git a/src/services/simulation/scoring.py b/src/services/simulation/scoring.py deleted file mode 100644 index c78cf22c254df7d3d51bbcb031910d500dafc5cc..0000000000000000000000000000000000000000 --- a/src/services/simulation/scoring.py +++ /dev/null @@ -1,167 +0,0 @@ -from typing import Dict - -def calculate_technical_gap_score(indicators: Dict[str, bool]) -> int: - """ - Calcule le niveau (0-4) pour une lacune technique basée sur les indicateurs binaires. - - Poids: - - concept_sous_jacent: 2 - - experience_liee: 1 - - outil_adjacent: 1 - - cas_usage: 1 - - strategie_montee: 1 - - Mapping: - - 0 pts -> Niveau 0 - - 1-2 pts -> Niveau 1 - - 3 pts -> Niveau 2 - - 4 pts -> Niveau 3 - - 5-6 pts -> Niveau 4 - """ - weights = { - "concept_sous_jacent": 2, - "experience_liee": 1, - "outil_adjacent": 1, - "cas_usage": 1, - "strategie_montee": 1 - } - - score = sum(weights.get(k, 0) for k, v in indicators.items() if v) - - if score == 0: return 0 - if score <= 2: return 1 - if score == 3: return 2 - if score == 4: return 3 - return 4 - -def calculate_project_tech_understanding_score(indicators: Dict[str, bool]) -> int: - """ - Calcule le score (1-5) pour la compréhension d'une technologie projet. - - Poids: - - justifie_choix: 1 - - fonctionnement_interne: 3 - - identifie_limites: 2 - - propose_alternatives: 2 - - quantifie_resultats: 1 - - resolution_probleme: 2 - - Total max: 11 - - Mapping: - - 0-2 -> 1/5 (usage superficiel) - - 3-4 -> 2/5 (usage fonctionnel) - - 5-6 -> 3/5 (compréhension partielle) - - 7-9 -> 4/5 (bonne compréhension) - - 10-11 -> 5/5 (maîtrise démontrée) - """ - weights = { - "justifie_choix": 1, - "fonctionnement_interne": 3, - "identifie_limites": 2, - "propose_alternatives": 2, - "quantifie_resultats": 1, - "resolution_probleme": 2 - } - - score = sum(weights.get(k, 0) for k, v in indicators.items() if v) - - if score <= 2: return 1 - if score <= 4: return 2 - if score <= 6: return 3 - if score <= 9: return 4 - return 5 - -def calculate_behavioral_score(competence: str, indicators: Dict[str, bool]) -> int: - """ - Calcule le score (1-5) pour une compétence comportementale. - Utilise une pondération spécifique par compétence si définie, sinon une générique. - Mapping proportionnel au max possible. - """ - # Exemples de poids par défaut si non spécifiés dans le code appelant - # Ici on suppose que les indicateurs passés correspondent à ceux attendus pour la compétence - # On va utiliser une heuristique simple: 1 point par indicateur si pas de poids spécifique, - # ou on code en dur les poids des exemples connus. - - # Poids par défaut pour les exemples connus - weights_map = { - "Adaptabilité": { - "situation_changement": 2, - "actions_concretes": 2, - "apprentissage_ajustement": 2, - "resultat_quantifie": 1, - "limites_reconnues": 1, - "transfert_competence": 2 - }, - "Apprentissage autonome": { - "demarche_auto_formation": 2, # Mapping approximatif des clés JSON - "ressources_specifiques": 1, - "progression_mesurable": 2, - "application_concrete": 2, - "identifie_reste_a_apprendre": 1 - } - } - - # Si la compétence est connue, on utilise ses poids, sinon 1. - competence_weights = weights_map.get(competence, {}) - - total_score = 0 - max_score = 0 - - for key, val in indicators.items(): - weight = competence_weights.get(key, 1) # Default weight 1 if unknown key - if val: - total_score += weight - max_score += weight - - if max_score == 0: - return 3 # Score neutre si aucun indicateur defini - - ratio = total_score / max_score - - # Mapping linéaire approximatif vers 1-5 - if ratio <= 0.2: return 1 - if ratio <= 0.4: return 2 - if ratio <= 0.6: return 3 - if ratio <= 0.8: return 4 - return 5 - -def calculate_situation_score(indicators: Dict[str, bool]) -> int: - """ - Calcule le score (1-5) pour la mise en situation. - - Poids: - - comprehension_probleme: 2 - - demarche_structuree: 3 - - pertinence_technique: 3 - - gestion_contraintes: 2 - - communication_solution: 1 - - identification_risques: 2 - - proposition_alternatives: 1 - - Total max: 14 - - Mapping: - - 0-3 -> 1/5 - - 4-6 -> 2/5 - - 7-9 -> 3/5 - - 10-12 -> 4/5 - - 13-14 -> 5/5 - """ - weights = { - "comprehension_probleme": 2, - "demarche_structuree": 3, - "pertinence_technique": 3, - "gestion_contraintes": 2, - "communication_solution": 1, - "identification_risques": 2, - "proposition_alternatives": 1 - } - - score = sum(weights.get(k, 0) for k, v in indicators.items() if v) - - if score <= 3: return 1 - if score <= 6: return 2 - if score <= 9: return 3 - if score <= 12: return 4 - return 5 diff --git a/src/tasks.py b/src/tasks.py deleted file mode 100644 index 2c5c6a0c0d485ac578ebdde7dcb18ca368a00485..0000000000000000000000000000000000000000 --- a/src/tasks.py +++ /dev/null @@ -1,27 +0,0 @@ -from src.celery_app import celery_app -from src.tools.analysis_tools import trigger_interview_analysis -import logging - -logger = logging.getLogger(__name__) - -@celery_app.task(bind=True, max_retries=3, default_retry_delay=30) -def run_analysis_task(self, user_id, job_offer_id, job_description, conversation_history, cv_content, cheat_metrics=None, simulation_report=None): - """ - Background task to run the feedback analysis. - """ - logger.info(f"Starting background analysis for job {job_offer_id}") - try: - result = trigger_interview_analysis.invoke({ - "user_id": user_id, - "job_offer_id": job_offer_id, - "job_description": job_description, - "conversation_history": conversation_history, - "cv_content": cv_content, - "cheat_metrics": cheat_metrics or {}, - "simulation_report": simulation_report - }) - logger.info("Background analysis completed successfully") - return result - except Exception as e: - logger.error(f"Background analysis failed (attempt {self.request.retries + 1}): {e}") - raise self.retry(exc=e) diff --git a/src/tools/analysis_tools.py b/src/tools/analysis_tools.py deleted file mode 100644 index a6fa8834dd88b91cd10412ae48f0ef0e42fa4976..0000000000000000000000000000000000000000 --- a/src/tools/analysis_tools.py +++ /dev/null @@ -1,69 +0,0 @@ -import logging -from langchain_core.tools import tool -from src.services.analysis_service import AnalysisService -import json -import os -from datetime import datetime -from pydantic import BaseModel, Field -from typing import List, Dict, Any, Optional -import httpx - -logger = logging.getLogger(__name__) - -BACKEND_API_URL = os.getenv("BACKEND_API_URL", "http://localhost:8000") -INTERNAL_API_KEY = os.getenv("INTERNAL_API_KEY") - -class InterviewAnalysisArgs(BaseModel): - """Arguments for the trigger_interview_analysis tool.""" - user_id: str = Field(..., description="The unique identifier for the user.") - job_offer_id: str = Field(..., description="The unique identifier for the job offer.") - job_description: str = Field(..., description="The full JSON string of the job offer description.") - conversation_history: List[Dict[str, Any]] = Field(..., description="The complete conversation history between the user and the agent.") - cv_content: str = Field(..., description="The content of the candidate's CV (JSON string or text).") - cheat_metrics: Optional[Dict[str, Any]] = Field(default=None, description="Metrics related to copy-paste behavior.") - simulation_report: Optional[Dict[str, Any]] = Field(default=None, description="Pre-computed structured simulation report.") - -@tool("trigger_interview_analysis", args_schema=InterviewAnalysisArgs) -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): - """ - Call this tool to end the interview and launch the final analysis. - Arguments: user_id, job_offer_id, job_description, conversation_history, cv_content. - """ - try: - logger.info(f"Tool 'trigger_interview_analysis' called for user_id: {user_id}") - - analysis_service = AnalysisService() - - feedback_data = analysis_service.run_analysis( - conversation_history=conversation_history, - job_description=job_description, - cv_content=cv_content, - cheat_metrics=cheat_metrics, - simulation_report=simulation_report - ) - - feedback_payload = { - "user_id": user_id, - "interview_id": job_offer_id, - "feedback_content": feedback_data, - "feedback_date": datetime.utcnow().isoformat() - } - - try: - headers = {"X-Internal-API-Key": INTERNAL_API_KEY} if INTERNAL_API_KEY else {} - response = httpx.post( - f"{BACKEND_API_URL}/api/v1/feedback/", - json=feedback_payload, - headers=headers, - timeout=30.0 - ) - response.raise_for_status() - logger.info("Feedback saved to Backend API successfully.") - except Exception as api_err: - logger.error(f"Failed to save feedback to API: {api_err}") - - return "Analysis triggered and completed successfully." - - except Exception as e: - logger.error(f"Error in analysis tool: {e}", exc_info=True) - return "An error occurred while launching the analysis." diff --git a/start.sh b/start.sh deleted file mode 100644 index f08e39b95b8742b345782c29092c32f55bc2a87d..0000000000000000000000000000000000000000 --- a/start.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash - -# Start Celery worker in the background -echo "Starting Celery worker..." -celery -A src.celery_app worker --loglevel=info & - -# Start FastAPI server -# Using exec to make uvicorn the main process (PID 1) -echo "Starting FastAPI server..." -exec uvicorn main:app --host 0.0.0.0 --port 7860 diff --git a/tests/test_search_service.py b/tests/test_search_service.py deleted file mode 100644 index 79d49abc55f044866f850579210a09376e13bc15..0000000000000000000000000000000000000000 --- a/tests/test_search_service.py +++ /dev/null @@ -1,56 +0,0 @@ - -import pytest -from src.services.search_service import SearchService - -class TestSearchService: - def setup_method(self): - self.service = SearchService() - - def test_detect_job_type(self): - assert self.service._detect_job_type("Cherche Senior Data Scientist NLP") == "DATA_SCIENTIST" - assert self.service._detect_job_type("Offre Data Analyst PowerBI") == "DATA_ANALYST" - assert self.service._detect_job_type("Besoin d'un ML Engineer pour la prod") == "DATA_ENGINEER" - assert self.service._detect_job_type("Développeur Python Backend") == "GENERAL_TECH" - - def test_extract_action_verbs_found(self): - text = "J'ai pu optimiser les performances et déployer le modèle en production." - verbs = self.service._extract_action_verbs(text, self.service.VERB_MAPPINGS["DATA_ENGINEER"]) - assert "optimiser" in verbs - assert "déployer" in verbs - assert len(verbs) >= 2 - - def test_extract_action_verbs_analyst(self): - text = "J'ai réalisé des visualisations et présenté des insights." - verbs = self.service._extract_action_verbs(text, self.service.VERB_MAPPINGS["DATA_ANALYST"]) - assert "visualiser" in verbs or "présenter" in verbs - - def test_extract_action_verbs_none(self): - text = "J'ai fait de la gestion de projet et de la rédaction." - verbs = self.service._extract_action_verbs(text, self.service.VERB_MAPPINGS["DATA_ENGINEER"]) - assert len(verbs) == 0 - - def test_detect_reconversion_true(self): - cv_text = "Après un bootcamp intensif en Data Science, je cherche..." - job_desc = "Cherche Data Scientist junior." - is_reconversion, reason = self.service._detect_reconversion(cv_text, job_desc) - assert is_reconversion is True - assert "bootcamp" in reason - - def test_detect_reconversion_false(self): - cv_text = "Ingénieur diplomé avec 5 ans d'expérience." - job_desc = "Cherche Senior Dev." - is_reconversion, reason = self.service._detect_reconversion(cv_text, job_desc) - assert is_reconversion is False - assert "Parcours classique" in reason - - def test_analyze_gap_structure(self): - cv_text = "Développeur Python avec expérience en optimiser et monitorer." - job_desc = "Cherche expert Python pour optimiser le backend." - - result = self.service.analyze_gap(cv_text, job_desc) - - assert "semantic_score" in result - assert "production_mindset_score" in result - assert "is_reconversion" in result - assert "production_verbs_found" in result - assert "optimiser" in result["production_verbs_found"] diff --git a/tests/test_simulation_flow.py b/tests/test_simulation_flow.py deleted file mode 100644 index 82cf321e0bbd4b95a5b2d76deee060367839a9c5..0000000000000000000000000000000000000000 --- a/tests/test_simulation_flow.py +++ /dev/null @@ -1,108 +0,0 @@ -import os -import sys -import json -import logging -from dotenv import load_dotenv - -# Add src to path -sys.path.append(os.path.join(os.path.dirname(__file__), '..')) - -from src.services.graph_service import GraphInterviewProcessor - -# Force re-configuration of logging -for handler in logging.root.handlers[:]: - logging.root.removeHandler(handler) - -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - filename='simulation_test.log', - filemode='w' -) -logger = logging.getLogger(__name__) - -load_dotenv() - -# Mock data -USER_ID = "test_user" -JOB_OFFER_ID = "test_job" -CV_DATA = { - "candidat": { - "info_personnelle": {"nom": "Doe", "prenom": "John"}, - "reconversion": {"is_reconversion": False}, - "etudiant": { - "is_etudiant": True, - "niveau_etudes": "Master 2", - "specialite": "IA", - "latest_education_end_date": "2025" - }, - "expériences": [{"poste": "Dev", "entreprise": "TestCorp", "durée": "2 ans"}], - "compétences": { - "hard_skills": ["Python", "Docker"], - "soft_skills": ["Curiosité"], - "skills_with_context": [ - {"skill": "Python", "context": "Projet académique"}, - {"skill": "Docker", "context": "Stage"} - ] - }, - "projets": { - "professional": [ - { - "title": "Projet A", - "technologies": ["Python", "Flask"], - "outcomes": ["API déployée", "User base +10%"] - } - ], - "personal": [] - }, - "centres_interet": ["Football", "Voyage"] - } -} -JOB_OFFER = { - "poste": "Backend Developer", - "entreprise": "AIRH", - "mission": "Develop API", - "profil_recherche": "Passionné", - "competences": "Python, FastAPI" -} - -PAYLOAD = { - "user_id": USER_ID, - "job_offer_id": JOB_OFFER_ID, - "cv_document": CV_DATA, - "job_offer": JOB_OFFER, - "messages": [] -} - -def run_simulation_start(): - logger.info("Initializing Processor for START SCENARIO...") - processor = GraphInterviewProcessor(PAYLOAD) - - # 0 messages -> Should trigger IceBreaker first message - output = processor.invoke([]) - logger.info(f"\n=== START OF INTERVIEW ===") - logger.info(f"Agent Response: {output['response']}") - logger.info(f"Status: {output['status']}") - -def run_simulation_end(): - logger.info("Initializing Processor for END SCENARIO...") - processor = GraphInterviewProcessor(PAYLOAD) - - # Simulate a history with 10 user messages (Completed flow) - # The Orchestrator counts only HUMAN messages. - conversation = [] - for i in range(10): - conversation.append({"role": "user", "content": f"Response {i+1}"}) - conversation.append({"role": "assistant", "content": f"Question {i+2}"}) - - logger.info(f"\n=== TRIGGERING END OF INTERVIEW (10 User Messages) ===") - output = processor.invoke(conversation) - logger.info(f"Final Agent Response: {output['response'][:100]}...") - logger.info(f"Final Status: {output['status']}") - -if __name__ == "__main__": - try: - run_simulation_start() - # run_simulation_end() - except Exception as e: - logger.error(f"Simulation failed: {e}", exc_info=True) diff --git a/tools/analysis_tools.py b/tools/analysis_tools.py deleted file mode 100644 index a9f02d21c9bff9fccd1e47938b7e026f040d8f2b..0000000000000000000000000000000000000000 --- a/tools/analysis_tools.py +++ /dev/null @@ -1,60 +0,0 @@ -import logging -from langchain_core.tools import tool -from src.services.analysis_service import AnalysisService -import json -import os -from datetime import datetime -from pydantic import BaseModel, Field -from typing import List, Dict, Any -import httpx - -logger = logging.getLogger(__name__) - -BACKEND_API_URL = os.getenv("BACKEND_API_URL", "http://localhost:8000") - -class InterviewAnalysisArgs(BaseModel): - """Arguments for the trigger_interview_analysis tool.""" - user_id: str = Field(..., description="The unique identifier for the user.") - job_offer_id: str = Field(..., description="The unique identifier for the job offer.") - job_description: str = Field(..., description="The full JSON string of the job offer description.") - conversation_history: List[Dict[str, Any]] = Field(..., description="The complete conversation history between the user and the agent.") - cv_content: str = Field(..., description="The content of the candidate's CV (JSON string or text).") - cheat_metrics: Dict[str, Any] = Field(None, description="Metrics related to copy-paste behavior.") - -@tool("trigger_interview_analysis", args_schema=InterviewAnalysisArgs) -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): - """ - Call this tool to end the interview and launch the final analysis. - Arguments: user_id, job_offer_id, job_description, conversation_history, cv_content. - """ - try: - logger.info(f"Tool 'trigger_interview_analysis' called for user_id: {user_id}") - - analysis_service = AnalysisService() - - feedback_data = analysis_service.run_analysis( - conversation_history=conversation_history, - job_description=job_description, - cv_content=cv_content, - cheat_metrics=cheat_metrics - ) - - feedback_payload = { - "user_id": user_id, - "interview_id": job_offer_id, - "feedback_content": feedback_data, - "feedback_date": datetime.utcnow().isoformat() - } - - try: - response = httpx.post(f"{BACKEND_API_URL}/api/v1/feedback/", json=feedback_payload, timeout=30.0) - response.raise_for_status() - logger.info("Feedback saved to Backend API successfully.") - except Exception as api_err: - logger.error(f"Failed to save feedback to API: {api_err}") - - return "Analysis triggered and completed successfully." - - except Exception as e: - logger.error(f"Error in analysis tool: {e}", exc_info=True) - return "An error occurred while launching the analysis."