Spaces:
Runtime error
Runtime error
Commit History
19 days ago
-
fix: ne pas appeler la fonction @spaces.GPU a l'import et nettoyer PGSSLMODE
a145b74 -
feat(deploy): configurer le deploiement sur Hugging Face Spaces (Gradio/FastAPI)
8272a44
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(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 -
feat(personas): une seule liste des profils, alias sans accent, profil general
La liste des profils vivait en trois copies : le validateur du chat (schemas/rag.py), PersonaService.VALID_PERSONAS et les cles des prompts. Une valeur inconnue y recevait trois traitements : 422, 400, ou repli silencieux sur le prompt citoyen (« etudiant » sans accent). app/core/personas.py, sans import applicatif, porte desormais : - PERSONAS, les 4 profils proposes, « étudiant » en NFC ; - PROFIL_GENERAL (« general »), la valeur d'un utilisateur qui n'a pas repondu ; - normaliser_profil : casse, accents et forme NFD ignores, vide = general ; - normaliser_persona : les 4 seulement, pour un compte, qui ne garde jamais un refus. Aucun changement de comportement a cette etape. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>0a62da7
30 days ago
-
feat(mistral): ministral-14b-2512 epingle, ministral-8b-2512 en secours
Mesure du 07/10/2026 (scripts/eval/comparer_mistral.py) : 20 questions du jeu d'evaluation relu et 4 comparaisons, le meme contexte pour les deux modeles, 48 appels, aucune erreur ni reponse coupee. 14b 8b citations justes 96,6 % 96,4 % premier morceau 0,98 s 0,82 s (medianes) duree 2,74 s 2,21 s comparaisons en JSON 4/4 4/4 ajouts non sources 13,0 14,5 (relecture a l'aveugle, repond a la question 39/40 37/40 2 relecteurs, 20 questions) Regle fixee avant la mesure : garder le 14b, sauf si le 8b egale la justesse des citations sans ajouter plus de contenu non source. Il egale les citations mais ajoute un peu plus : le 14b reste, le 8b sert de secours. Les versions sont epinglees, pas les alias -latest : un changement de modele chez Mistral ne doit pas changer les reponses sans qu'on le sache. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>40829a2 -
fix(limiteur): attente bornee, 429 retenus, mentions dans la langue de la reponse
Issus de la revue de la branche : - L'attente d'une place de generation (4 par modele) n'avait pas de limite : quand toutes etaient prises par des comparaisons longues, un message du chat attendait des minutes, sans secours ni 503. Elle est bornee par l'attente acceptee ; au-dela, le modele de secours. - Mistral : un Retry-After plus long que le budget, ou recu au dernier essai, n'etait pas inscrit au limiteur ; l'appel suivant retournait frapper le modele sanctionne. Il est inscrit avant de decider d'abandonner. - Un `repousser` (429) ne retenait pas les appels deja endormis sur un depart reserve : ils partaient en pleine penalite. Ils attendent l'echeance. - Le recalage sur les en-tetes de Groq s'inscrivait en queue de fenetre, apres des departs reserves pour plus tard, et le calcul du prochain depart faisait attendre trop (88 s au lieu de 59). Il s'inscrit a sa place. Groq corrige l'usage reel AVANT de recaler, sinon l'estimation masquait la consommation des…ecf0802 -
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(intention): classement par Groq court et en cache, plus de classement local
Le classement local par embeddings est retire (intent_local.py, intent_examples.py, leurs tests et le prechauffage au demarrage). INTENT_CLASSIFIER accepte « groq » (defaut) ou « llm » (le modele du chat) ; l'ancienne valeur « gemini » est ramenee a « llm », et « local » est refuse avec la raison plutot qu'avec l'erreur generique de pydantic. Le mode groq menage le quota du jour (200 000 jetons, 1 000 requetes) : - consignes courtes (~300 jetons au lieu de ~870 : le chat plafonnait vers 215 questions par jour), schema strict, sortie de 40 jetons ; - deux derniers echanges seulement, 200 caracteres chacun ; - cache des verdicts (2 000 entrees, 24 h) : un message identique ne coute plus de requete ; un repli n'y entre jamais. Quand Groq ne peut pas repondre dans les temps — quota epuise (aucune requete n'est alors envoyee), attente du limiteur au-dela de 0,5 s, delai GROQ_INTENTION_TIMEOUT_S (3 s) depasse, panne — la question part aussitot en « juridique », avec une regle `defaut-g…21d412f -
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 -
feat(limiteur): limiteur de debit partage pour les API externes
Les paliers gratuits sont etroits (Groq : 30 requetes et 8 000 jetons par minute, 1 000 requetes et 200 000 jetons par jour, par modele ; Mistral ministral-14b : 30 requetes par minute) et chaque depassement coutait une requete perdue plus une attente imposee. app/core/limiteur.py fait attendre AVANT d'envoyer : - debit par seau a jetons (GCRA), avec rafale ; - fenetres glissantes : jetons par minute, jetons d'entree par minute, requetes et jetons par jour ; - repousser() apres un 429, recaler() sur les en-tetes x-ratelimit-*, qui voient aussi les appels des autres processus ; - attente_max : au-dela, AttenteTropLongue sans rien consommer ; - un semaphore de concurrence par boucle d'evenements. Horloge et sommeil injectables : les 33 tests tournent sans aucun vrai sleep. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>9a31d98 -
feat: Mistral pour le chat, Groq pour le routage et le classement, 14 categories
Travail de l'utilisateur, verse tel quel avant les correctifs qui suivent : - MistralService (generation et flux), LLM_PROVIDER=mistral par defaut ; - GroqService : classement d'intention et classement des lois par Groq ; - 14 categories canoniques (migration 013) et script de reclassement ; - alembic/env.py retombe sur settings.DATABASE_URL. Defauts connus, corriges dans les commits suivants : settings non importe dans comparison_service et explanation_service, tests appelant les vraies API, migration 013 sans retour arriere.26dddaf
-
feat(chat): classement de l'intention en local, sans appel a Gemini
Chaque question coutait deux appels a Gemini : le classement (juridique, bavardage, question sur l'assistant, hors sujet) puis la reponse. Le classement prenait 3,6 s en reflexion minimale et une requete sur deux du quota gratuit. Il se fait desormais par EmbeddingGemma, deja en memoire pour la recherche : le message est rapproche d'une banque d'exemples etiquetes (intent_examples.py, questions de droit surtout en mots de tous les jours, noms de personnes et nominations compris). Chaque categorie recoit la moyenne de ses 3 exemples les plus proches ; une categorie non juridique ne l'emporte qu'avec 0,04 d'avance — dans le doute, c'est une question de droit. Un message court qui n'est pas clairement juridique est reclasse avec la question precedente (« et pour un mineur ? ») ; un verdict non juridique est encore contredit si un article du corpus est a 0,42 de similarite ou plus. Mesure sur 122 messages etiquetes (tests/fixtures/intent_eval.json, distincts de la banque), banque seule :…52efadf -
revert(chat): la reponse garde la reflexion du modele
Choix de l'utilisateur apres comparaison sur deux questions, meme contexte : « low » repondait en 3 a 5 s au lieu de 11 a 12, avec les memes articles cites, mais un peu moins precis par endroits. Le reglage reste disponible par GEMINI_REFLEXION_REPONSE ; la classification garde « minimal ». Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>e803030 -
perf(chat): reflexion du modele reglee, la reponse arrive en 12 s au lieu de 37
Mesure sur une question au chat (gemini-3-flash-preview), 37 s au total : classification 8,5 s (232 jetons de reflexion pour 18 de sortie), recherche 0,26 s, reponse 28 s (3 104 jetons de reflexion pour 247 de reponse). La reflexion faisait l'essentiel de l'attente. GEMINI_REFLEXION_REPONSE (« low ») et GEMINI_REFLEXION_CLASSIFICATION (« minimal ») : 12 s, dont 5,5 s de reponse. Vides, le modele choisit, comme avant. N'agit que sur Gemini 3 : les 2.5 reglent un budget et rejetteraient un niveau. Le flux (ask_stream) plafonnait encore a 1 000 jetons : meme piege que la reponse coupee, meme budget desormais. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>148f7c1
-
feat(extraction): Docling en local, OCR pleine page, cache reprenable
Docling remplace l'API Gemini, payante, comme extracteur par defaut (PDF_EXTRACTION_ENGINE=docling ; « gemini » reste disponible). - Chaque page est rasterisee (pypdfium2, 216 dpi) et passe par l'OCR pleine page : 42 % des pages n'ont pas de couche texte, et docling-parse rend NOIRES les 59 pages de scans masques (MRC) du corpus. - OCR RapidOCR sur torch, donc sur le GPU : mesure sur 97 pages contre Gemini et LlamaParse, il lit les numeros d'article la ou Tesseract en fausse (« ARTICLE 16 » lu « 13 »), a 3,8-4 s par page. - Conversion par lots de 8 pages, chaque lot mis en cache aussitot, brut : une interruption ne coute que le lot en cours, et ameliorer le nettoyage n'oblige jamais a refaire l'OCR. L'empreinte du cache porte les versions, les revisions des modeles et le rendu ; les lots se relisent quelle que soit leur taille. - Un lot en echec est rejoue ; au bout de TENTATIVES_MAX, il est repris page a page, et seule la page fautive est abandonnee et signalee. - F…69b13a2 -
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(embeddings): schema en 768 dimensions, index HNSW sur la colonne
La colonne articles.embedding passe de vector(3072) a vector(768) : la sortie native d'EmbeddingGemma, et une dimension que gemini-embedding-001 produit aussi. Un seul schema pour les deux fournisseurs. Le fournisseur par defaut reste gemini : ce commit ne change que le schema et ce qui en depend. LA MIGRATION DETRUIT TOUS LES VECTEURS (c4d5e6f7a8b9). C'est inevitable : un vecteur 3072 n'a pas d'equivalent dans l'espace d'un autre modele. Ici les bases etaient vides. Une base peuplee devra etre regeneree ensuite, et comme le conteneur lance `alembic upgrade head` a chaque demarrage, deployer ce code declenche la purge. La procedure est a ecrire avant tout deploiement. L'index HNSW se pose directement sur la colonne, sans le detour par une expression halfvec. Les anciens index sont supprimes AVANT l'ALTER : laisse en place, l'index halfvec survit a la conversion et ne sert plus a rien. UN PIEGE TROUVE EN CHEMIN, mesure par EXPLAIN. La recherche garde deux etages : l'index fournit des…195c1c3 -
feat(embeddings): EmbeddingGemma en local, present mais pas encore par defaut
Un second fournisseur d'embeddings, gratuit et sans reseau : EmbeddingGemma execute par onnxruntime. Gemini reste le defaut ; la bascule viendra apres celle du schema. INT8 ET NON Q4, SUR MESURE. q4 est 5,6 fois plus rapide par requete et prend 3,4 fois moins de memoire, mais sur 180 articles reels du Code Minier et 15 questions, il change le premier resultat pour 2 questions sur 15 et ~13 % du top-10. L'int8, lui, colle au fp32 de reference. La fidelite prime pour un corpus juridique ; q4 reste l'option d'un hebergement contraint en memoire. TROIS PIEGES QUI CASSENT EN SILENCE, chacun verrouille par un test verifie par mutation : - la sortie lue PAR SON NOM. La sortie 0 du graphe est last_hidden_state ; la moyenner donne un vecteur de 768, unitaire, et quasi orthogonal au vrai (cosinus 0,0188 mesure) ; - les prefixes de tache, au caractere pres. Sans eux le classement change ; - les jetons speciaux lus comme du texte. Un « <image_soft_token> » laisse par l'OCR donne l'id 26214…a5f02bb -
feat(chat): aiguillage d'intention, le RAG seulement pour le droit
Toute question partait au pipeline RAG, sans exception. A « comment vas tu ? », le modele rendait un pave a rubriques (« Source legale : l'information n'est pas presente dans les documents fournis ») sous un bloc Sources citant un article du Code Minier sans rapport. UN CLASSIFICATEUR DECIDE AVANT LA RECHERCHE. Quatre intentions — juridique, smalltalk, meta, hors_sujet — chacune avec sa reponse. Hors juridique : ni embedding, ni recherche hybride, ni source fabriquee. Le defaut sur toute panne est « juridique », donc le comportement anterieur : une saturation du fournisseur ne fait pas basculer la plateforme en bavardage. DEUX COURT-CIRCUITS SANS APPEL. Un numero d'article, et une salutation dont le message ENTIER est une formule de politesse. Egalite stricte, jamais par sous-chaine : « bonjour, puis-je divorcer ? » part au modele. LES REGLAGES SONT MESURES. INTENT_TIMEOUT_S passe de 3 a 10 s : sur six classifications reelles, mediane 3,3 s, maximum 6,5 s. A 3 s, cinq appels sur six…153dd7c
-
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
-
fix(extraction): un delai propre, un decoupage par poids, et des erreurs qui parlent
Trois defauts constates en re-extrayant le corpus pour de vrai. 1. UN MESSAGE D'ERREUR VIDE. La Loi de finances a echoue sur « Extraction de X impossible : » suivi de RIEN, ecrit tel quel dans `laws.processing_error`, colonne que l'administrateur lit. La cause etait un `httpx.ReadTimeout`, dont le `str()` est vide. `_decrire` nomme desormais l'exception quand elle est muette : un message qui ne dit rien coute autant a lire qu'un message juste. 2. LE DELAI ETAIT CELUI DU CHAT. `GEMINI_TIMEOUT_S` vaut 120 s, ce qui convient a une question de RAG et pas a un lot de vingt pages scannees. `PDF_EXTRACTION_TIMEOUT_S` (600 s) le remplace, et un depassement est desormais retente au meme titre qu'une saturation — il est transitoire de la meme facon. 3. LE DECOUPAGE IGNORAIT LE POIDS. Vingt pages scannees pesent 10 Mo la ou vingt pages de texte en pesent 1, et c'est le poids qui faisait deborder le delai. Le decoupage respecte maintenant DEUX bornes : le nombre…b66dd74
-
feat(extraction): remplacer LlamaParse par Gemini multimodal
La cle LlamaParse ayant ete retiree, l'ingestion de tout nouveau PDF levait et marquait le document `refused` — le bon echec, mais l'ingestion etait a l'arret. LE MOTIF DU CHANGEMENT EST LA PAGINATION, PAS LE PRIX. LlamaParse rend le document en un bloc sans signaler ses coupures de page ; `_split_markdown_pages` les reconstruisait en coupant sur les lignes de trois tirets, et sa propre docstring admettait la limite : « la pagination ne devient exacte qu'avec un moteur qui renvoie un numero de page explicite ». Mesure en base : les 710 articles portent tous `page_number = 1`, et sur les 27 lois, 271 pages de PDF ne donnent que 27 pages extraites — couverture moyenne 0,60. Un lien `?article=35` ouvre donc le PDF a la premiere page. MESURE AVANT D'ECRIRE, sur le Code Minier dont la sortie LlamaParse est en base : densite 2352 car/page contre 2405 — equivalent, rien n'est perdu marqueurs de page 1 par page contre 1 pour 69 pages lot pages 21 a 32 numerote 21…ca48b04 -
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(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(rag): le modele configure n'existait pas, et la recherche tombait au niveau loi
GEMINI_MODEL valait "gemini-3-flash", qui n'existe pas : l'API repond 404 NOT_FOUND. Le chat RAG ne pouvait donc produire AUCUNE reponse — chaque appel echouait avant meme d'atteindre le modele. Verifie contre ListModels sur ce compte, ou gemini-2.5-flash repond 404 lui aussi ; les modeles reellement disponibles pour generateContent sont gemini-3-flash-preview (retenu, le plus capable), gemini-3.1-flash-lite et gemini-2.5-flash-lite. Le chat repond desormais, avec ses sources. websearch_to_tsquery relie les mots par ET : "obligations dirigeants societe" exigeait les trois dans le MEME article, ce qu'aucun ne satisfaisait, alors que des dizaines d'articles parlent de societes. La recherche tombait donc directement au niveau LOI, et le RAG recevait un document entier sans identite d'article — la citation sortait sans numero et sans identifiant. Un second essai en OU est tente avant le repli niveau-loi ; ts_rank_cd continue de favoriser les articles portant le plus de termes, donc le rap…dfc6e36 -
fix(gemini): aucun delai maximal sur les appels, blocage indefini possible
genai.Client etait construit sans http_options, donc sans le moindre delai. Constate en conditions reelles pendant l'ingestion du corpus : le processus est reste 40 MINUTES endormi sur une lecture de prise reseau au milieu d'un lot d'embeddings — vivant, 16 secondes d'UC en une heure, aucune trace. Comme aucune exception n'est levee, la boucle de reprise du service ne se declenche jamais : le blocage est silencieux et sans fin. settings.GEMINI_TIMEOUT_S (120 s) est applique aux deux clients, celui des embeddings et celui de la generation. Un appel qui depasse leve desormais, ce qui reactive les reprises deja en place. Deux tests verifient que le delai est bien pose sur chaque client. Ajout de scripts/ingest_corpus.py, qui a servi a trouver le probleme : ingestion d'un echantillon graine du corpus prc.cm par la chaine complete (copie, extraction LlamaParse, decoupage, embeddings, tsvector). Echantillonnage stratifie a 70 % de textes normatifs : un tirage uniforme donne 52,6 % de decr…7dc0e8b -
feat(search): 3072 dimensions indexees via halfvec, recherche en deux etages
La migration precedente ramenait les embeddings a 1536 dimensions en partant du principe que 3072 etait inindexable. C'est vrai du type `vector`, plafonne a 2000 dimensions pour HNSW et IVFFlat. C'est FAUX du type `halfvec`, qui monte a 4000 — verifie sur ce serveur : pgvector 0.8.6, opclass hnsw/halfvec_cosine_ops presente, et pgvector.sqlalchemy expose HALFVEC. On garde donc la sortie native du modele, sans troncature : - la colonne repasse en vector(3072), fp32, valeurs exactes ; - l'index HNSW est pose sur l'EXPRESSION embedding::halfvec(3072), donc en fp16, ce qui le rend possible et divise sa taille par deux ; - la requete devient un deux-etages : parcours de l'index pour extraire des candidats sur-echantillonnes (x8, plancher 100, plafond 500), puis reclassement de ces candidats a la distance fp32 EXACTE. Le fp16 ne sert qu'a selectionner, jamais a classer. Quatre details portent le montage, et chacun a une variante fausse qui a l'air correcte : la distan…c6e37b4
-
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 -
feat(search): retrieval au niveau chunk, embeddings 1536 indexables
La recherche travaillait au niveau document et ne pouvait pas etre indexee. 1. Niveau chunk. postgres_search_service appliquait un SELECT DISTINCT ON (law_id) ... ORDER BY law_id : les articles etaient ecrases a un par loi, et l'ordre transmis a la fusion RRF n'etait plus celui de la pertinence mais celui des identifiants. Cote semantique, la distance etait calculee par article puis passee a func.max() + GROUP BY Law.* : l'article gagnant etait jete, le resultat sortait avec matched_articles vide, highlights vide et content nul — soit aucun texte pour le prompt. Nouveau schema ChunkResult, une ligne par article, avec son numero, son titre, sa section, sa page et son contenu integral. SearchResult est inchangee champ pour champ et desormais DERIVEE des chunks : le front n'a rien a changer et recoit enfin des matched_articles remplis. SearchResponse.chunks est additif, avec un defaut, donc les reponses deja en cache se deserialisent encore. 2. Filtre…72be68d -
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 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(ocr): extraction LlamaParse et raffinage des chunks pour le RAG
La couche texte des PDF scrapes depuis prc.cm est inexploitable : mesuree a ~20% de rappel sur 6 pages representatives (filigrane www.prc.cm insere au milieu des phrases, cachets officiels lus comme du charabia, et 21% des documents sans aucun texte extractible). Trois moteurs compares sur les memes pages, avec verite terrain relevee visuellement : moteur prose (46 phrases) tableau budgetaire couche texte existante 9/46 0/9 tesseract 5.5 local 34/46 0/9 mistral OCR 46/46 8/9 llamaparse cost_effective 46/46 9/9 Mistral OCR lisait "5 016 910 000" au lieu de "5 816 910 000" de facon reproductible sur 3 tirages — une corruption silencieuse inacceptable sur une base fiscale. LlamaParse retenu. - app/services/llama_parse_service.py : API v2, cache sha256 (une page n'est jamais payee deux fois), retry exponentiel, decoupe des fichi…29a6f4a
-
feat(migration): Replace Redis/Meilisearch/Celery with 100% native PostgreSQL FTS and FastAPI BackgroundTasks
c77abb9
-
chore: initial standalone backend for JuriX API (Render)
9cf9a2a