jurix-api / README.md
rahim237cm's picture
docs: ajouter les metadonnees Hugging Face Space au README
717d7c3
|
Raw History Blame Contribute Delete
36.4 kB

A newer version of the Gradio SDK is available: 6.31.0

Upgrade
metadata
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 :

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

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é :

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) :

# 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 <<PAGE:n>> 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.

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 :

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 :

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 :

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.

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.

<!-- La base de test partage le serveur de developpement : un seul conteneur,
     deux bases. Deux conteneurs sur deux ports etaient annonces ici, et la
     realite n'en a jamais compte qu'un. -->

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 :

    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 :

    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.

    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é.