Spaces:
Runtime error
Runtime error
|
Download README.md from rahim237cm/jurix-api: direct link, hf CLI and curl.
- Browser
- Download file 36.4 kB
-
https://huggingface.co/spaces/rahim237cm/jurix-api/resolve/main/README.md
- Command line
-
hf download hf://spaces/rahim237cm/jurix-api/README.md
-
curl -L -o README.md https://huggingface.co/spaces/rahim237cm/jurix-api/resolve/main/README.md
36.4 kB
| 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 `<<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. | |
| ```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 | |
| <!-- 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 : | |
| ```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é. | |