Spaces:
Runtime error
Runtime error
Commit History
21 days ago
-
feat(embeddings): voyage-4-large remplace EmbeddingGemma (API Voyage, halfvec 1024)
Mesure sur tout le corpus (32 303 morceaux) et le jeu relu de 102 questions : en recherche dense, MRR@20 0,758 contre 0,537 pour EmbeddingGemma en francais, 0,680 contre 0,537 en anglais. Par le chemin du chat lui-meme (mots-cles en plein texte, question entiere vectorisee, 8 extraits envoyes au modele), l'article attendu figure parmi les extraits 92 % du temps en francais contre 61 % avec Gemma (MRR@8 0,504 contre 0,336), et 87 % en anglais contre 56 % (0,538 contre 0,282). Vectoriser la question entiere plutot que ses mots-cles gagne a lui seul +0,087 de MRR@8 en francais [IC 95 % +0,035 ; +0,143] et +0,127 en anglais [+0,067 ; +0,191]. - embedding_service.py : VoyageProvider (httpx, connexion gardee : 0,42 s par question contre 1,14 s avec une connexion neuve). Son corps de requete reproduit les vecteurs deja calcules du corpus : un test voyage_live le prouve, cosinus >= 0,999 sur deux questions et deux documents. Lots de 900 textes et 90 000 jetons estimes, refus definit…57c8117
26 days ago
-
feat(compte): le profil choisi suit le compte d'un appareil a l'autre
- Migration 015 (f7a8b9c0d1e2) : users.persona VARCHAR(50) NULL, et la contrainte ck_users_persona_connu, qui n'admet que les quatre profils (« étudiant » en NFC). NULL : aucun choix ; un refus de repondre n'est jamais stocke sur un compte. Rejouable, comme 013 et 014 : le test d'idempotence de 013 recule la version par stamp puis rejoue la suite. Instantanee sous PostgreSQL 16 (colonne nullable). - UserResponse.persona : /auth/me, la connexion, l'inscription, Google et la liste admin le renvoient. Pas dans UserBase : UserCreate en heriterait et la creation admin l'ignorerait. - PATCH /auth/me : le profil, seul champ modifiable en libre-service. MiseAJourDuCompte n'herite pas de UserUpdate (email, mot de passe, role : escalade de privileges) et refuse toute autre cle (422). Journal : l'identifiant du compte seulement. Jamais dans le JWT. - README : la route et les regles de synchronisation du front. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>4808bed -
feat(chat): le profil choisi pilote la reponse, une conversation garde le sien
- persona absent, null ou vide vaut desormais « general » (l'utilisateur n'a pas repondu) et non plus « citoyen » : les deux etaient indiscernables, dans la reponse comme dans les statistiques. La casse et les accents sont toleres (normaliser_profil). - Une conversation garde un seul profil. Un session_id existant envoye avec un autre profil ouvre une NOUVELLE conversation (nouveau session_id, historique vide) ; l'ancienne n'est ni modifiee ni adoptee. Le controle d'appartenance passe avant : la conversation d'autrui reste refusee quel que soit le profil. Ni 409 (un profil change entre deux visites ferait echouer la premiere question), ni silence (on repondrait avec un profil que l'utilisateur vient de quitter). - Le dernier evenement du flux porte `persona`, le profil effectif. - Les reponses conversationnelles et le message « aucun resultat » suivent le profil. - _handle_no_results recevait sa conversation en la rechargeant : sans session_id, il en creait une de…2c463a5 -
fix(confidentialite): questions et avis des utilisateurs reserves aux administrateurs
GET /personas/{p}/questions rendait a n'importe qui le texte exact des questions posees au chat par tous les utilisateurs sur 90 jours (21 questions citoyen dans jurix_dev), et GET /personas/feedback/{id} les commentaires libres des avis. Les deux exigent desormais un compte administrateur : 401 sans jeton, 403 pour un compte ordinaire. Les agregats sans texte restent publics. POST /personas/feedback reste public : hors de ce lot, signale. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>4a7363d
30 days ago
-
fix(reclassement): la simulation montre tout ce que --force ecrira
Issus de la revue de la branche : - `appliquer --dry-run` sans --force n'inscrivait pas dans changements.csv les categories deja posees que le verdict contredit : sur le corpus, ou toutes les lois en ont une, on relisait un fichier presque vide avant un --force qui les reecrivait toutes. changements.csv liste desormais toute loi dont la categorie changerait, avec sa suite (appliquee, ou gardee faute de --force), et le README recommande `--dry-run --force`. - Une relance `--incertains` apres un arret sur quota repayait les lois deja retentees avec un extrait : elle les saute. - Une ligne de journal coupee par un arret brutal absorbait la ligne suivante : le journal est termine par un saut de ligne avant d'ecrire. - Le journal est indexe par l'identifiant de la loi : chaque verdict porte desormais sa reference, et celui d'une autre loi (base reconstruite) n'est ni repris ni applique. - L'anomalie « Classement indisponible » posee par le pipeline est effacee une foi…f508b99 -
docs(config): .env.example aligne sur Settings, seed aux 14 domaines, README a jour
.env.example : - chaque reglage de Mistral et de Groq y figure, avec les plafonds verifies du palier gratuit ; MISTRAL_MODEL suit le defaut de config.py (14b) ; INTENT_CLASSIFIER n'annonce plus « local » ; - plus aucun commentaire en fin de ligne : docker --env-file le garde dans la valeur, et « groq # groq | local » etait refuse au demarrage ; - l'extraction des PDF annonce Docling, son moteur par defaut. tests/test_core/test_env_example.py verifie que chaque cle est un reglage (Settings ignore en silence une cle inconnue), qu'aucune ligne ne porte de commentaire, que les valeurs actives se chargent et que chaque reglage des fournisseurs est documente. scripts/seed_database.py semait cinq categories aux identifiants fixes et aux noms d'avant la migration 013 : sur une base migree, une quinzieme categorie, et des lois rattachees a l'identifiant 1, 2 ou 3, quel qu'il soit. Il cree les 14 domaines manquants par leur nom et rattache ses lois par le nom. Textes perimes : « 12 cat…deb8048 -
fix(mistral): les reponses ne sont plus coupees, les 429 sont rejoues
Les fins de reponse de Mistral sont traduites dans le vocabulaire du reste du code : « length » et « model_length » deviennent « MAX_TOKENS » (rag_service n'attendait que ce nom, et une reponse coupee en flux s'arretait au milieu d'une phrase, sans mention), « stop » « STOP », « error » « ERROR ». Une reponse coupee — par le budget de jetons, ou par un flux rompu apres les premiers mots — est completee : une suite de 2 048 jetons au plus est demandee, le texte deja produit en prefixe de l'assistant (`"prefix": true`), l'echo eventuel du prefixe retire au fil du flux. Pas de suite pour un texte qui tourne en boucle, ni pour une reponse JSON. La mention reste seulement si la suite echoue ; un flux rompu a sa propre mention (« la connexion avec le modèle a été coupée »). Le chat, les reponses conversationnelles et l'explication d'article la portent. Debit : un limiteur par modele (0,5 requete par seconde pour ministral-14b, 3 pour ministral-8b, 4 generations simultanees). Un 429 de debi…cf54f10 -
feat(groq): classement des lois par lots, sans repli silencieux, et suite isolee du reseau
GroqService (app/services/groq_service.py) : - chaque appel passe par le limiteur de son modele (les quotas Groq sont par modele) ; un 429 est respecte (Retry-After) puis rejoue ; une attente imposee de plus de GROQ_ATTENTE_MAX_429_S, ou un message de quota journalier, ferme le modele jusqu'a l'echeance (GroqQuotaError, sans requete) ; 5xx et pannes reseau : 4 essais ; - erreurs typees au lieu de None : GroqLimiteError, GroqQuotaError, GroqIndisponibleError, GroqReponseInvalideError ; - schema JSON strict, max_completion_tokens, temperature 0, reflexion coupee (qwen : « none ») ou reduite et masquee (gpt-oss) ; - la sonde de sante lit /models/{modele} au lieu de generer. Classement des lois (legal_domain_classifier.py) : - par LOTS de documents numerotes, codes courts en sortie : 2 226 lois une par une demandaient 11 jours de quota ; un lot incomplet est redemande (les manquants, ou le lot coupe en deux), un document qui manque encore est « a revoir » ; - modele prop…1a3d3f5
-
fix(chat): le flux refuse la conversation d'autrui en 404, plus en 500
La route /rag/ask/stream verifie l'appartenance de la conversation avant d'ouvrir le flux, mais sa clause generale `except Exception` attrapait le HTTPException 404 et le renvoyait en 500. L'interface, qui emprunte desormais le flux, ne pouvait pas repartir sur une conversation neuve. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>89d5b4d
-
feat(ingestion): le corpus en deux passes reprenables
Passe 1, scripts/extraire_corpus.py : Docling seul, sans base. Le travail se fait dans un processus enfant (spawn) recycle tous les 25 documents ou au-dela de 5 Go : la memoire de Docling grossit au fil des pages. Le superviseur compte une tentative au lot pendant lequel l'enfant meurt, tue un enfant bloque, s'arrete sur une panne generale (initialisation, echecs en serie) ou si .env et les paquets ont change depuis le lancement — sans quoi l'enfant ecrirait dans un autre cache et la boucle ne finirait pas. Arret propre par data/ingestion/STOP ; suivi dans progression.json. Passe 2, scripts/ingest_corpus.py : ne relit que le cache, jamais Docling. Reprend les lois pending, processing et refused ; une panne de base est rejouee avec attente croissante au lieu de compter chaque document en echec. Les PDF identiques sous deux numeros d'acte sont indexes une fois, l'autre signale. Type et date de l'acte relus dans le titre, l'annee controlee par le numero d'acte. Pipeline (process_law) : …1be6163 -
fix(uploads): le nettoyage ne supprime plus le PDF des lois
La route de nettoyage effacait tout fichier de plus de 24 h dans data/uploads, y compris le PDF de chaque loi publiee, que laws.file_id designe : son affichage cassait, et toute re-extraction devenait impossible. Seuls les fichiers qu'aucune loi ne reference partent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>19a2ab9 -
feat(embeddings): EmbeddingGemma par defaut, et une panne qui se voit
EMBEDDING_PROVIDER vaut desormais gemma : les embeddings sont calcules en local, gratuitement, et Gemini ne sert plus qu'au chat. `gemini` reste disponible, a 768 dimensions lui aussi. LA PANNE LA PLUS PROBABLE DU SEMANTIQUE ETAIT LA SEULE INVISIBLE. Modele absent, fichier tronque, vecteurs d'un autre modele : dans chacun de ces cas, la recherche hybride avalait l'erreur et repondait en plein texte seul, pendant que GET /search/health disait « healthy ». Trois filets : - le modele est charge AU DEMARRAGE, et une question est reellement encodee. Un echec n'empeche pas l'API de demarrer — plein texte, chat et comptes s'en passent — mais il est journalise, et le service est ECARTE : garde, il retenterait de charger 300 Mo a chaque question ; - la sante passe en « degraded » si le service manque, si des vecteurs d'un AUTRE fournisseur sont en base (leurs cosinus ne veulent rien dire), ou si aucun vecteur n'est exploitable alors qu'il y a des articles a vectoriser. Ce dernier …7a1d560
-
feat: deployment on free tiers, object-storage documents, and email
Enables the project to run on a free hosting tier — ephemeral disk, managed Postgres behind a connection pooler, no outbound SMTP — and adds the password reset flow that was previously a dead end. Two blocking deployment bugs, both reproduced rather than deduced: DATABASE_URL with ?sslmode=require TypeError: connect() got an unexpected keyword argument 'sslmode' SQLAlchemy's asyncpg dialect forwards unknown query parameters straight to asyncpg.connect(), whose signature accepts neither sslmode nor channel_binding and has no **kwargs. The failure surfaces only on the first database access, long after an apparently successful start — and that is exactly the URL the "copy" buttons of managed Postgres providers hand out, and what .env.example recommended. Settings now refuse such a URL at load time, naming PGSSLMODE as the remedy. alembic/env.py, swapping asyncpg for psycopg2 but keeping the query string ProgrammingError: invalid dsn: invalid URI query parameter prepared_s…6429ad3
-
feat(auth): inscription publique, connexion Google, et conversations rattachees a un compte
L'utilisateur peut desormais creer un compte — nom, adresse, mot de passe — ou se connecter par Google, afin de retrouver ses conversations d'une visite a l'autre. PAS DE FIREBASE. Le navigateur obtient un jeton d'identite de Google, ce serveur le verifie avec google-auth, puis emet le JWT maison. Une dependance, zero paquet npm, et surtout UN SEUL fichier d'utilisateurs — Firebase en imposerait deux, a garder synchronises. L'ESCALADE DE PRIVILEGE EST FERMEE PAR CONSTRUCTION. `UserCreate` porte un champ `role` fourni par le client ; le reutiliser pour une route publique aurait laisse n'importe qui poster {"role": "superadmin"}. `SignupRequest` n'herite pas de `UserBase`, donc ce champ n'existe pas, et `extra="forbid"` fait de son envoi un 422 plutot qu'un silence — la tentative laisse une trace. LE TROU DE CONFIDENTIALITE EST FERME, et c'etait la condition pour promettre quoi que ce soit. GET et DELETE /rag/conversations/{sid} n'avaient aucune authentification ni controle d'apparten…d21ea1e -
feat(ia): expliquer un article et comparer deux regimes, sur la page, sources a l'appui
Deux usages du modele qui ne passent pas par le chat, parce qu'il n'y a rien a converser. POST /laws/{id}/explain-article explique un article en langage courant a partir de son texte, de ses deux voisins et des metadonnees du document. La page envoie aussi l'extrait affiche : il sert de repli quand la base n'a pas de ligne pour ce numero (193 articles reconnus par la page contre 230 en base), mais seulement apres verification qu'il provient bien du document — sans ce controle, une route publique et non limitee serait un proxy de prompt gratuit sur un quota partage avec le chat. POST /compare compare deux regimes sur des criteres imposes. UNE recherche par sujet, l'une apres l'autre : mesure sur le corpus, la requete unique du chat rendait six articles d'un regime contre trois de l'autre et ratait le bloc 49-54 qui definit le second. Un schema JSON force chaque cellule a citer ses articles ; un numero sans article correspondant part dans unmatched_citations plutot que d'etre avale ; l…88e3dbc
-
fix(gemini): un quota epuise n'est pas une panne, et une cadence pour le palier gratuit
Deux corrections tirees de l'usage reel du nouvel extracteur. 1. LA SONDE CONFONDAIT QUOTA ET PANNE. `GET /api/v1/rag/health` rendait `llm: unhealthy` avec, dans `reason`, le JSON brut de Google — identifiants de quota compris. Or un quota epuise est un etat CONNU, temporaire, et dont le fournisseur donne la date de fin. Il rend desormais `status: quota_exhausted` avec le delai conseille, et la route le distingue d'une vraie panne. 2. LE PALIER GRATUIT LIMITE AUSSI PAR MINUTE. Le 429 ne dit pas « demain », il dit « reprise dans 23 secondes ». Sans pause entre deux lots d'un meme document, un texte de 28 pages epuisait la fenetre a son deuxieme appel et s'arretait, alors qu'il suffisait d'attendre six secondes. Verifie au passage : le script de re-extraction s'arrete proprement sur quota epuise, annonce combien de documents restent et sort en code 2. La re-extraction elle-meme n'a pas pu etre lancee — le quota du jour a ete consomme par les mesures qui ont val…caf4d2d -
fix(qualite): ruff devient une vraie barriere, et trouve un schema mort et casse
Ruff tournait sans jeu de regles declare : 1332 signalements, dont 98 faux positifs sur le motif `Depends()` de FastAPI et 615 modernisations de style. Un controle que personne ne peut passer n'est pas un controle. Le jeu retenu ne garde que ce qui signale un defaut reel (F, E4, E7, E9, I, RUF100), avec des exceptions nommees la ou l'ordre des imports EST la protection : env.py doit poser sys.path avant d'importer les modeles, conftest doit surcharger DATABASE_URL avant d'importer l'application. Ce que le controle a alors trouve : - UN SCHEMA MORT ET CASSE. `LawDetailResponse` existait deja, exposant `List[ArticleResponse]`. Il n'etait branche sur aucune route, et il etait doublement inutilisable : ArticleResponse porte le CONTENU integral de chaque article — pour le Code Minier, 166 Ko servis deux fois — et son `number` etait plafonne a 20 caracteres alors que la colonne en fait 64 depuis qu'elle accueille « QUATRE-VINGT-DIX-SEPTIEME ». Le plafond du schema est corrige, et…7435134 -
refactor: retirer 165 Mo de dependances mortes et dix scripts a usage unique
DEPENDANCES scikit-learn et joblib : ZERO occurrence dans tout le depot depuis la suppression du classifieur ML. Avec scipy qu'ils entrainent, ils pesaient 165 Mo dans l'image pour rien. Le classement de domaine est fait par des regles depuis legal_domain_classifier.py. SCRIPTS Sept scripts a usage unique, sans argparse, avec `law_id = 1` code en dur et aucun garde `__main__` — donc executables au simple import. Quatre d'entre eux etaient DESTRUCTIFS sans confirmation : force_clean_extract, reextract_ constitution, reextract_pdfplumber effacaient les articles de la loi 1. reprocess_law.py portait en plus un file_id code en dur ; il figurait dans un plan de suppression precedent et n'avait pas ete supprime. Trois scripts de maintenance des categories (add_category_icons, update_category_icons, cleanup_categories) sont perimes par la migration 007, qui possede desormais les 14 domaines avec leurs icones et le verifie par assertion. Les executer se battrait contre elle. reset_databas…d41abd1 -
fix: colmater les fuites et les silences
Cinq defauts qui laissaient une panne passer pour un fonctionnement normal. 1. `cleanup_expired_cache` existait, etait importee par search_service, et n'etait APPELEE NULLE PART. `query_cache` et `embedding_cache` portent une colonne `expires_at` que rien n'appliquait : les deux tables grossissaient sans fin. Une colonne d'expiration non appliquee est pire que pas de colonne du tout — elle laisse croire que le menage est fait. Une tache de fond la purge toutes les quinze minutes. 2. `close_db()` etait appele APRES le `yield` du lifespan, donc jamais si l'arret venait d'une exception. Il passe dans le `finally`. 3. `GET /laws/{id}` chargeait deja les articles par `selectinload` et les jetait ensuite : `LawResponse` n'expose que `article_count`. La page de lecture les reconstruisait donc par expression reguliere sur le contenu — 193 articles la ou la base en compte 230 pour le Code Minier, et aucune page de PDF. Un nouveau `LawDetailResponse`, servi par c…5ca34ed -
fix(analytics): mesurer au lieu d'inventer, et fermer la porte
Le tableau de bord admin affichait des chiffres fabriques comme des mesures. `GET /analytics/search` et `/analytics/usage` renvoyaient des constantes codees en dur — 350 recherches, 150 ms, 1150 appels, 25 utilisateurs actifs, une repartition 100/50/200 entre les modes — accompagnees d'un champ `note: "Mock data"` que le front ne lisait pas. S'y ajoutaient deux litteraux `change="+12%"` et `"+5.4%"` ecrits directement dans le composant, rendus sous « depuis le mois dernier ». Hors `total_laws` et `recent_laws`, tout le tableau de bord etait fictif. Rien en base ne permettait de faire mieux pour la recherche : `query_cache` ne garde qu'un hachage, avec cinq minutes de duree de vie, et une requete servie depuis le cache n'y cree aucune ligne. Ce n'est pas un journal. La migration f1a2b3c4d5e6 en ajoute un — `search_events`, une ligne par recherche, ecrite sans jamais pouvoir faire echouer la recherche elle-meme. Les metriques d'usage, elles, etaient deja mesurables : `conversations` e…9066a18 -
fix(chat): la sonde mentait, le 503 n'etait pas rattrape, la reflexion fuyait
`GET /api/v1/rag/health` annoncait `llm: unhealthy` en permanence. Quatre causes distinctes, toutes mesurees. 1. LA SONDE ETAIT FAUSSE. Elle interrogeait le modele avec `max_output_tokens=10`. Les modeles a raisonnement consomment ce budget en reflexion interne : `response.text` revenait vide et la sonde concluait « en panne ». Mesure sur gemini-3-flash-preview, gemini-3.5-flash et gemini-flash-latest : les trois echouaient a 10 jetons, les trois repondaient a 512. La sonde lit desormais `finish_reason` et distingue « budget epuise » de « service injoignable ». 2. LE 503 N'ETAIT PAS RATTRAPE. « This model is currently experiencing high demand » revient par salves de quelques secondes sur le palier gratuit. `GeminiServiceError` etait levee en trois endroits et attrapee NULLE PART — l'import cense la traiter dormait, inutilise, en tete de rag_service. Trois tentatives a delai croissant absorbent la salve ; au-dela, un 503 HTTP avec Retry-After remplace …092315b -
feat(recherche): le titre passe devant le corps du texte, et les fautes de frappe passent
Trois demandes utilisateur, quatre causes. 1. Chercher "nomination" remontait des documents sans ce mot dans leur titre, et le moins pertinent sortait premier. `articles.search_vector` ne contenait ni le titre de la loi ni celui de l'article ; `laws.search_vector` collait titre et contenu sans un seul `setweight` ; et la seule branche qui voyait `laws.title` etait gardee derriere `if not chunks:`, donc jamais executee. Mesure avant : le decret SOCADEL (titre sans le mot) sortait a 0,4000 devant cinq documents dont le titre le porte, a 0,2000. Apres ponderation : 2,10 contre 0,20. 2. "article 35 du code minier" ne pouvait pas aboutir : le decoupeur attendait `Article 35` la ou le markdown ecrit `**ARTICLE 1er.**-` et `**Article1er.-**`. 8 lois sur 27 avaient un numero reconnu, contre 27 sur 27 apres correction ; 127 numeros reels contre 599. Le Code Minier passe de 41 a 193 articles. 3. Aucune tolerance aux fautes : `word_similarity` sur titre deplie, s…893a679
-
fix: nom du fichier telecharge, image de production, flux non bloquant
Le fichier telecharge portait le nom brut du fichier aspire depuis prc.cm : "10691_decret-n-2026-164-du-4-mai-2026-portant-approbation-des-statuts-...". Le prefixe numerique est l'identifiant interne du site source et les tirets remplacent une ponctuation qui existait. Le nom propose est desormais construit sur le TITRE du document, accents compris — Content-Disposition les encode via filename* (RFC 5987) — en ne retirant que les caracteres interdits par les systemes de fichiers et en bornant la longueur. Dockerfile. poppler-utils et tesseract-ocr (avec les dictionnaires francais et anglais) sont installes : sans poppler, GET /laws/{id}/page/{n} — la visionneuse par images — repond 500 en production alors qu'il fonctionne en developpement, ou le binaire est sur la machine. TESSERACT_PATH pointe le binaire Linux. Les migrations sont jouees au demarrage : le schema du produit vit dans alembic (search_vector, index GIN, declencheurs, extensions) et non dans Base.metadata, donc un contene…4093877 -
fix(laws): le telechargement rend le document, et un seul chemin le resout
Le bouton "Telecharger" du front appelait window.print() : ce n'est pas un telechargement mais l'impression de la page telle qu'affichee. Comme la page ne monte qu'UN article a la fois et qu'aucune feuille @media print n'existe, le PDF obtenu contenait le fil d'Ariane, le sommaire, l'en-tete et l'unique article lu. Le document d'origine n'etait jamais en jeu. GET /laws/{id}/download servait pourtant le bon fichier depuis toujours, sans qu'aucun code du front ne l'appelle. Cote API, deux corrections. content_disposition_type passe de "inline" a "attachment" : un bouton nomme "Telecharger" doit enregistrer le fichier, pas l'afficher dans l'onglet. Et les CINQ endpoints qui servent un PDF (/download, /pdf-data, /pdf-stream, /pdf-info, /page/{n}) partagent desormais _law_file_path, qui delegue a resolve_upload_path. Chacun reconstruisait le chemin a la main, avec un repli joignant law.file_id au repertoire d'upload sans motif, sans resolve() ni confinement — cinq copies exactes de la faib…56362b9 -
fix: autocompletion vide, categories jamais ecrites, titre de resultat inerte
Trois pannes constatees en utilisant l'application sur un corpus reel. 1. /search/suggest ne renvoyait RIEN, pour a peu pres toute saisie. Deux causes cumulees. similarity() compare la requete au titre ENTIER : sur ce corpus un titre fait quatre-vingt-dix caracteres et le mot cherche une dizaine, la similarite plafonne a 0,13 quand le seuil est a 0,3 — mesure sur la base. Et la branche prefixe ne rattrapait rien, aucun titre ne commencant par le mot cherche. word_similarity mesure la requete contre le MEILLEUR extrait du titre et vaut 1,0 dans ce meme cas ; l'operateur %> est celui que gin_trgm_ops sait accelerer. S'y ajoutait la sensibilite aux accents : "societe" contre un titre portant "societe" accentue donne 0,500, sous le seuil de 0,6 — or personne ne tape les accents. La migration a6b7c8d9e0f1 installe unaccent, un enrobage IMMUTABLE (unaccent est STABLE, donc inutilisable dans un index) et deux index trigrammes sur l'expression depliee, sans…a381e6a
-
fix: backfill relancable, boucle d'evenements, reglages morts
scripts/regenerate_embeddings.py reecrit. Il s'executait A L'IMPORT, sans main() ni garde __main__, avec LAW_ID = 3 code en dur, un appel API par article et un message final renvoyant vers un script Meilisearch supprime. Desormais : argparse (--all / --law-id / --reindex / --dry-run / --force), reprise par defaut sur embedding IS NULL avec curseur sur l'id (un OFFSET saute des lignes, celles traitees sortant du filtre au fur et a mesure), API batch, attente exponentielle plafonnee sur quota avec reprise du MEME lot, commit par lot, troncature des contenus trop longs, et ecriture via CAST(:embedding AS vector) sur une chaine — une liste Python passee a travers text() est adaptee en ARRAY, qui ne se caste pas en vector. --reindex reconstruit l'index HNSW en CONCURRENTLY : la migration le cree sur une table vide, la reconstruction en masse apres backfill donne un meilleur graphe. process_law._extract_pdf_text appelait asyncio.run() depuis un thread de l'executor : cela ne fonctionnait qu…202650d -
Etape 13 : suppression du code mort de l'ancienne architecture
Fichiers supprimes (~950 lignes, aucun importateur) : - app/tasks/analytics.py — importait celery au niveau module, paquet absent des deux manifestes ; codait en dur un courtier redis://localhost:6379. - app/scripts/reindex_search.py — importait meilisearch ET lisait settings.MEILISEARCH_URL/KEY, champs supprimes de la configuration : double echec garanti. Le repertoire app/scripts/ ne contenait que ce fichier. - app/tasks/clean_text_helper.py — orphelin, doublon plus ancien de process_law._clean_extracted_text. - 6 scripts Meilisearch/Redis inexecutables : check_meili_content, index_articles_meili, reindex_meilisearch, test_meilisearch, flush_cache, debug_search. - scripts/reset_database.py (references Redis) ; reset_database_fixed.py, qui le remplacait deja, reprend son nom. Alias MeilisearchError supprime de search_service (le test qui l'importait pointe desormais sur TextSearchError). Documentation realignee sur la pile reelle dans une dizaine de fichiers : plus auc…51a64ba -
Etape 11 : authentification reelle branchee (demande n°2)
app/core/auth.py (188 lignes, JWT + bcrypt + roles) etait complet et importe par PERSONNE. Les 9 endpoints d'administration passaient par deux stubs renvoyant {"id": 1, "role": "admin"} : ils etaient ouverts a tous. BUG BLOQUANT decouvert en chemin : passlib 1.7.4 (derniere version, 2020) est incompatible avec bcrypt >= 4.1 — il lit bcrypt.__about__.__version__, supprime depuis. Avec bcrypt 5.x installe, hash_password echouait avec "password cannot be longer than 72 bytes" MEME pour 13 caracteres : l'authentification aurait ete inutilisable telle que declaree. -> bcrypt utilise directement, passlib retire des manifestes. Pre-hachage SHA-256 + base64 avant bcrypt : entree de longueur fixe, donc support des phrases de passe longues sans la troncature silencieuse a 72 octets de bcrypt (deux mots de passe partageant leurs 72 premiers octets etaient equivalents). Ajouts : - app/api/routes/auth.py : POST /login (formulaire OAuth2, l'URL exacte attendue par oauth2_scheme, donc le…da2ad52 -
Etape 10 : /suggest, /stats et /health reecrits en PostgreSQL
Les trois endpoints referencaient search_service.meilisearch_client ou redis_client, attributs supprimes lors du passage a PostgreSQL natif. - /suggest renvoyait 200 avec une liste VIDE : l'AttributeError etait avalee par un except. Casse en silence, donc invisible. Reecrit en requete trigramme dediee sur laws.title + laws.reference. Le filtre utilise l'operateur % et ILIKE 'q%' (tous deux accelerables par gin_trgm_ops) ; similarity() ne sert qu'au tri, car `similarity(a,b) > seuil` n'est PAS indexable. Les correspondances par prefixe passent devant les approximatives. Pas de mise en cache : une ecriture par frappe serait de l'amplification. Verifie : "const" -> Constitution, "constitucion" (mal orthographie) -> Constitution via trigramme, "c" -> vide. - /stats renvoyait TOUJOURS 500, et ses chiffres par langue et par categorie etaient codes en dur a zero. Reecrit en agregations reelles. Quatre champs ajoutes — indexed_documents, total_articles, articles_with_embe…c1d6b49 -
Etape 9 : chaine d'ingestion — session detachee, echec franc, statuts
Demande n°4 — process_batch recevait la session de la requete Depends(get_db) la ferme des le retour du handler : chaque acces de la tache detachee echouait, et un `except:` nu avalait l'erreur. process_batch ouvre desormais sa propre session, UNE PAR DOCUMENT (le traitement dure plusieurs minutes ; une session pour tout le lot immobiliserait une connexion du pool et un document en echec entrainerait les suivants). Demande n°5 — plus aucun repli degrade Le repli LlamaParse -> Tesseract -> pypdf renvoyait la couche texte deja presente dans les PDF prc.cm : ~20% de rappel, filigrane injecte au milieu des phrases, cachets lus comme du charabia, et 21% des documents sans aucun texte. Le document etait publie avec ce contenu et marque "completed". Desormais : LlamaParse fait ses 4 tentatives, puis on leve. Le document passe en "refused" avec processing_error et reste retraitable. _extract_pdf_ocr_fallback supprimee ; get_llama_parse_service() utilise a la place d'…bde990c
-
Etape 2 : bugs independants du reste (auth et schema non touches)
Corriges avant l'auth pour que le commit d'authentification ne porte que sur l'authentification. Toujours casses, desormais reparés : - database.py : health_check_db executait la chaine brute "SELECT 1", refusee par SQLAlchemy 2. L'exception etait avalee -> GET /admin/system declarait la base en panne EN PERMANENCE. - rag_service.py : SYSTEM_PROMPTS est indexe par langue, pas par persona. SYSTEM_PROMPTS["citoyen"] levait KeyError a chaque appel -> /rag/ask/stream n'a jamais rien renvoye d'autre qu'une erreur. Remplace par get_system_prompt(persona, language), deja utilise en non-streaming. - rag_service.py + rag.py : joinedload sur une collection sans .unique() -> InvalidRequestError. Toute requete portant un session_id existant echouait, et GET /rag/conversations/{id} renvoyait toujours 500. - 4 endpoints inatteignables, masques par une route parametree declaree avant (FastAPI resout dans l'ordre) : /upload/health (404), /categories/health (422), /personas/health (4…016376a
-
feat(migration): Replace Redis/Meilisearch/Celery with 100% native PostgreSQL FTS and FastAPI BackgroundTasks
c77abb9
-
chore: initial standalone backend for JuriX API (Render)
9cf9a2a