--- title: JuriX API emoji: ⚖️ colorFrom: blue colorTo: indigo sdk: gradio app_file: app.py pinned: false --- # JuriX Backend API de la plateforme juridique camerounaise JuriX. Recherche et question-réponse sur un corpus de textes officiels : recherche plein texte et sémantique, extraction OCR des PDF scannés, découpage par article, réponses citées via Mistral, classements par Groq. ## Pile technique | Composant | Choix | |---|---| | API | FastAPI (Python 3.11) | | Base | PostgreSQL 16 + `pgvector` + `pg_trgm` | | Recherche plein texte | `tsvector` / `websearch_to_tsquery`, index GIN, triggers | | Recherche sémantique | `pgvector` + index HNSW, embeddings Voyage AI `voyage-4-large`, 1 024 dim. stockées en `halfvec` (Gemini en option) | | Cache | tables `query_cache` et `embedding_cache` | | Réponses du chat, explications, comparaisons | Mistral `ministral-14b` (API REST, `LLM_PROVIDER=mistral`) ; Gemini en option | | Classement d'intention et des lois | Groq : `qwen3.8-27b` (chat), `gpt-oss-120b` (lois) | | Extraction PDF | Docling en local, OCR pleine page (Gemini en option) | | Tâches de fond | `BackgroundTasks` FastAPI | Pas de Redis, pas de Meilisearch, pas de Celery : recherche, cache et files d'attente sont assurés par PostgreSQL et par le serveur applicatif. ## Prérequis - Python 3.11 - PostgreSQL 16 avec les extensions `vector` (pgvector 0.8 au moins : `halfvec` dès 0.7, le parcours itératif de l'index sous filtre dès 0.8) et `pg_trgm` - Une clé API Mistral (console.mistral.ai), pour le chat - Une clé API Groq (console.groq.com), pour le classement des messages et des lois - Une clé API Voyage AI (dash.voyageai.com), pour la recherche sémantique, sur un compte **avec moyen de paiement** (voir plus bas) - Pour ingérer des PDF : Docling et torch (`requirements-ingestion.txt`), un GPU CUDA de préférence Le plus simple pour la base : ```bash docker run -d --name jurix-pg -p 5433:5432 \ -e POSTGRES_USER=jurix -e POSTGRES_PASSWORD=jurix -e POSTGRES_DB=jurix_dev \ pgvector/pgvector:pg16 # La base de TEST vit sur le meme serveur, sous un autre nom : docker exec jurix-pg psql -U jurix -d jurix_dev -c "CREATE DATABASE jurix_test" ``` ## Installation ```bash python3.11 -m venv .venv && source .venv/bin/activate pip install -r requirements.txt -c constraints.txt # production, aux versions de l'image pip install -r requirements-dev.txt # + outils de test # L'image Docker installe EXACTEMENT les versions de constraints.txt. Après un # changement de dépendances (requirements.txt ou venv), régénérer puis commiter : bash scripts/figer_versions.sh cp .env.example .env # puis renseigner les valeurs # Schéma, extensions, index, triggers. Alembic ne lit PAS le .env : la base # visée est nommée à chaque commande, et elle est affichée avant de migrer. # Même forme d'URL que l'application (+asyncpg) : alembic/env.py importe # l'application, dont le moteur asynchrone refuse un postgresql:// nu, et # passe lui-même au pilote synchrone. DATABASE_URL=postgresql+asyncpg://jurix:jurix@localhost:5433/jurix_dev alembic upgrade head python scripts/create_admin.py # premier compte administrateur uvicorn app.main:app --reload ``` API sur `http://localhost:8000`, documentation interactive sur `/docs`. Le bouton **Authorize** de `/docs` fonctionne : il appelle `POST /api/v1/auth/login` avec le compte créé ci-dessus. ### Modèle de détection de langue `langdetect` + fastText. Le modèle fastText (131 Mo) n'est pas versionné : ```bash mkdir -p models/fasttext curl -L -o models/fasttext/lid.176.bin \ https://dl.fbaipublicfiles.com/fasttext/supervised-models/lid.176.bin ``` Sans lui, `POST /api/v1/language/detect` échoue ; le pipeline d'ingestion retombe sur une heuristique par mots vides. ### Clé Voyage (embeddings) La recherche sémantique vectorise chaque question par l'API Voyage AI (`voyage-4-large`). Le compte de la clé doit avoir un **moyen de paiement** : sans lui, Voyage bride à 3 requêtes et 10 000 jetons par minute, pour tout le site. Les 200 M premiers jetons sont gratuits ; une question en coûte une dizaine, le corpus entier environ 10 M. Sans clé valide, l'API démarre quand même — plein texte, chat et comptes fonctionnent — mais la recherche sémantique est coupée et `GET /api/v1/search/health` répond `degraded`. Si Voyage ne répond pas en 4 secondes, la question est servie par le plein texte seul, et cette réponse n'est pas mise en cache. ## Configuration Toutes les variables de `.env.example` sont réellement lues par `app/core/config.py`. Les principales : | Variable | Rôle | |---|---| | `DATABASE_URL` | `postgresql+asyncpg://…` — le driver asyncpg est obligatoire | | `LLM_PROVIDER` | `mistral` (défaut) ou `gemini` : le modèle des réponses, explications et comparaisons | | `MISTRAL_API_KEY` / `MISTRAL_MODEL` | Modèle des réponses, version épinglée (`ministral-14b-2512`) ; `MISTRAL_MODEL_SECOURS` (`ministral-8b-2512`) sert quand le premier reste saturé | | `GROQ_API_KEY` | Classement d'intention (`GROQ_MODEL`) et classement des lois (`GROQ_MODEL_CLASSEMENT`), deux modèles pour deux quotas | | `INTENT_CLASSIFIER` | `groq` (défaut) ou `llm` (le modèle du chat, un appel de plus par question) | | `GEMINI_API_KEY` / `GEMINI_MODEL` | Seulement si `LLM_PROVIDER=gemini` ou `EMBEDDING_PROVIDER=gemini` | | `VOYAGE_API_KEY` | Embeddings de la recherche sémantique (questions et corpus) | | `EMBEDDING_PROVIDER` | `voyage` (défaut) ou `gemini`. En changer impose de régénérer les vecteurs | | `VOYAGE_EMBEDDING_MODEL` | `voyage-4-large`. Il entre dans l'empreinte des vecteurs : en changer les déclare tous périmés | | `VOYAGE_TIMEOUT_QUESTION_S` / `VOYAGE_TIMEOUT_DOCUMENT_S` | Délai de chaque lecture pour une question (3 s ; la question attend au plus ce délai plus une seconde, puis passe au plein texte seul) et pour un lot de documents (120 s) | | `PDF_EXTRACTION_PAGES_PER_CALL` | Pages envoyées par appel d'extraction (défaut 20) | | `SECRET_KEY` | Signature JWT. **Obligatoire hors développement** : l'application refuse de démarrer si la valeur du dépôt est conservée | | `ADMIN_EMAIL` / `ADMIN_PASSWORD` | Valeurs par défaut de `scripts/create_admin.py` | ### Fournisseurs et quotas Paliers gratuits vérifiés le 07/10/2026 : | Fournisseur | Modèle | Plafonds | |---|---|---| | Mistral | `ministral-14b` | 30 requêtes par minute | | Mistral | `ministral-8b` (secours) | 188 requêtes par minute | | 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 | | Voyage AI (16/10/2026) | `voyage-4-large` | 200 M de jetons offerts ; sans moyen de paiement, 3 requêtes et 10 000 jetons par minute ; avec, 2 000 requêtes et 3 M de jetons par minute | Chaque appel à Mistral et à Groq passe par un **limiteur** (`app/core/limiteur.py`) qui fait attendre avant d'envoyer, plutôt que de collectionner les refus. Pas Voyage : une question ne peut pas attendre son tour. Sur un compte sans moyen de paiement (3 requêtes par minute), les questions au-delà reviennent en 429 et passent au plein texte seul. - **Mistral.** Un 429 est rejoué (`Retry-After`), puis le modèle de secours prend le relais ; au-delà de 20 s d'attente, l'utilisateur reçoit « service saturé, réessayez » (503), jamais « quota épuisé ». Une réponse coupée — budget de jetons, ou flux rompu en cours de route — est **complétée** par une suite ; la mention « réponse interrompue » ne reste que si la suite échoue. Chaque appel journalise modèle, fin, jetons et durée. - **Groq, classement d'intention.** Consignes courtes, verdicts en cache. Quand Groq ne peut pas répondre dans les temps (quota, attente, panne), le message est traité comme une question de droit : c'est l'erreur sans conséquence. Mesure sur 122 messages étiquetés : 121 justes, aucune question de droit perdue (`python -m scripts.eval.evaluer_intention --limite 122`). - **Groq, classement des lois.** Par lots (40 titres par requête). Jamais de catégorie inventée : un classement impossible laisse la catégorie vide et le dit dans `processing_error`. ### Reclassement des lois Les catégories se recalculent par lots, en deux temps, avec relecture entre les deux (`scripts/maintenance/reclassify_domains.py`) : ```bash # 1. Verdicts dans data/reclassement/verdicts.jsonl, rien en base. Reprenable : # une relance saute les lois déjà classées. Code 4 = quota du jour épuisé, # l'heure de reprise est affichée. python scripts/maintenance/reclassify_domains.py classer --extrait 0 --lot 40 python scripts/maintenance/reclassify_domains.py classer --incertains --extrait 500 --lot 15 # 2. Simulation de l'étape 3, à l'identique : rapport, changements.csv (toute # loi dont la catégorie changerait) et a_revoir.csv. À RELIRE. python scripts/maintenance/reclassify_domains.py appliquer --dry-run --force # 3. Écriture en base, catégories déjà posées comprises. python scripts/maintenance/reclassify_domains.py appliquer --force ``` Une loi « à revoir » ou sous le seuil de confiance (`--seuil`, 0,6) n'est jamais appliquée. Sans `--force`, une catégorie déjà posée (le choix d'un administrateur, peut-être) est gardée, et `changements.csv` le dit pour chaque loi concernée. Le nom de la catégorie est figé dans le texte vectorisé (`embed_text`) : les lois de `changements.csv` sont à redécouper puis revectoriser. Pour une ingestion de masse, `ingest_corpus.py --sans-classement` laisse le classement à ce script : une requête pour 40 lois au lieu de 40. ## Authentification JWT porteur, trois rôles : `user`, `admin`, `superadmin`. | Route | Usage | |---|---| | `POST /api/v1/auth/signup` | Inscription publique : nom, adresse, mot de passe | | `POST /api/v1/auth/google` | Connexion par Google Identity Services | | `POST /api/v1/auth/login` | Formulaire OAuth2 (utilisé par `/docs`) | | `POST /api/v1/auth/login/json` | JSON (utilisé par le front) | | `GET /api/v1/auth/me` | Compte associé au jeton, profil (`persona`) compris | | `PATCH /api/v1/auth/me` | Le profil du compte, seul champ modifiable en libre-service | **Le profil du compte** (`users.persona`, migration 015) suit l'utilisateur d'un appareil à l'autre. `PATCH /auth/me` accepte exactement `{"persona": "avocat"}` (casse et accents tolérés) ou `{"persona": null}` pour l'effacer ; tout autre champ, et `general`, répondent 422. Un refus de répondre n'est jamais enregistré sur le compte : il vaut pour la visite. Le profil n'entre pas dans le JWT, qui resterait périmé jusqu'à 30 jours. Règles côté front : - à la connexion, un profil enregistré sur le compte l'emporte ; un compte sans profil n'hérite pas de celui choisi dans le navigateur avant la connexion (la question sera reposée) ; - au démarrage, connecté : un envoi en attente est renvoyé ; sinon le compte fait foi, `null` compris ; - un changement explicite s'écrit dans le navigateur, puis sur le compte ; - la déconnexion efface le profil du navigateur (appareil partagé). Les écritures, les endpoints d'administration, l'OCR et l'upload exigent un rôle administrateur. L'inscription publique, elle, écrit `role: "user"` côté serveur : son schéma n'expose pas ce champ et refuse toute clé inconnue. **Deux durées de session.** 30 jours pour un compte ordinaire — l'intérêt d'un compte étant de retrouver ses conversations, être déconnecté toutes les 30 minutes le rendait inutilisable. 12 heures dès que le compte porte un rôle privilégié : les JWT sont sans état ici, `/logout` ne révoque rien, et il n'existe aucune liste de révocation. **Levier d'urgence :** `get_current_user` relit l'utilisateur en base à chaque requête et refuse un compte inactif. Désactiver un compte révoque donc ses jetons immédiatement — c'est le seul moyen d'annuler une session en cours sans changer `SECRET_KEY`, ce qui déconnecterait tout le monde. ### Connexion Google Pas de Firebase : le navigateur obtient un jeton d'identité de Google, le serveur le vérifie avec `google-auth`, puis émet le JWT maison. Une dépendance, zéro paquet npm, et **un seul fichier d'utilisateurs**. Console Google Cloud, une fois : écran de consentement *External*, périmètres `openid email profile` uniquement, puis un identifiant client OAuth de type *Web application* dont les origines JavaScript autorisées couvrent `http://localhost:5173`, `http://localhost:4173` et le domaine de production. Ni URI de redirection, ni client secret. Reporter l'identifiant dans `GOOGLE_CLIENT_ID` (backend) et `VITE_GOOGLE_CLIENT_ID` (frontend). `GOOGLE_CLIENT_ID` vide ⇒ `/auth/google` répond 503 et le bouton n'est pas rendu ; le reste fonctionne. Ce n'est pas une commodité : vérifier un jeton sans audience acceptrait n'importe quel jeton Google émis pour n'importe quelle application. Un compte créé par mot de passe qui se connecte ensuite par Google est **lié** au même compte, et garde son mot de passe. L'inverse est refusé en 409 : sans vérification d'adresse de notre côté, autoriser un mot de passe sur une adresse déjà rattachée à Google serait une prise de contrôle de compte. ### Mot de passe oublié **Il n'y a aucune infrastructure d'envoi d'e-mail**, donc ni vérification d'adresse, ni lien de réinitialisation. Deux conséquences assumées : - `is_verified` ne vaut `true` que lorsque Google a affirmé `email_verified`. Il ne conditionne rien. - La seule remise à zéro est administrative : `PUT /api/v1/admin/users/{id}` accepte un champ `password`. Pour un compte lié à Google, se reconnecter par Google fait office de récupération. ### Conversations et appartenance `Conversation.user_id` est le propriétaire ; `NULL` signifie « anonyme, appartient à qui détient le `session_id` ». Une conversation demandée par un autre compte répond **404**, jamais 403 — un 403 confirmerait l'existence du `session_id` et permettrait de les énumérer. Le chat reste utilisable **sans compte**. Une conversation anonyme reprise par un compte connecté lui est rattachée : on peut discuter, puis s'inscrire en gardant le fil. ### Profils (personas) La liste unique est dans `app/core/personas.py` : `citoyen`, `avocat`, `entrepreneur`, `étudiant`, plus `general` pour qui n'a pas dit qui il est. - `persona` absent, `null` ou vide vaut `general`. Avant, il valait `citoyen`, et un refus de répondre ne se distinguait pas d'un citoyen. La casse et les accents sont tolérés (`etudiant` désigne l'étudiant). - Le profil choisit le prompt de la réponse juridique, le registre des réponses conversationnelles (salutations, questions sur JuriX, hors-sujet) et les conseils du message « aucun résultat ». - **Une conversation garde un seul profil.** Un `session_id` existant envoyé avec un autre profil ouvre une nouvelle conversation (nouveau `session_id`, historique vide) ; l'ancienne n'est pas touchée. Le contrôle d'appartenance passe avant. - Le dernier événement du flux (`/rag/ask/stream`) porte `persona`, le profil effectif de la conversation. - Le serveur ne remplace jamais un profil absent par celui du compte : un « Ne pas répondre » vaut pour la visite. ## Chaîne d'ingestion ``` PDF → extraction Docling (OCR pleine page) → normalisation → découpage par article → embeddings Voyage (voyage-4-large) → tsvector (trigger) → published ``` Un document est créé en `processing`. Il passe à `published` en cas de succès, à `refused` avec `processing_error` en cas d'échec. Une page que l'extraction n'a pas pu lire ne bloque pas le document : il est publié sans elle, et sa liste part dans `processing_error`. **OCR sur toutes les pages, par Docling, en local.** 97 % des pages du corpus prc.cm sont des scans ; la couche texte que portent 55 % d'entre elles est l'OCR du scanner, bruité (« portantnomination deresponsables »), mesuré à environ 20 % de rappel. Docling l'ignore et relit l'image de chaque page (mode OCR pleine page), avec la mise en page et les tableaux sur le GPU s'il y en a un. `PDF_EXTRACTION_ENGINE=gemini` repasse sur l'API Gemini, payante. **Pagination.** L'extraction rend un marqueur `<>` par page physique ; chaque article porte la page où il commence. ### Ingérer le corpus Deux passes : l'extraction, longue et gourmande (Docling, GPU), puis l'indexation, qui ne charge jamais Docling et appelle Voyage pour les vecteurs. Toutes deux sont **reprenables** — une interruption, quelle qu'elle soit, ne coûte que le travail en cours, et relancer la même commande reprend où elle s'était arrêtée. ```bash pip install -r requirements-ingestion.txt # Docling, torch (voir le fichier) # Passe 1 — extraction, sans base. Une vingtaine d'heures pour les 13 970 pages. # Détachée : elle survit à la fermeture du terminal. setsid nohup .venv/bin/python scripts/extraire_corpus.py \ > data/ingestion/extraction.out 2>&1 & python scripts/extraire_corpus.py --etat # avancement, durée restante estimée touch data/ingestion/STOP # arrêt propre après le lot en cours # Passe 2 — indexation en base. Ne relit que l'extraction faite : un document # que la passe 1 n'a pas fini est laissé pour la prochaine exécution. python scripts/ingest_corpus.py # Enfin : vecteurs manquants éventuels (appels Voyage), puis index vectoriel # reconstruit en masse python scripts/regenerate_embeddings.py --all python scripts/regenerate_embeddings.py --reindex ``` - **Passe 1** (`scripts/extraire_corpus.py`) : chaque document est converti par lots de pages, chaque lot écrit en cache dès qu'il est fait (`data/ocr_cache/docling/`). Le travail se fait dans un processus enfant, recyclé régulièrement — la mémoire de Docling grossit au fil des pages. Si ce processus meurt, le lot en cours compte une tentative perdue ; au bout de trois, le lot est repris page à page, et seule la page qui échoue encore est abandonnée et signalée, sans bloquer le reste. Les petits documents passent d'abord ; aucun n'est exclu pour sa taille. Si `.env` ou les paquets changent pendant l'extraction, elle s'arrête plutôt que d'écrire dans un autre cache : relancer la commande. Suivi : `data/ingestion/extraction.log` et `progression.json`. - **Passe 2** (`scripts/ingest_corpus.py`) : une loi en échec passe `refused` ; la relance reprend les lois `refused`, `processing` et `pending`, et ignore les `published`. Journal : `data/ingestion/indexation.log`. - **Cache d'extraction.** Son empreinte porte les versions de Docling et du moteur OCR : changer de version, ou de moteur, extrait de nouveau dans un autre dossier, sans rien écraser. Les versions sont donc épinglées dans `requirements-ingestion.txt`. ## Découpage `app/utils/text_chunker.py` découpe le document en articles, puis `app/utils/chunk_refiner.py` classe chaque chunk et décide de son sort : | `kind` | vectorisé | pourquoi | |---|---|---| | `article` | oui | le texte normatif | | `legal_basis`, `preamble` | non | les visas ne répondent à aucune question | | `boilerplate` | non | « sera enregistré, publié au Journal Officiel », identique dans des milliers de décrets | | `roster` | un vecteur par liste | listes nominatives : le contenu garde tous les noms (affichage, plein texte), le vecteur n'en porte que le résumé ; une longue liste est coupée entre ses lignes | | `signature`, `annexe` | non, oui | la signature et ce qui suit le dernier article : lieu, date, signataire hors index ; une annexe reste un chunk à part entière | | `fragment` | non | moins de 120 caractères hors vrais articles (30 pour un article) : 62 % du corpus sous ce seuil sont des lignes de tableau ou de liste | | `table`, `continuation` | selon la taille | découpés aux alinéas au-delà de 3 000 caractères | **Rien n'est supprimé.** Un chunk non vectorisé reste en base, affichable et trouvable en recherche plein texte — il ne consomme simplement pas d'appel d'embedding et n'encombre pas les résultats sémantiques. `embed_text` est le texte réellement envoyé au modèle : le contenu **préfixé de l'en-tête du document** (référence, titre, date, catégorie, section, page). Sans lui, « Article 3.- La dépense résultant des présentes dispositions sera imputée sur le budget de l'État » est indistinguable des milliers d'articles identiques du corpus. `content` reste intact : ce qui est affiché et cité ne change pas. ## Recherche et RAG La recherche renvoie des **chunks** — un article, pas un document. `SearchResult` (niveau loi) reste exposé pour le front et est dérivé de ces chunks ; `SearchResponse.chunks` porte les articles eux-mêmes, avec leur numéro, leur section, leur page et leur contenu intégral. C'est ce que consomme le RAG, et c'est ce qui permet à une citation de pointer un `article_id`. Les embeddings font **1 024 dimensions**, stockées en `halfvec` (2 octets par composante) sous un index HNSW `halfvec_cosine_ops` posé sur la colonne. Ils viennent de l'API **Voyage AI** (`voyage-4-large`), choisie par mesure le 16/10/2026 sur le jeu relu de 102 questions et tout le corpus : MRR@20 de 0,758 contre 0,537 pour EmbeddingGemma, en français. Le corpus est vectorisé en `input_type: document`, les questions en `input_type: query`. `EMBEDDING_PROVIDER=gemini` repasse sur l'API Gemini, à 1 024 dimensions elle aussi : le schéma ne change pas. Le fournisseur est sondé au démarrage : une clé refusée y apparaît, et `GET /api/v1/search/health` répond `degraded` au lieu de laisser la recherche retomber en silence sur le plein texte. Une question n'attend Voyage que 4 secondes au plus (`VOYAGE_TIMEOUT_QUESTION_S` plus une seconde), en un seul essai : au-delà, ou en cas de panne, la recherche hybride répond par le plein texte seul, sans mettre cette réponse en cache. Une panne survenue APRÈS le démarrage (clé révoquée, compte bridé en 429) se lit dans `GET /api/v1/search/health`, champ `semantic_recent` : appels au fournisseur et échecs des 15 dernières minutes (une question servie par le cache ne compte pas), dernier motif court, issue de la sonde du démarrage. `status` passe à `degraded` à partir de 3 échecs représentant au moins 30 % des appels, ou tant qu'une sonde ratée au démarrage n'a été suivie d'aucun appel réussi. Le chat cherche en plein texte sur les mots-clés de la question, mais vectorise la question **entière** (`SearchRequest.semantic_query`). Mesuré par le chemin du chat sur les 102 questions relues, le bon article figure parmi les 8 extraits envoyés au modèle 92 % du temps en français et 87 % en anglais, contre 79 % et 72 % en vectorisant les mots-clés, et 61 % et 56 % avec EmbeddingGemma. Chaque vecteur porte l'empreinte du modèle qui l'a produit (`articles.embedding_model`). Après une migration de dimension, une restauration ou un changement de fournisseur, les vecteurs manquants ou d'un autre modèle doivent être régénérés : ```bash DATABASE_URL=postgresql+asyncpg://jurix:jurix@localhost:5433/jurix_dev alembic upgrade head python scripts/regenerate_embeddings.py --all # reprenable, appels Voyage python scripts/regenerate_embeddings.py --reindex # index en masse ``` Les vecteurs `voyage-4-large` de tout le corpus ont déjà été calculés par la mesure (`data/mesure_voyage`, hors git). Ils se chargent sans appel à l'API ; un article dont le texte a changé depuis l'instantané garde son vecteur à NULL, que `--all` calcule ensuite : ```bash python scripts/regenerate_embeddings.py --depuis-lots data/mesure_voyage/lots/voyage-4-large \ --instantane data/mesure_voyage/morceaux.jsonl ``` Tant que le backfill n'est pas terminé, la recherche sémantique ne renvoie rien et le mode hybride dégrade en recherche plein texte ; la santé de la recherche le signale (`vectors.missing`, `vectors.foreign`). ### Re-ranking Les chunks remontés passent par `app/services/reranker.py` avant d'être tronqués et mis en cache. L'étage 1 est lexical (numéro d'article demandé, densité des termes, expression exacte, titre de loi, pénalité des formules d'exécution) : sans dépendance, sans réseau, actif par défaut. L'étage 2 fait noter les 20 meilleurs chunks par le modèle du chat ; il ajoute un appel sur le chemin critique, reste désactivé (`RERANK_LLM_ENABLED`) et dégrade toujours vers l'étage 1. ### Explication et comparaison Deux usages du modèle qui ne passent pas par le pipeline de chat, parce qu'il n'y a rien à converser : - `POST /laws/{id}/explain-article` explique un article en langage courant à partir de son texte, de ses deux voisins et des métadonnées du document (`app/services/explanation_service.py`). L'article est résolu en base par son numéro ; à défaut, l'extrait envoyé par la page sert de repli, mais seulement après vérification qu'il provient bien du document — sans ce contrôle, la route serait un proxy de prompt gratuit. - `POST /compare` compare deux régimes sur des critères imposés (`app/services/comparison_service.py`). **Une recherche par sujet**, l'une après l'autre : une requête unique mélangeant les deux termes rendait six articles d'un régime contre trois de l'autre, en ratant le bloc qui définissait le second. La sortie est contrainte par un schéma JSON **strict** où chaque cellule doit citer ses articles (une grille coupée est redemandée une fois, au double du budget) ; un numéro cité sans article correspondant est renvoyé dans `unmatched_citations` plutôt qu'avalé, et le texte intégral des sources accompagne chaque cellule pour que le lecteur vérifie lui-même. Aucun des deux n'est mis en cache : chaque appel dépense un appel au modèle. ### Mesure `RRF_K`, `TEXT_WEIGHT` et `SEMANTIC_WEIGHT` ne sont **pas** calibrés sur ce corpus. Le harnais qui les calibre : ```bash python -m scripts.eval.generate_eval_set --sample 120 # puis RELECTURE python -m scripts.eval.run_eval --dims 1024 512 256 # la dimension vaut-elle son coût ? python -m scripts.eval.run_eval --sweep rrf # les poids ``` Le jeu d'évaluation est committé sous `tests/fixtures/eval/`, les résultats de run vont dans `data/eval_runs/` (ignoré). Un item non relu ou un corpus qui a bougé depuis la génération font **échouer** la mesure plutôt que produire des chiffres faux. Avec 60 questions, l'erreur type est d'environ 6 points : un écart de 2 points entre deux configurations n'est pas un résultat. Le chat lui-même (modèle, prompt, profil) se mesure avec `comparer_mistral`. Son protocole et sa règle de décision sont écrits dans sa docstring, avant toute mesure. `--budget` refuse de lancer plus d'appels que prévu. ```bash python -m scripts.eval.comparer_mistral --modeles ministral-14b-2512 --questions 8 \ --personas citoyen avocat entrepreneur etudiant --temoin --comparaisons 0 --label personas # Une suggestion du chat a-t-elle une réponse dans le corpus ? Aucun appel au modèle : python -m scripts.eval.comparer_mistral --dry-run --questions-libres "Quel est le rôle du Conseil constitutionnel ?" ``` ## Tests La suite tourne sur un **vrai PostgreSQL** — le cœur du produit est du SQL PostgreSQL brut, et les objets dont il dépend (`search_vector`, index GIN, triggers, extensions) n'existent que dans les migrations, jamais dans `Base.metadata`. ```bash export TEST_DATABASE_URL=postgresql+asyncpg://jurix:jurix@localhost:5433/jurix_test pytest # tout, sans aucun appel reseau pytest -m "not integration" # sans base pytest -m groq_live # appels reels a Groq (une poignee) pytest -m mistral_live # appels reels a Mistral (cinq) pytest -m voyage_live # appels reels a Voyage (quelques milliers de jetons) pytest --cov=app --cov-report=term-missing ``` Sans base joignable, les tests qui en dépendent sont **ignorés avec la commande à lancer** — jamais silencieusement verts. **Aucun appel réel par défaut.** Les clés de Gemini, Groq, Mistral et Voyage sont remplacées par des clés factices, et une garde réseau fait échouer tout test qui tenterait de joindre Groq, Mistral ou Voyage : une suite complète vidait le quota gratuit de Groq. Les tests marqués `groq_live`, `mistral_live` ou `voyage_live` lèvent la garde du seul service visé, avec les vraies clés, et ne tournent que sur demande (`-m`). `JURIX_E2E=1` garde les vraies clés et ouvre tout, pour un essai de bout en bout délibéré. ## Structure ``` app/ api/routes/ endpoints HTTP core/ configuration, base de données, authentification models/ modèles SQLAlchemy schemas/ schémas Pydantic services/ recherche, embeddings, RAG, OCR, classification tasks/ pipeline d'ingestion utils/ découpage en articles, raffinage des chunks, fichiers alembic/versions/ migrations scripts/ administration et maintenance tests/ unitaires et intégration ``` ## Déploiement `Dockerfile` fourni. Il lance `alembic upgrade head` **puis** uvicorn : les migrations partent donc toutes seules à chaque démarrage. C'est voulu — `search_vector`, les index GIN et les déclencheurs n'existent que dans les migrations, jamais dans `Base.metadata`, si bien qu'un schéma créé par `create_all` serait incomplet. ### Bascule des embeddings sur une base peuplée **À ne jamais déclencher sans avoir d'abord vérifié s'il existe une base de production, et ce qu'elle contient.** La migration `a8b9c0d1e2f3` passe `articles.embedding` de `vector(768)` à `halfvec(1024)` et met **tous** les vecteurs à NULL : un vecteur EmbeddingGemma n'a pas d'équivalent dans l'espace de Voyage. Le conteneur lançant `alembic upgrade head` à chaque démarrage, **déployer ce code suffit à déclencher la purge**. Jusqu'au rechargement, la recherche sémantique ne rend rien et l'hybride répond en plein texte seul. 1. **Lire l'état de la base visée**, sans rien modifier : ```sql SELECT version_num FROM alembic_version; SELECT count(*) AS articles, count(embedding) AS vecteurs FROM articles; ``` Aucun article : déployer suffit. Déjà en `a8b9c0d1e2f3` : la purge a eu lieu, reprendre à l'étape 5 si des vecteurs manquent. 2. **Sauvegarder.** `alembic downgrade` rétablit le schéma 768, pas les vecteurs. Seul un dump permet de revenir en arrière sans tout recalculer : ```bash pg_dump --format=custom --file=jurix-avant-voyage.dump "postgresql://…" ``` 3. **Poser `VOYAGE_API_KEY`**, d'un compte avec moyen de paiement, et **retirer** de l'environnement de l'hôte `EMBEDDING_PROVIDER=gemma`, `EMBEDDING_DIM=768` et les `GEMMA_*` : la configuration refuse les deux premiers, et le conteneur s'arrêterait dès `alembic upgrade head`. Sans la clé, l'API démarre, mais en `degraded` et sans recherche sémantique. 4. **Arrêter l'ancienne API, puis déployer.** Le démarrage applique la migration. Une ancienne API qui tournerait encore enverrait des vecteurs de 768 dimensions à une colonne de 1 024. 5. **Remplir les vecteurs** : charger les lots déjà calculés si la base a accès à `data/mesure_voyage`, sinon recalculer par l'API (environ 10 M de jetons, quelques minutes au palier payant). Les deux sont reprenables. ```bash export DATABASE_URL="postgresql+asyncpg://…" # la base visée export PGSSLMODE=require python scripts/regenerate_embeddings.py --depuis-lots data/mesure_voyage/lots/voyage-4-large \ --instantane data/mesure_voyage/morceaux.jsonl python scripts/regenerate_embeddings.py --all --dry-run # ce qui reste python scripts/regenerate_embeddings.py --all python scripts/regenerate_embeddings.py --reindex ``` Dans le conteneur : `docker compose exec api python scripts/regenerate_embeddings.py --all`. 6. **Vérifier** `GET /api/v1/search/health` : `status` à `healthy`, `vectors.missing` et `vectors.foreign` à 0. Le même déroulé vaut pour tout changement de fournisseur ou de modèle à dimension égale (voyage vers gemini, ou un meilleur modèle plus tard), à une différence près : sans migration, les anciens vecteurs restent en place pendant la régénération. Leurs similarités avec une question encodée par l'autre modèle n'ont pas de sens ; la santé les compte dans `vectors.foreign` et reste `degraded` tant qu'il en reste. ### L'URL de la base : deux pièges opposés **Ne mettez jamais `?sslmode=` ni `?channel_binding=` dans `DATABASE_URL`.** Le dialecte asyncpg de SQLAlchemy transmet les paramètres qu'il ne connaît pas directement à `asyncpg.connect()`, dont la signature ne les accepte pas : ``` TypeError: connect() got an unexpected keyword argument 'sslmode' ``` L'erreur ne survient qu'au premier accès à la base, donc bien après un démarrage en apparence réussi. Or c'est exactement l'URL que fournissent les boutons « copier » des bases infogérées. La configuration refuse maintenant de se charger dans ce cas, avec le remède dans le message : **posez `PGSSLMODE=require` en variable d'environnement**, lue aussi bien par asyncpg que par libpq. Symétriquement, `alembic/env.py` **retire la chaîne de requête** avant de passer l'URL à psycopg2 : `prepared_statement_cache_size`, indispensable côté application derrière un pooler en mode transaction, ferait échouer libpq (`invalid URI query parameter`) et donc le démarrage du conteneur. ### Les autres points - **`data/` est éphémère.** Sans réglage, les PDF disparaissent au redéploiement alors que les lignes en base subsistent. Poser `DOCUMENTS_BASE_URL` sur un magasin HTTPS public : les documents sont alors récupérés en flux et mis en cache sur disque, et rien d'autre ne change. Vide, le comportement historique (lecture dans `./data/uploads`) est conservé. - **`--workers 1`** : plusieurs états sont en mémoire du processus (connexions WebSocket du suivi de lot, étranglement des envois de courriel par IP). - **Embeddings** : poser `VOYAGE_API_KEY` chez l'hébergeur. L'API ne garde aucun modèle d'embeddings en mémoire ; elle a besoin d'un HTTPS sortant vers `api.voyageai.com`. - **`SECRET_KEY`** : `openssl rand -hex 32`. Avec `ENVIRONMENT` différent de `development`, le conteneur **refuse de démarrer** sur la valeur du dépôt. - **`ALLOWED_ORIGINS`** : origine exacte, sans slash final. `allow_credentials` étant actif, le joker `"*"` est refusé par les navigateurs. - **Envoi de courriel** : `BREVO_API_KEY`, `BREVO_SENDER_EMAIL` et `FRONTEND_BASE_URL`. Sans elles, l'inscription et la connexion fonctionnent normalement ; seules la vérification d'adresse et la réinitialisation de mot de passe restent inertes. Le transport est l'API HTTPS de Brevo et **jamais SMTP** : les hébergeurs gratuits bloquent les ports sortants 25, 465 et 587. - **Extensions PostgreSQL** : `vector` (pgvector 0.8 au moins ; avant 0.7, pas de `halfvec` : la migration échoue et l'API ne démarre pas), `pg_trgm` et `unaccent` doivent exister **dans le schéma `public`**. Certains hébergeurs les installent ailleurs par défaut, ce qui casse `immutable_unaccent` (migration `a6b7c8d9e0f1`). Créer explicitement `CREATE EXTENSION ... WITH SCHEMA public;` avant la première migration. - **Maintien en éveil** : `GET /health` ne touche pas la base, délibérément — c'est la cible d'une sonde fréquente. Une seconde sonde, bien plus espacée, doit viser une vraie route de lecture si l'hébergeur met la base en pause pour inactivité. ### Mot de passe oublié La réinitialisation par courriel existe (`POST /auth/password/forgot` puis `/auth/password/reset`). Sans clé Brevo configurée, elle est inerte : le seul recours reste alors `PUT /admin/users/{id}`, ou une reconnexion par Google si le compte y est lié.