Spaces:
Runtime error
docs(config): .env.example aligne sur Settings, seed aux 14 domaines, README a jour
Browse files.env.example :
- chaque reglage de Mistral et de Groq y figure, avec les plafonds verifies
du palier gratuit ; MISTRAL_MODEL suit le defaut de config.py (14b) ;
INTENT_CLASSIFIER n'annonce plus « local » ;
- plus aucun commentaire en fin de ligne : docker --env-file le garde dans
la valeur, et « groq # groq | local » etait refuse au demarrage ;
- l'extraction des PDF annonce Docling, son moteur par defaut.
tests/test_core/test_env_example.py verifie que chaque cle est un reglage
(Settings ignore en silence une cle inconnue), qu'aucune ligne ne porte de
commentaire, que les valeurs actives se chargent et que chaque reglage des
fournisseurs est documente.
scripts/seed_database.py semait cinq categories aux identifiants fixes et
aux noms d'avant la migration 013 : sur une base migree, une quinzieme
categorie, et des lois rattachees a l'identifiant 1, 2 ou 3, quel qu'il
soit. Il cree les 14 domaines manquants par leur nom et rattache ses lois
par le nom.
Textes perimes : « 12 categories » du modele Category, exemples des routes
de categories et du schema de recherche. generate_eval_set.py ne plantait
plus apres une generation entiere sur la cle `rejected`.
README : Mistral et Groq avec leurs quotas reels et leur comportement en
cas de saturation, la procedure de reclassement, Alembic qui exige
DATABASE_URL, les marqueurs groq_live et mistral_live et la garde reseau.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- .env.example +64 -17
- README.md +84 -11
- app/api/routes/categories.py +15 -15
- app/models/law.py +6 -14
- app/schemas/search.py +2 -2
- app/services/category_service.py +2 -2
- scripts/eval/generate_eval_set.py +3 -1
- scripts/seed_database.py +41 -24
- tests/test_core/test_env_example.py +77 -0
|
@@ -37,20 +37,60 @@ DATABASE_URL=postgresql+asyncpg://jurix:jurix@localhost:5433/jurix_dev
|
|
| 37 |
# differents.
|
| 38 |
PGSSLMODE=require
|
| 39 |
|
| 40 |
-
# ----
|
| 41 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
LLM_PROVIDER=mistral
|
| 43 |
|
| 44 |
-
# ---- Mistral AI
|
| 45 |
-
#
|
|
|
|
|
|
|
|
|
|
| 46 |
MISTRAL_API_KEY=your-mistral-api-key-here
|
| 47 |
-
MISTRAL_MODEL=ministral-
|
| 48 |
-
|
| 49 |
-
# --
|
| 50 |
-
#
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
GROQ_API_KEY=your-groq-api-key-here
|
|
|
|
|
|
|
|
|
|
| 53 |
GROQ_MODEL=qwen/qwen3.8-27b
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
# ---- Gemini API (optionnel) ----
|
| 56 |
# GEMINI_API_KEY=your-gemini-api-key-here
|
|
@@ -69,13 +109,17 @@ GROQ_MODEL=qwen/qwen3.8-27b
|
|
| 69 |
# Apres une migration de dimension ou un changement de fournisseur :
|
| 70 |
# python scripts/regenerate_embeddings.py --all
|
| 71 |
# python scripts/regenerate_embeddings.py --reindex
|
| 72 |
-
#
|
|
|
|
| 73 |
# GEMMA_MODEL_DIR=models/embeddinggemma-300m-onnx
|
| 74 |
-
#
|
|
|
|
| 75 |
|
| 76 |
# ---- Extraction des PDF ----
|
| 77 |
-
#
|
| 78 |
-
#
|
|
|
|
|
|
|
| 79 |
# a ~2400 caracteres par page, 20 pages en produisent ~13 000. Le palier gratuit
|
| 80 |
# plafonne a 20 appels par jour : agrandir le lot economise des appels.
|
| 81 |
# PDF_EXTRACTION_PAGES_PER_CALL=20
|
|
@@ -130,15 +174,18 @@ ALLOWED_ORIGINS=https://jurix.vercel.app
|
|
| 130 |
# Ils etaient absents de ce fichier alors qu'ils pilotent tout le classement.
|
| 131 |
# Attention : config.py pose `extra = "ignore"`, donc une cle mal orthographiee
|
| 132 |
# ici est ignoree EN SILENCE.
|
| 133 |
-
#
|
| 134 |
-
#
|
|
|
|
| 135 |
# GEMINI_TIMEOUT_S=120
|
| 136 |
# RERANK_ENABLED=true
|
| 137 |
# RERANK_LLM_ENABLED=false
|
| 138 |
# RERANK_LLM_TOP_N=20
|
| 139 |
# RERANK_LLM_TIMEOUT_S=4.0
|
| 140 |
-
#
|
| 141 |
-
#
|
|
|
|
|
|
|
| 142 |
# TEXT_WEIGHT=0.4
|
| 143 |
# SEMANTIC_WEIGHT=0.6
|
| 144 |
|
|
|
|
| 37 |
# differents.
|
| 38 |
PGSSLMODE=require
|
| 39 |
|
| 40 |
+
# ---- Generation du chat (LLM_PROVIDER) ----
|
| 41 |
+
# mistral (defaut : rapide, bilingue FR/EN) ou gemini.
|
| 42 |
+
#
|
| 43 |
+
# Aucune ligne de ce fichier ne porte de commentaire a sa suite : docker
|
| 44 |
+
# --env-file le garderait DANS la valeur (INTENT_CLASSIFIER deviendrait
|
| 45 |
+
# « groq # groq | local », refuse au demarrage).
|
| 46 |
LLM_PROVIDER=mistral
|
| 47 |
|
| 48 |
+
# ---- Mistral AI ----
|
| 49 |
+
# Cle API : https://console.mistral.ai
|
| 50 |
+
# Palier gratuit (verifie le 07/10/2026) : 30 requetes par minute pour
|
| 51 |
+
# ministral-14b, 188 pour ministral-8b. Une reponse coupee est completee une
|
| 52 |
+
# fois ; un 429 est rejoue, puis passe au modele de secours.
|
| 53 |
MISTRAL_API_KEY=your-mistral-api-key-here
|
| 54 |
+
MISTRAL_MODEL=ministral-14b-latest
|
| 55 |
+
# Modele essaye quand le principal reste en 429. Vide : pas de secours.
|
| 56 |
+
# MISTRAL_MODEL_SECOURS=ministral-8b-latest
|
| 57 |
+
# Delai minimal d'une reponse entiere (il croit avec le budget de jetons) ;
|
| 58 |
+
# en flux, delai maximal entre deux morceaux.
|
| 59 |
+
# MISTRAL_TIMEOUT_S=60
|
| 60 |
+
# MISTRAL_REQUETES_PAR_SECONDE=0.5
|
| 61 |
+
# MISTRAL_REQUETES_PAR_SECONDE_SECOURS=3.0
|
| 62 |
+
# Generations simultanees au plus.
|
| 63 |
+
# MISTRAL_CONCURRENCE=4
|
| 64 |
+
# Attente totale acceptee (limiteur et 429) avant de repondre « sature,
|
| 65 |
+
# reessayez » (503).
|
| 66 |
+
# MISTRAL_ATTENTE_MAX_S=20
|
| 67 |
+
# Jetons de la suite demandee quand une reponse est coupee.
|
| 68 |
+
# MISTRAL_JETONS_SUITE=2048
|
| 69 |
+
|
| 70 |
+
# ---- Groq : classement d'intention et classement des lois ----
|
| 71 |
+
# Cle API : https://console.groq.com
|
| 72 |
+
# Palier gratuit, PAR MODELE : 30 requetes et 8 000 jetons par minute,
|
| 73 |
+
# 1 000 requetes et 200 000 jetons par jour. Un quota epuise ferme le modele
|
| 74 |
+
# jusqu'a l'echeance : le chat traite alors chaque message comme une question
|
| 75 |
+
# de droit, et le reclassement s'arrete en disant quand reprendre.
|
| 76 |
GROQ_API_KEY=your-groq-api-key-here
|
| 77 |
+
# groq (defaut) ou llm (le modele du chat : un appel de plus par question).
|
| 78 |
+
INTENT_CLASSIFIER=groq
|
| 79 |
+
# Modele du classement d'intention (chaque message du chat).
|
| 80 |
GROQ_MODEL=qwen/qwen3.8-27b
|
| 81 |
+
# Modele du classement des lois : son quota est distinct de celui du chat.
|
| 82 |
+
GROQ_MODEL_CLASSEMENT=openai/gpt-oss-120b
|
| 83 |
+
# GROQ_TIMEOUT_S=10
|
| 84 |
+
# Au-dela, le message est traite comme une question de droit.
|
| 85 |
+
# GROQ_INTENTION_TIMEOUT_S=3
|
| 86 |
+
# GROQ_REQUETES_PAR_MINUTE=30
|
| 87 |
+
# GROQ_RAFALE=5
|
| 88 |
+
# GROQ_JETONS_PAR_MINUTE=8000
|
| 89 |
+
# GROQ_JETONS_ENTREE_PAR_MINUTE=7000
|
| 90 |
+
# GROQ_REQUETES_PAR_JOUR=1000
|
| 91 |
+
# GROQ_JETONS_PAR_JOUR=200000
|
| 92 |
+
# Attente imposee par un 429 au-dela de laquelle c'est un quota epuise.
|
| 93 |
+
# GROQ_ATTENTE_MAX_429_S=120
|
| 94 |
|
| 95 |
# ---- Gemini API (optionnel) ----
|
| 96 |
# GEMINI_API_KEY=your-gemini-api-key-here
|
|
|
|
| 109 |
# Apres une migration de dimension ou un changement de fournisseur :
|
| 110 |
# python scripts/regenerate_embeddings.py --all
|
| 111 |
# python scripts/regenerate_embeddings.py --reindex
|
| 112 |
+
# gemma (local, defaut) ou gemini (API payante).
|
| 113 |
+
# EMBEDDING_PROVIDER=gemma
|
| 114 |
# GEMMA_MODEL_DIR=models/embeddinggemma-300m-onnx
|
| 115 |
+
# 0 = coeurs physiques moins un.
|
| 116 |
+
# GEMMA_INTRA_OP_THREADS=0
|
| 117 |
|
| 118 |
# ---- Extraction des PDF ----
|
| 119 |
+
# docling (defaut) : en local, gratuit, OCR de toutes les pages. gemini :
|
| 120 |
+
# l'API multimodale payante, reglee par les PDF_EXTRACTION_* ci-dessous.
|
| 121 |
+
# PDF_EXTRACTION_ENGINE=docling
|
| 122 |
+
# Pages envoyees par appel a Gemini. La limite du modele est de 65 536 jetons EN SORTIE ;
|
| 123 |
# a ~2400 caracteres par page, 20 pages en produisent ~13 000. Le palier gratuit
|
| 124 |
# plafonne a 20 appels par jour : agrandir le lot economise des appels.
|
| 125 |
# PDF_EXTRACTION_PAGES_PER_CALL=20
|
|
|
|
| 174 |
# Ils etaient absents de ce fichier alors qu'ils pilotent tout le classement.
|
| 175 |
# Attention : config.py pose `extra = "ignore"`, donc une cle mal orthographiee
|
| 176 |
# ici est ignoree EN SILENCE.
|
| 177 |
+
# Fixe : la colonne est en vector(768), toute autre valeur est refusee au
|
| 178 |
+
# demarrage.
|
| 179 |
+
# EMBEDDING_DIM=768
|
| 180 |
# GEMINI_TIMEOUT_S=120
|
| 181 |
# RERANK_ENABLED=true
|
| 182 |
# RERANK_LLM_ENABLED=false
|
| 183 |
# RERANK_LLM_TOP_N=20
|
| 184 |
# RERANK_LLM_TIMEOUT_S=4.0
|
| 185 |
+
# Tolerance aux fautes sur les titres.
|
| 186 |
+
# TITLE_TRIGRAM_THRESHOLD=0.5
|
| 187 |
+
# Fusion des classements texte et semantique.
|
| 188 |
+
# RRF_K=60
|
| 189 |
# TEXT_WEIGHT=0.4
|
| 190 |
# SEMANTIC_WEIGHT=0.6
|
| 191 |
|
|
@@ -4,7 +4,7 @@ API de la plateforme juridique camerounaise JuriX.
|
|
| 4 |
|
| 5 |
Recherche et question-réponse sur un corpus de textes officiels : recherche
|
| 6 |
plein texte et sémantique, extraction OCR des PDF scannés, découpage par
|
| 7 |
-
article, réponses citées via
|
| 8 |
|
| 9 |
## Pile technique
|
| 10 |
|
|
@@ -15,7 +15,8 @@ article, réponses citées via Gemini.
|
|
| 15 |
| Recherche plein texte | `tsvector` / `websearch_to_tsquery`, index GIN, triggers |
|
| 16 |
| Recherche sémantique | `pgvector` + index HNSW, embeddings EmbeddingGemma 768 dim. calculés en local (Gemini en option) |
|
| 17 |
| Cache | tables `query_cache` et `embedding_cache` |
|
| 18 |
-
|
|
|
|
|
| 19 |
| Extraction PDF | Docling en local, OCR pleine page (Gemini en option) |
|
| 20 |
| Tâches de fond | `BackgroundTasks` FastAPI |
|
| 21 |
|
|
@@ -26,7 +27,9 @@ d'attente sont assurés par PostgreSQL et par le serveur applicatif.
|
|
| 26 |
|
| 27 |
- Python 3.11
|
| 28 |
- PostgreSQL 16 avec les extensions `vector` et `pg_trgm`
|
| 29 |
-
- Une clé API
|
|
|
|
|
|
|
| 30 |
- Le modèle EmbeddingGemma sur disque (voir plus bas), pour la recherche
|
| 31 |
sémantique
|
| 32 |
- Pour ingérer des PDF : Docling et torch (`requirements-ingestion.txt`), un GPU
|
|
@@ -52,7 +55,9 @@ pip install -r requirements-dev.txt # + outils de test
|
|
| 52 |
|
| 53 |
cp .env.example .env # puis renseigner les valeurs
|
| 54 |
|
| 55 |
-
|
|
|
|
|
|
|
| 56 |
python scripts/create_admin.py # premier compte administrateur
|
| 57 |
|
| 58 |
uvicorn app.main:app --reload
|
|
@@ -101,13 +106,70 @@ Toutes les variables de `.env.example` sont réellement lues par
|
|
| 101 |
| Variable | Rôle |
|
| 102 |
|---|---|
|
| 103 |
| `DATABASE_URL` | `postgresql+asyncpg://…` — le driver asyncpg est obligatoire |
|
| 104 |
-
| `
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
| `EMBEDDING_PROVIDER` | `gemma` (défaut, local et gratuit) ou `gemini` (API payante). En changer impose de régénérer les vecteurs |
|
| 106 |
| `GEMMA_MODEL_DIR` | Dossier du modèle EmbeddingGemma (défaut `models/embeddinggemma-300m-onnx`) |
|
| 107 |
| `PDF_EXTRACTION_PAGES_PER_CALL` | Pages envoyées par appel d'extraction (défaut 20) |
|
| 108 |
| `SECRET_KEY` | Signature JWT. **Obligatoire hors développement** : l'application refuse de démarrer si la valeur du dépôt est conservée |
|
| 109 |
| `ADMIN_EMAIL` / `ADMIN_PASSWORD` | Valeurs par défaut de `scripts/create_admin.py` |
|
| 110 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
## Authentification
|
| 112 |
|
| 113 |
JWT porteur, trois rôles : `user`, `admin`, `superadmin`.
|
|
@@ -295,7 +357,7 @@ restauration ou un changement de fournisseur, les vecteurs manquants ou
|
|
| 295 |
d'un autre modèle doivent être régénérés :
|
| 296 |
|
| 297 |
```bash
|
| 298 |
-
alembic upgrade head
|
| 299 |
python scripts/regenerate_embeddings.py --all --batch-size 16 # reprenable
|
| 300 |
python scripts/regenerate_embeddings.py --reindex # index en masse
|
| 301 |
```
|
|
@@ -310,7 +372,7 @@ Les chunks remontés passent par `app/services/reranker.py` avant d'être tronqu
|
|
| 310 |
et mis en cache. L'étage 1 est lexical (numéro d'article demandé, densité des
|
| 311 |
termes, expression exacte, titre de loi, pénalité des formules d'exécution) :
|
| 312 |
sans dépendance, sans réseau, actif par défaut. L'étage 2 fait noter les 20
|
| 313 |
-
meilleurs chunks par
|
| 314 |
reste désactivé (`RERANK_LLM_ENABLED`) et dégrade toujours vers l'étage 1.
|
| 315 |
|
| 316 |
### Explication et comparaison
|
|
@@ -328,12 +390,13 @@ n'y a rien à converser :
|
|
| 328 |
(`app/services/comparison_service.py`). **Une recherche par sujet**, l'une
|
| 329 |
après l'autre : une requête unique mélangeant les deux termes rendait six
|
| 330 |
articles d'un régime contre trois de l'autre, en ratant le bloc qui définissait
|
| 331 |
-
le second. La sortie est contrainte par un schéma JSON où chaque
|
| 332 |
-
citer ses articles
|
|
|
|
| 333 |
dans `unmatched_citations` plutôt qu'avalé, et le texte intégral des sources
|
| 334 |
accompagne chaque cellule pour que le lecteur vérifie lui-même.
|
| 335 |
|
| 336 |
-
Aucun des deux n'est mis en cache : chaque appel dépense un appel
|
| 337 |
|
| 338 |
### Mesure
|
| 339 |
|
|
@@ -365,14 +428,24 @@ triggers, extensions) n'existent que dans les migrations, jamais dans
|
|
| 365 |
realite n'en a jamais compte qu'un. -->
|
| 366 |
|
| 367 |
export TEST_DATABASE_URL=postgresql+asyncpg://jurix:jurix@localhost:5433/jurix_test
|
| 368 |
-
pytest # tout
|
| 369 |
pytest -m "not integration" # sans base
|
|
|
|
|
|
|
| 370 |
pytest --cov=app --cov-report=term-missing
|
| 371 |
```
|
| 372 |
|
| 373 |
Sans base joignable, les tests qui en dépendent sont **ignorés avec la commande
|
| 374 |
à lancer** — jamais silencieusement verts.
|
| 375 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
## Structure
|
| 377 |
|
| 378 |
```
|
|
|
|
| 4 |
|
| 5 |
Recherche et question-réponse sur un corpus de textes officiels : recherche
|
| 6 |
plein texte et sémantique, extraction OCR des PDF scannés, découpage par
|
| 7 |
+
article, réponses citées via Mistral, classements par Groq.
|
| 8 |
|
| 9 |
## Pile technique
|
| 10 |
|
|
|
|
| 15 |
| Recherche plein texte | `tsvector` / `websearch_to_tsquery`, index GIN, triggers |
|
| 16 |
| Recherche sémantique | `pgvector` + index HNSW, embeddings EmbeddingGemma 768 dim. calculés en local (Gemini en option) |
|
| 17 |
| Cache | tables `query_cache` et `embedding_cache` |
|
| 18 |
+
| Réponses du chat, explications, comparaisons | Mistral `ministral-14b` (API REST, `LLM_PROVIDER=mistral`) ; Gemini en option |
|
| 19 |
+
| Classement d'intention et des lois | Groq : `qwen3.8-27b` (chat), `gpt-oss-120b` (lois) |
|
| 20 |
| Extraction PDF | Docling en local, OCR pleine page (Gemini en option) |
|
| 21 |
| Tâches de fond | `BackgroundTasks` FastAPI |
|
| 22 |
|
|
|
|
| 27 |
|
| 28 |
- Python 3.11
|
| 29 |
- PostgreSQL 16 avec les extensions `vector` et `pg_trgm`
|
| 30 |
+
- Une clé API Mistral (console.mistral.ai), pour le chat
|
| 31 |
+
- Une clé API Groq (console.groq.com), pour le classement des messages et des
|
| 32 |
+
lois
|
| 33 |
- Le modèle EmbeddingGemma sur disque (voir plus bas), pour la recherche
|
| 34 |
sémantique
|
| 35 |
- Pour ingérer des PDF : Docling et torch (`requirements-ingestion.txt`), un GPU
|
|
|
|
| 55 |
|
| 56 |
cp .env.example .env # puis renseigner les valeurs
|
| 57 |
|
| 58 |
+
# Schéma, extensions, index, triggers. Alembic ne lit PAS le .env : la base
|
| 59 |
+
# visée est nommée à chaque commande, et elle est affichée avant de migrer.
|
| 60 |
+
DATABASE_URL=postgresql://jurix:jurix@localhost:5433/jurix_dev alembic upgrade head
|
| 61 |
python scripts/create_admin.py # premier compte administrateur
|
| 62 |
|
| 63 |
uvicorn app.main:app --reload
|
|
|
|
| 106 |
| Variable | Rôle |
|
| 107 |
|---|---|
|
| 108 |
| `DATABASE_URL` | `postgresql+asyncpg://…` — le driver asyncpg est obligatoire |
|
| 109 |
+
| `LLM_PROVIDER` | `mistral` (défaut) ou `gemini` : le modèle des réponses, explications et comparaisons |
|
| 110 |
+
| `MISTRAL_API_KEY` / `MISTRAL_MODEL` | Modèle des réponses (`ministral-14b-latest`) ; `MISTRAL_MODEL_SECOURS` (`ministral-8b-latest`) sert quand le premier reste saturé |
|
| 111 |
+
| `GROQ_API_KEY` | Classement d'intention (`GROQ_MODEL`) et classement des lois (`GROQ_MODEL_CLASSEMENT`), deux modèles pour deux quotas |
|
| 112 |
+
| `INTENT_CLASSIFIER` | `groq` (défaut) ou `llm` (le modèle du chat, un appel de plus par question) |
|
| 113 |
+
| `GEMINI_API_KEY` / `GEMINI_MODEL` | Seulement si `LLM_PROVIDER=gemini` ou `EMBEDDING_PROVIDER=gemini` |
|
| 114 |
| `EMBEDDING_PROVIDER` | `gemma` (défaut, local et gratuit) ou `gemini` (API payante). En changer impose de régénérer les vecteurs |
|
| 115 |
| `GEMMA_MODEL_DIR` | Dossier du modèle EmbeddingGemma (défaut `models/embeddinggemma-300m-onnx`) |
|
| 116 |
| `PDF_EXTRACTION_PAGES_PER_CALL` | Pages envoyées par appel d'extraction (défaut 20) |
|
| 117 |
| `SECRET_KEY` | Signature JWT. **Obligatoire hors développement** : l'application refuse de démarrer si la valeur du dépôt est conservée |
|
| 118 |
| `ADMIN_EMAIL` / `ADMIN_PASSWORD` | Valeurs par défaut de `scripts/create_admin.py` |
|
| 119 |
|
| 120 |
+
### Fournisseurs et quotas
|
| 121 |
+
|
| 122 |
+
Paliers gratuits vérifiés le 07/10/2026 :
|
| 123 |
+
|
| 124 |
+
| Fournisseur | Modèle | Plafonds |
|
| 125 |
+
|---|---|---|
|
| 126 |
+
| Mistral | `ministral-14b` | 30 requêtes par minute |
|
| 127 |
+
| Mistral | `ministral-8b` (secours) | 188 requêtes par minute |
|
| 128 |
+
| Groq, **par modèle** | `qwen3.8-27b`, `gpt-oss-120b` | 30 requêtes et 8 000 jetons par minute, 1 000 requêtes et 200 000 jetons par jour |
|
| 129 |
+
|
| 130 |
+
Chaque appel passe par un **limiteur** (`app/core/limiteur.py`) qui fait
|
| 131 |
+
attendre avant d'envoyer, plutôt que de collectionner les refus.
|
| 132 |
+
|
| 133 |
+
- **Mistral.** Un 429 est rejoué (`Retry-After`), puis le modèle de secours
|
| 134 |
+
prend le relais ; au-delà de 20 s d'attente, l'utilisateur reçoit « service
|
| 135 |
+
saturé, réessayez » (503), jamais « quota épuisé ». Une réponse coupée — budget
|
| 136 |
+
de jetons, ou flux rompu en cours de route — est **complétée** par une suite ;
|
| 137 |
+
la mention « réponse interrompue » ne reste que si la suite échoue. Chaque
|
| 138 |
+
appel journalise modèle, fin, jetons et durée.
|
| 139 |
+
- **Groq, classement d'intention.** Consignes courtes, verdicts en cache. Quand
|
| 140 |
+
Groq ne peut pas répondre dans les temps (quota, attente, panne), le message
|
| 141 |
+
est traité comme une question de droit : c'est l'erreur sans conséquence. Mesure
|
| 142 |
+
sur 122 messages étiquetés : 121 justes, aucune question de droit perdue
|
| 143 |
+
(`python -m scripts.eval.evaluer_intention --limite 122`).
|
| 144 |
+
- **Groq, classement des lois.** Par lots (40 titres par requête). Jamais de
|
| 145 |
+
catégorie inventée : un classement impossible laisse la catégorie vide et le
|
| 146 |
+
dit dans `processing_error`.
|
| 147 |
+
|
| 148 |
+
### Reclassement des lois
|
| 149 |
+
|
| 150 |
+
Les catégories se recalculent par lots, en deux temps, avec relecture entre
|
| 151 |
+
les deux (`scripts/maintenance/reclassify_domains.py`) :
|
| 152 |
+
|
| 153 |
+
```bash
|
| 154 |
+
# 1. Verdicts dans data/reclassement/verdicts.jsonl, rien en base. Reprenable :
|
| 155 |
+
# une relance saute les lois déjà classées. Code 4 = quota du jour épuisé,
|
| 156 |
+
# l'heure de reprise est affichée.
|
| 157 |
+
python scripts/maintenance/reclassify_domains.py classer --extrait 0 --lot 40
|
| 158 |
+
python scripts/maintenance/reclassify_domains.py classer --incertains --extrait 500 --lot 15
|
| 159 |
+
|
| 160 |
+
# 2. Simulation : rapport, changements.csv et a_revoir.csv. À RELIRE.
|
| 161 |
+
python scripts/maintenance/reclassify_domains.py appliquer --dry-run
|
| 162 |
+
|
| 163 |
+
# 3. Écriture en base, catégories déjà posées comprises.
|
| 164 |
+
python scripts/maintenance/reclassify_domains.py appliquer --force
|
| 165 |
+
```
|
| 166 |
+
|
| 167 |
+
Une loi « à revoir » ou sous le seuil de confiance (`--seuil`, 0,6) n'est jamais
|
| 168 |
+
appliquée. Le nom de la catégorie est figé dans le texte vectorisé
|
| 169 |
+
(`embed_text`) : les lois de `changements.csv` sont à redécouper puis
|
| 170 |
+
revectoriser. Pour une ingestion de masse, `ingest_corpus.py --sans-classement`
|
| 171 |
+
laisse le classement à ce script : une requête pour 40 lois au lieu de 40.
|
| 172 |
+
|
| 173 |
## Authentification
|
| 174 |
|
| 175 |
JWT porteur, trois rôles : `user`, `admin`, `superadmin`.
|
|
|
|
| 357 |
d'un autre modèle doivent être régénérés :
|
| 358 |
|
| 359 |
```bash
|
| 360 |
+
DATABASE_URL=postgresql://jurix:jurix@localhost:5433/jurix_dev alembic upgrade head
|
| 361 |
python scripts/regenerate_embeddings.py --all --batch-size 16 # reprenable
|
| 362 |
python scripts/regenerate_embeddings.py --reindex # index en masse
|
| 363 |
```
|
|
|
|
| 372 |
et mis en cache. L'étage 1 est lexical (numéro d'article demandé, densité des
|
| 373 |
termes, expression exacte, titre de loi, pénalité des formules d'exécution) :
|
| 374 |
sans dépendance, sans réseau, actif par défaut. L'étage 2 fait noter les 20
|
| 375 |
+
meilleurs chunks par le modèle du chat ; il ajoute un appel sur le chemin critique,
|
| 376 |
reste désactivé (`RERANK_LLM_ENABLED`) et dégrade toujours vers l'étage 1.
|
| 377 |
|
| 378 |
### Explication et comparaison
|
|
|
|
| 390 |
(`app/services/comparison_service.py`). **Une recherche par sujet**, l'une
|
| 391 |
après l'autre : une requête unique mélangeant les deux termes rendait six
|
| 392 |
articles d'un régime contre trois de l'autre, en ratant le bloc qui définissait
|
| 393 |
+
le second. La sortie est contrainte par un schéma JSON **strict** où chaque
|
| 394 |
+
cellule doit citer ses articles (une grille coupée est redemandée une fois, au
|
| 395 |
+
double du budget) ; un numéro cité sans article correspondant est renvoyé
|
| 396 |
dans `unmatched_citations` plutôt qu'avalé, et le texte intégral des sources
|
| 397 |
accompagne chaque cellule pour que le lecteur vérifie lui-même.
|
| 398 |
|
| 399 |
+
Aucun des deux n'est mis en cache : chaque appel dépense un appel au modèle.
|
| 400 |
|
| 401 |
### Mesure
|
| 402 |
|
|
|
|
| 428 |
realite n'en a jamais compte qu'un. -->
|
| 429 |
|
| 430 |
export TEST_DATABASE_URL=postgresql+asyncpg://jurix:jurix@localhost:5433/jurix_test
|
| 431 |
+
pytest # tout, sans aucun appel reseau
|
| 432 |
pytest -m "not integration" # sans base
|
| 433 |
+
pytest -m groq_live # appels reels a Groq (une poignee)
|
| 434 |
+
pytest -m mistral_live # appels reels a Mistral (cinq)
|
| 435 |
pytest --cov=app --cov-report=term-missing
|
| 436 |
```
|
| 437 |
|
| 438 |
Sans base joignable, les tests qui en dépendent sont **ignorés avec la commande
|
| 439 |
à lancer** — jamais silencieusement verts.
|
| 440 |
|
| 441 |
+
**Aucun appel réel par défaut.** Les clés de Gemini, Groq et Mistral sont
|
| 442 |
+
remplacées par des clés factices, et une garde réseau fait échouer tout test qui
|
| 443 |
+
tenterait de joindre Groq ou Mistral : une suite complète vidait le quota
|
| 444 |
+
gratuit de Groq. Les tests marqués `groq_live` ou `mistral_live` lèvent la garde
|
| 445 |
+
du seul service visé, avec les vraies clés, et ne tournent que sur demande
|
| 446 |
+
(`-m`). `JURIX_E2E=1` garde les vraies clés et ouvre tout, pour un essai de bout
|
| 447 |
+
en bout délibéré.
|
| 448 |
+
|
| 449 |
## Structure
|
| 450 |
|
| 451 |
```
|
|
@@ -140,9 +140,9 @@ async def list_categories(
|
|
| 140 |
```json
|
| 141 |
[
|
| 142 |
{
|
| 143 |
-
"id":
|
| 144 |
-
"name": "Droit Civil",
|
| 145 |
-
"description": "
|
| 146 |
"created_at": "2024-01-01T00:00:00",
|
| 147 |
"law_count": 45
|
| 148 |
}
|
|
@@ -424,8 +424,8 @@ async def get_category_stats(
|
|
| 424 |
**Example Response:**
|
| 425 |
```json
|
| 426 |
{
|
| 427 |
-
"category_id":
|
| 428 |
-
"category_name": "Droit Civil",
|
| 429 |
"law_count": 45,
|
| 430 |
"percentage": 26.47
|
| 431 |
}
|
|
@@ -476,14 +476,14 @@ async def get_all_category_stats(
|
|
| 476 |
```json
|
| 477 |
[
|
| 478 |
{
|
| 479 |
-
"category_id":
|
| 480 |
-
"category_name": "Droit Civil",
|
| 481 |
"law_count": 45,
|
| 482 |
"percentage": 26.47
|
| 483 |
},
|
| 484 |
{
|
| 485 |
-
"category_id":
|
| 486 |
-
"category_name": "Droit Pénal",
|
| 487 |
"law_count": 38,
|
| 488 |
"percentage": 22.35
|
| 489 |
}
|
|
@@ -532,9 +532,9 @@ async def get_id_to_name_mapping(
|
|
| 532 |
**Example Response:**
|
| 533 |
```json
|
| 534 |
{
|
| 535 |
-
"
|
| 536 |
-
"
|
| 537 |
-
"
|
| 538 |
}
|
| 539 |
```
|
| 540 |
|
|
@@ -576,9 +576,9 @@ async def get_name_to_id_mapping(
|
|
| 576 |
**Example Response:**
|
| 577 |
```json
|
| 578 |
{
|
| 579 |
-
"Droit Civil":
|
| 580 |
-
"Droit Pénal":
|
| 581 |
-
"Droit
|
| 582 |
}
|
| 583 |
```
|
| 584 |
|
|
|
|
| 140 |
```json
|
| 141 |
[
|
| 142 |
{
|
| 143 |
+
"id": 1007,
|
| 144 |
+
"name": "Droit Civil et Procédure Civile",
|
| 145 |
+
"description": "Personnes, obligations, contrats, instances civiles et voies d'exécution",
|
| 146 |
"created_at": "2024-01-01T00:00:00",
|
| 147 |
"law_count": 45
|
| 148 |
}
|
|
|
|
| 424 |
**Example Response:**
|
| 425 |
```json
|
| 426 |
{
|
| 427 |
+
"category_id": 1007,
|
| 428 |
+
"category_name": "Droit Civil et Procédure Civile",
|
| 429 |
"law_count": 45,
|
| 430 |
"percentage": 26.47
|
| 431 |
}
|
|
|
|
| 476 |
```json
|
| 477 |
[
|
| 478 |
{
|
| 479 |
+
"category_id": 1007,
|
| 480 |
+
"category_name": "Droit Civil et Procédure Civile",
|
| 481 |
"law_count": 45,
|
| 482 |
"percentage": 26.47
|
| 483 |
},
|
| 484 |
{
|
| 485 |
+
"category_id": 1006,
|
| 486 |
+
"category_name": "Droit Pénal et Procédure Pénale",
|
| 487 |
"law_count": 38,
|
| 488 |
"percentage": 22.35
|
| 489 |
}
|
|
|
|
| 532 |
**Example Response:**
|
| 533 |
```json
|
| 534 |
{
|
| 535 |
+
"1007": "Droit Civil et Procédure Civile",
|
| 536 |
+
"1006": "Droit Pénal et Procédure Pénale",
|
| 537 |
+
"1010": "Droit des Affaires, Banque et OHADA"
|
| 538 |
}
|
| 539 |
```
|
| 540 |
|
|
|
|
| 576 |
**Example Response:**
|
| 577 |
```json
|
| 578 |
{
|
| 579 |
+
"Droit Civil et Procédure Civile": 1007,
|
| 580 |
+
"Droit Pénal et Procédure Pénale": 1006,
|
| 581 |
+
"Droit des Affaires, Banque et OHADA": 1010
|
| 582 |
}
|
| 583 |
```
|
| 584 |
|
|
@@ -4,7 +4,7 @@ Modèles SQLAlchemy pour les lois, articles et catégories.
|
|
| 4 |
Ce module définit les modèles de base de données pour le système JuriX:
|
| 5 |
- Law: Documents juridiques (lois, décrets, ordonnances)
|
| 6 |
- Article: Articles individuels d'une loi
|
| 7 |
-
- Category: Catégories juridiques (
|
| 8 |
|
| 9 |
Features v2.1:
|
| 10 |
- Détection automatique de langue (language, language_confidence)
|
|
@@ -47,19 +47,11 @@ class Category(Base):
|
|
| 47 |
"""
|
| 48 |
Modèle pour les catégories juridiques.
|
| 49 |
|
| 50 |
-
Représente les
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
5. Droit du Travail
|
| 56 |
-
6. Droit Fiscal
|
| 57 |
-
7. Droit Administratif
|
| 58 |
-
8. Droit Foncier
|
| 59 |
-
9. Droit de la Famille
|
| 60 |
-
10. Droit de l'Environnement
|
| 61 |
-
11. Droit International
|
| 62 |
-
12. Droit des Affaires
|
| 63 |
|
| 64 |
Attributes:
|
| 65 |
id: Identifiant unique
|
|
|
|
| 4 |
Ce module définit les modèles de base de données pour le système JuriX:
|
| 5 |
- Law: Documents juridiques (lois, décrets, ordonnances)
|
| 6 |
- Article: Articles individuels d'une loi
|
| 7 |
+
- Category: Catégories juridiques (les 14 domaines canoniques, migration 013)
|
| 8 |
|
| 9 |
Features v2.1:
|
| 10 |
- Détection automatique de langue (language, language_confidence)
|
|
|
|
| 47 |
"""
|
| 48 |
Modèle pour les catégories juridiques.
|
| 49 |
|
| 50 |
+
Représente les 14 domaines canoniques du droit camerounais, fixés par
|
| 51 |
+
la migration 013 et repris dans legal_domain_classifier.CANONICAL_DOMAINS
|
| 52 |
+
(un test vérifie l'accord) : du Droit Constitutionnel à l'Éducation,
|
| 53 |
+
Recherche, Culture et Médias. Une loi s'y rattache PAR LE NOM du domaine,
|
| 54 |
+
jamais par une position.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
Attributes:
|
| 57 |
id: Identifiant unique
|
|
@@ -384,8 +384,8 @@ class SearchResponse(BaseModel):
|
|
| 384 |
"type": "code",
|
| 385 |
"language": "fr",
|
| 386 |
"status": "published",
|
| 387 |
-
"category_id":
|
| 388 |
-
"category_name": "Droit
|
| 389 |
"relevance_score": 0.92,
|
| 390 |
"matched_articles": [],
|
| 391 |
"highlights": {
|
|
|
|
| 384 |
"type": "code",
|
| 385 |
"language": "fr",
|
| 386 |
"status": "published",
|
| 387 |
+
"category_id": 1010,
|
| 388 |
+
"category_name": "Droit des Affaires, Banque et OHADA",
|
| 389 |
"relevance_score": 0.92,
|
| 390 |
"matched_articles": [],
|
| 391 |
"highlights": {
|
|
@@ -362,7 +362,7 @@ class CategoryService:
|
|
| 362 |
Dictionary mapping category IDs to names
|
| 363 |
|
| 364 |
Example:
|
| 365 |
-
{
|
| 366 |
"""
|
| 367 |
try:
|
| 368 |
logger.debug("🗺️ Fetching category ID → Name mapping")
|
|
@@ -387,7 +387,7 @@ class CategoryService:
|
|
| 387 |
Dictionary mapping category names to IDs
|
| 388 |
|
| 389 |
Example:
|
| 390 |
-
{"Droit Civil":
|
| 391 |
"""
|
| 392 |
try:
|
| 393 |
logger.debug("🗺️ Fetching category Name → ID mapping")
|
|
|
|
| 362 |
Dictionary mapping category IDs to names
|
| 363 |
|
| 364 |
Example:
|
| 365 |
+
{1007: "Droit Civil et Procédure Civile", 1006: "Droit Pénal et Procédure Pénale", ...}
|
| 366 |
"""
|
| 367 |
try:
|
| 368 |
logger.debug("🗺️ Fetching category ID → Name mapping")
|
|
|
|
| 387 |
Dictionary mapping category names to IDs
|
| 388 |
|
| 389 |
Example:
|
| 390 |
+
{"Droit Civil et Procédure Civile": 1007, "Droit Pénal et Procédure Pénale": 1006, ...}
|
| 391 |
"""
|
| 392 |
try:
|
| 393 |
logger.debug("🗺️ Fetching category Name → ID mapping")
|
|
@@ -208,7 +208,9 @@ def _write_review_file(path: Path, items: List[Dict[str, Any]],
|
|
| 208 |
for index, item in enumerate(items):
|
| 209 |
source = articles[item["expected_article_id"]]
|
| 210 |
excerpt = " ".join(source["content"].split())[:140]
|
| 211 |
-
|
|
|
|
|
|
|
| 212 |
lines.append(
|
| 213 |
f"| {index} | {item['lexical_overlap']:.2f}{flag} | {item['question']} | "
|
| 214 |
f"{source['reference']} art. {source['number']} — {excerpt}… |"
|
|
|
|
| 208 |
for index, item in enumerate(items):
|
| 209 |
source = articles[item["expected_article_id"]]
|
| 210 |
excerpt = " ".join(source["content"].split())[:140]
|
| 211 |
+
# `rejected` est retire des items retenus avant l'ecriture du jeu :
|
| 212 |
+
# le lire en dur levait KeyError apres une generation entiere.
|
| 213 |
+
flag = " ⚠️" if item.get("rejected") else ""
|
| 214 |
lines.append(
|
| 215 |
f"| {index} | {item['lexical_overlap']:.2f}{flag} | {item['question']} | "
|
| 216 |
f"{source['reference']} art. {source['number']} — {excerpt}… |"
|
|
@@ -1,4 +1,14 @@
|
|
| 1 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
import sys
|
|
@@ -11,21 +21,20 @@ from datetime import date
|
|
| 11 |
|
| 12 |
from app.core.database import AsyncSessionLocal, Base, engine
|
| 13 |
from app.models.law import Article, Category, Law
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
]
|
| 22 |
|
| 23 |
SAMPLE_LAWS = [
|
| 24 |
{
|
| 25 |
"reference": "LOI-2024-001",
|
| 26 |
"title": "Code Civil Camerounais - Livre 1",
|
| 27 |
"type": "Loi",
|
| 28 |
-
"
|
| 29 |
"language": "fr",
|
| 30 |
"status": "published",
|
| 31 |
"publication_date": date(2024, 1, 15),
|
|
@@ -73,7 +82,7 @@ Article 9. La responsabilité civile engage celui qui cause un dommage à autrui
|
|
| 73 |
"reference": "LOI-OHADA-2023-015",
|
| 74 |
"title": "Acte Uniforme OHADA - Droit Commercial",
|
| 75 |
"type": "Acte Uniforme",
|
| 76 |
-
"
|
| 77 |
"language": "fr",
|
| 78 |
"status": "published",
|
| 79 |
"publication_date": date(2023, 6, 20),
|
|
@@ -115,7 +124,7 @@ Article 7. Les formes de sociétés commerciales reconnues sont : la société e
|
|
| 115 |
"reference": "LOI-2016-007",
|
| 116 |
"title": "Code Pénal Camerounais",
|
| 117 |
"type": "Loi",
|
| 118 |
-
"
|
| 119 |
"language": "fr",
|
| 120 |
"status": "published",
|
| 121 |
"publication_date": date(2016, 7, 12),
|
|
@@ -164,25 +173,33 @@ async def seed_database():
|
|
| 164 |
print(" Tables created successfully")
|
| 165 |
|
| 166 |
async with AsyncSessionLocal() as session:
|
| 167 |
-
#
|
| 168 |
print("\n[2/4] Creating categories...")
|
| 169 |
from sqlalchemy import select
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
|
| 182 |
# Create laws with articles
|
| 183 |
print("\n[3/4] Creating laws and articles...")
|
| 184 |
for law_data in SAMPLE_LAWS:
|
| 185 |
articles_data = law_data.pop("articles")
|
|
|
|
|
|
|
| 186 |
law = Law(**law_data)
|
| 187 |
session.add(law)
|
| 188 |
await session.flush() # Get law ID
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Seed database with sample Cameroonian laws for testing.
|
| 3 |
+
|
| 4 |
+
Les categories sont les 14 domaines canoniques, crees PAR LEUR NOM s'ils
|
| 5 |
+
manquent, et les lois s'y rattachent par le nom. L'ancienne version semait
|
| 6 |
+
cinq categories aux identifiants fixes (1 a 5) et aux noms d'avant la
|
| 7 |
+
migration 013 : sur une base migree, elle en ajoutait une quinzieme, et ses
|
| 8 |
+
lois pointaient sur la categorie qui portait par hasard l'identifiant 1, 2
|
| 9 |
+
ou 3 — le defaut meme que la resolution par le nom a corrige dans le
|
| 10 |
+
pipeline.
|
| 11 |
+
"""
|
| 12 |
|
| 13 |
import asyncio
|
| 14 |
import sys
|
|
|
|
| 21 |
|
| 22 |
from app.core.database import AsyncSessionLocal, Base, engine
|
| 23 |
from app.models.law import Article, Category, Law
|
| 24 |
+
from app.services.legal_domain_classifier import (
|
| 25 |
+
AFFAIRES,
|
| 26 |
+
CANONICAL_DOMAINS,
|
| 27 |
+
CIVIL,
|
| 28 |
+
DESCRIPTIONS_DOMAINES,
|
| 29 |
+
PENAL,
|
| 30 |
+
)
|
|
|
|
| 31 |
|
| 32 |
SAMPLE_LAWS = [
|
| 33 |
{
|
| 34 |
"reference": "LOI-2024-001",
|
| 35 |
"title": "Code Civil Camerounais - Livre 1",
|
| 36 |
"type": "Loi",
|
| 37 |
+
"domaine": CIVIL,
|
| 38 |
"language": "fr",
|
| 39 |
"status": "published",
|
| 40 |
"publication_date": date(2024, 1, 15),
|
|
|
|
| 82 |
"reference": "LOI-OHADA-2023-015",
|
| 83 |
"title": "Acte Uniforme OHADA - Droit Commercial",
|
| 84 |
"type": "Acte Uniforme",
|
| 85 |
+
"domaine": AFFAIRES,
|
| 86 |
"language": "fr",
|
| 87 |
"status": "published",
|
| 88 |
"publication_date": date(2023, 6, 20),
|
|
|
|
| 124 |
"reference": "LOI-2016-007",
|
| 125 |
"title": "Code Pénal Camerounais",
|
| 126 |
"type": "Loi",
|
| 127 |
+
"domaine": PENAL,
|
| 128 |
"language": "fr",
|
| 129 |
"status": "published",
|
| 130 |
"publication_date": date(2016, 7, 12),
|
|
|
|
| 173 |
print(" Tables created successfully")
|
| 174 |
|
| 175 |
async with AsyncSessionLocal() as session:
|
| 176 |
+
# Les 14 domaines, par leur nom : seuls les manquants sont crees.
|
| 177 |
print("\n[2/4] Creating categories...")
|
| 178 |
from sqlalchemy import select
|
| 179 |
+
presents = {
|
| 180 |
+
nom.lower(): identifiant
|
| 181 |
+
for nom, identifiant in (await session.execute(select(Category.name, Category.id))).all()
|
| 182 |
+
}
|
| 183 |
+
crees = 0
|
| 184 |
+
for ordre, nom in enumerate(CANONICAL_DOMAINS):
|
| 185 |
+
if nom.lower() not in presents:
|
| 186 |
+
session.add(Category(
|
| 187 |
+
name=nom, description=DESCRIPTIONS_DOMAINES[nom], display_order=ordre,
|
| 188 |
+
))
|
| 189 |
+
crees += 1
|
| 190 |
+
await session.commit()
|
| 191 |
+
ids = {
|
| 192 |
+
nom.lower(): identifiant
|
| 193 |
+
for nom, identifiant in (await session.execute(select(Category.name, Category.id))).all()
|
| 194 |
+
}
|
| 195 |
+
print(f" {crees} created, {len(CANONICAL_DOMAINS) - crees} already present")
|
| 196 |
|
| 197 |
# Create laws with articles
|
| 198 |
print("\n[3/4] Creating laws and articles...")
|
| 199 |
for law_data in SAMPLE_LAWS:
|
| 200 |
articles_data = law_data.pop("articles")
|
| 201 |
+
# Rattachement par le NOM du domaine, jamais par une position.
|
| 202 |
+
law_data["category_id"] = ids[law_data.pop("domaine").lower()]
|
| 203 |
law = Law(**law_data)
|
| 204 |
session.add(law)
|
| 205 |
await session.flush() # Get law ID
|
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
.env.example et Settings s'accordent.
|
| 3 |
+
|
| 4 |
+
`Settings` pose `extra = "ignore"` : une cle mal orthographiee dans le .env
|
| 5 |
+
est ignoree EN SILENCE, et le reglage garde sa valeur par defaut. Un exemple
|
| 6 |
+
qui documente une cle inexistante — ou qui en oublie une — est donc la source
|
| 7 |
+
la plus probable d'un reglage qui « ne marche pas ». Ces tests tournent sans
|
| 8 |
+
base ni reseau.
|
| 9 |
+
|
| 10 |
+
Usage:
|
| 11 |
+
pytest tests/test_core/test_env_example.py -v
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import re
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
from app.core.config import Settings
|
| 18 |
+
|
| 19 |
+
EXEMPLE = Path(__file__).resolve().parents[2] / ".env.example"
|
| 20 |
+
|
| 21 |
+
# Lues ailleurs que par Settings : legitimes dans l'exemple.
|
| 22 |
+
EXTERNES = {
|
| 23 |
+
"PGSSLMODE": "lue par libpq et asyncpg, pas par l'application",
|
| 24 |
+
"ADMIN_EMAIL": "lue par scripts/create_admin.py",
|
| 25 |
+
"ADMIN_PASSWORD": "lue par scripts/create_admin.py",
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
_LIGNE = re.compile(r"^(#\s*)?([A-Z][A-Z0-9_]+)=(.*)$")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _cles():
|
| 32 |
+
"""(numero de ligne, commentee ?, cle, valeur) de chaque affectation."""
|
| 33 |
+
for numero, ligne in enumerate(EXEMPLE.read_text(encoding="utf-8").splitlines(), start=1):
|
| 34 |
+
correspondance = _LIGNE.match(ligne.strip())
|
| 35 |
+
if correspondance:
|
| 36 |
+
commentee, cle, valeur = correspondance.groups()
|
| 37 |
+
yield numero, bool(commentee), cle, valeur
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_chaque_cle_est_un_reglage():
|
| 41 |
+
inconnues = [
|
| 42 |
+
(numero, cle) for numero, _, cle, _ in _cles()
|
| 43 |
+
if cle not in Settings.model_fields and cle not in EXTERNES
|
| 44 |
+
]
|
| 45 |
+
|
| 46 |
+
assert inconnues == []
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_aucun_commentaire_en_fin_de_ligne():
|
| 50 |
+
"""docker --env-file garde « groq # groq | local » tout entier comme valeur."""
|
| 51 |
+
fautives = [(numero, cle) for numero, _, cle, valeur in _cles() if re.search(r"\s#", valeur)]
|
| 52 |
+
|
| 53 |
+
assert fautives == []
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_les_valeurs_actives_se_chargent():
|
| 57 |
+
"""Chaque valeur active de l'exemple est acceptee par Settings."""
|
| 58 |
+
valeurs = {
|
| 59 |
+
cle: valeur for _, commentee, cle, valeur in _cles()
|
| 60 |
+
if not commentee and cle in Settings.model_fields
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
reglages = Settings(**valeurs)
|
| 64 |
+
|
| 65 |
+
assert reglages.INTENT_CLASSIFIER == "groq"
|
| 66 |
+
assert reglages.LLM_PROVIDER == "mistral"
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def test_les_reglages_des_fournisseurs_sont_documentes():
|
| 70 |
+
"""Chaque reglage Mistral et Groq apparait dans l'exemple, actif ou commente."""
|
| 71 |
+
documentees = {cle for _, _, cle, _ in _cles()}
|
| 72 |
+
reglages = [
|
| 73 |
+
cle for cle in Settings.model_fields
|
| 74 |
+
if cle.startswith(("MISTRAL_", "GROQ_")) or cle in ("INTENT_CLASSIFIER", "LLM_PROVIDER")
|
| 75 |
+
]
|
| 76 |
+
|
| 77 |
+
assert [cle for cle in reglages if cle not in documentees] == []
|