GitLab CI commited on
Commit
3f637a5
·
1 Parent(s): db5376e

Deploy from GitLab CI - 6509512f

Browse files
.devcontainer/devcontainer.json ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Python 3",
3
+ // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
4
+ "image": "mcr.microsoft.com/devcontainers/python:1-3.11-bookworm",
5
+ "customizations": {
6
+ "codespaces": {
7
+ "openFiles": [
8
+ "README.md",
9
+ "app.py"
10
+ ]
11
+ },
12
+ "vscode": {
13
+ "settings": {},
14
+ "extensions": [
15
+ "ms-python.python",
16
+ "ms-python.vscode-pylance"
17
+ ]
18
+ }
19
+ },
20
+ "updateContentCommand": "[ -f packages.txt ] && sudo apt update && sudo apt upgrade -y && sudo xargs apt install -y <packages.txt; [ -f requirements.txt ] && pip3 install --user -r requirements.txt; pip3 install --user streamlit; echo '✅ Packages installed and Requirements met'",
21
+ "postAttachCommand": {
22
+ "server": "streamlit run app.py --server.enableCORS false --server.enableXsrfProtection false"
23
+ },
24
+ "portsAttributes": {
25
+ "8501": {
26
+ "label": "Application",
27
+ "onAutoForward": "openPreview"
28
+ }
29
+ },
30
+ "forwardPorts":[
31
+ 8501
32
+ ]
33
+ }
.dockerignore ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # .dockerignore
2
+ # Fichiers à exclure de l'image Docker
3
+
4
+ # Git
5
+ .git
6
+ .gitignore
7
+
8
+ # Python
9
+ __pycache__/
10
+ *.pyc
11
+ *.pyo
12
+ venv/
13
+ .venv/
14
+
15
+ # Environnement (on garde .env pour les tests locaux)
16
+ .env.local
17
+
18
+ # IDE
19
+ .vscode/
20
+ .idea/
21
+
22
+ # CI/CD
23
+ .github/
24
+
25
+ # Docs
26
+ README.md
27
+ LICENSE
28
+
29
+ # Tests
30
+ tests/
.gitignore ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Variables d'environnement (Clés API)
2
+ .env
3
+
4
+ # Python
5
+ __pycache__/
6
+ *.py[cod]
7
+ *$py.class
8
+ venv/
9
+ env/
10
+
11
+ # OS
12
+ .DS_Store
Dockerfile CHANGED
@@ -1,20 +1,44 @@
1
- FROM python:3.13.5-slim
 
2
 
 
 
 
 
 
 
 
 
 
 
3
  WORKDIR /app
4
 
5
- RUN apt-get update && apt-get install -y \
6
- build-essential \
7
- curl \
8
- git \
9
- && rm -rf /var/lib/apt/lists/*
 
 
 
 
 
 
 
 
 
10
 
11
- COPY requirements.txt ./
12
- COPY src/ ./src/
13
 
14
- RUN pip3 install -r requirements.txt
 
15
 
16
- EXPOSE 8501
 
17
 
18
- HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health
 
19
 
20
- ENTRYPOINT ["streamlit", "run", "src/streamlit_app.py", "--server.port=8501", "--server.address=0.0.0.0"]
 
 
1
+ # Étape 1 : Image de base Python
2
+ FROM python:3.11-slim AS base
3
 
4
+ # Variables d'environnement pour Python
5
+ ENV PYTHONUNBUFFERED=1 \
6
+ PYTHONDONTWRITEBYTECODE=1 \
7
+ PIP_NO_CACHE_DIR=1 \
8
+ PIP_DISABLE_PIP_VERSION_CHECK=1
9
+
10
+ # Créer un utilisateur non-root (UID 1000 imposé par HF)
11
+ RUN useradd -m -u 1000 user
12
+
13
+ # Définit le répertoire de travail
14
  WORKDIR /app
15
 
16
+ # Étape 2 : Installation des dépendances
17
+ FROM base AS dependencies
18
+
19
+ # Copie uniquement les fichiers de dépendances
20
+ COPY requirements.txt .
21
+
22
+ # Installation des dépendances
23
+ RUN pip install --no-cache-dir -r requirements.txt
24
+
25
+ # Étape 3 : Image finale
26
+ FROM base AS final
27
+
28
+ # Copie les dépendances installées depuis l'étape précédente
29
+ COPY --from=dependencies /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
30
 
31
+ # AJOUT CRUCIAL : copier les binaires (streamlit, etc.)
32
+ COPY --from=dependencies /usr/local/bin /usr/local/bin
33
 
34
+ # Copie tout le code de l'application avec les bons propriétaires
35
+ COPY --chown=user:user . .
36
 
37
+ # Bascule vers l'utilisateur non-root
38
+ USER user
39
 
40
+ # Expose le port attendu par Hugging Face Spaces
41
+ EXPOSE 7860
42
 
43
+ # Commande de démarrage
44
+ CMD ["streamlit", "run", "app.py", "--server.port=7860", "--server.address=0.0.0.0"]
README.md CHANGED
@@ -1,20 +1,365 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
- title: Cveval
3
- emoji: 🚀
4
- colorFrom: red
5
- colorTo: red
6
- sdk: docker
7
- app_port: 8501
8
- tags:
9
- - streamlit
10
- pinned: false
11
- short_description: Streamlit template space
12
- license: mit
 
13
  ---
 
 
 
 
14
 
15
- # Welcome to Streamlit!
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
- Edit `/src/streamlit_app.py` to customize this app to your heart's desire. :heart:
18
 
19
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
20
- forums](https://discuss.streamlit.io).
 
1
+ # CV Evaluator — Système Multi-Agents d'Évaluation de CV
2
+
3
+ [![CI/CD Pipeline](https://github.com/yacineberkani/cv_evaluator/actions/workflows/ci-cd.yml/badge.svg)](https://github.com/yacineberkani/cv_evaluator/actions/workflows/ci-cd.yml)
4
+ [![Python 3.10+](https://img.shields.io/badge/Python-3.10%2B-blue?logo=python)](https://www.python.org/)
5
+ [![Streamlit](https://img.shields.io/badge/Streamlit-1.x-FF4B4B?logo=streamlit)](https://streamlit.io/)
6
+ [![LangChain](https://img.shields.io/badge/LangChain-latest-1C3C3C?logo=langchain)](https://www.langchain.com/)
7
+ [![Docker](https://img.shields.io/badge/Docker-multi--stage-2496ED?logo=docker)](https://www.docker.com/)
8
+ [![Hugging Face](https://img.shields.io/badge/Hugging%20Face-Spaces-FFD21E?logo=huggingface)](https://huggingface.co/spaces/yacineberkani/cv_evaluator)
9
+ [![License](https://img.shields.io/badge/License-JEMSLABS-green)](./README.md)
10
+
11
+ ---
12
+
13
+ ## 📌 Description
14
+
15
+ Application d'évaluation automatisée de CV utilisant une architecture **multi-agents** propulsée par **LangChain** et plusieurs backends LLM au choix : **Google Gemini**, **OpenAI ChatGPT** et **Ollama** (cloud). Le système analyse, évalue et note chaque section d'un CV de manière **déterministe et reproductible**, puis restitue un rapport structuré avec score, tableau d'évaluation, verdict et recommandations.
16
+
17
+ **Stack technique :** Python 3.11 · Streamlit · LangChain · Google Gemini / ChatGPT / Ollama · Pydantic · PyMuPDF · Docker · GitHub Actions · Hugging Face Spaces
18
+
19
+
20
+
21
+
22
+ <table>
23
+ <tr>
24
+ <td width="100%">
25
+
26
+ ### 📹 Voir la vidéo de démonstration
27
+ ---
28
+ https://github.com/user-attachments/assets/45300066-c809-473d-ba67-3bd57212b555
29
+
30
+ </td>
31
+ </tr>
32
+ </table>
33
+
34
+ ### 🌐 Accéder à l'application en ligne
35
+
36
+ > **L'application est déployée et accessible directement sur Hugging Face Spaces :**
37
+ >
38
+ > ## 👉 [**Lancer CV Evaluator**](https://berkani-cv-evaluator.hf.space/)
39
+ >
40
+ > Aucune installation requise — uploadez votre CV en PDF et obtenez votre évaluation en quelques secondes.
41
+
42
+ ---
43
+
44
+ ## 🏗 Architecture
45
+
46
+ ```
47
+ ┌─────────────────────────────────────────────────────────────┐
48
+ │ STREAMLIT FRONTEND │
49
+ │ Upload PDF → Affichage Résultats → Export JSON │
50
+ └──────────────────────┬──────────────────────────────────────┘
51
+
52
+ ┌──────────────────────▼──────────────────────────────────────┐
53
+ │ ORCHESTRATOR │
54
+ │ (Gestion du pipeline, cache, parallélisme) │
55
+ └──────────────────────┬──────────────────────────────────────┘
56
+
57
+ ┌──────────────────┼────────────────────┐
58
+ │ │ │
59
+ ┌───▼──────────┐ │ │
60
+ │ Phase 1 │ │ │
61
+ │ Experience │ │ │
62
+ │ Analysis │ │ │
63
+ │ Agent │ │ │
64
+ └──────┬───────┘ │ │
65
+ │ │ │
66
+ ┌──────▼───────┐ ┌─────▼─────────┐ │
67
+ │ Phase 2a │ │ Phase 2b │ │
68
+ │ Skills & │ │ Summary │ (parallel)
69
+ │ Education │ │ Validation │ │
70
+ │ Agent │ │ Agent │ │
71
+ └──────┬───────┘ └──────┬────────┘ │
72
+ └────────┬───────┘ │
73
+ ▼ │
74
+ ┌─────────────────┐ │
75
+ │ Phase 3 │ │
76
+ │ Scoring Agent │ │
77
+ └──────┬──────────┘ │
78
+ ▼ │
79
+ ┌──────────────┐ ┌────────────────┐ │
80
+ │ Phase 4a │ │ Phase 4b │ (parallel)
81
+ │ Quality │ │ Table │ │
82
+ │ Control │ │ Generator │ │
83
+ │ Agent │ │ Agent │ ��
84
+ └──────────────┘ └────────────────┘ │
85
+
86
+ ┌───────────────────────────────────────┘
87
+
88
+ ┌─────────────────────────────────────────────────────────────┐
89
+ │ RAPPORT FINAL (JSON) │
90
+ │ Score /100 · Tableau · Verdict · Recommandation │
91
+ └─────────────────────────────────────────────────────────────┘
92
+ ```
93
+
94
+ ### Les 6 Agents
95
+
96
+ | # | Agent | Rôle | Entrées | Sorties |
97
+ |---|-------|------|---------|---------|
98
+ | 1 | `ExperienceAnalysisAgent` | Analyse chaque expérience professionnelle | Texte expériences + CV complet | Score, missions, résultats, erreurs détectées |
99
+ | 2 | `SkillsEducationAgent` | Évalue compétences & formations | Texte compétences/formations + résultat Agent 1 | Scores, compétences démontrées vs non démontrées |
100
+ | 3 | `SummaryValidationAgent` | Valide le résumé vs preuves concrètes | Texte résumé + résultat Agent 1 | Taux de preuve, écarts identifiés, score |
101
+ | 4 | `ScoringAgent` | Calcule le score pondéré global | Scores des agents 1 à 3 | Note /10, /20, /100 + détail par critère |
102
+ | 5 | `QualityControlAgent` | Verdict final et recommandations | Résultats agents 1 à 4 | Verdict, recommandation, forces/faiblesses |
103
+ | 6 | `TableGeneratorAgent` | Génère le tableau d'évaluation visuel | Résultats agents 1 à 3 | Tableau avec emojis + justifications |
104
+
105
+ ---
106
+
107
+ ## 📂 Structure du Projet
108
+
109
+ ```
110
+ cv_evaluator/
111
+ ├── app.py # Application Streamlit (frontend)
112
+ ├── orchestrator.py # Orchestration multi-agents
113
+ ├── requirements.txt # Dépendances Python (production)
114
+ ├── requirements-dev.txt # Dépendances de développement (tests, linting)
115
+ ├── pyproject.toml # Configuration du projet (ruff, pytest, mypy)
116
+ ├── Dockerfile # Image Docker multi-stage, utilisateur non-root
117
+ ├── docker-compose.prod.yml # Compose pour déploiement production
118
+ ├── .dockerignore # Exclusions du build Docker
119
+ ├── .env.example # Template variables d'environnement
120
+ ├── .gitignore # Exclusions Git
121
+ ├── README.md # Ce fichier
122
+
123
+ ├── .github/
124
+ │ └── workflows/
125
+ │ └── ci-cd.yml # Pipeline CI/CD (qualité, tests, Docker, HF deploy)
126
+
127
+ ├── .devcontainer/ # Configuration Dev Container (VS Code)
128
+
129
+ ├── assets/ # Ressources statiques
130
+ │ └── ci-cd-success.png # Capture d'écran du pipeline CI/CD réussi
131
+
132
+ ├── agents/ # Agents spécialisés
133
+ │ ├── __init__.py
134
+ │ ├── base_agent.py # Classe de base (LangChain + LLM abstrait)
135
+ │ ├── experience_agent.py # Agent 1 : Analyse expériences
136
+ │ ├── skills_education_agent.py # Agent 2 : Compétences & formations
137
+ │ ├── summary_validation_agent.py # Agent 3 : Validation résumé
138
+ │ ├── scoring_agent.py # Agent 4 : Calcul scores
139
+ │ ├── quality_control_agent.py # Agent 5 : Contrôle qualité
140
+ │ └── table_generator_agent.py # Agent 6 : Tableau d'évaluation
141
+
142
+ ├── models/ # Schémas Pydantic
143
+ │ ├── __init__.py
144
+ │ └── schemas.py # Tous les modèles de données validés
145
+
146
+ ├── prompts/ # Templates de prompts
147
+ │ ├── __init__.py
148
+ │ └── templates.py # Prompts optimisés par provider LLM
149
+
150
+ ├── tests/ # Suite de tests
151
+ │ ├── __init__.py
152
+ │ └── ... # Tests unitaires et d'intégration
153
+
154
+ └── utils/ # Utilitaires
155
+ ├── __init__.py
156
+ ├── pdf_parser.py # Extraction PDF (PyMuPDF)
157
+ ├── chunking.py # Découpage sémantique du CV
158
+ └── cache.py # Cache des résultats intermédiaires
159
+ ```
160
+
161
+ ---
162
+
163
+ ## 🚀 Installation & Exécution Locale
164
+
165
+ ### Prérequis
166
+
167
+ - Python 3.10+
168
+ - Une clé API LLM au choix :
169
+ - **Google Gemini** — [obtenir ici](https://makersuite.google.com/app/apikey)
170
+ - **OpenAI ChatGPT** — [obtenir ici](https://platform.openai.com/api-keys)
171
+ - **Ollama** (local ou cloud)
172
+
173
+ ### Installation
174
+
175
+ ```bash
176
+ # Cloner le projet
177
+ git clone https://github.com/yacineberkani/cv_evaluator.git
178
+ cd cv_evaluator
179
+
180
+ # Créer un environnement virtuel
181
+ python -m venv venv
182
+ source venv/bin/activate # Linux/Mac
183
+ # ou : venv\Scripts\activate # Windows
184
+
185
+ # Installer les dépendances de production
186
+ pip install -r requirements.txt
187
+
188
+ # (Optionnel) Installer les dépendances de développement
189
+ pip install -r requirements-dev.txt
190
+
191
+ # Configurer les variables d'environnement
192
+ cp .env.example .env
193
+ # Éditer .env et renseigner votre clé API
194
+ ```
195
+
196
+ ### Exécution
197
+
198
+ ```bash
199
+ streamlit run app.py
200
+ ```
201
+
202
+ L'application s'ouvrira sur `http://localhost:8501`.
203
+
204
+ ---
205
+
206
+ ## 🐳 Exécution avec Docker (via Docker Hub)
207
+
208
+ L'image est publiée et maintenue sur **Docker Hub** : [`yacineberkani32/cv-evaluator`](https://hub.docker.com/r/yacineberkani32/cv-evaluator)
209
+
210
+ <img width="1914" height="919" alt="Image" src="https://github.com/user-attachments/assets/c7ab269c-1af2-4b7f-842b-c7b8bc0f4c19" />
211
+
212
  ---
213
+ | Propriété | Valeur |
214
+ |-----------|--------|
215
+ | **Repository** | `yacineberkani32/cv-evaluator` |
216
+ | **Tag stable** | `latest` |
217
+ | **OS / Architecture** | `linux/amd64` |
218
+ | **Taille compressée** | 251.62 MB |
219
+ | **Runtime Python** | 3.11.15 |
220
+
221
+ ### Récupérer l'image depuis Docker Hub
222
+ ```bash
223
+ docker pull yacineberkani32/cv-evaluator:latest
224
+ ```
225
  ---
226
+ <img width="1368" height="772" alt="Image" src="https://github.com/user-attachments/assets/ac85dac9-473e-4169-a8dd-99a6b7b5ec0b" />
227
+
228
+
229
+ ### Lancer le conteneur
230
 
231
+ ```bash
232
+ docker run -p 7860:7860 \
233
+ -e GOOGLE_API_KEY=votre_clé_ici \
234
+ yacineberkani32/cv-evaluator:latest
235
+ ```
236
+
237
+ L'application sera accessible sur `http://localhost:7860`.
238
+
239
+ ### Avec Docker Compose (production)
240
+
241
+ ```bash
242
+ docker compose -f docker-compose.prod.yml up
243
+ ```
244
+
245
+ ### Builder l'image localement (développement)
246
+
247
+ Si vous souhaitez modifier l'image et la reconstruire :
248
+
249
+ ```bash
250
+ docker build -t cv-evaluator .
251
+ docker run -p 7860:7860 -e GOOGLE_API_KEY=votre_clé_ici cv-evaluator
252
+ ```
253
+
254
+ > **Note :** L'image expose le port `7860` — requis par Hugging Face Spaces. Le binaire `streamlit` est explicitement copié depuis l'étape `dependencies` vers l'étape finale (`COPY --from=dependencies /usr/local/bin /usr/local/bin`), indispensable dans un build multi-stage non-root (UID 1000).
255
+
256
+ ---
257
+
258
+ ## ☁️ Déploiement sur Hugging Face Spaces
259
+
260
+ Le déploiement est entièrement automatisé via un pipeline **GitHub Actions CI/CD** déclenché à chaque `push` sur la branche `main`.
261
+
262
+ ### Pipeline CI/CD — 4 jobs
263
+
264
+ ```
265
+ push → main
266
+
267
+ ├─ [1] quality → Linting (ruff), formatage, vérification types (mypy)
268
+
269
+ ├─ [2] tests → Exécution des tests unitaires (pytest) avec mock LLM
270
+
271
+ ├─ [3] build → Build Docker multi-stage + validation de l'image
272
+ │ (dépend de : quality + tests)
273
+
274
+ └─ [4] deploy → Push vers Hugging Face Spaces via l'API HF
275
+ (dépend de : build)
276
+ ```
277
+
278
+ ### Secrets GitHub à configurer
279
+
280
+ Rendez-vous dans **Settings → Secrets and variables → Actions** de votre repository :
281
+
282
+ | Secret | Description |
283
+ |--------|-------------|
284
+ | `HF_TOKEN` | Token d'accès Hugging Face (write) |
285
+ | `HF_SPACE_NAME` | Nom du Space HF cible (ex. `berkani/cv_evaluator`) |
286
+ | `GOOGLE_API_KEY` | Clé API Google Gemini (injectée dans le Space) |
287
+ | `OPENAI_API_KEY` | Clé API OpenAI (optionnel, si provider ChatGPT utilisé) |
288
+
289
+ ### Fonctionnement automatique
290
+
291
+ Une fois les secrets configurés, chaque `git push` sur `main` déclenche automatiquement le pipeline. En cas de succès sur tous les jobs, le Space Hugging Face est mis à jour sans intervention manuelle.
292
+
293
+ ---
294
+
295
+ ## 📊 Statut du Déploiement
296
+
297
+ Le pipeline CI/CD passe intégralement — qualité, tests, build Docker et déploiement Hugging Face sont tous au vert.
298
+
299
+ <img width="1658" height="447" alt="Image" src="https://github.com/user-attachments/assets/a96ee773-e521-4391-9259-fdd63a53c0f3" />
300
+
301
+ ---
302
+
303
+ ## ⚙️ Variables d'Environnement
304
+
305
+ | Variable | Description | Défaut |
306
+ |----------|-------------|--------|
307
+ | `GOOGLE_API_KEY` | Clé API Google Gemini | *(obligatoire si provider Gemini)* |
308
+ | `OPENAI_API_KEY` | Clé API OpenAI ChatGPT | *(obligatoire si provider ChatGPT)* |
309
+ | `OLLAMA_BASE_URL` | URL du serveur Ollama | `http://localhost:11434` |
310
+ | `LLM_PROVIDER` | Provider LLM actif (`gemini`, `openai`, `ollama`) | `gemini` |
311
+ | `GEMINI_MODEL` | Modèle Gemini à utiliser | `gemini-2.5-flash-lite` |
312
+ | `GEMINI_TEMPERATURE` | Température LLM (0 = déterministe) | `0` |
313
+
314
+ ---
315
+
316
+ ## 📏 Formule de Scoring
317
+
318
+ ```
319
+ Note /10 = (Expériences × 0.5) + (Compétences × 0.2) + (Formations × 0.1) + (Résumé × 0.2)
320
+ ```
321
+
322
+ - **Note /20** = Note /10 × 2
323
+ - **Note /100** = Note /10 × 10
324
+
325
+ Le `ScoringAgent` effectue le calcul via le LLM, puis le valide **programmatiquement** via Pydantic pour garantir la cohérence du résultat.
326
+
327
+ ---
328
+
329
+ ## 🔧 Bonnes Pratiques Implémentées
330
+
331
+ ### Intelligence Artificielle
332
+
333
+ - ✅ **Déterminisme** : température = 0, prompts stricts, sorties JSON validées par Pydantic
334
+ - ✅ **Gestion d'erreurs** : retry avec backoff exponentiel (3 tentatives max), fallback de parsing JSON robuste
335
+ - ✅ **Chunking sémantique** : découpage du CV par sections (expériences, compétences, résumé) pour respecter la fenêtre de contexte du LLM
336
+ - ✅ **Parallélisme** : les paires d'agents 2a/2b et 4a/4b s'exécutent en parallèle via `ThreadPoolExecutor`
337
+ - ✅ **Caching** : résultats intermédiaires mis en cache pour éviter les appels LLM redondants
338
+ - ✅ **Modularité** : chaque agent est une classe indépendante, extensible et testable isolément
339
+ - ✅ **Validation stricte** : chaque sortie JSON est validée par un modèle Pydantic dédié avant traitement
340
+ - ✅ **Données manquantes** : signalement systématique d'une section absente plutôt qu'invention de données
341
+
342
+ ### DevOps
343
+
344
+ - ✅ **Docker multi-stage non-root** : image slim en 3 étapes (`base → dependencies → final`), l'utilisateur `user` (UID 1000) est imposé par Hugging Face Spaces
345
+ - ✅ **Pipeline CI/CD GitHub Actions** : 4 jobs ordonnés (qualité → tests → build → déploiement) avec dépendances explicites
346
+ - ✅ **Déploiement continu** : chaque push sur `main` déclenche automatiquement la mise à jour du Space HF
347
+ - ✅ **Cohérence des ports** : port `7860` utilisé de bout en bout (Dockerfile, docker-compose, CMD Streamlit) pour la compatibilité native avec Hugging Face Spaces
348
+
349
+ ---
350
+
351
+ ## 🧩 Défis Techniques Relevés
352
+
353
+ Ce projet a nécessité la résolution de plusieurs problèmes non triviaux lors du déploiement sur Hugging Face Spaces :
354
+
355
+ - **Port incorrect** : Streamlit démarrait par défaut sur le port `8501`, incompatible avec HF Spaces qui exige le port `7860`. Correction appliquée dans le `CMD` du Dockerfile et dans `docker-compose.prod.yml`.
356
+ - **Binaires manquants en multi-stage build** : dans un build Docker multi-stage, seuls les `site-packages` étaient copiés vers l'image finale, mais pas les exécutables (`streamlit`, etc.) présents dans `/usr/local/bin`. Ajout explicite de `COPY --from=dependencies /usr/local/bin /usr/local/bin`.
357
+ - **Emoji invalide dans le YAML CI/CD** : certains caractères Unicode (emojis) dans les `name:` des steps GitHub Actions provoquaient des erreurs de parsing YAML. Suppression ou remplacement par des équivalents textuels.
358
+ - **Dépôt Git imbriqué** : un sous-dossier contenant un `.git/` propre était ignoré par Git lors du push, entraînant un déploiement incomplet sur HF. Résolu par suppression du `.git/` interne ou utilisation d'un submodule explicite.
359
+ - **Permissions utilisateur HF** : Hugging Face impose l'UID `1000` pour l'utilisateur non-root. Configuration du `useradd -m -u 1000 user` et `COPY --chown=user:user` dans le Dockerfile.
360
+
361
+ ---
362
 
363
+ ## 📄 Licence
364
 
365
+ **JEMSLABS** Tous droits réservés.
 
agents/__init__.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from agents.experience_agent import ExperienceAnalysisAgent
2
+ from agents.quality_control_agent import QualityControlAgent
3
+ from agents.scoring_agent import ScoringAgent
4
+ from agents.skills_education_agent import SkillsEducationAgent
5
+ from agents.summary_validation_agent import SummaryValidationAgent
6
+ from agents.table_generator_agent import TableGeneratorAgent
7
+
8
+ __all__ = [
9
+ "ExperienceAnalysisAgent",
10
+ "SkillsEducationAgent",
11
+ "SummaryValidationAgent",
12
+ "ScoringAgent",
13
+ "QualityControlAgent",
14
+ "TableGeneratorAgent",
15
+ ]
agents/base_agent.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Base agent class for all CV evaluation agents.
3
+ Supports multiple LLM providers: OpenAI (ChatGPT), Google (Gemini) via LangChain.
4
+ """
5
+
6
+ import json
7
+ import logging
8
+ import os
9
+ import re
10
+ from typing import Any, Literal, TypeVar
11
+
12
+ from langchain_core.language_models.chat_models import BaseChatModel
13
+ from langchain_core.messages import HumanMessage, SystemMessage
14
+ from pydantic import BaseModel, ValidationError
15
+ from tenacity import (
16
+ retry,
17
+ retry_if_exception_type,
18
+ stop_after_attempt,
19
+ wait_exponential,
20
+ )
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ T = TypeVar("T", bound=BaseModel)
25
+
26
+ # Supported provider types
27
+ ProviderType = Literal["gemini", "openai", "ollama"]
28
+
29
+
30
+ def create_llm(
31
+ provider: ProviderType,
32
+ model_name: str | None,
33
+ temperature: float,
34
+ api_key: str | None,
35
+ ) -> BaseChatModel:
36
+ """Factory function to instantiate the correct LangChain LLM based on provider."""
37
+
38
+ if provider == "gemini":
39
+ from langchain_google_genai import ChatGoogleGenerativeAI
40
+
41
+ resolved_key = api_key or os.getenv("GOOGLE_API_KEY", "")
42
+ resolved_model = model_name or os.getenv("GEMINI_MODEL", "gemini-1.5-flash")
43
+
44
+ if not resolved_key:
45
+ raise ValueError(
46
+ "GOOGLE_API_KEY not found. Set it in .env or pass it directly."
47
+ )
48
+
49
+ return ChatGoogleGenerativeAI(
50
+ model=resolved_model,
51
+ google_api_key=resolved_key,
52
+ temperature=temperature,
53
+ convert_system_message_to_human=True, # Gemini doesn't support SystemMessage natively
54
+ )
55
+
56
+ elif provider == "openai":
57
+ from langchain_openai import ChatOpenAI
58
+
59
+ resolved_key = api_key or os.getenv("OPENAI_API_KEY", "")
60
+ resolved_model = model_name or os.getenv("OPENAI_MODEL", "gpt-4o-mini")
61
+
62
+ if not resolved_key:
63
+ raise ValueError(
64
+ "OPENAI_API_KEY not found. Set it in .env or pass it directly."
65
+ )
66
+
67
+ return ChatOpenAI(
68
+ model=resolved_model,
69
+ openai_api_key=resolved_key,
70
+ temperature=temperature,
71
+ request_timeout=60, # 60s timeout
72
+ max_retries=2,
73
+ )
74
+
75
+ elif provider == "ollama":
76
+ from langchain_openai import ChatOpenAI
77
+
78
+ # Ollama Cloud API configuration
79
+ resolved_key = api_key or os.getenv("OLLAMA_API_KEY")
80
+ resolved_model = model_name or os.getenv("OLLAMA_MODEL", "glm-5.1:cloud")
81
+
82
+ if not resolved_key:
83
+ raise ValueError(
84
+ "OLLAMA_API_KEY not found. Set it in .env or pass it directly."
85
+ )
86
+
87
+ return ChatOpenAI(
88
+ model=resolved_model,
89
+ base_url="https://ollama.com/v1",
90
+ openai_api_key=resolved_key,
91
+ temperature=temperature,
92
+ request_timeout=120, # 120s timeout for Ollama Cloud
93
+ max_retries=2,
94
+ )
95
+
96
+ else:
97
+ raise ValueError(
98
+ f"Unsupported provider: '{provider}'. Choose 'gemini', 'openai', or 'ollama'."
99
+ )
100
+
101
+
102
+ class BaseAgent:
103
+ """
104
+ Base class for all CV evaluation agents.
105
+ Supports OpenAI (ChatGPT) and Google (Gemini) via LangChain.
106
+ """
107
+
108
+ def __init__(
109
+ self,
110
+ name: str,
111
+ role: str,
112
+ provider: ProviderType = "gemini",
113
+ model_name: str | None = None,
114
+ temperature: float = 0,
115
+ api_key: str | None = None,
116
+ ):
117
+ self.name = name
118
+ self.role = role
119
+ self.provider = provider
120
+ self.model_name = model_name
121
+ self.temperature = temperature
122
+
123
+ self.llm: BaseChatModel = create_llm(
124
+ provider=provider,
125
+ model_name=model_name,
126
+ temperature=temperature,
127
+ api_key=api_key,
128
+ )
129
+
130
+ logger.info(
131
+ f"[{self.name}] Initialized with provider='{provider}', model='{self.model_name or 'default'}'"
132
+ )
133
+
134
+ def _build_messages(self, prompt: str) -> list:
135
+ """
136
+ Build the message list for the LLM.
137
+ Gemini uses convert_system_message_to_human, so SystemMessage is safe for OpenAI
138
+ and handled automatically for Gemini.
139
+ """
140
+ messages = []
141
+ if self.role:
142
+ messages.append(SystemMessage(content=self.role))
143
+ messages.append(HumanMessage(content=prompt))
144
+ return messages
145
+
146
+ def _extract_json_from_response(self, text: str) -> str:
147
+ """Extract JSON from LLM response, handling markdown code blocks."""
148
+ patterns = [
149
+ r"```json\s*([\s\S]*?)```",
150
+ r"```\s*([\s\S]*?)```",
151
+ r"(\{[\s\S]*\})",
152
+ ]
153
+ for pattern in patterns:
154
+ match = re.search(pattern, text)
155
+ if match:
156
+ candidate = match.group(1).strip()
157
+ try:
158
+ json.loads(candidate)
159
+ return candidate
160
+ except json.JSONDecodeError:
161
+ continue
162
+
163
+ return text.strip()
164
+
165
+ @retry(
166
+ stop=stop_after_attempt(3),
167
+ wait=wait_exponential(multiplier=1, min=2, max=10),
168
+ retry=retry_if_exception_type((json.JSONDecodeError, ValidationError)),
169
+ reraise=True,
170
+ )
171
+ def _call_llm_with_retry(self, prompt: str, output_model: type[T]) -> T:
172
+ """Call LLM with retry logic for JSON parsing failures."""
173
+ logger.info(f"[{self.name}] Calling {self.provider} LLM...")
174
+
175
+ messages = self._build_messages(prompt)
176
+ response = self.llm.invoke(messages)
177
+ raw_text = response.content
178
+
179
+ logger.debug(f"[{self.name}] Raw response:\n{raw_text}")
180
+
181
+ json_str = self._extract_json_from_response(raw_text)
182
+
183
+ # --- JSON parse ---
184
+ try:
185
+ data = json.loads(json_str)
186
+ except json.JSONDecodeError as e:
187
+ logger.warning(f"[{self.name}] JSON parse error: {e}. Asking LLM to fix...")
188
+ fix_prompt = (
189
+ f"Le texte suivant devait être un JSON valide mais contient des erreurs. "
190
+ f"Corrige-le et renvoie UNIQUEMENT le JSON valide :\n\n{json_str}"
191
+ )
192
+ fix_response = self.llm.invoke([HumanMessage(content=fix_prompt)])
193
+ json_str = self._extract_json_from_response(fix_response.content)
194
+ data = json.loads(json_str) # raises → retry
195
+
196
+ # --- Pydantic validation ---
197
+ try:
198
+ result = output_model.model_validate(data)
199
+ except ValidationError as e:
200
+ # Log exactly which fields are wrong
201
+ logger.warning(
202
+ f"[{self.name}] Pydantic ValidationError:\n{e}\n"
203
+ f"Data received:\n{json.dumps(data, indent=2, ensure_ascii=False)}"
204
+ )
205
+ # Send schema + errors back to LLM for self-correction
206
+ schema = json.dumps(
207
+ output_model.model_json_schema(), indent=2, ensure_ascii=False
208
+ )
209
+ fix_prompt = (
210
+ f"Le JSON suivant ne respecte pas le schéma attendu.\n\n"
211
+ f"SCHÉMA:\n{schema}\n\n"
212
+ f"JSON REÇU:\n{json.dumps(data, indent=2, ensure_ascii=False)}\n\n"
213
+ f"ERREURS DE VALIDATION:\n{str(e)}\n\n"
214
+ f"Renvoie UNIQUEMENT un JSON corrigé qui respecte exactement le schéma."
215
+ )
216
+ fix_response = self.llm.invoke([HumanMessage(content=fix_prompt)])
217
+ json_str = self._extract_json_from_response(fix_response.content)
218
+ data = json.loads(json_str)
219
+ result = output_model.model_validate(data) # raises ValidationError → retry
220
+
221
+ logger.info(f"[{self.name}] Successfully parsed and validated output.")
222
+ return result
223
+
224
+ def run(self, **kwargs) -> Any:
225
+ """Override in subclasses."""
226
+ raise NotImplementedError("Subclasses must implement run()")
agents/experience_agent.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ExperienceAnalysisAgent - Extracts and evaluates each professional experience.
3
+ """
4
+
5
+ import logging
6
+
7
+ from agents.base_agent import BaseAgent
8
+ from models.schemas import ExperienceAnalysisOutput
9
+ from prompts.templates import EXPERIENCE_ANALYSIS_PROMPT
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class ExperienceAnalysisAgent(BaseAgent):
15
+ def __init__(self, **kwargs):
16
+ super().__init__(
17
+ name="ExperienceAnalysisAgent",
18
+ role=(
19
+ "Extraire et évaluer chaque expérience professionnelle du CV. "
20
+ "Critères : contexte métier, missions différenciantes, résultats mesurables, "
21
+ "cohérence technique, détection d'erreurs naïves."
22
+ ),
23
+ **kwargs,
24
+ )
25
+
26
+ def run(self, cv_experiences: str, cv_full_text: str) -> ExperienceAnalysisOutput:
27
+ """
28
+ Analyze the experience section of a CV.
29
+
30
+ Args:
31
+ cv_experiences: Text content of the experiences section.
32
+ cv_full_text: Full CV text for context.
33
+
34
+ Returns:
35
+ ExperienceAnalysisOutput: Validated analysis results.
36
+ """
37
+ logger.info(f"[{self.name}] Starting experience analysis...")
38
+
39
+ prompt = EXPERIENCE_ANALYSIS_PROMPT.format(
40
+ cv_experiences=cv_experiences,
41
+ cv_full_text=cv_full_text[:8000], # Limit context to avoid overflow
42
+ )
43
+
44
+ result = self._call_llm_with_retry(prompt, ExperienceAnalysisOutput)
45
+ logger.info(
46
+ f"[{self.name}] Analysis complete. "
47
+ f"Found {len(result.experiences)} experiences. "
48
+ f"Global score: {result.score_global_experiences}/10"
49
+ )
50
+ return result
agents/quality_control_agent.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ QualityControlAgent - Final quality assessment and verdict.
3
+ """
4
+
5
+ import json
6
+ import logging
7
+
8
+ from agents.base_agent import BaseAgent
9
+ from models.schemas import (
10
+ ExperienceAnalysisOutput,
11
+ QualityControlOutput,
12
+ ScoringOutput,
13
+ SkillsEducationOutput,
14
+ SummaryValidationOutput,
15
+ )
16
+ from prompts.templates import QUALITY_CONTROL_PROMPT
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class QualityControlAgent(BaseAgent):
22
+ def __init__(self, **kwargs):
23
+ super().__init__(
24
+ name="QualityControlAgent",
25
+ role=(
26
+ "Contrôle qualité final du CV. Vérifie la présence des éléments clés, "
27
+ "évalue l'alignement global et rend un verdict : profil vendeur vs banal."
28
+ ),
29
+ **kwargs,
30
+ )
31
+
32
+ def run(
33
+ self,
34
+ experience_analysis: ExperienceAnalysisOutput,
35
+ skills_education: SkillsEducationOutput,
36
+ summary_validation: SummaryValidationOutput,
37
+ scoring: ScoringOutput,
38
+ ) -> QualityControlOutput:
39
+ """
40
+ Perform final quality control assessment.
41
+
42
+ Args:
43
+ experience_analysis: Results from ExperienceAnalysisAgent.
44
+ skills_education: Results from SkillsEducationAgent.
45
+ summary_validation: Results from SummaryValidationAgent.
46
+ scoring: Results from ScoringAgent.
47
+
48
+ Returns:
49
+ QualityControlOutput: Final verdict and quality assessment.
50
+ """
51
+ logger.info(f"[{self.name}] Starting quality control...")
52
+
53
+ def truncated_json(obj, max_len=4000):
54
+ s = json.dumps(obj.model_dump(), ensure_ascii=False, indent=2)
55
+ return s[:max_len] if len(s) > max_len else s
56
+
57
+ prompt = QUALITY_CONTROL_PROMPT.format(
58
+ experience_analysis=truncated_json(experience_analysis),
59
+ skills_education=truncated_json(skills_education),
60
+ summary_validation=truncated_json(summary_validation),
61
+ scoring=truncated_json(scoring),
62
+ )
63
+
64
+ result = self._call_llm_with_retry(prompt, QualityControlOutput)
65
+ logger.info(
66
+ f"[{self.name}] Quality control complete. "
67
+ f"Verdict: {result.verdict}, "
68
+ f"Recommendation: {result.recommandation}"
69
+ )
70
+ return result
agents/scoring_agent.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ScoringAgent - Calculates weighted scores according to the strict formula.
3
+ """
4
+
5
+ import logging
6
+
7
+ from agents.base_agent import BaseAgent
8
+ from models.schemas import ScoringOutput
9
+ from prompts.templates import SCORING_PROMPT
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class ScoringAgent(BaseAgent):
15
+ def __init__(self, **kwargs):
16
+ super().__init__(
17
+ name="ScoringAgent",
18
+ role=(
19
+ "Calculer le score final pondéré du CV selon la formule stricte : "
20
+ "Note/10 = (Exp × 0.5) + (Comp × 0.2) + (Form × 0.1) + (Résumé × 0.2). "
21
+ "Afficher les calculs intermédiaires et valider mathématiquement."
22
+ ),
23
+ **kwargs,
24
+ )
25
+
26
+ def run(
27
+ self,
28
+ score_experiences: float,
29
+ score_competences: float,
30
+ score_formations: float,
31
+ score_resume: float,
32
+ ) -> ScoringOutput:
33
+ """
34
+ Calculate the final weighted score.
35
+
36
+ Args:
37
+ score_experiences: Experience score /10
38
+ score_competences: Skills score /10
39
+ score_formations: Education score /10
40
+ score_resume: Summary score /10
41
+
42
+ Returns:
43
+ ScoringOutput: Validated scoring results.
44
+ """
45
+ logger.info(f"[{self.name}] Calculating scores...")
46
+
47
+ prompt = SCORING_PROMPT.format(
48
+ score_experiences=score_experiences,
49
+ score_competences=score_competences,
50
+ score_formations=score_formations,
51
+ score_resume=score_resume,
52
+ )
53
+
54
+ result = self._call_llm_with_retry(prompt, ScoringOutput)
55
+
56
+ # Double-check the math programmatically
57
+ expected = (
58
+ score_experiences * 0.5
59
+ + score_competences * 0.2
60
+ + score_formations * 0.1
61
+ + score_resume * 0.2
62
+ )
63
+ expected = round(expected, 2)
64
+
65
+ if abs(result.note_finale_sur_10 - expected) > 0.1:
66
+ logger.warning(
67
+ f"[{self.name}] Math discrepancy detected! "
68
+ f"LLM: {result.note_finale_sur_10}, Expected: {expected}. Correcting..."
69
+ )
70
+ result.note_finale_sur_10 = expected
71
+ result.note_finale_sur_20 = round(expected * 2, 2)
72
+ result.note_finale_sur_100 = round(expected * 10, 2)
73
+ result.validation_mathematique = True
74
+ result.erreur_calcul = (
75
+ f"Corrigé programmatiquement. LLM avait calculé différemment. "
76
+ f"Valeur correcte : {expected}/10"
77
+ )
78
+ result.calcul_intermediaire = (
79
+ f"({score_experiences} × 0.5) + ({score_competences} × 0.2) + "
80
+ f"({score_formations} × 0.1) + ({score_resume} × 0.2) = {expected}"
81
+ )
82
+
83
+ logger.info(
84
+ f"[{self.name}] Final score: {result.note_finale_sur_10}/10 "
85
+ f"({result.note_finale_sur_100}/100)"
86
+ )
87
+ return result
agents/skills_education_agent.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SkillsEducationAgent - Evaluates skills and education sections.
3
+ """
4
+
5
+ import json
6
+ import logging
7
+
8
+ from agents.base_agent import BaseAgent
9
+ from models.schemas import ExperienceAnalysisOutput, SkillsEducationOutput
10
+ from prompts.templates import SKILLS_EDUCATION_PROMPT
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class SkillsEducationAgent(BaseAgent):
16
+ def __init__(self, **kwargs):
17
+ super().__init__(
18
+ name="SkillsEducationAgent",
19
+ role=(
20
+ "Évaluer les compétences et formations du CV. "
21
+ "Critères : clarté, structuration, correspondance compétences ↔ expériences, "
22
+ "détection des compétences non démontrées, cohérence formation ↔ parcours."
23
+ ),
24
+ **kwargs,
25
+ )
26
+
27
+ def run(
28
+ self,
29
+ cv_competences: str,
30
+ cv_formations: str,
31
+ experience_analysis: ExperienceAnalysisOutput,
32
+ ) -> SkillsEducationOutput:
33
+ """
34
+ Analyze skills and education sections.
35
+
36
+ Args:
37
+ cv_competences: Text of the skills section.
38
+ cv_formations: Text of the education section.
39
+ experience_analysis: Results from ExperienceAnalysisAgent for cross-checking.
40
+
41
+ Returns:
42
+ SkillsEducationOutput: Validated analysis results.
43
+ """
44
+ logger.info(f"[{self.name}] Starting skills & education analysis...")
45
+
46
+ # Serialize experience analysis for context
47
+ exp_json = json.dumps(
48
+ experience_analysis.model_dump(),
49
+ ensure_ascii=False,
50
+ indent=2,
51
+ )
52
+
53
+ prompt = SKILLS_EDUCATION_PROMPT.format(
54
+ cv_competences=cv_competences,
55
+ cv_formations=cv_formations,
56
+ experience_analysis=exp_json[:6000],
57
+ )
58
+
59
+ result = self._call_llm_with_retry(prompt, SkillsEducationOutput)
60
+ logger.info(
61
+ f"[{self.name}] Analysis complete. "
62
+ f"Skills score: {result.score_competences}/10, "
63
+ f"Education score: {result.score_formations}/10"
64
+ )
65
+ return result
agents/summary_validation_agent.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SummaryValidationAgent - Validates resume/profile claims against experience evidence.
3
+ """
4
+
5
+ import json
6
+ import logging
7
+
8
+ from agents.base_agent import BaseAgent
9
+ from models.schemas import ExperienceAnalysisOutput, SummaryValidationOutput
10
+ from prompts.templates import SUMMARY_VALIDATION_PROMPT
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class SummaryValidationAgent(BaseAgent):
16
+ def __init__(self, **kwargs):
17
+ super().__init__(
18
+ name="SummaryValidationAgent",
19
+ role=(
20
+ "Valider le résumé/profil du CV en confrontant chaque affirmation "
21
+ "aux preuves trouvées dans les expériences. Distinguer les affirmations "
22
+ "prouvées des déclaratives non étayées."
23
+ ),
24
+ **kwargs,
25
+ )
26
+
27
+ def run(
28
+ self,
29
+ cv_resume: str,
30
+ experience_analysis: ExperienceAnalysisOutput,
31
+ ) -> SummaryValidationOutput:
32
+ """
33
+ Validate the summary/profile section against experience analysis.
34
+
35
+ Args:
36
+ cv_resume: Text of the summary/profile section.
37
+ experience_analysis: Results from ExperienceAnalysisAgent.
38
+
39
+ Returns:
40
+ SummaryValidationOutput: Validated analysis results.
41
+ """
42
+ logger.info(f"[{self.name}] Starting summary validation...")
43
+
44
+ exp_json = json.dumps(
45
+ experience_analysis.model_dump(),
46
+ ensure_ascii=False,
47
+ indent=2,
48
+ )
49
+
50
+ prompt = SUMMARY_VALIDATION_PROMPT.format(
51
+ cv_resume=cv_resume,
52
+ experience_analysis=exp_json[:6000],
53
+ )
54
+
55
+ result = self._call_llm_with_retry(prompt, SummaryValidationOutput)
56
+ logger.info(
57
+ f"[{self.name}] Validation complete. "
58
+ f"Resume score: {result.score_resume}/10, "
59
+ f"Claims proven: {result.taux_affirmations_prouvees}%"
60
+ )
61
+ return result
agents/table_generator_agent.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ TableGeneratorAgent - Generates structured evaluation table.
3
+ """
4
+
5
+ import json
6
+ import logging
7
+
8
+ from agents.base_agent import BaseAgent
9
+ from models.schemas import (
10
+ ExperienceAnalysisOutput,
11
+ SkillsEducationOutput,
12
+ SummaryValidationOutput,
13
+ TableGeneratorOutput,
14
+ )
15
+ from prompts.templates import TABLE_GENERATOR_PROMPT
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class TableGeneratorAgent(BaseAgent):
21
+ def __init__(self, **kwargs):
22
+ super().__init__(
23
+ name="TableGeneratorAgent",
24
+ role=(
25
+ "Générer un tableau d'évaluation structuré avec emojis et justifications "
26
+ "pour chaque section du CV."
27
+ ),
28
+ **kwargs,
29
+ )
30
+
31
+ def run(
32
+ self,
33
+ experience_analysis: ExperienceAnalysisOutput,
34
+ skills_education: SkillsEducationOutput,
35
+ summary_validation: SummaryValidationOutput,
36
+ ) -> TableGeneratorOutput:
37
+ """
38
+ Generate structured evaluation table.
39
+
40
+ Args:
41
+ experience_analysis: Results from ExperienceAnalysisAgent.
42
+ skills_education: Results from SkillsEducationAgent.
43
+ summary_validation: Results from SummaryValidationAgent.
44
+
45
+ Returns:
46
+ TableGeneratorOutput: Structured evaluation table.
47
+ """
48
+ logger.info(f"[{self.name}] Generating evaluation table...")
49
+
50
+ def truncated_json(obj, max_len=4000):
51
+ s = json.dumps(obj.model_dump(), ensure_ascii=False, indent=2)
52
+ return s[:max_len] if len(s) > max_len else s
53
+
54
+ prompt = TABLE_GENERATOR_PROMPT.format(
55
+ experience_analysis=truncated_json(experience_analysis),
56
+ skills_education=truncated_json(skills_education),
57
+ summary_validation=truncated_json(summary_validation),
58
+ )
59
+
60
+ result = self._call_llm_with_retry(prompt, TableGeneratorOutput)
61
+ logger.info(f"[{self.name}] Table generated with {len(result.lignes)} rows.")
62
+ return result
app.py ADDED
@@ -0,0 +1,1379 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ CV Evaluator - Multi-Agent Streamlit Application
3
+ Main entry point for the CV evaluation system.
4
+ """
5
+
6
+ import json
7
+ import logging
8
+ import os
9
+ import sys
10
+ import warnings
11
+ from datetime import datetime
12
+
13
+ import streamlit as st
14
+ from dotenv import load_dotenv
15
+
16
+ # ── Setup ──
17
+ load_dotenv(override=False) # Ne pas écraser les variables d'environnement existantes
18
+ logging.basicConfig(
19
+ level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s"
20
+ )
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # Suppress urllib3/requests compatibility warnings
24
+ warnings.filterwarnings("ignore", category=ImportWarning, module="requests")
25
+
26
+ # Add project root to path
27
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
28
+
29
+ from models.schemas import FinalReport
30
+ from orchestrator import CVEvaluationOrchestrator
31
+ from utils.pdf_parser import extract_text_from_uploaded_file
32
+
33
+ # ── Page config ──
34
+ st.set_page_config(
35
+ page_title="CV Evaluator - JEMS Group",
36
+ page_icon="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAMAAABF0y+mAAAAulBMVEUAAAD9sxr+YXP/bmP6eFj/Tob/vBFHcEz/iUf+YXL/UoL/tRn/VX7+shv/ToX/pSn/uBb/vRD/sB7/XnT/ljr+mjX+vRH/////TYf/vRD/U4H/WHz/jj//XXX/oiz/qyL/gkn/mzT/fVP/aGj/Ym//tBn/cGP/ljn/d1v/TXb/4+H/hYj/cUz/V2j/qgj/4sH/0tT/qrT/xr//vqn/z5r/maX/j23/rJn/Xkr/8/D/jiP/w2X+fBr/wEkOWfK9AAAAF3RSTlMBL17fFObgAP42wLzzVoXlfPH5f9SEgb0o5TAAAAGFSURBVCiRZdLpkoIwDADgckbAa92j4IkIrhyiiPfq+7/WJi0o6/KD6eSbtJkkjIkPAKx322i3DbtrAbDmB6BqYTgZjYZD13UNtcEAurZcLMJJra6hPxSsYLX8q64FtfmB0PC/gj5u+cFqRanhE11xM3wQBtnx5WIDETrzb1S/5DwVGsexVBUY9KZCOee7c5qu18X2IBVTlRnh+Ii4PXFepHgYVTWx/kxoSZkJ58kBD4VM7TInmk2n8+sGY+WF80uBhyQWRdnMjEivGOIn+lEmj0XJBnvzIrqYQvQuT8UPERWR9IdCVDE/i3SZykwPNd9T6IZvJudEVES9MJjzwMtth0VlO6p7TZ2yWR/Ry6nYzXVcnrKMytoK7DKF0CPcYy98HIFEVIuBWaNsY5AlhNjkNva2Q3jHxLzSY5KkND2VxiJSvXsum0y5GU1Po0UCXaDsBT0byMlXa6TUOq0upqWpl6jSqKnWczl1s3lxy9fkAtWb2zGf2lJfdp6B8uWYg0HP+VSgtl/wnEmGER38dAAAAABJRU5ErkJggg==",
37
+ layout="wide",
38
+ initial_sidebar_state="expanded",
39
+ )
40
+
41
+ # ── Custom CSS ──
42
+ st.markdown(
43
+ """
44
+ <style>
45
+ /* ── Global fonts & base ── */
46
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
47
+
48
+ html, body, [class*="css"] {
49
+ font-family: 'Inter', sans-serif;
50
+ }
51
+
52
+ /* ── Header banner ── */
53
+ .main-header {
54
+ text-align: center;
55
+ padding: 2rem 1.5rem;
56
+ background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
57
+ color: white;
58
+ border-radius: 16px;
59
+ margin-bottom: 2rem;
60
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
61
+ border: 1px solid rgba(255,255,255,0.08);
62
+ }
63
+ .main-header h1 {
64
+ font-size: 2.2rem;
65
+ font-weight: 700;
66
+ margin: 0 0 0.4rem 0;
67
+ letter-spacing: -0.5px;
68
+ }
69
+ .main-header .subtitle {
70
+ font-size: 1rem;
71
+ opacity: 0.75;
72
+ margin: 0;
73
+ }
74
+ .main-header .badge-row {
75
+ display: flex;
76
+ justify-content: center;
77
+ gap: 0.6rem;
78
+ margin-top: 0.8rem;
79
+ flex-wrap: wrap;
80
+ }
81
+ .main-header .badge {
82
+ background: rgba(255,255,255,0.12);
83
+ border: 1px solid rgba(255,255,255,0.2);
84
+ border-radius: 20px;
85
+ padding: 0.2rem 0.75rem;
86
+ font-size: 0.78rem;
87
+ font-weight: 500;
88
+ backdrop-filter: blur(4px);
89
+ }
90
+
91
+ /* ── Upload zone ── */
92
+ .upload-zone {
93
+ border: 2px dashed #4f6ef7;
94
+ border-radius: 12px;
95
+ padding: 2rem;
96
+ text-align: center;
97
+ background: linear-gradient(135deg, rgba(79,110,247,0.05), rgba(118,75,162,0.05));
98
+ margin-bottom: 1rem;
99
+ }
100
+ .upload-zone h3 { color: #4f6ef7; margin-bottom: 0.3rem; }
101
+ .upload-zone p { color: #888; font-size: 0.9rem; margin: 0; }
102
+
103
+ /* ── File info banner ── */
104
+ .file-info-banner {
105
+ display: flex;
106
+ align-items: center;
107
+ gap: 1rem;
108
+ padding: 1rem 1.25rem;
109
+ background: linear-gradient(135deg, #d4edda, #c3e6cb);
110
+ border: 1px solid #b1dfbb;
111
+ border-radius: 10px;
112
+ margin: 0.75rem 0 1.25rem 0;
113
+ }
114
+ .file-info-banner .icon { font-size: 1.8rem; }
115
+ .file-info-banner .name { font-weight: 600; font-size: 1rem; color: #155724; }
116
+ .file-info-banner .size { font-size: 0.82rem; color: #2d6a4f; }
117
+
118
+ /* ── Score cards ── */
119
+ .score-card {
120
+ text-align: center;
121
+ padding: 1.5rem 1rem;
122
+ border-radius: 14px;
123
+ margin: 0.4rem 0;
124
+ box-shadow: 0 4px 16px rgba(0,0,0,0.12);
125
+ transition: transform 0.2s;
126
+ }
127
+ .score-card:hover { transform: translateY(-2px); }
128
+ .score-excellent { background: linear-gradient(135deg, #11998e, #38ef7d); color: white; }
129
+ .score-good { background: linear-gradient(135deg, #36d1dc, #5b86e5); color: white; }
130
+ .score-average { background: linear-gradient(135deg, #f2994a, #f2c94c); color: white; }
131
+ .score-low { background: linear-gradient(135deg, #eb3349, #f45c43); color: white; }
132
+
133
+ /* ── Verdict box ── */
134
+ .verdict-box {
135
+ padding: 1.25rem 1.5rem;
136
+ border-radius: 10px;
137
+ border-left: 5px solid;
138
+ margin: 1rem 0;
139
+ }
140
+ .verdict-oui { background: #d4edda; border-color: #28a745; }
141
+ .verdict-non { background: #f8d7da; border-color: #dc3545; }
142
+ .verdict-maybe { background: #fff3cd; border-color: #ffc107; }
143
+
144
+ /* ── Agent section (sidebar) ── */
145
+ .agent-section {
146
+ background: rgba(79,110,247,0.06);
147
+ padding: 0.85rem 1rem;
148
+ border-radius: 8px;
149
+ margin: 0.4rem 0;
150
+ border-left: 3px solid #4f6ef7;
151
+ }
152
+
153
+ /* ── Progress bar color ── */
154
+ .stProgress > div > div > div > div {
155
+ background: linear-gradient(90deg, #4f6ef7, #764ba2);
156
+ }
157
+
158
+ /* ── Action buttons row ── */
159
+ .action-row {
160
+ display: flex;
161
+ gap: 0.75rem;
162
+ margin: 1rem 0;
163
+ flex-wrap: wrap;
164
+ }
165
+
166
+ /* ── Section divider ── */
167
+ .section-title {
168
+ font-size: 1.2rem;
169
+ font-weight: 600;
170
+ color: #1a1a2e;
171
+ padding-bottom: 0.4rem;
172
+ border-bottom: 2px solid #4f6ef7;
173
+ margin: 1.5rem 0 1rem 0;
174
+ }
175
+
176
+ /* ── Info chip ── */
177
+ .chip {
178
+ display: inline-block;
179
+ background: #eef0ff;
180
+ color: #4f6ef7;
181
+ border-radius: 20px;
182
+ padding: 0.15rem 0.65rem;
183
+ font-size: 0.78rem;
184
+ font-weight: 500;
185
+ margin: 0.15rem;
186
+ }
187
+
188
+ /* ── Tabs style override ── */
189
+ .stTabs [data-baseweb="tab-list"] {
190
+ gap: 4px;
191
+ background: #f3f4f8;
192
+ padding: 4px;
193
+ border-radius: 10px;
194
+ }
195
+ .stTabs [data-baseweb="tab"] {
196
+ border-radius: 8px;
197
+ padding: 6px 14px;
198
+ font-size: 0.88rem;
199
+ }
200
+ .stTabs [aria-selected="true"] {
201
+ background: white !important;
202
+ box-shadow: 0 1px 4px rgba(0,0,0,0.12);
203
+ }
204
+
205
+ /* ── Barème card ── */
206
+ .bareme-card {
207
+ padding: 1.4rem 1.6rem;
208
+ border-radius: 16px;
209
+ color: white;
210
+ box-shadow: 0 6px 24px rgba(0,0,0,0.22);
211
+ margin-bottom: 1.2rem;
212
+ display: flex;
213
+ align-items: center;
214
+ gap: 1.2rem;
215
+ border: 1px solid rgba(255,255,255,0.15);
216
+ }
217
+ .bareme-card .bc-icon { font-size: 3rem; line-height: 1; flex-shrink: 0; }
218
+ .bareme-card .bc-body { flex: 1; }
219
+ .bareme-card .bc-label { font-size: 1.55rem; font-weight: 800; letter-spacing: -.5px; margin: 0; line-height: 1.15; }
220
+ .bareme-card .bc-desc { font-size: .95rem; opacity: .85; margin: .3rem 0 0; }
221
+ .bareme-card .bc-score { font-size: 2.6rem; font-weight: 900; line-height: 1; flex-shrink: 0; text-align:right; }
222
+ .bareme-card .bc-score span { font-size: 1.1rem; font-weight: 500; opacity: .8; }
223
+
224
+ /* ── Barème scale strip ── */
225
+ .bareme-scale {
226
+ display: flex;
227
+ border-radius: 8px;
228
+ overflow: hidden;
229
+ height: 36px;
230
+ margin: .6rem 0 1.2rem;
231
+ box-shadow: 0 2px 8px rgba(0,0,0,0.12);
232
+ }
233
+ .bareme-scale-seg {
234
+ display: flex;
235
+ align-items: center;
236
+ justify-content: center;
237
+ font-size: .72rem;
238
+ font-weight: 600;
239
+ color: white;
240
+ transition: opacity .2s;
241
+ cursor: default;
242
+ }
243
+ .bareme-scale-seg.active {
244
+ outline: 3px solid white;
245
+ outline-offset: -2px;
246
+ z-index: 1;
247
+ border-radius: 4px;
248
+ }
249
+ .bareme-scale-seg.inactive { opacity: .38; }
250
+
251
+ /* ── Reset banner ── */
252
+ .reset-banner {
253
+ display: flex;
254
+ align-items: center;
255
+ justify-content: space-between;
256
+ padding: 0.9rem 1.25rem;
257
+ background: linear-gradient(135deg, #fff8e1, #fff3cd);
258
+ border: 1px solid #ffe082;
259
+ border-radius: 10px;
260
+ margin-bottom: 1.2rem;
261
+ }
262
+ .reset-banner .label { font-size: 0.9rem; font-weight: 500; color: #795548; }
263
+ </style>
264
+ """,
265
+ unsafe_allow_html=True,
266
+ )
267
+
268
+
269
+ # ══════════════════════════════════════════════
270
+ # HELPERS
271
+ # ══════════════════════════════════════════════
272
+
273
+
274
+ def get_score_class(score_100: float) -> str:
275
+ if score_100 >= 75:
276
+ return "score-excellent"
277
+ if score_100 >= 55:
278
+ return "score-good"
279
+ if score_100 >= 35:
280
+ return "score-average"
281
+ return "score-low"
282
+
283
+
284
+ # ── Barème d'appréciation officiel ──
285
+ BAREME = [
286
+ {
287
+ "range": (0, 10),
288
+ "label": "Inexploitable",
289
+ "short": "DC inutilisable, décrédibilisant.",
290
+ "emoji": "🚫",
291
+ "gradient": "linear-gradient(135deg,#3a0000,#8b0000)",
292
+ "text": "#ffcdd2",
293
+ "bar_color": "#c62828",
294
+ },
295
+ {
296
+ "range": (11, 12),
297
+ "label": "Très insuffisant",
298
+ "short": "DC incomplet, brouillon, donne une mauvaise image.",
299
+ "emoji": "❌",
300
+ "gradient": "linear-gradient(135deg,#7f0000,#d32f2f)",
301
+ "text": "#ffcdd2",
302
+ "bar_color": "#e53935",
303
+ },
304
+ {
305
+ "range": (13, 14),
306
+ "label": "Insuffisant",
307
+ "short": "DC exploitable mais faible, non vendeur.",
308
+ "emoji": "⚠️",
309
+ "gradient": "linear-gradient(135deg,#bf360c,#f4511e)",
310
+ "text": "#ffe0b2",
311
+ "bar_color": "#f4511e",
312
+ },
313
+ {
314
+ "range": (15, 16),
315
+ "label": "Correct",
316
+ "short": "DC utilisable mais perfectible, profil crédible mais banal.",
317
+ "emoji": "📋",
318
+ "gradient": "linear-gradient(135deg,#e65100,#fb8c00)",
319
+ "text": "#fff3e0",
320
+ "bar_color": "#fb8c00",
321
+ },
322
+ {
323
+ "range": (17, 17),
324
+ "label": "Bon",
325
+ "short": "DC solide, clair, cohérent, peut être transmis.",
326
+ "emoji": "👍",
327
+ "gradient": "linear-gradient(135deg,#1565c0,#1e88e5)",
328
+ "text": "#e3f2fd",
329
+ "bar_color": "#1e88e5",
330
+ },
331
+ {
332
+ "range": (18, 19),
333
+ "label": "Très bon",
334
+ "short": "DC percutant, vendeur, bien rédigé.",
335
+ "emoji": "🌟",
336
+ "gradient": "linear-gradient(135deg,#1b5e20,#2e7d32)",
337
+ "text": "#e8f5e9",
338
+ "bar_color": "#43a047",
339
+ },
340
+ {
341
+ "range": (20, 20),
342
+ "label": "Excellent",
343
+ "short": "DC exemplaire, parfaitement aligné, riche en résultats et démonstrations.",
344
+ "emoji": "🏆",
345
+ "gradient": "linear-gradient(135deg,#4a148c,#7b1fa2)",
346
+ "text": "#f3e5f5",
347
+ "bar_color": "#8e24aa",
348
+ },
349
+ ]
350
+
351
+ ALL_LEVELS = [
352
+ (0, 10, "Inexploitable", "🚫", "#c62828"),
353
+ (11, 12, "Très insuffisant", "❌", "#e53935"),
354
+ (13, 14, "Insuffisant", "⚠️", "#f4511e"),
355
+ (15, 16, "Correct", "📋", "#fb8c00"),
356
+ (17, 17, "Bon", "👍", "#1e88e5"),
357
+ (18, 19, "Très bon", "🌟", "#43a047"),
358
+ (20, 20, "Excellent", "🏆", "#8e24aa"),
359
+ ]
360
+
361
+
362
+ def get_bareme(note_sur_20: float) -> dict:
363
+ """Return the matching barème entry for a /20 score."""
364
+ n = round(note_sur_20)
365
+ for entry in BAREME:
366
+ lo, hi = entry["range"]
367
+ if lo <= n <= hi:
368
+ return entry
369
+ # Fallback: clamp to extremes
370
+ return BAREME[0] if n < 10 else BAREME[-1]
371
+
372
+
373
+ def reset_evaluation():
374
+ """
375
+ Clear all evaluation-related session state keys.
376
+ Called when user wants to start a new evaluation.
377
+ """
378
+ keys_to_clear = [
379
+ "report",
380
+ "cv_text",
381
+ "evaluated_filename",
382
+ "evaluation_started",
383
+ "evaluation_complete",
384
+ ]
385
+ for key in keys_to_clear:
386
+ st.session_state.pop(key, None)
387
+
388
+
389
+ # ══════════════════════════════════════════════
390
+ # LAYOUT COMPONENTS
391
+ # ══════════════════════════════════════════════
392
+
393
+
394
+ def render_header():
395
+ st.markdown(
396
+ """
397
+ <style>
398
+ /* Animation de clignotement */
399
+ @keyframes blinker {
400
+ 50% {
401
+ opacity: 0; /* Devient invisible au milieu du cycle */
402
+ }
403
+ }
404
+
405
+ /* Classe pour le logo qui clignote */
406
+ .blinking-logo {
407
+ animation: blinker 4.0s linear infinite;
408
+ height: 50px;
409
+ }
410
+
411
+ .header-container {
412
+ display: flex;
413
+ flex-direction: row;
414
+ align-items: center;
415
+ justify-content: center;
416
+ gap: 10px;
417
+ margin-bottom: 10px;
418
+ }
419
+ .main-header {
420
+ text-align: center;
421
+ }
422
+ </style>
423
+
424
+ <div class="main-header">
425
+ <div class="header-container">
426
+ <img src="https://www.jems-group.com/wp-content/uploads/2021/12/Logo.svg"
427
+ alt="JEMS Group Logo"
428
+ class="blinking-logo">
429
+ <img src="https://readme-typing-svg.demolab.com?font=Bungee+Spice&size=40&duration=3000&pause=800&color=FFFFFF&vCenter=true&width=350&lines=CV+Evaluator"
430
+ alt="CV Evaluator">
431
+ </div>
432
+ <p class="subtitle">Système Multi-Agents d'Évaluation de CV propulsé par IA GEN</p>
433
+ <div class="badge-row">
434
+ <span class="badge">⚡ 6 agents spécialisés</span>
435
+ <span class="badge">🧠 Analyse déterministe</span>
436
+ <span class="badge">📋 Rapport structuré</span>
437
+ <span class="badge">🔗 LangChainc</span>
438
+ </div>
439
+ </div>
440
+ """,
441
+ unsafe_allow_html=True,
442
+ )
443
+
444
+
445
+ def render_sidebar():
446
+ with st.sidebar:
447
+ st.markdown(
448
+ """
449
+ <div style="text-align:center;padding:1rem 0 .5rem;">
450
+ <img src="https://img.icons8.com/fluency/96/artificial-intelligence.png" width="56">
451
+ <div style="font-size:1.1rem;font-weight:700;color:#1a1a2e;margin-top:.4rem;">Configuration</div>
452
+ </div>
453
+ """,
454
+ unsafe_allow_html=True,
455
+ )
456
+
457
+ st.divider()
458
+
459
+ # ── Mode Gratuit Ollama Cloud ──
460
+ use_ollama = st.toggle(
461
+ "🆓 Utiliser le mode gratuit (Ollama Cloud)",
462
+ value=False,
463
+ help="Aucune clé API requise · Modèles open-source · Totalement gratuit",
464
+ )
465
+
466
+ if use_ollama:
467
+ # Ollama Cloud models available
468
+
469
+ model = st.selectbox(
470
+ "🤖 Modèle Ollama Cloud",
471
+ ["gpt-oss:20b-cloud", "gpt-oss:120b-cloud", "gemma4:31b-cloud"],
472
+ index=0,
473
+ help="Modèles open-source accessibles via l'API Ollama Cloud",
474
+ )
475
+ api_key = (
476
+ "" # No API key needed for Ollama Cloud (uses default embedded key)
477
+ )
478
+ st.info("🔑 Clé API Ollama incluse automatiquement")
479
+ else:
480
+ # ── Mode Premium (Gemini / OpenAI) ──
481
+ api_key = st.text_input(
482
+ "🔑 Clé API Google Gemini Ou OpenAI",
483
+ type="password",
484
+ value=os.getenv("GOOGLE_API_KEY", ""),
485
+ help="Obtenez votre clé sur https://makersuite.google.com/app/apikey",
486
+ )
487
+
488
+ model = st.selectbox(
489
+ "🤖 Modèle Gemini & OpenAI",
490
+ [
491
+ "gemini-2.5-flash-lite",
492
+ "gemini-2.5-flash",
493
+ "gemini-2.5-pro",
494
+ "gpt-5",
495
+ "gpt-5-mini",
496
+ "gpt-5-nano",
497
+ "gpt-4o",
498
+ "gpt-4o-mini",
499
+ "gpt-4.1",
500
+ "gpt-4.1-mini",
501
+ "gpt-4.1-nano",
502
+ "gpt-4-turbo",
503
+ "gpt-4",
504
+ ],
505
+ index=0,
506
+ help="Flash = rapide & économique · Pro = plus précis",
507
+ )
508
+
509
+ st.divider()
510
+ st.caption("v1.0.0 · CV-Evaluator © JEMS GROUP")
511
+
512
+ return api_key, model, use_ollama
513
+
514
+
515
+ # ══════════════════════════════════════════════
516
+ # RESULT RENDERERS
517
+ # ══════════════════════════════════════════════
518
+
519
+
520
+ def _bareme_color(note_20: float) -> str:
521
+ """Return the exact hex color for a /20 score per the barème."""
522
+ n = round(note_20)
523
+ if n <= 10:
524
+ return "#c62828" # Rouge — Inexploitable
525
+ if n <= 12:
526
+ return "#e53935" # Rouge-orange — Très insuffisant
527
+ if n <= 14:
528
+ return "#f4511e" # Orange — Insuffisant
529
+ if n <= 16:
530
+ return "#7cb342" # Vert clair — Correct
531
+ if n == 17:
532
+ return "#388e3c" # Vert moyen — Bon
533
+ if n <= 19:
534
+ return "#2e7d32" # Vert foncé — Très bon
535
+ return "#1b5e20" # Vert très foncé — Excellent
536
+
537
+
538
+ def _progress_ring_svg(
539
+ value: float, max_val: float, label: str, sublabel: str, color: str, size: int = 160
540
+ ) -> str:
541
+ """
542
+ Generate an SVG animated progress ring.
543
+ value : raw score value
544
+ max_val : maximum possible value
545
+ label : big centred text (the score string)
546
+ sublabel: small text below (e.g. '/ 20')
547
+ color : stroke colour hex
548
+ """
549
+ pct = min(value / max_val, 1.0)
550
+ radius = (size - 24) / 2
551
+ circ = 2 * 3.14159 * radius
552
+ dash_val = pct * circ
553
+ track_color = "#e8eaf0"
554
+ cx = cy = size / 2
555
+ anim_id = f"anim_{label.replace('/', '').replace(' ', '')}"
556
+
557
+ return f"""
558
+ <svg width="{size}" height="{size}" viewBox="0 0 {size} {size}" xmlns="http://www.w3.org/2000/svg">
559
+ <defs>
560
+ <style>
561
+ @keyframes {anim_id} {{
562
+ from {{ stroke-dashoffset: {circ:.2f}; }}
563
+ to {{ stroke-dashoffset: {circ - dash_val:.2f}; }}
564
+ }}
565
+ </style>
566
+ <filter id="glow_{anim_id}">
567
+ <feGaussianBlur stdDeviation="3" result="blur"/>
568
+ <feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
569
+ </filter>
570
+ </defs>
571
+ <!-- Track -->
572
+ <circle cx="{cx}" cy="{cy}" r="{radius}" fill="none"
573
+ stroke="{track_color}" stroke-width="12"/>
574
+ <!-- Progress arc -->
575
+ <circle cx="{cx}" cy="{cy}" r="{radius}" fill="none"
576
+ stroke="{color}" stroke-width="12"
577
+ stroke-linecap="round"
578
+ stroke-dasharray="{circ:.2f}"
579
+ stroke-dashoffset="{circ:.2f}"
580
+ transform="rotate(-90 {cx} {cy})"
581
+ filter="url(#glow_{anim_id})"
582
+ style="animation:{anim_id} 1.2s ease-out forwards;">
583
+ <animate attributeName="stroke-dashoffset"
584
+ from="{circ:.2f}" to="{circ - dash_val:.2f}"
585
+ dur="1.2s" fill="freeze" calcMode="spline"
586
+ keyTimes="0;1" keySplines="0.4 0 0.2 1"/>
587
+ </circle>
588
+ <!-- Centre label -->
589
+ <text x="{cx}" y="{cy - 8}" text-anchor="middle" dominant-baseline="middle"
590
+ font-family="Inter,sans-serif" font-size="28" font-weight="800" fill="{color}">{label}</text>
591
+ <text x="{cx}" y="{cy + 20}" text-anchor="middle"
592
+ font-family="Inter,sans-serif" font-size="13" font-weight="500" fill="#9ea3b0">{sublabel}</text>
593
+ </svg>"""
594
+
595
+
596
+ def render_scores(report: FinalReport):
597
+ scoring = report.scoring
598
+ score_20 = scoring.note_finale_sur_20
599
+ score_10 = scoring.note_finale_sur_10
600
+ score_100 = scoring.note_finale_sur_100
601
+ bareme = get_bareme(score_20)
602
+ ring_color = _bareme_color(score_20)
603
+
604
+ # ── Section title ──
605
+ st.markdown('<div class="section-title">📊 Scores</div>', unsafe_allow_html=True)
606
+
607
+ # ── 3 progress rings ──
608
+ c10, c20, c100 = st.columns(3)
609
+
610
+ ring_css = """
611
+ <style>
612
+ .ring-wrapper {
613
+ display:flex; flex-direction:column; align-items:center;
614
+ padding:1.4rem 1rem 1rem;
615
+ background:#fff;
616
+ border-radius:18px;
617
+ box-shadow:0 2px 16px rgba(0,0,0,.07);
618
+ border:1px solid #f0f1f5;
619
+ transition:transform .2s;
620
+ }
621
+ .ring-wrapper:hover { transform:translateY(-3px); box-shadow:0 6px 24px rgba(0,0,0,.11); }
622
+ .ring-title {
623
+ font-family:'Inter',sans-serif;
624
+ font-size:.78rem; font-weight:600; letter-spacing:.06em;
625
+ text-transform:uppercase; color:#9ea3b0; margin-bottom:.6rem;
626
+ }
627
+ .ring-badge {
628
+ margin-top:.8rem;
629
+ display:inline-block;
630
+ padding:.28rem .85rem;
631
+ border-radius:20px;
632
+ font-size:.82rem; font-weight:700;
633
+ color:white;
634
+ }
635
+ </style>
636
+ """
637
+ st.markdown(ring_css, unsafe_allow_html=True)
638
+
639
+ with c10:
640
+ svg = _progress_ring_svg(score_10, 10, f"{score_10:.1f}", "/ 10", ring_color)
641
+ st.markdown(
642
+ f'<div class="ring-wrapper">'
643
+ f'<div class="ring-title">Score sur 10</div>'
644
+ f"{svg}"
645
+ f'<div class="ring-badge" style="background:{ring_color};">{bareme["emoji"]} {bareme["label"]}</div>'
646
+ f"</div>",
647
+ unsafe_allow_html=True,
648
+ )
649
+
650
+ with c20:
651
+ svg = _progress_ring_svg(
652
+ score_20, 20, f"{score_20:.1f}", "/ 20", ring_color, size=190
653
+ )
654
+ st.markdown(
655
+ f'<div class="ring-wrapper" style="border:2px solid {ring_color}30;">'
656
+ f'<div class="ring-title" style="color:{ring_color};">⭐ Score sur 20</div>'
657
+ f"{svg}"
658
+ f'<div class="ring-badge" style="background:{ring_color};font-size:.9rem;padding:.35rem 1.1rem;">'
659
+ f"{bareme['emoji']} {bareme['label']}</div>"
660
+ f"</div>",
661
+ unsafe_allow_html=True,
662
+ )
663
+
664
+ with c100:
665
+ svg = _progress_ring_svg(
666
+ score_100, 100, f"{score_100:.0f}", "/ 100", ring_color
667
+ )
668
+ st.markdown(
669
+ f'<div class="ring-wrapper">'
670
+ f'<div class="ring-title">Score sur 100</div>'
671
+ f"{svg}"
672
+ f'<div class="ring-badge" style="background:{ring_color};">{bareme["emoji"]} {bareme["label"]}</div>'
673
+ f"</div>",
674
+ unsafe_allow_html=True,
675
+ )
676
+
677
+ st.markdown("<br>", unsafe_allow_html=True)
678
+
679
+ # ── Recommandation + Verdict row ──
680
+ col_rec, col_ver = st.columns(2)
681
+
682
+ with col_rec:
683
+ rec = report.quality_control.recommandation
684
+ rec_emoji = {"Oui": "✅", "Non": "❌", "Peut-être": "⚠️"}.get(rec, "❓")
685
+ rec_color = {"Oui": "#155724", "Non": "#721c24", "Peut-être": "#856404"}.get(
686
+ rec, "#6c757d"
687
+ )
688
+ rec_bg = {"Oui": "#d4edda", "Non": "#f8d7da", "Peut-être": "#fff3cd"}.get(
689
+ rec, "#f0f2f8"
690
+ )
691
+ st.markdown(
692
+ f"""
693
+ <div style="display:flex;align-items:center;gap:1rem;padding:1.1rem 1.4rem;
694
+ background:{rec_bg};border-radius:12px;border:1px solid {rec_color}30;">
695
+ <span style="font-size:2rem;">{rec_emoji}</span>
696
+ <div>
697
+ <div style="font-size:.72rem;font-weight:600;text-transform:uppercase;
698
+ letter-spacing:.06em;color:{rec_color};opacity:.7;">Recommandation</div>
699
+ <div style="font-size:1.2rem;font-weight:800;color:{rec_color};">{rec}</div>
700
+ </div>
701
+ </div>
702
+ """,
703
+ unsafe_allow_html=True,
704
+ )
705
+
706
+ with col_ver:
707
+ verdict_label = report.quality_control.verdict.replace("_", " ").title()
708
+ verdict_emoji = {
709
+ "profil vendeur": "🌟",
710
+ "profil banal": "😐",
711
+ "profil intermediaire": "🤔",
712
+ }.get(report.quality_control.verdict.replace("_", " "), "❓")
713
+ st.markdown(
714
+ f"""
715
+ <div style="display:flex;align-items:center;gap:1rem;padding:1.1rem 1.4rem;
716
+ background:#f5f6fa;border-radius:12px;border:1px solid #e0e2ea;">
717
+ <span style="font-size:2rem;">{verdict_emoji}</span>
718
+ <div>
719
+ <div style="font-size:.72rem;font-weight:600;text-transform:uppercase;
720
+ letter-spacing:.06em;color:#888;">Verdict</div>
721
+ <div style="font-size:1.2rem;font-weight:800;color:#1a1a2e;">{verdict_label}</div>
722
+ </div>
723
+ </div>
724
+ """,
725
+ unsafe_allow_html=True,
726
+ )
727
+
728
+ st.markdown("<br>", unsafe_allow_html=True)
729
+
730
+ # ── Detail by criterion ──
731
+ st.markdown(
732
+ '<div class="section-title">📈 Détail par critère</div>', unsafe_allow_html=True
733
+ )
734
+ cols = st.columns(4)
735
+ for i, detail in enumerate(scoring.details):
736
+ with cols[i]:
737
+ pct = detail.score_brut * 10
738
+ bar_color = _bareme_color(
739
+ detail.score_brut * 2
740
+ ) # map /10 → /20 scale for colour
741
+ st.metric(
742
+ label=f"{detail.critere}",
743
+ value=f"{detail.score_brut}/10",
744
+ delta=f"Pondéré : {detail.score_pondere:.2f} (×{detail.poids})",
745
+ )
746
+ st.markdown(
747
+ f'<div style="height:6px;border-radius:4px;background:#e8eaf0;overflow:hidden;">'
748
+ f'<div style="width:{pct}%;height:100%;background:{bar_color};'
749
+ f'border-radius:4px;transition:width 1s ease;"></div></div><br>',
750
+ unsafe_allow_html=True,
751
+ )
752
+
753
+ with st.expander("🔢 Détail du calcul mathématique"):
754
+ st.code(scoring.calcul_intermediaire)
755
+ if scoring.validation_mathematique:
756
+ st.success("✅ Validation mathématique OK")
757
+ else:
758
+ st.error("❌ Erreur de calcul détectée")
759
+ if scoring.erreur_calcul:
760
+ st.warning(scoring.erreur_calcul)
761
+
762
+
763
+ def render_evaluation_table(report: FinalReport):
764
+ st.markdown(
765
+ '<div class="section-title">📋 Tableau d\'Évaluation Détaillé</div>',
766
+ unsafe_allow_html=True,
767
+ )
768
+
769
+ table = report.evaluation_table
770
+ if not table.lignes:
771
+ st.warning("Aucune donnée dans le tableau d'évaluation.")
772
+ return
773
+
774
+ headers = [
775
+ "Élément",
776
+ "Clarté",
777
+ "Cohérence",
778
+ "Qualité réd.",
779
+ "Pertinence",
780
+ "Respect règles",
781
+ "Erreurs naïves",
782
+ ]
783
+ header_row = "| " + " | ".join(headers) + " |"
784
+ separator = "| " + " | ".join(["---"] * len(headers)) + " |"
785
+ rows = []
786
+ for row in table.lignes:
787
+ cells = [
788
+ f"**{row.element}**",
789
+ f"{row.clarte.emoji} {row.clarte.justification[:50]}",
790
+ f"{row.coherence.emoji} {row.coherence.justification[:50]}",
791
+ f"{row.qualite_redactionnelle.emoji} {row.qualite_redactionnelle.justification[:50]}",
792
+ f"{row.pertinence.emoji} {row.pertinence.justification[:50]}",
793
+ f"{row.respect_regles.emoji} {row.respect_regles.justification[:50]}",
794
+ f"{row.erreurs_naives.emoji} {row.erreurs_naives.justification[:50]}",
795
+ ]
796
+ rows.append("| " + " | ".join(cells) + " |")
797
+
798
+ st.markdown("\n".join([header_row, separator] + rows), unsafe_allow_html=True)
799
+
800
+ with st.expander("🔎 Voir les justifications complètes"):
801
+ for row in table.lignes:
802
+ st.markdown(f"#### {row.element}")
803
+ detail_cols = st.columns(6)
804
+ for j, (label, cell) in enumerate(
805
+ [
806
+ ("Clarté", row.clarte),
807
+ ("Cohérence", row.coherence),
808
+ ("Qualité réd.", row.qualite_redactionnelle),
809
+ ("Pertinence", row.pertinence),
810
+ ("Respect règles", row.respect_regles),
811
+ ("Erreurs naïves", row.erreurs_naives),
812
+ ]
813
+ ):
814
+ with detail_cols[j]:
815
+ st.markdown(f"**{label}** {cell.emoji}")
816
+ st.caption(cell.justification)
817
+ st.divider()
818
+
819
+ st.info(f"📝 {table.resume_tableau}")
820
+
821
+
822
+ def render_experience_analysis(report: FinalReport):
823
+ st.markdown(
824
+ '<div class="section-title">🔍 Analyse des Expériences</div>',
825
+ unsafe_allow_html=True,
826
+ )
827
+ exp = report.experience_analysis
828
+
829
+ c1, c2 = st.columns([1, 3])
830
+ with c1:
831
+ st.metric("Score global", f"{exp.score_global_experiences}/10")
832
+ with c2:
833
+ st.info(f"💬 **Synthèse :** {exp.synthese}")
834
+
835
+ col1, col2 = st.columns(2)
836
+ with col1:
837
+ st.markdown("#### ✅ Points forts")
838
+ for p in exp.points_forts:
839
+ st.markdown(f"- 🟢 {p}")
840
+ with col2:
841
+ st.markdown("#### ⚠️ Points faibles")
842
+ for p in exp.points_faibles:
843
+ st.markdown(f"- 🔴 {p}")
844
+
845
+ if exp.donnees_manquantes:
846
+ st.warning("**Données manquantes :** " + ", ".join(exp.donnees_manquantes))
847
+
848
+ st.markdown(f"#### 💼 Expériences détaillées ({len(exp.experiences)})")
849
+ for e in exp.experiences:
850
+ with st.expander(f"💼 {e.poste} @ {e.entreprise} — {e.score}/10"):
851
+ cols = st.columns([1, 1, 1])
852
+ with cols[0]:
853
+ st.markdown(f"📅 **Période :** {e.periode}")
854
+ with cols[1]:
855
+ st.markdown(f"⏱️ **Durée :** {e.duree_estimee or 'non précisée'}")
856
+ with cols[2]:
857
+ st.metric("Score", f"{e.score}/10")
858
+
859
+ st.markdown(f"**Contexte métier :** {e.contexte_metier}")
860
+ st.markdown(f"**Cohérence technique :** {e.coherence_technique}")
861
+
862
+ if e.missions:
863
+ st.markdown("**Missions :**")
864
+ for m in e.missions:
865
+ st.markdown(f" - {m}")
866
+ if e.missions_differenciantes:
867
+ st.markdown("**🌟 Missions différenciantes :**")
868
+ for m in e.missions_differenciantes:
869
+ st.markdown(f" - ⭐ {m}")
870
+ if e.resultats_mesurables:
871
+ st.markdown("**📊 Résultats mesurables :**")
872
+ for r in e.resultats_mesurables:
873
+ st.markdown(f" - 📈 {r}")
874
+ if e.erreurs_naives:
875
+ st.error("**❌ Erreurs naïves détectées :**")
876
+ for err in e.erreurs_naives:
877
+ st.markdown(f" - ⚠️ {err}")
878
+ st.caption(f"**Justification du score :** {e.justification_score}")
879
+
880
+
881
+ def render_skills_education(report: FinalReport):
882
+ st.markdown(
883
+ '<div class="section-title">🎯 Compétences & Formations</div>',
884
+ unsafe_allow_html=True,
885
+ )
886
+ se = report.skills_education
887
+
888
+ col1, col2 = st.columns(2)
889
+ with col1:
890
+ st.metric("Score Compétences", f"{se.score_competences}/10")
891
+ with col2:
892
+ st.metric("Score Formations", f"{se.score_formations}/10")
893
+
894
+ st.markdown("#### 🛠️ Compétences")
895
+ demonstrated = [c for c in se.competences if c.demontree_dans_experience]
896
+ not_demonstrated = [c for c in se.competences if not c.demontree_dans_experience]
897
+
898
+ if demonstrated:
899
+ st.markdown("**✅ Compétences démontrées**")
900
+ for c in demonstrated:
901
+ level = f" ({c.niveau_estime})" if c.niveau_estime else ""
902
+ assoc = f" → _{c.experience_associee}_" if c.experience_associee else ""
903
+ st.markdown(f"- ✅ **{c.nom}** `{c.categorie}`{level}{assoc}")
904
+
905
+ if not_demonstrated:
906
+ st.markdown("**❌ Compétences non démontrées**")
907
+ for c in not_demonstrated:
908
+ st.markdown(f"- ❌ **{c.nom}** `{c.categorie}` — Déclarée mais non prouvée")
909
+
910
+ st.markdown("#### 🎓 Formations")
911
+ for f in se.formations:
912
+ year = f" ({f.annee})" if f.annee else ""
913
+ st.markdown(f"- 📚 **{f.diplome}** — {f.etablissement}{year}")
914
+ st.caption(f" Cohérence parcours : {f.coherence_parcours}")
915
+
916
+ st.info(f"**Cohérence formation ↔ parcours :** {se.coherence_formation_parcours}")
917
+
918
+
919
+ def render_summary_validation(report: FinalReport):
920
+ st.markdown(
921
+ '<div class="section-title">✅ Validation du Résumé / Profil</div>',
922
+ unsafe_allow_html=True,
923
+ )
924
+ sv = report.summary_validation
925
+
926
+ col1, col2, col3 = st.columns(3)
927
+ with col1:
928
+ st.metric("Score Résumé", f"{sv.score_resume}/10")
929
+ with col2:
930
+ st.metric("Affirmations prouvées", f"{sv.taux_affirmations_prouvees:.0f}%")
931
+ with col3:
932
+ total = len(sv.affirmations_analysees)
933
+ proven = sum(1 for a in sv.affirmations_analysees if a.prouvee)
934
+ st.metric("Ratio", f"{proven}/{total}")
935
+
936
+ col_pos = st.columns(2)
937
+ with col_pos[0]:
938
+ st.info(f"📌 **Positionnement déclaré :** {sv.positionnement_declare}")
939
+ with col_pos[1]:
940
+ st.info(f"🔎 **Positionnement réel :** {sv.positionnement_reel}")
941
+
942
+ if sv.ecarts_alignement:
943
+ st.warning("**Écarts d'alignement :**")
944
+ for e in sv.ecarts_alignement:
945
+ st.markdown(f"- ⚠️ {e}")
946
+
947
+ st.markdown("#### 📝 Analyse des affirmations")
948
+ for a in sv.affirmations_analysees:
949
+ icon = "✅" if a.prouvee else "❌"
950
+ label = a.affirmation[:80] + "…" if len(a.affirmation) > 80 else a.affirmation
951
+ with st.expander(f"{icon} « {label} »"):
952
+ st.markdown(f"**Prouvée :** {'Oui ✅' if a.prouvee else 'Non ❌'}")
953
+ if a.preuve:
954
+ st.markdown(f"**Preuve :** {a.preuve}")
955
+ st.markdown(f"**Commentaire :** {a.commentaire}")
956
+
957
+
958
+ def render_quality_control(report: FinalReport):
959
+ st.markdown(
960
+ '<div class="section-title">🏁 Contrôle Qualité Final</div>',
961
+ unsafe_allow_html=True,
962
+ )
963
+ qc = report.quality_control
964
+
965
+ verdict_class = {
966
+ "Oui": "verdict-oui",
967
+ "Non": "verdict-non",
968
+ "Peut-être": "verdict-maybe",
969
+ }.get(qc.recommandation, "verdict-maybe")
970
+ st.markdown(
971
+ f"""
972
+ <div class="verdict-box {verdict_class}">
973
+ <h3 style="margin:0 0 .4rem 0;">Recommandation : {qc.recommandation}</h3>
974
+ <p style="margin:0;">{qc.justification_recommandation}</p>
975
+ </div>
976
+ """,
977
+ unsafe_allow_html=True,
978
+ )
979
+
980
+ c1, c2, c3 = st.columns(3)
981
+ with c1:
982
+ st.markdown(f"**Verdict :** {qc.verdict.replace('_', ' ').title()}")
983
+ with c2:
984
+ st.markdown(f"**Alignement global :** {qc.alignement_global}")
985
+ with c3:
986
+ st.metric("Score Alignement", f"{qc.score_alignement}/10")
987
+
988
+ st.markdown(f"**Justification :** {qc.justification_verdict}")
989
+
990
+ col1, col2 = st.columns(2)
991
+ with col1:
992
+ st.markdown("#### 💪 Forces")
993
+ for f in qc.forces:
994
+ st.markdown(f"- 🟢 {f}")
995
+ with col2:
996
+ st.markdown("#### 📉 Faiblesses")
997
+ for f in qc.faiblesses:
998
+ st.markdown(f"- 🔴 {f}")
999
+
1000
+ with st.expander("📋 Éléments vérifiés"):
1001
+ quality_colors = {
1002
+ "excellent": "🟢",
1003
+ "bon": "🔵",
1004
+ "moyen": "🟡",
1005
+ "faible": "🟠",
1006
+ "absent": "🔴",
1007
+ }
1008
+ for item in qc.elements_verifies:
1009
+ icon = "✅" if item.present else "❌"
1010
+ q_emoji = quality_colors.get(item.qualite, "⚪")
1011
+ st.markdown(
1012
+ f"{icon} **{item.element}** — {q_emoji} {item.qualite.title()} : {item.commentaire}"
1013
+ )
1014
+
1015
+
1016
+ def render_export_section(report: FinalReport):
1017
+ """Render the Export tab with JSON download and CV text download."""
1018
+ st.markdown(
1019
+ '<div class="section-title">📥 Export & Téléchargements</div>',
1020
+ unsafe_allow_html=True,
1021
+ )
1022
+
1023
+ report_json = report.model_dump_json(indent=2)
1024
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1025
+
1026
+ col_json, col_txt, col_preview = st.columns([1, 1, 1])
1027
+
1028
+ # ── JSON download ──
1029
+ with col_json:
1030
+ st.markdown("**📄 Rapport complet**")
1031
+ st.download_button(
1032
+ label="⬇️ Télécharger JSON",
1033
+ data=report_json,
1034
+ file_name=f"cv_evaluation_{timestamp}.json",
1035
+ mime="application/json",
1036
+ use_container_width=True,
1037
+ )
1038
+
1039
+ # ── CV text download ──
1040
+ with col_txt:
1041
+ st.markdown("**📝 Texte extrait du CV**")
1042
+ cv_text = st.session_state.get("cv_text", "")
1043
+ if cv_text:
1044
+ st.download_button(
1045
+ label="⬇️ Télécharger CV (.txt)",
1046
+ data=cv_text,
1047
+ file_name=f"cv_extrait_{timestamp}.txt",
1048
+ mime="text/plain",
1049
+ use_container_width=True,
1050
+ help="Télécharger le texte brut extrait du PDF",
1051
+ )
1052
+ else:
1053
+ st.info("Texte du CV non disponible.")
1054
+
1055
+ # ── Copy preview ──
1056
+ with col_preview:
1057
+ st.markdown("**🔍 Aperçu JSON**")
1058
+ if st.button("👁️ Afficher aperçu", use_container_width=True):
1059
+ st.code(report_json[:600] + "\n…", language="json")
1060
+
1061
+ with st.expander("🔎 Voir le JSON complet"):
1062
+ st.json(json.loads(report_json))
1063
+
1064
+
1065
+ # ══════════════════════════════════════════════
1066
+ # MAIN APPLICATION
1067
+ # ══════════════════════════════════════════════
1068
+
1069
+
1070
+ def main():
1071
+ render_header()
1072
+ api_key, model, use_ollama = render_sidebar()
1073
+
1074
+ # ── Upload section ──
1075
+ st.markdown(
1076
+ '<div class="section-title">📤 Importer un CV</div>', unsafe_allow_html=True
1077
+ )
1078
+
1079
+ # If a previous result exists, show a reset banner at the top
1080
+ if "report" in st.session_state:
1081
+ fname = st.session_state.get("evaluated_filename", "CV précédent")
1082
+ reset_col1, reset_col2 = st.columns([5, 1])
1083
+ with reset_col1:
1084
+ st.markdown(
1085
+ f'<div class="reset-banner">'
1086
+ f'<span class="label">📌 Résultat actuel : <strong>{fname}</strong> — '
1087
+ f"Pour analyser un nouveau CV, réinitialisez d'abord.</span>"
1088
+ f"</div>",
1089
+ unsafe_allow_html=True,
1090
+ )
1091
+ with reset_col2:
1092
+ if st.button(
1093
+ "🔄 Réinitialiser",
1094
+ type="secondary",
1095
+ use_container_width=True,
1096
+ help="Efface les résultats et permet de déposer un nouveau CV",
1097
+ ):
1098
+ reset_evaluation()
1099
+ st.rerun()
1100
+
1101
+ uploaded_file = st.file_uploader(
1102
+ "Glissez votre CV au format PDF ici",
1103
+ type=["pdf"],
1104
+ help="Format accepté : PDF · Taille max : 10 Mo · 2 pages max recommandées",
1105
+ disabled="report" in st.session_state, # lock uploader once evaluated
1106
+ )
1107
+
1108
+ # Validate file size early
1109
+ if uploaded_file and uploaded_file.size > 10 * 1024 * 1024:
1110
+ st.error("❌ Le fichier dépasse 10 Mo. Veuillez compresser votre PDF.")
1111
+ return
1112
+
1113
+ if uploaded_file:
1114
+ # File info card
1115
+ st.markdown(
1116
+ f'<div class="file-info-banner">'
1117
+ f'<span class="icon">📄</span>'
1118
+ f'<div><div class="name">{uploaded_file.name}</div>'
1119
+ f'<div class="size">{uploaded_file.size / 1024:.1f} Ko · PDF</div></div>'
1120
+ f"</div>",
1121
+ unsafe_allow_html=True,
1122
+ )
1123
+
1124
+ # API key validation: required for Gemini/OpenAI, not for Ollama Cloud
1125
+ if not use_ollama and not api_key:
1126
+ st.error(
1127
+ "⚠️ Veuillez entrer votre clé API Gemini/OpenAI dans la barre latérale."
1128
+ )
1129
+ return
1130
+
1131
+ # ── Evaluate button ──
1132
+ if "report" not in st.session_state:
1133
+ if st.button(
1134
+ "🚀 Lancer l'évaluation", type="primary", use_container_width=True
1135
+ ):
1136
+ progress_bar = st.progress(0)
1137
+ status_box = st.empty()
1138
+
1139
+ def progress_callback(message: str, percentage: float):
1140
+ progress_bar.progress(min(percentage, 1.0))
1141
+ status_box.info(f"⏳ {message}")
1142
+
1143
+ try:
1144
+ # Step 1 – extract text
1145
+ with st.spinner("📄 Extraction du texte du PDF…"):
1146
+ try:
1147
+ cv_text = extract_text_from_uploaded_file(uploaded_file)
1148
+ except Exception as e:
1149
+ # Handle custom PDFExtractionError with user-friendly message
1150
+ error_msg = str(e)
1151
+ if "vide" in error_msg.lower():
1152
+ st.error(
1153
+ "❌ Le PDF est vide. Veuillez vérifier le fichier."
1154
+ )
1155
+ elif (
1156
+ "scanné" in error_msg.lower()
1157
+ or "image" in error_msg.lower()
1158
+ ):
1159
+ st.error(
1160
+ "❌ Le PDF semble être une image scannée. "
1161
+ "Le texte ne peut pas être extrait. "
1162
+ "Utilisez un PDF avec du texte sélectionnable."
1163
+ )
1164
+ else:
1165
+ st.error(
1166
+ f"❌ Erreur lors de la lecture du PDF : {error_msg}"
1167
+ )
1168
+ return
1169
+
1170
+ if len(cv_text.strip()) < 100:
1171
+ st.error(
1172
+ "❌ Le PDF contient très peu de texte (< 100 caractères). "
1173
+ "Veuillez vérifier le fichier."
1174
+ )
1175
+ return
1176
+
1177
+ # Persist extracted text for download later
1178
+ st.session_state["cv_text"] = cv_text
1179
+
1180
+ with st.expander("📄 Texte extrait du CV (aperçu)", expanded=False):
1181
+ st.text(cv_text[:3000] + ("…" if len(cv_text) > 3000 else ""))
1182
+
1183
+ # Step 2 – run evaluation
1184
+ # Force ollama provider when using Ollama Cloud mode
1185
+ if use_ollama:
1186
+ # Use API key from environment variable (required for Ollama Cloud)
1187
+ ollama_api_key = "d3416cecd2bd4e81a52dde8ba54bbd9c.uT8ag03jpMcxjOm5we3zKGYK"
1188
+ if not ollama_api_key:
1189
+ st.error(
1190
+ "⚠️ Clé API Ollama manquante. "
1191
+ "Ajoutez OLLAMA_API_KEY dans votre fichier .env ou en variable d'environnement."
1192
+ )
1193
+ return
1194
+ orchestrator = CVEvaluationOrchestrator(
1195
+ api_key=ollama_api_key,
1196
+ model_name=model,
1197
+ cache_dir=None,
1198
+ progress_callback=progress_callback,
1199
+ )
1200
+ else:
1201
+ orchestrator = CVEvaluationOrchestrator(
1202
+ api_key=api_key,
1203
+ model_name=model,
1204
+ cache_dir=None,
1205
+ progress_callback=progress_callback,
1206
+ )
1207
+
1208
+ report = orchestrator.evaluate(cv_text)
1209
+
1210
+ # Persist results
1211
+ st.session_state["report"] = report
1212
+ st.session_state["evaluated_filename"] = uploaded_file.name
1213
+
1214
+ progress_bar.progress(1.0)
1215
+ status_box.success("✅ Évaluation terminée avec succès !")
1216
+
1217
+ except Exception as e:
1218
+ error_msg = str(e)
1219
+ # User-friendly error messages
1220
+ if "API" in error_msg or "api" in error_msg.lower():
1221
+ st.error(
1222
+ "❌ Erreur de connexion à l'API. Vérifiez votre clé API et votre connexion internet."
1223
+ )
1224
+ elif "timeout" in error_msg.lower():
1225
+ st.error(
1226
+ "⏱️ La requête a expiré. Le modèle est peut-être surchargé. Réessayez dans quelques instants."
1227
+ )
1228
+ elif "JSON" in error_msg or "parsing" in error_msg.lower():
1229
+ st.error(
1230
+ "🔧 Erreur d'analyse de la réponse IA. Le modèle a renvoyé un format invalide. Réessayez."
1231
+ )
1232
+ else:
1233
+ st.error(f"❌ Erreur lors de l'évaluation : {error_msg}")
1234
+ logger.error(f"[App] Evaluation error: {error_msg}", exc_info=True)
1235
+ return
1236
+
1237
+ # ── Results display ──
1238
+ if "report" in st.session_state:
1239
+ report = st.session_state["report"]
1240
+
1241
+ # ══════════════════════════════════════════════
1242
+ # NEW EVALUATION SECTION - Prominent CTA
1243
+ # ══════════════════════════════════════════════
1244
+ st.markdown(
1245
+ """
1246
+ <style>
1247
+ .new-eval-section {
1248
+ display: flex;
1249
+ align-items: center;
1250
+ justify-content: space-between;
1251
+ padding: 1.5rem 2rem;
1252
+ background: linear-gradient(135deg, rgba(79,110,247,0.08), rgba(118,75,162,0.08));
1253
+ border: 2px solid #4f6ef7;
1254
+ border-radius: 16px;
1255
+ margin: 1.5rem 0;
1256
+ box-shadow: 0 4px 20px rgba(79,110,247,0.15);
1257
+ }
1258
+ .new-eval-text h3 {
1259
+ color: #1a1a2e;
1260
+ margin: 0 0 0.3rem 0;
1261
+ font-size: 1.3rem;
1262
+ }
1263
+ .new-eval-text p {
1264
+ color: #666;
1265
+ margin: 0;
1266
+ font-size: 0.95rem;
1267
+ }
1268
+ .new-eval-btn {
1269
+ background: linear-gradient(135deg, #4f6ef7, #764ba2);
1270
+ color: white;
1271
+ border: none;
1272
+ padding: 0.85rem 2rem;
1273
+ border-radius: 12px;
1274
+ font-size: 1rem;
1275
+ font-weight: 600;
1276
+ cursor: pointer;
1277
+ box-shadow: 0 4px 15px rgba(79,110,247,0.3);
1278
+ transition: transform 0.2s, box-shadow 0.2s;
1279
+ }
1280
+ .new-eval-btn:hover {
1281
+ transform: translateY(-2px);
1282
+ box-shadow: 0 6px 20px rgba(79,110,247,0.4);
1283
+ }
1284
+ </style>
1285
+ """,
1286
+ unsafe_allow_html=True,
1287
+ )
1288
+
1289
+ st.markdown(
1290
+ """
1291
+ <div class="new-eval-section">
1292
+ <div class="new-eval-text">
1293
+ <h3>✨ Évaluation terminée !</h3>
1294
+ <p>Souhaitez-vous analyser un nouveau CV ?</p>
1295
+ </div>
1296
+ </div>
1297
+ """,
1298
+ unsafe_allow_html=True,
1299
+ )
1300
+
1301
+ # Full-width button for new evaluation
1302
+ if st.button(
1303
+ "🔄 Nouvelle évaluation",
1304
+ type="primary",
1305
+ use_container_width=True,
1306
+ help="Réinitialiser tous les résultats et commencer une nouvelle évaluation",
1307
+ key="new_evaluation_btn",
1308
+ ):
1309
+ reset_evaluation()
1310
+ st.rerun()
1311
+
1312
+ st.divider()
1313
+
1314
+ # Sidebar metadata
1315
+ with st.sidebar:
1316
+ st.divider()
1317
+ st.markdown("### 📊 Métadonnées")
1318
+ meta = report.metadata
1319
+ st.caption(f"📅 {meta.get('date_evaluation', 'N/A')}")
1320
+
1321
+ # Display provider badge
1322
+ model_name = meta.get("modele_llm", "N/A")
1323
+ provider_badge = ""
1324
+ if model_name.endswith("-cloud") or "ollama" in model_name.lower():
1325
+ provider_badge = "🆓 Ollama Cloud"
1326
+ elif model_name.startswith("gemini"):
1327
+ provider_badge = "💎 Google Gemini"
1328
+ else:
1329
+ provider_badge = "🔵 OpenAI"
1330
+
1331
+ st.caption(f"🤖 {model_name}")
1332
+ st.markdown(
1333
+ f"<span class='chip'>{provider_badge}</span>", unsafe_allow_html=True
1334
+ )
1335
+ st.caption(f"⏱️ {meta.get('duree_evaluation_secondes', 'N/A')} s")
1336
+ st.caption(f"📂 {', '.join(meta.get('sections_detectees', []))}")
1337
+
1338
+ # Also add a reset button in sidebar for convenience
1339
+ st.divider()
1340
+ if st.button(
1341
+ "🗑️ Effacer les résultats",
1342
+ type="secondary",
1343
+ use_container_width=True,
1344
+ help="Supprimer les résultats actuels",
1345
+ key="sidebar_reset_btn",
1346
+ ):
1347
+ reset_evaluation()
1348
+ st.rerun()
1349
+
1350
+ tabs = st.tabs(
1351
+ [
1352
+ "📊 Scores",
1353
+ "📋 Tableau",
1354
+ "🔍 Expériences",
1355
+ "🎯 Compétences",
1356
+ "✅ Résumé",
1357
+ "🏁 Qualité",
1358
+ "📥 Export",
1359
+ ]
1360
+ )
1361
+
1362
+ with tabs[0]:
1363
+ render_scores(report)
1364
+ with tabs[1]:
1365
+ render_evaluation_table(report)
1366
+ with tabs[2]:
1367
+ render_experience_analysis(report)
1368
+ with tabs[3]:
1369
+ render_skills_education(report)
1370
+ with tabs[4]:
1371
+ render_summary_validation(report)
1372
+ with tabs[5]:
1373
+ render_quality_control(report)
1374
+ with tabs[6]:
1375
+ render_export_section(report)
1376
+
1377
+
1378
+ if __name__ == "__main__":
1379
+ main()
docker-compose.prod.yml ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # docker-compose.prod.yml
2
+ # Configuration pour le déploiement en production
3
+
4
+ version: "3.8"
5
+
6
+ services:
7
+ cv-evaluator:
8
+ image: ${DOCKER_USERNAME:-yacineberkani}/cv-evaluator:latest
9
+ container_name: cv-evaluator
10
+ restart: unless-stopped
11
+ ports:
12
+ - "8501:8501"
13
+ environment:
14
+ - GOOGLE_API_KEY=${GOOGLE_API_KEY}
15
+ - GEMINI_MODEL=${GEMINI_MODEL:-gemini-2.5-flash-lite}
16
+ - GEMINI_TEMPERATURE=${GEMINI_TEMPERATURE:-0}
17
+ healthcheck:
18
+ test: ["CMD", "curl", "-f", "http://localhost:8501"]
19
+ interval: 30s
20
+ timeout: 10s
21
+ retries: 3
22
+ start_period: 40s
models/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from models.schemas import *
models/schemas.py ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pydantic schemas for strict JSON validation across all agents.
3
+ Each model enforces deterministic, structured output.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Literal
9
+
10
+ from pydantic import BaseModel, Field
11
+
12
+ # ─────────────────────────────────────────────
13
+ # ExperienceAnalysisAgent models
14
+ # ─────────────────────────────────────────────
15
+
16
+
17
+ class ExperienceEntry(BaseModel):
18
+ poste: str = Field(..., description="Intitulé du poste")
19
+ entreprise: str = Field(..., description="Nom de l'entreprise")
20
+ periode: str = Field(..., description="Période (ex: Jan 2020 - Dec 2022)")
21
+ duree_estimee: str | None = Field(None, description="Durée estimée")
22
+ contexte_metier: str = Field(..., description="Contexte métier décrit ou inféré")
23
+ missions: list[str] = Field(default_factory=list, description="Liste des missions")
24
+ missions_differenciantes: list[str] = Field(
25
+ default_factory=list, description="Missions qui se démarquent"
26
+ )
27
+ resultats_mesurables: list[str] = Field(
28
+ default_factory=list, description="Résultats chiffrés/mesurables"
29
+ )
30
+ coherence_technique: str = Field(
31
+ ..., description="Évaluation de la cohérence technique"
32
+ )
33
+ erreurs_naives: list[str] = Field(
34
+ default_factory=list, description="Erreurs naïves détectées"
35
+ )
36
+ score: float = Field(
37
+ ..., ge=0, le=10, description="Score /10 pour cette expérience"
38
+ )
39
+ justification_score: str = Field(..., description="Justification du score attribué")
40
+
41
+
42
+ class ExperienceAnalysisOutput(BaseModel):
43
+ experiences: list[ExperienceEntry] = Field(default_factory=list)
44
+ score_global_experiences: float = Field(..., ge=0, le=10)
45
+ synthese: str = Field(..., description="Synthèse globale des expériences")
46
+ points_forts: list[str] = Field(default_factory=list)
47
+ points_faibles: list[str] = Field(default_factory=list)
48
+ donnees_manquantes: list[str] = Field(
49
+ default_factory=list, description="Informations critiques absentes"
50
+ )
51
+
52
+
53
+ # ─────────────────────────────────────────────
54
+ # SkillsEducationAgent models
55
+ # ─────────────────────────────────────────────
56
+
57
+
58
+ class CompetenceEntry(BaseModel):
59
+ nom: str = Field(..., description="Nom de la compétence")
60
+ categorie: str = Field(
61
+ ..., description="Catégorie (technique, soft skill, métier...)"
62
+ )
63
+ demontree_dans_experience: bool = Field(
64
+ ..., description="Si la compétence est démontrée dans les expériences"
65
+ )
66
+ experience_associee: str | None = Field(
67
+ None, description="Expérience où elle est démontrée"
68
+ )
69
+ niveau_estime: str | None = Field(None, description="Niveau estimé si mentionné")
70
+
71
+
72
+ class FormationEntry(BaseModel):
73
+ diplome: str = Field(..., description="Intitulé du diplôme")
74
+ etablissement: str = Field(..., description="Établissement")
75
+ annee: str | None = Field(None, description="Année d'obtention")
76
+ coherence_parcours: str = Field(
77
+ ..., description="Cohérence avec le parcours professionnel"
78
+ )
79
+
80
+
81
+ class SkillsEducationOutput(BaseModel):
82
+ competences: list[CompetenceEntry] = Field(default_factory=list)
83
+ formations: list[FormationEntry] = Field(default_factory=list)
84
+ score_competences: float = Field(..., ge=0, le=10)
85
+ score_formations: float = Field(..., ge=0, le=10)
86
+ competences_non_demontrees: list[str] = Field(default_factory=list)
87
+ coherence_formation_parcours: str = Field(...)
88
+ points_forts: list[str] = Field(default_factory=list)
89
+ points_faibles: list[str] = Field(default_factory=list)
90
+ donnees_manquantes: list[str] = Field(default_factory=list)
91
+
92
+
93
+ # ─────────────────────────────────────────────
94
+ # SummaryValidationAgent models
95
+ # ─────────────────────────────────────────────
96
+
97
+
98
+ class AffirmationCheck(BaseModel):
99
+ affirmation: str = Field(..., description="Affirmation extraite du résumé")
100
+ prouvee: bool = Field(
101
+ ..., description="Si l'affirmation est prouvée par les expériences"
102
+ )
103
+ preuve: str | None = Field(None, description="Preuve trouvée dans les expériences")
104
+ commentaire: str = Field(..., description="Commentaire sur la validation")
105
+
106
+
107
+ class SummaryValidationOutput(BaseModel):
108
+ affirmations_analysees: list[AffirmationCheck] = Field(default_factory=list)
109
+ score_resume: float = Field(..., ge=0, le=10)
110
+ taux_affirmations_prouvees: float = Field(
111
+ ..., ge=0, le=100, description="Pourcentage d'affirmations prouvées"
112
+ )
113
+ ecarts_alignement: list[str] = Field(
114
+ default_factory=list, description="Écarts entre positionnement et réalité"
115
+ )
116
+ positionnement_declare: str = Field(
117
+ ..., description="Positionnement déclaré dans le résumé"
118
+ )
119
+ positionnement_reel: str = Field(
120
+ ..., description="Positionnement réel basé sur les expériences"
121
+ )
122
+ synthese: str = Field(...)
123
+ donnees_manquantes: list[str] = Field(default_factory=list)
124
+
125
+
126
+ # ─────────────────────────────────────────────
127
+ # ScoringAgent models
128
+ # ─────────────────────────────────────────────
129
+
130
+
131
+ class ScoreDetail(BaseModel):
132
+ critere: str = Field(...)
133
+ score_brut: float = Field(..., ge=0, le=10)
134
+ poids: float = Field(..., ge=0, le=1)
135
+ score_pondere: float = Field(..., ge=0, le=10)
136
+ justification: str = Field(...)
137
+
138
+
139
+ class ScoringOutput(BaseModel):
140
+ details: list[ScoreDetail] = Field(...)
141
+ note_finale_sur_10: float = Field(..., ge=0, le=10)
142
+ note_finale_sur_20: float = Field(..., ge=0, le=20)
143
+ note_finale_sur_100: float = Field(..., ge=0, le=100)
144
+ calcul_intermediaire: str = Field(..., description="Détail du calcul mathématique")
145
+ validation_mathematique: bool = Field(
146
+ ..., description="True si le calcul est cohérent"
147
+ )
148
+ erreur_calcul: str | None = Field(
149
+ None, description="Description de l'erreur si incohérence"
150
+ )
151
+
152
+
153
+ # ─────────────────────────────────────────────
154
+ # QualityControlAgent models
155
+ # ─────────────────────────────────────────────
156
+
157
+
158
+ class QualityCheckItem(BaseModel):
159
+ element: str = Field(...)
160
+ present: bool = Field(...)
161
+ qualite: Literal["excellent", "bon", "moyen", "faible", "absent"] = Field(...)
162
+ commentaire: str = Field(...)
163
+
164
+
165
+ class QualityControlOutput(BaseModel):
166
+ elements_verifies: list[QualityCheckItem] = Field(default_factory=list)
167
+ alignement_global: str = Field(
168
+ ..., description="Évaluation de l'alignement compétences ↔ expériences ↔ résumé"
169
+ )
170
+ score_alignement: float = Field(..., ge=0, le=10)
171
+ verdict: Literal["profil_vendeur", "profil_banal", "profil_intermediaire"] = Field(
172
+ ...
173
+ )
174
+ justification_verdict: str = Field(...)
175
+ recommandation: Literal["Oui", "Non", "Peut-être"] = Field(...)
176
+ justification_recommandation: str = Field(...)
177
+ forces: list[str] = Field(default_factory=list)
178
+ faiblesses: list[str] = Field(default_factory=list)
179
+
180
+
181
+ # ─────────────────────────────────────────────
182
+ # TableGeneratorAgent models
183
+ # ─────────────────────────────────────────────
184
+
185
+
186
+ class TableCell(BaseModel):
187
+ emoji: Literal["✅", "⚠️", "❌"] = Field(...)
188
+ justification: str = Field(..., max_length=200)
189
+
190
+
191
+ class TableRow(BaseModel):
192
+ element: str = Field(
193
+ ..., description="Nom de l'élément évalué (expérience, compétences, etc.)"
194
+ )
195
+ clarte: TableCell = Field(...)
196
+ coherence: TableCell = Field(...)
197
+ qualite_redactionnelle: TableCell = Field(...)
198
+ pertinence: TableCell = Field(...)
199
+ respect_regles: TableCell = Field(...)
200
+ erreurs_naives: TableCell = Field(...)
201
+
202
+
203
+ class TableGeneratorOutput(BaseModel):
204
+ lignes: list[TableRow] = Field(default_factory=list)
205
+ resume_tableau: str = Field(..., description="Résumé textuel du tableau")
206
+
207
+
208
+ # ─────────────────────────────────────────────
209
+ # Final Report model
210
+ # ─────────────────────────────────────────────
211
+
212
+
213
+ class FinalReport(BaseModel):
214
+ experience_analysis: ExperienceAnalysisOutput = Field(...)
215
+ skills_education: SkillsEducationOutput = Field(...)
216
+ summary_validation: SummaryValidationOutput = Field(...)
217
+ scoring: ScoringOutput = Field(...)
218
+ quality_control: QualityControlOutput = Field(...)
219
+ evaluation_table: TableGeneratorOutput = Field(...)
220
+ metadata: dict = Field(
221
+ default_factory=dict, description="Métadonnées (date, modèle, version...)"
222
+ )
orchestrator.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Orchestrator - Multi-agent pipeline for CV evaluation.
3
+ Manages the execution flow, caching, and parallel processing where possible.
4
+
5
+ Architecture:
6
+ Phase 1 (independent): ExperienceAnalysisAgent
7
+ Phase 2 (depends on 1): SkillsEducationAgent + SummaryValidationAgent (parallel)
8
+ Phase 3 (depends on 1+2): ScoringAgent
9
+ Phase 4 (depends on all): QualityControlAgent + TableGeneratorAgent (parallel)
10
+ """
11
+
12
+ import logging
13
+ import time
14
+ from collections.abc import Callable
15
+ from concurrent.futures import ThreadPoolExecutor, as_completed
16
+ from datetime import datetime, timezone
17
+
18
+ from agents import (
19
+ ExperienceAnalysisAgent,
20
+ QualityControlAgent,
21
+ ScoringAgent,
22
+ SkillsEducationAgent,
23
+ SummaryValidationAgent,
24
+ TableGeneratorAgent,
25
+ )
26
+ from models.schemas import (
27
+ FinalReport,
28
+ QualityControlOutput,
29
+ SkillsEducationOutput,
30
+ SummaryValidationOutput,
31
+ TableGeneratorOutput,
32
+ )
33
+ from utils.cache import ResultCache
34
+ from utils.chunking import chunk_cv_by_sections, get_section_or_full
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ # Models that belong to OpenAI — everything else is treated as Gemini
40
+ GEMINI_MODEL_PREFIXES = ("gemini-2.5-flash-lite", "gemini-2.5-flash", "gemini-2.5-pro")
41
+ # Ollama Cloud models — identified by "-cloud" suffix
42
+ OLLAMA_MODEL_SUFFIXES = ("-cloud",)
43
+
44
+
45
+ def detect_provider(model_name: str) -> str:
46
+ """Infer the provider from the model name."""
47
+ model_lower = model_name.lower()
48
+ if any(model_lower.startswith(p) for p in GEMINI_MODEL_PREFIXES):
49
+ return "gemini"
50
+ if any(model_lower.endswith(s) for s in OLLAMA_MODEL_SUFFIXES):
51
+ return "ollama"
52
+ return "openai"
53
+
54
+
55
+ class CVEvaluationOrchestrator:
56
+ """Orchestrates the multi-agent CV evaluation pipeline."""
57
+
58
+ def __init__(
59
+ self,
60
+ api_key: str,
61
+ model_name: str = "gemini-1.5-flash",
62
+ cache_dir: str | None = None,
63
+ progress_callback: Callable[[str, float], None] | None = None,
64
+ ):
65
+ self.api_key = api_key
66
+ self.model_name = model_name
67
+ self.provider = detect_provider(model_name)
68
+ self.cache = ResultCache(cache_dir=cache_dir)
69
+ self.progress_callback = progress_callback or (lambda msg, pct: None)
70
+
71
+ logger.info(
72
+ f"[Orchestrator] Provider detected: '{self.provider}' for model '{model_name}'"
73
+ )
74
+
75
+ # ✅ provider is now passed to every agent
76
+ agent_kwargs = {
77
+ "api_key": api_key,
78
+ "model_name": model_name,
79
+ "provider": self.provider,
80
+ }
81
+ self.experience_agent = ExperienceAnalysisAgent(**agent_kwargs)
82
+ self.skills_education_agent = SkillsEducationAgent(**agent_kwargs)
83
+ self.summary_validation_agent = SummaryValidationAgent(**agent_kwargs)
84
+ self.scoring_agent = ScoringAgent(**agent_kwargs)
85
+ self.quality_control_agent = QualityControlAgent(**agent_kwargs)
86
+ self.table_generator_agent = TableGeneratorAgent(**agent_kwargs)
87
+
88
+ def _update_progress(self, message: str, percentage: float):
89
+ """Send progress update."""
90
+ self.progress_callback(message, percentage)
91
+ logger.info(f"[Orchestrator] {percentage:.0%} - {message}")
92
+
93
+ def evaluate(self, cv_text: str) -> FinalReport:
94
+ """
95
+ Run the complete CV evaluation pipeline.
96
+
97
+ Args:
98
+ cv_text: Full text extracted from the CV PDF.
99
+
100
+ Returns:
101
+ FinalReport: Complete evaluation report.
102
+ """
103
+ start_time = time.time()
104
+ self._update_progress("📄 Découpage sémantique du CV...", 0.05)
105
+
106
+ # ── Phase 0: Semantic chunking ──
107
+ sections = chunk_cv_by_sections(cv_text)
108
+ cv_experiences = get_section_or_full(sections, "experiences")
109
+ cv_competences = get_section_or_full(sections, "competences")
110
+ cv_formations = get_section_or_full(sections, "formations")
111
+ cv_resume = get_section_or_full(sections, "resume")
112
+ cv_full = get_section_or_full(sections, "full_text")
113
+
114
+ logger.info(
115
+ f"[Orchestrator] Sections detected: {[k for k in sections.keys() if k != 'full_text']}"
116
+ )
117
+
118
+ # ── Phase 1: Experience Analysis (independent) ──
119
+ self._update_progress("🔍 Agent 1/6 : Analyse des expériences...", 0.10)
120
+ experience_result = self.experience_agent.run(
121
+ cv_experiences=cv_experiences,
122
+ cv_full_text=cv_full,
123
+ )
124
+ self.cache.set("experience", cv_text, experience_result.model_dump())
125
+
126
+ # ── Phase 2: Skills/Education + Summary Validation (parallel, depend on Phase 1) ──
127
+ self._update_progress(
128
+ "🎯 Agents 2-3/6 : Compétences, formations et validation du résumé (parallèle)...",
129
+ 0.30,
130
+ )
131
+
132
+ skills_result: SkillsEducationOutput | None = None
133
+ summary_result: SummaryValidationOutput | None = None
134
+
135
+ with ThreadPoolExecutor(max_workers=2) as executor:
136
+ future_skills = executor.submit(
137
+ self.skills_education_agent.run,
138
+ cv_competences=cv_competences,
139
+ cv_formations=cv_formations,
140
+ experience_analysis=experience_result,
141
+ )
142
+ future_summary = executor.submit(
143
+ self.summary_validation_agent.run,
144
+ cv_resume=cv_resume,
145
+ experience_analysis=experience_result,
146
+ )
147
+
148
+ for future in as_completed([future_skills, future_summary]):
149
+ if future == future_skills:
150
+ skills_result = future.result()
151
+ self.cache.set("skills", cv_text, skills_result.model_dump())
152
+ self._update_progress("✅ Compétences & formations analysées", 0.45)
153
+ else:
154
+ summary_result = future.result()
155
+ self.cache.set("summary", cv_text, summary_result.model_dump())
156
+ self._update_progress("✅ Résumé validé", 0.50)
157
+
158
+ # ── Phase 3: Scoring (depends on Phase 1 + 2) ──
159
+ self._update_progress("📊 Agent 4/6 : Calcul des scores...", 0.60)
160
+ scoring_result = self.scoring_agent.run(
161
+ score_experiences=experience_result.score_global_experiences,
162
+ score_competences=skills_result.score_competences,
163
+ score_formations=skills_result.score_formations,
164
+ score_resume=summary_result.score_resume,
165
+ )
166
+ self.cache.set("scoring", cv_text, scoring_result.model_dump())
167
+
168
+ # ── Phase 4: Quality Control + Table Generation (parallel, depend on all previous) ──
169
+ self._update_progress(
170
+ "🏁 Agents 5-6/6 : Contrôle qualité et tableau d'évaluation (parallèle)...",
171
+ 0.75,
172
+ )
173
+
174
+ quality_result: QualityControlOutput | None = None
175
+ table_result: TableGeneratorOutput | None = None
176
+
177
+ with ThreadPoolExecutor(max_workers=2) as executor:
178
+ future_quality = executor.submit(
179
+ self.quality_control_agent.run,
180
+ experience_analysis=experience_result,
181
+ skills_education=skills_result,
182
+ summary_validation=summary_result,
183
+ scoring=scoring_result,
184
+ )
185
+ future_table = executor.submit(
186
+ self.table_generator_agent.run,
187
+ experience_analysis=experience_result,
188
+ skills_education=skills_result,
189
+ summary_validation=summary_result,
190
+ )
191
+
192
+ for future in as_completed([future_quality, future_table]):
193
+ if future == future_quality:
194
+ quality_result = future.result()
195
+ self.cache.set("quality", cv_text, quality_result.model_dump())
196
+ self._update_progress("✅ Contrôle qualité terminé", 0.88)
197
+ else:
198
+ table_result = future.result()
199
+ self.cache.set("table", cv_text, table_result.model_dump())
200
+ self._update_progress("✅ Tableau d'évaluation généré", 0.92)
201
+
202
+ # ── Phase 5: Assemble final report ──
203
+ self._update_progress("📋 Assemblage du rapport final...", 0.95)
204
+
205
+ elapsed = round(time.time() - start_time, 2)
206
+
207
+ report = FinalReport(
208
+ experience_analysis=experience_result,
209
+ skills_education=skills_result,
210
+ summary_validation=summary_result,
211
+ scoring=scoring_result,
212
+ quality_control=quality_result,
213
+ evaluation_table=table_result,
214
+ metadata={
215
+ "date_evaluation": datetime.now(timezone.utc).isoformat(),
216
+ "modele_llm": self.model_name,
217
+ "temperature": 0,
218
+ "duree_evaluation_secondes": elapsed,
219
+ "version": "1.0.0",
220
+ "sections_detectees": [k for k in sections.keys() if k != "full_text"],
221
+ },
222
+ )
223
+
224
+ self._update_progress("✅ Évaluation terminée !", 1.0)
225
+ logger.info(f"[Orchestrator] Evaluation complete in {elapsed}s")
226
+
227
+ return report
prompts/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from prompts.templates import *
prompts/templates.py ADDED
@@ -0,0 +1,307 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Prompt templates for all 6 agents.
3
+ Each prompt enforces JSON output, low temperature reasoning, and strict evaluation criteria.
4
+ """
5
+
6
+ EXPERIENCE_ANALYSIS_PROMPT = """Tu es un expert senior en recrutement et analyse de CV.
7
+ Tu dois analyser la section EXPÉRIENCES du CV fourni avec une rigueur absolue.
8
+
9
+ ## INSTRUCTIONS STRICTES
10
+ 1. Extrais CHAQUE expérience professionnelle listée
11
+ 2. Pour chaque expérience, évalue :
12
+ - Le contexte métier (secteur, enjeux, taille d'équipe/projet)
13
+ - Les missions différenciantes (ce qui distingue le candidat)
14
+ - Les résultats mesurables (chiffres, KPI, impacts concrets)
15
+ - La cohérence technique (stack, outils, méthodologies mentionnés)
16
+ - Les erreurs naïves : mention de concurrents sans contexte, formulations faibles/vagues,
17
+ absence de contexte métier, buzzwords sans substance
18
+ 3. Ne JAMAIS inventer de données absentes. Signale explicitement ce qui manque.
19
+ 4. Score chaque expérience de 0 à 10 avec justification.
20
+ 5. Calcule un score global des expériences (moyenne pondérée par pertinence).
21
+
22
+ ## CONTENU DU CV - SECTION EXPÉRIENCES
23
+ {cv_experiences}
24
+
25
+ ## CONTENU COMPLET DU CV (pour contexte)
26
+ {cv_full_text}
27
+
28
+ ## FORMAT DE SORTIE OBLIGATOIRE (JSON strict)
29
+ Tu DOIS répondre UNIQUEMENT avec un objet JSON valide respectant exactement ce schéma :
30
+ {{
31
+ "experiences": [
32
+ {{
33
+ "poste": "string",
34
+ "entreprise": "string",
35
+ "periode": "string",
36
+ "duree_estimee": "string ou null",
37
+ "contexte_metier": "string",
38
+ "missions": ["string"],
39
+ "missions_differenciantes": ["string"],
40
+ "resultats_mesurables": ["string"],
41
+ "coherence_technique": "string",
42
+ "erreurs_naives": ["string"],
43
+ "score": 0.0,
44
+ "justification_score": "string"
45
+ }}
46
+ ],
47
+ "score_global_experiences": 0.0,
48
+ "synthese": "string",
49
+ "points_forts": ["string"],
50
+ "points_faibles": ["string"],
51
+ "donnees_manquantes": ["string"]
52
+ }}
53
+
54
+ Réponds UNIQUEMENT avec le JSON, sans texte avant ni après."""
55
+
56
+
57
+ SKILLS_EDUCATION_PROMPT = """Tu es un expert en évaluation des compétences et formations professionnelles.
58
+ Tu dois analyser les sections COMPÉTENCES et FORMATIONS du CV avec une rigueur absolue.
59
+
60
+ ## INSTRUCTIONS STRICTES
61
+ 1. Extrais CHAQUE compétence mentionnée et catégorise-la (technique, soft skill, métier, outil)
62
+ 2. Vérifie si chaque compétence est DÉMONTRÉE dans une expérience concrète
63
+ 3. Identifie les compétences listées mais non prouvées par l'expérience
64
+ 4. Pour les formations, vérifie la cohérence avec le parcours professionnel
65
+ 5. Évalue la clarté et la structuration de la section compétences
66
+ 6. Ne JAMAIS inventer de données absentes.
67
+
68
+ ## CONTENU DU CV - SECTION COMPÉTENCES
69
+ {cv_competences}
70
+
71
+ ## CONTENU DU CV - SECTION FORMATIONS
72
+ {cv_formations}
73
+
74
+ ## RÉSULTATS DE L'ANALYSE DES EXPÉRIENCES (pour cross-check)
75
+ {experience_analysis}
76
+
77
+ ## FORMAT DE SORTIE OBLIGATOIRE (JSON strict)
78
+ Tu DOIS répondre UNIQUEMENT avec un objet JSON valide :
79
+ {{
80
+ "competences": [
81
+ {{
82
+ "nom": "string",
83
+ "categorie": "string",
84
+ "demontree_dans_experience": true,
85
+ "experience_associee": "string ou null",
86
+ "niveau_estime": "string ou null"
87
+ }}
88
+ ],
89
+ "formations": [
90
+ {{
91
+ "diplome": "string",
92
+ "etablissement": "string",
93
+ "annee": "string ou null",
94
+ "coherence_parcours": "string"
95
+ }}
96
+ ],
97
+ "score_competences": 0.0,
98
+ "score_formations": 0.0,
99
+ "competences_non_demontrees": ["string"],
100
+ "coherence_formation_parcours": "string",
101
+ "points_forts": ["string"],
102
+ "points_faibles": ["string"],
103
+ "donnees_manquantes": ["string"]
104
+ }}
105
+
106
+ Réponds UNIQUEMENT avec le JSON, sans texte avant ni après."""
107
+
108
+
109
+ SUMMARY_VALIDATION_PROMPT = """Tu es un analyste spécialisé dans la vérification des affirmations de CV.
110
+ Tu dois valider le résumé/profil du CV en le confrontant aux expériences réelles.
111
+
112
+ ## INSTRUCTIONS STRICTES
113
+ 1. Extrais CHAQUE affirmation du résumé/profil du candidat
114
+ 2. Pour chaque affirmation, cherche une PREUVE concrète dans les expériences analysées
115
+ 3. Distingue clairement :
116
+ - Affirmations PROUVÉES (avec preuve concrète)
117
+ - Affirmations NON PROUVÉES (déclaratives sans preuve)
118
+ 4. Identifie les écarts entre le positionnement déclaré et la réalité des missions
119
+ 5. Calcule le taux d'affirmations prouvées
120
+ 6. Ne JAMAIS inventer de preuves absentes.
121
+
122
+ ## CONTENU DU CV - SECTION RÉSUMÉ/PROFIL
123
+ {cv_resume}
124
+
125
+ ## RÉSULTATS DE L'ANALYSE DES EXPÉRIENCES
126
+ {experience_analysis}
127
+
128
+ ## FORMAT DE SORTIE OBLIGATOIRE (JSON strict)
129
+ Tu DOIS répondre UNIQUEMENT avec un objet JSON valide :
130
+ {{
131
+ "affirmations_analysees": [
132
+ {{
133
+ "affirmation": "string",
134
+ "prouvee": true,
135
+ "preuve": "string ou null",
136
+ "commentaire": "string"
137
+ }}
138
+ ],
139
+ "score_resume": 0.0,
140
+ "taux_affirmations_prouvees": 0.0,
141
+ "ecarts_alignement": ["string"],
142
+ "positionnement_declare": "string",
143
+ "positionnement_reel": "string",
144
+ "synthese": "string",
145
+ "donnees_manquantes": ["string"]
146
+ }}
147
+
148
+ Réponds UNIQUEMENT avec le JSON, sans texte avant ni après."""
149
+
150
+
151
+ SCORING_PROMPT = """Tu es un calculateur de scores de CV rigoureux et mathématiquement exact.
152
+ Tu dois calculer le score final du CV selon une formule pondérée STRICTE.
153
+
154
+ ## FORMULE OBLIGATOIRE
155
+ Note /10 = (Expériences × 0.5) + (Compétences × 0.2) + (Formations × 0.1) + (Résumé × 0.2)
156
+
157
+ ## SCORES D'ENTRÉE (fournis par les agents précédents)
158
+ - Score Expériences : {score_experiences}/10
159
+ - Score Compétences : {score_competences}/10
160
+ - Score Formations : {score_formations}/10
161
+ - Score Résumé : {score_resume}/10
162
+
163
+ ## INSTRUCTIONS STRICTES
164
+ 1. Applique EXACTEMENT la formule ci-dessus
165
+ 2. Affiche CHAQUE calcul intermédiaire
166
+ 3. Vérifie mathématiquement la cohérence (la somme des poids = 1.0)
167
+ 4. Convertis en /20 et /100
168
+ 5. Si une incohérence est détectée, signale-la comme erreur
169
+
170
+ ## FORMAT DE SORTIE OBLIGATOIRE (JSON strict)
171
+ Tu DOIS répondre UNIQUEMENT avec un objet JSON valide :
172
+ {{
173
+ "details": [
174
+ {{
175
+ "critere": "Expériences",
176
+ "score_brut": 0.0,
177
+ "poids": 0.5,
178
+ "score_pondere": 0.0,
179
+ "justification": "string"
180
+ }},
181
+ {{
182
+ "critere": "Compétences",
183
+ "score_brut": 0.0,
184
+ "poids": 0.2,
185
+ "score_pondere": 0.0,
186
+ "justification": "string"
187
+ }},
188
+ {{
189
+ "critere": "Formations",
190
+ "score_brut": 0.0,
191
+ "poids": 0.1,
192
+ "score_pondere": 0.0,
193
+ "justification": "string"
194
+ }},
195
+ {{
196
+ "critere": "Résumé",
197
+ "score_brut": 0.0,
198
+ "poids": 0.2,
199
+ "score_pondere": 0.0,
200
+ "justification": "string"
201
+ }}
202
+ ],
203
+ "note_finale_sur_10": 0.0,
204
+ "note_finale_sur_20": 0.0,
205
+ "note_finale_sur_100": 0.0,
206
+ "calcul_intermediaire": "string montrant le calcul complet",
207
+ "validation_mathematique": true,
208
+ "erreur_calcul": null
209
+ }}
210
+
211
+ Réponds UNIQUEMENT avec le JSON, sans texte avant ni après."""
212
+
213
+
214
+ QUALITY_CONTROL_PROMPT = """Tu es un contrôleur qualité senior spécialisé dans l'évaluation finale de CV.
215
+ Tu dois rendre un verdict global sur la qualité du CV et le profil du candidat.
216
+
217
+ ## INSTRUCTIONS STRICTES
218
+ 1. Vérifie la présence des éléments clés :
219
+ - Enjeux métier clairement exprimés
220
+ - Livrables différenciants identifiés
221
+ - Résultats concrets et mesurables
222
+ - Cohérence du parcours
223
+ 2. Évalue l'alignement global : compétences ↔ expériences ↔ résumé
224
+ 3. Rends un verdict : "profil_vendeur" (CV bien construit, convaincant) vs "profil_banal" (générique)
225
+ ou "profil_intermediaire"
226
+ 4. Émets une recommandation : "Oui", "Non" ou "Peut-être" avec justification
227
+ 5. Liste les forces et faiblesses principales
228
+
229
+ ## DONNÉES D'ENTRÉE
230
+ ### Analyse des expériences
231
+ {experience_analysis}
232
+
233
+ ### Analyse compétences/formations
234
+ {skills_education}
235
+
236
+ ### Validation du résumé
237
+ {summary_validation}
238
+
239
+ ### Scores
240
+ {scoring}
241
+
242
+ ## FORMAT DE SORTIE OBLIGATOIRE (JSON strict)
243
+ Tu DOIS répondre UNIQUEMENT avec un objet JSON valide :
244
+ {{
245
+ "elements_verifies": [
246
+ {{
247
+ "element": "string",
248
+ "present": true,
249
+ "qualite": "excellent|bon|moyen|faible|absent",
250
+ "commentaire": "string"
251
+ }}
252
+ ],
253
+ "alignement_global": "string",
254
+ "score_alignement": 0.0,
255
+ "verdict": "profil_vendeur|profil_banal|profil_intermediaire",
256
+ "justification_verdict": "string",
257
+ "recommandation": "Oui|Non|Peut-être",
258
+ "justification_recommandation": "string",
259
+ "forces": ["string"],
260
+ "faiblesses": ["string"]
261
+ }}
262
+
263
+ Réponds UNIQUEMENT avec le JSON, sans texte avant ni après."""
264
+
265
+
266
+ TABLE_GENERATOR_PROMPT = """Tu es un générateur de tableaux d'évaluation de CV.
267
+ Tu dois produire un tableau structuré évaluant chaque section du CV.
268
+
269
+ ## INSTRUCTIONS STRICTES
270
+ 1. Crée une ligne pour CHAQUE expérience individuellement
271
+ 2. Crée une ligne pour "Compétences" (global)
272
+ 3. Crée une ligne pour "Formations" (global)
273
+ 4. Crée une ligne pour "Résumé/Profil" (global)
274
+ 5. Chaque cellule contient un emoji + justification courte :
275
+ - ✅ = Bon/Conforme
276
+ - ⚠️ = Acceptable avec réserves
277
+ - ❌ = Insuffisant/Problématique
278
+ 6. Les colonnes sont : Clarté, Cohérence, Qualité rédactionnelle, Pertinence, Respect des règles, Erreurs naïves
279
+
280
+ ## DONNÉES D'ENTRÉE
281
+ ### Analyse des expériences
282
+ {experience_analysis}
283
+
284
+ ### Analyse compétences/formations
285
+ {skills_education}
286
+
287
+ ### Validation du résumé
288
+ {summary_validation}
289
+
290
+ ## FORMAT DE SORTIE OBLIGATOIRE (JSON strict)
291
+ Tu DOIS répondre UNIQUEMENT avec un objet JSON valide :
292
+ {{
293
+ "lignes": [
294
+ {{
295
+ "element": "string (nom de l'expérience ou section)",
296
+ "clarte": {{"emoji": "✅|⚠️|❌", "justification": "string (max 200 chars)"}},
297
+ "coherence": {{"emoji": "✅|⚠️|❌", "justification": "string"}},
298
+ "qualite_redactionnelle": {{"emoji": "✅|⚠️|❌", "justification": "string"}},
299
+ "pertinence": {{"emoji": "✅|⚠️|❌", "justification": "string"}},
300
+ "respect_regles": {{"emoji": "✅|⚠️|❌", "justification": "string"}},
301
+ "erreurs_naives": {{"emoji": "✅|⚠️|❌", "justification": "string"}}
302
+ }}
303
+ ],
304
+ "resume_tableau": "string résumant les observations du tableau"
305
+ }}
306
+
307
+ Réponds UNIQUEMENT avec le JSON, sans texte avant ni après."""
pyproject.toml ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # pyproject.toml
2
+ # Configuration centralisée pour les outils Python
3
+
4
+ [project]
5
+ name = "cv_evaluator"
6
+ version = "0.1.0"
7
+ description = "Système Multi-Agents d'Évaluation de CV"
8
+ requires-python = ">=3.10"
9
+ dependencies = [
10
+ # Tes dépendances seront listées ici
11
+ # (on les gardera aussi dans requirements.txt pour compatibilité)
12
+ ]
13
+
14
+ [tool.ruff]
15
+ # Configuration du linter/formatteur
16
+ target-version = "py310"
17
+ line-length = 88
18
+
19
+ # Règles activées
20
+ select = [
21
+ "E", # pycodestyle (erreurs)
22
+ "W", # pycodestyle (warnings)
23
+ "F", # pyflakes
24
+ "I", # isort (imports)
25
+ "N", # pep8-naming
26
+ "UP", # pyupgrade
27
+ "B", # flake8-bugbear
28
+ "SIM", # flake8-simplify
29
+ ]
30
+
31
+ # Règles ignorées (optionnel, ajuste selon tes besoins)
32
+ ignore = [
33
+ "E501", # ligne trop longue (parfois inévitable)
34
+ ]
35
+
36
+ # Fichiers à exclure
37
+ exclude = [
38
+ "venv",
39
+ ".git",
40
+ "__pycache__",
41
+ "*.pyc",
42
+ ]
43
+
44
+ [tool.ruff.format]
45
+ # Configuration du formatage (style black)
46
+ quote-style = "double"
47
+ indent-style = "space"
48
+
49
+ [tool.pytest.ini_options]
50
+ # Configuration des tests
51
+ testpaths = ["tests"]
52
+ python_files = ["test_*.py"]
53
+ python_functions = ["test_*"]
54
+ addopts = "-v --tb=short"
55
+
56
+ [tool.bandit]
57
+ # Configuration de la sécurité
58
+ exclude_dirs = ["venv", "tests"]
59
+ skips = ["B101"] # B101 = assert utilisé (ok dans les tests)
requirements-dev.txt ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # requirements-dev.txt
2
+ # Dépendances uniquement pour le développement et le CI/CD
3
+
4
+ # Linting et formatage
5
+ ruff>=0.4.0
6
+
7
+ # Tests
8
+ pytest>=8.0.0
9
+ pytest-cov>=5.0.0
10
+
11
+ # Sécurité
12
+ bandit>=1.7.0
13
+ safety>=3.0.0
14
+
15
+ # Typing (optionnel mais recommandé)
16
+ mypy>=1.10.0
requirements.txt CHANGED
@@ -1,3 +1,11 @@
1
- altair
2
- pandas
3
- streamlit
 
 
 
 
 
 
 
 
 
1
+ streamlit>=1.30.0
2
+ plotly>=6.6.0
3
+ langchain>=0.1.0
4
+ langchain-google-genai>=1.0.0
5
+ langchain-openai>=1.1.12
6
+ langchain-core>=0.1.0
7
+ google-generativeai>=0.4.0
8
+ pydantic>=2.5.0
9
+ pymupdf>=1.23.0
10
+ tenacity>=9.1.4
11
+ python-dotenv>=1.0.0
tests/__init__.py ADDED
File without changes
tests/test_basic.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # tests/test_basic.py
2
+ # Tests ultra-simples pour vérifier que le CI fonctionne
3
+
4
+
5
+ def test_python_works():
6
+ """Vérifie que Python fonctionne."""
7
+ assert 1 + 1 == 2
8
+
9
+
10
+ def test_string_operations():
11
+ """Vérifie les opérations sur les chaînes."""
12
+ text = "CV Evaluator"
13
+ assert "CV" in text
14
+ assert text.lower() == "cv evaluator"
15
+
16
+
17
+ def test_list_operations():
18
+ """Vérifie les opérations sur les listes."""
19
+ items = [1, 2, 3]
20
+ assert len(items) == 3
21
+ assert 2 in items
22
+
23
+
24
+ def test_dictionary_operations():
25
+ """Vérifie les opérations sur les dictionnaires."""
26
+ data = {"score": 85, "max": 100}
27
+ assert data["score"] == 85
28
+ assert "max" in data
29
+
30
+
31
+ def test_import_standard_library():
32
+ """Vérifie que les bibliothèques standard sont disponibles."""
33
+ import json
34
+ import os
35
+ import sys
36
+
37
+ assert json is not None
38
+ assert os is not None
39
+ assert sys is not None
utils/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ from utils.cache import ResultCache
2
+ from utils.chunking import chunk_cv_by_sections, get_section_or_full
3
+ from utils.pdf_parser import (
4
+ extract_text_from_pdf,
5
+ extract_text_from_uploaded_file,
6
+ get_page_count,
7
+ )
utils/cache.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Caching utility for intermediate agent results.
3
+ Avoids redundant API calls during the evaluation pipeline.
4
+ """
5
+
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import time
10
+ from typing import Any
11
+
12
+
13
+ class ResultCache:
14
+ """In-memory + optional file-based cache for agent results."""
15
+
16
+ def __init__(self, cache_dir: str | None = None):
17
+ self._memory: dict[str, Any] = {}
18
+ self._timestamps: dict[str, float] = {}
19
+ self.cache_dir = cache_dir
20
+ if cache_dir:
21
+ os.makedirs(cache_dir, exist_ok=True)
22
+
23
+ def _make_key(self, agent_name: str, input_hash: str) -> str:
24
+ return f"{agent_name}_{input_hash}"
25
+
26
+ def _hash_input(self, input_data: str) -> str:
27
+ return hashlib.sha256(input_data.encode("utf-8")).hexdigest()[:16]
28
+
29
+ def get(self, agent_name: str, input_data: str) -> Any | None:
30
+ """Retrieve cached result for an agent given input data."""
31
+ key = self._make_key(agent_name, self._hash_input(input_data))
32
+
33
+ # Check memory first
34
+ if key in self._memory:
35
+ return self._memory[key]
36
+
37
+ # Check file cache
38
+ if self.cache_dir:
39
+ filepath = os.path.join(self.cache_dir, f"{key}.json")
40
+ if os.path.exists(filepath):
41
+ try:
42
+ with open(filepath, encoding="utf-8") as f:
43
+ data = json.load(f)
44
+ self._memory[key] = data
45
+ return data
46
+ except (OSError, json.JSONDecodeError):
47
+ pass
48
+
49
+ return None
50
+
51
+ def set(self, agent_name: str, input_data: str, result: Any) -> None:
52
+ """Cache result for an agent."""
53
+ key = self._make_key(agent_name, self._hash_input(input_data))
54
+ self._memory[key] = result
55
+ self._timestamps[key] = time.time()
56
+
57
+ # Write to file cache
58
+ if self.cache_dir:
59
+ filepath = os.path.join(self.cache_dir, f"{key}.json")
60
+ try:
61
+ with open(filepath, "w", encoding="utf-8") as f:
62
+ if isinstance(result, str):
63
+ json.dump({"raw": result}, f, ensure_ascii=False, indent=2)
64
+ else:
65
+ json.dump(result, f, ensure_ascii=False, indent=2)
66
+ except (OSError, TypeError):
67
+ pass
68
+
69
+ def clear(self) -> None:
70
+ """Clear all cached data."""
71
+ self._memory.clear()
72
+ self._timestamps.clear()
73
+ if self.cache_dir:
74
+ for f in os.listdir(self.cache_dir):
75
+ if f.endswith(".json"):
76
+ os.remove(os.path.join(self.cache_dir, f))
77
+
78
+ def get_stats(self) -> dict[str, Any]:
79
+ """Return cache statistics."""
80
+ return {
81
+ "entries": len(self._memory),
82
+ "agents_cached": list(
83
+ set(k.rsplit("_", 1)[0] for k in self._memory.keys())
84
+ ),
85
+ }
utils/chunking.py ADDED
@@ -0,0 +1,485 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Découpage dynamique intelligent pour le contenu des CV.
3
+
4
+ Stratégie : découpage hybride par section + prise en compte des tokens
5
+ ───────────────────────────────────────────────────────────────────────
6
+ 1. DÉTECTION DES SECTIONS → expressions régulières (FR + EN) pour localiser
7
+ les limites sémantiques
8
+ 2. ESTIMATION DES TOKENS → heuristique ~4 caractères/token, sans librairie externe
9
+ 3. DÉCOUPAGE ADAPTATIF → les sections qui dépassent le budget de tokens sont
10
+ sous-découpées par paragraphe / bloc de dates afin
11
+ que le LLM ne reçoive jamais un mur de texte tronqué
12
+ en pleine phrase
13
+ 4. INJECTION DE CONTEXTE → chaque fragment de dépassement reçoit un « en‑tête »
14
+ léger résumant ce qui précède (continuité sémantique)
15
+ 5. SOLUTION DE SECOURS → si aucune section n’est trouvée, le texte complet
16
+ est divisé en fenêtres avec chevauchement paramétrable
17
+
18
+ Budget de tokens par défaut
19
+ ───────────────────────────
20
+ MAX_TOKENS_PER_CHUNK = 3 000 (sûr pour les modèles avec contexte 4k)
21
+ OVERLAP_TOKENS = 200 (préservation du contexte entre fragments)
22
+ CHARS_PER_TOKEN = 4 (heuristique conservative pour le français/anglais)
23
+
24
+ API rétrocompatible
25
+ ───────────────────
26
+ chunk_cv_by_sections() → interface dict héritée (utilisée par l’orchestrateur actuel)
27
+ get_section_or_full() → fonction utilitaire héritée (utilisée par l’orchestrateur actuel)
28
+
29
+ Nouvelle API
30
+ ────────────
31
+ chunk_cv() → renvoie un dataclass CVSections
32
+ get_best_chunks_for_agent() → chaîne de caractères adaptée au budget de tokens
33
+ pour l’agent
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import logging
39
+ import re
40
+ from dataclasses import dataclass, field
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ # ── Tunable constants ────────────────────────────────────────────────────────
45
+ MAX_TOKENS_PER_CHUNK: int = 3_000
46
+ OVERLAP_TOKENS: int = 200
47
+ CHARS_PER_TOKEN: float = 4.0
48
+ MAX_CHARS: int = int(MAX_TOKENS_PER_CHUNK * CHARS_PER_TOKEN)
49
+ OVERLAP_CHARS: int = int(OVERLAP_TOKENS * CHARS_PER_TOKEN)
50
+
51
+ # ── Section vocabulary (FR + EN) ─────────────────────────────────────────────
52
+ SECTION_PATTERNS: dict[str, list[str]] = {
53
+ "resume": [
54
+ r"(?i)(profil\s*pro|profil\s*candidat|résumé\s*pro|summary|about\s*me"
55
+ r"|à\s*propos|objectif(\s*(pro|career))?|présentation|introduction"
56
+ r"|accroche|profil$|executive\s*summary)",
57
+ ],
58
+ "experiences": [
59
+ r"(?i)(expérience[s]?\s*(professionnelle[s]?)?|professional\s*experience"
60
+ r"|work\s*experience|employment|parcours\s*professionnel"
61
+ r"|postes?\s*occupés?|carrière|career\s*history)",
62
+ ],
63
+ "competences": [
64
+ r"(?i)(compétence[s]?|skills?|savoir[s]?\s*faire|technical\s*skills?"
65
+ r"|compétences?\s*techniques?|hard\s*skills?|soft\s*skills?"
66
+ r"|outils?|technologies?|stack\s*technique|expertise)",
67
+ ],
68
+ "formations": [
69
+ r"(?i)(formation[s]?|education|diplôme[s]?|cursus|études"
70
+ r"|certifications?|parcours\s*académique|academic|qualifications?)",
71
+ ],
72
+ "langues": [
73
+ r"(?i)(langue[s]?|languages?|linguistic)",
74
+ ],
75
+ "centres_interet": [
76
+ r"(?i)(centre[s]?\s*d'intérêt|hobbies?|loisirs?|interests?"
77
+ r"|activités?\s*extra|passions?)",
78
+ ],
79
+ "projets": [
80
+ r"(?i)(projet[s]?|projects?|réalisations?|portfolio|open.?source)",
81
+ ],
82
+ "references": [
83
+ r"(?i)(référence[s]?|references?|recommendations?)",
84
+ ],
85
+ "publications": [
86
+ r"(?i)(publications?|articles?|recherche[s]?|research|papers?)",
87
+ ],
88
+ }
89
+
90
+ REQUIRED_SECTIONS = {"resume", "experiences", "competences", "formations"}
91
+
92
+
93
+ # ── Core data structures ──────────────────────────────────────────────────────
94
+
95
+
96
+ @dataclass
97
+ class Chunk:
98
+ """A single text chunk with metadata."""
99
+
100
+ section: str
101
+ index: int
102
+ total_chunks: int
103
+ text: str
104
+ token_estimate: int
105
+ preceding_context: str = ""
106
+ is_overflow: bool = False
107
+
108
+ @property
109
+ def full_text(self) -> str:
110
+ if self.preceding_context:
111
+ return (
112
+ f"[CONTEXTE PRÉCÉDENT]\n{self.preceding_context}"
113
+ f"\n\n[CONTENU PRINCIPAL]\n{self.text}"
114
+ )
115
+ return self.text
116
+
117
+ def __repr__(self) -> str:
118
+ return (
119
+ f"Chunk(section={self.section!r}, "
120
+ f"idx={self.index}/{self.total_chunks - 1}, "
121
+ f"~{self.token_estimate} tokens, overflow={self.is_overflow})"
122
+ )
123
+
124
+
125
+ @dataclass
126
+ class CVSections:
127
+ """Container returned by chunk_cv()."""
128
+
129
+ chunks_by_section: dict[str, list[Chunk]] = field(default_factory=dict)
130
+ full_text: str = ""
131
+ detected_sections: list[str] = field(default_factory=list)
132
+
133
+ def get_section_text(
134
+ self,
135
+ section: str,
136
+ max_tokens: int = MAX_TOKENS_PER_CHUNK,
137
+ join_sep: str = "\n\n",
138
+ ) -> str:
139
+ chunks = self.chunks_by_section.get(section, [])
140
+ if not chunks or sum(c.token_estimate for c in chunks) < 20:
141
+ logger.warning(
142
+ "[CVSections] Section '%s' absent. Using full_text window.", section
143
+ )
144
+ return _window(self.full_text, max_tokens)
145
+ budget = max_tokens
146
+ parts: list[str] = []
147
+ for chunk in chunks:
148
+ if budget <= 0:
149
+ break
150
+ parts.append(chunk.full_text)
151
+ budget -= chunk.token_estimate
152
+ result = join_sep.join(parts)
153
+ if budget < 0:
154
+ result = _truncate(result, max_tokens)
155
+ return result
156
+
157
+ def get_first_chunk(self, section: str) -> Chunk | None:
158
+ chunks = self.chunks_by_section.get(section, [])
159
+ return chunks[0] if chunks else None
160
+
161
+ def section_token_count(self, section: str) -> int:
162
+ return sum(c.token_estimate for c in self.chunks_by_section.get(section, []))
163
+
164
+ def summary_report(self) -> str:
165
+ lines = ["=== CV Chunking Report ==="]
166
+ for sec, chunks in self.chunks_by_section.items():
167
+ total_tok = sum(c.token_estimate for c in chunks)
168
+ overflow_tag = (
169
+ " [OVERFLOW → SPLIT]" if any(c.is_overflow for c in chunks) else ""
170
+ )
171
+ lines.append(
172
+ f" {sec:<20} {len(chunks)} chunk(s) ~{total_tok} tokens{overflow_tag}"
173
+ )
174
+ return "\n".join(lines)
175
+
176
+
177
+ # ── Public API ────────────────────────────────────────────────────────────────
178
+
179
+
180
+ def chunk_cv(full_text: str) -> CVSections:
181
+ """
182
+ Main entry-point. Returns a CVSections object.
183
+
184
+ Algorithm
185
+ ─────────
186
+ 1. Detect section header lines via regex.
187
+ 2. Slice raw text between consecutive headers.
188
+ 3. For each raw slice:
189
+ a. <= MAX_CHARS → single Chunk
190
+ b. > MAX_CHARS → adaptive split (experience blocks, paragraphs,
191
+ hard character split as last resort)
192
+ 4. Ensure all REQUIRED_SECTIONS exist with a full_text fallback.
193
+ """
194
+ result = CVSections(full_text=full_text)
195
+ lines = full_text.splitlines()
196
+
197
+ boundaries = _detect_boundaries(lines)
198
+ logger.info("[Chunking] Detected %d section boundaries.", len(boundaries))
199
+
200
+ raw_sections = _slice_sections(lines, boundaries)
201
+ result.detected_sections = list(raw_sections.keys())
202
+
203
+ for section_name, raw_text in raw_sections.items():
204
+ new_chunks = _adaptive_chunk(section_name, raw_text)
205
+ if section_name in result.chunks_by_section:
206
+ existing = result.chunks_by_section[section_name]
207
+ offset = len(existing)
208
+ for c in new_chunks:
209
+ c.index += offset
210
+ result.chunks_by_section[section_name] = existing + new_chunks
211
+ else:
212
+ result.chunks_by_section[section_name] = new_chunks
213
+
214
+ # Fix total_chunks after potential merging of duplicate sections
215
+ for section_name, chunks in result.chunks_by_section.items():
216
+ total = len(chunks)
217
+ for c in chunks:
218
+ c.total_chunks = total
219
+
220
+ # Fallback for required but absent sections
221
+ for sec in REQUIRED_SECTIONS:
222
+ if sec not in result.chunks_by_section:
223
+ logger.warning(
224
+ "[Chunking] Required section '%s' not found. Injecting fallback.", sec
225
+ )
226
+ fallback_text = (
227
+ f"[Section '{sec}' non détectée — contenu complet du CV]\n\n"
228
+ + _window(full_text, MAX_TOKENS_PER_CHUNK)
229
+ )
230
+ result.chunks_by_section[sec] = [
231
+ Chunk(
232
+ section=sec,
233
+ index=0,
234
+ total_chunks=1,
235
+ text=fallback_text,
236
+ token_estimate=_tokens(fallback_text),
237
+ is_overflow=False,
238
+ )
239
+ ]
240
+
241
+ logger.info("[Chunking]\n%s", result.summary_report())
242
+ return result
243
+
244
+
245
+ def get_best_chunks_for_agent(
246
+ cv: CVSections,
247
+ primary_section: str,
248
+ context_sections: list[str] | None = None,
249
+ agent_token_budget: int = MAX_TOKENS_PER_CHUNK * 2,
250
+ ) -> str:
251
+ """
252
+ Compose optimal input string for an agent within a token budget.
253
+ primary_section fills the budget first; context_sections are appended
254
+ in order until the budget is exhausted.
255
+ """
256
+ parts: list[str] = []
257
+ remaining = agent_token_budget
258
+
259
+ primary_text = cv.get_section_text(primary_section, max_tokens=remaining)
260
+ parts.append(primary_text)
261
+ remaining -= _tokens(primary_text)
262
+
263
+ for ctx_sec in context_sections or []:
264
+ if remaining <= 100:
265
+ break
266
+ ctx_text = cv.get_section_text(
267
+ ctx_sec, max_tokens=min(remaining, MAX_TOKENS_PER_CHUNK)
268
+ )
269
+ parts.append(f"\n\n--- [CONTEXTE : {ctx_sec.upper()}] ---\n{ctx_text}")
270
+ remaining -= _tokens(ctx_text)
271
+
272
+ return "\n\n".join(parts)
273
+
274
+
275
+ # ── Backward-compatible interfaces ────────────────────────────────────────────
276
+
277
+
278
+ def chunk_cv_by_sections(full_text: str) -> dict[str, str]:
279
+ """
280
+ Legacy dict interface used by the current orchestrator.
281
+ Returns {section_name: joined_text, 'full_text': full_text}.
282
+ """
283
+ cv = chunk_cv(full_text)
284
+ out: dict[str, str] = {"full_text": full_text}
285
+ for sec, chunks in cv.chunks_by_section.items():
286
+ out[sec] = "\n\n".join(c.full_text for c in chunks)
287
+ return out
288
+
289
+
290
+ def get_section_or_full(
291
+ sections: dict[str, str],
292
+ section_name: str,
293
+ max_chars: int = MAX_CHARS,
294
+ ) -> str:
295
+ """
296
+ Legacy helper used by the current orchestrator.
297
+ Retrieves section text, falling back to full_text, truncated to max_chars.
298
+ """
299
+ content = sections.get(section_name, "")
300
+ if len(content) < 100:
301
+ content = sections.get("full_text", "")
302
+ return _truncate_chars(content, max_chars)
303
+
304
+
305
+ # ── Internal helpers ──────────────────────────────────────────────────────────
306
+
307
+
308
+ def _tokens(text: str) -> int:
309
+ return max(1, int(len(text) / CHARS_PER_TOKEN))
310
+
311
+
312
+ def _truncate(text: str, max_tokens: int) -> str:
313
+ return _truncate_chars(text, int(max_tokens * CHARS_PER_TOKEN))
314
+
315
+
316
+ def _truncate_chars(text: str, max_chars: int) -> str:
317
+ if len(text) <= max_chars:
318
+ return text
319
+ return text[:max_chars] + "\n\n[… TRONQUÉ — dépasse la fenêtre de contexte …]"
320
+
321
+
322
+ def _window(text: str, max_tokens: int) -> str:
323
+ return _truncate(text, max_tokens)
324
+
325
+
326
+ def _detect_boundaries(lines: list[str]) -> list[tuple[int, str]]:
327
+ boundaries: list[tuple[int, str]] = []
328
+ seen_at: dict[str, int] = {}
329
+
330
+ for i, line in enumerate(lines):
331
+ stripped = line.strip()
332
+ if not stripped or len(stripped) > 80:
333
+ continue
334
+ for section_name, patterns in SECTION_PATTERNS.items():
335
+ for pattern in patterns:
336
+ if re.search(pattern, stripped):
337
+ last = seen_at.get(section_name, -999)
338
+ if i - last > 5:
339
+ boundaries.append((i, section_name))
340
+ seen_at[section_name] = i
341
+ break
342
+
343
+ boundaries.sort(key=lambda x: x[0])
344
+ return boundaries
345
+
346
+
347
+ def _slice_sections(
348
+ lines: list[str],
349
+ boundaries: list[tuple[int, str]],
350
+ ) -> dict[str, str]:
351
+ raw: dict[str, str] = {}
352
+ n = len(boundaries)
353
+
354
+ for idx, (start_line, section_name) in enumerate(boundaries):
355
+ end_line = boundaries[idx + 1][0] if idx + 1 < n else len(lines)
356
+ content = "\n".join(lines[start_line:end_line]).strip()
357
+ if not content:
358
+ continue
359
+ if section_name in raw:
360
+ raw[section_name] += "\n\n" + content
361
+ else:
362
+ raw[section_name] = content
363
+
364
+ return raw
365
+
366
+
367
+ def _adaptive_chunk(section_name: str, raw_text: str) -> list[Chunk]:
368
+ """Split raw_text into Chunks, respecting MAX_CHARS."""
369
+ if len(raw_text) <= MAX_CHARS:
370
+ return [
371
+ Chunk(
372
+ section=section_name,
373
+ index=0,
374
+ total_chunks=1,
375
+ text=raw_text,
376
+ token_estimate=_tokens(raw_text),
377
+ is_overflow=False,
378
+ )
379
+ ]
380
+
381
+ logger.info(
382
+ "[Chunking] Section '%s' (%d chars). Splitting adaptively.",
383
+ section_name,
384
+ len(raw_text),
385
+ )
386
+
387
+ if section_name == "experiences":
388
+ blocks = _split_by_experience_blocks(raw_text)
389
+ else:
390
+ blocks = _split_by_paragraphs(raw_text)
391
+
392
+ normalised = _normalise_blocks(blocks)
393
+
394
+ chunks: list[Chunk] = []
395
+ prev_tail = ""
396
+
397
+ for i, block in enumerate(normalised):
398
+ preceding = _make_context_header(prev_tail) if prev_tail else ""
399
+ chunks.append(
400
+ Chunk(
401
+ section=section_name,
402
+ index=i,
403
+ total_chunks=len(normalised),
404
+ text=block,
405
+ token_estimate=_tokens(block),
406
+ preceding_context=preceding,
407
+ is_overflow=True,
408
+ )
409
+ )
410
+ prev_tail = block[-OVERLAP_CHARS:] if len(block) > OVERLAP_CHARS else block
411
+
412
+ return chunks
413
+
414
+
415
+ def _split_by_experience_blocks(text: str) -> list[str]:
416
+ """Split on lines that look like experience anchors (caps title or year)."""
417
+ ANCHOR = re.compile(
418
+ r"(?m)^(?:"
419
+ r"[A-ZÁÀÂÉÈÊÎÏÔÙÛÜ][^\n]{5,60}(?:[-–|@•]|chez|at)\s*\S"
420
+ r"|.*\b(19|20)\d{2}\b.*"
421
+ r")$"
422
+ )
423
+ positions = [m.start() for m in ANCHOR.finditer(text)]
424
+
425
+ if len(positions) < 2:
426
+ return _split_by_paragraphs(text)
427
+
428
+ blocks: list[str] = []
429
+ if positions[0] > 0:
430
+ blocks.append(text[: positions[0]].strip())
431
+ for i, pos in enumerate(positions):
432
+ end = positions[i + 1] if i + 1 < len(positions) else len(text)
433
+ blocks.append(text[pos:end].strip())
434
+
435
+ return [b for b in blocks if b]
436
+
437
+
438
+ def _split_by_paragraphs(text: str) -> list[str]:
439
+ paragraphs = re.split(r"\n{2,}", text)
440
+ return [p.strip() for p in paragraphs if p.strip()]
441
+
442
+
443
+ def _normalise_blocks(blocks: list[str]) -> list[str]:
444
+ """Merge tiny blocks; hard-split oversized ones."""
445
+ merged: list[str] = []
446
+ buffer = ""
447
+ for block in blocks:
448
+ if len(buffer) + len(block) + 2 <= MAX_CHARS:
449
+ buffer = (buffer + "\n\n" + block).strip() if buffer else block
450
+ else:
451
+ if buffer:
452
+ merged.append(buffer)
453
+ buffer = block
454
+ if buffer:
455
+ merged.append(buffer)
456
+
457
+ result: list[str] = []
458
+ for block in merged:
459
+ if len(block) <= MAX_CHARS:
460
+ result.append(block)
461
+ else:
462
+ result.extend(_hard_split(block))
463
+ return result
464
+
465
+
466
+ def _hard_split(text: str) -> list[str]:
467
+ """Last-resort split on character count with newline-aware boundary."""
468
+ chunks: list[str] = []
469
+ start = 0
470
+ while start < len(text):
471
+ end = min(start + MAX_CHARS, len(text))
472
+ if end < len(text):
473
+ search_start = end - MAX_CHARS // 5
474
+ nl = text.rfind("\n", search_start, end)
475
+ if nl > search_start:
476
+ end = nl
477
+ chunks.append(text[start:end].strip())
478
+ start = max(start + 1, end - OVERLAP_CHARS)
479
+ return [c for c in chunks if c]
480
+
481
+
482
+ def _make_context_header(prev_tail: str) -> str:
483
+ lines = [l.strip() for l in prev_tail.splitlines() if l.strip()]
484
+ summary = " | ".join(lines[-3:]) if lines else prev_tail[:120]
485
+ return f"(Suite — contexte fin du bloc précédent) : {summary}"
utils/pdf_parser.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PDF Parser utility using PyMuPDF (fitz).
3
+ Extracts raw text from PDF files with page tracking.
4
+ """
5
+
6
+ import fitz # PyMuPDF
7
+
8
+
9
+ class PDFExtractionError(Exception):
10
+ """Custom exception for PDF extraction errors."""
11
+
12
+ pass
13
+
14
+
15
+ def extract_text_from_pdf(pdf_content: bytes) -> str:
16
+ """
17
+ Extract full text from a PDF file.
18
+
19
+ Raises:
20
+ PDFExtractionError: If the PDF is corrupted, empty, or cannot be read.
21
+ """
22
+ try:
23
+ doc = fitz.open(stream=pdf_content, filetype="pdf")
24
+ except Exception as e:
25
+ raise PDFExtractionError(f"Impossible d'ouvrir le PDF: {str(e)}")
26
+
27
+ if len(doc) == 0:
28
+ doc.close()
29
+ raise PDFExtractionError("Le PDF est vide (aucune page).")
30
+
31
+ full_text = []
32
+ for page_num in range(len(doc)):
33
+ try:
34
+ page = doc.load_page(page_num)
35
+ text = page.get_text("text")
36
+ if text.strip():
37
+ full_text.append(f"--- PAGE {page_num + 1} ---\n{text}")
38
+ except Exception as e:
39
+ raise PDFExtractionError(
40
+ f"Erreur lors de l'extraction de la page {page_num + 1}: {str(e)}"
41
+ )
42
+
43
+ doc.close()
44
+
45
+ if not full_text:
46
+ raise PDFExtractionError(
47
+ "Le PDF ne contient aucun texte extractible (scanné ou image uniquement)."
48
+ )
49
+
50
+ return "\n\n".join(full_text)
51
+
52
+
53
+ def extract_text_from_uploaded_file(uploaded_file) -> str:
54
+ """
55
+ Extract text from a Streamlit uploaded file object.
56
+
57
+ Raises:
58
+ PDFExtractionError: If extraction fails.
59
+ """
60
+ try:
61
+ pdf_bytes = uploaded_file.read()
62
+ except Exception as e:
63
+ raise PDFExtractionError(f"Impossible de lire le fichier: {str(e)}")
64
+
65
+ if len(pdf_bytes) == 0:
66
+ raise PDFExtractionError("Le fichier est vide.")
67
+
68
+ uploaded_file.seek(0) # Reset for potential re-read
69
+ return extract_text_from_pdf(pdf_bytes)
70
+
71
+
72
+ def get_page_count(pdf_content: bytes) -> int:
73
+ """Get the number of pages in a PDF."""
74
+ try:
75
+ doc = fitz.open(stream=pdf_content, filetype="pdf")
76
+ count = len(doc)
77
+ doc.close()
78
+ return count
79
+ except Exception:
80
+ return 0