rahim237cm Claude Opus 5.5 commited on
Commit
deb8048
·
1 Parent(s): c84fadf

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 CHANGED
@@ -37,20 +37,60 @@ DATABASE_URL=postgresql+asyncpg://jurix:jurix@localhost:5433/jurix_dev
37
  # differents.
38
  PGSSLMODE=require
39
 
40
- # ---- LLM Provider pour le Chat RAG ----
41
- # LLM_PROVIDER=mistral # mistral (recommandé, rapide, bilingue FR/EN) | gemini
 
 
 
 
42
  LLM_PROVIDER=mistral
43
 
44
- # ---- Mistral AI (LLM de génération du Chat RAG) ----
45
- # Clé API : https://console.mistral.ai
 
 
 
46
  MISTRAL_API_KEY=your-mistral-api-key-here
47
- MISTRAL_MODEL=ministral-8b-latest
48
-
49
- # ---- Groq (Routage d'intention ultra-rapide < 100 ms) ----
50
- # Clé API : https://console.groq.com
51
- INTENT_CLASSIFIER=groq # groq (recommandé) | local | gemini
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # EMBEDDING_PROVIDER=gemma # gemma (local, defaut) | gemini (API payante)
 
73
  # GEMMA_MODEL_DIR=models/embeddinggemma-300m-onnx
74
- # GEMMA_INTRA_OP_THREADS=0 # 0 = coeurs physiques moins un
 
75
 
76
  # ---- Extraction des PDF ----
77
- # L'extraction passe par Gemini multimodal : meme cle que la generation.
78
- # Pages envoyees par appel. La limite du modele est de 65 536 jetons EN SORTIE ;
 
 
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
- # EMBEDDING_DIM=768 # fixe : la colonne est en vector(768), toute
134
- # # autre valeur est refusee au demarrage
 
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
- # TITLE_TRIGRAM_THRESHOLD=0.5 # tolerance aux fautes sur les titres
141
- # RRF_K=60 # fusion des classements texte et semantique
 
 
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
 
README.md CHANGED
@@ -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 Gemini.
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
- | LLM | Gemini (`google-genai`) |
 
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 Gemini (Google AI Studio), pour le chat
 
 
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
- alembic upgrade head # schéma, extensions, index, triggers
 
 
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
- | `GEMINI_API_KEY` / `GEMINI_MODEL` | LLM des réponses. Modèles Flash actuels : `gemini-3.8-flash`, `3.7`, `3.6`, `3.5` |
 
 
 
 
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 Gemini ; il ajoute un appel facturé sur le chemin critique,
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 cellule doit
332
- citer ses articles ; un numéro cité sans article correspondant est renvoyé
 
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 Gemini.
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
  ```
app/api/routes/categories.py CHANGED
@@ -140,9 +140,9 @@ async def list_categories(
140
  ```json
141
  [
142
  {
143
- "id": 1,
144
- "name": "Droit Civil",
145
- "description": "Civil law",
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": 1,
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": 1,
480
- "category_name": "Droit Civil",
481
  "law_count": 45,
482
  "percentage": 26.47
483
  },
484
  {
485
- "category_id": 2,
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
- "1": "Droit Civil",
536
- "2": "Droit Pénal",
537
- "3": "Droit Commercial"
538
  }
539
  ```
540
 
@@ -576,9 +576,9 @@ async def get_name_to_id_mapping(
576
  **Example Response:**
577
  ```json
578
  {
579
- "Droit Civil": 1,
580
- "Droit Pénal": 2,
581
- "Droit Commercial": 3
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
 
app/models/law.py CHANGED
@@ -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 (12 catégories camerounaises)
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 12 catégories de droit camerounais:
51
- 1. Droit Constitutionnel
52
- 2. Droit Civil
53
- 3. Droit Pénal
54
- 4. Droit Commercial OHADA
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
app/schemas/search.py CHANGED
@@ -384,8 +384,8 @@ class SearchResponse(BaseModel):
384
  "type": "code",
385
  "language": "fr",
386
  "status": "published",
387
- "category_id": 3,
388
- "category_name": "Droit Commercial OHADA",
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": {
app/services/category_service.py CHANGED
@@ -362,7 +362,7 @@ class CategoryService:
362
  Dictionary mapping category IDs to names
363
 
364
  Example:
365
- {1: "Droit Civil", 2: "Droit Pénal", ...}
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": 1, "Droit Pénal": 2, ...}
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")
scripts/eval/generate_eval_set.py CHANGED
@@ -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
- flag = " ⚠️" if item["rejected"] else ""
 
 
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}… |"
scripts/seed_database.py CHANGED
@@ -1,4 +1,14 @@
1
- """Seed database with sample Cameroonian laws for testing."""
 
 
 
 
 
 
 
 
 
 
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
- SAMPLE_CATEGORIES = [
16
- {"id": 1, "name": "Droit Civil", "description": "Droit civil camerounais"},
17
- {"id": 2, "name": "Droit Commercial OHADA", "description": "Droit commercial OHADA"},
18
- {"id": 3, "name": "Droit Pénal", "description": "Droit pénal camerounais"},
19
- {"id": 4, "name": "Droit Administratif", "description": "Droit administratif"},
20
- {"id": 5, "name": "Droit du Travail", "description": "Code du travail"},
21
- ]
22
 
23
  SAMPLE_LAWS = [
24
  {
25
  "reference": "LOI-2024-001",
26
  "title": "Code Civil Camerounais - Livre 1",
27
  "type": "Loi",
28
- "category_id": 1,
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
- "category_id": 2,
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
- "category_id": 3,
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
- # Create categories (skip if exist)
168
  print("\n[2/4] Creating categories...")
169
  from sqlalchemy import select
170
- result = await session.execute(select(Category))
171
- existing_categories = result.scalars().all()
172
-
173
- if not existing_categories:
174
- for cat_data in SAMPLE_CATEGORIES:
175
- category = Category(**cat_data)
176
- session.add(category)
177
- await session.commit()
178
- print(f" Created {len(SAMPLE_CATEGORIES)} categories")
179
- else:
180
- print(f" Skipped (already exist: {len(existing_categories)} categories)")
 
 
 
 
 
 
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
tests/test_core/test_env_example.py ADDED
@@ -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] == []