GitLab CI commited on
Commit ·
3f637a5
1
Parent(s): db5376e
Deploy from GitLab CI - 6509512f
Browse files- .devcontainer/devcontainer.json +33 -0
- .dockerignore +30 -0
- .gitignore +12 -0
- Dockerfile +36 -12
- README.md +360 -15
- agents/__init__.py +15 -0
- agents/base_agent.py +226 -0
- agents/experience_agent.py +50 -0
- agents/quality_control_agent.py +70 -0
- agents/scoring_agent.py +87 -0
- agents/skills_education_agent.py +65 -0
- agents/summary_validation_agent.py +61 -0
- agents/table_generator_agent.py +62 -0
- app.py +1379 -0
- docker-compose.prod.yml +22 -0
- models/__init__.py +1 -0
- models/schemas.py +222 -0
- orchestrator.py +227 -0
- prompts/__init__.py +1 -0
- prompts/templates.py +307 -0
- pyproject.toml +59 -0
- requirements-dev.txt +16 -0
- requirements.txt +11 -3
- tests/__init__.py +0 -0
- tests/test_basic.py +39 -0
- utils/__init__.py +7 -0
- utils/cache.py +85 -0
- utils/chunking.py +485 -0
- utils/pdf_parser.py +80 -0
.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 |
-
|
|
|
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
WORKDIR /app
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
-
|
| 12 |
-
COPY
|
| 13 |
|
| 14 |
-
|
|
|
|
| 15 |
|
| 16 |
-
|
|
|
|
| 17 |
|
| 18 |
-
|
|
|
|
| 19 |
|
| 20 |
-
|
|
|
|
|
|
| 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
| 13 |
---
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
-
|
| 18 |
|
| 19 |
-
|
| 20 |
-
forums](https://discuss.streamlit.io).
|
|
|
|
| 1 |
+
# CV Evaluator — Système Multi-Agents d'Évaluation de CV
|
| 2 |
+
|
| 3 |
+
[](https://github.com/yacineberkani/cv_evaluator/actions/workflows/ci-cd.yml)
|
| 4 |
+
[](https://www.python.org/)
|
| 5 |
+
[](https://streamlit.io/)
|
| 6 |
+
[](https://www.langchain.com/)
|
| 7 |
+
[](https://www.docker.com/)
|
| 8 |
+
[](https://huggingface.co/spaces/yacineberkani/cv_evaluator)
|
| 9 |
+
[](./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 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|