Spaces:
Runtime error
Runtime error
Commit History
22 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
24 days ago
-
feat(chat): chaque extrait dit sa date et si un texte plus recent l'a change
Le corpus garde chaque texte dans sa version publiee : le Code electoral de 2012 tel quel, et a cote la loi de 2026 qui en reecrit des articles. La recherche remontait souvent l'ancien article, et le chat le citait comme la regle en vigueur (« l'article 38 de la Loi N°2004/004 » pour le constat de la vacance de la presidence, reecrit en 2026). Archiver les textes anciens n'etait pas la reponse : la plupart de leurs articles restent applicables. - app/services/liens_entre_textes.py : 67 liens verifies (texte touche, texte recent, nature, articles, portee), chacun avec sa preuve citee : Constitution, Code electoral, Conseil constitutionnel, Cour supreme, traite OHADA, Code penal, Code civil (etat civil, suretes, vente et bail commerciaux, prescription...), liberte d'association, cybersecurite, foncier, incitations a l'investissement, lois de finances. Releves en base, contre-verifies, relus par une revue adversariale ; les liens ecartes le sont avec leur raison. - app/serv…c193c4e -
fix(classement): la marge de reflexion de gpt-oss passe de 400 a 1000 jetons
Le classement Groq accorde a chaque requete 400 jetons de marge, plus 45 par verdict. gpt-oss ne permet pas de couper tout a fait sa reflexion, qui se paie sur ce meme plafond. Le 2026-10-14, un lot de 40 titres est revenu coupe (« max_completion_tokens atteint »), puis ses deux moities de 20 aussi : couper le lot reduit le plafond avec lui, alors que c'est la reflexion qui deborde. Chaque essai brulait le quota du jour sans un verdict, et le reclassement du corpus est reste bloque pres d'une heure. Le meme defaut explique l'ordonnance 74/1 restee sans categorie a l'ingestion (« JSON invalide »). Avec 1000 jetons de marge, le lot bloque est passe au premier essai. Le plafond ne coute que ce qui est consomme ; seule la reservation locale du quota attend un peu plus avant de partir. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>bb1f445
27 days ago
-
fix(confidentialite): le journal d'une conversation reprise ne porte plus les profils
La ligne voisine relie la conversation au compte : les deux ensemble associaient un compte a sa profession. Releve par la revue adversariale. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>9626b84 -
fix(analytique): le profil general a ses statistiques, les alias du chat sont admis
Le chat repond desormais en profil general quand aucun profil n'est choisi. Seule la repartition d'usage le comptait : l'agregation quotidienne, /stats/all, /compare, les avis par profil et toutes les routes /personas/{persona}/... l'ignoraient ou repondaient 400. Les conversations sans profil disparaissaient des statistiques, et le total des avis ne valait plus la somme par profil. - validate_persona rend la forme canonique, avec les alias du chat (« etudiant » donnait 400 ici alors que le chat l'accepte) ; general=True admet le profil general la ou il a des donnees. La fiche descriptive (/personas/{persona}) reste reservee aux 4 profils. - Agregation, statistiques, comparaison et avis parcourent les 5 profils ; le profil general s'affiche « Profil general ». Releve par la revue adversariale du lot personas. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>0b43949 -
fix(prompts): l'etudiant s'en tient aux documents, des tests qui protegent vraiment
- Etudiant : « Tu peux rappeler une notion generale du droit » et « sa place dans la hierarchie des normes » invitaient a puiser hors du corpus, dans le meme appel que le message utilisateur qui l'interdit (« Base ta reponse UNIQUEMENT sur ces documents »). La notion se definit a partir des documents ; s'ils ne la definissent pas, le modele le dit au lieu de la definir de memoire. Le test « aucun profil ne pousse a inventer » couvre desormais ces formules. - Le test de la phrase-exemple passait des qu'une citation quelconque du prompt etait lue, contre-exemple compris. Il exige la phrase « l'article 5 du Code Minier et l'article 6 du Code Minier » et ses DEUX citations. - Le message sans resultat du citoyen etait compare a lui-meme (NO_RESULTS_MESSAGE en est construit) : le texte d'avant les profils est fige dans le test. Releve par la revue adversariale du lot personas. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>4472b60 -
fix(citations): deux citations dans une phrase, alinea 1er, la loi citee departage
Releves par la revue adversariale du lot personas ; les deux premiers defauts existaient avant lui, mais les nouveaux prompts y poussent. - Le texte cite s'arrete avant la citation suivante (« et l'article », « and Article »), a une parenthese ou un point-virgule. « l'article 5 du Code Minier et l'article 6 du Code Minier », la forme que les prompts demandent pour plusieurs articles, ne donnait qu'une source : le groupe du texte avalait la seconde citation. - « alinéa 1er », « alinéa premier », « paragraphe 2 » et « Article 33(2) » rendaient toute la citation illisible. - Le premier chunk portant le numero l'emportait quelle que soit sa loi : « l'article 5 du Code Minier » devenait l'article 5 d'un decret place avant lui. La loi citee departage desormais ; le numero seul ne sert que si la loi citee n'est reconnue nulle part dans le contexte, jamais pour l'article d'une autre loi presente. Regex lineaire : 300 000 caracteres adverses en moins de 200 ms. Tests : 9 nou…fba6673 -
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(prompts): quatre personas vraiment distincts, un profil general, registre adapte hors du droit
Les quatre profils ne differaient que de quatre ou cinq lignes de role, et deux poussaient a inventer (« jurisprudence pertinente si disponible », « contexte historique ») alors que le corpus n'en contient pas. Chaque profil dit desormais qui est l'interlocuteur, ce qu'il attend et comment lui repondre : - citoyen : mots simples, demarche, delai, autorite ; avocat si l'enjeu est serieux ; - avocat : vocabulaire exact, conditions, exceptions, sanctions, articulation des textes ; ni jurisprudence ni renvoi vers un avocat ; - entrepreneur : obligation, etapes, delais, montants ; obligatoire et facultatif distingues ; - etudiant : notion definie, raisonnement, place dans la hierarchie des normes ; - general (n'a pas repondu) : ne presume rien du lecteur. Le bloc partage (longueur, grammaire de citation, interdits) ne change pas. Un profil inconnu recoit le profil general, avec un avertissement, et non plus citoyen en silence. Les reponses conversationnelles portent une ligne d'audi…406ec90 -
fix(analytique): compteurs exacts des conversations reprises, le profil general compte
- update_interaction_metrics compte en SQL. Il lisait Conversation.messages par selectinload, mais la collection, deja chargee par _load_or_create_conversation avant l'ajout des messages du tour, etait relue perimee (expire_on_commit=False) : chaque conversation reprise comptait un echange de retard (conversation 1 de jurix_dev : 3 questions reelles, 2 comptees). Le contenu des messages n'est plus charge. - personas_used est une liste neuve : modifiee en place, une colonne JSON simple ne voyait pas le changement, et l'ajout n'etait jamais ecrit. - La repartition d'usage compte « general » : la part des visiteurs qui n'ont pas dit qui ils sont, sans laquelle les pourcentages ne totalisaient pas 100. Les trois tests ajoutes echouent sur l'ancien code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>08a963c -
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 -
fix(recherche): la passe floue compare les titres mot par mot
« peine de mort au cameroun » rendait en tete des decrets de pret, a egalite (0,959), devant les 11 articles du Code penal sur la peine de mort. La passe floue sur les titres, faite pour rattraper les fautes de frappe, comparait la requete ENTIERE : « de », « au » et « cameroun » font plus de la moitie de ses trigrammes, et tout titre en « ... de ... au Cameroun » passait le seuil (0,516 pour 0,5). Etiquetes correspondance de titre, ces decrets passaient devant toute correspondance d'article. - Chaque mot significatif (sans mots vides, ni « cameroun », « loi », « decret »...) doit se retrouver dans le titre, meme mal orthographie. Les fautes de frappe (« nominaton », « code miner ») restent rattrapees. - Le mode entre dans la cle du cache : une recherche texte et une recherche hybride de la meme requete se servaient l'une l'autre. - scripts/eval/evaluer_recherche.py rejoue la requete de la page sur les 102 questions relues et 10 requetes temoins. Mesure (102 questions), mode …baaafb1
-
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(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(intention): budget de sortie suffisant, nominations reconnues comme du droit
Mesure du classement d'intention par Groq (qwen3.8-27b) sur les 122 messages de tests/fixtures/intent_eval.json : - a 40 jetons de sortie, 2 messages sur 60 revenaient VIDES : le modele depense des jetons invisibles avant son verdict (128 factures pour un verdict de 15), et Groq repond alors 400 json_validate_failed. Ces messages partaient en « juridique » par defaut. Le plafond passe a 256 ; le quota compte l'usage reel. GroqService traduit desormais json_validate_failed en GroqReponseInvalideError : un lot de classement des lois se redecoupe au lieu d'arreter le reclassement comme une panne ; - 3 questions de droit sur 72 partaient en hors sujet : un nom de personne, « les universites d'Etat et leurs recteurs », « who was appointed governor of the Littoral region? ». Le corpus est fait aux deux tiers de decrets de nomination ; les consignes le disent maintenant en toutes lettres. Apres correction, sur les 122 : 121 justes (99,2 %), rappel « juridique » 1,000 (ex…c84fadf -
feat(eval): comparaison ministral-14b / 8b, et jeu d'evaluation par le modele du chat
scripts/eval/comparer_mistral.py mesure les deux modeles sur 20 questions du jeu d'evaluation relu et 4 comparaisons de regimes, au plus 48 appels : meme contexte pour les deux (la recherche est faite une fois), aucun appel a Groq, modele de secours coupe. Mesures : premier morceau, duree, jetons, fin, articles cites presents dans les extraits, JSON lisible et lignes remplies. Les reponses sont ecrites anonymisees (X/Y tires au sort) pour une relecture a l'aveugle des ajouts non sources. La regle de decision est ecrite avant la mesure : on garde le 14b, sauf si le 8b egale la justesse des citations a 2 points pres sans ajouter plus de contenu non source. generate_eval_set.py passe par get_llm_service() au lieu de Gemini, avec un systeme neutre : sans lui, l'instruction du chat exigeait « Sources: » au milieu d'un JSON. MistralService.generate_stream accepte un rappel `usage` (jetons du flux, modele qui a repondu), pour les mesures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.…f565440 -
fix(comparaison): grille au format JSON impose, second essai si elle est coupee
Mistral recoit le schema de la grille en mode strict (voir le commit precedent) ; le prompt nomme en plus chaque cle attendue et le mot JSON. Sans cela, la grille revenait sous d'autres cles et chaque case s'affichait « Non trouvé ». Une grille coupee (tronquee) ou illisible est redemandee une fois, a 16 384 jetons au lieu de 8 192 : le JSON coupe ne se recolle pas, et l'utilisateur recevait une erreur apres une minute d'attente. Un critere sans reponse reste affiche comme absent, et il est journalise. Le re-ranking par le modele recoit un systeme neutre : sans lui, `generate` retombait sur l'instruction du chat, qui exige de finir par « Sources: », au lieu des notes JSON demandees. Test reel sur demande (mistral_live, un appel) : JSON valide, une ligne par critere. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>f8caf8a -
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 -
fix(imports): un seul choix du fournisseur, et chaque fichier importe ce qu'il utilise
La comparaison et l'explication d'article lisaient settings.LLM_PROVIDER sans importer settings. Rien n'echouait a l'import : le nom n'etait resolu qu'a la construction du service, donc chaque /compare et chaque /explain-article repondait 500 des qu'aucune doublure n'etait injectee — en production, jamais dans les tests. Le choix du fournisseur, recopie trois fois, vit desormais dans app/services/llm.py : get_llm_service() rend Mistral ou Gemini selon LLM_PROVIDER, et les erreurs sont regroupees par nature (ERREURS_QUOTA, ERREURS_SATURATION, ERREURS_LLM), quel que soit le fournisseur. Les messages « Please set up Gemini API » deviennent neutres. ruff rendait 18 signalements (2 noms inconnus, 14 imports inutiles, 2 blocs mal ordonnes) ; il en rend zero. Les __import__("sqlalchemy") et __import__("datetime") deviennent de vrais imports. Garde permanente, tests/test_qualite/test_imports.py : ruff doit sortir a zero sur app, scripts, tests et alembic, et chaque module de app doit s'impo…e77f0a8 -
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 -
fix(chat): le flux fait ce que fait ask, avant que l'interface l'emprunte
POST /rag/ask/stream divergeait de /rag/ask : - il ignorait le document ouvert (law_id) et la reponse « article absent », et n'avait pas de recherche de repli ; - sans citation reconnue, il ne rendait aucune source ; - une reponse coupee par le budget n'etait pas signalee ; - un quota epuise ou une saturation arrivait comme une erreur quelconque. Il passe desormais par _retrieve_and_merge_context et _fallback_search, fabrique les memes sources de repli, ajoute la meme mention de troncature (generate_stream rapporte sa raison d'arret) et porte un error_code (quota, overloaded, server) : le statut HTTP d'un flux est fige a 200 des le premier octet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>5214b46 -
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 -
fix(chat): les citations dans les phrases, pas en liste finale
Le modele terminait parfois par une liste brute de references, affichee telle quelle (« l'article 33 de la Loi N°2023/014 portant Code Minier l'article 34 de la Loi... »), alors que l'interface montre deja les sources sous la reponse. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>6b8bb1e -
fix(recherche): les termes rares d'abord quand aucun article n'a tous les mots
Quand aucun article ne porte tous les mots, la recherche plein texte passait en OU. Or ts_rank_cd ignore la rarete d'un terme : le mot banal l'emportait. Sur le corpus, « sanctions harcelement sexuel » placait en tete les sanctions disciplinaires des etudiants (« sanctions » repete, aucun harcelement), devant l'article 302 du Code penal ; la fusion hybride les gardait devant, et le chat les citait en source. Avant le OU, on retire desormais les termes du plus frequent au plus rare (une requete compte leur nombre d'articles, par l'index GIN) : la premiere combinaison qui trouve des articles passe en tete, le OU complete a la suite. Le Code penal art. 302 et le Code de justice militaire art. 49 sortent premiers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>c4a75e6 -
fix(chat): une reponse coupee ne passe plus pour complete
La reflexion du modele est facturee sur le meme budget que la reponse. Mesure sur « sanctions du harcelement sexuel » : 2 804 jetons de reflexion pour 695 de reponse. A 4 096, la meme question s'arretait, selon les essais, au milieu d'une phrase — sans erreur, et rien ne le signalait. Budget de reponse porte a 8 192. Quand il s'epuise malgre tout, GeminiService le journalise avec le detail des jetons et le signale (« tronquee ») ; le chat ajoute une mention au lecteur. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>1c2a6f4 -
fix(decoupage): l'article 1er illisible ne sort plus de l'index vectoriel
Le cachet et le sceau recouvrent souvent, dans la marge, le marqueur de l'article 1er. Illisible, l'article partait avec les visas (base legale), ou se collait a la formule d'execution quand c'est l'article 2 qui etait illisible — dans les deux cas hors de l'index vectoriel. Audit du decoupage sur les 2238 documents extraits : 53 n'avaient AUCUN vecteur. Apres correction : 1 (une page que l'OCR n'a pas su lire), et 83 articles reconnus en plus, aucun en moins. - Ce qui suit « DECRETE : », « ARRETE : » ou la formule de promulgation devient un chunk DISPOSITIF, traite comme un article ; a defaut de ces mots, ce qui suit le dernier visa, s'il fait une vraie phrase. - Un chunk n'est une formule d'execution que s'il ne reste presque aucun mot hors des paragraphes qui la portent. - Marqueurs ecorches releves sur tout le corpus : « ARTICIE », « ARTCLE », « RTICLE », « ARTTICLE », « Artice », « ARTI CLE1er », « Article ler », et prefixes parasites (« ■ ARTiCLE », « ww ARTICLE », « 1…f8209f9
-
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 -
fix(recherche): aucun pseudo-numero affiche comme un article
La recherche, le chat et l'assemblage du contexte tenaient chacun leur liste de pseudo-numeros (PREAMBULE, LEGAL_BASIS), et aucune ne connaissait SECTION_n, PARA_n, ni les nouveaux SIGNATURE et ANNEXE : le front aurait affiche « Article SECTION_1 ». Ils partagent desormais est_pseudo_numero. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>f739f88 -
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 -
fix(langue): fastText fonctionne de nouveau sous NumPy 2
FastText.predict (fasttext-wheel 0.9.2) appelle np.array(probs, copy=False), qui leve sous NumPy 2 : fastText echouait a chaque detection, seul langdetect repondait, et le consensus ne passait jamais a vrai. La liaison C++ model.f.predict rend une liste Python, sans numpy. NumPy passe en 2.x : opencv-python, que tire Docling via rapidocr, l'exige. Le plafond <2 ne servait qu'a fastText. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>57d3657 -
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 -
style: ordre des imports et import inutilise laisses par mes commits du jour
ruff signalait trois ecarts dans des fichiers touches par 153dd7c et ad3ae33 : deux blocs d'imports mal ordonnes et un `Optional` importe sans usage. Aucun changement de comportement. Restent deux signalements anterieurs, dans tests/test_main.py et tests/test_migrations/test_email_tokens_schema.py, laisses en l'etat. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>a9c1b19 -
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 -
refactor(embeddings): un fournisseur interchangeable, comportement inchange
Le service parlait directement a Gemini. Il delegue desormais a un FOURNISSEUR et garde tout le reste : validation, cache, gardes de dimension, normalisation, reprises, ordre des lots. POURQUOI. Le modele d'embeddings est le composant que JuriX changera le plus souvent — gratuit en local pour le lancement, meilleur quand les moyens le permettront. Le changer doit etre un reglage, pas un chantier. LE CONTRAT est ecrit sur GeminiProvider : name, label, model, fingerprint, native_dim, retryable, inter_batch_delay_s, et embed(), qui rend exactement un vecteur par texte et laisse remonter ses exceptions brutes. LA CLE DE CACHE PORTE L'EMPREINTE DU FOURNISSEUR (v3 -> v4). Elle portait le nom du modele Gemini, fige a l'import : deux fournisseurs a la meme dimension se seraient resservi leurs vecteurs pendant sept jours, et la garde de dimension, aveugle a 768 = 768, n'aurait rien vu. Le test qui le verrouille a ete verifie par mutation : avec l'ancienne cle, il tombe. UNE DIMENSION TROP G…3a66be4 -
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 -
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(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
-
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 -
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(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