Compare commits

..

109 Commits

Author SHA1 Message Date
13f4b994ab Ajout de postgres sur git et gitea en conteneur docker pour pouvoir exécuter les tests unitaires concernant la BDD
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Successful in 1m51s
Build & Push Images / tests (push) Successful in 2m25s
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 21s
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 22s
Build & Push Images / build (brain) (push) Successful in 1m16s
Build & Push Images / build (core) (push) Successful in 3m20s
Build & Push Images / build (web) (push) Successful in 1m41s
Build & Push Images / build-switcher (push) Successful in 39s
2026-06-18 16:28:10 +02:00
d1653b8bea Ajout du mvn wrapper dans le workflow ; ajout d'un gate keeper coté workflow git afin d'éviter de build le .msi si des tests échoues
Some checks failed
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Failing after 1m40s
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 19s
Build & Push Images / tests (push) Failing after 1m26s
Build & Push Images / build (brain) (push) Has been skipped
Build & Push Images / build (core) (push) Has been skipped
Build & Push Images / build (web) (push) Has been skipped
E2E Tests / e2e (push) Has been cancelled
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 24s
Build & Push Images / build-switcher (push) Has been skipped
2026-06-18 16:13:36 +02:00
4d049274f9 Refacto du code coté Python, java et coté angular afin de mieux séparer les responsabilité et d'avoir moins de répétitivité dans le code.
Some checks failed
E2E Tests / e2e (push) Waiting to run
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 26s
Build & Push Images / tests (push) Failing after 13s
Build & Push Images / build (brain) (push) Has been skipped
Build & Push Images / build (core) (push) Has been skipped
Build & Push Images / build (web) (push) Has been skipped
Build & Push Images / build-switcher (push) Has been skipped
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 1m12s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Failing after 1m28s
Mise en place de tests unitaires coté Python et Angular
Mise en place de la couverture de test directement dans le workflow : le programme ne build pas si jamais un test échoue
Passage en v0.16.2 en conséquence
2026-06-18 15:59:10 +02:00
eb78a75621 Ajout du binaire tesseract pour la reconnaissance OCR des PDF pour l'installation local
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m10s
Build & Push Images / build (core) (push) Successful in 3m8s
Build & Push Images / build (web) (push) Successful in 1m41s
Build & Push Images / build-switcher (push) Successful in 39s
Correction d'un problème d'écrasement de BDD à la réinstallation
2026-06-18 10:28:31 +02:00
f04ecf1021 Passage v0.16.0
Some checks are pending
E2E Tests / e2e (push) Waiting to run
Build & Push Images / build (brain) (push) Successful in 1m14s
Build & Push Images / build (core) (push) Successful in 3m16s
Build & Push Images / build (web) (push) Successful in 1m57s
Build & Push Images / build-switcher (push) Successful in 40s
2026-06-18 09:52:53 +02:00
72fe5e6215 Mise en place de l'import / export des données pour pouvoir sauvegarder les lores / campagnes 2026-06-18 09:49:34 +02:00
7dfa9c3655 Mise à jour v0.15.1
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m36s
Build & Push Images / build (web) (push) Successful in 1m43s
Build & Push Images / build-switcher (push) Successful in 41s
Build & Push Images / build (core) (push) Successful in 3m10s
2026-06-17 18:24:51 +02:00
7aa174d75a Mise à jour du workflow pour forcer le declenchement à la main si par malheur il ne tourne pas à l'avenir automatiquement 2026-06-17 18:24:18 +02:00
48baa08cfb Mise en place d'un installeur pour la version bureau sans passer par Docker.
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m25s
Build & Push Images / build (core) (push) Successful in 2m55s
Build & Push Images / build (web) (push) Successful in 1m49s
Build & Push Images / build-switcher (push) Successful in 39s
Permet d'utiliser Loremind sans passer par Docker et sans lancer tous les conteneurs
Passage en v0.15.0
2026-06-17 18:04:33 +02:00
f1c68634f7 Merge branch 'beta'
Some checks failed
E2E Tests / e2e (push) Failing after 1h1m2s
2026-06-16 15:16:19 +02:00
1e501e03a4 Mise en place du readme en anglais 2026-06-16 15:15:48 +02:00
9d4e72af26 Mise à jour du gitignore pour ne pas avoir la documentation réservée patreon dans le répertoire général 2026-06-16 14:13:47 +02:00
1fb4563557 Redécoupage des fichiers et sortie des prompts dans leurs propre fichiers pour ne pas tout mélanger ensemble.
On garde malgrès tout les promps à coté des parseurs car ils évoluent généralement ensemble.
2026-06-15 10:16:01 +02:00
84025911f8 Prise en compte du langage de l'utilisateur pour le prompt de réponse. Si par exemple l'interface est en anglais, les IA vont favoriser l'anglais pour la réponse 2026-06-15 09:49:05 +02:00
bf871852b8 Mise à jour de l'orchestrateur
Some checks failed
E2E Tests / e2e (push) Failing after 2m31s
2026-06-14 17:18:48 +02:00
78e735c959 Mise à jour de la partie watchtower ; pas nécéssaire tout le temps
Some checks are pending
E2E Tests / e2e (push) Waiting to run
2026-06-14 16:51:46 +02:00
c734de447f Merge branch 'beta'
Some checks failed
E2E Tests / e2e (push) Failing after 24s
Build & Push Images / build (brain) (push) Successful in 1m42s
Build & Push Images / build (core) (push) Successful in 1m52s
Build & Push Images / build-switcher (push) Successful in 53s
Build & Push Images / build (web) (push) Successful in 2m0s
2026-06-14 16:32:51 +02:00
914767f793 Montée version v0.14.0-beta
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m49s
Build & Push Images / build (core) (push) Successful in 2m12s
Build & Push Images / build-switcher (push) Successful in 16s
Build & Push Images / build (web) (push) Successful in 1m58s
2026-06-14 16:24:52 +02:00
af3a6d443c Mise en place de l'anglais comme deuxième langue pour l'application
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-06-14 16:24:05 +02:00
6e75326779 Amélioration de la couverture de tests pour la partie infrastructure.ai 2026-06-14 11:45:20 +02:00
d0b53bb15a Ajout, corrections et modifications de tests unitaires pour la partie infrastructure.web.controller.
Amélioration de la couverture de test
2026-06-14 11:25:29 +02:00
bbcb5ee34e Correction du test CampaignStructuralContextBuilderTest
Ajout du mock EnemyRepository manquant (dependance ajoutee au
constructeur lors du referencement des ennemis). Sans ce mock,
@InjectMocks injectait null -> NPE sur enemyRepository.findByCampaignId.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:11:27 +02:00
c77c0bc994 référencement des ennemis dans les lieux d'une quête ou d'un chapitre
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m33s
Build & Push Images / build (core) (push) Successful in 1m58s
Build & Push Images / build-switcher (push) Successful in 17s
Build & Push Images / build (web) (push) Successful in 1m53s
2026-06-13 11:08:16 +02:00
6035df262d Ajout de la possibilité de faire des stats blocs pour tout ce qui est ennemis / créatures adverses.
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m32s
Build & Push Images / build (core) (push) Successful in 1m52s
Build & Push Images / build-switcher (push) Successful in 20s
Build & Push Images / build (web) (push) Successful in 1m48s
Dorénavant, l'IA est capable de prendre en compte le format des quêtes, chapitres, Arc.... pour proposer des blocs plus complets.
Les ennemis sont également référençables directement dans la campagne.
Les références vers les ennemis dans la partie "donjon" est en cours d'ajout
2026-06-12 23:38:43 +02:00
809e00ce49 Ajout de la possibilité d'archiver le chat dans l'atelier PDF + IA, ainsi que de référencer l'archive dans la conversation actuelle.
All checks were successful
Build & Push Images / build (core) (push) Successful in 1m47s
Build & Push Images / build (brain) (push) Successful in 1m52s
Build & Push Images / build-switcher (push) Successful in 27s
Build & Push Images / build (web) (push) Successful in 1m57s
Le chat est limité à 16 000 caractères pour l'archive et le début est tronqué pour laisser plutôt la conclusion en visibilité.
Passage bêta 0.12.6
2026-06-12 16:57:57 +02:00
bc0cbb0f7b Correction sur le NotebookController....
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m34s
Build & Push Images / build (core) (push) Successful in 2m1s
Build & Push Images / build-switcher (push) Successful in 20s
Build & Push Images / build (web) (push) Successful in 1m50s
2026-06-12 16:05:34 +02:00
6740ed2177 Mise en place de la sélection des source que l'on souhaite que ce soit la partie RAG ou la partie analyse approfondie : on est plus obligé d'envoyer tous les PDFs qu'on a dans la partie atelier PDF + IA.
Some checks failed
Build & Push Images / build (brain) (push) Successful in 1m41s
Build & Push Images / build (core) (push) Failing after 1m46s
Build & Push Images / build-switcher (push) Successful in 26s
Build & Push Images / build (web) (push) Successful in 1m54s
Les réponses ne ce baseront que sur les sources que l'on aura cocher au préalable
2026-06-12 15:58:45 +02:00
8cc90bd24d Amélioration du feedback pendant les imports sur les PDF
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m38s
Build & Push Images / build (core) (push) Successful in 1m59s
Build & Push Images / build-switcher (push) Successful in 17s
Build & Push Images / build (web) (push) Successful in 1m54s
passage en 0.12.4-beta
2026-06-12 14:35:10 +02:00
14fc1c28fe Ajout de tableaux dans la partie templates / pages de lore : possibilité d'ajouter un tableau multiligne (par exemple pour faire des tableaux d'objets dans les boutiques) ; tableau type liste clé / valeur (pour des statistiques et ce genre de chose).
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m36s
Build & Push Images / build (core) (push) Successful in 1m53s
Build & Push Images / build-switcher (push) Successful in 25s
Build & Push Images / build (web) (push) Successful in 1m59s
Ajout de la possibilité de lié un PNJ à une page de lore
Ajout d'un graphe de liaison entre lore / PNJs
Passage en v.0.12.3-beta
2026-06-12 13:23:46 +02:00
7f519588b6 Amélioration de l'exploitation des PDF par l'IA
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m44s
Build & Push Images / build (core) (push) Successful in 2m0s
Build & Push Images / build-switcher (push) Successful in 23s
Build & Push Images / build (web) (push) Successful in 1m50s
Amélioration des feedbacks en cas d'erreur d'exploitation des PDF
2026-06-12 01:28:45 +02:00
0799c850ec On essai de contraindre le modèle utilisé par ollama à répondre dans un certain format et ne plus mettre à l'interieur sa "réflexion"
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m30s
Build & Push Images / build (core) (push) Successful in 1m56s
Build & Push Images / build-switcher (push) Successful in 26s
Build & Push Images / build (web) (push) Successful in 1m44s
2026-06-11 15:51:22 +02:00
113df6a391 amélioration import ollama
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m59s
Build & Push Images / build (core) (push) Successful in 1m59s
Build & Push Images / build-switcher (push) Successful in 29s
Build & Push Images / build (web) (push) Successful in 1m59s
2026-06-11 15:29:28 +02:00
a1f3b9b796 Améliorations sur l'utilisation de l'IA pour l'exploitation des PDF, que ce soit la partie cloud ou la partie ollama + montée en version
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m43s
Build & Push Images / build (core) (push) Successful in 1m51s
Build & Push Images / build-switcher (push) Successful in 26s
Build & Push Images / build (web) (push) Successful in 1m46s
2026-06-11 01:31:24 +02:00
cff2ceb0b9 Ajout des dépendance dans le package.json pour forcer les dépendances sur les bonnes versions
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m45s
Build & Push Images / build (core) (push) Successful in 1m58s
Build & Push Images / build-switcher (push) Successful in 25s
Build & Push Images / build (web) (push) Successful in 1m51s
2026-06-10 16:38:25 +02:00
d1a11823bc Regénération du package-lock avec les bonnes versions
Some checks failed
Build & Push Images / build (brain) (push) Successful in 1m34s
Build & Push Images / build (core) (push) Successful in 1m57s
Build & Push Images / build-switcher (push) Successful in 20s
Build & Push Images / build (web) (push) Failing after 54s
2026-06-10 16:32:37 +02:00
e1da369cfa Regénération du package-lock pour résorber les problème sur git actions
Some checks failed
Build & Push Images / build (brain) (push) Successful in 1m42s
Build & Push Images / build (core) (push) Successful in 1m58s
Build & Push Images / build-switcher (push) Successful in 24s
Build & Push Images / build (web) (push) Failing after 46s
2026-06-10 16:22:46 +02:00
177bf6e781 Montée version 0.12.0-beta
Some checks failed
Build & Push Images / build (brain) (push) Successful in 1m40s
Build & Push Images / build (core) (push) Successful in 1m57s
Build & Push Images / build-switcher (push) Successful in 24s
Build & Push Images / build (web) (push) Failing after 48s
2026-06-10 16:11:01 +02:00
0303786aef Corrige deux regressions de demarrage de la montee Spring Boot 3.5
- ArcJpaEntity.type : columnDefinition VARCHAR(16) DEFAULT remplace par
  length=16 + @ColumnDefault. Hibernate 6.6 recopiait la chaine brute dans son
  ALTER SET DATA TYPE de migration (DDL invalide pour PostgreSQL, retente
  a chaque demarrage).
- MinioConfig : la verification du bucket construisait le client via la
  methode @Bean proxifiee depuis @PostConstruct, interdit par Spring 6.2
  (Requested bean is currently in creation), le check ne tournait plus.
  Fabrique privee directe partagee par le bean et la verification.

Verifie : 507 tests verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 16:09:12 +02:00
7dec288829 Chat atelier : reranking LLM d un pool elargi (opt-in RAG_RERANK)
Recupere 3x top_k passages (max 24), fait noter leur pertinence par le LLM
en un appel (temperature 0, extraits tronques a 600 car.), garde les top_k
mieux notes (tri stable : a note egale l ordre cosinus est preserve).
Best-effort : echec LLM ou notes inexploitables -> classement cosinus.
Desactive par defaut (+1 appel avant le premier token) ; recommande avec
un provider cloud rapide via RAG_RERANK=true.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 15:52:50 +02:00
e26d11a99f Analyse approfondie : index de resumes pour ne relire que les lots pertinents
A la premiere analyse d une source, chaque lot est resume (1 appel LLM,
cache disque, purge avec la source) et son resume embedde. Aux questions
suivantes, la question est comparee aux resumes et seuls les lots proches
du meilleur score (marge 0.10, plancher 3 lots) sont relus -> 3-5x moins
d appels sur un gros livre pour les questions ciblees. Selection
volontairement conservatrice ; best-effort (tout echec -> plein scan) ;
desactivable via DEEP_SUMMARY_FILTER=false (exhaustivite maximale).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 15:51:09 +02:00
3d1cf6e495 Chat atelier : evenement SSE sources + affichage des pages utilisees
Le Brain emet un evenement sources (source_id, page, score des passages
retenus) AVANT le premier token ; le Core le relaie tel quel (JSON brut) ;
l UI affiche une ligne discrete sous la reponse (ex: 12, 47, 103),
prefixee du nom de fichier si plusieurs sources. Transparence pour le MJ
et diagnostic immediat quand le RAG repond a cote.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 15:45:56 +02:00
0e4820a2f8 Ateliers : reecriture de la question en question autonome avant recherche
Sur une relance conversationnelle (et ses faiblesses ?), l embedding du
dernier message seul ne contient pas le sujet -> retrieval aveugle. Un appel
LLM leger (temperature 0, uniquement a partir du 2e tour) resout les
references implicites depuis l historique ; la question autonome sert a la
RECHERCHE (chat RAG + phase MAP de l analyse approfondie), la reponse finale
voit toujours l historique complet. Best-effort : tout echec retombe sur la
question brute (comportement historique).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 15:37:56 +02:00
092898e379 Import campagne : detection des PNJ et creatures notables (bout en bout)
Brain : la phase MAP recense les PNJ NOMMES et creatures uniques (pas les
monstres generiques) avec une courte fiche fidele au livre ; dedoublonnage
inter-morceaux (description la plus complete gardee) ; payload done + npc_count.

Core : CampaignImportProposal.npcs + NpcProposal ; a l apply, creation comme
Npc de la campagne (description -> values[Description], meme convention que
les cartes d action des ateliers) avec anti-doublon par nom.

Web : section de revue PNJ a cases a cocher (coches par defaut, grises si
deja presents), compteurs de progression et recapitulatif mis a jour.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 15:36:17 +02:00
5061457f76 Import campagne : passe de consolidation finale des quasi-doublons
Apres la fusion des morceaux, un unique appel LLM sur le SQUELETTE de l arbre
(noms seuls, quasi gratuit) detecte les chapitres/scenes en double sous des
libelles legerement differents et les fusionne (contenus accumules, rooms
reunies). Consigne conservatrice (doute = pas de fusion) et best-effort
strict : toute erreur laisse l arbre tel quel, jamais d import perdu.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 15:25:35 +02:00
c1811b0040 Import campagne : narration et notes MJ accumulees au lieu de premier-gagne
Une scene coupee entre deux morceaux perdait la moitie de ses gm_notes /
player_narration (la regle premier-non-vide-gagne jetait la suite). Les deux
champs sont desormais CONCATENES, avec dedoublonnage (overlap relu) et
remplacement si un morceau apporte une version plus complete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 15:19:42 +02:00
8369886f42 Parallelisation des appels MAP (import campagne + analyse approfondie)
Les morceaux/lots sont traites par vagues de llm_map_concurrency appels
simultanes (defaut 3, .env). L ordre narratif est preserve (fusion vague par
vague dans l ordre du livre), la resilience par morceau et les heartbeats SSE
sont conserves. Divise le temps d import d un gros livre par ~3 sur un
provider cloud ; sans effet sur Ollama local (qui sequence cote serveur).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 15:18:51 +02:00
e7aa67bc42 Import campagne : la table des matieres du PDF guide la structuration
Les bookmarks PDF (doc.get_toc, gratuits) sont injectes dans chaque prompt MAP
comme referentiel commun de nommage : les morceaux traites separement nomment
desormais les memes chapitres a l identique, et la fusion par nom du _TreeMerger
recolle les chapitres coupes au lieu de creer des doublons. Bornee (2 niveaux,
80 entrees) pour ne pas gonfler le prompt ; absente sur les scans -> inchange.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 15:15:50 +02:00
91069525a5 RAG : prefixes de tache nomic-embed (search_document / search_query)
nomic-embed-text est entraine avec des prefixes de tache distincts pour le
corpus et la question ; sans eux la pertinence du retrieval est degradee.
Applique uniquement aux modeles nomic (les autres restent neutres).
NB : les sources indexees avant ce changement doivent etre re-uploadees.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 15:10:39 +02:00
23878f1c63 Migration Angular 20 -> 21 : fin de la migration securite (0 vulnerabilite npm)
- ng update @angular/core@21 @angular/cli@21 (corrige GHSA-jrmj-c5cx-3cw6, XSS SVG)
- Migration automatique des templates vers le control flow natif (@if/@for, 138 fichiers)
- Correction des 5 `track` invalides generes par la migration (trackBy a 1 argument
  -> track $index) + suppression des fonctions trackBy mortes
- TypeScript 5.9, zone.js 0.15 ; npm audit : 0 vulnerabilite

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:55:36 +02:00
6acad41672 Migration Angular 19 -> 20 (ng update, build OK)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:49:39 +02:00
05bbe64841 Migration Angular 18 -> 19 (ng update, standalone par defaut, build OK)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:47:19 +02:00
d772e969ea Migration Angular 17 -> 18 (ng update, build OK)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:45:15 +02:00
04816ae9df Mise a jour des dependances vulnerables (CVE)
Core : Spring Boot 3.2.12 (EOL) -> 3.5.14 (Tomcat 10.1.54, Spring 6.2.18,
Security 6.5.10, Netty 4.1.132), nimbus-jose-jwt 9.40 -> 10.9.1
(CVE-2025-53864), tink 1.14.1 -> 1.21.0 (protobuf CVE-2024-7254),
bcprov 1.78.1 -> 1.84, minio 8.5.11 -> 8.6.0 (okhttp -> okhttp-jvm 5.1.0),
commons-lang3 force a 3.20.0 (CVE-2025-48924).

Brain : pin explicite starlette>=0.49.1 (CVE-2025-54121, CVE-2025-62727) —
fastapi n'exige que >=0.46, le commentaire precedent etait une fausse garantie.

Verifie : 507 tests verts + JaCoCo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:42:13 +02:00
49a94bb73e Refactor SRP : decoupage de main.py (Brain) en routers et du SettingsComponent (web)
Brain : main.py (1496 l.) reduit a l'assemblage (~95 l.) ; un router par
responsabilite (generation, chat, tables, imports, notebooks, settings, models),
factories DI dans api/deps.py, DTOs chat + mapping anti-corruption separes,
auto-pull embeddings deplace en infrastructure. Chemins HTTP inchanges.

Web : SettingsComponent (729 l.) recentre sur le formulaire (~330 l.) ;
sous-composants standalone updates-section (MAJ + licence Patreon + switch
canal) et ollama-model-manager (liste/pull/suppression de modeles).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:41:43 +02:00
b0e8fade03 Optimisation du RAG des ateliers : seuil de pertinence, recherche hybride, cache et overlap
- Seuil rag_min_score (defaut 0.30) : plus d'extraits hors-sujet injectes dans le prompt
- Recherche hybride : cosinus + bonus lexical (noms propres JdR mieux retrouves)
- Cache memoire du vector store (invalidation mtime) : plus de re-parse JSON par question
- Overlap de 80 tokens entre extraits RAG consecutifs (phrases a cheval retrouvables)
- Script de non-regression brain/scripts/sanity_rag_check.py

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:41:25 +02:00
341f6a5aae Suppression des credentials de test du fichier application.properties
Les identifiants sont desormais fournis via variables d'environnement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 01:46:07 +02:00
6c7dbff6a0 passage version v0.11.3-beta
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-06-08 17:01:05 +02:00
833280c784 Amélioration de l'IA pour la partie atelier PDF
Mise en place d'un outil permettant de faire des tableau d'objets pour des boutiques par exemple
2026-06-08 17:00:22 +02:00
70ec1f2fb9 Modification du timeout pour permettre d'analyser de plus gros livres
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-06-08 14:49:00 +02:00
f638fdd24b Mise à jour d'un test unitaire qui ne passait plus avec la mise à jour précédente
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-06-08 11:11:50 +02:00
e85ab0e6b1 passage version 0.11.1-beta 2026-06-08 11:05:48 +02:00
ed22d9f29c Résolution d'un bug de switch entre les PNJ lorsqu'on essai de passer directement de l'un à l'autre
Ajout de dossiers pour la partie PNJ pour qu'on puisse les regrouper par cité par exemple et que ce soit plus lisible
2026-06-08 11:05:07 +02:00
edc4434298 Plusieurs gros ajouts :
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
- Possibilité de discuter avec un PDF ; RAG ou analyse approfondie. Enlèvement de l'autre outil PDF de discussion qui analysait d'abord un PDF en proposant directement une intégration sans attendre qu'on pose de question
- Mise en place de l'import directement dans les outils dans la sidebar
- Mise en place d'un outil pour créer des tables aléatoires avec possibilité d'utiliser pendant la partie
- Mise en place d'un outil pour mettre en place des PNJ, scènes, chapitre.... directement à partir de la discussion avec le PDF
- Mise en place RAG avec mistal-embeding ou nomic si on utilise ollama
- Mise en place mistral, google en fournisseurs alternatifs pour l'IA dans le cloud
- version 0.11.0-bêta
2026-06-07 09:52:15 +02:00
5eb15dc449 passage 0.10.4
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-06-05 16:54:41 +02:00
da5b602f20 Meilleure gestion des erreurs, moins permissif au niveau de la créativité du modèle. 2026-06-05 16:54:28 +02:00
9ea43a1889 Ajout d'open router en fournisseur IA ; ajout de la possibilité de mettre des conditions de déverouillage pour les chapitres optionnels
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-06-05 00:23:19 +02:00
211e26dae1 changement de version
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-06-04 14:14:19 +02:00
53065c952b Correction de la configuration nginx pour ne pas bloquer un gros PDF 2026-06-04 14:13:41 +02:00
6d00543a59 Ajout de 2 fonctionnalitées principales : import PDF que ce soit pour les règles ou les campagnes directement.
Some checks failed
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
Fonctionnalité de comparaison PDF / campagne pour faire un mix et demander des conseils à l'IA
2026-06-04 13:55:27 +02:00
091de0daf7 Mise à jour des tests unitaires et passage en v0.9.2-beta en conséquence.
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-06-03 15:56:26 +02:00
504c4b7b6c Plusieurs ajouts :
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
- Possibilité de configurer des lieux dans une scène : permet de configurer un donjon par exemple avec les pièces, les trésors par pièce, la narration..... Mise en place également de conditions permettant de conditionner le déblocage des quêtes.
- Possibilité de transformé un arc en instance non linéaire afin de faire un hub. Permet de jouer de préparer des campagnes type Dragon of Icespire peak plus facilement.
- Configuration de partie : chaque partie va contenir les séances, ce qui permettra de suivre le déblocage des conditions pour les quêtes.

Passage en 0.9.1-beta
2026-06-03 15:44:48 +02:00
e3fc96a1bc Ajout d'un mode "jeu" (possibilité de lancer des sessions dans une campagne). Cela permet de faire de prendre des notes en live au cours d'une partie et d'avoir plusieurs outils sous la main pour aider le mj :
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
- Possibilité de parler à une IA pour règle de jeu ou élément de lore / campagne au cours d'une partie comme aide mémoire
- Onglet dédié aux personnages de la campagne
- Onglet dédié aux scènes
- Onglet avec dès pour ceux qui souhaitent ;

Possibilité de rajouté une note en tant qu'évènement, jet de dès ou encore action du joueur par exemple. D'autres ajouts seront fait dans le futur (notamment des tables aléatoires pour PNJ en live).
2026-05-20 14:59:26 +02:00
bc05b1536d Correction du bug de switch entre lore / campagne et la sidebar qui ne s'actualise pas en conséquence. Ajout d'un test playwright pour éviter toute régression à l'avenir 2026-05-19 19:15:00 +02:00
0e350142de mise à jour vers 0.8.7-beta
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-05-19 18:45:56 +02:00
11cb708de9 Mise à jour du switcher pour régler le soucis de switch entre stable et bêta
Some checks failed
E2E Tests / e2e (push) Failing after 29s
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-05-19 18:36:00 +02:00
53343c0a75 Mise à jour de la config du switcher pour prendre les crédit du GHCR + sh plus verbeux en cas de bugs
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-05-19 18:25:54 +02:00
43f45d68a1 Mise à jour vers 0.8.5 ; ajout de la bascule entre le canal bêta et le canal stable 2026-05-19 18:05:17 +02:00
4bbc6333fc Ajout d'un globalExceptionHandler pour intercepter toutes les erreurs possibles et avoir un peu plus de détails.
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Suppression du détail de la mise à jour de chaque composant : l'utilisateur ce fiche de savoir composant x / y à jour car on fera la mise à jour pour tout à chaque fois
(même montée en version pour chaque composant même si composant y non touché par exemple... c'est la montée en version de l'appli qui compte)
2026-05-19 14:38:38 +02:00
dc908b8d93 Mise à jour du pom coté pore pour intégrer une dépendance vers crypto.tink ; sinon la vérification de la licence patreon ne marche pas.
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-05-19 14:06:57 +02:00
ae9455c244 Ajout de tests playwright et correction de tests non passant (pour les tests ajoutés : partie game system ).
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Correction de plusieurs anomalies : problème de switch entre 2 templates (par exemple si on était sur un template 1 et qu'on voulait passer directement au 2, ce dernier ne chargeait pas) ;
correction du soucis d'apparition de la sidebar à gauche qui disparaissait sans explication ; problème de redirection : lorsqu'on terminait de créer un PJ / PNJ ; on arrivait sur l'accueil de la campagne au lieu de voir le résultat de la création.
Problème de redirection également lors du clique sur un PNJ / PJ sur le coté : on arrivait sur l'édition au lieu de la présentation. Correction de la première lettre stylisée : tout est au même style comme ça plus de probleme de lecture.

Nouveautées : stylisation des modales (notamment suppression, warning.....) avec en prime l'ajout d'un warning lors du changement de système pour avertir que les fiches persos ne sont pas conservées.
Ajout d'une option pour créer un game system directement à la création d'une campagne afin de faciliter la mise en place de cette dernière.
Ajout d'un bouton pour créer un nouveau template directement lorsqu'on créer une page : ça permet de créer un template et de revenir sur la page qu'on était en train de créer sans perdre le titre.

Passage en bêta 0.8.4
2026-05-19 13:37:22 +02:00
e346bb4b85 Changement du readme 2026-05-17 18:04:24 +02:00
a1c9e67013 Refonte de toute la partie fiche de personnage avec mise en place d'un nouveau bloc de liste d'attribut (pour tout ce qui sera statistiques, compétences etc....)
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Passage V0.8.3
2026-04-30 15:53:38 +02:00
b0edfb5aab Mise en place du picker d'image pour la partie header / illustration des fiches personnage
Some checks failed
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
Migration pour l'ancienne partie des fiches perso vers les nouvelles pages
Vue retravaillée pour les fiches perso
2026-04-30 10:54:27 +02:00
117334315f Refonte du système JDR + système de personnage joueurs / non joueurs :
- Système de templating dans le game system : en effet, les templates sont liés au game system car les fiches personnages ne sont pas forcément les même selon les jeux (perso Dnd possède + de compétences que Nimble par exemple)
- changement des fiches personnages pour adapter le templating au niveau des campagnes et remplir des pages de perso
2026-04-30 10:42:09 +02:00
8e0c1c1017 Mise en place d'un composant permettant d'améliorer l'experience de mise à jour (via un rafraichissement de l'appli).
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Modification de la partie web pour prendre la modification en compte
2026-04-29 14:39:30 +02:00
cb9eb14054 Correction problème mise à jour : l'application ne voyait pas les mises à jour quand on lançait docker après avoir push la dernière version.
Effectivement : au demarrage, docker ce mettait automatiquement sur la dernière version alors qu'il n'avait pas necessairement récupérer, ducoup comparaison faisait true et on arrivait pas à avoir la derniere version du code.
Push de la clé jwt publique : sinon pas incluse dans le jar finale et la section patreon n'apparaissait pas.
2026-04-29 10:56:37 +02:00
fd14e1e572 Correction updateCheckServiceTest qui faisait planter le build gitea
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-04-28 19:12:09 +02:00
f3c445cfa1 Mise en place de la connexion au canal privé pour la bêta avec Patreon et passage en v0.8.0 2026-04-28 19:04:11 +02:00
1a5a87629d Autre patch dockerfile
Some checks failed
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
2026-04-27 22:11:43 +02:00
e61bcd5dbb Patch dockerfile bookworm a lieu de alpine pour corriger le problème de build 2026-04-27 21:56:04 +02:00
bc8fb30398 Patch dockerfile pour ne plus que le build plante 2026-04-27 21:43:13 +02:00
ec45104a80 Correction package-lock 2026-04-27 19:17:01 +02:00
3f8cdd177b Modification lors de la création d'élément de campagne : quand on créer un nouvel élément, on arrive sur la modification et non le résumé de l'élément 2026-04-27 19:03:58 +02:00
4ed9104a53 Correction du soucis de mise à jour via l'application
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-04-27 16:19:56 +02:00
defbf2d1f7 Passage V0.7.0
Some checks failed
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
2026-04-27 15:51:13 +02:00
d2cf9f8c5c Changement sur le Readme
Ajout d'une partie spécifique pour des PNJ dans la partie campagne
2026-04-27 15:48:04 +02:00
a92e31b187 Mise à jour du readme d'accueil pour l'accès à la documentation 2026-04-27 08:27:38 +02:00
00627d1543 Passage version 0.6.14 + résolution d'un soucis sur l'updater depuis la migration sur git
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-04-26 19:08:49 +02:00
47a8fc529d Mise en place de la pipeline pour github plutot que gitea ; mise en place des images docker sur GHCR plutôt que gitea
Some checks failed
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
Passage version v0.6.13
2026-04-26 10:46:46 +02:00
c524516b2a Empêche la modale de ce fermer tant que le llm n'est pas télécharger
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-04-26 09:12:36 +02:00
6a4ae5c326 Forçage HTTP/1.1 pour la partie python et passage en v0.6.11
Some checks failed
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
2026-04-26 01:55:02 +02:00
4e01fdfa9c Correction d'un bug lors de tentative de téléchargement de llm pour ollama
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-04-26 01:45:39 +02:00
b60a790c70 Passage version 0.6.9
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-04-26 01:30:35 +02:00
147d0701db Changement du watchtower pour une version plus récente : projet originel abandonné, repris par un fork. 2026-04-26 01:19:58 +02:00
98e54613ce Mise en place v0.6.8
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Amélioration de l'installation automatique
Ajout de la possibilité de télécharger le llm que l'on veut à l'interieur de l'application en communicant avec ollama
2026-04-26 01:11:04 +02:00
c47f987cef Mise à jour de la conf pour être sur que le cache angular est bien refresh
Mise à jour des installeurs
Mise en place de secure-host pour ne pas exposer Ollama à l'exterieur
2026-04-26 00:18:49 +02:00
7cd0a8253a Correction pour éviter que la fenêtre ce ferme sans qu'on voit le message d'erreur
Some checks failed
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
2026-04-25 18:37:40 +02:00
7b189acee9 Ajout d'un .bat pour l'exécution du .ps1 2026-04-25 18:34:52 +02:00
08c91dd4c6 Changement sur l'installation automatique : réduction des patterns suspects dans l'installation pour les antivirus (par exemple, monter automatiquement les privilèges en admin...),
afin d'éviter que l'appli ne soit détectée comme un virus
2026-04-25 18:24:44 +02:00
803 changed files with 74482 additions and 11684 deletions

86
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,86 @@
name: Tests unitaires
# Gate de qualité : lance les 3 suites unitaires (Java / Python / Angular) à
# chaque push sur main et sur chaque PR. Une suite rouge fait échouer la CI
# (et, via la branch protection Gitea, peut bloquer le merge).
# Le build/push des images (release.yml) dépend AUSSI de ces tests via `needs`.
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
core:
name: Core (Java · mvn test + JaCoCo)
runs-on: ubuntu-latest
# Les tests Core utilisent une VRAIE base PostgreSQL (cf.
# src/test/resources/application.properties, ddl-auto=create-drop).
# On en fournit une en service container. Sur Gitea (job en conteneur),
# le service est joignable par son NOM d'hôte `postgres`.
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: loremind_test
POSTGRES_USER: loremind_test
POSTGRES_PASSWORD: loremind_test
options: >-
--health-cmd "pg_isready -U loremind_test -d loremind_test"
--health-interval 10s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
cache: maven
# Maven wrapper (./mvnw) : le runner Gitea n'a pas `mvn` préinstallé et
# setup-java n'installe que le JDK → le wrapper bootstrappe Maven lui-même.
# `mvn test` exécute aussi jacoco:report + jacoco:check (plancher 60%).
- name: mvn test (via wrapper)
working-directory: core
env:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/loremind_test
SPRING_DATASOURCE_USERNAME: loremind_test
SPRING_DATASOURCE_PASSWORD: loremind_test
run: |
chmod +x ./mvnw
./mvnw -B test
brain:
name: Brain (Python · pytest + couverture)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
cache-dependency-path: brain/requirements-dev.txt
- name: Install deps (test)
working-directory: brain
run: pip install -r requirements-dev.txt
- name: pytest (+ plancher couverture 50%)
working-directory: brain
run: pytest --cov=app --cov-report=term-missing --cov-fail-under=50
web:
name: Web (Angular · vitest + couverture)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: web/package-lock.json
- name: npm ci
working-directory: web
run: npm ci --no-audit --no-fund
# vitest run --coverage applique les seuils définis dans vitest.config.ts.
- name: vitest (+ seuils couverture)
working-directory: web
run: npm run test:unit:coverage

View File

@@ -6,11 +6,77 @@ on:
- 'v*'
env:
REGISTRY: git.igmlcreation.fr
REGISTRY_USER: ietm64
GITEA_REGISTRY: git.igmlcreation.fr
GITEA_REGISTRY_USER: ietm64
GHCR_REGISTRY: ghcr.io
GHCR_NAMESPACE: igmlcreation
jobs:
# GATE : aucune image n'est build/push tant que les 3 suites unitaires
# (Java / Python / Angular) ne passent pas. Un tag posé sur un commit aux
# tests rouges ne publiera donc PAS d'images.
tests:
runs-on: ubuntu-latest
# Base PostgreSQL réelle pour les tests Core (joignable par le nom `postgres`
# depuis le conteneur du job Gitea).
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: loremind_test
POSTGRES_USER: loremind_test
POSTGRES_PASSWORD: loremind_test
options: >-
--health-cmd "pg_isready -U loremind_test -d loremind_test"
--health-interval 10s
--health-timeout 5s
--health-retries 10
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
cache: maven
- name: Core — mvn test (+ JaCoCo check)
working-directory: core
env:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/loremind_test
SPRING_DATASOURCE_USERNAME: loremind_test
SPRING_DATASOURCE_PASSWORD: loremind_test
run: |
chmod +x ./mvnw
./mvnw -B test
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
cache-dependency-path: brain/requirements-dev.txt
- name: Brain — pytest (+ couverture)
working-directory: brain
run: |
pip install -r requirements-dev.txt
pytest --cov=app --cov-report=term-missing --cov-fail-under=50
- name: Set up Node 20
uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: web/package-lock.json
- name: Web — vitest (+ couverture)
working-directory: web
run: |
npm ci --no-audit --no-fund
npm run test:unit:coverage
build:
needs: tests
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -26,19 +92,115 @@ jobs:
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.REGISTRY_USER }}
registry: ${{ env.GITEA_REGISTRY }}
username: ${{ env.GITEA_REGISTRY_USER }}
password: ${{ secrets.DOCKER_PAT }}
- name: Extract version
id: meta
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
# Login to GHCR (GitHub Container Registry) pour distribuer les images
# publiquement aux utilisateurs finaux. Reputation domaine plus elevee
# que git.igmlcreation.fr (mieux pour les antivirus / SmartScreen).
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ env.GHCR_NAMESPACE }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Build & push ${{ matrix.component }}
# Detection du canal :
# - tag vX.Y.Z -> stable (push :latest + :version sur les repos publics)
# - tag vX.Y.Z-beta* -> beta (push :beta + :version sur les repos GHCR prives
# loremind-beta-<component> ; backup Gitea avec :version)
- name: Extract version & channel
id: meta
run: |
VERSION="${GITHUB_REF_NAME#v}"
echo "version=${VERSION}" >> $GITHUB_OUTPUT
if [[ "${VERSION}" == *-beta* ]]; then
echo "channel=beta" >> $GITHUB_OUTPUT
else
echo "channel=stable" >> $GITHUB_OUTPUT
fi
# Build & push canal STABLE
- name: Build & push ${{ matrix.component }} (stable)
if: steps.meta.outputs.channel == 'stable'
uses: docker/build-push-action@v5
with:
context: ./${{ matrix.component }}
push: true
tags: |
${{ env.REGISTRY }}/${{ env.REGISTRY_USER }}/${{ matrix.component }}:latest
${{ env.REGISTRY }}/${{ env.REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:latest
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:latest
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:${{ steps.meta.outputs.version }}
# Build & push canal BETA
# GHCR : repos prives loremind-beta-<component> (gated par PAT distribue
# via le relais Patreon aux tiers Compagnon).
# Gitea : backup prive avec :version uniquement (pas de :latest pour ne
# pas faire upgrader les installs branchees sur Gitea).
- name: Build & push ${{ matrix.component }} (beta)
if: steps.meta.outputs.channel == 'beta'
uses: docker/build-push-action@v5
with:
context: ./${{ matrix.component }}
push: true
tags: |
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-beta-${{ matrix.component }}:beta
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-beta-${{ matrix.component }}:${{ steps.meta.outputs.version }}
# Job separe pour le sidecar `switcher`.
# Pourquoi separe : le switcher est volontairement HORS de IMAGE_NAMESPACE
# (cf. docker-compose.yml). Il est toujours pulle depuis le repo public
# `loremind-switcher`, quel que soit le canal de l'instance. On le build
# donc uniquement sur les releases stables — pas la peine de re-publier
# une variante beta du switcher, c'est une infrastructure neutre.
build-switcher:
needs: tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Detect channel
id: meta
run: |
VERSION="${GITHUB_REF_NAME#v}"
echo "version=${VERSION}" >> $GITHUB_OUTPUT
if [[ "${VERSION}" == *-beta* ]]; then
echo "channel=beta" >> $GITHUB_OUTPUT
else
echo "channel=stable" >> $GITHUB_OUTPUT
fi
- name: Login to Gitea Registry
if: steps.meta.outputs.channel == 'stable'
uses: docker/login-action@v3
with:
registry: ${{ env.GITEA_REGISTRY }}
username: ${{ env.GITEA_REGISTRY_USER }}
password: ${{ secrets.DOCKER_PAT }}
- name: Login to GHCR
if: steps.meta.outputs.channel == 'stable'
uses: docker/login-action@v3
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ env.GHCR_NAMESPACE }}
password: ${{ secrets.GHCR_TOKEN }}
- name: Build & push switcher (stable only)
if: steps.meta.outputs.channel == 'stable'
uses: docker/build-push-action@v5
with:
context: ./switcher
push: true
tags: |
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/switcher:latest
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/switcher:${{ steps.meta.outputs.version }}
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-switcher:latest
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-switcher:${{ steps.meta.outputs.version }}

196
.github/workflows/desktop-release.yml vendored Normal file
View File

@@ -0,0 +1,196 @@
name: Desktop installers
# Produit les installeurs de BUREAU (.msi Windows pour l'instant) et les publie
# en tant qu'assets d'une GitHub Release, sur tag `v*`.
#
# Complementaire au pipeline Gitea Actions (.gitea/workflows/release.yml) qui,
# lui, build et pousse les IMAGES Docker. Ici on est sur GitHub car jpackage et
# PyInstaller ne savent PAS cross-compiler : le .msi DOIT etre construit sur un
# runner Windows, et GitHub en fournit gratuitement (windows-latest).
#
# Prerequis : le depot Gitea doit etre mirrore vers GitHub (push mirror, tags
# inclus) pour que le tag declenche ce workflow.
#
# Tag stable vX.Y.Z -> GitHub Release PUBLIQUE avec le .msi attache.
# Tag beta vX.Y.Z-beta* -> AUCUNE publication publique. Le .msi est depose en
# ARTEFACT PRIVE du run (telechargeable seulement par
# toi via l'onglet Actions) ; tu le joins ensuite a un
# post Patreon reserve a un palier. Patreon = la
# barriere d'acces (equivalent du registry prive +
# relais pour les images Docker beta).
on:
push:
tags: ['v*']
# Declenchement MANUEL depuis l'onglet Actions ("Run workflow"). Utile quand un
# tag a ete pousse AVANT que le workflow existe sur GitHub (ne se redeclenche
# pas tout seul), ou pour rejouer un build. Saisir la version SANS le "v".
workflow_dispatch:
inputs:
version:
description: "Version a builder (doit correspondre a un tag existant, ex: 0.15.0 ou 0.15.0-beta)"
required: true
permissions:
contents: write # requis pour creer la Release et y attacher le .msi
jobs:
# GATE : les 3 suites unitaires (Java / Python / Angular) doivent passer avant
# de construire le .msi. Tourne sur ubuntu-latest (moins cher/plus rapide que
# windows) ; le build natif lui-meme reste sur windows-latest via `needs`.
# Auto-suffisant : ne depend PAS du resultat de Gitea (CI separee), il rejoue
# les memes tests ici. Un test rouge => pas d'installeur publie.
tests:
runs-on: ubuntu-latest
# Base PostgreSQL réelle pour les tests Core. Sur les runners GitHub (job sur
# la VM, pas en conteneur), le service est joignable via localhost + le port mappé.
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: loremind_test
POSTGRES_USER: loremind_test
POSTGRES_PASSWORD: loremind_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U loremind_test -d loremind_test"
--health-interval 10s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'workflow_dispatch' && format('v{0}', inputs.version) || github.ref }}
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
cache: maven
- name: Core — mvn test (+ JaCoCo check)
working-directory: core
env:
SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/loremind_test
SPRING_DATASOURCE_USERNAME: loremind_test
SPRING_DATASOURCE_PASSWORD: loremind_test
run: |
chmod +x ./mvnw
./mvnw -B test
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
cache-dependency-path: brain/requirements-dev.txt
- name: Brain — pytest (+ couverture)
working-directory: brain
run: |
pip install -r requirements-dev.txt
pytest --cov=app --cov-report=term-missing --cov-fail-under=50
- name: Set up Node 20
uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: web/package-lock.json
- name: Web — vitest (+ couverture)
working-directory: web
run: |
npm ci --no-audit --no-fund
npm run test:unit:coverage
windows:
needs: tests
runs-on: windows-latest
steps:
# En declenchement manuel, on checkout le TAG correspondant a la version
# saisie (sinon checkout prendrait la branche par defaut). En push de tag,
# on prend la ref poussee.
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'workflow_dispatch' && format('v{0}', inputs.version) || github.ref }}
# Apporte jpackage (lanceur d'empaquetage natif) dans le PATH.
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '20'
# Python 3.12 = meme version que l'image Docker du Brain (coherence runtime).
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
# jpackage genere le MSI via WiX Toolset v3 (candle.exe/light.exe). WiX 4+
# ne convient pas (outils renommes). Le paquet choco `wixtoolset` est la
# ligne 3.x et s'ajoute au PATH.
- name: Install WiX Toolset 3
shell: pwsh
run: choco install wixtoolset -y --no-progress
# Tesseract OCR : non preinstalle sur windows-latest. Requis pour que
# build-windows.ps1 embarque l'OCR des PDF scannes (sinon il skip en
# degradation gracieuse). Installe dans %ProgramFiles%\Tesseract-OCR.
- name: Install Tesseract OCR
shell: pwsh
run: choco install tesseract -y --no-progress
# Version de l'installeur = version du tag (push) OU de l'input (manuel).
# Sorties : version (numerique X.Y.Z pour le MSI), tag (vX.Y.Z[-beta]),
# isbeta (true/false) — independant du nom de ref (qui est une branche en manuel).
- name: Derive version
id: ver
shell: pwsh
run: |
if ('${{ github.event_name }}' -eq 'workflow_dispatch') {
$raw = '${{ inputs.version }}'
} else {
$raw = '${{ github.ref_name }}'
}
$raw = $raw -replace '^v','' # 0.15.0 ou 0.15.0-beta
$num = ($raw -split '-')[0] # 0.15.0
$isbeta = if ($raw -like '*-beta*') { 'true' } else { 'false' }
"version=$num" >> $env:GITHUB_OUTPUT
"tag=v$raw" >> $env:GITHUB_OUTPUT
"isbeta=$isbeta" >> $env:GITHUB_OUTPUT
- name: Build Windows installer
shell: pwsh
run: .\installers\desktop\build-windows.ps1 -Version ${{ steps.ver.outputs.version }}
# STABLE uniquement : Release GitHub publique avec le .msi.
# tag_name explicite : en declenchement manuel, github.ref est une branche,
# donc on cible le tag derive (la release est attachee au bon tag).
- name: Publish installer to GitHub Release (stable)
if: ${{ steps.ver.outputs.isbeta == 'false' }}
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.ver.outputs.tag }}
files: core/target/dist-out/*.msi
fail_on_unmatched_files: true
generate_release_notes: true
# BETA uniquement : artefact PRIVE (pas de release publique). A recuperer
# via l'onglet Actions puis a joindre a un post Patreon gate par palier.
- name: Upload installer as private artifact (beta)
if: ${{ steps.ver.outputs.isbeta == 'true' }}
uses: actions/upload-artifact@v4
with:
name: loremind-beta-${{ steps.ver.outputs.version }}-msi
path: core/target/dist-out/*.msi
retention-days: 90
# TODO (plus tard) : job `linux` sur ubuntu-latest produisant un AppImage
# (jpackage --type app-image + appimagetool) + PyInstaller Linux du Brain,
# attache a la MEME release. Reutilise la meme matrice / les memes etapes.

32
.gitignore vendored
View File

@@ -7,6 +7,11 @@
brain/data/settings.json
*.key
*.pem
# Exception : la cle PUBLIQUE JWT du relais Patreon est destinee a etre
# embarquee dans le binaire. Pas de risque a la committer (c'est une cle
# publique par construction). Sans cette exception, le module licensing
# est silencieusement desactive dans les builds CI.
!core/src/main/resources/licensing/jwt-public-key.pem
# ============================================================================
# Java / Spring Boot / Maven
@@ -40,6 +45,12 @@ env/
.coverage
htmlcov/
# Artefacts du build bureau (cf. installers/desktop)
.venv-build/
brain/build/
brain/dist-embed/
*.spec
# ============================================================================
# Angular / Node (Web)
# ============================================================================
@@ -91,8 +102,29 @@ Thumbs.db
# Documentation hors-code (conservee hors du repo)
# ============================================================================
docs/
loremind-docs/
# ============================================================================
# Docker Compose override (dev uniquement, non-distribue aux end users)
# ============================================================================
docker-compose.override.yml
# ============================================================================
# Relais OAuth Patreon (repo Gitea separe, clone localement pour facilite)
# ============================================================================
relay/
scripts/bump-version.mjs
brain/data/notebooks/5.json
# ============================================================================
# Documentation reservee aux patrons (repo Gitea PRIVE separe, clone
# localement). NE DOIT JAMAIS partir dans le repo LoreMind public.
# Contient le site premium (sources) + son Worker de gate dans gate/.
# ============================================================================
docusaurus/loremind-patreon/
installers/desktop/README.md
# Rapports de couverture de tests
web/coverage/
brain/htmlcov/
brain/.coverage

View File

@@ -1,311 +0,0 @@
# Installation de LoreMindMJ
Ce document decrit la procedure d'installation de LoreMindMJ. Temps estime :
5 a 10 minutes selon la qualite de la connexion reseau.
## 1. Prerequis
- **Docker Desktop** ([Windows](https://www.docker.com/products/docker-desktop/) /
[Mac](https://www.docker.com/products/docker-desktop/)) ou
**Docker Engine + Compose v2** (Linux). Verification :
```
docker --version
docker compose version
```
Compose v2 est requis : la commande est `docker compose`, non `docker-compose`.
- **Un fournisseur LLM**, au choix :
- **[Ollama](https://ollama.com/)** installe sur la machine hote (gratuit,
local, necessite environ 6 Go de RAM libre pour les modeles recommandes).
- **Une cle API [1min.ai](https://1min.ai)** (hebergement cloud, facturation
a l'usage, aucune installation supplementaire requise).
- Environ **2 Go d'espace disque** pour les images Docker, auxquels s'ajoute
la taille des modeles Ollama si l'option locale est retenue.
## 2. Recuperation des fichiers
Telecharger les deux fichiers suivants depuis la
[derniere release](https://git.igmlcreation.fr/ietm64/LoreMindMJ/releases) et
les placer dans un dossier dedie (par exemple `~/loremind/` ou
`C:\Programs\loremind\`) :
- `docker-compose.yml`
- `.env.example`
Le code source n'est pas necessaire : les images sont pre-construites et
publiees sur le registry Gitea `git.igmlcreation.fr` (non Docker Hub). Le
premier `docker compose pull` les telechargera automatiquement.
## 3. Configuration du fichier `.env`
Renommer `.env.example` en `.env` et l'ouvrir dans un editeur de texte. **Trois
variables sont obligatoires** ; sans elles, `docker compose up` refusera de
demarrer. Ce comportement est volontaire afin d'eviter tout deploiement
non-securise par defaut.
### `POSTGRES_PASSWORD`
Mot de passe de la base de donnees PostgreSQL. Choisir une valeur robuste.
Seuls les conteneurs utilisent cette valeur : il n'est pas necessaire de la
memoriser au-dela du fichier `.env`.
### `ADMIN_PASSWORD`
Protege l'ecran **Parametres** de l'application via HTTP Basic. Cette valeur
sera demandee par le navigateur lors de toute modification de la configuration
(changement de modele LLM, saisie de cle API, etc.). Le nom d'utilisateur par
defaut est `admin`, modifiable via la variable `ADMIN_USERNAME`.
### `BRAIN_INTERNAL_SECRET`
Secret partage entre le service Java (`core`) et le service Python (`brain`).
Empeche toute requete externe d'atteindre directement le service Brain.
Generer une valeur aleatoire de 64 caracteres hexadecimaux :
```
openssl rand -hex 32
```
Sous Windows sans `openssl`, utiliser PowerShell :
```powershell
-join ((48..57) + (97..102) | Get-Random -Count 64 | % {[char]$_})
```
### Variables optionnelles
- `WEB_PORT` (defaut `8081`) : port d'ecoute de l'interface web.
- `ADMIN_USERNAME` (defaut `admin`) : identifiant de la popup Parametres.
- `LLM_PROVIDER` (defaut `ollama`) : choix du fournisseur LLM (voir
section 5).
Les autres variables (`MINIO_USER`/`MINIO_PASSWORD`, `POSTGRES_DB`,
`POSTGRES_USER`) disposent de valeurs par defaut adaptees a un deploiement
personnel et peuvent etre conservees en l'etat.
## 4. Lancement de la stack
Depuis le dossier contenant `docker-compose.yml` et `.env` :
```
docker compose up -d
```
Le premier demarrage telecharge les images (environ 1 a 2 Go au total) et
initialise la base. Compter 2 a 5 minutes selon la qualite de la connexion.
La progression peut etre suivie via :
```
docker compose logs -f
```
(`Ctrl+C` pour quitter l'affichage ; les services continuent de fonctionner
en arriere-plan.)
Une fois les services en etat `healthy`, ouvrir **http://localhost:8081**
dans un navigateur.
### Verification du fonctionnement
```
docker compose ps
```
Cinq conteneurs doivent apparaitre en etat `Up` ou `healthy` :
`loremind-postgres`, `loremind-minio`, `loremind-core`, `loremind-brain`,
`loremind-web`. Le conteneur `loremind-minio-init` s'arrete automatiquement
apres creation du bucket d'images : ce comportement est normal.
## 5. Configuration du fournisseur LLM
### Ollama (local, gratuit)
Installer Ollama sur la machine hote (pas dans Docker), puis telecharger un
modele :
```
ollama pull gemma4:26b
```
Dans `.env` :
```
LLM_PROVIDER=ollama
LLM_MODEL=gemma4:26b
OLLAMA_BASE_URL=http://host.docker.internal:11434
```
L'adresse `host.docker.internal` permet au conteneur `brain` d'atteindre
Ollama sur la machine hote. Cette resolution est native sous Docker Desktop
(Mac / Windows). Sous Linux, le fichier `docker-compose.yml` declare un
`extra_hosts` equivalent.
### 1min.ai (cloud, paye)
Dans `.env` :
```
LLM_PROVIDER=onemin
ONEMIN_API_KEY=sk-...
ONEMIN_MODEL=gpt-4o-mini
```
### Modification a chaud
Le fournisseur, le modele et la cle API peuvent etre modifies a chaud depuis
l'ecran **Parametres** de l'application. Les modifications sont persistees
dans un volume Docker et survivent aux redemarrages. Les variables d'env du
fichier `.env` sont uniquement utilisees comme valeurs initiales au premier
demarrage.
## 6. Mise a jour
```
docker compose pull
docker compose up -d
```
Les donnees (base PostgreSQL, images MinIO, configuration Brain) sont
stockees dans des volumes Docker et survivent aux mises a jour.
## 7. Sauvegarde
Les donnees sont reparties dans trois volumes Docker :
- `loremindmj_postgres-data` — ensemble des donnees applicatives (lores,
campagnes, pages, templates, branches narratives, etc.).
- `loremindmj_minio-data` — images uploadees.
- `loremindmj_brain-data` — parametres IA (fournisseur courant, cle API
1min.ai).
### Export SQL de la base
```
docker compose exec postgres pg_dump -U loremind loremind > backup.sql
```
### Sauvegarde complete des volumes
Arreter la stack au prealable afin de garantir la coherence des donnees :
```
docker compose stop
docker run --rm -v loremindmj_postgres-data:/data -v $(pwd):/backup alpine tar czf /backup/postgres-data.tar.gz -C /data .
docker run --rm -v loremindmj_minio-data:/data -v $(pwd):/backup alpine tar czf /backup/minio-data.tar.gz -C /data .
docker compose start
```
Sous Windows PowerShell, remplacer `$(pwd)` par `${PWD}`.
## 8. Resolution des problemes
### Port 8081 deja utilise
Modifier `WEB_PORT=8082` (ou toute autre valeur libre) dans `.env`, puis
relancer :
```
docker compose up -d
```
### Erreur "set POSTGRES_PASSWORD in .env" (ou variable equivalente) au lancement
Une des trois variables obligatoires de l'etape 3 est manquante. Verifier le
contenu du fichier `.env`.
### Popup "Ce site vous demande de vous connecter" sur l'ecran Parametres
Comportement attendu : il s'agit de l'authentification HTTP Basic. Utiliser
la valeur de `ADMIN_USERNAME` (par defaut `admin`) et celle de
`ADMIN_PASSWORD`.
### Erreurs `password authentication failed` en boucle dans les logs Postgres
Si la variable `POSTGRES_PASSWORD` a ete modifiee apres un premier lancement,
le volume Postgres conserve l'ancien mot de passe (initialise une seule fois).
Deux options :
- **Redemarrer avec un volume vierge** (entraine la perte des donnees) :
```
docker compose down -v
docker compose up -d
```
- **Modifier le mot de passe en base** sans toucher au volume :
```
docker compose exec postgres psql -U postgres
```
Puis dans le prompt `psql` :
```sql
ALTER USER loremind WITH PASSWORD 'valeur_exacte_du_env';
\q
```
Redemarrer ensuite le Core : `docker compose restart core`.
### Erreur "502 Bad Gateway" ou message d'erreur IA dans l'interface
Le service Brain ne parvient pas a contacter le fournisseur LLM. Verifier :
- **Ollama** : `ollama serve` est-il actif ? Le modele est-il telecharge
(`ollama list`) ? La valeur de `LLM_MODEL` correspond-elle exactement au
nom d'un modele liste ?
- **1min.ai** : la cle API est-elle valide ? Le modele existe-t-il ?
- Consulter les logs du Brain :
```
docker compose logs brain
```
### Un service ne demarre pas ou reste en etat `unhealthy`
Consulter les logs du service concerne :
```
docker compose logs <service>
```
Services disponibles : `postgres`, `minio`, `core`, `brain`, `web`.
### Redemarrage d'un service apres modification du `.env`
```
docker compose up -d <service>
```
Redemarrage complet : `docker compose restart`.
### Remise a zero complete (PERTE DES DONNEES)
```
docker compose down -v
```
L'option `-v` supprime les volumes. L'ensemble des lores, campagnes, images
et parametres est perdu de maniere definitive.
### "No such image" ou "pull access denied" au premier lancement
Le registry Gitea peut necessiter une authentification selon la visibilite
configuree pour les images. Contacter l'editeur du projet.
## 9. Exposition reseau des services
- **Interface web** : http://localhost:8081 (port configurable via
`WEB_PORT`).
- **PostgreSQL** : accessible uniquement via le reseau Docker interne, non
expose vers l'hote.
- **MinIO** : accessible uniquement via le reseau Docker interne. Les images
transitent par le reverse-proxy Java sur `/api/images/{id}/content`. Le
binding `127.0.0.1:9000/9001` defini dans `docker-compose.override.yml`
n'est actif qu'en developpement.
- **Brain Python** : accessible uniquement via le reseau Docker interne.
Toute requete doit porter l'en-tete `X-Internal-Secret`, injectee
automatiquement par le Core Java et jamais exposee au navigateur.
## 10. Desinstallation
```
docker compose down -v
docker image rm git.igmlcreation.fr/ietm64/core git.igmlcreation.fr/ietm64/brain git.igmlcreation.fr/ietm64/web
```
Supprimer ensuite le dossier contenant `docker-compose.yml` et `.env`.

68
README.fr.md Normal file
View File

@@ -0,0 +1,68 @@
# LoreMind
[English](README.md) · **Français**
> Application web auto-hébergeable pour MJ qui veulent centraliser leur univers, leurs campagnes et leurs personnages — avec un assistant IA contextuel.
[![Licence: AGPL v3](https://img.shields.io/badge/Licence-AGPL%20v3-blue.svg)](LICENSE)
[![Documentation](https://img.shields.io/badge/docs-loremind--docs-green)](https://loremind-docs.igmlcreation.fr/)
[![Démo](https://img.shields.io/badge/d%C3%A9mo-en%20ligne-orange)](https://loremind-demo.igmlcreation.fr/)
[![Patreon](https://img.shields.io/badge/Patreon-soutenir-red)](https://www.patreon.com/c/IGMLCreation)
[![Discord](https://img.shields.io/badge/Discord-rejoindre-5865F2)](https://discord.gg/cPpFzCjEzQ)
## Découvrir LoreMind en vidéo
[![Présentation LoreMind](https://img.youtube.com/vi/llJkmlotbB8/maxresdefault.jpg)](https://www.youtube.com/watch?v=llJkmlotbB8)
![Tableau de bord](https://raw.githubusercontent.com/IGMLcreation/loremind-docs/main/static/img/screenshots/dashboard.png)
## Ce que ça fait
LoreMind regroupe ce qu'un MJ utilise habituellement éparpillé entre plusieurs outils. L'application s'articule autour de trois modules principaux, augmentés par un assistant IA qui exploite tout votre contenu.
### Lore
Construire votre univers avec une arborescence de pages templatées : lieux, factions, PNJ, événements, organisations... Chaque type de page suit un template configurable, ce qui garantit la cohérence et facilite la navigation dans des univers riches.
### Game System
Stocker les règles de votre système de jeu (D&D, Nimble, créations maison...) et définir les modèles de fiches de personnages associés. Les règles indexées peuvent être injectées dans le contexte de l'IA pour des réponses fidèles à votre système.
### Campaign
Structurer vos campagnes en Arcs → Chapitres → Scènes avec séparation claire du contenu MJ et du contenu joueurs. Gérer les PJ et PNJ via des fiches dynamiques basées sur les templates du game system retenu.
### Assistant IA
Un assistant contextuel qui pioche dans votre Lore, vos règles et vos campagnes pour répondre à vos questions, suggérer du contenu cohérent, ou rebondir sur une situation improvisée en table.
L'IA s'exécute **en local via [Ollama](https://ollama.com/)** ou via **[1min.ai](https://1min.ai/)**. D'autres moteurs seront supportés à l'avenir.
## Documentation
Toute la documentation (installation, configuration, prise en main) est sur **[loremind-docs.igmlcreation.fr](https://loremind-docs.igmlcreation.fr/)**.
## Démo en ligne
Une instance de démonstration est disponible sur **[loremind-demo.igmlcreation.fr](https://loremind-demo.igmlcreation.fr/)**.
Quelques limites à connaître :
- 10 utilisateurs maximum simultanés (instances isolées)
- Session limitée à 20 minutes avant réinitialisation
- Partie IA non incluse dans la démo (nécessite Ollama ou 1min.ai côté serveur)
## Soutenir le projet
LoreMind est **et restera gratuit en auto-hébergement**. Le développement avance plus vite avec votre soutien :
- **[Patreon](https://www.patreon.com/c/IGMLCreation)** — accès anticipé aux features, vote sur la roadmap, devlogs exclusifs
- **[Discord](https://discord.gg/cPpFzCjEzQ)** — annonces, support, retours utilisateurs
## Licence
LoreMind est distribué sous licence **[GNU AGPL v3](LICENSE)**.
En pratique :
- Vous pouvez l'utiliser gratuitement, l'héberger, la modifier, la redistribuer.
- Si vous modifiez le code et que vous exposez l'application modifiée sur un réseau (même en SaaS privé), vous devez rendre vos modifications publiques sous la même licence.
- Les univers (Lore) et campagnes que vous créez avec LoreMind **vous appartiennent entièrement** — la licence ne couvre que le code de l'application.

View File

@@ -1,75 +1,68 @@
# LoreMind
Application web d'aide aux Maîtres de Jeu (JDR) pour centraliser la gestion de l'univers (Lore) et le suivi des campagnes, avec un moteur IA intégré pour générer du contenu structuré.
**English** · [Français](README.fr.md)
## Fonctionnalités
> A self-hostable web app for game masters who want to centralize their world, campaigns and characters — with a context-aware AI assistant.
- Gestion centralisée du Lore : Lieux, Factions, PNJ, et tous les éléments de votre univers
- Suivi de campagnes : Sessions, actions des joueurs, chronologie
- Moteur IA intégré : Génération automatique de contenu (PNJ, Villes, Quêtes) à partir de templates
- Export vers FoundryVTT : Transfert structuré des données vers votre VTT préféré (en développement)
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](LICENSE)
[![Documentation](https://img.shields.io/badge/docs-loremind--docs-green)](https://loremind-docs.igmlcreation.fr/en/)
[![Demo](https://img.shields.io/badge/demo-online-orange)](https://loremind-demo.igmlcreation.fr/)
[![Patreon](https://img.shields.io/badge/Patreon-support-red)](https://www.patreon.com/c/IGMLCreation)
[![Discord](https://img.shields.io/badge/Discord-join-5865F2)](https://discord.gg/cPpFzCjEzQ)
## Captures d'écran
## See LoreMind in action
### Page d'accueil
![Accueil](docs/maquettes/général/Accueil.png)
[![LoreMind overview](https://img.youtube.com/vi/llJkmlotbB8/maxresdefault.jpg)](https://www.youtube.com/watch?v=llJkmlotbB8)
### Recherche
![Recherche](docs/maquettes/général/Ecran de recherche.png)
![Dashboard](https://raw.githubusercontent.com/IGMLcreation/loremind-docs/main/static/img/screenshots/dashboard.png)
## Stack Technologique
## What it does
LoreMind utilise une architecture distribuée pour séparer les responsabilités :
LoreMind brings together what a game master usually scatters across several tools. The app is built around three core modules, augmented by an AI assistant that draws on all of your content.
- **Frontend** : Angular (Interface utilisateur, affichage du lore, formulaires de templates)
- **Backend Core** : Java (Spring Boot) - Orchestration, persistance, export VTT
- **Backend IA** : Python - Traitement des LLM et génération de contenu
- **Base de données** : PostgreSQL avec JSONB pour les templates flexibles
### Lore
## Architecture
Build your world with a tree of templated pages: locations, factions, NPCs, events, organizations... Each page type follows a configurable template, which keeps things consistent and makes navigating rich worlds easy.
### Backend Java (Domain-Driven Design & Hexagonal)
### Game System
Le Backend Core respecte strictement :
- **Domain-Driven Design (DDD)** : Séparation en Bounded Contexts autonomes
- **Architecture Hexagonale (Ports et Adaptateurs)** : Domaine pur sans dépendances techniques
Store the rules of your game system (D&D, Nimble, homebrew...) and define the matching character sheet templates. Indexed rules can be injected into the AI's context for answers that stay true to your system.
#### Bounded Contexts
- **LoreContext** : Gestion de l'encyclopédie de l'univers
- **CampaignContext** : Suivi des sessions et chronologie
- **GenerationContext** : Gestion des requêtes IA et templates
### Campaign
#### Couches
- **Domaine (Core)** : Entités métier pures et interfaces (Ports)
- **Application** : Orchestration des flux (Use Cases)
- **Infrastructure** : Implémentation technique (Adapters)
Structure your campaigns as Arcs → Chapters → Scenes, with a clear split between GM-only and player-facing content. Manage PCs and NPCs through dynamic sheets based on your chosen game system's templates.
## Installation
### AI Assistant
Pour installer LoreMind chez vous (Docker requis), suivez le guide **[INSTALL.md](INSTALL.md)** — 3 étapes, 5 minutes chrono :
A context-aware assistant that pulls from your Lore, rules and campaigns to answer your questions, suggest consistent content, or improvise around an unexpected situation at the table.
1. Télécharger `docker-compose.yml` + `.env.example` depuis la [dernière release](https://git.igmlcreation.fr/ietm64/LoreMindMJ/releases)
2. Renommer `.env.example``.env` et changer `POSTGRES_PASSWORD`
3. `docker compose up -d` → ouvrir http://localhost:8081
The AI runs **locally via [Ollama](https://ollama.com/)** or via **[1min.ai](https://1min.ai/)**. More engines will be supported in the future.
Mise à jour : `docker compose pull && docker compose up -d`.
## Documentation
## Développement (contributeurs)
The full documentation (installation, configuration, getting started) lives at **[loremind-docs.igmlcreation.fr/en](https://loremind-docs.igmlcreation.fr/en/)**.
Pour builder les images localement depuis les sources :
## Live demo
```bash
git clone https://git.igmlcreation.fr/ietm64/LoreMindMJ.git
cd LoreMindMJ
# Créer un docker-compose.override.yml local (voir docs de contrib)
docker compose up -d --build
```
A demo instance is available at **[loremind-demo.igmlcreation.fr](https://loremind-demo.igmlcreation.fr/)**.
A few limitations to be aware of:
- 10 concurrent users maximum (isolated instances)
- Sessions limited to 20 minutes before reset
- The AI part is not included in the demo (requires Ollama or 1min.ai server-side)
## Support the project
LoreMind is **and will remain free when self-hosted**. Development moves faster with your support:
- **[Patreon](https://www.patreon.com/c/IGMLCreation)** — early access to features, roadmap voting, exclusive devlogs
- **[Discord](https://discord.gg/cPpFzCjEzQ)** — announcements, support, user feedback
## License
LoreMind est distribué sous licence **[GNU AGPL v3](LICENSE)**.
LoreMind is distributed under the **[GNU AGPL v3](LICENSE)** license.
En pratique :
- Tu peux l'utiliser gratuitement, l'héberger où tu veux, le modifier, le redistribuer.
- Si tu modifies le code et que tu exposes l'application modifiée sur un réseau (même en SaaS privé), tu dois rendre tes modifications publiques sous la même licence.
- Les univers (Lore) et campagnes que tu crées avec LoreMind **t'appartiennent entièrement**la licence ne couvre que le code de l'application.
In practice:
- You can use it for free, host it, modify it, and redistribute it.
- If you modify the code and expose the modified app over a network (even as a private SaaS), you must make your changes public under the same license.
- The worlds (Lore) and campaigns you create with LoreMind **belong entirely to you**the license only covers the application's code.

15
brain/.coveragerc Normal file
View File

@@ -0,0 +1,15 @@
# Configuration de couverture (coverage.py / pytest-cov).
# Rapport HTML (équivalent JaCoCo) : pytest --cov=app --cov-report=html → htmlcov/
# Plancher anti-régression appliqué en CI : --cov-fail-under=50
[run]
source = app
branch = false
[report]
show_missing = true
skip_covered = false
# Lignes jamais comptées comme « à couvrir ».
exclude_lines =
pragma: no cover
if __name__ == .__main__.:
raise NotImplementedError

5
brain/.gitignore vendored
View File

@@ -2,3 +2,8 @@
__pycache__/
*.pyc
.env
# Couverture de tests (pytest-cov)
htmlcov/
.coverage
.pytest_cache/

View File

@@ -1,7 +1,15 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl \
# curl : healthcheck docker.
# tesseract-ocr (+ langues fra/eng) : repli OCR de l'import de PDF de regles
# pour les pages sans couche texte (scans). Inutile pour les PDF born-digital
# mais necessaire pour couvrir tous les cas.
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
tesseract-ocr \
tesseract-ocr-fra \
tesseract-ocr-eng \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .

View File

@@ -0,0 +1,5 @@
"""Adapter web (architecture hexagonale) : routers FastAPI, DTOs et factories DI.
C'est la FRONTIÈRE HTTP du Brain : validation Pydantic, mapping DTO ↔ domaine,
traduction des erreurs domaine → HTTP. Aucune logique métier ici.
"""

215
brain/app/api/chat_dto.py Normal file
View File

@@ -0,0 +1,215 @@
"""DTOs Pydantic du chat contextuel — frontière HTTP avec le Core Java.
C'est ici (et seulement ici, avec les autres modules de `app.api`) qu'on
utilise Pydantic : le domaine ne voit que des dataclasses (voir chat_mapping).
"""
from pydantic import BaseModel, Field
class ChatMessageDTO(BaseModel):
"""Un message de la conversation. Rôles acceptés : user, assistant, system."""
role: str = Field(pattern="^(user|assistant|system)$")
content: str
class PageSummaryDTO(BaseModel):
"""Résumé enrichi d'une page : identité + contenu + interconnexions.
Depuis b9 : values/tags/related_page_titles sont optionnels côté JSON —
le Core Java ne les sérialise que s'ils sont non-vides (payload léger
pour un Lore avec beaucoup de pages vierges).
"""
title: str
template_name: str
values: dict[str, str] = Field(default_factory=dict)
tags: list[str] = Field(default_factory=list)
related_page_titles: list[str] = Field(default_factory=list)
class LoreContextDTO(BaseModel):
"""Carte structurelle du Lore avec contenu des pages (b9+)."""
lore_name: str
lore_description: str | None = None
folders: dict[str, list[PageSummaryDTO]] = Field(default_factory=dict)
tags: list[str] = Field(default_factory=list)
class PageContextDTO(BaseModel):
"""Contexte d'une page spécifique pour focaliser le chat (optionnel)."""
title: str
template_name: str
template_fields: list[str] = Field(default_factory=list)
values: dict[str, str] = Field(default_factory=dict)
class SceneBranchHintDTO(BaseModel):
"""Indice d'une branche narrative (le Core a deja resolu le nom cible)."""
label: str
target_scene_name: str
condition: str | None = None
class RoomBranchHintDTO(BaseModel):
"""Sortie d'une pièce vers une autre pièce du même lieu (donjon)."""
label: str
target_room_name: str
condition: str | None = None
class RoomSummaryDTO(BaseModel):
"""Pièce d'un lieu explorable. Omise par le Core si la scène est classique."""
name: str
floor: int | None = None
description: str | None = None
enemies: str | None = None
branches: list[RoomBranchHintDTO] = Field(default_factory=list)
class SceneSummaryDTO(BaseModel):
"""Résumé d'une scène : nom + description courte (synopsis)."""
name: str
description: str | None = None
# Optionnel : le Core Java ne serialise illustration_count QUE si > 0
# (payload plus leger). Defaut 0 = pas d'illustrations ou champ absent.
illustration_count: int = 0
# Branches narratives sortantes, omises cote Core si vides.
branches: list[SceneBranchHintDTO] = Field(default_factory=list)
# Pièces du lieu explorable, omises par Core si scène classique.
rooms: list[RoomSummaryDTO] = Field(default_factory=list)
class ChapterSummaryDTO(BaseModel):
"""Résumé d'un chapitre : nom + description courte + ses scènes."""
name: str
description: str | None = None
scenes: list[SceneSummaryDTO] = Field(default_factory=list)
illustration_count: int = 0
class ArcSummaryDTO(BaseModel):
"""Résumé d'un arc narratif : nom + description courte + ses chapitres."""
name: str
description: str | None = None
chapters: list[ChapterSummaryDTO] = Field(default_factory=list)
illustration_count: int = 0
class CharacterSummaryDTO(BaseModel):
"""Résumé d'un PJ : nom + snippet. Pas de fiche complète au niveau résumé."""
name: str
snippet: str = ""
class NpcSummaryDTO(BaseModel):
"""Résumé d'un PNJ : symétrique à CharacterSummaryDTO."""
name: str
snippet: str = ""
class CampaignContextDTO(BaseModel):
"""Carte narrative enrichie : arcs → chapitres → scènes avec synopsis."""
campaign_name: str
campaign_description: str | None = None
arcs: list[ArcSummaryDTO] = Field(default_factory=list)
characters: list[CharacterSummaryDTO] = Field(default_factory=list)
npcs: list[NpcSummaryDTO] = Field(default_factory=list)
class NarrativeEntityDTO(BaseModel):
"""Entité narrative (arc/chapter/scene/character) en cours d'édition — focus optionnel."""
entity_type: str = Field(pattern="^(arc|chapter|scene|character|npc)$")
title: str
fields: dict[str, str] = Field(default_factory=dict)
class GameSystemContextDTO(BaseModel):
"""Règles de JDR présélectionnées par le Core (filtrées par intent).
Les sections sont un dict titre_H2 → contenu_markdown. Peuvent être
vides si aucune section ne matchait l'intent de génération courant.
"""
system_name: str
system_description: str | None = None
sections: dict[str, str] = Field(default_factory=dict)
class JournalEntrySummaryDTO(BaseModel):
"""Une entrée du journal de session.
`source_session_name` est présent uniquement pour les évènements issus
des sessions précédentes — sert à ancrer temporellement dans le prompt.
"""
type: str
content: str
occurred_at: str | None = None
source_session_name: str | None = None
class QuestSummaryDTO(BaseModel):
"""Résumé d'une quête (Chapter dans un Arc HUB). Voir QuestSummary côté domaine."""
name: str
arc_name: str
description: str | None = None
class SessionContextDTO(BaseModel):
"""Contexte d'une Session de jeu en cours (Play Context).
Combine le journal complet (`entries`), les EVENTs des sessions précédentes
(`previous_events`), et — depuis l'ajout du mode Hub — l'état des quêtes
Hub de la campagne (disponibles / en cours / verrouillées) plus les flags
narratifs actuellement actifs.
"""
session_name: str
active: bool
started_at: str | None = None
entries: list[JournalEntrySummaryDTO] = Field(default_factory=list)
previous_events: list[JournalEntrySummaryDTO] = Field(default_factory=list)
available_quests: list[QuestSummaryDTO] = Field(default_factory=list)
in_progress_quests: list[QuestSummaryDTO] = Field(default_factory=list)
locked_quest_titles: list[str] = Field(default_factory=list)
active_flags: list[str] = Field(default_factory=list)
class ChatStreamRequestDTO(BaseModel):
"""Requête de chat streamé : historique + contextes structurels.
Les contextes (lore, page, campaign, narrative_entity, session) sont
optionnels, mais au moins l'un des contextes "racines" (lore_context,
campaign_context ou session_context) doit être fourni. Le validateur
`check_scope` applique cette règle à la frontière HTTP.
"""
messages: list[ChatMessageDTO] = Field(min_length=1)
lore_context: LoreContextDTO | None = None
page_context: PageContextDTO | None = None
campaign_context: CampaignContextDTO | None = None
narrative_entity: NarrativeEntityDTO | None = None
game_system_context: GameSystemContextDTO | None = None
session_context: SessionContextDTO | None = None
def has_scope(self) -> bool:
"""Vrai si au moins un contexte racine (Lore, Campagne ou Session) est fourni."""
return (
self.lore_context is not None
or self.campaign_context is not None
or self.session_context is not None
)

View File

@@ -0,0 +1,192 @@
"""Mapping DTO → domaine (couche anti-corruption de la frontière HTTP).
Traduit les DTOs Pydantic du chat contextuel en dataclasses du domaine :
le cœur métier ne dépend ainsi jamais de Pydantic ni du format JSON du Core.
"""
from app.api.chat_dto import (
CampaignContextDTO,
GameSystemContextDTO,
JournalEntrySummaryDTO,
LoreContextDTO,
NarrativeEntityDTO,
PageContextDTO,
PageSummaryDTO,
QuestSummaryDTO,
SessionContextDTO,
)
from app.domain.models import (
ArcSummary,
CampaignStructuralContext,
ChapterSummary,
CharacterSummary,
GameSystemContext,
JournalEntrySummary,
LoreStructuralContext,
NarrativeEntityContext,
NpcSummary,
PageContext,
PageSummary,
QuestSummary,
RoomBranchHint,
RoomSummary,
SceneBranchHint,
SceneSummary,
SessionContext,
)
def to_lore_context(dto: LoreContextDTO | None) -> LoreStructuralContext | None:
if dto is None:
return None
return LoreStructuralContext(
lore_name=dto.lore_name,
lore_description=dto.lore_description,
folders={
folder: [_to_page_summary(p) for p in pages]
for folder, pages in dto.folders.items()
},
tags=dto.tags,
)
def _to_page_summary(dto: PageSummaryDTO) -> PageSummary:
return PageSummary(
title=dto.title,
template_name=dto.template_name,
values=dict(dto.values),
tags=list(dto.tags),
related_page_titles=list(dto.related_page_titles),
)
def to_page_context(dto: PageContextDTO | None) -> PageContext | None:
if dto is None:
return None
return PageContext(
title=dto.title,
template_name=dto.template_name,
template_fields=dto.template_fields,
values=dto.values,
)
def to_campaign_context(dto: CampaignContextDTO | None) -> CampaignStructuralContext | None:
if dto is None:
return None
arcs = [
ArcSummary(
name=arc.name,
description=arc.description,
illustration_count=arc.illustration_count,
chapters=[
ChapterSummary(
name=ch.name,
description=ch.description,
illustration_count=ch.illustration_count,
scenes=[
SceneSummary(
name=sc.name,
description=sc.description,
illustration_count=sc.illustration_count,
branches=[
SceneBranchHint(
label=br.label,
target_scene_name=br.target_scene_name,
condition=br.condition,
)
for br in sc.branches
],
rooms=[
RoomSummary(
name=room.name,
floor=room.floor,
description=room.description,
enemies=room.enemies,
branches=[
RoomBranchHint(
label=rb.label,
target_room_name=rb.target_room_name,
condition=rb.condition,
)
for rb in room.branches
],
)
for room in sc.rooms
],
)
for sc in ch.scenes
],
)
for ch in arc.chapters
],
)
for arc in dto.arcs
]
characters = [
CharacterSummary(name=c.name, snippet=c.snippet)
for c in dto.characters
]
npcs = [
NpcSummary(name=n.name, snippet=n.snippet)
for n in dto.npcs
]
return CampaignStructuralContext(
campaign_name=dto.campaign_name,
campaign_description=dto.campaign_description,
arcs=arcs,
characters=characters,
npcs=npcs,
)
def to_narrative_entity(dto: NarrativeEntityDTO | None) -> NarrativeEntityContext | None:
if dto is None:
return None
return NarrativeEntityContext(
entity_type=dto.entity_type,
title=dto.title,
fields=dict(dto.fields),
)
def to_game_system_context(dto: GameSystemContextDTO | None) -> GameSystemContext | None:
if dto is None:
return None
return GameSystemContext(
system_name=dto.system_name,
system_description=dto.system_description,
sections=dict(dto.sections),
)
def to_session_context(dto: SessionContextDTO | None) -> SessionContext | None:
if dto is None:
return None
return SessionContext(
session_name=dto.session_name,
active=dto.active,
started_at=dto.started_at,
entries=[_to_journal_entry(e) for e in dto.entries],
previous_events=[_to_journal_entry(e) for e in dto.previous_events],
available_quests=[_to_quest_summary(q) for q in dto.available_quests],
in_progress_quests=[_to_quest_summary(q) for q in dto.in_progress_quests],
locked_quest_titles=list(dto.locked_quest_titles),
active_flags=list(dto.active_flags),
)
def _to_quest_summary(dto: QuestSummaryDTO) -> QuestSummary:
return QuestSummary(
name=dto.name,
arc_name=dto.arc_name,
description=dto.description,
)
def _to_journal_entry(dto: JournalEntrySummaryDTO) -> JournalEntrySummary:
return JournalEntrySummary(
type=dto.type,
content=dto.content,
occurred_at=dto.occurred_at,
source_session_name=dto.source_session_name,
)

26
brain/app/api/common.py Normal file
View File

@@ -0,0 +1,26 @@
"""Utilitaires partagés des routers : encodage SSE + garde-fous d'upload PDF."""
from __future__ import annotations
import json
# Garde-fou taille : un livre de règles dépasse rarement quelques dizaines de Mo.
# Au-delà, on refuse (probable erreur d'upload) plutôt que d'OOM le conteneur.
MAX_PDF_BYTES = 60 * 1024 * 1024 # 60 Mo
def sse_event(event: str, data: dict) -> str:
"""Encode un évènement Server-Sent Events (accents préservés)."""
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
def pdf_upload_error(content: bytes) -> str | None:
"""Message d'erreur si l'upload PDF est invalide (vide / trop gros), sinon None.
Utilisé par les flux SSE, où l'erreur doit partir en évènement `error`
plutôt qu'en HTTPException (le flux est déjà ouvert en 200).
"""
if not content:
return "Fichier PDF vide."
if len(content) > MAX_PDF_BYTES:
return f"PDF trop volumineux (> {MAX_PDF_BYTES // (1024 * 1024)} Mo)."
return None

191
brain/app/api/deps.py Normal file
View File

@@ -0,0 +1,191 @@
"""Factories d'injection de dépendance — le point d'inversion de l'hexagone.
C'est ICI (et seulement ici) qu'on choisit QUEL adapter concret incarne chaque
port (LLM, embeddings, extracteur PDF), en fonction des Settings — modifiables
à chaud depuis l'écran Paramètres de l'UI. Les routers ne connaissent que les
ports et les use cases, jamais Ollama/Mistral/etc.
"""
import logging
from typing import Annotated
from fastapi import Depends, HTTPException
from app.application.adapt_campaign import AdaptCampaignUseCase
from app.application.chat import ChatUseCase
from app.application.embeddings import EmbeddingError
from app.application.generate_page import GeneratePageUseCase
from app.application.import_campaign import ImportCampaignUseCase
from app.application.import_rules import ImportRulesUseCase
from app.application.notebook_chat import NotebookChatUseCase
from app.application.notebook_deep import NotebookDeepUseCase
from app.application.notebook_rag import NotebookRagUseCase
from app.core.config import Settings, get_settings
from app.domain.ports import LLMProvider, LLMProviderError
from app.infrastructure.gemini_adapter import GeminiLLMProvider
from app.infrastructure.mistral_adapter import MistralLLMProvider
from app.infrastructure.mistral_embedding_adapter import MistralEmbeddingProvider
from app.infrastructure.ollama_adapter import OllamaLLMProvider
from app.infrastructure.ollama_embedding_adapter import OllamaEmbeddingProvider
from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
from app.infrastructure.openrouter_adapter import OpenRouterLLMProvider
from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor
logger = logging.getLogger(__name__)
# Extracteur PDF partagé : la détection OCR (version Tesseract) a un coût
# (subprocess) qu'on ne veut pas payer à chaque requête → singleton module.
_PDF_EXTRACTOR = PyMuPdfTextExtractor()
def _effective_import_chunk_tokens(settings: Settings) -> int:
"""Taille de morceau réellement utilisable pour l'import.
Avec Ollama, le morceau (entrée) ET sa réécriture en sections (sortie ≈ même
taille) doivent tenir ensemble dans `num_ctx` — sinon Ollama remplit la fenêtre
avec le prompt et la génération s'arrête après quelques tokens (JSON coupé net,
morceau perdu). Budget : entrée×~1.3 (les morceaux sont mesurés en tokens
cl100k, plus compacts que les tokenizers locaux) + consignes + sortie×~1.4
≤ num_ctx → morceau ≤ (num_ctx 800) / 2.7. On plafonne, avec un log pour
rester transparent. Les providers cloud (gros contexte) ne sont pas plafonnés.
"""
requested = settings.import_chunk_tokens
if settings.llm_provider != "ollama":
return requested
cap = max(1000, int((settings.llm_num_ctx - 800) / 2.7))
if requested > cap:
logger.warning(
"Taille de morceau d'import réduite de %s à %s tokens : avec num_ctx=%s, "
"un morceau plus gros ne laisserait pas la place à la sortie du modèle "
"(génération coupée). Augmentez num_ctx pour utiliser de plus gros morceaux.",
requested, cap, settings.llm_num_ctx,
)
return cap
return requested
def get_llm_provider(
settings: Annotated[Settings, Depends(get_settings)],
) -> LLMProvider:
"""Factory d'adapter — point d'inversion de dépendance.
C'est ici (et uniquement ici) qu'on choisit QUEL adapter concret
incarne le port, en fonction du champ `llm_provider` des Settings
(modifiable a chaud depuis l'ecran Parametres de l'UI).
"""
try:
if settings.llm_provider == "onemin":
return OneMinAiLLMProvider(settings)
if settings.llm_provider == "openrouter":
return OpenRouterLLMProvider(settings)
if settings.llm_provider == "mistral":
return MistralLLMProvider(settings)
if settings.llm_provider == "gemini":
return GeminiLLMProvider(settings)
return OllamaLLMProvider(settings)
except LLMProviderError as exc:
# Ex : cle 1min.ai manquante. On renvoie du 400 plutot que du 500
# pour que le frontend puisse afficher un message actionnable.
raise HTTPException(status_code=400, detail=str(exc)) from exc
def get_generate_page_use_case(
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
) -> GeneratePageUseCase:
"""Factory du use case — injecte le port LLMProvider sans connaître l'adapter."""
return GeneratePageUseCase(llm=llm)
def get_chat_use_case(
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
) -> ChatUseCase:
"""Factory du use case chat.
L'adapter OllamaLLMProvider satisfait les deux protocoles (LLMProvider
et LLMChatProvider) par duck typing ; on lui passe la même instance.
"""
return ChatUseCase(llm=llm) # type: ignore[arg-type]
def get_import_rules_use_case(
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
settings: Annotated[Settings, Depends(get_settings)],
) -> ImportRulesUseCase:
"""Factory du use case d'import de règles PDF (extraction + structuration)."""
# Modèle LOCAL → mode segmentation : le LLM ne renvoie que les frontières des
# sections (~200 tokens) et le texte original est découpé localement. Réécrire
# tout le contenu à ~100 tokens/s prendrait des dizaines de minutes par livre.
# Les providers cloud (rapides, grand contexte) gardent la réécriture nettoyée.
return ImportRulesUseCase(
llm=llm, extractor=_PDF_EXTRACTOR,
chunk_target_tokens=_effective_import_chunk_tokens(settings),
segment_only=settings.llm_provider == "ollama")
def get_import_campaign_use_case(
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
settings: Annotated[Settings, Depends(get_settings)],
) -> ImportCampaignUseCase:
"""Factory du use case d'import de campagne PDF (extraction + arborescence)."""
return ImportCampaignUseCase(
llm=llm,
extractor=_PDF_EXTRACTOR,
chunk_target_tokens=_effective_import_chunk_tokens(settings),
map_concurrency=settings.llm_map_concurrency,
)
def get_adapt_campaign_use_case(
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
settings: Annotated[Settings, Depends(get_settings)],
) -> AdaptCampaignUseCase:
"""Factory du use case d'adaptation d'un PDF à une campagne (conseils streamés)."""
# L'adapter satisfait aussi LLMChatProvider (stream_chat) par duck typing.
# Budget d'entrée = taille de morceau configurée (qui passe déjà côté provider).
return AdaptCampaignUseCase( # type: ignore[arg-type]
llm=llm, extractor=_PDF_EXTRACTOR, max_input_tokens=settings.import_chunk_tokens)
def get_embedding_provider(
settings: Annotated[Settings, Depends(get_settings)],
):
"""Factory de l'adapter d'embeddings (RAG) selon `embedding_provider`."""
try:
if settings.embedding_provider == "mistral":
return MistralEmbeddingProvider(settings)
return OllamaEmbeddingProvider(settings)
except EmbeddingError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
def get_notebook_rag_use_case(
embedder: Annotated[object, Depends(get_embedding_provider)],
settings: Annotated[Settings, Depends(get_settings)],
) -> NotebookRagUseCase:
return NotebookRagUseCase(
extractor=_PDF_EXTRACTOR,
embedder=embedder, # type: ignore[arg-type]
min_score=settings.rag_min_score,
)
def get_notebook_chat_use_case(
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
rag: Annotated[NotebookRagUseCase, Depends(get_notebook_rag_use_case)],
settings: Annotated[Settings, Depends(get_settings)],
) -> NotebookChatUseCase:
return NotebookChatUseCase(
rag=rag, llm=llm, rerank_enabled=settings.rag_rerank) # type: ignore[arg-type]
def get_notebook_deep_use_case(
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
embedder: Annotated[object, Depends(get_embedding_provider)],
settings: Annotated[Settings, Depends(get_settings)],
) -> NotebookDeepUseCase:
return NotebookDeepUseCase(
llm=llm,
batch_tokens=settings.import_chunk_tokens,
map_concurrency=settings.llm_map_concurrency,
embedder=embedder,
summary_filter=settings.deep_summary_filter,
)

View File

@@ -0,0 +1,5 @@
"""Routers FastAPI du Brain, un par responsabilité métier.
Chemins inchangés par rapport à l'ancien main.py monolithique : le Core Java
et le frontend ne voient AUCUNE différence.
"""

View File

@@ -0,0 +1,123 @@
"""Endpoint du chat contextuel (/chat/stream) : Structural Context + jauge tokens."""
import json
from typing import Annotated, AsyncIterator
import tiktoken
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from app.api.chat_dto import ChatStreamRequestDTO
from app.api.chat_mapping import (
to_campaign_context,
to_game_system_context,
to_lore_context,
to_narrative_entity,
to_page_context,
to_session_context,
)
from app.api.deps import get_chat_use_case
from app.application.chat import ChatUseCase
from app.core.config import get_settings
from app.core.language import get_user_language
from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError
router = APIRouter()
# Encodeur tiktoken partagé — chargé une fois pour éviter le coût de lookup
# à chaque requête. On utilise cl100k_base (GPT-3.5/4) comme tokenizer
# universel approximatif : ±10% d'écart avec Llama/Gemma mais largement
# suffisant pour une jauge visuelle à l'utilisateur.
_TOKEN_ENCODER: tiktoken.Encoding | None = None
def _count_tokens(text: str | None) -> int:
"""Compte les tokens d'un texte via tiktoken. Null/empty → 0."""
if not text:
return 0
global _TOKEN_ENCODER
if _TOKEN_ENCODER is None:
_TOKEN_ENCODER = tiktoken.get_encoding("cl100k_base")
return len(_TOKEN_ENCODER.encode(text))
@router.post("/chat/stream")
async def chat_stream(
body: ChatStreamRequestDTO,
use_case: Annotated[ChatUseCase, Depends(get_chat_use_case)],
language: Annotated[str, Depends(get_user_language)],
) -> StreamingResponse:
"""Chat streamé (Server-Sent Events) avec Structural Context.
Accepte jusqu'à 4 contextes optionnels (Lore, Page focalisée, Campagne,
entité narrative focalisée). Au moins un contexte racine (Lore ou
Campagne) est requis pour que la requête ait du sens.
Format de flux :
- Chaque token : `data: {"token": "..."}\\n\\n`
- Fin normale : `event: done\\ndata: {}\\n\\n`
- Erreur LLM : `event: error\\ndata: {"message": "..."}\\n\\n`
"""
if not body.has_scope():
raise HTTPException(
status_code=422,
detail="Au moins un des deux contextes racines (lore_context ou campaign_context) est requis.",
)
messages = [ChatMessage(role=m.role, content=m.content) for m in body.messages]
lore_context = to_lore_context(body.lore_context)
page_context = to_page_context(body.page_context)
campaign_context = to_campaign_context(body.campaign_context)
narrative_entity = to_narrative_entity(body.narrative_entity)
game_system_context = to_game_system_context(body.game_system_context)
session_context = to_session_context(body.session_context)
# --- Comptage tokens pour la jauge de contexte frontend ---
# On construit le system prompt une fois ici pour le compter — le use case
# le reconstruira à l'identique en interne (coût négligeable : concat de str).
# Cette duplication évite de complexifier le contrat stream() avec un
# paramètre optionnel system_prompt précalculé.
system_prompt_preview = use_case.build_system_prompt(
lore_context=lore_context,
page_context=page_context,
campaign_context=campaign_context,
narrative_entity=narrative_entity,
game_system_context=game_system_context,
session_context=session_context,
language=language,
)
# Dernier message = "current" (souvent user), le reste = historique accumulé.
current_msg = messages[-1] if messages else None
history_msgs = messages[:-1] if messages else []
settings = get_settings()
usage_payload = {
"system": _count_tokens(system_prompt_preview),
"history": sum(_count_tokens(m.content) for m in history_msgs),
"current": _count_tokens(current_msg.content) if current_msg else 0,
# Plafond connu seulement pour Ollama (num_ctx). Pour le cloud (1min/OpenRouter)
# on ne connaît pas la fenêtre réelle → 0 = "pas de max" (jauge sans dénominateur).
"max": settings.llm_num_ctx if settings.llm_provider == "ollama" else 0,
}
async def event_stream() -> AsyncIterator[str]:
# Event 'usage' émis en tout premier : le frontend peut afficher la
# jauge avant même le premier token de réponse.
yield f"event: usage\ndata: {json.dumps(usage_payload, ensure_ascii=False)}\n\n"
try:
async for token in use_case.stream(
messages,
lore_context=lore_context,
page_context=page_context,
campaign_context=campaign_context,
narrative_entity=narrative_entity,
game_system_context=game_system_context,
session_context=session_context,
language=language,
):
# json.dumps avec ensure_ascii=False pour préserver les accents
yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n"
yield "event: done\ndata: {}\n\n"
except LLMProviderError as exc:
yield f"event: error\ndata: {json.dumps({'message': str(exc)})}\n\n"
return StreamingResponse(event_stream(), media_type="text/event-stream")

View File

@@ -0,0 +1,133 @@
"""Endpoints de génération « simple » : prompt libre, page de Lore, auto-titre."""
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from app.api.deps import get_generate_page_use_case, get_llm_provider
from app.application.generate_page import GeneratePageUseCase
from app.application.prompts import conversation_title as title_prompts
from app.core.config import Settings, get_settings
from app.core.language import get_user_language
from app.domain.models import PageGenerationContext
from app.domain.ports import LLMProvider, LLMProviderError
router = APIRouter()
class GenerateRequest(BaseModel):
prompt: str
class GenerateResponse(BaseModel):
model: str
response: str
@router.post("/generate", response_model=GenerateResponse)
async def generate(
body: GenerateRequest,
settings: Annotated[Settings, Depends(get_settings)],
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
) -> GenerateResponse:
"""Endpoint libre : prompt → texte brut. Utile pour debug et exploration."""
try:
text = await llm.generate(body.prompt)
except LLMProviderError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return GenerateResponse(model=settings.llm_model, response=text)
class GeneratePageRequestDTO(BaseModel):
"""Contexte envoyé par le Core Java pour remplir une page via le LLM."""
lore_name: str
folder_name: str
template_name: str
template_fields: list[str] = Field(min_length=1)
page_title: str
lore_description: str | None = None
class GeneratePageResponseDTO(BaseModel):
"""Retour : une valeur textuelle par champ du template (clé = field name)."""
values: dict[str, str]
@router.post("/generate-page", response_model=GeneratePageResponseDTO)
async def generate_page(
body: GeneratePageRequestDTO,
use_case: Annotated[
GeneratePageUseCase, Depends(get_generate_page_use_case)
],
language: Annotated[str, Depends(get_user_language)],
) -> GeneratePageResponseDTO:
"""Endpoint métier : contexte LoreMind → valeurs structurées par champ.
Branche tout le use case `GeneratePageUseCase`. Ce controller ne fait
que le mapping DTO ↔ dataclass et la traduction d'erreur domaine → HTTP.
"""
context = PageGenerationContext(
lore_name=body.lore_name,
lore_description=body.lore_description,
folder_name=body.folder_name,
template_name=body.template_name,
template_fields=body.template_fields,
page_title=body.page_title,
)
try:
result = await use_case.execute(context, language=language)
except LLMProviderError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return GeneratePageResponseDTO(values=result.values)
# --- Auto-titre d'une conversation persistee --------------------------------
class SummarizeTitleMessageDTO(BaseModel):
role: Literal["user", "assistant", "system"]
content: str
class SummarizeTitleRequestDTO(BaseModel):
"""Premiers messages d'une conversation pour auto-generer un titre court."""
messages: list[SummarizeTitleMessageDTO] = Field(default_factory=list)
class SummarizeTitleResponseDTO(BaseModel):
title: str
@router.post("/summarize/conversation-title", response_model=SummarizeTitleResponseDTO)
async def summarize_conversation_title(
body: SummarizeTitleRequestDTO,
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
language: Annotated[str, Depends(get_user_language)],
) -> SummarizeTitleResponseDTO:
"""Genere un titre court a partir des premiers echanges de la conversation.
Appele par le core apres le 1er couple user/assistant, pour remplacer le
titre provisoire "Nouvelle conversation" par quelque chose de parlant.
"""
if not body.messages:
raise HTTPException(status_code=422, detail="Au moins un message requis")
transcript = "\n".join(f"{m.role.upper()}: {m.content}" for m in body.messages[:6])
prompt = f"{title_prompts.title_system_prompt(language)}\n\nConversation :\n{transcript}\n\nTitre :"
try:
raw = await llm.generate(prompt)
except LLMProviderError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
title = raw.strip().splitlines()[0].strip().strip('"').strip("'").rstrip(".")
if len(title) > 80:
title = title[:80].rstrip()
if not title:
title = title_prompts.TITLE_FALLBACK.get(language, title_prompts.TITLE_FALLBACK["fr"])
return SummarizeTitleResponseDTO(title=title)

View File

@@ -0,0 +1,188 @@
"""Endpoints d'import/adaptation de PDF (règles, campagne) — REST + flux SSE."""
import json
import logging
from typing import Annotated, AsyncIterator
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from app.api.common import MAX_PDF_BYTES, pdf_upload_error, sse_event
from app.api.deps import (
get_adapt_campaign_use_case,
get_import_campaign_use_case,
get_import_rules_use_case,
)
from app.application.adapt_campaign import AdaptCampaignUseCase
from app.application.import_campaign import ImportCampaignUseCase
from app.application.import_rules import ImportRulesUseCase
from app.core.language import get_user_language
from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError, PdfExtractionError
logger = logging.getLogger(__name__)
router = APIRouter()
class RulesImportResponseDTO(BaseModel):
"""Proposition de sections de règles extraites d'un PDF.
`sections` = {titre → contenu markdown}. C'est une PROPOSITION : le Core
et l'UI laissent l'utilisateur réviser/éditer avant toute persistance.
`ocr_page_count` permet d'indiquer si le PDF était un scan (OCR utilisé).
"""
sections: dict[str, str]
page_count: int
ocr_page_count: int
@router.post("/import/rules", response_model=RulesImportResponseDTO)
async def import_rules(
use_case: Annotated[ImportRulesUseCase, Depends(get_import_rules_use_case)],
language: Annotated[str, Depends(get_user_language)],
file: UploadFile = File(...),
) -> RulesImportResponseDTO:
"""Import d'un PDF de règles → sections markdown structurées (proposition).
Extrait le texte (couche texte + repli OCR par page pour les scans), découpe,
et demande au LLM de répartir les règles en sections thématiques. Ne persiste
rien : renvoie la proposition au Core, qui la présente pour révision.
"""
content = await file.read()
if not content:
raise HTTPException(status_code=422, detail="Fichier PDF vide.")
if len(content) > MAX_PDF_BYTES:
raise HTTPException(
status_code=413,
detail=f"PDF trop volumineux (> {MAX_PDF_BYTES // (1024 * 1024)} Mo).",
)
try:
result = await use_case.execute(content, language=language)
except PdfExtractionError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except LLMProviderError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return RulesImportResponseDTO(
sections=result.sections,
page_count=result.page_count,
ocr_page_count=result.ocr_page_count,
)
@router.post("/import/rules/stream")
async def import_rules_stream(
use_case: Annotated[ImportRulesUseCase, Depends(get_import_rules_use_case)],
language: Annotated[str, Depends(get_user_language)],
file: UploadFile = File(...),
) -> StreamingResponse:
"""Import streamé : émet l'avancement (SSE) puis le résultat final.
Évènements SSE :
- `event: extracting` → data: {} (extraction en cours)
- `event: start` → data: {page_count, ocr_page_count, total}
- `event: progress` → data: {current, total, new_sections:[...]}
- `event: done` → data: {sections, page_count, ocr_page_count}
- `event: error` → data: {message}
"""
content = await file.read()
async def event_stream() -> AsyncIterator[str]:
upload_error = pdf_upload_error(content)
if upload_error:
yield sse_event("error", {"message": upload_error})
return
try:
async for ev in use_case.stream(content, language=language):
event_type = ev.pop("type")
yield sse_event(event_type, ev)
except PdfExtractionError as exc:
yield sse_event("error", {"message": str(exc)})
except LLMProviderError as exc:
yield sse_event("error", {"message": str(exc)})
except Exception as exc: # noqa: BLE001 — filet : une erreur inattendue ne doit
# PAS casser le flux SSE brutalement (sinon le Core n'a qu'un message générique
# sans détail). On la transforme en évènement `error` propre + log avec trace.
logger.exception("Import règles : erreur inattendue dans le flux.")
yield sse_event("error", {"message": f"Erreur inattendue du Brain : {type(exc).__name__} : {exc}"})
return StreamingResponse(event_stream(), media_type="text/event-stream")
@router.post("/import/campaign/stream")
async def import_campaign_stream(
use_case: Annotated[ImportCampaignUseCase, Depends(get_import_campaign_use_case)],
file: UploadFile = File(...),
) -> StreamingResponse:
"""Import streamé d'un PDF de campagne → arbre arc→chapitre→scène (SSE).
Évènements : `extracting`, `start` {page_count, ocr_page_count, total},
`progress` {current, total, arc_count, chapter_count, scene_count},
`done` {arcs:[...], page_count, ocr_page_count}, `error` {message}.
"""
content = await file.read()
async def event_stream() -> AsyncIterator[str]:
upload_error = pdf_upload_error(content)
if upload_error:
yield sse_event("error", {"message": upload_error})
return
try:
async for ev in use_case.stream(content):
event_type = ev.pop("type")
yield sse_event(event_type, ev)
except PdfExtractionError as exc:
yield sse_event("error", {"message": str(exc)})
except LLMProviderError as exc:
yield sse_event("error", {"message": str(exc)})
except Exception as exc: # noqa: BLE001 — voir import règles : on ne laisse pas
# une erreur inattendue casser le flux sans détail.
logger.exception("Import campagne : erreur inattendue dans le flux.")
yield sse_event("error", {"message": f"Erreur inattendue du Brain : {type(exc).__name__} : {exc}"})
return StreamingResponse(event_stream(), media_type="text/event-stream")
@router.post("/adapt/campaign/stream")
async def adapt_campaign_stream(
use_case: Annotated[AdaptCampaignUseCase, Depends(get_adapt_campaign_use_case)],
language: Annotated[str, Depends(get_user_language)],
file: UploadFile = File(...),
brief: str = Form(""),
messages: str = Form("[]"),
) -> StreamingResponse:
"""Adaptation CONVERSATIONNELLE d'un PDF à une campagne (SSE markdown).
`brief` = description de la campagne (Core). `messages` = JSON de l'échange
([{role, content}, …]) ; vide au 1er tour. Évènements : `token`, `done`, `error`.
"""
content = await file.read()
try:
raw_messages = json.loads(messages) if messages else []
except json.JSONDecodeError:
raw_messages = []
convo = [
ChatMessage(role=str(m.get("role", "user")), content=str(m.get("content", "")))
for m in raw_messages
if isinstance(m, dict) and str(m.get("content", "")).strip()
]
async def event_stream() -> AsyncIterator[str]:
upload_error = pdf_upload_error(content)
if upload_error:
yield sse_event("error", {"message": upload_error})
return
try:
async for token in use_case.stream(content, brief, convo, language=language):
yield sse_event("token", {"token": token})
yield sse_event("done", {})
except PdfExtractionError as exc:
yield sse_event("error", {"message": str(exc)})
except LLMProviderError as exc:
yield sse_event("error", {"message": str(exc)})
return StreamingResponse(event_stream(), media_type="text/event-stream")

View File

@@ -0,0 +1,365 @@
"""Endpoints de catalogue de modèles (Ollama, OpenRouter, Mistral, Gemini, 1min.ai).
Proxifie les APIs des providers pour que l'UI propose des listes de modèles ;
repli statique quand l'API est injoignable ou la clé absente (pas de 500 à l'UI).
"""
import json
from typing import Annotated, AsyncIterator
import httpx
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from app.core.config import Settings, get_settings
router = APIRouter()
@router.get("/models/ollama")
async def list_ollama_models(
settings: Annotated[Settings, Depends(get_settings)],
) -> dict[str, list[str]]:
"""Liste les modeles disponibles sur le serveur Ollama configure.
Retourne une liste vide si Ollama est injoignable — l'UI affichera un
message plutot qu'une 500.
"""
url = f"{settings.ollama_base_url}/api/tags"
try:
async with httpx.AsyncClient(timeout=5) as client:
response = await client.get(url)
response.raise_for_status()
data = response.json()
except httpx.HTTPError:
return {"models": []}
models = [m.get("name", "") for m in data.get("models", []) if m.get("name")]
return {"models": sorted(models)}
class OllamaModelInfoDTO(BaseModel):
"""Info utile extraite de /api/show pour un modele Ollama donne.
`context_length` = fenetre de contexte max supportee par le modele
(extraite des metadonnees GGUF). 0 si inconnue. Le frontend s'en sert
pour borner le slider de num_ctx dans les Parametres.
"""
context_length: int = 0
@router.post("/models/ollama/info", response_model=OllamaModelInfoDTO)
async def get_ollama_model_info(
body: dict[str, str],
settings: Annotated[Settings, Depends(get_settings)],
) -> OllamaModelInfoDTO:
"""Retourne les metadonnees d'un modele Ollama via /api/show.
On passe par POST (et pas GET /models/ollama/{name}) parce que les noms
Ollama contiennent souvent un `:` (ex: `gemma3:e2b`) qui se segmente
mal dans une URL — le body JSON evite le probleme d'escaping.
Le champ qui nous interesse est `model_info["<arch>.context_length"]`
(ex: `gemma3.context_length: 131072`). L'arch varie selon le modele, on
scanne donc tous les champs finissant par `.context_length`.
"""
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name requis")
url = f"{settings.ollama_base_url}/api/show"
try:
async with httpx.AsyncClient(timeout=5) as client:
response = await client.post(url, json={"model": name})
response.raise_for_status()
data = response.json()
except httpx.HTTPError:
return OllamaModelInfoDTO(context_length=0)
model_info = data.get("model_info") or {}
for key, value in model_info.items():
if key.endswith(".context_length") and isinstance(value, int):
return OllamaModelInfoDTO(context_length=value)
return OllamaModelInfoDTO(context_length=0)
@router.post("/models/ollama/pull")
async def pull_ollama_model(
body: dict[str, str],
settings: Annotated[Settings, Depends(get_settings)],
) -> StreamingResponse:
"""Telecharge un modele depuis Ollama et streame la progression.
Proxifie l'endpoint `/api/pull` d'Ollama qui renvoie du JSON ligne par
ligne (NDJSON) avec le statut de chaque etape : manifest, layers,
digest, success. On reemet ce flux tel quel au client (le front
parsera les lignes et affichera une barre de progression).
Le timeout est intentionnellement tres long (60 min) car certains
modeles font 30+ Go.
"""
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name requis")
url = f"{settings.ollama_base_url}/api/pull"
async def stream() -> AsyncIterator[bytes]:
# On utilise un timeout long pour la lecture (60 min) mais court pour
# la connexion (10s) — si Ollama n'est pas joignable, on echoue vite.
timeout = httpx.Timeout(connect=10, read=3600, write=10, pool=10)
try:
async with httpx.AsyncClient(timeout=timeout) as client:
async with client.stream("POST", url, json={"model": name, "stream": True}) as r:
if r.status_code != 200:
# Ollama renvoie un message JSON d'erreur. On le passe
# tel quel au client en preservant le code HTTP.
body_text = await r.aread()
yield body_text
return
async for chunk in r.aiter_bytes():
yield chunk
except httpx.HTTPError as e:
# Erreur reseau : on emet une ligne JSON d'erreur compatible
# avec le format NDJSON d'Ollama.
err = json.dumps({"error": f"Connexion a Ollama impossible : {e}"}) + "\n"
yield err.encode("utf-8")
# application/x-ndjson : un objet JSON par ligne, pas de wrapping SSE.
# C'est le format natif d'Ollama, le front le parsera ligne par ligne.
return StreamingResponse(stream(), media_type="application/x-ndjson")
@router.delete("/models/ollama/{name:path}")
async def delete_ollama_model(
name: str,
settings: Annotated[Settings, Depends(get_settings)],
) -> dict[str, str]:
"""Supprime un modele du serveur Ollama.
Le `:path` dans le pattern autorise les `:` du nom (ex: `gemma4:e4b`)
sans avoir besoin de URL-encoder cote client.
"""
if not name.strip():
raise HTTPException(status_code=400, detail="name requis")
url = f"{settings.ollama_base_url}/api/delete"
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.request("DELETE", url, json={"model": name})
if response.status_code == 404:
raise HTTPException(status_code=404, detail=f"Modele '{name}' introuvable")
response.raise_for_status()
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"Ollama injoignable : {e}")
return {"status": "deleted", "name": name}
@router.get("/models/openrouter")
async def list_openrouter_models() -> dict[str, list[dict[str, object]]]:
"""Catalogue DYNAMIQUE des modeles OpenRouter (API publique, sans cle).
Renvoie {models: [{id, name, context_length, free}]}, trie gratuits d'abord
puis contexte decroissant. `free` = id finissant par ':free' OU prix nul.
"""
try:
async with httpx.AsyncClient(timeout=20) as client:
response = await client.get("https://openrouter.ai/api/v1/models")
response.raise_for_status()
data = response.json()
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"OpenRouter injoignable : {exc}")
def _is_zero(value: object) -> bool:
try:
return float(value) == 0.0 # type: ignore[arg-type]
except (TypeError, ValueError):
return False
models: list[dict[str, object]] = []
for m in data.get("data", []) or []:
mid = str(m.get("id") or "")
if not mid:
continue
pricing = m.get("pricing") or {}
is_free = mid.endswith(":free") or (
_is_zero(pricing.get("prompt")) and _is_zero(pricing.get("completion"))
)
try:
ctx = int(m.get("context_length") or 0)
except (TypeError, ValueError):
ctx = 0
models.append({
"id": mid,
"name": str(m.get("name") or mid),
"context_length": ctx,
"free": is_free,
})
models.sort(key=lambda x: (not x["free"], -int(x["context_length"]))) # type: ignore[index]
return {"models": models}
# Repli statique si la cle Mistral n'est pas (encore) configuree ou si l'API est
# injoignable — l'utilisateur peut quand meme choisir un modele. Liste curee
# (juin 2026) ; pour l'extraction de PDF, prefere `large` (fidele, 128k) ou `small`.
_MISTRAL_FALLBACK_MODELS = [
"mistral-large-latest",
"mistral-medium-latest",
"mistral-small-latest",
"open-mistral-nemo",
"ministral-8b-latest",
"ministral-3b-latest",
"magistral-medium-latest",
"magistral-small-latest",
"pixtral-large-latest",
"codestral-latest",
]
@router.get("/models/mistral")
async def list_mistral_models(
settings: Annotated[Settings, Depends(get_settings)],
) -> dict[str, list[dict[str, object]]]:
"""Catalogue des modeles Mistral. Dynamique si une cle est configuree
(GET /v1/models, qui requiert l'auth), sinon repli statique.
Renvoie {models: [{id}]} (tous accessibles sur le tier gratuit Experiment)."""
key = settings.mistral_api_key
if not key:
return {"models": [{"id": m} for m in _MISTRAL_FALLBACK_MODELS]}
try:
async with httpx.AsyncClient(timeout=20) as client:
response = await client.get(
"https://api.mistral.ai/v1/models",
headers={"Authorization": f"Bearer {key}"},
)
response.raise_for_status()
data = response.json()
except httpx.HTTPError:
# Cle invalide / API down : on ne casse pas l'UI, on propose le repli.
return {"models": [{"id": m} for m in _MISTRAL_FALLBACK_MODELS]}
ids = sorted({str(m.get("id")) for m in data.get("data", []) or [] if m.get("id")})
if not ids:
ids = _MISTRAL_FALLBACK_MODELS
return {"models": [{"id": i} for i in ids]}
# Repli statique Gemini (juin 2026). Pour l'extraction, prefere un Flash a grand
# contexte ; `gemini-2.0-flash` a le quota gratuit le plus genereux.
_GEMINI_FALLBACK_MODELS = [
"gemini-2.0-flash",
"gemini-2.0-flash-lite",
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
"gemini-2.5-pro",
"gemini-1.5-flash",
"gemini-1.5-pro",
]
@router.get("/models/gemini")
async def list_gemini_models(
settings: Annotated[Settings, Depends(get_settings)],
) -> dict[str, list[dict[str, object]]]:
"""Catalogue des modeles Gemini. Dynamique si une cle est configuree (endpoint
OpenAI-compatible /openai/models), sinon repli statique. Renvoie {models:[{id}]}."""
key = settings.gemini_api_key
if not key:
return {"models": [{"id": m} for m in _GEMINI_FALLBACK_MODELS]}
try:
async with httpx.AsyncClient(timeout=20) as client:
response = await client.get(
"https://generativelanguage.googleapis.com/v1beta/openai/models",
headers={"Authorization": f"Bearer {key}"},
)
response.raise_for_status()
data = response.json()
except httpx.HTTPError:
return {"models": [{"id": m} for m in _GEMINI_FALLBACK_MODELS]}
# Les ids peuvent arriver prefixes "models/" → on nettoie pour que la valeur
# selectionnee soit directement utilisable dans l'appel chat. On garde les
# modeles "gemini-*" (hors embeddings/aqa) pour ne pas noyer la liste.
ids: set[str] = set()
for m in data.get("data", []) or []:
mid = str(m.get("id") or "")
if mid.startswith("models/"):
mid = mid[len("models/"):]
if mid.startswith("gemini-"):
ids.add(mid)
clean = sorted(ids) if ids else _GEMINI_FALLBACK_MODELS
return {"models": [{"id": i} for i in clean]}
@router.get("/models/onemin")
def list_onemin_models() -> dict[str, list[dict[str, object]]]:
"""Catalogue statique des modeles 1min.ai, groupes par fournisseur.
Liste construite par probing direct de l'endpoint chat-with-ai avec
une vraie cle API (avril 2026) : chaque ID renvoie 200, les IDs
absents renvoient 400 UNSUPPORTED_MODEL.
Nota : les IDs Anthropic utilisent la nomenclature propre a 1min.ai
(`claude-<family>-<version>`), pas la convention officielle Anthropic.
"""
return {
"groups": [
{
"provider": "Anthropic",
"models": ["claude-opus-4-6", "claude-sonnet-4-6"],
},
{
"provider": "OpenAI",
"models": [
"gpt-5",
"gpt-5-mini",
"gpt-5-nano",
"gpt-4.1",
"gpt-4.1-mini",
"gpt-4.1-nano",
"gpt-4o",
"gpt-4o-mini",
"gpt-4-turbo",
"gpt-3.5-turbo",
"o3",
"o3-pro",
"o3-mini",
"o4-mini",
],
},
{
"provider": "Google",
"models": ["gemini-2.5-pro", "gemini-2.5-flash"],
},
{
"provider": "Mistral",
"models": [
"mistral-large-latest",
"mistral-medium-latest",
"mistral-small-latest",
"open-mistral-nemo",
],
},
{
"provider": "DeepSeek",
"models": ["deepseek-chat", "deepseek-reasoner"],
},
{
"provider": "xAI",
"models": ["grok-3", "grok-3-mini"],
},
{
"provider": "Meta",
"models": [
"meta/meta-llama-3.1-405b-instruct",
"meta/meta-llama-3-70b-instruct",
],
},
{
"provider": "Alibaba",
"models": ["qwen-plus", "qwen3-max"],
},
{
"provider": "Perplexity",
"models": ["sonar", "sonar-pro"],
},
]
}

View File

@@ -0,0 +1,133 @@
"""Endpoints des notebooks (atelier RAG) : indexation des sources + chats ancrés."""
import logging
from typing import Annotated, AsyncIterator
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app.api.common import MAX_PDF_BYTES, sse_event
from app.api.deps import (
get_notebook_chat_use_case,
get_notebook_deep_use_case,
get_notebook_rag_use_case,
)
from app.application.embeddings import EmbeddingError
from app.application.notebook_chat import NotebookChatUseCase
from app.application.notebook_deep import NotebookDeepUseCase
from app.application.notebook_rag import NotebookRagUseCase
from app.core.config import Settings, get_settings
from app.core.language import get_user_language
from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError, PdfExtractionError
from app.infrastructure import vector_store
logger = logging.getLogger(__name__)
router = APIRouter()
class IndexSourceResponseDTO(BaseModel):
chunks: int
page_count: int
ocr_page_count: int
@router.post("/index/notebook-source", response_model=IndexSourceResponseDTO)
async def index_notebook_source(
rag: Annotated[NotebookRagUseCase, Depends(get_notebook_rag_use_case)],
source_id: str = Form(...),
file: UploadFile = File(...),
) -> IndexSourceResponseDTO:
"""Indexe une source PDF (extraction + embeddings + stockage vectoriel)."""
content = await file.read()
if not content:
raise HTTPException(status_code=422, detail="Fichier PDF vide.")
if len(content) > MAX_PDF_BYTES:
raise HTTPException(
status_code=413, detail=f"PDF trop volumineux (> {MAX_PDF_BYTES // (1024 * 1024)} Mo).")
try:
recap = await rag.index_source(source_id, content)
except PdfExtractionError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except EmbeddingError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return IndexSourceResponseDTO(**recap)
@router.delete("/index/notebook-source/{source_id}")
def delete_notebook_source(source_id: str) -> dict[str, str]:
"""Supprime les vecteurs d'une source (au DELETE d'une source/notebook)."""
vector_store.delete(source_id)
return {"status": "deleted", "source_id": source_id}
class NotebookChatMessageDTO(BaseModel):
role: str
content: str
class NotebookChatRequestDTO(BaseModel):
source_ids: list[str] = Field(default_factory=list)
messages: list[NotebookChatMessageDTO] = Field(default_factory=list)
context: str = Field(default="")
@router.post("/chat/notebook/stream")
async def chat_notebook_stream(
body: NotebookChatRequestDTO,
use_case: Annotated[NotebookChatUseCase, Depends(get_notebook_chat_use_case)],
settings: Annotated[Settings, Depends(get_settings)],
language: Annotated[str, Depends(get_user_language)],
) -> StreamingResponse:
"""Chat ANCRÉ sur les sources (RAG) : récupère les passages pertinents puis
streame la réponse. Évènements SSE : `token` {token}, `done` {}, `error` {message}."""
messages = [ChatMessage(role=m.role, content=m.content) for m in body.messages]
top_k = max(1, min(settings.rag_top_k, 200))
async def event_stream() -> AsyncIterator[str]:
try:
async for ev in use_case.stream(body.source_ids, messages, context=body.context, top_k=top_k, language=language):
if ev["type"] == "token":
if ev.get("token"):
yield sse_event("token", {"token": ev["token"]})
else:
# 'sources' (et tout futur évènement typé) : relayé tel quel.
ev_type = ev.pop("type")
yield sse_event(ev_type, ev)
yield sse_event("done", {})
except (LLMProviderError, EmbeddingError) as exc:
yield sse_event("error", {"message": str(exc)})
except Exception as exc: # noqa: BLE001 — filet : pas de coupure brutale du flux.
logger.exception("Chat notebook : erreur inattendue.")
yield sse_event("error", {"message": f"Erreur inattendue du Brain : {type(exc).__name__} : {exc}"})
return StreamingResponse(event_stream(), media_type="text/event-stream")
@router.post("/chat/notebook/deep/stream")
async def chat_notebook_deep_stream(
body: NotebookChatRequestDTO,
use_case: Annotated[NotebookDeepUseCase, Depends(get_notebook_deep_use_case)],
language: Annotated[str, Depends(get_user_language)],
) -> StreamingResponse:
"""Analyse APPROFONDIE (map-reduce sur tout le document). Évènements SSE :
`progress` {current,total} pendant la lecture, puis `token` {token}, puis `done`."""
messages = [ChatMessage(role=m.role, content=m.content) for m in body.messages]
question = next((m.content for m in reversed(messages) if m.role == "user"), "")
async def event_stream() -> AsyncIterator[str]:
if not question.strip():
yield sse_event("error", {"message": "Question vide."})
return
try:
async for ev in use_case.stream(body.source_ids, messages, context=body.context, language=language):
ev_type = ev.pop("type")
yield sse_event(ev_type, ev)
except (LLMProviderError, EmbeddingError) as exc:
yield sse_event("error", {"message": str(exc)})
except Exception as exc: # noqa: BLE001 — filet : pas de coupure brutale.
logger.exception("Analyse approfondie : erreur inattendue.")
yield sse_event("error", {"message": f"Erreur inattendue du Brain : {type(exc).__name__} : {exc}"})
return StreamingResponse(event_stream(), media_type="text/event-stream")

View File

@@ -0,0 +1,115 @@
"""Endpoints de paramétrage runtime (écran Paramètres de l'UI)."""
from typing import Annotated, Literal
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from app.core.config import Settings, get_settings
from app.core.settings_store import save_overrides
router = APIRouter()
class SettingsDTO(BaseModel):
"""Vue serialisable des settings modifiables depuis l'UI.
Expose uniquement les champs que l'utilisateur peut changer a chaud.
Les secrets (onemin_api_key) sont masques en lecture.
"""
llm_provider: Literal["ollama", "onemin", "openrouter", "mistral", "gemini"]
ollama_base_url: str
llm_model: str
onemin_model: str
# True si une cle 1min.ai est deja configuree — pas de leak de la cle elle-meme.
onemin_api_key_set: bool
openrouter_model: str
# True si une cle OpenRouter est deja configuree (cle elle-meme jamais renvoyee).
openrouter_api_key_set: bool
mistral_model: str
# True si une cle Mistral est deja configuree (cle elle-meme jamais renvoyee).
mistral_api_key_set: bool
gemini_model: str
# True si une cle Gemini est deja configuree (cle elle-meme jamais renvoyee).
gemini_api_key_set: bool
# Embeddings (RAG des ateliers) : provider + modeles + auto-pull Ollama.
embedding_provider: Literal["ollama", "mistral"]
ollama_embedding_model: str
mistral_embedding_model: str
auto_pull_embedding_model: bool
rag_top_k: int
# Fenetre de contexte effective passee au modele (num_ctx Ollama) — sert
# aussi de plafond a la jauge de contexte UI.
llm_num_ctx: int
# Taille cible d'un morceau (tokens) pour l'import de PDF (regles/campagne).
import_chunk_tokens: int
# Timeout HTTP des appels LLM (s). A monter si les imports lourds expirent.
llm_timeout_seconds: int
class SettingsUpdateDTO(BaseModel):
"""Patch partiel des settings. Tous les champs sont optionnels."""
llm_provider: Literal["ollama", "onemin", "openrouter", "mistral", "gemini"] | None = None
ollama_base_url: str | None = None
llm_model: str | None = None
onemin_model: str | None = None
# Chaine vide => on efface la cle. None => pas de changement.
onemin_api_key: str | None = None
openrouter_model: str | None = None
openrouter_api_key: str | None = None
mistral_model: str | None = None
mistral_api_key: str | None = None
gemini_model: str | None = None
gemini_api_key: str | None = None
embedding_provider: Literal["ollama", "mistral"] | None = None
ollama_embedding_model: str | None = None
mistral_embedding_model: str | None = None
auto_pull_embedding_model: bool | None = None
rag_top_k: int | None = None
llm_num_ctx: int | None = None
import_chunk_tokens: int | None = None
llm_timeout_seconds: int | None = None
def _to_settings_dto(s: Settings) -> SettingsDTO:
return SettingsDTO(
llm_provider=s.llm_provider,
ollama_base_url=s.ollama_base_url,
llm_model=s.llm_model,
onemin_model=s.onemin_model,
onemin_api_key_set=bool(s.onemin_api_key),
openrouter_model=s.openrouter_model,
openrouter_api_key_set=bool(s.openrouter_api_key),
mistral_model=s.mistral_model,
mistral_api_key_set=bool(s.mistral_api_key),
gemini_model=s.gemini_model,
gemini_api_key_set=bool(s.gemini_api_key),
embedding_provider=s.embedding_provider,
ollama_embedding_model=s.ollama_embedding_model,
mistral_embedding_model=s.mistral_embedding_model,
auto_pull_embedding_model=s.auto_pull_embedding_model,
rag_top_k=s.rag_top_k,
llm_num_ctx=s.llm_num_ctx,
import_chunk_tokens=s.import_chunk_tokens,
llm_timeout_seconds=s.llm_timeout_seconds,
)
@router.get("/settings", response_model=SettingsDTO)
def read_settings(settings: Annotated[Settings, Depends(get_settings)]) -> SettingsDTO:
"""Retourne la config courante (secrets masques)."""
return _to_settings_dto(settings)
@router.put("/settings", response_model=SettingsDTO)
def update_settings(patch: SettingsUpdateDTO) -> SettingsDTO:
"""Applique un patch partiel aux settings et persiste les overrides.
Toute requete HTTP suivante verra les nouvelles valeurs (pas de cache).
"""
overrides = {k: v for k, v in patch.model_dump().items() if v is not None}
if overrides:
save_overrides(overrides)
# Relit .env + overrides fusionnes pour confirmation.
return _to_settings_dto(get_settings())

View File

@@ -0,0 +1,187 @@
"""Endpoints « outils de table » : tables aléatoires, improvisation, catalogues d'objets."""
import re
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from app.api.deps import get_llm_provider
from app.application.llm_json import load_json_object
from app.application.llm_retry import generate_with_retry
from app.application.prompts import tables as prompts
from app.core.language import get_user_language
from app.domain.ports import LLMProvider, LLMProviderError
router = APIRouter()
_DICE_FORMULA_RE = re.compile(r"^\s*(\d*)\s*[dD]\s*(\d+)\s*$")
def _dice_total_range(formula: str) -> tuple[int, int] | None:
"""(min, max) des totaux possibles d'une formule NdM, ou None si invalide."""
match = _DICE_FORMULA_RE.match(formula or "")
if not match:
return None
count = int(match.group(1)) if match.group(1) else 1
faces = int(match.group(2))
if count < 1 or count > 100 or faces < 2 or faces > 10000:
return None
return count, count * faces
class GenerateTableRequestDTO(BaseModel):
description: str
dice_formula: str = Field(default="1d20")
# Contexte libre assemblé par le Core (nom de campagne, système, ambiance…).
context: str = Field(default="")
class GeneratedTableEntryDTO(BaseModel):
min_roll: int
max_roll: int
label: str
detail: str = ""
class GenerateTableResponseDTO(BaseModel):
name: str
description: str = ""
entries: list[GeneratedTableEntryDTO]
@router.post("/generate/random-table", response_model=GenerateTableResponseDTO)
async def generate_random_table(
body: GenerateTableRequestDTO,
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
language: Annotated[str, Depends(get_user_language)],
) -> GenerateTableResponseDTO:
"""Génère une table aléatoire (entrées par plage) couvrant la formule de dé."""
rng = _dice_total_range(body.dice_formula)
if rng is None:
raise HTTPException(status_code=422, detail="Formule de dé invalide (ex. 1d20, 2d6, d100).")
lo, hi = rng
prompt = prompts.random_table_prompt(
body.description, body.dice_formula, lo, hi, body.context, language)
try:
raw = await generate_with_retry(llm, prompt, output_format="json", temperature=0.7)
except LLMProviderError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
parsed, _ = load_json_object(raw)
if not isinstance(parsed, dict):
raise HTTPException(status_code=502, detail="Le modèle n'a pas renvoyé de table exploitable.")
entries: list[GeneratedTableEntryDTO] = []
for e in parsed.get("entries", []) or []:
if not isinstance(e, dict):
continue
try:
mn = int(e["min_roll"])
mx = int(e["max_roll"])
except (KeyError, TypeError, ValueError):
continue
label = str(e.get("label") or "").strip()
if not label:
continue
entries.append(GeneratedTableEntryDTO(
min_roll=mn, max_roll=max(mn, mx), label=label[:200],
detail=str(e.get("detail") or "").strip(),
))
if not entries:
raise HTTPException(status_code=502, detail="Aucune entrée générée — réessaie ou reformule.")
name = str(parsed.get("name") or body.description).strip()[:120] or "Table générée"
return GenerateTableResponseDTO(
name=name,
description=str(parsed.get("description") or "").strip(),
entries=entries,
)
class ImproviseRollRequestDTO(BaseModel):
table_name: str
result_label: str
result_detail: str = Field(default="")
context: str = Field(default="")
class ImproviseRollResponseDTO(BaseModel):
narration: str
@router.post("/improvise/table-roll", response_model=ImproviseRollResponseDTO)
async def improvise_table_roll(
body: ImproviseRollRequestDTO,
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
language: Annotated[str, Depends(get_user_language)],
) -> ImproviseRollResponseDTO:
"""Brode un court récit (2-3 phrases) sur un résultat tiré, pour lancer la scène."""
prompt = prompts.improvise_roll_prompt(
body.table_name, body.result_label, body.result_detail, body.context, language)
try:
raw = await llm.generate(prompt, temperature=0.8)
except LLMProviderError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return ImproviseRollResponseDTO(narration=raw.strip())
# --- Catalogues d'objets (boutiques) : génération IA -------------------------
class GenerateCatalogRequestDTO(BaseModel):
description: str
context: str = Field(default="")
class GeneratedCatalogItemDTO(BaseModel):
name: str
price: str = ""
category: str = ""
description: str = ""
class GenerateCatalogResponseDTO(BaseModel):
name: str
description: str = ""
items: list[GeneratedCatalogItemDTO]
@router.post("/generate/item-catalog", response_model=GenerateCatalogResponseDTO)
async def generate_item_catalog(
body: GenerateCatalogRequestDTO,
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
language: Annotated[str, Depends(get_user_language)],
) -> GenerateCatalogResponseDTO:
"""Génère un catalogue d'objets (boutique, butin…) — nom, prix, catégorie, description."""
prompt = prompts.item_catalog_prompt(body.description, body.context, language)
try:
raw = await generate_with_retry(llm, prompt, output_format="json", temperature=0.7)
except LLMProviderError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
parsed, _ = load_json_object(raw)
if not isinstance(parsed, dict):
raise HTTPException(status_code=502, detail="Le modèle n'a pas renvoyé de catalogue exploitable.")
items: list[GeneratedCatalogItemDTO] = []
for it in parsed.get("items", []) or []:
if not isinstance(it, dict):
continue
name = str(it.get("name") or "").strip()
if not name:
continue
items.append(GeneratedCatalogItemDTO(
name=name[:200],
price=str(it.get("price") or "").strip(),
category=str(it.get("category") or "").strip(),
description=str(it.get("description") or "").strip(),
))
if not items:
raise HTTPException(status_code=502, detail="Aucun objet généré — réessaie ou reformule.")
name = str(parsed.get("name") or body.description).strip()[:120] or "Catalogue généré"
return GenerateCatalogResponseDTO(
name=name,
description=str(parsed.get("description") or "").strip(),
items=items,
)

View File

@@ -0,0 +1,107 @@
"""Use case : conseils d'adaptation d'un PDF à une campagne EXISTANTE.
L'IA connaît la campagne de l'utilisateur (un « brief » : structure arcs/chapitres/
scènes + PNJ + univers/lore), lit le contenu du PDF, et rédige des recommandations
d'INTÉGRATION/ADAPTATION (où insérer, reskins de PNJ, transposition à l'univers,
doublons à réconcilier…). Sortie en markdown, streamée token par token.
Contrairement à l'IMPORT (qui produit une arborescence à créer), ici on produit
du CONSEIL libre : rien n'est créé, l'utilisateur applique à la main.
"""
from __future__ import annotations
import logging
from typing import AsyncIterator
from app.application.prompts import adapt_campaign as prompts
from app.core.language import DEFAULT as _DEFAULT_LANG
from app.domain.models import ChatMessage
from app.domain.ports import LLMChatProvider, PdfExtractionError, PdfTextExtractor
logger = logging.getLogger(__name__)
# Plus créatif que l'import (tâche de structuration) : ici on conseille/adapte.
_TEMPERATURE = 0.7
class AdaptCampaignUseCase:
"""Génère (en streaming) des conseils d'adaptation d'un PDF à une campagne."""
def __init__(
self,
llm: LLMChatProvider,
extractor: PdfTextExtractor,
max_input_tokens: int = 10000,
) -> None:
self._llm = llm
self._extractor = extractor
# L'adaptation envoie le PDF en UNE requête (pas de découpage). On plafonne
# donc l'entrée pour ne pas dépasser la taille de requête acceptée par le
# provider (sinon HTTP 400). Calé sur la taille des morceaux d'import.
self._max_input_tokens = max_input_tokens
async def stream(
self,
pdf_bytes: bytes,
brief: str,
messages: list[ChatMessage],
language: str = _DEFAULT_LANG,
) -> AsyncIterator[str]:
"""Conversationnel : le PDF + la campagne sont le CONTEXTE (system prompt),
`messages` est l'échange (demande initiale, puis feedbacks de l'utilisateur)."""
doc = self._extractor.extract(pdf_bytes)
pdf_text = doc.full_text
if not pdf_text.strip():
raise PdfExtractionError("Aucun texte exploitable n'a été extrait du PDF.")
brief = brief or ""
pdf_text, truncated = self._fit_pdf_to_budget(pdf_text, brief)
logger.info(
"Adaptation campagne : %s page(s) (%s via OCR), brief %s car., PDF %s car.%s, %s message(s).",
doc.page_count, doc.ocr_page_count, len(brief), len(pdf_text),
" (tronqué)" if truncated else "", len(messages),
)
trunc_note = (
"\n[Note : PDF tronqué pour tenir dans une requête — base-toi sur ce début.]"
if truncated else ""
)
# Concaténation (pas .format) : brief/PDF peuvent contenir des { } littéraux.
system_prompt = (
f"{prompts.SYSTEM_PREFIX}\n\n"
"--- CAMPAGNE EXISTANTE DE L'UTILISATEUR ---\n"
f"{brief.strip() or '(campagne encore vide)'}\n\n"
"--- CONTENU DU PDF À ADAPTER ---\n"
f"{pdf_text}{trunc_note}\n\n"
f"{prompts.system_suffix(language)}\n\n"
"Tu es en CONVERSATION : à chaque message de l'utilisateur, ajuste, corrige "
"ou propose des alternatives en gardant tout ce contexte à l'esprit."
)
# 1er tour : si aucun message, on lance la demande initiale par défaut.
convo = messages or [ChatMessage(
role="user",
content="Propose-moi comment intégrer et adapter ce PDF à ma campagne.",
)]
async for token in self._llm.stream_chat(
convo, system_prompt=system_prompt, temperature=_TEMPERATURE
):
yield token
def _fit_pdf_to_budget(self, pdf_text: str, brief: str) -> tuple[str, bool]:
"""Tronque le texte du PDF pour que (brief + PDF) tienne dans le budget tokens.
Évite un HTTP 400 « requête trop grosse » côté provider. Réserve une marge
pour le prompt système et le brief.
"""
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
brief_tokens = len(enc.encode(brief))
budget = max(2000, self._max_input_tokens - brief_tokens - 1000) # 1000 = marge système
pdf_tokens = enc.encode(pdf_text)
if len(pdf_tokens) <= budget:
return pdf_text, False
return enc.decode(pdf_tokens[:budget]), True

View File

@@ -21,12 +21,18 @@ from app.domain.models import (
ChatMessage,
ChapterSummary,
CharacterSummary,
NpcSummary,
GameSystemContext,
JournalEntrySummary,
LoreStructuralContext,
NarrativeEntityContext,
PageContext,
PageSummary,
QuestSummary,
SessionContext,
)
from app.application.prompts import chat as prompts
from app.core.language import DEFAULT as _DEFAULT_LANG
from app.domain.ports import LLMChatProvider
@@ -36,21 +42,6 @@ from app.domain.ports import LLMChatProvider
_DEFAULT_TEMPERATURE = 0.7
_BASE_SYSTEM = """Tu es un assistant d'écriture pour un Maître de Jeu de JDR.
Tu dialogues avec le MJ pour l'aider à enrichir son univers et ses campagnes.
Règles de ton :
- Réponds en français, ton chaleureux et créatif.
- Sois concis : listes à puces courtes plutôt que longs paragraphes.
- Propose des idées qui s'intègrent dans le contexte existant ci-dessous.
Règles de cohérence (IMPORTANT) :
- Tu PEUX et DOIS inventer des éléments originaux (personnages, lieux, objets, intrigues, créatures, scènes) — c'est ton rôle d'assistant créatif.
- Tu ne peux PAS faire référence à un élément du MJ (du Lore, des arcs, chapitres ou scènes) comme s'il existait déjà, SAUF s'il apparaît EXACTEMENT (même orthographe) dans l'une des sections de contexte ci-dessous.
- Si l'utilisateur mentionne un nom que tu ne vois pas dans le contexte, ne fais surtout pas semblant de le connaître : dis clairement "Je ne vois pas [nom] dans le contexte actuel, veux-tu qu'on le crée ?" plutôt que d'inventer des détails à son sujet.
- Évite les précisions inventées qu'on ne peut pas vérifier : dates exactes, chiffres de population, hiérarchies politiques complexes, généalogies détaillées. Préfère des formulations ouvertes que le MJ validera ("il y a longtemps", "de nombreux", "la haute noblesse")."""
class ChatUseCase:
"""Orchestre un tour de conversation avec le LLM + contextes structurels."""
@@ -66,16 +57,19 @@ class ChatUseCase:
campaign_context: CampaignStructuralContext | None = None,
narrative_entity: NarrativeEntityContext | None = None,
game_system_context: GameSystemContext | None = None,
session_context: SessionContext | None = None,
language: str = _DEFAULT_LANG,
) -> AsyncIterator[str]:
"""Streame les tokens de la réponse assistant pour le dernier message user.
Les contextes sont tous optionnels, mais au moins l'un des deux
"niveaux haut" (lore_context ou campaign_context) doit être fourni
pour que le prompt ait du sens. Le controller (main.py) applique
cette règle à la frontière HTTP.
cette règle à la frontière HTTP. `language` pilote la langue de réponse.
"""
system_prompt = self._build_system_prompt(
lore_context, page_context, campaign_context, narrative_entity, game_system_context
lore_context, page_context, campaign_context, narrative_entity,
game_system_context, session_context, language,
)
async for token in self._llm.stream_chat(
messages,
@@ -91,12 +85,15 @@ class ChatUseCase:
campaign_context: CampaignStructuralContext | None = None,
narrative_entity: NarrativeEntityContext | None = None,
game_system_context: GameSystemContext | None = None,
session_context: SessionContext | None = None,
language: str = _DEFAULT_LANG,
) -> str:
"""Version publique — utilisée par le controller HTTP pour compter
les tokens du system prompt avant de streamer (jauge de contexte).
"""
return self._build_system_prompt(
lore_context, page_context, campaign_context, narrative_entity, game_system_context
lore_context, page_context, campaign_context, narrative_entity,
game_system_context, session_context, language,
)
# --- Construction du system prompt --------------------------------------
@@ -108,8 +105,10 @@ class ChatUseCase:
campaign: CampaignStructuralContext | None,
narrative: NarrativeEntityContext | None,
game_system: GameSystemContext | None = None,
session: SessionContext | None = None,
language: str = _DEFAULT_LANG,
) -> str:
sections = [_BASE_SYSTEM]
sections = [prompts.base_system(language)]
if lore is not None:
sections.append(self._format_lore(lore))
if campaign is not None:
@@ -120,6 +119,8 @@ class ChatUseCase:
sections.append(self._format_page(page))
if narrative is not None:
sections.append(self._format_narrative_entity(narrative))
if session is not None:
sections.append(self._format_session(session))
return "\n\n".join(sections)
# --- Blocs Lore ---------------------------------------------------------
@@ -198,10 +199,12 @@ class ChatUseCase:
else "\n(Cette campagne n'est associée à aucun univers — tu peux proposer des éléments d'ambiance libres.)"
)
characters_block = ChatUseCase._format_characters(ctx.characters)
npcs_block = ChatUseCase._format_npcs(ctx.npcs)
return (
"--- CAMPAGNE COURANTE ---\n"
f"Nom : {ctx.campaign_name}{desc}{lore_note}\n"
f"{characters_block}\n"
f"{characters_block}"
f"{npcs_block}\n"
"Structure narrative (les flèches → indiquent des transitions de scène "
"déclenchées par un choix des joueurs) :\n"
f"{arcs_block}"
@@ -231,6 +234,33 @@ class ChatUseCase:
)
return "\n".join(lines) + "\n"
@staticmethod
def _format_npcs(npcs: list[NpcSummary]) -> str:
"""Bloc PNJ — symétrique aux PJ avec sa propre instruction anti-halluci.
Distinction importante : pour les PNJ, l'IA est ENCOURAGÉE à proposer de
nouveaux PNJ (création créative = OK). En revanche, elle ne doit pas
référencer comme existant un PNJ qui n'est pas dans la liste ci-dessous.
"""
if not npcs:
return (
"\nPersonnages non-joueurs (PNJ) : aucun défini pour l'instant. "
"Tu peux librement proposer de nouveaux PNJ au MJ, mais ne "
"fais pas comme s'ils existaient déjà dans la campagne.\n"
)
lines = ["\nPersonnages non-joueurs (PNJ) connus :"]
for n in npcs:
if n.snippet:
lines.append(f"- **{n.name}** — {n.snippet}")
else:
lines.append(f"- **{n.name}** (fiche vide)")
lines.append(
"Pour une fiche complète d'un PNJ existant (apparence, motivations), "
"n'invente rien : demande au MJ d'ouvrir l'éditeur du PNJ. Tu peux "
"en revanche proposer librement de NOUVEAUX PNJ."
)
return "\n".join(lines) + "\n"
@staticmethod
def _format_arcs(arcs: list[ArcSummary]) -> str:
if not arcs:
@@ -258,7 +288,8 @@ class ChatUseCase:
else:
for scene in chapter.scenes:
sc_hint = ChatUseCase._illustration_hint(scene.illustration_count)
block.append(f" - {scene.name} (scène){sc_hint}")
scene_kind = " (lieu explorable)" if scene.rooms else " (scène)"
block.append(f" - {scene.name}{scene_kind}{sc_hint}")
if scene.description:
block.append(f" Description : {scene.description}")
for br in scene.branches:
@@ -266,6 +297,19 @@ class ChatUseCase:
block.append(
f'"{br.label}" vers {br.target_scene_name}{cond}'
)
# Pièces du lieu explorable (mode donjon)
for room in scene.rooms:
floor = f" [étage {room.floor}]" if room.floor is not None else ""
block.append(f"{room.name}{floor}")
if room.description:
block.append(f" {room.description}")
if room.enemies:
block.append(f" Ennemis : {room.enemies}")
for rb in room.branches:
cond = f" (si : {rb.condition})" if rb.condition else ""
block.append(
f'"{rb.label}" vers {rb.target_room_name}{cond}'
)
return block
@staticmethod
@@ -312,6 +356,141 @@ class ChatUseCase:
f"{sections_block}"
)
# --- Bloc Session de jeu (Play Context) ---------------------------------
_ENTRY_TYPE_LABELS = {
"NOTE": "Note du MJ",
"EVENT": "Évènement",
"DICE_ROLL": "Jet de dés",
"PLAYER_ACTION": "Action joueur",
}
@staticmethod
def _format_session(sc: SessionContext) -> str:
"""Bloc journal de la session en cours + résumé des sessions précédentes.
Fournit à l'IA le contexte temporel : ce qui s'est passé jusqu'ici,
dans l'ordre chronologique. Permet de référencer un PNJ rencontré,
rappeler un évènement antérieur, ou rebondir sur une action joueur.
Pour les sessions PRÉCÉDENTES de la même campagne, on ne remonte que
les EVENTs (les moments marquants) pour préserver le contexte LLM.
"""
status = "EN COURS" if sc.active else "TERMINÉE"
started = f" — démarrée {sc.started_at}" if sc.started_at else ""
previous_block = ChatUseCase._format_previous_events(sc.previous_events)
hub_block = ChatUseCase._format_hub_status(sc)
if not sc.entries:
current_block = "(Aucune entrée dans le journal pour l'instant — la session vient de commencer.)"
else:
lines: list[str] = []
for e in sc.entries:
label = ChatUseCase._ENTRY_TYPE_LABELS.get(e.type, e.type)
ts = f" [{e.occurred_at}]" if e.occurred_at else ""
content = e.content.replace("\n", "\n ")
lines.append(f"- {label}{ts} : {content}")
current_block = "\n".join(lines)
return (
"--- SESSION DE JEU EN COURS ---\n"
f"Nom : {sc.session_name}\n"
f"Statut : {status}{started}\n"
f"{hub_block}"
f"{previous_block}"
"\nJournal chronologique de la session courante (du plus ancien au plus récent) :\n"
f"{current_block}\n\n"
"IMPORTANT : tu es l'assistant du MJ PENDANT la partie. Tes réponses doivent :\n"
"- Tenir compte des évènements déjà capturés (sessions précédentes + journal courant).\n"
"- Être concrètes et utiles en temps réel : descriptions sensorielles, "
"réactions de PNJ cohérentes avec leur fiche, suggestions de complications "
"qui s'enchaînent à ce qui vient de se passer.\n"
"- Éviter les longs développements : le MJ est en train d'animer une partie, "
"il a besoin d'idées immédiatement actionnables."
)
@staticmethod
def _format_hub_status(sc: SessionContext) -> str:
"""Bloc Hub : quêtes ouvertes + flags actifs.
Vide si la campagne n'a aucun Arc HUB (toutes les listes vides côté Core).
Les quêtes LOCKED apparaissent par leur TITRE uniquement : l'IA sait
qu'elles existent (utile pour les teasers, les rumeurs en jeu) mais ne
peut pas spoiler leurs détails.
"""
if (not sc.available_quests
and not sc.in_progress_quests
and not sc.locked_quest_titles
and not sc.active_flags):
return ""
lines = ["", "État du Hub (quêtes parallèles et faits narratifs) :"]
if sc.in_progress_quests:
lines.append(" Quêtes en cours :")
lines.extend(ChatUseCase._format_quest_lines(sc.in_progress_quests))
if sc.available_quests:
lines.append(" Quêtes disponibles (non démarrées, prêtes à être lancées) :")
lines.extend(ChatUseCase._format_quest_lines(sc.available_quests))
if sc.locked_quest_titles:
titles = ", ".join(f'"{t}"' for t in sc.locked_quest_titles)
lines.append(
" Quêtes encore verrouillées (existent mais non accessibles — "
f"tu peux y faire allusion sous forme de rumeurs sans spoiler leur contenu) : {titles}"
)
if sc.active_flags:
lines.append(
" Faits actifs : " + ", ".join(f"`{f}`" for f in sc.active_flags)
)
lines.append(
" Conseille des actions cohérentes avec ces quêtes ouvertes. Ne fais "
"PAS comme si une quête verrouillée était déjà accessible aux PJ."
)
lines.append("") # séparateur visuel avant le récap des sessions précédentes
return "\n".join(lines)
@staticmethod
def _format_quest_lines(quests: list[QuestSummary]) -> list[str]:
"""Sérialise une liste de QuestSummary en lignes indentées."""
out: list[str] = []
for q in quests:
arc = f" [arc : {q.arc_name}]" if q.arc_name else ""
out.append(f" - {q.name}{arc}")
if q.description:
out.append(f" Synopsis : {q.description}")
return out
@staticmethod
def _format_previous_events(events: list[JournalEntrySummary]) -> str:
"""Bloc "Story so far" : EVENTs marquants des sessions antérieures.
Vide si la campagne en est à sa première session. On groupe par
session source pour aider l'IA à situer chaque évènement temporellement.
"""
if not events:
return ""
# Groupement par session source en préservant l'ordre d'apparition.
grouped: dict[str, list[JournalEntrySummary]] = {}
for e in events:
key = e.source_session_name or "(session inconnue)"
grouped.setdefault(key, []).append(e)
lines = ["\nRécapitulatif des sessions précédentes (évènements marquants uniquement) :"]
for session_name, items in grouped.items():
lines.append(f"{session_name} :")
for e in items:
ts = f" [{e.occurred_at}]" if e.occurred_at else ""
content = e.content.replace("\n", "\n ")
lines.append(f" - {content}{ts}")
lines.append("") # ligne vide avant le bloc journal courant
return "\n".join(lines) + "\n"
@staticmethod
def _format_narrative_entity(ne: NarrativeEntityContext) -> str:
"""Bloc équivalent à _format_page mais pour Arc/Chapter/Scene."""
@@ -319,7 +498,8 @@ class ChatUseCase:
"arc": "ARC",
"chapter": "CHAPITRE",
"scene": "SCÈNE",
"character": "FICHE DE PERSONNAGE",
"character": "FICHE DE PERSONNAGE (PJ)",
"npc": "FICHE DE PNJ",
}.get(ne.entity_type.lower(), ne.entity_type.upper())
if ne.fields:
fields_block = "\n".join(

View File

@@ -0,0 +1,123 @@
"""Découpage d'un long texte en morceaux qui tiennent dans la fenêtre LLM.
Partagé par les imports (règles, campagne) : un livre dépasse la fenêtre de
contexte, on le découpe par paragraphes jusqu'à une cible de tokens, en coupant
les paragraphes géants si besoin. Dimensionnement via tiktoken (cl100k_base),
approximation suffisante (±10% vs tokenizer natif).
"""
from __future__ import annotations
# Cible conservatrice : tient dans une fenêtre Ollama (num_ctx 16384) en laissant
# la place au prompt + à la sortie JSON. Les providers à grand contexte (1min.ai)
# le supportent largement.
CHUNK_TARGET_TOKENS = 6000
def chunk_text(
full_text: str,
target_tokens: int = CHUNK_TARGET_TOKENS,
overlap_tokens: int = 0,
) -> list[str]:
"""Découpe `full_text` en morceaux ~`target_tokens` tokens (frontières de §).
`overlap_tokens` > 0 : chaque morceau reprend la fin du précédent (les derniers
paragraphes, jusqu'à ~`overlap_tokens` tokens). Utile pour le RAG : une phrase-clé
à cheval sur deux morceaux reste retrouvable dans au moins l'un des deux. À
laisser à 0 pour les imports (recopie) : un overlap y DUPLIQUERAIT du texte.
Un morceau peut légèrement dépasser la cible (jusqu'à target + overlap).
"""
if not full_text.strip():
return []
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
paragraphs = [p for p in full_text.split("\n\n") if p.strip()]
chunks: list[str] = []
current: list[str] = []
current_tokens = 0
fresh = False # `current` contient-il du contenu pas encore émis ? (évite de
# ré-émettre un morceau composé uniquement de l'overlap en fin de texte)
for para in paragraphs:
para_tokens = len(enc.encode(para))
# Un paragraphe seul plus gros que la cible : on le coupe en sous-blocs.
if para_tokens > target_tokens:
if current and fresh:
chunks.append("\n\n".join(current))
current, current_tokens, fresh = [], 0, False
chunks.extend(_split_oversized(para, enc, target_tokens, overlap_tokens))
continue
if current_tokens + para_tokens > target_tokens and current:
if fresh:
chunks.append("\n\n".join(current))
current, current_tokens = _overlap_tail(current, enc, overlap_tokens)
fresh = False
current.append(para)
current_tokens += para_tokens
fresh = True
if current and fresh:
chunks.append("\n\n".join(current))
return chunks
def _overlap_tail(parts: list[str], enc, overlap_tokens: int) -> tuple[list[str], int]:
"""Derniers paragraphes de `parts` totalisant au plus `overlap_tokens` tokens —
le « rappel » recopié en tête du morceau suivant."""
if overlap_tokens <= 0 or not parts:
return [], 0
tail: list[str] = []
total = 0
for para in reversed(parts):
para_tokens = len(enc.encode(para))
if total + para_tokens > overlap_tokens:
break
tail.insert(0, para)
total += para_tokens
if not tail:
# Aucun paragraphe entier ne tient dans le budget (paragraphes longs) :
# on reprend la FIN du dernier paragraphe pour garantir le recouvrement.
tokens = enc.encode(parts[-1])
tail = [enc.decode(tokens[-overlap_tokens:])]
total = min(overlap_tokens, len(tokens))
return tail, total
def _split_oversized(paragraph: str, enc, target_tokens: int, overlap_tokens: int = 0) -> list[str]:
"""Coupe un paragraphe géant en sous-blocs ~`target_tokens` tokens (fenêtre
glissante avec recouvrement si `overlap_tokens` > 0)."""
tokens = enc.encode(paragraph)
step = max(1, target_tokens - overlap_tokens)
out: list[str] = []
i = 0
while i < len(tokens):
out.append(enc.decode(tokens[i : i + target_tokens]))
if i + target_tokens >= len(tokens):
break
i += step
return out
def split_in_half(text: str) -> tuple[str, str]:
"""Coupe `text` en deux moitiés ~égales, de préférence sur un saut de ligne
proche du milieu (pour ne pas trancher en plein mot/phrase).
Sert au repli anti-troncature des imports : quand la SORTIE d'un morceau est
coupée (le modèle ne peut pas tout réécrire en une réponse), on retraite ce
morceau en deux moitiés. Renvoie ('', '') si le texte est trop court pour
être découpé utilement (garde-fou anti-récursion infinie).
"""
text = text.strip()
if len(text) < 400:
return "", ""
mid = len(text) // 2
# Cherche un saut de ligne juste avant le milieu, sinon juste après.
cut = text.rfind("\n", 0, mid)
if cut < len(text) // 4:
nxt = text.find("\n", mid)
cut = nxt if nxt != -1 else mid
left, right = text[:cut].strip(), text[cut:].strip()
if not left or not right:
return "", ""
return left, right

View File

@@ -0,0 +1,26 @@
"""Port d'embeddings (RAG des notebooks).
Abstraction du calcul de vecteurs : un texte → une liste de floats. Les adapters
concrets (Ollama local, Mistral cloud) la satisfont par duck typing, comme pour
les LLMProvider. Le RAG n'en dépend que via cette interface.
"""
from __future__ import annotations
from typing import Protocol
class EmbeddingError(Exception):
"""Échec du calcul d'embeddings (modèle indisponible, réseau, quota…)."""
class EmbeddingProvider(Protocol):
"""Calcule les vecteurs d'une liste de textes (ordre préservé).
`kind` distingue les DOCUMENTS indexés ("document") de la QUESTION posée
("query") : certains modèles (nomic-embed-text) sont entraînés avec des
préfixes de tâche distincts et perdent en pertinence sans eux. Les adapters
qui n'en ont pas besoin (mistral-embed) ignorent simplement le paramètre.
"""
async def embed(self, texts: list[str], kind: str = "document") -> list[list[float]]:
...

View File

@@ -8,9 +8,13 @@ permet de tester ce use case avec un FakeLLMProvider, sans Ollama qui tourne.
"""
import json
from app.application.prompts import generate_page as prompts
from app.domain.models import PageGenerationContext, PageGenerationResult
from app.domain.ports import LLMProvider, LLMProviderError
# Langue de repli quand le router n'en fournit pas (appel direct / vieux client).
from app.core.language import DEFAULT as _DEFAULT_LANG
# Température basse : remplissage de champs = tâche factuelle, peu créative.
# Une valeur trop haute (par défaut Ollama = 0.8) encourage l'IA à broder
@@ -18,21 +22,6 @@ from app.domain.ports import LLMProvider, LLMProviderError
_DEFAULT_TEMPERATURE = 0.4
_SYSTEM_INSTRUCTIONS = """Tu es un assistant d'écriture pour un Maître de Jeu de JDR.
Tu vas générer le contenu d'une page appartenant à un univers fictionnel.
Règles impératives de ta réponse :
- Tu réponds UNIQUEMENT par un objet JSON valide.
- Les clés du JSON correspondent EXACTEMENT aux noms de champs demandés.
- Les valeurs sont des chaînes de texte en français, riches et évocatrices.
- Aucun markdown, aucune explication, aucun commentaire autour du JSON.
Règles de cohérence (IMPORTANT) :
- Tu PEUX inventer des détails originaux pour CETTE page : apparence, traits de caractère, anecdotes, histoire personnelle.
- Tu ne dois PAS faire référence à d'autres personnages, lieux, organisations ou événements comme s'ils existaient déjà dans l'univers, sauf si le contexte ci-dessous les mentionne explicitement.
- Si un champ appelle une précision externe (date, nom d'un roi, ville voisine, guerre passée), reste volontairement vague : "il y a de nombreuses années", "un bourg voisin", "une époque troublée". Le MJ préfère combler lui-même les blancs plutôt que trouver des faits inventés contradictoires avec son univers."""
class GeneratePageUseCase:
"""Orchestre la génération d'une page LoreMind via un LLM."""
@@ -42,8 +31,9 @@ class GeneratePageUseCase:
async def execute(
self,
context: PageGenerationContext,
language: str = _DEFAULT_LANG,
) -> PageGenerationResult:
prompt = self._build_prompt(context)
prompt = self._build_prompt(context, language)
raw = await self._llm.generate(
prompt,
output_format="json",
@@ -53,7 +43,7 @@ class GeneratePageUseCase:
return PageGenerationResult(values=values)
@staticmethod
def _build_prompt(context: PageGenerationContext) -> str:
def _build_prompt(context: PageGenerationContext, language: str = _DEFAULT_LANG) -> str:
fields_block = "\n".join(f'- "{field}"' for field in context.template_fields)
lore_desc_line = (
f"\nDescription de l'univers : {context.lore_description}"
@@ -62,7 +52,7 @@ class GeneratePageUseCase:
)
return (
f"{_SYSTEM_INSTRUCTIONS}\n\n"
f"{prompts.system_instructions(language)}\n\n"
f"Univers : {context.lore_name}"
f"{lore_desc_line}\n"
f"Catégorie (dossier) : {context.folder_name}\n"

View File

@@ -0,0 +1,676 @@
"""Use case : import d'un PDF de campagne → arbre arc → chapitre → scène.
Couche APPLICATION. Même chaîne que l'import de règles (extraction + OCR +
chunking + map-reduce) mais la cible est une ARBORESCENCE narrative :
- MAP : chaque morceau → un sous-arbre {arcs:[{chapters:[{scenes}]}]}
- REDUCE : fusion par NOM à chaque niveau (un chapitre coupé entre 2 morceaux
est recollé ; ses scènes s'accumulent).
PROPOSITION non persistée : le Core crée les entités seulement après revue.
"""
from __future__ import annotations
import asyncio
import logging
from app.application.chunking import chunk_text, split_in_half
from app.application.import_status import (
notify_status,
reset_status_queue,
set_status_queue,
)
from app.application.llm_json import load_json_object, looks_like_truncated_json
from app.application.llm_retry import generate_with_retry
from app.application.prompts import import_campaign as prompts
from app.application.streaming import with_heartbeat
# Repli anti-troncature : si la sortie d'un morceau est coupée, on le retraite en
# 2 moitiés. Borné en profondeur (3 niveaux => jusqu'à 8 sous-blocs).
_MAX_SPLIT_DEPTH = 3
from app.domain.models import (
ArcProposal,
CampaignImportResult,
ChapterProposal,
NpcImportProposal,
RoomProposal,
SceneProposal,
)
from app.domain.ports import (
LLMGenerationTimeout,
LLMProvider,
LLMProviderError,
PdfTextExtractor,
)
logger = logging.getLogger(__name__)
# Très basse : structuration = recopie/réorganisation fidèle, pas de créativité.
# Plus la valeur est haute, plus le modèle "brode" (invente du contenu absent).
_TEMPERATURE = 0.1
# Morceaux PLUS GROS que pour les règles : l'IA voit une quête/un chapitre entier
# d'un coup et le structure de façon cohérente (1 scène par lieu) au lieu de le
# fragmenter en dizaines de scènes. Adapté aux providers à grand contexte (1min.ai).
_CHUNK_TARGET_TOKENS = 10000
# Schéma de l'arbre attendu, passé aux providers à sorties structurées (Ollama
# contraint la grammaire : un modèle local ne PEUT plus produire de clés
# inventées, d'objets bavards type "thought" ni de texte hors JSON). Les
# adapters cloud le traduisent en mode JSON natif. Seuls les "name" sont
# requis : le _TreeMerger tolère déjà tous les champs absents.
_TREE_SCHEMA: dict = {
"type": "object",
"properties": {
"arcs": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
"type": {"type": "string", "enum": ["LINEAR", "HUB"]},
"chapters": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
"scenes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
"player_narration": {"type": "string"},
"gm_notes": {"type": "string"},
"rooms": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
"enemies": {"type": "string"},
"loot": {"type": "string"},
},
"required": ["name"],
"additionalProperties": False,
},
},
},
"required": ["name"],
"additionalProperties": False,
},
},
},
"required": ["name"],
"additionalProperties": False,
},
},
},
"required": ["name"],
"additionalProperties": False,
},
},
"npcs": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
},
"required": ["name"],
"additionalProperties": False,
},
},
},
"required": ["arcs"],
"additionalProperties": False,
}
# Garde-fou prompt : une TOC de gros livre peut compter des centaines d'entrées
# (sous-sous-sections). On la limite aux niveaux hauts et à un nombre raisonnable.
_TOC_MAX_LEVEL = 2
_TOC_MAX_ENTRIES = 80
def _format_toc(toc) -> str:
"""Formate la TOC du PDF en liste indentée, bornée (niveaux hauts d'abord)."""
entries = [e for e in toc if e.level <= _TOC_MAX_LEVEL][:_TOC_MAX_ENTRIES]
if not entries:
return ""
return "\n".join(f"{' ' * (e.level - 1)}- {e.title} (p. {e.page})" for e in entries)
class _TreeMerger:
"""Fusionne les sous-arbres des morceaux en un seul arbre, ordre préservé.
Clés insensibles à la casse à chaque niveau (nom d'arc / chapitre / scène).
Description : la première non-vide rencontrée l'emporte (les morceaux suivants
ne l'écrasent pas).
"""
def __init__(self) -> None:
# arc_key -> {"name", "description", "chapters": {chap_key -> {...}}}
self._arcs: dict[str, dict] = {}
# npc_key (nom en minuscules) -> {"name", "description"}
self._npcs: dict[str, dict] = {}
def add(self, arcs_json: list[dict]) -> None:
for arc in arcs_json or []:
name = str(arc.get("name", "")).strip()
if not name:
continue
a = self._arcs.setdefault(
name.lower(), {"name": name, "description": "", "type": "LINEAR", "chapters": {}})
self._fill_desc(a, arc)
# Type d'arc : HUB l'emporte si un seul morceau le signale (propriété globale
# souvent énoncée une fois, dans l'intro du livre).
if str(arc.get("type", "")).strip().upper() == "HUB":
a["type"] = "HUB"
for chap in arc.get("chapters", []) or []:
cname = str(chap.get("name", "")).strip()
if not cname:
continue
c = a["chapters"].setdefault(cname.lower(), {"name": cname, "description": "", "scenes": {}})
self._fill_desc(c, chap)
for sc in chap.get("scenes", []) or []:
sname = str(sc.get("name", "")).strip()
if not sname:
continue
s = c["scenes"].setdefault(
sname.lower(),
{"name": sname, "description": "", "player_narration": "",
"gm_notes": "", "rooms": {}})
self._fill_desc(s, sc)
# Narration/notes : CONCATÉNATION (pas premier-gagne) — une
# scène coupée entre deux morceaux apporte la suite de son
# contenu dans le morceau suivant ; la jeter perdrait la
# moitié du donjon. Le doublon exact (overlap) est filtré.
self._append_field(s, sc, "player_narration")
self._append_field(s, sc, "gm_notes")
for rm in sc.get("rooms", []) or []:
rname = str(rm.get("name", "")).strip()
if not rname:
continue
r = s["rooms"].setdefault(
rname.lower(),
{"name": rname, "description": "", "enemies": "", "loot": ""})
self._fill_desc(r, rm)
self._fill_field(r, rm, "enemies")
self._fill_field(r, rm, "loot")
def add_npcs(self, npcs_json: list[dict]) -> None:
"""Accumule les PNJ détectés. Un PNJ revu dans un autre morceau garde la
description la plus COMPLÈTE (la plus longue) — un PNJ récurrent est
souvent décrit en détail une seule fois."""
for npc in npcs_json or []:
name = str(npc.get("name", "")).strip()
if not name:
continue
desc = str(npc.get("description") or "").strip()
entry = self._npcs.setdefault(name.lower(), {"name": name, "description": ""})
if len(desc) > len(entry["description"]):
entry["description"] = desc
def npcs(self) -> list[NpcImportProposal]:
return [NpcImportProposal(n["name"], n["description"]) for n in self._npcs.values()]
@staticmethod
def _fill_desc(node: dict, src: dict) -> None:
if not node["description"]:
node["description"] = str(src.get("description") or "").strip()
@staticmethod
def _fill_field(node: dict, src: dict, field_name: str) -> None:
if not node[field_name]:
node[field_name] = str(src.get(field_name) or "").strip()
@staticmethod
def _append_field(node: dict, src: dict, field_name: str) -> None:
"""Accumule la valeur de `src` à la suite de l'existante (scène coupée
entre deux morceaux). Ignore le vide et le contenu déjà présent (un
morceau redondant — relecture d'overlap — ne duplique rien)."""
new = str(src.get(field_name) or "").strip()
if not new:
return
current = node[field_name]
if not current:
node[field_name] = new
elif new not in current and current not in new:
node[field_name] = current + "\n\n" + new
elif current in new:
# Le nouveau contenu ENGLOBE l'ancien (version plus complète) → on le prend.
node[field_name] = new
def result(self) -> list[ArcProposal]:
arcs: list[ArcProposal] = []
for a in self._arcs.values():
chapters: list[ChapterProposal] = []
for c in a["chapters"].values():
scenes: list[SceneProposal] = []
for s in c["scenes"].values():
rooms = [
RoomProposal(r["name"], r["description"], r["enemies"], r["loot"])
for r in s["rooms"].values()
]
scenes.append(SceneProposal(
s["name"], s["description"], s["player_narration"], s["gm_notes"], rooms))
chapters.append(ChapterProposal(c["name"], c["description"], scenes))
arcs.append(ArcProposal(a["name"], a["description"], a["type"], chapters))
return arcs
def counts(self) -> tuple[int, int, int]:
arcs = len(self._arcs)
chapters = sum(len(a["chapters"]) for a in self._arcs.values())
scenes = sum(len(c["scenes"]) for a in self._arcs.values() for c in a["chapters"].values())
return arcs, chapters, scenes
# --- Consolidation : fusion des quasi-doublons détectés par le LLM ---------
def skeleton_text(self) -> str:
"""Squelette de l'arbre (noms seuls) — entrée compacte de la consolidation."""
lines: list[str] = []
for a in self._arcs.values():
lines.append(f"ARC: {a['name']}")
for c in a["chapters"].values():
lines.append(f" CHAPITRE: {c['name']}")
for s in c["scenes"].values():
lines.append(f" SCENE: {s['name']}")
return "\n".join(lines)
def merge_chapters(self, into_name: str, merge_names: list[str]) -> bool:
"""Fusionne les chapitres `merge_names` dans `into_name` (tous arcs).
Best-effort : les noms inconnus sont ignorés. Renvoie True si modifié.
"""
target = self._find_chapter(into_name)
if target is None:
return False
changed = False
for mname in merge_names:
key = str(mname).strip().lower()
if not key or key == str(into_name).strip().lower():
continue
for a in self._arcs.values():
src = a["chapters"].pop(key, None)
if src is None or src is target:
continue
self._fill_desc(target, src)
for skey, sdict in src["scenes"].items():
if skey in target["scenes"]:
self._merge_scene_into(target["scenes"][skey], sdict)
else:
target["scenes"][skey] = sdict
changed = True
return changed
def merge_scenes(self, chapter_name: str, into_name: str, merge_names: list[str]) -> bool:
"""Fusionne les scènes `merge_names` dans `into_name` au sein du chapitre."""
chapter = self._find_chapter(chapter_name)
if chapter is None:
return False
target = chapter["scenes"].get(str(into_name).strip().lower())
if target is None:
return False
changed = False
for mname in merge_names:
key = str(mname).strip().lower()
if not key or key == str(into_name).strip().lower():
continue
src = chapter["scenes"].pop(key, None)
if src is None or src is target:
continue
self._merge_scene_into(target, src)
changed = True
return changed
def _find_chapter(self, name: str) -> dict | None:
key = str(name).strip().lower()
for a in self._arcs.values():
if key in a["chapters"]:
return a["chapters"][key]
return None
def _merge_scene_into(self, target: dict, src: dict) -> None:
self._fill_desc(target, src)
self._append_field(target, src, "player_narration")
self._append_field(target, src, "gm_notes")
for rkey, rdict in src.get("rooms", {}).items():
target["rooms"].setdefault(rkey, rdict)
class ImportCampaignUseCase:
"""Transforme un PDF de campagne en proposition d'arbre arc→chapitre→scène."""
def __init__(
self,
llm: LLMProvider,
extractor: PdfTextExtractor,
chunk_target_tokens: int = _CHUNK_TARGET_TOKENS,
map_concurrency: int = 1,
) -> None:
self._llm = llm
self._extractor = extractor
self._chunk_target_tokens = chunk_target_tokens
# Appels MAP par VAGUES de cette taille : l'ordre narratif est préservé
# (fusion vague par vague, dans l'ordre du livre) mais le mur d'attente
# des appels LLM est divisé d'autant. 1 = comportement séquentiel.
self._map_concurrency = max(1, map_concurrency)
async def execute(self, pdf_bytes: bytes) -> CampaignImportResult:
"""Variante non-streamée : traite tout puis renvoie l'arbre complet."""
doc = self._extractor.extract(pdf_bytes)
chunks = chunk_text(doc.full_text, self._chunk_target_tokens)
toc_block = _format_toc(doc.toc)
merger = _TreeMerger()
total = len(chunks)
for start in range(0, total, self._map_concurrency):
wave = list(enumerate(chunks))[start:start + self._map_concurrency]
results = await asyncio.gather(*(
self._map_chunk(c, index=i, total=total, toc_block=toc_block)
for i, c in wave
))
for res in results:
merger.add(res["arcs"])
merger.add_npcs(res["npcs"])
if total > 1:
await self._consolidate(merger)
return CampaignImportResult(
arcs=merger.result(),
page_count=doc.page_count,
ocr_page_count=doc.ocr_page_count,
npcs=merger.npcs(),
)
async def stream(self, pdf_bytes: bytes):
"""Variante streamée : yield des évènements d'avancement.
{"type":"extracting"}, puis {"type":"start", page_count, ocr_page_count,
total}, puis un {"type":"progress", current, total, arc_count,
chapter_count, scene_count} par morceau, et enfin
{"type":"done", arcs:[...], page_count, ocr_page_count}.
"""
yield {"type": "extracting"}
doc = self._extractor.extract(pdf_bytes)
chunks = chunk_text(doc.full_text, self._chunk_target_tokens)
toc_block = _format_toc(doc.toc)
total = len(chunks)
logger.info(
"Import campagne (stream) : %s page(s) (%s via OCR), %s morceau(x), TOC %s.",
doc.page_count, doc.ocr_page_count, total,
"présente" if toc_block else "absente",
)
yield {
"type": "start",
"page_count": doc.page_count,
"ocr_page_count": doc.ocr_page_count,
"total": total,
}
merger = _TreeMerger()
skipped = 0
last_error: str | None = None
done_count = 0
# Canal de statut : les couches profondes (retry LLM, re-découpage) y
# publient des messages destinés à l'UI — cf. import_status.notify_status.
status_queue: asyncio.Queue = asyncio.Queue()
status_token = set_status_queue(status_queue)
try:
# PARALLÉLISME : les morceaux sont traités par VAGUES de `map_concurrency`
# appels simultanés. L'ordre narratif est préservé : la fusion se fait
# vague par vague, dans l'ordre du livre.
# RÉSILIENCE : un morceau qui échoue (provider saturé, quota, etc.) est
# SAUTÉ — on ne perd pas tout l'import pour autant. On n'abandonne que
# si AUCUN morceau ne passe (cf. après la boucle).
# HEARTBEAT : keep-alive pendant la vague d'appels LLM pour ne jamais
# laisser le flux SSE silencieux (sinon le Core coupe sur inactivité).
for start in range(0, total, self._map_concurrency):
wave = list(enumerate(chunks))[start:start + self._map_concurrency]
gathered = asyncio.gather(
*(self._map_chunk(c, index=i, total=total, toc_block=toc_block)
for i, c in wave),
return_exceptions=True,
)
results: list | None = None
async for kind, payload in with_heartbeat(gathered, status_queue=status_queue):
if kind == "heartbeat":
yield {"type": "heartbeat", "current": done_count + 1, "total": total}
elif kind == "status":
yield {"type": "status", "message": payload,
"current": done_count + 1, "total": total}
else:
results = payload
for (i, _), res in zip(wave, results or []):
done_count += 1
if isinstance(res, LLMProviderError):
skipped += 1
last_error = str(res)
logger.warning("Morceau %s/%s ignoré (échec LLM) : %s", i + 1, total, res)
yield {"type": "chunk_failed", "current": i + 1, "total": total,
"message": str(res)[:300]}
elif isinstance(res, BaseException):
raise res # bug inattendu : ne pas l'avaler en silence
else:
merger.add((res or {}).get("arcs") or [])
merger.add_npcs((res or {}).get("npcs") or [])
arcs, chapters, scenes = merger.counts()
yield {
"type": "progress",
"current": done_count,
"total": total,
"arc_count": arcs,
"chapter_count": chapters,
"scene_count": scenes,
"npc_count": len(merger.npcs()),
"skipped": skipped,
}
if total > 0 and skipped == total:
# Tout a échoué : "done" vide serait trompeur → erreur explicite.
yield {"type": "error",
"message": "Tous les morceaux ont échoué auprès du fournisseur IA. "
f"Dernier message : {last_error or 'inconnu'}"}
return
if total > 0 and merger.counts()[0] == 0 and not merger.npcs():
# Le texte a été extrait mais le modèle n'a produit AUCUNE structure
# exploitable : sans ce signal, l'UI reçoit un `done` vide et
# l'utilisateur conclut à tort que le PDF est illisible.
yield {"type": "error",
"message": "Le texte du PDF a été extrait, mais le modèle n'a produit "
"aucune structure exploitable (réponses JSON vides ou coupées). "
"Réduisez la taille des morceaux d'import, augmentez la fenêtre "
"de contexte (num_ctx) ou essayez un autre modèle."}
return
# Consolidation finale : fusion des quasi-doublons inter-morceaux
# (best-effort, voir _consolidate). Inutile sur un import mono-morceau.
if total > 1:
yield {"type": "consolidating", "total": total}
async for kind, payload in with_heartbeat(
self._consolidate(merger), status_queue=status_queue
):
if kind == "heartbeat":
yield {"type": "heartbeat", "current": total, "total": total}
elif kind == "status":
yield {"type": "status", "message": payload,
"current": total, "total": total}
yield {
"type": "done",
"arcs": _serialize_arcs(merger.result()),
"npcs": [{"name": n.name, "description": n.description} for n in merger.npcs()],
"page_count": doc.page_count,
"ocr_page_count": doc.ocr_page_count,
"skipped": skipped,
}
finally:
reset_status_queue(status_token)
# --- Consolidation finale (fusion des quasi-doublons) ---------------------
async def _consolidate(self, merger: _TreeMerger) -> None:
"""Une passe LLM sur le squelette pour fusionner les quasi-doublons.
BEST-EFFORT : toute erreur (LLM indisponible, JSON invalide, noms
inconnus) laisse l'arbre tel quel — la consolidation ne peut qu'améliorer,
jamais bloquer un import.
"""
_, chapters, scenes = merger.counts()
if chapters + scenes < 3:
return # rien à dédoublonner sur un arbre minuscule
skeleton = merger.skeleton_text()
try:
raw = await generate_with_retry(
self._llm, prompts.CONSOLIDATE_PROMPT.format(skeleton=skeleton),
output_format="json", temperature=0.0)
except Exception as exc: # noqa: BLE001 — best-effort STRICT : une erreur ici
# (LLM, réseau, bug) ne doit JAMAIS faire perdre un import terminé.
logger.warning("Consolidation ignorée (échec) : %s", exc)
return
parsed, _ = load_json_object(raw)
if not isinstance(parsed, dict):
logger.warning("Consolidation ignorée (réponse non-JSON).")
return
merged = 0
for cm in parsed.get("chapter_merges") or []:
if isinstance(cm, dict) and merger.merge_chapters(
str(cm.get("into") or ""), list(cm.get("merge") or [])):
merged += 1
for sm in parsed.get("scene_merges") or []:
if isinstance(sm, dict) and merger.merge_scenes(
str(sm.get("chapter") or ""), str(sm.get("into") or ""),
list(sm.get("merge") or [])):
merged += 1
if merged:
logger.info("Consolidation : %s fusion(s) de quasi-doublons appliquée(s).", merged)
# --- MAP : un morceau → sous-arbre ---------------------------------------
async def _map_chunk(
self, chunk: str, *, index: int, total: int, toc_block: str = ""
) -> dict:
"""Phase MAP d'un morceau → {"arcs": [...], "npcs": [...]}."""
return await self._extract_payload(
chunk, index=index, total=total, depth=0, toc_block=toc_block)
async def _extract_payload(
self, text: str, *, index: int, total: int, depth: int, toc_block: str = ""
) -> dict:
"""Extrait l'arborescence + les PNJ d'un texte. Si la SORTIE est tronquée,
retraite le texte en DEUX moitiés et concatène — le `_TreeMerger` final
dédoublonne par nom (un arc/chapitre coupé entre les moitiés est recollé)."""
toc_section = prompts.TOC_BLOCK.format(toc=toc_block) if toc_block else ""
prompt = (
prompts.MAP_SYSTEM.format(default_arc=prompts.DEFAULT_ARC_NAME)
+ toc_section
+ f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n"
"Renvoie maintenant le JSON de l'arborescence."
)
try:
raw = await generate_with_retry(
self._llm, prompt, output_format=_TREE_SCHEMA, temperature=_TEMPERATURE)
except LLMGenerationTimeout:
# Génération trop lente pour la taille demandée (fréquent en local /
# tier gratuit) : même remède que la troncature, deux moitiés →
# sortie 2× plus courte. Re-lever si plus découpable.
if depth >= _MAX_SPLIT_DEPTH:
raise
left, right = split_in_half(text)
if not left or not right:
raise
logger.info(
"Morceau %s : timeout de génération → re-découpage en 2 moitiés (niveau %s).",
index, depth + 1)
notify_status(
f"Le modèle est trop lent sur le morceau {index + 1} : "
"re-découpage en 2 moitiés plus digestes…")
a = await self._extract_payload(
left, index=index, total=total, depth=depth + 1, toc_block=toc_block)
b = await self._extract_payload(
right, index=index, total=total, depth=depth + 1, toc_block=toc_block)
return {"arcs": a["arcs"] + b["arcs"], "npcs": a["npcs"] + b["npcs"]}
payload, truncated = self._parse_payload(raw, index=index)
if truncated and depth < _MAX_SPLIT_DEPTH:
left, right = split_in_half(text)
if left and right:
logger.info(
"Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).",
index, depth + 1)
notify_status(
f"Réponse du modèle coupée sur le morceau {index + 1} : "
"re-découpage en 2 moitiés plus digestes…")
a = await self._extract_payload(
left, index=index, total=total, depth=depth + 1, toc_block=toc_block)
b = await self._extract_payload(
right, index=index, total=total, depth=depth + 1, toc_block=toc_block)
return {"arcs": a["arcs"] + b["arcs"], "npcs": a["npcs"] + b["npcs"]}
if truncated:
logger.warning(
"Morceau %s : sortie tronquée, profondeur max atteinte — partiel conservé.", index)
return payload
@staticmethod
def _parse_payload(raw: str, *, index: int) -> tuple[dict, bool]:
"""Parse robuste → ({"arcs", "npcs"}, tronqué). `tronqué`=True si partiel."""
empty = {"arcs": [], "npcs": []}
parsed, recovered = load_json_object(raw)
if parsed is None:
truncated = looks_like_truncated_json(raw)
if not truncated:
logger.warning(
"Morceau %s : aucun objet JSON exploitable, ignoré. "
"Début de la réponse du modèle : %r",
index, (raw or "").strip()[:300] or "(réponse VIDE)")
return empty, truncated
if isinstance(parsed, dict):
arcs = parsed.get("arcs", [])
npcs = parsed.get("npcs", [])
return {
"arcs": arcs if isinstance(arcs, list) else [],
"npcs": npcs if isinstance(npcs, list) else [],
}, recovered
return empty, recovered
def _serialize_arcs(arcs: list[ArcProposal]) -> list[dict]:
"""Sérialise l'arbre de dataclasses en dicts JSON pour le flux SSE."""
return [
{
"name": a.name,
"description": a.description,
"type": a.arc_type,
"chapters": [
{
"name": c.name,
"description": c.description,
"scenes": [
{
"name": s.name,
"description": s.description,
"player_narration": s.player_narration,
"gm_notes": s.gm_notes,
"rooms": [
{
"name": r.name,
"description": r.description,
"enemies": r.enemies,
"loot": r.loot,
}
for r in s.rooms
],
}
for s in c.scenes
],
}
for c in a.chapters
],
}
for a in arcs
]

View File

@@ -0,0 +1,502 @@
"""Use case : import d'un PDF de règles → sections markdown structurées.
Couche APPLICATION. Orchestre :
PDF (bytes) → extraction texte (port PdfTextExtractor)
→ CHUNKING (le texte d'un livre dépasse la fenêtre de contexte)
→ MAP : chaque morceau → {titre de section → markdown}
→ REDUCE: fusion des sections de même titre entre morceaux
→ RulesImportResult (proposition, NON persistée)
Ne dépend que des abstractions du domaine (ports LLMProvider + PdfTextExtractor)
→ testable avec des fakes, et indépendant du provider concret (Ollama/1min.ai).
"""
from __future__ import annotations
import logging
import re
import asyncio
from app.application.chunking import CHUNK_TARGET_TOKENS, chunk_text, split_in_half
from app.application.import_status import (
notify_status,
reset_status_queue,
set_status_queue,
)
from app.application.llm_json import load_json_object, looks_like_truncated_json
from app.application.llm_retry import generate_with_retry
from app.application.prompts import import_rules as prompts
from app.application.streaming import with_heartbeat
from app.core.language import DEFAULT as _DEFAULT_LANG, language_name
# Repli anti-troncature : si la SORTIE d'un morceau est coupée (le modèle ne peut
# pas tout réécrire en une réponse), on retraite ce morceau en 2 moitiés. Borné en
# profondeur pour éviter une récursion infinie (3 niveaux => jusqu'à 8 sous-blocs ;
# 1-2 niveaux suffisent en pratique, le reste est un garde-fou).
_MAX_SPLIT_DEPTH = 3
from app.domain.models import RulesImportResult
from app.domain.ports import (
LLMGenerationTimeout,
LLMProvider,
LLMProviderError,
PdfTextExtractor,
)
logger = logging.getLogger(__name__)
# Température basse : tâche de tri/réécriture fidèle, pas de créativité.
# Très basse : structuration = recopie/réorganisation fidèle, pas de créativité.
# Plus la valeur est haute, plus le modèle "brode" (invente du contenu absent).
_TEMPERATURE = 0.1
# Schéma de la sortie attendue : objet PLAT {titre: markdown}. Passé tel quel à
# Ollama (structured outputs : la grammaire interdit physiquement les objets
# imbriqués, les clés "thought" à valeur non-string, le bavardage hors JSON…
# indispensable pour les petits modèles locaux qui ne suivent pas les consignes).
# Les adapters cloud le traduisent en mode JSON natif (json_object).
_SECTIONS_SCHEMA: dict = {
"type": "object",
"additionalProperties": {"type": "string"},
}
# --- Mode SEGMENTATION (modèles locaux) --------------------------------------
# Réécrire tout le texte en JSON impose une SORTIE ≈ taille de l'ENTRÉE : à
# ~100 tokens/s en local, un livre = des dizaines de minutes et des troncatures
# en cascade. Ici le modèle ne renvoie que les FRONTIÈRES des sections (titre +
# premiers mots exacts) — ~200 tokens quel que soit le morceau — et c'est NOUS
# qui découpons le texte original. ~50× plus rapide, fidélité parfaite du
# contenu (texte source intact), plus de troncature possible.
# Schéma passé à Ollama (structured outputs) : un objet {"sections": [...]}.
# Racine objet (pas tableau) car l'extraction côté Brain repère le premier {…}.
_ANCHORS_SCHEMA: dict = {
"type": "object",
"properties": {
"sections": {
"type": "array",
"items": {
"type": "object",
"properties": {
"titre": {"type": "string"},
"debut": {"type": "string"},
},
"required": ["titre", "debut"],
"additionalProperties": False,
},
},
},
"required": ["sections"],
"additionalProperties": False,
}
class _SectionMerger:
"""Fusionne les sections issues des différents morceaux, ordre préservé.
Titres insensibles à la casse ("Combat" / "combat" → une seule clé). Chaque
`add()` renvoie la liste (dé-dupliquée, ordonnée) des titres touchés par ce
morceau — sert au flux de progression pour annoncer les sections trouvées.
"""
def __init__(self) -> None:
self._merged: dict[str, list[str]] = {}
self._canonical_key: dict[str, str] = {}
def add(self, sections: dict[str, str]) -> list[str]:
touched: list[str] = []
for title, content in sections.items():
title = title.strip()
content = (content or "").strip()
if not title or not content:
continue
key = title.lower()
if key not in self._canonical_key:
self._canonical_key[key] = title
self._merged[title] = []
canonical = self._canonical_key[key]
self._merged[canonical].append(content)
touched.append(canonical)
# Dé-duplication en préservant l'ordre d'apparition.
seen: set[str] = set()
return [t for t in touched if not (t in seen or seen.add(t))]
def result(self) -> dict[str, str]:
return {title: "\n\n".join(parts) for title, parts in self._merged.items()}
# Clés "méta" que certains modèles glissent dans le JSON (fuite de raisonnement,
# schéma title/content inventé…) : jamais des titres de section voulus.
_META_KEYS = frozenset({
"thought", "thoughts", "thinking", "reasoning", "raisonnement",
"comment", "commentaire", "commentaires", "note", "notes", "explanation",
})
def _normalize_sections(parsed: dict) -> dict:
"""Ramène les formes déviantes courantes au format attendu {titre: contenu}.
Observé sur les petits modèles locaux (gemma 12b) malgré les consignes :
- enveloppe {"sections": {...}} ou {"règles": {...}} autour du vrai contenu ;
- schéma inventé {"title": "...", "content": "...", "thought": "..."} →
une seule section dont le titre est la valeur de "title" ;
- clés méta ("thought", "notes"…) mêlées aux vraies sections → retirées.
"""
by_lower = {str(k).strip().lower(): k for k in parsed}
# Enveloppe : un unique conteneur connu dont la valeur est l'objet attendu.
if len(parsed) == 1:
only_key, only_val = next(iter(parsed.items()))
if (isinstance(only_val, dict)
and str(only_key).strip().lower() in {"sections", "règles", "regles", "rules"}):
return _normalize_sections(only_val)
# Schéma {"title": ..., "content": ...} : le titre est une VALEUR, pas une clé.
if "title" in by_lower and "content" in by_lower:
title = str(parsed[by_lower["title"]]).strip()
content = parsed[by_lower["content"]]
if title and not isinstance(content, dict):
return {title: content}
return {k: v for k, v in parsed.items()
if str(k).strip().lower() not in _META_KEYS}
def _coerce_markdown(value: object) -> str:
"""Convertit une valeur de section renvoyée par le LLM en markdown plat.
Malgré la consigne « valeurs = markdown », certains modèles nichent des
sous-sections ({titre: {sous-titre: contenu}}) ou des listes. Un `str(v)`
naïf produirait du repr Python ({'k': 'v'}) ; on aplatit récursivement à la
place pour ne perdre aucun contenu.
"""
if isinstance(value, str):
return value
if isinstance(value, dict):
parts = []
for k, v in value.items():
content = _coerce_markdown(v)
# Clé = sous-titre (cas normal) ; si la "valeur" est vide, la clé
# elle-même porte le contenu (dérive observée sur certains modèles).
parts.append(f"{k}\n\n{content}".strip() if content else str(k))
return "\n\n".join(parts)
if isinstance(value, list):
return "\n\n".join(_coerce_markdown(v) for v in value)
return "" if value is None else str(value)
def _find_anchor(text: str, anchor: str, start: int) -> int | None:
"""Position de `anchor` dans `text` à partir de `start`, ou None.
Le modèle recopie les premiers mots d'un passage, mais le texte extrait du
PDF contient des sauts de ligne/espaces multiples au même endroit, et le
modèle normalise parfois la casse. Trois passes, de la plus stricte à la
plus tolérante : exacte → espaces≈\\s+ → idem insensible à la casse."""
pos = text.find(anchor, start)
if pos != -1:
return pos
words = anchor.split()
if not words:
return None
pattern = r"\s+".join(re.escape(w) for w in words)
match = re.compile(pattern).search(text, start)
if match:
return match.start()
match = re.compile(pattern, re.IGNORECASE).search(text, start)
return match.start() if match else None
def _combine_sections(a: dict[str, str], b: dict[str, str]) -> dict[str, str]:
"""Fusionne deux dicts de sections (issus des 2 moitiés d'un morceau re-découpé).
Titres insensibles à la casse : un même titre présent des deux côtés (une section
coupée par le re-découpage) voit ses contenus concaténés au lieu d'être écrasés.
"""
out = dict(a)
by_lower = {k.lower(): k for k in out}
for title, content in b.items():
key = by_lower.get(title.lower())
if key is not None:
out[key] = f"{out[key]}\n\n{content}".strip()
else:
out[title] = content
by_lower[title.lower()] = title
return out
class ImportRulesUseCase:
"""Transforme un PDF de règles en proposition de sections markdown."""
def __init__(
self,
llm: LLMProvider,
extractor: PdfTextExtractor,
chunk_target_tokens: int = CHUNK_TARGET_TOKENS,
segment_only: bool = False,
) -> None:
"""`segment_only=True` (modèles locaux) : le LLM ne renvoie que les
frontières des sections (titre + premiers mots) et le texte original est
découpé localement — sortie minuscule, pas de réécriture. False (cloud) :
le LLM réécrit le contenu en sections markdown nettoyées."""
self._llm = llm
self._extractor = extractor
self._chunk_target_tokens = chunk_target_tokens
self._segment_only = segment_only
async def execute(self, pdf_bytes: bytes, language: str = _DEFAULT_LANG) -> RulesImportResult:
"""Variante non-streamée : traite tout puis renvoie le résultat complet."""
doc = self._extractor.extract(pdf_bytes)
chunks = chunk_text(doc.full_text, self._chunk_target_tokens)
logger.info(
"Import règles : %s page(s) (%s via OCR), %s morceau(x) à traiter.",
doc.page_count, doc.ocr_page_count, len(chunks),
)
merger = _SectionMerger()
for i, chunk in enumerate(chunks):
merger.add(await self._map_chunk(chunk, index=i, total=len(chunks), language=language))
return RulesImportResult(
sections=merger.result(),
page_count=doc.page_count,
ocr_page_count=doc.ocr_page_count,
)
async def stream(self, pdf_bytes: bytes, language: str = _DEFAULT_LANG):
"""Variante streamée : yield des évènements d'avancement au fil de l'eau.
Évènements (dicts) : {"type": "extracting"}, puis
{"type": "start", page_count, ocr_page_count, total}, puis un
{"type": "progress", current, total, new_sections:[...]} par morceau,
et enfin {"type": "done", sections, page_count, ocr_page_count}.
"""
# Émis AVANT l'extraction (potentiellement lente si OCR) pour que l'UI
# affiche tout de suite "Extraction…" plutôt qu'un écran figé.
yield {"type": "extracting"}
doc = self._extractor.extract(pdf_bytes)
chunks = chunk_text(doc.full_text, self._chunk_target_tokens)
total = len(chunks)
logger.info(
"Import règles (stream) : %s page(s) (%s via OCR), %s morceau(x).",
doc.page_count, doc.ocr_page_count, total,
)
yield {
"type": "start",
"page_count": doc.page_count,
"ocr_page_count": doc.ocr_page_count,
"total": total,
}
merger = _SectionMerger()
skipped = 0
last_error: str | None = None
# Canal de statut : les couches profondes (retry LLM, re-découpage) y
# publient des messages destinés à l'UI — cf. import_status.notify_status.
status_queue: asyncio.Queue = asyncio.Queue()
status_token = set_status_queue(status_queue)
try:
for i, chunk in enumerate(chunks):
# RÉSILIENCE : un morceau qui échoue est SAUTÉ, l'import continue.
# Abandon seulement si AUCUN morceau ne passe (cf. après la boucle).
# HEARTBEAT : on émet des keep-alive pendant l'appel LLM (long sur un
# provider lent) pour que le flux SSE ne soit jamais coupé par le Core.
new_titles: list[str] = []
try:
sections: dict[str, str] | None = None
async for kind, payload in with_heartbeat(
self._map_chunk(chunk, index=i, total=total, language=language),
status_queue=status_queue,
):
if kind == "heartbeat":
yield {"type": "heartbeat", "current": i + 1, "total": total}
elif kind == "status":
yield {"type": "status", "message": payload,
"current": i + 1, "total": total}
else:
sections = payload
new_titles = merger.add(sections or {})
except LLMProviderError as exc:
skipped += 1
last_error = str(exc)
logger.warning("Morceau %s/%s ignoré (échec LLM) : %s", i + 1, total, exc)
yield {"type": "chunk_failed", "current": i + 1, "total": total,
"message": str(exc)[:300]}
yield {
"type": "progress",
"current": i + 1,
"total": total,
"new_sections": new_titles,
"skipped": skipped,
}
finally:
reset_status_queue(status_token)
if total > 0 and skipped == total:
yield {"type": "error",
"message": "Tous les morceaux ont échoué auprès du fournisseur IA. "
f"Dernier message : {last_error or 'inconnu'}"}
return
sections = merger.result()
if total > 0 and not sections:
# Le texte a bien été extrait mais AUCUN morceau n'a produit de JSON
# exploitable (sorties coupées/illisibles). Sans ce signal, l'UI reçoit
# un `done` vide et l'utilisateur conclut à tort que le PDF est illisible.
yield {"type": "error",
"message": "Le texte du PDF a été extrait, mais le modèle n'a produit "
"aucune section exploitable (réponses JSON vides ou coupées). "
"Réduisez la taille des morceaux d'import, augmentez la fenêtre "
"de contexte (num_ctx) ou essayez un autre modèle."}
return
yield {
"type": "done",
"sections": sections,
"page_count": doc.page_count,
"ocr_page_count": doc.ocr_page_count,
"skipped": skipped,
}
# --- MAP : un morceau → sections -----------------------------------------
async def _map_chunk(self, chunk: str, *, index: int, total: int,
language: str = _DEFAULT_LANG) -> dict[str, str]:
return await self._extract_sections(
chunk, index=index, total=total, depth=0, language=language)
async def _extract_sections(
self, text: str, *, index: int, total: int, depth: int,
language: str = _DEFAULT_LANG,
) -> dict[str, str]:
"""Extrait les sections d'un texte. Si la SORTIE est tronquée, retraite le
texte en DEUX moitiés (chacune produit une réponse complète) et fusionne —
ainsi aucune section n'est perdue, quel que soit le plafond de sortie."""
system = prompts.SEGMENT_SYSTEM if self._segment_only else prompts.MAP_SYSTEM
schema = _ANCHORS_SCHEMA if self._segment_only else _SECTIONS_SCHEMA
prompt = (
system.format(
canonical="\n".join(f" - {s}" for s in prompts.CANONICAL_SECTIONS),
language_name=language_name(language),
)
+ f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n"
"Renvoie maintenant le JSON des sections."
)
try:
raw = await generate_with_retry(
self._llm, prompt, output_format=schema, temperature=_TEMPERATURE)
except LLMGenerationTimeout:
# Le modèle générait mais trop lentement pour réécrire tout le morceau
# dans le temps imparti (fréquent sur tier gratuit + gros morceaux).
# Même remède que la troncature : deux moitiés → sortie 2× plus courte.
if depth >= _MAX_SPLIT_DEPTH:
raise
left, right = split_in_half(text)
if not left or not right:
raise
logger.info(
"Morceau %s : timeout de génération → re-découpage en 2 moitiés (niveau %s).",
index, depth + 1)
notify_status(
f"Le modèle est trop lent sur le morceau {index + 1} : "
"re-découpage en 2 moitiés plus digestes…")
a = await self._extract_sections(left, index=index, total=total, depth=depth + 1, language=language)
b = await self._extract_sections(right, index=index, total=total, depth=depth + 1, language=language)
return _combine_sections(a, b)
if self._segment_only:
sections, truncated = self._parse_anchors(raw, text, index=index)
else:
sections, truncated = self._parse_sections(raw, index=index)
if truncated and depth < _MAX_SPLIT_DEPTH:
left, right = split_in_half(text)
if left and right:
logger.info(
"Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).",
index, depth + 1)
notify_status(
f"Réponse du modèle coupée sur le morceau {index + 1} : "
"re-découpage en 2 moitiés plus digestes…")
a = await self._extract_sections(left, index=index, total=total, depth=depth + 1, language=language)
b = await self._extract_sections(right, index=index, total=total, depth=depth + 1, language=language)
return _combine_sections(a, b)
if truncated:
logger.warning(
"Morceau %s : sortie tronquée, profondeur max atteinte — partiel conservé.", index)
return sections
@staticmethod
def _parse_anchors(raw: str, text: str, *, index: int) -> tuple[dict[str, str], bool]:
"""Mode segmentation : réponse {"sections": [{titre, debut}, …]} → on localise
chaque `debut` dans le texte ORIGINAL et on découpe entre les ancres.
Une ancre introuvable est abandonnée (son contenu reste dans la section
précédente — aucun texte n'est perdu). Le texte avant la première ancre
trouvée est rattaché à la première section (le prompt demande au modèle de
faire démarrer la première entrée aux premiers mots de l'extrait)."""
parsed, recovered = load_json_object(raw)
if parsed is None:
truncated = looks_like_truncated_json(raw)
if not truncated:
logger.warning(
"Morceau %s : aucun objet JSON exploitable (segmentation), ignoré. "
"Début de la réponse du modèle : %r",
index, (raw or "").strip()[:300] or "(réponse VIDE)")
return {}, truncated
entries = parsed.get("sections") if isinstance(parsed, dict) else None
if not isinstance(entries, list):
logger.warning("Morceau %s : pas de liste 'sections' exploitable, ignoré.", index)
return {}, False
# Localisation séquentielle : chaque ancre est cherchée APRÈS la précédente
# (préserve l'ordre du texte, évite qu'une phrase répétée matche trop tôt).
located: list[tuple[str, int]] = []
cursor = 0
dropped = 0
for entry in entries:
if not isinstance(entry, dict):
continue
title = str(entry.get("titre") or "").strip()
anchor = str(entry.get("debut") or "").strip()
if not title or not anchor:
continue
pos = _find_anchor(text, anchor, cursor)
if pos is None:
dropped += 1
continue
located.append((title, pos))
cursor = pos + 1
if dropped:
logger.info(
"Morceau %s : %s ancre(s) de section introuvable(s) — contenu rattaché "
"à la section précédente.", index, dropped)
if not located:
return {}, False
# Découpe entre ancres ; le préambule éventuel rejoint la première section.
located[0] = (located[0][0], 0)
sections: dict[str, str] = {}
for i, (title, start) in enumerate(located):
end = located[i + 1][1] if i + 1 < len(located) else len(text)
content = text[start:end].strip()
if not content:
continue
if title in sections:
sections[title] = f"{sections[title]}\n\n{content}"
else:
sections[title] = content
return sections, recovered
@staticmethod
def _parse_sections(raw: str, *, index: int) -> tuple[dict[str, str], bool]:
"""Parse robuste → (sections, tronqué). `tronqué`=True si récupération partielle."""
parsed, recovered = load_json_object(raw)
if parsed is None:
# Rien d'exploitable : soit prose (échec), soit JSON coupé avant toute
# structure complète (→ on signalera 'tronqué' pour re-découper).
truncated = looks_like_truncated_json(raw)
if not truncated:
logger.warning(
"Morceau %s : aucun objet JSON exploitable, ignoré. "
"Début de la réponse du modèle : %r",
index, (raw or "").strip()[:300] or "(réponse VIDE)")
return {}, truncated
if not isinstance(parsed, dict):
logger.warning("Morceau %s : le LLM n'a pas renvoyé un objet, ignoré.", index)
return {}, False
normalized = _normalize_sections(parsed)
return {str(k): _coerce_markdown(v) for k, v in normalized.items()}, recovered

View File

@@ -0,0 +1,39 @@
"""Canal de statut des imports : remonte à l'UI ce qui n'existait qu'en logs.
Problème résolu : pendant un import, les événements internes (retry parce que
le fournisseur IA est saturé, re-découpage d'un morceau trop gros…) n'étaient
visibles que dans les logs Docker. L'utilisateur voyait une barre de
progression figée sans explication.
Mécanisme : le flux d'import (use case `stream()`) installe une Queue dans une
ContextVar ; les couches profondes (retry LLM, re-découpage) y publient des
messages via `notify_status()` sans connaître le flux SSE. La ContextVar est
propagée automatiquement aux tâches asyncio enfants → chaque import concurrent
a SA queue, sans couplage ni paramètre à faire transiter partout.
"""
from __future__ import annotations
import asyncio
from contextvars import ContextVar, Token
_QUEUE: ContextVar[asyncio.Queue | None] = ContextVar("import_status_queue", default=None)
def set_status_queue(queue: asyncio.Queue | None) -> Token:
"""Installe la queue de statut pour le contexte courant (et ses tâches filles).
Renvoie le token à passer à `reset_status_queue` en fin d'import.
"""
return _QUEUE.set(queue)
def reset_status_queue(token: Token) -> None:
_QUEUE.reset(token)
def notify_status(message: str) -> None:
"""Publie un message de statut si un import écoute. No-op sinon (appels
LLM hors import : chat, génération de page…)."""
queue = _QUEUE.get()
if queue is not None:
queue.put_nowait(message)

View File

@@ -0,0 +1,160 @@
"""Extraction robuste d'un objet JSON depuis une réponse LLM.
Les LLM enrobent souvent leur JSON : fences markdown ```json … ```, texte
d'introduction, commentaire de fin, voire un 2e objet. Un simple
`json.loads(raw)` ou un `raw[first_brace:last_brace]` échoue dans ces cas
("Extra data", accolade parasite dans une string, etc.).
Cette fonction scanne depuis la PREMIÈRE `{` et renvoie exactement le premier
objet `{…}` ÉQUILIBRÉ, en ignorant les accolades à l'intérieur des chaînes JSON
et tout ce qui suit. Renvoie None si aucun objet complet n'est trouvé
(sortie tronquée / accolades non refermées).
"""
from __future__ import annotations
import json
import re
# Blocs de "réflexion" des modèles raisonneurs (Nemotron, DeepSeek-R1, QwQ…).
# Leur contenu est de la prose truffée d'accolades qui piège le détecteur de JSON
# (et n'est jamais la réponse) → on le retire avant toute analyse.
_REASONING_RE = re.compile(r"<think(?:ing)?>.*?</think(?:ing)?>", re.DOTALL | re.IGNORECASE)
def _strip_reasoning(raw: str) -> str:
return _REASONING_RE.sub("", raw)
def load_json_object(raw: str) -> tuple[object | None, bool]:
"""Parse un objet JSON depuis une réponse LLM, avec récupération si tronqué.
Renvoie (objet_parsé, récupéré_partiellement) :
- d'abord on tente le 1er objet complet (extract_json_object) ;
- sinon on tente une réparation du JSON tronqué (repair_truncated_json),
auquel cas le second élément vaut True.
(None, False) si rien d'exploitable.
"""
raw = _strip_reasoning(raw)
obj = extract_json_object(raw)
if obj is not None:
try:
# strict=False : tolère les caractères de contrôle BRUTS (retours à la
# ligne non échappés…) dans les chaînes — erreur fréquente des LLM hors
# mode JSON natif, qui invalidait toute la réponse.
return json.loads(obj, strict=False), False
except json.JSONDecodeError:
pass
repaired = repair_truncated_json(raw)
if repaired is not None:
try:
return json.loads(repaired, strict=False), True
except json.JSONDecodeError:
pass
return None, False
def looks_like_truncated_json(raw: str) -> bool:
"""La sortie ressemble-t-elle à un JSON COUPÉ (accolades/crochets non refermés)
plutôt qu'à de la prose ? Sert à déclencher un re-découpage même quand RIEN n'a
pu être récupéré (cas où le 1er contenu est si long qu'il est coupé avant toute
sous-structure complète).
Une réponse qui COMMENCE par `{` est jugée sur le seul équilibre des accolades,
même très courte : en mode JSON un `{"` de 2 caractères est une génération
interrompue net (contexte plein, plafond de sortie), pas de la prose — c'est le
signal de re-découpage. Pour le reste (prose contenant des accolades), on exige
un contenu substantiel pour éviter les faux positifs."""
s = _strip_reasoning(raw or "").strip()
if "{" not in s:
return False
unbalanced = s.count("{") > s.count("}") or s.count("[") > s.count("]")
if s.startswith("{"):
return unbalanced
return len(s) >= 100 and unbalanced
def extract_json_object(raw: str) -> str | None:
if not raw:
return None
text = raw.strip()
start = text.find("{")
if start == -1:
return None
depth = 0
in_string = False
escape = False
for i in range(start, len(text)):
c = text[i]
if in_string:
if escape:
escape = False
elif c == "\\":
escape = True
elif c == '"':
in_string = False
else:
if c == '"':
in_string = True
elif c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return text[start : i + 1]
return None # accolades non refermées (réponse probablement tronquée)
# Fermeture correspondante de chaque ouvrant, pour reconstituer un JSON tronqué.
_CLOSE_OF = {"{": "}", "[": "]"}
def repair_truncated_json(raw: str) -> str | None:
"""Répare un JSON COUPÉ (sortie LLM tronquée) en gardant les éléments complets.
On scanne depuis la première `{` et on retient le DERNIER point où un conteneur
(`}` ou `]`) vient de se fermer — donc juste après une sous-structure complète
(un arc / chapitre / scène / pièce / section entièrement écrit). On coupe là et
on referme les conteneurs encore ouverts. L'élément en cours d'écriture au moment
de la troncature est abandonné, mais tous les précédents sont sauvés.
Renvoie une chaîne JSON équilibrée (à valider par json.loads) ou None.
"""
if not raw:
return None
text = raw.strip()
start = text.find("{")
if start == -1:
return None
stack: list[str] = []
in_string = False
escape = False
best_cut = -1 # index (exclusif) où couper
best_closing = "" # fermetures à ajouter pour rééquilibrer
for i in range(start, len(text)):
c = text[i]
if in_string:
if escape:
escape = False
elif c == "\\":
escape = True
elif c == '"':
in_string = False
else:
if c == '"':
in_string = True
elif c in "{[":
stack.append(c)
elif c in "}]":
if stack:
stack.pop()
# Point de coupe sûr : on vient de fermer une sous-structure complète.
best_cut = i + 1
best_closing = "".join(_CLOSE_OF[b] for b in reversed(stack))
if best_cut == -1:
return None # rien de complet à sauver
head = text[start:best_cut].rstrip().rstrip(",")
return head + best_closing

View File

@@ -0,0 +1,117 @@
"""Retry avec backoff pour les appels LLM one-shot (imports).
Les imports enchaînent de nombreux appels en série ; un échec TRANSITOIRE sur un
seul morceau (503/502 surcharge serveur, 504/524 passerelle, timeout réseau) ne
doit pas faire échouer tout l'import. On réessaie quelques fois avec une attente
croissante. Après épuisement, on relaie l'erreur (problème durable : quota, panne).
Réservé aux appels `generate` (one-shot, bufferisé) : réessayer est propre, sans
risque de doublons. À NE PAS utiliser sur le streaming (re-jouerait des tokens).
"""
from __future__ import annotations
import asyncio
import logging
import re
from app.application.import_status import notify_status
from app.domain.ports import LLMGenerationTimeout, LLMProvider, LLMProviderError
logger = logging.getLogger(__name__)
# 3 tentatives : assez pour absorber un hoquet transitoire, sans s'acharner des
# minutes sur un modèle durablement lent/saturé (les heartbeats gardent le flux
# vivant, mais inutile de faire patienter l'utilisateur 15 min pour rien).
_ATTEMPTS = 3
_BASE_DELAY_SECONDS = 3.0
# Un rate limit (429) "par minute" ne se libère pas en 2-3s : on attend plus
# longtemps pour ces erreurs-là (le free tier OpenRouter plafonne ~20 req/min).
_RATE_LIMIT_DELAYS = [10.0, 25.0, 45.0]
def _is_rate_limit(exc: LLMProviderError) -> bool:
msg = str(exc).lower()
return "429" in msg or "rate" in msg or "too many requests" in msg
def _is_daily_quota(exc: LLMProviderError) -> bool:
"""Limite PAR JOUR (vs par minute) : réessayer est inutile, elle ne se libère
qu'au reset quotidien. OpenRouter le précise dans le corps du 429."""
msg = str(exc).lower()
return "per-day" in msg or "per day" in msg or "free-models-per-day" in msg
# OpenRouter renvoie souvent le délai conseillé (saturation amont) :
# "retry_after_seconds": 8 ou "Retry-After": "8". On le respecte plutôt que
# d'attendre une durée fixe arbitraire.
_RETRY_AFTER_RE = re.compile(r'retry[_-]?after(?:_seconds)?"?\s*:\s*"?([0-9]+(?:\.[0-9]+)?)', re.IGNORECASE)
def _suggested_retry_after(exc: LLMProviderError) -> float | None:
match = _RETRY_AFTER_RE.search(str(exc))
if not match:
return None
try:
return float(match.group(1))
except ValueError:
return None
async def generate_with_retry(
llm: LLMProvider,
prompt: str,
*,
output_format: str | dict | None = None,
temperature: float | None = None,
) -> str:
"""Comme `llm.generate`, mais réessaie les erreurs transitoires (backoff).
Backoff plus long pour les 429 (rate limit) afin de laisser la fenêtre se
libérer. Nombre de tentatives borné : si le quota est durablement épuisé
(ex. limite/jour), l'erreur finit par remonter au lieu de boucler sans fin.
"""
delay = _BASE_DELAY_SECONDS
last_error: LLMProviderError | None = None
for attempt in range(_ATTEMPTS):
try:
return await llm.generate(prompt, output_format=output_format, temperature=temperature)
except LLMGenerationTimeout:
# Timeout de DÉBIT (génération trop lente pour la sortie demandée) :
# rejouer le même prompt re-timeoutera à l'identique — on a déjà perdu
# `timeout` secondes. On remonte tout de suite : l'appelant (import)
# sait re-découper le morceau en deux pour réduire la sortie.
raise
except LLMProviderError as exc:
last_error = exc
# Quota JOURNALIER épuisé : inutile d'insister, on remonte tout de suite
# (sinon on enchaîne des attentes longues pour rien, et on spamme l'API).
if _is_daily_quota(exc):
logger.warning("Quota journalier du fournisseur épuisé — abandon : %s", exc)
raise
if attempt < _ATTEMPTS - 1:
if _is_rate_limit(exc):
suggested = _suggested_retry_after(exc)
if suggested is not None:
# Indication serveur (saturation amont) + petite marge, plafonnée.
wait = min(suggested + 2.0, 60.0)
else:
wait = _RATE_LIMIT_DELAYS[min(attempt, len(_RATE_LIMIT_DELAYS) - 1)]
else:
wait = delay
delay *= 2
logger.warning(
"Appel LLM échoué (tentative %s/%s)%s : %s — nouvelle tentative dans %ss.",
attempt + 1, _ATTEMPTS, " [rate limit]" if _is_rate_limit(exc) else "",
exc, wait,
)
# Remonte aussi l'info à l'UI (flux d'import) : sans ça l'utilisateur
# voit une barre figée sans savoir que le fournisseur est saturé.
notify_status(
("Fournisseur IA saturé (rate limit)" if _is_rate_limit(exc)
else "Appel IA échoué")
+ f" — tentative {attempt + 1}/{_ATTEMPTS}, nouvel essai dans {int(wait)}s. "
+ str(exc)[:160]
)
await asyncio.sleep(wait)
assert last_error is not None
raise last_error

View File

@@ -0,0 +1,80 @@
"""Use case : chat ANCRÉ sur les sources d'un notebook (RAG).
À chaque message, on retrouve les passages pertinents des sources (via le RAG) et
on les injecte dans le prompt système, en plus du contexte de campagne. Le modèle
répond donc en s'appuyant sur la/les source(s) — pas sur ses connaissances générales.
"""
from __future__ import annotations
from typing import AsyncIterator
from app.application.notebook_rag import NotebookRagUseCase
from app.application.prompts import notebook as prompts
from app.application.query_rewrite import standalone_question
from app.application.rerank import pool_size, rerank
from app.core.language import DEFAULT as _DEFAULT_LANG, language_name
from app.domain.models import ChatMessage
from app.domain.ports import LLMChatProvider
class NotebookChatUseCase:
def __init__(
self, rag: NotebookRagUseCase, llm: LLMChatProvider, rerank_enabled: bool = False
) -> None:
self._rag = rag
self._llm = llm
# Reranking LLM d'un pool élargi avant injection (voir app.application.rerank).
self._rerank_enabled = rerank_enabled
async def stream(
self,
source_ids: list[str],
messages: list[ChatMessage],
context: str = "",
top_k: int = 6,
language: str = _DEFAULT_LANG,
) -> AsyncIterator[dict]:
"""Yield des évènements : {type:'sources', sources:[…]} (une fois, avant la
réponse — transparence sur les passages utilisés), puis {type:'token', token}."""
# Question AUTONOME pour la recherche : sur une relance (« et ses
# faiblesses ? »), l'embedding du dernier message seul ne contient pas
# le sujet → on le résout depuis l'historique (best-effort, 1 appel léger,
# uniquement à partir du 2e tour). La réponse, elle, voit tout l'historique.
search_query = await standalone_question(self._llm, messages)
if self._rerank_enabled:
# Pool élargi → notation LLM → top_k final (meilleure précision sur
# les questions ambiguës, au prix d'un appel avant le premier token).
pool = await self._rag.retrieve(
source_ids, search_query, top_k=pool_size(top_k))
passages = await rerank(self._llm, search_query, pool, top_k)
else:
passages = await self._rag.retrieve(source_ids, search_query, top_k=top_k)
# Évènement 'sources' AVANT le premier token : l'UI peut afficher les
# pages utilisées (« 📖 p. 12, 47 ») dès le début de la réponse.
yield {"type": "sources", "sources": [
{
"source_id": p.get("source_id"),
"page": p.get("page"),
"score": round(float(p.get("score") or 0.0), 3),
}
for p in passages
]}
sources_block = (
"\n\n".join(self._format_passage(p) for p in passages)
if passages else "(aucun passage pertinent trouvé dans les sources)"
)
context_block = (
f"--- TA CAMPAGNE ---\n{context.strip()}\n--- FIN CAMPAGNE ---\n\n"
if context.strip() else "--- TA CAMPAGNE ---\n(aucune donnée de campagne)\n--- FIN CAMPAGNE ---\n\n"
)
system_prompt = prompts.CHAT_SYSTEM.format(
context_block=context_block, sources_block=sources_block,
language_name=language_name(language))
async for token in self._llm.stream_chat(messages, system_prompt=system_prompt):
yield {"type": "token", "token": token}
@staticmethod
def _format_passage(p: dict) -> str:
page = p.get("page")
prefix = f"(p. {page}) " if page else ""
return f"{prefix}{p['text'].strip()}"

View File

@@ -0,0 +1,249 @@
"""Use case « Analyse approfondie » d'un notebook : map-reduce sur TOUT le document.
Contrairement au chat RAG (qui ne ramène que les top-k extraits), ce mode lit
l'INTÉGRALITÉ des sources par lots :
- MAP : pour chaque lot, le modèle extrait ce qui est pertinent pour la question
(ou « RAS » si rien) ;
- REDUCE : il synthétise toutes les notes en une réponse finale (streamée).
→ Répond aux questions globales/exhaustives (« liste tous les… ») quel que soit le
modèle, au prix de plusieurs appels (comme l'import). Le lot est dimensionné par
`batch_tokens` (= taille de morceau d'import) : avec un modèle gros-contexte, peu de
lots ; avec un petit modèle local, plus de lots (mais ça reste exhaustif).
"""
from __future__ import annotations
import asyncio
import logging
from typing import AsyncIterator
import tiktoken
from app.application.llm_retry import generate_with_retry
from app.application.prompts import notebook as prompts
from app.application.query_rewrite import standalone_question
from app.core.language import DEFAULT as _DEFAULT_LANG, language_name
from app.domain.models import ChatMessage
from app.domain.ports import LLMChatProvider, LLMProvider, LLMProviderError
from app.infrastructure import vector_store
logger = logging.getLogger(__name__)
_NO_MATCH = "RAS"
_MAP_TEMPERATURE = 0.2
# --- Index de résumés (pré-filtrage des lots) --------------------------------
# Sans index : CHAQUE question relit TOUT le document (1 appel LLM par lot).
# Avec : les résumés de lots (construits UNE fois, cache disque) sont comparés
# à la question par embedding, et seuls les lots plausiblement pertinents sont
# relus. Sélection volontairement CONSERVATRICE (on préfère relire un lot de
# trop que rater une mention) ; désactivable via deep_summary_filter=False.
# Un lot est gardé si son score est proche du meilleur (marge) OU bon dans
# l'absolu ; et on garde toujours au moins _MIN_KEPT lots.
_SELECT_MARGIN = 0.10
_SELECT_FLOOR = 0.5
_MIN_KEPT = 3
class NotebookDeepUseCase:
def __init__(
self,
llm: LLMProvider,
batch_tokens: int = 10000,
map_concurrency: int = 1,
embedder=None,
summary_filter: bool = True,
) -> None:
self._llm = llm
self._batch_tokens = max(2000, batch_tokens)
# Lots MAP traités par vagues de cette taille (parallélisme LLM).
self._map_concurrency = max(1, map_concurrency)
# EmbeddingProvider (duck typing) pour l'index de résumés ; None = pas
# de pré-filtrage (plein scan, comportement historique).
self._embedder = embedder
self._summary_filter = summary_filter
async def stream(
self,
source_ids: list[str],
messages: list[ChatMessage],
context: str = "",
history_limit: int = 8,
language: str = _DEFAULT_LANG,
) -> AsyncIterator[dict]:
"""Yield des évènements : {type:'progress',current,total}, {type:'token',token},
{type:'done'}. (Les erreurs LLM des lots sont tolérées : lot ignoré.)
La dernière question utilisateur sert à la LECTURE du document (map) ; la
SYNTHÈSE (reduce) reçoit les `history_limit` derniers messages → les relances
conversationnelles (« et pour les autres ? ») fonctionnent aussi en approfondi.
"""
# Question autonome : la phase MAP lit chaque lot avec LA question — sur
# une relance conversationnelle, il faut y résoudre les références
# implicites, sinon les lots sont filtrés sur un texte sans sujet.
question = await standalone_question(self._llm, messages)
# Lots PAR SOURCE (l'index de résumés est caché par source).
per_source: list[tuple[str, list[dict]]] = []
for sid in source_ids:
chunks = vector_store.all_chunks(sid)
for batch in self._group(chunks):
per_source.append((sid, batch))
if not per_source:
yield {"type": "token", "token": "Aucune source indexée à analyser."}
yield {"type": "done"}
return
# Pré-filtrage par index de résumés (best-effort : tout échec → plein scan).
selected: set[int] | None = None
if self._summary_filter and self._embedder is not None:
try:
async for ev_or_result in self._select_batches(per_source, question):
if isinstance(ev_or_result, dict):
yield ev_or_result # progress de construction de l'index
else:
selected = ev_or_result
except Exception as exc: # noqa: BLE001 — le filtre ne doit jamais bloquer
logger.warning("Index de résumés ignoré (échec) : %s", exc)
selected = None
if selected is not None:
logger.info(
"Analyse approfondie : %s/%s lot(s) retenus via l'index de résumés.",
len(selected), len(per_source))
indices = sorted(selected) if selected is not None else list(range(len(per_source)))
total = len(indices)
notes: list[str] = []
# Lots traités par VAGUES parallèles ; les notes restent dans l'ordre du
# document (gather préserve l'ordre des tâches de la vague).
for start in range(0, total, self._map_concurrency):
yield {"type": "progress", "current": start, "total": total}
wave = indices[start:start + self._map_concurrency]
results = await asyncio.gather(
*(self._map_batch(question, per_source[i][1]) for i in wave),
return_exceptions=True)
for j, res in enumerate(results):
if isinstance(res, LLMProviderError):
logger.warning(
"Analyse approfondie : lot %s/%s ignoré : %s", start + j + 1, total, res)
elif isinstance(res, BaseException):
raise res # bug inattendu : ne pas l'avaler
elif res:
notes.append(res)
yield {"type": "progress", "current": total, "total": total}
notes_block = "\n\n".join(notes) if notes else "(aucune information pertinente trouvée dans le document)"
context_block = (
f"--- TA CAMPAGNE (structure, PNJ, univers) ---\n{context.strip()}\n--- FIN CAMPAGNE ---\n\n"
if context.strip() else ""
)
system_prompt = prompts.REDUCE_SYSTEM.format(
context_block=context_block, notes_block=notes_block,
language_name=language_name(language))
# Historique récent pour la cohérence des relances ; on garantit que le
# dernier message est bien la question courante.
reduce_messages = messages[-history_limit:] if messages else [ChatMessage(role="user", content=question)]
llm_chat: LLMChatProvider = self._llm # type: ignore[assignment]
produced = False
async for token in llm_chat.stream_chat(reduce_messages, system_prompt=system_prompt):
if token:
produced = True
yield {"type": "token", "token": token}
if not produced:
# Jamais de bulle vide : message de repli + orientation vers le mode rapide,
# mieux adapté aux demandes créatives (et qui propose des cartes d'action).
yield {"type": "token", "token": (
"Je n'ai pas trouvé d'éléments pertinents dans le document pour cette demande "
"(elle porte sans doute sur des éléments que tu as inventés). Pour une "
"**adaptation créative** — proposer des arcs, chapitres, scènes ou PNJ — "
"utilise plutôt le bouton **« Envoyer »** (mode rapide) : il est conversationnel, "
"voit ta campagne, et te propose des cartes « Créer dans la campagne »."
)}
yield {"type": "done"}
# --- Index de résumés ------------------------------------------------------
async def _select_batches(self, per_source: list[tuple[str, list[dict]]], question: str):
"""Générateur : yield des évènements `progress` pendant la construction de
l'index (1ère analyse d'une source), puis le set des indices retenus —
ou None si le filtre n'apporte rien (tous retenus)."""
# 1. Charge/construit les résumés par source (cache disque).
by_sid: dict[str, list[int]] = {}
for i, (sid, _) in enumerate(per_source):
by_sid.setdefault(sid, []).append(i)
vectors: list[list[float] | None] = [None] * len(per_source)
to_build = []
for sid, idxs in by_sid.items():
cached = vector_store.load_summaries(sid, self._batch_tokens)
if cached is not None and len(cached) == len(idxs):
for i, entry in zip(idxs, cached):
vectors[i] = entry.get("vector")
else:
to_build.append((sid, idxs))
total_build = sum(len(idxs) for _, idxs in to_build)
done_build = 0
for sid, idxs in to_build:
summaries: list[str] = []
for start in range(0, len(idxs), self._map_concurrency):
yield {"type": "progress", "current": done_build, "total": total_build}
wave = idxs[start:start + self._map_concurrency]
results = await asyncio.gather(
*(self._summarize_batch(per_source[i][1]) for i in wave))
summaries.extend(results)
done_build += len(wave)
vecs = await self._embedder.embed(summaries, kind="document")
entries = [{"summary": s, "vector": v} for s, v in zip(summaries, vecs)]
vector_store.save_summaries(sid, self._batch_tokens, entries)
for i, entry in zip(idxs, entries):
vectors[i] = entry["vector"]
# 2. Score de chaque lot face à la question, sélection conservatrice.
qv = (await self._embedder.embed([question], kind="query"))[0]
scores = [
vector_store.cosine_similarity(qv, v) if v else 0.0
for v in vectors
]
best = max(scores)
keep = {i for i, s in enumerate(scores) if s >= best - _SELECT_MARGIN or s >= _SELECT_FLOOR}
floor = min(_MIN_KEPT, len(scores))
if len(keep) < floor:
keep = set(sorted(range(len(scores)), key=lambda i: -scores[i])[:floor])
yield keep if len(keep) < len(scores) else None
async def _summarize_batch(self, batch: list[dict]) -> str:
excerpt = "\n\n".join(c.get("text", "").strip() for c in batch)
raw = await generate_with_retry(
self._llm, prompts.SUMMARY_PROMPT.format(excerpt=excerpt), temperature=_MAP_TEMPERATURE)
return (raw or "").strip()
async def _map_batch(self, question: str, batch: list[dict]) -> str:
"""Phase MAP d'un lot : extrait les infos pertinentes ('' si RAS)."""
excerpt = "\n\n".join(
f"(p. {c['page']}) {c['text'].strip()}" if c.get("page") else c["text"].strip()
for c in batch
)
prompt = prompts.MAP_PROMPT.format(no_match=_NO_MATCH, question=question, excerpt=excerpt)
raw = await generate_with_retry(self._llm, prompt, temperature=_MAP_TEMPERATURE)
answer = raw.strip()
if answer and answer.upper().rstrip(".") != _NO_MATCH:
return answer
return ""
def _group(self, chunks: list[dict]) -> list[list[dict]]:
"""Regroupe les extraits en lots ~`batch_tokens` (compte tiktoken)."""
enc = tiktoken.get_encoding("cl100k_base")
batches: list[list[dict]] = []
current: list[dict] = []
current_tokens = 0
for c in chunks:
t = len(enc.encode(c.get("text", "")))
if current and current_tokens + t > self._batch_tokens:
batches.append(current)
current, current_tokens = [], 0
current.append(c)
current_tokens += t
if current:
batches.append(current)
return batches

View File

@@ -0,0 +1,94 @@
"""Use case RAG des notebooks : indexer une source PDF et retrouver les passages
pertinents pour une question.
Chaîne d'indexation : PDF → extraction texte (+OCR) → découpage en extraits courts
→ embeddings → stockage vectoriel (fichier). À la requête : on embed la question
et on récupère les extraits les plus proches (cosinus) pour ancrer le chat.
Extraits PLUS COURTS que pour l'import (recopie) : ici on veut une granularité fine
pour que la recherche pointe un passage précis, pas un demi-chapitre.
"""
from __future__ import annotations
import logging
from app.application.chunking import chunk_text
from app.application.embeddings import EmbeddingProvider
from app.domain.ports import PdfTextExtractor
from app.infrastructure import vector_store
logger = logging.getLogger(__name__)
_RAG_CHUNK_TOKENS = 600
# Recouvrement entre extraits consécutifs (~13% de la cible) : une phrase-clé à
# cheval sur deux extraits reste retrouvable dans au moins l'un des deux.
_RAG_OVERLAP_TOKENS = 80
# Un extrait avec quasi aucun texte réel (en-tête/pied de page, fragment de numéro
# de page isolé « 249 250 ») ne sert à rien en RAG → on l'écarte. Seuil bas et
# conservateur : on ne coupe QUE les fragments quasi-vides, jamais une vraie phrase.
_MIN_LETTERS = 15
def _has_enough_text(piece: str) -> bool:
return sum(c.isalpha() for c in piece) >= _MIN_LETTERS
class NotebookRagUseCase:
def __init__(
self,
extractor: PdfTextExtractor,
embedder: EmbeddingProvider,
chunk_target_tokens: int = _RAG_CHUNK_TOKENS,
min_score: float = 0.0,
) -> None:
self._extractor = extractor
self._embedder = embedder
self._chunk_target_tokens = chunk_target_tokens
# Cosinus minimal pour qu'un extrait soit injecté dans le prompt : sous ce
# seuil, l'extrait n'a aucun rapport avec la question → bruit. 0 = désactivé.
self._min_score = min_score
async def index_source(self, source_id: str, pdf_bytes: bytes) -> dict:
"""Extrait, découpe PAR PAGE (pour garder le n° de page → citations), embed
et stocke une source. Renvoie un récap."""
doc = self._extractor.extract(pdf_bytes)
chunks: list[str] = []
pages: list[int] = []
for page in doc.pages:
for piece in chunk_text(
page.text, self._chunk_target_tokens, overlap_tokens=_RAG_OVERLAP_TOKENS
):
if not _has_enough_text(piece):
continue # fragment quasi-vide (en-tête/pied/numéro) → ignoré
chunks.append(piece)
pages.append(page.index + 1) # n° de page 1-based pour l'affichage
logger.info(
"Indexation notebook source=%s : %s page(s) (%s OCR), %s extrait(s).",
source_id, doc.page_count, doc.ocr_page_count, len(chunks),
)
if not chunks:
vector_store.save(source_id, [], [])
return {"chunks": 0, "page_count": doc.page_count, "ocr_page_count": doc.ocr_page_count}
vectors = await self._embedder.embed(chunks)
count = vector_store.save(source_id, chunks, vectors, pages)
return {
"chunks": count,
"page_count": doc.page_count,
"ocr_page_count": doc.ocr_page_count,
}
async def retrieve(self, source_ids: list[str], query: str, top_k: int = 6) -> list[dict]:
"""Passages les plus pertinents (toutes sources) pour `query`.
Recherche hybride (cosinus + bonus lexical sur les mots de la question) ;
peut renvoyer moins de `top_k` passages si le seuil de pertinence écarte
les extraits hors-sujet."""
ids = [s for s in source_ids if vector_store.exists(s)]
if not ids or not query.strip():
return []
query_vectors = await self._embedder.embed([query], kind="query")
if not query_vectors:
return []
return vector_store.search(
ids, query_vectors[0], top_k, query_text=query, min_score=self._min_score
)

View File

@@ -0,0 +1,12 @@
"""Prompts LLM, regroupés hors de la logique des use cases.
Un prompt est du code (couplé à son schéma de sortie et à son parsing), mais
le mêler à la logique d'orchestration rend les use cases illisibles. Ce package
isole le TEXTE des prompts : un module par domaine fonctionnel, miroir des
modules de `app.application` / des routers.
Convention : les use cases importent depuis ici et gardent la logique (chunking,
parsing, fusion, schémas de sortie JSON, températures, sentinelles). Les prompts
restent en français (langue de travail) — seule la langue de SORTIE est
paramétrée, cf. `app.core.language`.
"""

View File

@@ -0,0 +1,26 @@
"""Prompts des conseils d'adaptation d'un PDF à une campagne (cf. adapt_campaign.py)."""
from app.core.language import language_name
SYSTEM_PREFIX = (
"Tu es un assistant pour Maître de Jeu de jeu de rôle. L'utilisateur a une "
"campagne EXISTANTE (décrite plus bas) et souhaite ADAPTER et INTÉGRER le "
"contenu d'un PDF (aventure, donjon, supplément) à CETTE campagne précise."
)
def system_suffix(language: str) -> str:
"""Consignes de sortie, avec la langue des conseils pilotée par l'utilisateur."""
return (
f"Produis des CONSEILS D'ADAPTATION concrets, actionnables et en {language_name(language).upper()}, "
"en markdown structuré (titres ##, listes). Couvre notamment :\n"
"- **Où l'insérer** : à quel(s) arc(s)/chapitre(s) EXISTANT(s) rattacher ce "
"contenu, dans quel ordre, et — si l'arc est un hub — sous quelles conditions de déblocage.\n"
"- **Reskins / liens PNJ** : quels PNJ EXISTANTS de la campagne peuvent incarner "
"ou remplacer les personnages clés du PDF.\n"
"- **Adaptation à l'univers** : comment transposer lieux, factions, noms propres et "
"ton vers l'univers de l'utilisateur plutôt que le cadre d'origine du PDF.\n"
"- **Doublons / conflits** : ce qui recoupe l'existant et comment le réconcilier.\n"
"- **Ajustements de ton et de difficulté**.\n\n"
"Réfère-toi TOUJOURS aux éléments existants par leur NOM. Ne réécris PAS le PDF en "
"entier : donne des recommandations. Si une information manque, propose des options."
)

View File

@@ -0,0 +1,23 @@
"""Prompt système de base du chat contextuel (cf. chat.py).
Les blocs de contexte (Lore, page, campagne, session…) sont sérialisés par les
méthodes `_format_*` du use case ; seul le SYSTEM de base vit ici.
"""
from app.core.language import language_name
def base_system(language: str) -> str:
"""System prompt de base, avec la langue de réponse pilotée par l'utilisateur."""
return f"""Tu es un assistant d'écriture pour un Maître de Jeu de JDR.
Tu dialogues avec le MJ pour l'aider à enrichir son univers et ses campagnes.
Règles de ton :
- Réponds en {language_name(language)}, ton chaleureux et créatif.
- Sois concis : listes à puces courtes plutôt que longs paragraphes.
- Propose des idées qui s'intègrent dans le contexte existant ci-dessous.
Règles de cohérence (IMPORTANT) :
- Tu PEUX et DOIS inventer des éléments originaux (personnages, lieux, objets, intrigues, créatures, scènes) — c'est ton rôle d'assistant créatif.
- Tu ne peux PAS faire référence à un élément du MJ (du Lore, des arcs, chapitres ou scènes) comme s'il existait déjà, SAUF s'il apparaît EXACTEMENT (même orthographe) dans l'une des sections de contexte ci-dessous.
- Si l'utilisateur mentionne un nom que tu ne vois pas dans le contexte, ne fais surtout pas semblant de le connaître : dis clairement "Je ne vois pas [nom] dans le contexte actuel, veux-tu qu'on le crée ?" plutôt que d'inventer des détails à son sujet.
- Évite les précisions inventées qu'on ne peut pas vérifier : dates exactes, chiffres de population, hiérarchies politiques complexes, généalogies détaillées. Préfère des formulations ouvertes que le MJ validera ("il y a longtemps", "de nombreux", "la haute noblesse")."""

View File

@@ -0,0 +1,15 @@
"""Prompt & repli de l'auto-titre de conversation (cf. router generation.py)."""
from app.core.language import language_name
# Titre de repli (LLM injoignable / réponse vide), localisé selon la langue UI.
TITLE_FALLBACK = {"fr": "Nouvelle conversation", "en": "New conversation"}
def title_system_prompt(language: str) -> str:
"""Consigne d'auto-titre, avec la langue du titre pilotée par l'utilisateur."""
return (
"Tu generes un titre court (4 a 7 mots max) qui resume le sujet de la "
"conversation ci-dessous. Reponds UNIQUEMENT par le titre, sans guillemets, "
"sans ponctuation finale, sans prefixe type 'Titre :'. Le titre doit etre "
f"en {language_name(language)} et capturer le sujet metier (pas 'Conversation IA')."
)

View File

@@ -0,0 +1,19 @@
"""Consignes système de la génération de page (cf. generate_page.py)."""
from app.core.language import language_name
def system_instructions(language: str) -> str:
"""Consignes système, avec la langue des valeurs générées pilotée par l'utilisateur."""
return f"""Tu es un assistant d'écriture pour un Maître de Jeu de JDR.
Tu vas générer le contenu d'une page appartenant à un univers fictionnel.
Règles impératives de ta réponse :
- Tu réponds UNIQUEMENT par un objet JSON valide.
- Les clés du JSON correspondent EXACTEMENT aux noms de champs demandés.
- Les valeurs sont des chaînes de texte en {language_name(language)}, riches et évocatrices.
- Aucun markdown, aucune explication, aucun commentaire autour du JSON.
Règles de cohérence (IMPORTANT) :
- Tu PEUX inventer des détails originaux pour CETTE page : apparence, traits de caractère, anecdotes, histoire personnelle.
- Tu ne dois PAS faire référence à d'autres personnages, lieux, organisations ou événements comme s'ils existaient déjà dans l'univers, sauf si le contexte ci-dessous les mentionne explicitement.
- Si un champ appelle une précision externe (date, nom d'un roi, ville voisine, guerre passée), reste volontairement vague : "il y a de nombreuses années", "un bourg voisin", "une époque troublée". Le MJ préfère combler lui-même les blancs plutôt que trouver des faits inventés contradictoires avec son univers."""

View File

@@ -0,0 +1,100 @@
"""Prompts de l'import de campagne PDF (cf. import_campaign.py)."""
# Nom de l'arc unique quand le livre n'est pas découpé en actes/parties.
DEFAULT_ARC_NAME = "Aventure principale"
MAP_SYSTEM = """Tu es un assistant qui structure un livre de campagne de jeu de rôle.
On te donne un EXTRAIT brut d'un PDF de campagne (texte parfois mal coupé par la mise en page).
Ta tâche : en dégager une ARBORESCENCE narrative à GROS GRAIN : arcs → chapitres → scènes,
et — pour les lieux explorables — leurs PIÈCES (rooms).
- Un ARC = un acte / une grande partie de la campagne (souvent un seul pour une aventure courte).
- Un CHAPITRE = une étape majeure du récit : un chapitre du livre, OU — dans une
campagne "hub" / bac-à-sable — UNE QUÊTE ou UN LIEU principal débloqué depuis le
point central (ex : Dragon of Icespire Peak → chaque quête/lieu = un chapitre).
- Une SCÈNE = un temps fort jouable du chapitre : un lieu, une rencontre clé, un moment pivot.
- Une PIÈCE (room) = une salle d'un lieu explorable (donjon, crypte, manoir...).
TYPE D'ARC ("type") :
- "HUB" si la campagne est un bac-à-sable : des quêtes/lieux optionnels, parallèles,
débloqués depuis un point central, SANS ordre fixe imposé (ex : Dragon of Icespire Peak).
- "LINEAR" si les chapitres se jouent dans un ordre séquentiel imposé.
- Dans le doute : "LINEAR".
GRANULARITÉ (évite la sur-détection) :
- Vise PEU de scènes : typiquement 1 à 6 par chapitre. PAS des dizaines.
- Un LIEU EXPLORABLE (donjon, crypte, manoir, grotte à plusieurs salles) = UNE SEULE
scène. Ses salles vont dans le tableau "rooms" de cette scène — JAMAIS en scènes séparées.
- NE crée PAS une scène par rencontre isolée, par PNJ, par monstre ou par paragraphe.
- IGNORE : blocs de stats, listes de monstres, encarts de règles, légendes de cartes,
pieds de page, sommaires, crédits.
CONTENU D'UNE SCÈNE (fidélité au livre — important) :
- `description` = synopsis de la scène, 2 à 4 phrases (plus que 1 ligne, mais pas le texte intégral).
- `player_narration` = le texte d'AMBIANCE « à lire aux joueurs » (encadrés / boxed text /
« lecture à voix haute »), recopié FIDÈLEMENT s'il existe dans l'extrait. Vide sinon.
- `gm_notes` = les informations pour le MJ : secrets, développement, ce qui se passe,
conséquences, indices cachés. Vide si rien de tel.
- Ne RÉSUME pas abusivement player_narration et gm_notes : recopie le contenu utile du livre.
PIÈCES (rooms) — uniquement pour les scènes qui sont des lieux explorables :
- Une entrée par salle numérotée/nommée du donjon (ex : "1. Entrée", "2. Salle des gardes").
- `enemies` = créatures/boss de la salle (vide si aucune). `loot` = trésor/récompense (vide si aucun).
- Pour une scène narrative classique (pas un donjon), "rooms" est un tableau vide [].
PNJ ET CRÉATURES NOTABLES ("npcs", tableau au niveau racine) :
- Recense les PNJ NOMMÉS (alliés, marchands, antagonistes) et les créatures UNIQUES
(boss, monstre récurrent) présents dans l'extrait.
- `description` = courte fiche utile au MJ : rôle dans l'histoire, apparence,
motivations, où on le rencontre. 2 à 4 phrases, fidèles au livre.
- N'inclus PAS les monstres génériques sans nom (« 3 gobelins », « un loup »).
- Aucun PNJ nommé dans l'extrait → "npcs": [].
Format de réponse :
- Tu réponds UNIQUEMENT par un objet JSON valide, sans markdown ni commentaire autour.
- Schéma EXACT :
{{"arcs": [{{"name": "...", "description": "...", "type": "LINEAR",
"chapters": [{{"name": "...", "description": "...", "scenes": [
{{"name": "...", "description": "...", "player_narration": "...", "gm_notes": "...",
"rooms": [{{"name": "...", "description": "...", "enemies": "...", "loot": "..."}}]}}
]}}]}}
],
"npcs": [{{"name": "...", "description": "..."}}]}}
- Utilise les VRAIS titres du livre pour les noms (pas de paraphrase).
- Si le livre n'est PAS découpé en actes/parties, regroupe tout sous un seul arc nommé "{default_arc}".
- N'invente pas de contenu : tu réorganises et recopies ce qui est présent dans l'extrait.
- Si l'extrait ne contient aucune matière narrative, renvoie {{"arcs": []}}."""
# Bloc TOC injecté quand le PDF a des bookmarks : les morceaux étant traités
# séparément, c'est CE référentiel commun qui garantit que tous nomment les
# mêmes chapitres à l'identique → la fusion par nom du _TreeMerger recolle
# les chapitres coupés au lieu de créer des doublons.
TOC_BLOCK = """
--- STRUCTURE OFFICIELLE DU LIVRE (table des matières du PDF) ---
{toc}
--- FIN DE LA STRUCTURE ---
IMPORTANT : pour nommer les arcs et chapitres, reprends EXACTEMENT les titres
de cette structure (caractère pour caractère). Rattache le contenu de l'extrait
au bon chapitre de la structure, même si son titre n'apparaît pas dans l'extrait."""
# Consolidation finale : le squelette (noms seuls) est minuscule, donc l'appel
# est quasi gratuit comparé aux MAP. Température 0 et consigne CONSERVATRICE :
# ne fusionner que les doublons évidents, jamais des entités distinctes.
CONSOLIDATE_PROMPT = """Voici le squelette d'une arborescence arc → chapitre → scène issue d'une
fusion AUTOMATIQUE de morceaux d'un livre de campagne de jeu de rôle. La fusion par nom exact
peut avoir laissé des QUASI-DOUBLONS : le même chapitre ou la même scène sous deux libellés
légèrement différents (ex: "La Crypte" et "Crypte de Karrak", "3. Salle des gardes" et
"Salle des gardes").
{skeleton}
Identifie UNIQUEMENT les fusions ÉVIDENTES (même entité du livre sous deux noms). Sois
CONSERVATEUR : dans le doute, ne fusionne PAS. Deux lieux/évènements distincts ne doivent
JAMAIS être fusionnés.
Réponds UNIQUEMENT par un objet JSON valide :
{{"chapter_merges": [{{"into": "nom du chapitre à garder", "merge": ["nom à fusionner", ...]}}],
"scene_merges": [{{"chapter": "nom du chapitre", "into": "nom de la scène à garder",
"merge": ["nom à fusionner", ...]}}]}}
S'il n'y a RIEN à fusionner (cas le plus fréquent) : {{"chapter_merges": [], "scene_merges": []}}"""

View File

@@ -0,0 +1,62 @@
"""Prompts de l'import de règles PDF (cf. import_rules.py).
Deux modes : MAP_SYSTEM (cloud, réécrit le contenu en sections markdown) et
SEGMENT_SYSTEM (local, ne renvoie que les frontières des sections). Les deux
templates attendent `.format(canonical=..., language_name=...)`.
"""
# Taxonomie canonique suggérée au modèle pour homogénéiser les titres entre
# morceaux (sinon "Combat" / "Le combat" / "Règles de combat" se dispersent).
# Le modèle reste libre d'en créer d'autres si rien ne correspond.
CANONICAL_SECTIONS = [
"Règles générales",
"Création de personnage",
"Caractéristiques et tests",
"Compétences",
"Combat",
"Magie et sorts",
"Équipement et objets",
"États et conditions",
"Repos et récupération",
"Progression et niveaux",
"Conseils au Maître de Jeu",
]
MAP_SYSTEM = """Tu es un assistant qui réorganise un livre de règles de jeu de rôle.
On te donne un EXTRAIT brut d'un PDF de règles (texte parfois mal coupé par la mise en page).
Ta tâche : répartir le contenu de cet extrait dans des SECTIONS THÉMATIQUES.
Format EXACT attendu — un objet JSON plat {{titre de section: contenu markdown}} :
{{"Combat": "## Initiative\\n\\nChaque participant lance 1d20...", "Magie et sorts": "## Sorts\\n\\n..."}}
Règles impératives :
- Tu réponds UNIQUEMENT par cet objet JSON, sans texte avant ni après.
- Les CLÉS sont des titres de section (texte court). Les VALEURS sont le contenu de la règle en markdown (chaîne de caractères, jamais un objet ou une liste).
- INTERDIT : des clés génériques comme "title", "content", "sections", "thought" ou "notes" ; des objets imbriqués ; tout commentaire sur ta démarche ou ton raisonnement.
- Utilise EN PRIORITÉ ces titres canoniques quand le contenu y correspond :
{canonical}
- Si un contenu ne rentre dans aucun, crée un titre clair et concis (en {language_name}).
- Reproduis FIDÈLEMENT les règles : tu peux nettoyer la coupure des lignes, recoller les mots coupés
par un tiret en fin de ligne, retirer les en-têtes/pieds de page et numéros de page parasites.
- N'INVENTE AUCUNE règle, ne résume pas abusivement : tu réorganises, tu ne réécris pas le fond.
- Ignore les pages de garde, sommaires, crédits, pages vides (renvoie {{}} si l'extrait n'a aucune règle)."""
SEGMENT_SYSTEM = """Tu analyses un EXTRAIT brut d'un livre de règles de jeu de rôle.
Ta tâche : repérer où COMMENCENT les sections thématiques. Tu ne réécris RIEN.
Format EXACT attendu :
{{"sections": [{{"titre": "Combat", "debut": "Le combat se déroule en tours de"}}, ...]}}
Règles impératives :
- "debut" = les 5 à 10 PREMIERS MOTS du passage où la section commence, COPIÉS À L'IDENTIQUE
depuis l'extrait (même orthographe, même ponctuation, même langue). JAMAIS un résumé.
- La PREMIÈRE entrée commence aux tout premiers mots de l'extrait (même si le contenu
poursuit une section entamée avant cet extrait).
- Les entrées suivent l'ordre du texte. Vise des sections LARGES (un thème), pas un titre
par paragraphe : un extrait contient typiquement 1 à 6 sections.
- Titres : EN PRIORITÉ parmi :
{canonical}
sinon un titre court et clair en {language_name}.
- Pages de garde, sommaires, crédits : n'en fais pas des sections. Si l'extrait n'est que ça,
renvoie {{"sections": []}}."""

View File

@@ -0,0 +1,134 @@
"""Prompts des notebooks (atelier RAG) : chat ancré (cf. notebook_chat.py) et
analyse approfondie map-reduce (cf. notebook_deep.py).
CHAT_SYSTEM attend `.format(context_block=..., sources_block=..., language_name=...)`.
REDUCE_SYSTEM attend `.format(context_block=..., notes_block=..., language_name=...)`.
MAP_PROMPT attend `.format(no_match=..., question=..., excerpt=...)`.
SUMMARY_PROMPT attend `.format(excerpt=...)`.
"""
# --- Chat ancré (RAG) --------------------------------------------------------
CHAT_SYSTEM = """Tu es un assistant de jeu de rôle qui aide à ADAPTER une source (PDF) à la CAMPAGNE de l'utilisateur.
Tu disposes de DEUX connaissances, toutes deux ci-dessous :
1) LA CAMPAGNE de l'utilisateur (sa structure arcs/chapitres/scènes, ses PNJ, son univers) ;
2) LA SOURCE (extraits pertinents du PDF).
Règles :
- Pour une question sur SA CAMPAGNE (ex. « mon chapitre 3 », « mes PNJ »), appuie-toi sur la section CAMPAGNE.
- Pour une question sur le livre, appuie-toi sur les EXTRAITS DE LA SOURCE.
- CROISE les deux pour proposer des adaptations cohérentes avec sa campagne existante.
- N'invente pas ce qui ne figure ni dans la campagne ni dans la source ; si tu ne sais pas, dis-le.
- Quand un extrait porte un numéro de page (« (p. 12) »), cite-le (« d'après la p. 12 »).
{context_block}
--- EXTRAITS PERTINENTS DE LA SOURCE ---
{sources_block}
--- FIN DES EXTRAITS ---
PROPOSITIONS D'INTÉGRATION (IMPORTANT) :
Quand l'utilisateur veut CRÉER ou ADAPTER un élément concret pour sa campagne (un PNJ,
une scène, un chapitre, une quête, un arc, une table aléatoire), termine ta réponse par
un ou plusieurs BLOCS D'ACTION — un objet JSON par bloc, dans une clôture
```loremind-action. L'interface les transformera en boutons « Créer dans la campagne ».
Si l'utilisateur demande PLUSIEURS éléments (« propose-moi 3 quêtes »), produis UN bloc
par élément. N'en mets pas si l'utilisateur pose une simple question.
VOCABULAIRE DE LA CAMPAGNE : une « quête » n'est PAS un type à part — c'est un CHAPITRE
rangé dans un arc de type HUB (quêtes parallèles, sans ordre imposé), tandis qu'un arc
LINEAR contient des chapitres joués en séquence. Donc :
- demande de QUÊTE → action "chapter" (l'utilisateur la placera dans son arc HUB) ;
s'il n'a aucun arc HUB dans sa campagne, propose AUSSI une action "arc" avec
"arcType": "HUB" pour les accueillir.
- demande de CHAPITRE → action "chapter" (destinée plutôt à un arc LINEAR).
RÈGLE CLÉ : remplis TOUS les champs pour lesquels tu as de la matière — pas seulement
le résumé ou les notes MJ. Chaque champ rempli atterrit au bon endroit de la fiche ;
un champ laissé vide est une fiche que l'utilisateur devra compléter à la main. Vise
2 à 5 phrases concrètes par champ narratif, tirées de la source et de la campagne.
Omets simplement un champ si tu n'as rien de précis à y mettre. Formats acceptés :
```loremind-action
{{"type": "npc", "name": "Nom",
"description": "Résumé du PNJ (rôle, apparence, motivation).",
"values": {{"<champ de la fiche PNJ>": "contenu", "<autre champ>": "contenu"}}}}
```
(`values` : utilise comme clés les CHAMPS DE LA FICHE PNJ listés dans le contexte
campagne s'ils y figurent — ex. "Histoire", "Apparence" — sinon omets `values`.)
```loremind-action
{{"type": "scene", "name": "Nom",
"description": "Résumé court de la scène.",
"location": "Lieu précis", "timing": "Quand elle survient",
"atmosphere": "Ambiance sensorielle (sons, odeurs, lumière…)",
"playerNarration": "Texte d'ambiance À LIRE AUX JOUEURS, immersif, à la 2e personne.",
"gmSecretNotes": "Secrets, vérités cachées, notes pour le MJ uniquement.",
"choicesConsequences": "Choix offerts aux joueurs et leurs conséquences.",
"combatDifficulty": "Difficulté du combat éventuel", "enemies": "Ennemis présents (effectifs, tactiques)"}}
```
```loremind-action
{{"type": "chapter", "name": "Nom",
"description": "Résumé du chapitre (ou de la quête).",
"playerObjectives": "Objectifs tels que les joueurs les perçoivent.",
"narrativeStakes": "Enjeux narratifs (ce qui se joue vraiment).",
"gmNotes": "Notes MJ : fils à tirer, points d'attention."}}
```
```loremind-action
{{"type": "arc", "name": "Nom", "description": "Résumé", "arcType": "LINEAR",
"themes": "Thèmes de l'arc", "stakes": "Enjeux",
"rewards": "Récompenses attendues", "resolution": "Issues possibles",
"gmNotes": "Notes MJ."}}
```
(`arcType` : "LINEAR" pour des chapitres en séquence, "HUB" pour un recueil de
quêtes parallèles.)
```loremind-action
{{"type": "table", "name": "Nom", "diceFormula": "1d8", "entries": [{{"minRoll":1,"maxRoll":4,"label":"...","detail":"..."}}]}}
```
Réponds en {language_name}, de façon utile et concise. Mets le texte explicatif AVANT les blocs d'action."""
# --- Analyse approfondie (map-reduce) ----------------------------------------
SUMMARY_PROMPT = """Résume l'EXTRAIT ci-dessous en 4 à 8 puces factuelles : lieux, PNJ et
créatures nommés, objets notables, évènements, règles particulières. Pas d'analyse, pas
d'introduction — uniquement les puces, pour servir d'index de recherche.
--- EXTRAIT ---
{excerpt}
--- FIN EXTRAIT ---
Résumé :"""
MAP_PROMPT = """Voici un EXTRAIT d'un document. Extrais UNIQUEMENT les informations
pertinentes pour répondre à la question ci-dessous. Conserve les détails utiles et
indique les numéros de page (format « p. X »). Si l'extrait ne contient RIEN de
pertinent, réponds EXACTEMENT « {no_match} » et rien d'autre.
QUESTION : {question}
--- EXTRAIT ---
{excerpt}
--- FIN EXTRAIT ---
Informations pertinentes (ou « {no_match} ») :"""
REDUCE_SYSTEM = """Tu es l'assistant-MJ d'un jeu de rôle. Tu réponds à la demande du MJ en
t'appuyant sur TROIS sources : (1) des NOTES extraites de l'ENSEMBLE du document source (vue
complète — mais POSSIBLEMENT VIDE si rien d'utile n'y figure), (2) le contexte de sa CAMPAGNE,
(3) la conversation ci-dessous.
- Si les notes contiennent des éléments utiles : exploite-les et CITE les pages (« p. X »).
- Si les notes sont VIDES ou pauvres (cas fréquent d'une demande CRÉATIVE portant sur des
éléments INVENTÉS par le MJ) : ne te bloque surtout PAS. Aide-le quand même en t'appuyant
sur sa CAMPAGNE, la CONVERSATION et ta connaissance du genre — propose des adaptations
concrètes (arcs, chapitres, scènes, PNJ), structurées et jouables.
- Sois concret et utile. N'affirme rien de FAUX sur le contenu du document.
{context_block}
--- NOTES EXTRAITES DE TOUT LE DOCUMENT ---
{notes_block}
--- FIN DES NOTES ---
Réponds en {language_name}."""

View File

@@ -0,0 +1,20 @@
"""Prompt de réécriture en question autonome (cf. query_rewrite.py).
Attend `.format(conversation=...)`.
"""
REWRITE_PROMPT = """Voici la fin d'une conversation entre un Maître de Jeu et son assistant.
Réécris le DERNIER message de l'utilisateur en une question AUTONOME et complète :
remplace les pronoms et références implicites (« il », « ses », « ce lieu », « et pour
les autres ? ») par ce qu'ils désignent dans la conversation.
Règles :
- Réponds UNIQUEMENT par la question réécrite, sans guillemets ni préfixe.
- Conserve la langue et l'intention d'origine. N'ajoute RIEN qui n'est pas demandé.
- Si le dernier message est déjà autonome, recopie-le tel quel.
--- CONVERSATION ---
{conversation}
--- FIN ---
Question autonome :"""

View File

@@ -0,0 +1,14 @@
"""Prompt de reranking LLM des passages RAG (cf. rerank.py).
Attend `.format(question=..., passages=..., count=...)`.
"""
RERANK_PROMPT = """Tu évalues la PERTINENCE d'extraits d'un document pour répondre à une question.
Note chaque extrait de 0 (sans rapport) à 10 (répond directement), indépendamment des autres.
QUESTION : {question}
{passages}
Réponds UNIQUEMENT par un objet JSON : {{"scores": [note_extrait_1, note_extrait_2, ...]}}
Le tableau doit contenir EXACTEMENT {count} notes, dans l'ordre des extraits."""

View File

@@ -0,0 +1,60 @@
"""Prompts des outils de table (tables aléatoires, improvisation, catalogues).
Ces prompts étaient auparavant construits en ligne dans le router `tables.py` ;
isolés ici pour garder la frontière HTTP fine. Le router calcule les plages de
dés et passe les champs bruts ; ces fonctions façonnent le texte.
"""
from app.core.language import language_name
def random_table_prompt(description: str, dice_formula: str, lo: int, hi: int,
context: str, language: str) -> str:
"""Prompt de génération d'une table aléatoire couvrant lo..hi."""
context_block = f"\nContexte de la campagne :\n{context.strip()}\n" if context.strip() else ""
return (
"Tu es un assistant de jeu de rôle. Génère une TABLE ALÉATOIRE évocatrice.\n"
f"Dé : {dice_formula} (résultats possibles de {lo} à {hi}).\n"
f"Sujet : {description.strip()}\n"
f"{context_block}\n"
"Règles IMPÉRATIVES :\n"
"- Réponds UNIQUEMENT par un objet JSON valide, sans texte autour.\n"
'- Format : {"name": "...", "description": "...", "entries": '
'[{"min_roll": N, "max_roll": M, "label": "résultat court", "detail": "1-2 phrases"}]}\n'
f"- Les plages (min_roll..max_roll) doivent COUVRIR EXACTEMENT {lo}..{hi}, "
"sans trou ni chevauchement, dans l'ordre croissant.\n"
"- Des résultats variés, cohérents avec le sujet (et le contexte s'il est fourni).\n"
f"- En {language_name(language)}. 'label' = résultat bref ; 'detail' = description/effet concret.\n"
"Renvoie maintenant le JSON."
)
def improvise_roll_prompt(table_name: str, result_label: str, result_detail: str,
context: str, language: str) -> str:
"""Prompt de narration brodée sur un résultat tiré."""
detail = f" ({result_detail.strip()})" if result_detail.strip() else ""
context_block = f"\nContexte : {context.strip()}" if context.strip() else ""
return (
"Tu es le Maître du Jeu. Les joueurs viennent de tirer sur la table "
f"« {table_name.strip()} » et ont obtenu : « {result_label.strip()} »{detail}."
f"{context_block}\n\n"
"Décris en 2-3 phrases vivantes et immédiates ce qui se passe, pour lancer la scène. "
f"Pas de méta, pas d'options : juste la narration, en {language_name(language)}."
)
def item_catalog_prompt(description: str, context: str, language: str) -> str:
"""Prompt de génération d'un catalogue d'objets (boutique, butin…)."""
context_block = f"\nContexte de la campagne :\n{context.strip()}\n" if context.strip() else ""
return (
"Tu es un assistant de jeu de rôle. Génère un CATALOGUE D'OBJETS (boutique, butin, trésor…).\n"
f"Sujet : {description.strip()}\n"
f"{context_block}\n"
"Règles IMPÉRATIVES :\n"
"- Réponds UNIQUEMENT par un objet JSON valide, sans texte autour.\n"
'- Format : {"name": "...", "description": "...", "items": '
'[{"name": "Objet", "price": "ex. 50 po", "category": "ex. Armes", "description": "effet/détails"}]}\n'
"- Des objets variés et cohérents avec le sujet (et le contexte s'il est fourni).\n"
"- 'price' = prix court dans la monnaie du jeu ; 'category' = regroupement (Armes, Potions…) ; "
f"'description' = effet/détails en une phrase. En {language_name(language)}.\n"
"Renvoie maintenant le JSON."
)

View File

@@ -0,0 +1,51 @@
"""Réécriture de la question courante en question AUTONOME (chat des ateliers).
Problème : le retrieval (embedding) et la phase MAP de l'analyse approfondie ne
voient que le DERNIER message. Une relance comme « et ses faiblesses ? » ne
contient pas le sujet (Strahd) → recherche aveugle. La parade standard
(conversational query rewriting) : un appel LLM léger condense la conversation
en une question autonome, utilisée UNIQUEMENT pour la recherche — la réponse
finale, elle, voit toujours l'historique complet.
"""
from __future__ import annotations
import logging
from app.application.prompts import query_rewrite as prompts
from app.domain.models import ChatMessage
logger = logging.getLogger(__name__)
# Nombre de messages récents fournis au réécrivain (assez pour résoudre les
# pronoms, pas plus — la latence de cet appel doit rester négligeable).
_MAX_HISTORY = 6
# Garde-fou : une « question » réécrite anormalement longue est suspecte (le
# modèle a divagué) → on retombe sur la question brute.
_MAX_REWRITE_CHARS = 400
async def standalone_question(llm, messages: list[ChatMessage]) -> str:
"""Condense `messages` en une question autonome pour la RECHERCHE.
Best-effort : premier message de la conversation, échec LLM ou réponse
suspecte → on renvoie simplement la dernière question brute (comportement
historique). `llm` doit exposer `generate()` (duck typing des adapters).
"""
last_user = next((m.content for m in reversed(messages) if m.role == "user"), "")
user_turns = sum(1 for m in messages if m.role == "user" and m.content.strip())
if user_turns <= 1 or not last_user.strip():
return last_user # pas d'historique à résoudre → appel LLM inutile
recent = [m for m in messages if m.content.strip()][-_MAX_HISTORY:]
conversation = "\n".join(f"{m.role.upper()}: {m.content.strip()}" for m in recent)
try:
raw = await llm.generate(
prompts.REWRITE_PROMPT.format(conversation=conversation), temperature=0.0)
except Exception as exc: # noqa: BLE001 — la recherche dégradée vaut mieux que pas de réponse
logger.warning("Réécriture de question ignorée (échec LLM) : %s", exc)
return last_user
rewritten = (raw or "").strip().strip('"').strip()
if not rewritten or len(rewritten) > _MAX_REWRITE_CHARS:
return last_user
return rewritten

View File

@@ -0,0 +1,64 @@
"""Reranking LLM des passages RAG (chat des ateliers).
Le cosinus classe par similarité de SURFACE ; sur les questions ambiguës, des
passages proches lexicalement mais inutiles passent devant l'extrait qui répond
vraiment. Le reranking récupère un POOL élargi (ex. 3× top_k), fait noter la
pertinence de chaque extrait par le LLM en UN appel, et garde les top_k mieux
notés. Coût : ~1 appel LLM avant le premier token — opt-in via RAG_RERANK.
"""
from __future__ import annotations
import logging
from app.application.llm_json import load_json_object
from app.application.prompts import rerank as prompts
logger = logging.getLogger(__name__)
# Taille du pool élargi : multiple du top_k demandé, plafonné (le prompt de
# notation doit rester raisonnable même avec rag_top_k élevé).
POOL_FACTOR = 3
POOL_MAX = 24
# Un extrait long n'a pas besoin d'être noté en entier : tronquer borne le
# prompt sans changer le jugement de pertinence.
_EXCERPT_CHARS = 600
def pool_size(top_k: int) -> int:
"""Taille du pool à récupérer avant reranking."""
return min(max(top_k * POOL_FACTOR, top_k), POOL_MAX)
async def rerank(llm, question: str, passages: list[dict], top_k: int) -> list[dict]:
"""Renvoie les `top_k` passages les mieux notés par le LLM (tri stable :
à note égale, l'ordre cosinus d'origine est préservé).
BEST-EFFORT : échec LLM, JSON invalide ou nombre de notes incohérent →
on renvoie simplement les `top_k` premiers du classement cosinus.
"""
if len(passages) <= top_k:
return passages
numbered = "\n\n".join(
f"--- EXTRAIT {i + 1} ---\n{(p.get('text') or '')[:_EXCERPT_CHARS]}"
for i, p in enumerate(passages)
)
prompt = prompts.RERANK_PROMPT.format(
question=question, passages=numbered, count=len(passages))
try:
raw = await llm.generate(prompt, temperature=0.0)
except Exception as exc: # noqa: BLE001 — un chat dégradé vaut mieux que pas de chat
logger.warning("Reranking ignoré (échec LLM) : %s", exc)
return passages[:top_k]
parsed, _ = load_json_object(raw)
scores = parsed.get("scores") if isinstance(parsed, dict) else None
if not isinstance(scores, list) or len(scores) != len(passages):
logger.warning("Reranking ignoré (notes inexploitables).")
return passages[:top_k]
try:
scored = [(float(s), i) for i, s in enumerate(scores)]
except (TypeError, ValueError):
logger.warning("Reranking ignoré (notes non numériques).")
return passages[:top_k]
order = sorted(range(len(passages)), key=lambda i: (-scored[i][0], i))
return [passages[i] for i in order[:top_k]]

View File

@@ -0,0 +1,67 @@
"""Heartbeats pour garder un flux SSE 'vivant' pendant une coroutine longue.
Problème résolu : pendant un appel LLM lent (import sur provider gratuit), le
Brain ne produit AUCUN évènement SSE. Le Core (WebClient) ne 'voit aucun item'
et coupe la connexion sur timeout d'inactivité :
ReactiveException: Did not observe any item or terminal signal within Nms
C'est le piège classique du SSE long. La parade standard = envoyer un keep-alive
périodique. `with_heartbeat` exécute une coroutine en émettant un évènement
'heartbeat' toutes les `interval` secondes tant qu'elle tourne, puis son résultat
('result', valeur). Le Core remet son chrono à zéro sur n'importe quel évènement
reçu (même inconnu) → plus de coupure, quelle que soit la lenteur du modèle.
"""
from __future__ import annotations
import asyncio
from typing import Any, AsyncIterator, Awaitable
# Bien sous le timeout d'inactivité du Core (600s) ET de tout proxy (nginx ~60s).
HEARTBEAT_INTERVAL_SECONDS = 15.0
async def with_heartbeat(
coro: Awaitable[Any],
*,
interval: float = HEARTBEAT_INTERVAL_SECONDS,
status_queue: "asyncio.Queue | None" = None,
) -> AsyncIterator[tuple[str, Any]]:
"""Exécute `coro` en émettant ('heartbeat', None) toutes les `interval`s tant
qu'elle n'est pas terminée, puis ('result', valeur).
Si `status_queue` est fournie, les messages qui y sont publiés pendant
l'exécution (cf. import_status.notify_status : retry LLM, re-découpage…)
sont émis AU FIL DE L'EAU sous forme ('status', message) — c'est ce qui
permet à l'UI d'expliquer une attente au lieu d'une barre figée.
L'exception éventuelle de `coro` est propagée (re-levée par `task.result()`),
donc l'appelant peut l'attraper normalement. Si l'itération est abandonnée
(client déconnecté), la tâche sous-jacente est annulée.
"""
task: asyncio.Task = asyncio.ensure_future(coro)
getter: asyncio.Task | None = None
try:
while not task.done():
waiters: set[asyncio.Task] = {task}
if status_queue is not None and getter is None:
getter = asyncio.ensure_future(status_queue.get())
if getter is not None:
waiters.add(getter)
done, _ = await asyncio.wait(
waiters, timeout=interval, return_when=asyncio.FIRST_COMPLETED)
if getter is not None and getter in done:
yield ("status", getter.result())
getter = None # un nouveau get() sera créé au tour suivant
if not done:
yield ("heartbeat", None)
# Vide les statuts restés en file (publiés juste avant la fin de la tâche).
if status_queue is not None:
while not status_queue.empty():
yield ("status", status_queue.get_nowait())
yield ("result", task.result())
finally:
if getter is not None and not getter.done():
getter.cancel()
if not task.done():
task.cancel()

View File

@@ -25,12 +25,16 @@ class Settings(BaseSettings):
extra="ignore",
)
# Provider LLM actif. "ollama" = local ; "onemin" = 1min.ai (etage 2).
llm_provider: Literal["ollama", "onemin"] = "ollama"
# Provider LLM actif. "ollama" = local ; "onemin" = 1min.ai ;
# "openrouter" = OpenRouter ; "mistral" = Mistral ; "gemini" = Google Gemini.
llm_provider: Literal["ollama", "onemin", "openrouter", "mistral", "gemini"] = "ollama"
ollama_base_url: str = "http://localhost:11434"
llm_model: str = "gemma4:26b"
llm_timeout_seconds: int = 120
# Timeout HTTP des appels au LLM. Les imports/adaptations PDF génèrent de gros
# blocs (surtout avec l'extraction riche) → 120s était trop court. Surchargeable
# depuis l'UI (Paramètres) si un import lourd dépasse encore.
llm_timeout_seconds: int = 300
# Fenêtre de contexte (num_ctx Ollama). Défaut Ollama = 2048, trop étroit
# dès que le Structural Context du Lore dépasse ~10 pages (b9). On monte
@@ -44,6 +48,74 @@ class Settings(BaseSettings):
onemin_api_key: str = ""
onemin_model: str = "gpt-4o-mini"
# OpenRouter (OpenAI-compatible). Cle + modele modifiables depuis l'UI.
# Defaut = routeur `openrouter/free` : choisit un modele GRATUIT (0 credit).
# Pour un modele precis gratuit : id finissant par `:free`.
openrouter_api_key: str = ""
openrouter_model: str = "openrouter/free"
# Mistral (La Plateforme, OpenAI-compatible). Cle + modele modifiables depuis
# l'UI. Tier gratuit « Experiment » sur console.mistral.ai (sans CB). Defaut =
# mistral-large-latest (128k contexte, bon en francais et en JSON fidele).
mistral_api_key: str = ""
mistral_model: str = "mistral-large-latest"
# Google Gemini (endpoint OpenAI-compatible). Cle gratuite sur
# aistudio.google.com (sans CB). Defaut = gemini-2.0-flash : ~1M de contexte
# (un livre tient en 1-2 appels), rapide, fidele, quota gratuit genereux.
gemini_api_key: str = ""
gemini_model: str = "gemini-2.0-flash"
# Embeddings (RAG des notebooks/ateliers). Modele SEPARE du chat.
# "ollama" = local (gratuit, illimite, ideal pour indexer un livre = bcp
# d'appels) ; "mistral" = cloud EU (mistral-embed, soumis au rate limit).
embedding_provider: Literal["ollama", "mistral"] = "ollama"
ollama_embedding_model: str = "nomic-embed-text"
mistral_embedding_model: str = "mistral-embed"
# Au démarrage, si le provider d'embeddings est Ollama et que le modèle n'est
# pas présent, le Brain le télécharge automatiquement (en arrière-plan) → le RAG
# marche "out of the box" pour un nouvel utilisateur. Désactivable (connexion
# limitée, gestion manuelle des modèles).
auto_pull_embedding_model: bool = True
# Nombre d'extraits récupérés par question dans le chat des ateliers (RAG).
# Plus haut = plus de couverture pour les questions larges (« liste les… »),
# mais prompt plus long. 8 par défaut (montable jusqu'à ~20 sur grand contexte).
rag_top_k: int = 8
# Analyse approfondie : pré-filtrage des lots via un index de résumés
# (construit une fois par source, cache disque). Les questions ciblées ne
# relisent que les lots plausiblement pertinents (3-5x moins d'appels) ;
# False = relire TOUT le document à chaque question (exhaustivité maximale).
deep_summary_filter: bool = True
# Reranking LLM du chat atelier : recupere un pool elargi (3x top_k, max 24)
# puis fait NOTER la pertinence de chaque extrait par le LLM avant d'injecter
# les top_k meilleurs. Meilleure precision sur les questions ambigues, MAIS
# +1 appel LLM avant le premier token (quelques secondes sur un petit modele
# local). Desactive par defaut ; recommande avec un provider cloud rapide.
rag_rerank: bool = False
# Cosinus minimal pour qu'un extrait soit injecté dans le prompt du chat
# atelier : en dessous, l'extrait n'a aucun rapport avec la question → mieux
# vaut moins d'extraits que du bruit. Défaut conservateur (0.30) : les paires
# pertinentes scorent typiquement 0.6+ avec nomic-embed-text/mistral-embed,
# les hors-sujet 0.2-0.4. Montable à ~0.4 si trop de bruit, 0 = désactivé.
rag_min_score: float = 0.30
# Nombre d'appels LLM MAP menes EN PARALLELE (import de campagne, analyse
# approfondie). 3 = bon defaut cloud (divise le temps d'un gros livre par ~3).
# Ollama local sequence les requetes de toute facon (pas de gain, pas de mal).
# Baisser a 1 si un provider gratuit rate-limite agressivement.
llm_map_concurrency: int = 3
# Taille cible d'un morceau (en tokens) pour l'import de PDF (regles/campagne).
# Plus c'est gros, moins il y a de morceaux => moins de fragmentation et un
# import plus rapide, MAIS il faut que ca tienne dans la fenetre du modele.
# Defaut prudent (compatible Ollama num_ctx 16384). Sur un modele a grand
# contexte (ex: GPT-5 mini, 400k), monter a ~100000 traite un livre en 1 passe.
import_chunk_tokens: int = 10000
# Secret partage entre le Core Spring et le Brain. Le Brain n'accepte une
# requete que si l'entete X-Internal-Secret correspond. Volontairement
# non-surchargeable via settings_store (securite critique, .env-only).

View File

@@ -0,0 +1,61 @@
"""Langue de sortie de l'IA, pilotée par l'utilisateur (et non plus figée en FR).
Le Core relaie la langue choisie dans l'UI via l'entête HTTP `X-User-Language`
(`fr`/`en`). Ce module centralise :
- la normalisation du code reçu (tolérante : `en-US`, `EN`, un `Accept-Language`
brut… → `en`) avec repli sur le français ;
- la fabrique de la directive de langue injectée dans les prompts ;
- la dépendance FastAPI qui lit l'entête côté router.
Ajouter une langue = une entrée dans `NAMES`. Aucun autre branchement n'est requis.
"""
from typing import Annotated
from fastapi import Header
# Nom (en français, langue de travail des prompts) de chaque langue supportée.
# La clé est le code court ISO 639-1 utilisé par l'UI (cf. LanguageService Angular).
NAMES: dict[str, str] = {
"fr": "français",
"en": "anglais",
}
DEFAULT = "fr"
def normalize(raw: str | None) -> str:
"""Réduit un code/entête langue arbitraire à un code supporté (`fr`/`en`).
Tolère les variantes régionales (`en-GB`), la casse, et un `Accept-Language`
complet (`fr-FR,fr;q=0.9,en;q=0.8`) dont on ne garde que la 1re préférence.
Repli systématique sur `DEFAULT` si rien ne matche.
"""
if not raw:
return DEFAULT
# 1re préférence d'un éventuel Accept-Language, puis base avant le tiret régional.
primary = raw.split(",")[0].split(";")[0].strip().lower()
base = primary.split("-")[0]
return base if base in NAMES else DEFAULT
def language_name(lang: str) -> str:
"""Nom de la langue (pour insertion inline dans un prompt)."""
return NAMES.get(lang, NAMES[DEFAULT])
def instruction(lang: str) -> str:
"""Directive forte à injecter dans un prompt pour imposer la langue de sortie."""
return (
f"IMPORTANT : rédige l'INTÉGRALITÉ de ta réponse en {language_name(lang)}, "
"quelle que soit la langue du contexte ou des documents fournis."
)
def get_user_language(
x_user_language: Annotated[str | None, Header()] = None,
) -> str:
"""Dépendance FastAPI : langue de l'utilisateur lue depuis l'entête `X-User-Language`.
Absente (appel direct, vieux client) → français par défaut.
"""
return normalize(x_user_language)

View File

@@ -29,6 +29,18 @@ _ALLOWED_KEYS = frozenset({
"llm_num_ctx",
"onemin_api_key",
"onemin_model",
"openrouter_api_key",
"openrouter_model",
"mistral_api_key",
"mistral_model",
"gemini_api_key",
"gemini_model",
"embedding_provider",
"ollama_embedding_model",
"mistral_embedding_model",
"auto_pull_embedding_model",
"rag_top_k",
"import_chunk_tokens",
})

View File

@@ -122,9 +122,29 @@ class SceneBranchHint:
condition: str | None = None
@dataclass(frozen=True)
class RoomBranchHint:
"""Indice d'une sortie entre pièces (donjon). target_room_name déjà résolu côté Core."""
label: str
target_room_name: str
condition: str | None = None
@dataclass(frozen=True)
class RoomSummary:
"""Pièce d'un lieu explorable. Projection plate pour le prompt IA (pas de notes MJ)."""
name: str
floor: int | None = None
description: str | None = None
enemies: str | None = None
branches: list[RoomBranchHint] = field(default_factory=list)
@dataclass(frozen=True)
class SceneSummary:
"""Résumé d'une scène : nom + description courte + illustrations + branches."""
"""Résumé d'une scène : nom + description courte + illustrations + branches + pièces."""
name: str
description: str | None
@@ -133,6 +153,8 @@ class SceneSummary:
illustration_count: int = 0
# Connexions narratives sortantes (livre dont vous etes le heros).
branches: list[SceneBranchHint] = field(default_factory=list)
# Pièces du lieu explorable (vide = scène classique).
rooms: list[RoomSummary] = field(default_factory=list)
@dataclass(frozen=True)
@@ -170,6 +192,7 @@ class CampaignStructuralContext:
campaign_description: str | None
arcs: list[ArcSummary]
characters: list["CharacterSummary"] = field(default_factory=list)
npcs: list["NpcSummary"] = field(default_factory=list)
@dataclass(frozen=True)
@@ -185,6 +208,19 @@ class CharacterSummary:
snippet: str
@dataclass(frozen=True)
class NpcSummary:
"""Résumé d'un PNJ : symétrique à CharacterSummary.
Permet à l'IA de connaître les PNJ d'une campagne (nom + snippet) sans
injecter leurs fiches complètes. Évolution prévue : entity_type="npc"
pour focus sur la fiche complète.
"""
name: str
snippet: str
@dataclass(frozen=True)
class NarrativeEntityContext:
"""Contexte d'une entité narrative précise en cours d'édition.
@@ -215,3 +251,208 @@ class GameSystemContext:
system_name: str
system_description: str | None
sections: dict[str, str]
@dataclass(frozen=True)
class JournalEntrySummary:
"""Une entrée du journal d'une Session.
`source_session_name` n'est renseigné que pour les entrées issues de
sessions précédentes (option 3 : continuité narrative entre séances).
"""
type: str
content: str
occurred_at: str | None
source_session_name: str | None = None
@dataclass(frozen=True)
class QuestSummary:
"""Résumé d'une quête (Chapter dans un Arc HUB) pour le system prompt.
Volontairement sans notes MJ ni statut texte : c'est déjà classé côté Core
dans available_quests / in_progress_quests / locked_quest_titles.
"""
name: str
arc_name: str
description: str | None = None
@dataclass(frozen=True)
class SessionContext:
"""Contexte d'une Session de jeu en cours (Play Context).
Combine plusieurs niveaux :
- `entries` : journal COMPLET de la session courante (cappé ~80 entrées)
- `previous_events` : EVENTs marquants des sessions précédentes (continuité)
- `available_quests` / `in_progress_quests` : quêtes du Hub ouvertes
- `locked_quest_titles` : titres seuls des quêtes verrouillées (anti-spoiler)
- `active_flags` : noms des flags de campagne actuellement à true
"""
session_name: str
active: bool
started_at: str | None
entries: list[JournalEntrySummary]
previous_events: list[JournalEntrySummary]
available_quests: list[QuestSummary] = field(default_factory=list)
in_progress_quests: list[QuestSummary] = field(default_factory=list)
locked_quest_titles: list[str] = field(default_factory=list)
active_flags: list[str] = field(default_factory=list)
# ─────────────────────── Import de PDF (règles → GameSystem) ───────────────────────
@dataclass(frozen=True)
class ExtractedPage:
"""Texte extrait d'UNE page de PDF, avec la trace de la méthode utilisée.
`used_ocr=True` signale que la page n'avait pas de couche texte exploitable
(born-digital absent) et a donc été rasterisée puis passée à l'OCR. Permet
au CLI/diagnostic de dire à l'utilisateur si son PDF est "texte" ou "scan".
"""
index: int # 0-based
text: str
used_ocr: bool
@dataclass(frozen=True)
class TocEntry:
"""Une entrée de la table des matières (bookmarks/outline) du PDF.
`level` : profondeur 1-based (1 = chapitre, 2 = section…). `page` : 1-based.
"""
level: int
title: str
page: int
@dataclass(frozen=True)
class ExtractedDocument:
"""Résultat brut de l'extraction d'un PDF : une entrée par page."""
pages: list[ExtractedPage]
# Table des matières (bookmarks PDF). Vide si le PDF n'en a pas — fréquent
# pour les scans ; les livres born-digital en ont presque toujours une.
toc: list[TocEntry] = field(default_factory=list)
@property
def page_count(self) -> int:
return len(self.pages)
@property
def ocr_page_count(self) -> int:
return sum(1 for p in self.pages if p.used_ocr)
@property
def full_text(self) -> str:
"""Concatène le texte de toutes les pages, séparées par un saut double."""
return "\n\n".join(p.text for p in self.pages if p.text.strip())
@dataclass(frozen=True)
class RulesImportResult:
"""Proposition structurée de règles : sections markdown indexées par titre.
`sections` = {titre H2 → contenu markdown}. C'est une PROPOSITION : rien
n'est persisté côté Core tant que l'utilisateur n'a pas validé/édité.
`page_count` / `ocr_page_count` remontent au diagnostic d'extraction.
"""
sections: dict[str, str]
page_count: int
ocr_page_count: int
def to_markdown(self) -> str:
"""Assemble les sections en un markdown monolithique (## titre + contenu).
Format aligné sur `GameSystem.rulesMarkdown` côté Core (découpé par H2).
"""
blocks = [f"## {title}\n\n{content.strip()}" for title, content in self.sections.items()]
return "\n\n".join(blocks).strip() + "\n"
# ─────────────────────── Import de PDF de campagne (arbre arc→chapitre→scène) ──────────────
@dataclass(frozen=True)
class RoomProposal:
"""Pièce d'un lieu explorable (donjon) proposée pour une scène."""
name: str
description: str
enemies: str = ""
loot: str = ""
@dataclass(frozen=True)
class SceneProposal:
"""Scène proposée. `rooms` non vide => donjon/lieu explorable.
On capture aussi, quand le livre les fournit, le texte d'encadré « à lire aux
joueurs » (`player_narration`) et les secrets/développement MJ (`gm_notes`).
"""
name: str
description: str
player_narration: str = ""
gm_notes: str = ""
rooms: list[RoomProposal] = field(default_factory=list)
@dataclass(frozen=True)
class ChapterProposal:
"""Chapitre proposé : nom + synopsis + ses scènes."""
name: str
description: str
scenes: list[SceneProposal] = field(default_factory=list)
@dataclass(frozen=True)
class ArcProposal:
"""Arc proposé : nom + synopsis + type (LINEAR/HUB) + ses chapitres."""
name: str
description: str
arc_type: str = "LINEAR"
chapters: list[ChapterProposal] = field(default_factory=list)
@dataclass(frozen=True)
class NpcImportProposal:
"""PNJ/créature notable détecté à l'import d'un PDF de campagne.
PNJ NOMMÉS et créatures uniques (boss) — pas les monstres génériques.
`description` = courte fiche (rôle, apparence, motivations, où on le croise).
"""
name: str
description: str = ""
@dataclass(frozen=True)
class CampaignImportResult:
"""Proposition d'arborescence narrative extraite d'un PDF de campagne.
PROPOSITION non persistée : l'UI laisse l'utilisateur réviser/éditer l'arbre
avant la création effective des arcs/chapitres/scènes côté Core.
"""
arcs: list[ArcProposal]
page_count: int
ocr_page_count: int
# PNJ/créatures notables détectés au fil des morceaux (proposition, à cocher
# dans l'écran de revue avant création).
npcs: list[NpcImportProposal] = field(default_factory=list)
def counts(self) -> tuple[int, int, int]:
"""(nb arcs, nb chapitres, nb scènes) — pour le diagnostic / la progression."""
chapters = sum(len(a.chapters) for a in self.arcs)
scenes = sum(len(c.scenes) for a in self.arcs for c in a.chapters)
return len(self.arcs), chapters, scenes

View File

@@ -7,7 +7,10 @@ En Python moderne on privilégie Protocol (PEP 544) sur ABC pour bénéficier
du duck typing structurel : toute classe qui possède les bonnes méthodes
satisfait le contrat, sans héritage explicite.
"""
from typing import AsyncIterator, Protocol
from typing import TYPE_CHECKING, AsyncIterator, Protocol
if TYPE_CHECKING:
from app.domain.models import ExtractedDocument
class LLMProvider(Protocol):
@@ -21,17 +24,20 @@ class LLMProvider(Protocol):
self,
prompt: str,
*,
output_format: str | None = None,
output_format: str | dict | None = None,
temperature: float | None = None,
) -> str:
"""Génère une réponse textuelle à partir d'un prompt donné.
Args:
prompt: le texte envoyé au modèle.
output_format: contrainte de format optionnelle. Exemple : "json"
pour forcer le modèle à renvoyer du JSON valide. Les
fournisseurs qui ne supportent pas une valeur donnée doivent
l'ignorer silencieusement ou la traduire au mieux.
output_format: contrainte de format optionnelle. "json" pour forcer
un JSON valide ; un dict = SCHÉMA JSON décrivant la structure
attendue (les fournisseurs qui supportent les sorties
structurées — ex. Ollama — contraignent la génération au schéma,
les autres retombent sur leur mode JSON natif). Les fournisseurs
qui ne supportent pas une valeur donnée doivent l'ignorer
silencieusement ou la traduire au mieux.
temperature: créativité du modèle, 0.0 (déterministe/factuel) à
1.0+ (très créatif, hallucine plus facilement). None =
valeur par défaut de l'adapter. Recommandation LoreMind :
@@ -78,9 +84,46 @@ class LLMChatProvider(Protocol):
...
class PdfTextExtractor(Protocol):
"""Port sortant — extrait le texte d'un PDF (born-digital ou scan).
L'implémentation décide de sa stratégie (couche texte directe, repli OCR
page par page…). Le domaine ne connaît ni PyMuPDF ni Tesseract.
"""
def extract(self, pdf_bytes: bytes) -> "ExtractedDocument":
"""Extrait le texte du PDF fourni sous forme d'octets.
Args:
pdf_bytes: contenu binaire du fichier PDF.
Returns:
ExtractedDocument : une entrée par page (texte + flag OCR).
Raises:
PdfExtractionError: si le PDF est illisible/corrompu.
"""
...
class PdfExtractionError(Exception):
"""Erreur du domaine : un PDF n'a pas pu être lu/extrait."""
class LLMProviderError(Exception):
"""Erreur du domaine signalant qu'un LLMProvider n'a pas pu générer.
Définie dans le domaine (pas dans l'infra) pour que les couches
supérieures puissent l'attraper sans connaître l'adapter concret.
"""
class LLMGenerationTimeout(LLMProviderError):
"""La génération a démarré mais n'a pas FINI dans le temps imparti.
Cas distinct d'un échec transitoire (file d'attente, 503) : le modèle
produisait des tokens mais trop lentement pour la taille de sortie demandée.
Réessayer à l'identique est inutile (même entrée → même lenteur) ; la bonne
réaction est de RÉDUIRE la sortie demandée (ex. import : re-découper le
morceau en deux moitiés).
"""

View File

@@ -0,0 +1,226 @@
"""Socle commun aux adapters LLM « OpenAI-compatible » (OpenRouter, Gemini,
Mistral) — ils exposent tous `POST {base}/chat/completions` en SSE avec le même
schéma de payload et de flux.
Cette classe de base porte la mécanique partagée (construction du payload, appel
HTTP streamé, parsing SSE, garde-fous de timeout au temps écoulé, traduction des
erreurs). Chaque adapter concret ne fournit plus que ses spécificités :
URL, en-têtes, support du mode JSON natif, messages d'erreur, lecture de la config.
`generate` one-shot passe lui aussi par le streaming (puis recollage) pour éviter
les coupures de passerelle sur les longues générations (cf. Cloudflare 524).
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import AsyncIterator
import httpx
from app.domain.models import ChatMessage
from app.domain.ports import LLMGenerationTimeout, LLMProviderError
logger = logging.getLogger(__name__)
# Délai max pour le PREMIER token de contenu. Un modèle « en file d'attente »
# n'envoie que des keep-alive (aucun contenu) → on échoue vite et clairement au
# lieu de pendre. Le timeout réseau d'httpx ne suffit pas : des keep-alive font
# « arriver des octets » et empêchent son read-timeout de se déclencher.
_FIRST_TOKEN_TIMEOUT_SECONDS = 120.0
class BaseOpenAICompatibleAdapter:
"""Base des adapters clients d'une API OpenAI-compatible (chat/completions SSE).
Satisfait par duck typing les ports LLMProvider et LLMChatProvider. Les
sous-classes définissent : ``_provider_label``, ``_api_url``,
``_supports_json_object`` (mode JSON natif), et surchargent au besoin
``_headers`` / ``_error_for_status`` / les messages de timeout.
"""
# Surchargés par les sous-classes.
_provider_label: str = "LLM"
_api_url: str = ""
_supports_json_object: bool = False
def __init__(self, api_key: str, model: str, timeout: int) -> None:
self._api_key = api_key
self._model = model
self._timeout = timeout
# --- Spécificités surchargeables ----------------------------------------
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
}
def _first_token_timeout_message(self) -> str:
return (
f"Erreur {self._provider_label} : aucun contenu produit en "
f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s — le modèle est probablement en "
"file d'attente / saturé. Réessayez plus tard ou choisissez un autre modèle."
)
def _generation_timeout_message(self) -> str:
return (
f"Erreur {self._provider_label} : génération non terminée en {self._timeout}s. "
"Réduisez la taille des morceaux d'import, augmentez le timeout, ou changez de modèle."
)
def _error_for_status(self, status_code: int, detail: str) -> LLMProviderError:
"""Erreur de domaine pour une réponse HTTP >= 400 (détail déjà lu)."""
return LLMProviderError(
f"Erreur {self._provider_label} (HTTP {status_code})"
+ (f" : {detail[:500]}" if detail else "")
)
# --- API publique (ports) -----------------------------------------------
async def generate(
self,
prompt: str,
*,
output_format: str | None = None,
temperature: float | None = None,
) -> str:
"""One-shot via streaming (puis recollage), avec garde-fous au temps écoulé."""
return await self._collect_with_timeouts(
[ChatMessage(role="user", content=prompt)], temperature, output_format
)
async def stream_chat(
self,
messages: list[ChatMessage],
*,
system_prompt: str | None = None,
temperature: float | None = None,
) -> AsyncIterator[str]:
async for token in self._stream(messages, system_prompt, temperature):
yield token
# --- Mécanique partagée -------------------------------------------------
async def _collect_with_timeouts(
self,
messages: list[ChatMessage],
temperature: float | None,
output_format: str | None,
) -> str:
"""Collecte le stream avec DEUX garde-fous au temps écoulé :
- 1er token borné (`_FIRST_TOKEN_TIMEOUT_SECONDS`) : détecte un modèle bloqué
en file d'attente (que des keep-alive, aucun contenu) → échec rapide ;
- ceiling global (`self._timeout`) : génération qui ne se termine jamais.
"""
async def _collect() -> str:
chunks: list[str] = []
agen = self._stream(messages, None, temperature, output_format)
try:
while True:
# Borne SEULEMENT l'attente du 1er token ; ensuite on laisse
# générer (le ceiling global couvre le reste).
first = _FIRST_TOKEN_TIMEOUT_SECONDS if not chunks else None
try:
token = await asyncio.wait_for(agen.__anext__(), timeout=first)
except StopAsyncIteration:
break
except asyncio.TimeoutError:
raise LLMProviderError(self._first_token_timeout_message())
chunks.append(token)
finally:
await agen.aclose()
return "".join(chunks)
try:
return await asyncio.wait_for(_collect(), timeout=self._timeout)
except asyncio.TimeoutError as exc:
raise LLMGenerationTimeout(self._generation_timeout_message()) from exc
def _build_body(
self,
messages: list[ChatMessage],
system_prompt: str | None,
temperature: float | None,
output_format: str | None,
) -> dict[str, object]:
payload_messages: list[dict[str, str]] = []
if system_prompt:
payload_messages.append({"role": "system", "content": system_prompt})
for m in messages:
payload_messages.append({"role": m.role, "content": m.content})
body: dict[str, object] = {
"model": self._model,
"messages": payload_messages,
"stream": True,
}
if temperature is not None:
body["temperature"] = temperature
# Mode JSON natif : supprime les fences ```json et le JSON invalide (retours
# à la ligne bruts), principale cause de morceaux d'import ignorés. Un SCHÉMA
# (dict) est traduit en json_object — suffisant, les grands modèles cloud
# respectent la structure demandée par le prompt. Désactivé pour les
# providers/modèles gratuits qui ne le supportent pas (réponse vide).
if self._supports_json_object and output_format is not None:
body["response_format"] = {"type": "json_object"}
return body
async def _stream(
self,
messages: list[ChatMessage],
system_prompt: str | None,
temperature: float | None,
output_format: str | None = None,
) -> AsyncIterator[str]:
body = self._build_body(messages, system_prompt, temperature, output_format)
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
async with client.stream(
"POST", self._api_url, headers=self._headers(), json=body
) as response:
if response.status_code >= 400:
# En streaming le corps n'est pas lu automatiquement : on le
# lit pour exposer le détail du provider (le 429 précise le
# type de quota, le 401 la clé invalide…), sinon on n'a que
# le code HTTP nu et le diagnostic est impossible.
detail = (await response.aread()).decode("utf-8", "replace").strip()
raise self._error_for_status(response.status_code, detail)
async for token in self._parse_sse(response):
yield token
except httpx.HTTPError as exc:
raise LLMProviderError(self._format_http_error(exc)) from exc
@staticmethod
async def _parse_sse(response: httpx.Response) -> AsyncIterator[str]:
"""SSE OpenAI : lignes `data: {json}`, fin sur `data: [DONE]`."""
async for line in response.aiter_lines():
if not line or not line.startswith("data:"):
continue # lignes vides ou commentaires keep-alive (`: ...`)
data = line[len("data:"):].strip()
if data == "[DONE]":
return
try:
obj = json.loads(data)
except json.JSONDecodeError:
continue
choices = obj.get("choices")
if not choices:
continue
delta = choices[0].get("delta") or {}
content = delta.get("content")
if content:
yield content
def _format_http_error(self, exc: httpx.HTTPError) -> str:
"""Message lisible (timeout, quota 429, crédits 402, modèle inconnu…)."""
if isinstance(exc, httpx.TimeoutException):
return (
f"Erreur {self._provider_label} : délai dépassé (timeout {self._timeout}s). "
"Le modèle a mis trop de temps — réduis la taille des morceaux d'import ou "
"augmente le timeout."
)
detail = str(exc) or exc.__class__.__name__
return f"Erreur {self._provider_label} ({exc.__class__.__name__}) : {detail}"

View File

@@ -0,0 +1,66 @@
"""Adapter Google Gemini — implémente les ports LLMProvider / LLMChatProvider.
Gemini expose un endpoint COMPATIBLE OpenAI
(POST {base}/openai/chat/completions, SSE) : client "OpenAI-compatible" qui hérite
de BaseOpenAICompatibleAdapter et ne fournit que ses spécificités (dont un message
dédié quand Google refuse la clé en 401/403).
Tier GRATUIT : clé API sur aistudio.google.com (sans CB). Atout majeur pour
l'extraction de PDF : un CONTEXTE de ~1M tokens → un livre entier tient en 1-2
appels. Modèle conseillé : `gemini-2.0-flash` (rapide, gros contexte, fidèle).
"""
from __future__ import annotations
from app.core.config import Settings
from app.domain.ports import LLMProviderError
from app.infrastructure.base_openai_adapter import (
_FIRST_TOKEN_TIMEOUT_SECONDS,
BaseOpenAICompatibleAdapter,
)
class GeminiLLMProvider(BaseOpenAICompatibleAdapter):
"""Adapter Gemini (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider."""
_provider_label = "Gemini"
_api_url = "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions"
_supports_json_object = True
def __init__(self, settings: Settings) -> None:
if not settings.gemini_api_key:
raise LLMProviderError(
"Clé API Gemini manquante. Configure-la depuis l'écran Paramètres "
"(clé gratuite sur aistudio.google.com)."
)
super().__init__(
settings.gemini_api_key, settings.gemini_model, settings.llm_timeout_seconds
)
def _headers(self) -> dict[str, str]:
return {**super()._headers(), "Accept": "application/json"}
def _first_token_timeout_message(self) -> str:
return (
f"Erreur Gemini : aucun contenu produit en "
f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s. Réessayez ou vérifiez "
"votre quota gratuit."
)
def _generation_timeout_message(self) -> str:
return (
f"Erreur Gemini : génération non terminée en {self._timeout}s. Réduisez la "
"taille des morceaux d'import ou augmentez le timeout."
)
def _error_for_status(self, status_code: int, detail: str) -> LLMProviderError:
# 401/403 = clé rejetée par GOOGLE (pas un problème LoreMind) : message
# actionnable plutôt que le JSON brut de l'API.
if status_code in (401, 403):
return LLMProviderError(
"Erreur Gemini : clé API refusée par Google "
f"(HTTP {status_code}). Vérifiez que la clé vient bien "
"de aistudio.google.com (« Get API key ») et qu'elle n'a pas de "
"restrictions (API ou adresse IP) dans la Google Cloud Console. "
f"Détail : {detail[:300]}"
)
return super()._error_for_status(status_code, detail)

View File

@@ -0,0 +1,48 @@
"""Adapter Mistral — implémente les ports LLMProvider / LLMChatProvider.
Mistral (La Plateforme) expose l'API OpenAI standard (POST {base}/chat/completions,
SSE) : client "OpenAI-compatible" qui hérite de BaseOpenAICompatibleAdapter et ne
fournit que ses spécificités.
Tier GRATUIT : compte sur console.mistral.ai (tier « Experiment »), clé API à
coller dans l'écran Paramètres. Modèles conseillés pour l'extraction : un grand
contexte fidèle comme `mistral-large-latest` (128k) ou `mistral-small-latest`.
Mode JSON natif : TOUS les modèles Mistral le supportent (`_supports_json_object`).
"""
from __future__ import annotations
from app.core.config import Settings
from app.domain.ports import LLMProviderError
from app.infrastructure.base_openai_adapter import (
_FIRST_TOKEN_TIMEOUT_SECONDS,
BaseOpenAICompatibleAdapter,
)
class MistralLLMProvider(BaseOpenAICompatibleAdapter):
"""Adapter Mistral (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider."""
_provider_label = "Mistral"
_api_url = "https://api.mistral.ai/v1/chat/completions"
_supports_json_object = True
def __init__(self, settings: Settings) -> None:
if not settings.mistral_api_key:
raise LLMProviderError(
"Clé API Mistral manquante. Configure-la depuis l'écran Paramètres."
)
super().__init__(
settings.mistral_api_key, settings.mistral_model, settings.llm_timeout_seconds
)
def _headers(self) -> dict[str, str]:
return {**super()._headers(), "Accept": "application/json"}
def _first_token_timeout_message(self) -> str:
return (
f"Erreur Mistral : aucun contenu produit en "
f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s — le modèle est probablement "
"en file d'attente (tier gratuit, 2 req/min). Réessayez plus tard ou "
"choisissez un modèle plus disponible."
)

View File

@@ -0,0 +1,59 @@
"""Adapter d'embeddings Mistral (cloud, EU) — POST /v1/embeddings.
Soumis au rate limit du tier gratuit : pour indexer un gros document on envoie
les textes par lots (et l'appelant peut espacer les appels si besoin).
"""
from __future__ import annotations
import httpx
from app.application.embeddings import EmbeddingError
from app.core.config import Settings
_API_URL = "https://api.mistral.ai/v1/embeddings"
# Lot raisonnable pour ne pas envoyer un payload géant d'un coup.
_BATCH_SIZE = 64
class MistralEmbeddingProvider:
"""Implémente EmbeddingProvider via l'API Mistral embeddings."""
def __init__(self, settings: Settings) -> None:
if not settings.mistral_api_key:
raise EmbeddingError(
"Clé API Mistral manquante (requise pour les embeddings Mistral). "
"Configure-la dans les Paramètres ou choisis Ollama pour les embeddings."
)
self._api_key = settings.mistral_api_key
self._model = settings.mistral_embedding_model
self._timeout = settings.llm_timeout_seconds
async def embed(self, texts: list[str], kind: str = "document") -> list[list[float]]:
# `kind` ignoré : mistral-embed n'utilise pas de préfixe de tâche.
if not texts:
return []
out: list[list[float]] = []
headers = {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
}
async with httpx.AsyncClient(timeout=self._timeout) as client:
for start in range(0, len(texts), _BATCH_SIZE):
batch = texts[start:start + _BATCH_SIZE]
try:
response = await client.post(
_API_URL, headers=headers, json={"model": self._model, "input": batch})
if response.status_code >= 400:
raise EmbeddingError(
f"Mistral embeddings HTTP {response.status_code} : "
f"{response.text.strip()[:300]}")
data = response.json()
except httpx.HTTPError as exc:
raise EmbeddingError(f"Erreur Mistral embeddings : {exc}") from exc
items = data.get("data")
if not isinstance(items, list) or len(items) != len(batch):
raise EmbeddingError("Réponse d'embeddings Mistral inattendue (taille incohérente).")
for item in items:
out.append([float(x) for x in item.get("embedding", [])])
return out

View File

@@ -5,13 +5,16 @@ Isole le reste de l'application des spécificités du protocole Ollama
demain, on écrit un nouvel adapter sans toucher au reste du code.
"""
import json
import logging
from typing import AsyncIterator
import httpx
from app.core.config import Settings
from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError
from app.domain.ports import LLMGenerationTimeout, LLMProviderError
logger = logging.getLogger(__name__)
class OllamaLLMProvider:
@@ -45,7 +48,7 @@ class OllamaLLMProvider:
self,
prompt: str,
*,
output_format: str | None = None,
output_format: str | dict | None = None,
temperature: float | None = None,
) -> str:
url = f"{self._base_url}/api/generate"
@@ -55,19 +58,65 @@ class OllamaLLMProvider:
"stream": False,
"options": self._build_options(temperature),
}
# "json" (mode JSON simple) ou un SCHÉMA JSON complet (structured outputs) :
# Ollama contraint alors la grammaire de génération au schéma — un petit
# modèle local ne PEUT physiquement plus produire d'objets imbriqués, de
# clés "thought" bavardes ou de texte hors JSON.
if output_format is not None:
payload["format"] = output_format
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
response = await client.post(url, json=payload)
response.raise_for_status()
if response.status_code >= 400:
body = response.text
try:
err_obj = json.loads(body)
err_msg = err_obj.get("error") or body
except json.JSONDecodeError:
err_msg = body
raise LLMProviderError(
f"Ollama HTTP {response.status_code} : {err_msg.strip()[:500]}"
)
except httpx.ConnectTimeout as exc:
# Serveur injoignable : erreur d'infrastructure, pas de lenteur.
raise LLMProviderError(
f"Erreur lors de l'appel à Ollama : {exc}"
) from exc
except httpx.TimeoutException as exc:
# `stream: False` → le read-timeout court jusqu'à la réponse COMPLÈTE,
# donc le dépasser = génération trop lente pour la sortie demandée
# (fréquent : modèle local modeste + gros morceau d'import à réécrire).
# Type dédié → pas de retry à l'identique ; l'import re-découpe le
# morceau en deux moitiés (sortie 2× plus courte) à la place.
raise LLMGenerationTimeout(
f"Erreur Ollama : génération non terminée en {self._timeout}s. Réduisez "
"la taille des morceaux d'import, augmentez le timeout, ou utilisez un "
"modèle plus rapide."
) from exc
except httpx.HTTPError as exc:
raise LLMProviderError(
f"Erreur lors de l'appel à Ollama : {exc}"
) from exc
return response.json()["response"]
data = response.json()
# Diagnostic crucial pour les imports : `done_reason` != "stop" signifie que
# la génération a été INTERROMPUE (fenêtre de contexte pleine, num_predict…)
# et non terminée par le modèle. Sans ce log, on ne voit qu'un JSON coupé
# en aval, sans la cause. `prompt_eval_count` révèle aussi la VRAIE taille
# du prompt en tokens du modèle (les morceaux sont mesurés en tokens
# cl100k, ~20-40% plus compacts que les tokenizers locaux).
done_reason = data.get("done_reason")
if done_reason and done_reason != "stop":
logger.warning(
"Ollama a interrompu la génération (done_reason=%s) : prompt=%s tokens, "
"sortie=%s tokens, num_ctx demandé=%s. Si prompt+sortie ≈ num_ctx, la "
"fenêtre de contexte est pleine : réduisez la taille des morceaux "
"d'import ou augmentez num_ctx (Paramètres).",
done_reason, data.get("prompt_eval_count"),
data.get("eval_count"), self._num_ctx,
)
return data["response"]
async def stream_chat(
self,
@@ -105,7 +154,20 @@ class OllamaLLMProvider:
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
async with client.stream("POST", url, json=payload) as response:
response.raise_for_status()
if response.status_code >= 400:
# On lit le body d'erreur pour le remonter a l'utilisateur,
# sinon on ne voit que "500 Internal Server Error" sans
# savoir POURQUOI Ollama refuse (modele introuvable, OOM,
# num_ctx trop grand pour la VRAM, etc.).
body = (await response.aread()).decode("utf-8", errors="replace")
try:
err_obj = json.loads(body)
err_msg = err_obj.get("error") or body
except json.JSONDecodeError:
err_msg = body
raise LLMProviderError(
f"Ollama HTTP {response.status_code} : {err_msg.strip()[:500]}"
)
async for line in response.aiter_lines():
if not line.strip():
continue

View File

@@ -0,0 +1,62 @@
"""Adapter d'embeddings Ollama (local) — endpoint /api/embed.
Gratuit et illimité (tourne sur la machine). Nécessite d'avoir pullé le modèle
d'embedding (ex. `ollama pull nomic-embed-text`).
"""
from __future__ import annotations
import httpx
from app.application.embeddings import EmbeddingError
from app.core.config import Settings
# Préfixes de tâche des modèles nomic-embed : le modèle est ENTRAÎNÉ avec
# (search_document pour le corpus, search_query pour la question). Sans eux,
# la pertinence du retrieval est mesurablement dégradée. Ne s'applique qu'aux
# modèles nomic — les autres (mxbai, bge…) ont leurs propres conventions ou
# aucune ; on reste neutre pour eux.
_NOMIC_PREFIXES = {"document": "search_document: ", "query": "search_query: "}
class OllamaEmbeddingProvider:
"""Implémente EmbeddingProvider via Ollama /api/embed (batch)."""
def __init__(self, settings: Settings) -> None:
self._base_url = settings.ollama_base_url
self._model = settings.ollama_embedding_model
self._timeout = settings.llm_timeout_seconds
def _prepare(self, texts: list[str], kind: str) -> list[str]:
"""Applique le préfixe de tâche si le modèle est de la famille nomic-embed.
NB : les sources indexées AVANT l'introduction des préfixes doivent être
ré-uploadées pour que documents et questions vivent dans le même espace.
"""
if "nomic-embed" not in self._model:
return texts
prefix = _NOMIC_PREFIXES.get(kind, _NOMIC_PREFIXES["document"])
return [prefix + t for t in texts]
async def embed(self, texts: list[str], kind: str = "document") -> list[list[float]]:
if not texts:
return []
url = f"{self._base_url}/api/embed"
payload = {"model": self._model, "input": self._prepare(texts, kind)}
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
response = await client.post(url, json=payload)
if response.status_code >= 400:
body = response.text
raise EmbeddingError(
f"Ollama embeddings HTTP {response.status_code} : {body.strip()[:300]}. "
f"Le modèle '{self._model}' est-il installé ? (ollama pull {self._model})"
)
data = response.json()
except httpx.HTTPError as exc:
raise EmbeddingError(f"Erreur Ollama embeddings : {exc}") from exc
vectors = data.get("embeddings")
if not isinstance(vectors, list) or len(vectors) != len(texts):
raise EmbeddingError("Réponse d'embeddings Ollama inattendue (taille incohérente).")
return [[float(x) for x in v] for v in vectors]

View File

@@ -0,0 +1,51 @@
"""Auto-installation du modèle d'embeddings Ollama au démarrage du Brain.
Adapter d'infrastructure : parle directement à l'API HTTP d'Ollama. Best-effort
(Ollama peut être absent / la connexion limitée) — n'empêche jamais le démarrage.
"""
from __future__ import annotations
import asyncio
import logging
import httpx
logger = logging.getLogger(__name__)
async def ensure_ollama_embedding_model(base_url: str, model: str) -> None:
"""Télécharge `model` sur le serveur Ollama s'il n'y est pas déjà.
Attend qu'Ollama soit joignable (ordre de démarrage des conteneurs), puis
vérifie la présence du modèle avant de le tirer.
"""
for attempt in range(10):
try:
async with httpx.AsyncClient(timeout=10) as client:
tags = await client.get(f"{base_url}/api/tags")
tags.raise_for_status()
names = [m.get("name", "") for m in tags.json().get("models", [])]
if any(n == model or n.startswith(model + ":") for n in names):
logger.info("Modèle d'embedding '%s' déjà présent.", model)
return
break # Ollama joignable, modèle absent → on tire (ci-dessous)
except httpx.HTTPError:
await asyncio.sleep(min(5 * (attempt + 1), 30))
else:
logger.warning(
"Ollama injoignable au démarrage — modèle d'embedding '%s' non auto-installé "
"(il sera tirable manuellement : ollama pull %s).", model, model)
return
logger.info("Téléchargement automatique du modèle d'embedding '%s'", model)
try:
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream("POST", f"{base_url}/api/pull", json={"name": model}) as resp:
resp.raise_for_status()
async for _line in resp.aiter_lines():
pass # on draine la progression NDJSON jusqu'à la fin
logger.info("Modèle d'embedding '%s' prêt.", model)
except httpx.HTTPError as exc:
logger.warning(
"Auto-installation du modèle d'embedding '%s' échouée : %s "
"(tirage manuel possible : ollama pull %s).", model, exc, model)

View File

@@ -14,6 +14,7 @@ avec des marqueurs de role lisibles pour le modele.
from __future__ import annotations
import json
import logging
from typing import AsyncIterator
import httpx
@@ -22,6 +23,8 @@ from app.core.config import Settings
from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError
logger = logging.getLogger(__name__)
_API_BASE = "https://api.1min.ai/api/chat-with-ai"
_PAYLOAD_TYPE = "UNIFY_CHAT_WITH_AI"
@@ -48,6 +51,18 @@ class OneMinAiLLMProvider:
"promptObject": {"prompt": prompt},
}
def _format_http_error(self, exc: httpx.HTTPError) -> str:
"""Message d'erreur lisible. Un timeout httpx a un str() vide → on le nomme."""
if isinstance(exc, httpx.TimeoutException):
return (
f"Erreur 1min.ai : délai dépassé (timeout {self._timeout}s). Le modèle a mis "
"trop de temps à répondre — typique d'un morceau d'import trop gros. "
"Réduisez « Taille des morceaux à l'import » (Paramètres → Import de PDF) "
"ou augmentez le timeout LLM."
)
detail = str(exc) or exc.__class__.__name__
return f"Erreur 1min.ai ({exc.__class__.__name__}) : {detail}"
async def generate(
self,
prompt: str,
@@ -55,18 +70,18 @@ class OneMinAiLLMProvider:
output_format: str | None = None, # 1min.ai ne supporte pas format=json
temperature: float | None = None, # idem, pas d'hyperparam expose ici
) -> str:
"""Appel one-shot : retourne la reponse complete sous forme de string."""
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
response = await client.post(
_API_BASE, headers=self._headers(), json=self._payload(prompt)
)
response.raise_for_status()
data = response.json()
except httpx.HTTPError as exc:
raise LLMProviderError(f"Erreur 1min.ai : {exc}") from exc
"""One-shot, mais via l'endpoint STREAMING (puis recollage).
return self._extract_result(data)
On NE passe PAS par l'endpoint non-streame `chat-with-ai` : sur les longues
generations (gros imports), la passerelle Cloudflare de 1min.ai coupe la
connexion au bout de ~100s et renvoie un HTTP 524. En streaming, des octets
circulent en continu => pas de 524, quelle que soit la duree. On accumule
tous les fragments pour reconstituer la reponse complete.
"""
chunks: list[str] = []
async for token in self._stream_prompt(prompt):
chunks.append(token)
return "".join(chunks)
async def stream_chat(
self,
@@ -75,17 +90,18 @@ class OneMinAiLLMProvider:
system_prompt: str | None = None,
temperature: float | None = None,
) -> AsyncIterator[str]:
"""Streame via SSE.
1min.ai expose deux evenements utiles :
- `event: content` → `data: {"content": "..."}`
- `event: done` → fin du stream
- `event: error` → erreur serveur
On yield le champ `content` au fil de l'arrivee.
"""
"""Streame une conversation : aplatit les messages puis delegue au coeur SSE."""
prompt = self._flatten_messages(messages, system_prompt)
url = f"{_API_BASE}?isStreaming=true"
async for token in self._stream_prompt(prompt):
yield token
async def _stream_prompt(self, prompt: str) -> AsyncIterator[str]:
"""Coeur du streaming SSE 1min.ai (`?isStreaming=true`) pour un prompt brut.
1min.ai expose : `event: content` → `data: {"content": "..."}`, `event: done`,
`event: error`. On yield le champ `content` au fil de l'arrivee.
"""
url = f"{_API_BASE}?isStreaming=true"
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
async with client.stream(
@@ -95,9 +111,7 @@ class OneMinAiLLMProvider:
async for token in self._parse_sse(response):
yield token
except httpx.HTTPError as exc:
raise LLMProviderError(
f"Erreur lors du streaming 1min.ai : {exc}"
) from exc
raise LLMProviderError(self._format_http_error(exc)) from exc
# --- Helpers ------------------------------------------------------------
@@ -146,12 +160,21 @@ class OneMinAiLLMProvider:
"""
record = payload.get("aiRecord") or {}
detail = record.get("aiRecordDetail") or {}
result = detail.get("resultObject") or []
if isinstance(result, list):
result = detail.get("resultObject")
if isinstance(result, list) and result:
return "".join(str(x) for x in result)
if isinstance(result, str):
if isinstance(result, str) and result:
return result
raise LLMProviderError("Reponse 1min.ai inattendue : resultObject absent.")
# Schema inattendu : on remonte un EXTRAIT du vrai payload pour diagnostiquer.
# Causes frequentes : credits/quota 1min.ai epuises, moderation, modele
# indisponible, ou reponse asynchrone (record cree mais resultat pas encore
# pret). Sans ce detail, l'erreur "resultObject absent" est aveugle.
snippet = json.dumps(payload, ensure_ascii=False)
if len(snippet) > 800:
snippet = snippet[:800] + ""
logger.warning("Reponse 1min.ai inattendue (resultObject absent) : %s", snippet)
raise LLMProviderError(f"Reponse 1min.ai inattendue (resultObject absent) : {snippet}")
@staticmethod
def _flatten_messages(

View File

@@ -0,0 +1,57 @@
"""Adapter OpenRouter — implémente les ports LLMProvider / LLMChatProvider.
OpenRouter expose l'API OpenAI standard (POST {base}/chat/completions, SSE), donc
cet adapter est un client "OpenAI-compatible" : il hérite de toute la mécanique de
BaseOpenAICompatibleAdapter et ne fournit que ses spécificités (URL, en-têtes
d'attribution, messages, lecture de config).
Modèles GRATUITS : utiliser un id finissant par `:free` (ex.
`meta-llama/llama-3.3-70b-instruct:free`) ou le routeur `openrouter/free` (défaut)
qui choisit automatiquement un modèle gratuit — aucun crédit consommé.
NB : on n'impose PAS `response_format=json_object` (`_supports_json_object=False`).
Beaucoup de modèles/providers GRATUITS ne le supportent pas et renvoient une
réponse VIDE. On laisse le modèle répondre librement ; l'extraction JSON en aval
(load_json_object + nettoyage du raisonnement) récupère le JSON dans la prose.
"""
from __future__ import annotations
from app.core.config import Settings
from app.domain.ports import LLMProviderError
from app.infrastructure.base_openai_adapter import (
_FIRST_TOKEN_TIMEOUT_SECONDS,
BaseOpenAICompatibleAdapter,
)
class OpenRouterLLMProvider(BaseOpenAICompatibleAdapter):
"""Adapter OpenRouter (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider."""
_provider_label = "OpenRouter"
_api_url = "https://openrouter.ai/api/v1/chat/completions"
_supports_json_object = False
def __init__(self, settings: Settings) -> None:
if not settings.openrouter_api_key:
raise LLMProviderError(
"Clé API OpenRouter manquante. Configure-la depuis l'écran Paramètres."
)
super().__init__(
settings.openrouter_api_key, settings.openrouter_model, settings.llm_timeout_seconds
)
def _headers(self) -> dict[str, str]:
return {
**super()._headers(),
# Attribution facultative (classement OpenRouter) — sans impact fonctionnel.
"HTTP-Referer": "https://loremind.app",
"X-Title": "LoreMind",
}
def _first_token_timeout_message(self) -> str:
return (
f"Erreur OpenRouter : aucun contenu produit en "
f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s — le modèle gratuit est "
"probablement en file d'attente / saturé. Réessayez plus tard ou "
"choisissez un autre modèle (1min.ai, ou payant)."
)

View File

@@ -0,0 +1,113 @@
"""Adapter d'extraction de texte PDF — implémente le port PdfTextExtractor.
Stratégie HYBRIDE auto :
1. On tente d'abord l'extraction de la couche texte (PyMuPDF). Les PDF
"born-digital" (livres de règles officiels type Nimble, faits dans
InDesign/Affinity : très graphiques mais avec une vraie couche texte)
passent par là → rapide, fidèle, AUCUN OCR.
2. Si une page ne rend (quasi) aucun texte → c'est probablement une image
(scan ou page 100% illustrée). On rasterise la page et on la passe à
Tesseract (OCR). Gère donc aussi les scans purs et les PDF mixtes.
Tesseract est un binaire SYSTÈME (installé dans l'image Docker du Brain). S'il
est absent (ex: exécution locale Windows sans install), l'OCR est désactivé
proprement : les pages-images ressortent vides mais l'extraction ne plante pas,
et le diagnostic le signale (used_ocr reste False, texte vide).
"""
from __future__ import annotations
import logging
import pymupdf as fitz # PyMuPDF — on importe par le nom canonique `pymupdf`
# (et NON `import fitz`) pour éviter la collision avec le faux paquet PyPI "fitz"
# qui échoue sur `from frontend import *`.
from app.domain.models import ExtractedDocument, ExtractedPage, TocEntry
from app.domain.ports import PdfExtractionError
logger = logging.getLogger(__name__)
# En dessous de ce nombre de caractères "significatifs" sur une page, on
# considère qu'il n'y a pas de couche texte exploitable → repli OCR.
_MIN_TEXT_CHARS = 20
# DPI de rasterisation avant OCR. 300 = bon compromis qualité/vitesse pour du
# texte de livre. Plus haut = plus lent et plus gourmand en mémoire.
_OCR_DPI = 300
# Langues Tesseract : français + anglais (la plupart des règles de JDR FR ont
# des termes anglais résiduels). Doivent être installées dans l'image Docker
# (tesseract-ocr-fra, tesseract-ocr-eng).
_OCR_LANGS = "fra+eng"
class PyMuPdfTextExtractor:
"""Extracteur PDF basé sur PyMuPDF, avec repli OCR Tesseract optionnel."""
def __init__(self) -> None:
# On détecte la disponibilité de l'OCR une seule fois (le binaire
# Tesseract ne va pas apparaître/disparaître en cours d'exécution).
self._ocr_available = self._detect_ocr()
@staticmethod
def _detect_ocr() -> bool:
"""True si pytesseract + le binaire Tesseract sont disponibles."""
try:
import pytesseract
pytesseract.get_tesseract_version()
return True
except Exception as exc: # ImportError, TesseractNotFoundError, etc.
logger.warning(
"OCR indisponible (Tesseract non installé ?) : %s. "
"Les pages sans couche texte ressortiront vides.",
exc,
)
return False
def extract(self, pdf_bytes: bytes) -> ExtractedDocument:
try:
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
except Exception as exc:
raise PdfExtractionError(f"PDF illisible ou corrompu : {exc}") from exc
pages: list[ExtractedPage] = []
toc: list[TocEntry] = []
try:
# Bookmarks/outline du PDF : structure officielle du livre, gratuite
# (pas d'appel LLM). Sert de squelette de référence aux imports.
try:
for level, title, page_no in doc.get_toc(simple=True) or []:
title = str(title or "").strip()
if title:
toc.append(TocEntry(level=int(level), title=title, page=int(page_no)))
except Exception as exc: # noqa: BLE001 — TOC best-effort, jamais bloquante
logger.warning("Lecture de la table des matières impossible : %s", exc)
for index, page in enumerate(doc):
text = (page.get_text() or "").strip()
used_ocr = False
if len(text) < _MIN_TEXT_CHARS and self._ocr_available:
ocr_text = self._ocr_page(page)
if ocr_text.strip():
text = ocr_text.strip()
used_ocr = True
pages.append(ExtractedPage(index=index, text=text, used_ocr=used_ocr))
finally:
doc.close()
return ExtractedDocument(pages=pages, toc=toc)
@staticmethod
def _ocr_page(page: "fitz.Page") -> str:
"""Rasterise une page et lui applique l'OCR Tesseract."""
import pytesseract
from PIL import Image
pix = page.get_pixmap(dpi=_OCR_DPI)
img = Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
try:
return pytesseract.image_to_string(img, lang=_OCR_LANGS)
except Exception as exc:
logger.warning("Échec OCR sur la page %s : %s", page.number, exc)
return ""

View File

@@ -0,0 +1,218 @@
"""Stockage vectoriel fichier (RAG des notebooks) — sans dépendance lourde.
Chaque SOURCE est persistée en un fichier JSON sur le volume `data/` du Brain :
data/notebooks/{source_id}.json = {"dim": N, "chunks": [{"text":..., "vector":[...]}]}
À l'échelle d'un livre (quelques centaines d'extraits), une recherche cosinus en
Python pur est instantanée — inutile d'ajouter numpy/pgvector/une base vectorielle.
Les fichiers sont mis en cache mémoire (invalidation par mtime) : le coûteux est
le re-parse JSON des vecteurs, pas le cosinus.
Recherche HYBRIDE : score = cosinus + bonus lexical (mots significatifs de la
question présents dans l'extrait). Sur du JdR, les requêtes sont souvent des noms
propres exacts (« Strahd », « Barovia ») où le lexical bat l'embedding.
"""
from __future__ import annotations
import json
import math
import re
from pathlib import Path
_STORE_DIR = Path("data/notebooks")
_SAFE_ID = re.compile(r"[^A-Za-z0-9_-]")
# Cache mémoire {source_id: (mtime_ns, chunks)} — évite de relire/re-parser le JSON
# (vecteurs = gros) à chaque question. Invalidé si le fichier change (mtime).
_CACHE: dict[str, tuple[int, list[dict]]] = {}
_CACHE_MAX_SOURCES = 32 # garde-fou mémoire : ~10 Mo par gros livre en cache
# Poids du bonus lexical dans le score hybride. Le cosinus reste dominant ; le
# bonus (0..0.15) sert surtout à départager / repêcher les correspondances exactes.
_LEX_WEIGHT = 0.15
_WORD_RE = re.compile(r"[a-z0-9àâäçéèêëîïôöùûüœæ]{3,}")
# Mots-outils FR/EN fréquents (≥3 lettres) : sans eux, le bonus lexical serait
# dominé par « les », « pour », « the »… au lieu des termes porteurs de sens.
_STOPWORDS = frozenset({
"les", "des", "une", "est", "son", "ses", "aux", "par", "pour", "dans",
"sur", "avec", "qui", "que", "quoi", "dont", "mais", "comme", "plus",
"pas", "tout", "tous", "toute", "toutes", "ils", "elles", "leur", "leurs",
"nous", "vous", "cette", "ces", "cet", "ont", "sont", "fait", "etre",
"être", "avoir", "peut", "quel", "quelle", "quels", "quelles", "ainsi",
"the", "and", "for", "with", "this", "that", "are", "was", "has", "have",
"not", "you", "his", "her", "its", "they", "them", "from", "what", "which",
})
def _path(source_id: str) -> Path:
safe = _SAFE_ID.sub("_", str(source_id))
return _STORE_DIR / f"{safe}.json"
def save(
source_id: str,
chunks: list[str],
vectors: list[list[float]],
pages: list[int] | None = None,
) -> int:
"""Persiste les (chunk, vecteur[, page]) d'une source. Renvoie le nb d'extraits."""
if len(chunks) != len(vectors):
raise ValueError("chunks et vectors de tailles différentes")
if pages is not None and len(pages) != len(chunks):
raise ValueError("pages et chunks de tailles différentes")
_STORE_DIR.mkdir(parents=True, exist_ok=True)
items = []
for i, (c, v) in enumerate(zip(chunks, vectors)):
item = {"text": c, "vector": v}
if pages is not None:
item["page"] = pages[i]
items.append(item)
payload = {"dim": len(vectors[0]) if vectors else 0, "chunks": items}
_path(source_id).write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
_CACHE.pop(source_id, None) # le mtime suffirait, mais soyons explicites
return len(chunks)
def exists(source_id: str) -> bool:
return _path(source_id).exists()
def delete(source_id: str) -> None:
_CACHE.pop(source_id, None)
_path(source_id).unlink(missing_ok=True)
_summaries_path(source_id).unlink(missing_ok=True)
# --- Index de résumés (analyse approfondie) ----------------------------------
# Cache disque des résumés PAR LOT d'une source : construit paresseusement à la
# première analyse approfondie, réutilisé ensuite pour ne relire que les lots
# pertinents. Invalidé avec la source (delete) et si batch_tokens change.
def _summaries_path(source_id: str) -> Path:
safe = _SAFE_ID.sub("_", str(source_id))
return _STORE_DIR / f"{safe}.summaries.json"
def save_summaries(source_id: str, batch_tokens: int, entries: list[dict]) -> None:
"""Persiste les résumés de lots ({"summary": str, "vector": [...]})."""
_STORE_DIR.mkdir(parents=True, exist_ok=True)
payload = {"batch_tokens": int(batch_tokens), "entries": entries}
_summaries_path(source_id).write_text(
json.dumps(payload, ensure_ascii=False), encoding="utf-8")
def load_summaries(source_id: str, batch_tokens: int) -> list[dict] | None:
"""Résumés de lots d'une source, ou None si absents / construits avec une
autre taille de lot (le découpage ne correspondrait plus)."""
p = _summaries_path(source_id)
if not p.exists():
return None
try:
data = json.loads(p.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
if not isinstance(data, dict) or data.get("batch_tokens") != int(batch_tokens):
return None
entries = data.get("entries")
return entries if isinstance(entries, list) else None
def _load(source_id: str) -> list[dict]:
p = _path(source_id)
try:
mtime = p.stat().st_mtime_ns
except OSError:
_CACHE.pop(source_id, None)
return []
cached = _CACHE.get(source_id)
if cached is not None and cached[0] == mtime:
return cached[1]
try:
data = json.loads(p.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return []
chunks = data.get("chunks", []) if isinstance(data, dict) else []
if len(_CACHE) >= _CACHE_MAX_SOURCES:
_CACHE.pop(next(iter(_CACHE))) # éviction FIFO simple
_CACHE[source_id] = (mtime, chunks)
return chunks
def all_chunks(source_id: str) -> list[dict]:
"""Tous les extraits d'une source (texte + page), sans vecteurs — pour le mode
« analyse approfondie » (map-reduce sur tout le document)."""
return [{"text": c.get("text", ""), "page": c.get("page")} for c in _load(source_id)]
def _cosine(a: list[float], b: list[float]) -> float:
if not a or not b or len(a) != len(b):
return 0.0
dot = 0.0
na = 0.0
nb = 0.0
for x, y in zip(a, b):
dot += x * y
na += x * x
nb += y * y
if na == 0.0 or nb == 0.0:
return 0.0
return dot / (math.sqrt(na) * math.sqrt(nb))
def _significant_words(text: str) -> frozenset[str]:
"""Mots porteurs de sens d'un texte (minuscules, ≥3 lettres, hors mots-outils)."""
return frozenset(w for w in _WORD_RE.findall(text.lower()) if w not in _STOPWORDS)
def _chunk_words(chunk: dict) -> frozenset[str]:
"""Mots significatifs d'un extrait, mémoïsés sur le dict caché (calculés à la
1ère recherche, réutilisés tant que la source reste en cache)."""
words = chunk.get("_words")
if words is None:
words = _significant_words(chunk.get("text", ""))
chunk["_words"] = words
return words
# Alias public du cosinus (réutilisé par l'index de résumés de l'analyse
# approfondie — même métrique que la recherche).
cosine_similarity = _cosine
def search(
source_ids: list[str],
query_vector: list[float],
top_k: int = 6,
query_text: str = "",
min_score: float = 0.0,
) -> list[dict]:
"""Renvoie les `top_k` extraits les plus proches, toutes sources confondues.
Score HYBRIDE : cosinus + `_LEX_WEIGHT` × (part des mots significatifs de
`query_text` présents dans l'extrait). Les extraits dont le cosinus est sous
`min_score` sont écartés (peut donc renvoyer MOINS de `top_k` résultats —
mieux vaut aucun extrait que du bruit injecté dans le prompt).
Chaque résultat : {"text": str, "score": float, "source_id": str, "page": int|None}.
"""
query_words = _significant_words(query_text) if query_text else frozenset()
scored: list[dict] = []
for sid in source_ids:
for chunk in _load(sid):
vector = chunk.get("vector") or []
cos = _cosine(query_vector, vector)
if cos < min_score:
continue
score = cos
if query_words:
overlap = len(query_words & _chunk_words(chunk)) / len(query_words)
score += _LEX_WEIGHT * overlap
scored.append({
"text": chunk.get("text", ""),
"score": score,
"source_id": sid,
"page": chunk.get("page"),
})
scored.sort(key=lambda c: c["score"], reverse=True)
return scored[:top_k]

View File

@@ -1,65 +1,35 @@
"""Point d'entrée FastAPI du Brain LoreMind.
"""Point d'entrée FastAPI du Brain LoreMind : assemblage de l'application.
Controller volontairement FIN : il valide l'entrée (DTOs Pydantic), délègue
au domaine via injection de dépendance (ports + use cases), et transforme les
erreurs du domaine en réponses HTTP. Aucune connaissance d'Ollama ici.
Responsabilité UNIQUE : créer l'app, brancher le middleware d'auth inter-service,
le hook de démarrage et les routers (un par responsabilité, voir `app.api.routers`).
Toute la logique HTTP vit dans les routers ; la logique métier dans `app.application`.
"""
import json
from typing import Annotated, AsyncIterator, Literal
import asyncio
import hmac
import httpx
import tiktoken
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field
import logging
from app.application.chat import ChatUseCase
from app.application.generate_page import GeneratePageUseCase
from app.core.config import Settings, get_settings
from app.core.settings_store import save_overrides
from app.domain.models import (
ArcSummary,
CampaignStructuralContext,
ChapterSummary,
CharacterSummary,
ChatMessage,
GameSystemContext,
LoreStructuralContext,
NarrativeEntityContext,
PageContext,
PageGenerationContext,
PageSummary,
SceneBranchHint,
SceneSummary,
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from app.api.routers import (
chat,
generation,
imports,
models,
notebooks,
settings as settings_router,
tables,
)
from app.domain.ports import LLMProvider, LLMProviderError
from app.infrastructure.ollama_adapter import OllamaLLMProvider
from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
from app.core.config import get_settings
from app.infrastructure.ollama_model_installer import ensure_ollama_embedding_model
app = FastAPI(
title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.",
version="0.6.6",
version="0.16.2",
)
# Encodeur tiktoken partagé — chargé une fois pour éviter le coût de lookup
# à chaque requête. On utilise cl100k_base (GPT-3.5/4) comme tokenizer
# universel approximatif : ±10% d'écart avec Llama/Gemma mais largement
# suffisant pour une jauge visuelle à l'utilisateur.
_TOKEN_ENCODER: tiktoken.Encoding | None = None
def _count_tokens(text: str | None) -> int:
"""Compte les tokens d'un texte via tiktoken. Null/empty → 0."""
if not text:
return 0
global _TOKEN_ENCODER
if _TOKEN_ENCODER is None:
_TOKEN_ENCODER = tiktoken.get_encoding("cl100k_base")
return len(_TOKEN_ENCODER.encode(text))
logger = logging.getLogger(__name__)
# Chemins exemptes d'auth inter-service : healthcheck docker + introspection
# FastAPI (docs uniquement utiles en dev ; en prod docker-compose, le Brain
@@ -91,695 +61,31 @@ async def require_internal_secret(request: Request, call_next):
return await call_next(request)
# --- DTOs HTTP (frontière, c'est ici et seulement ici qu'on utilise Pydantic) ---
class GenerateRequest(BaseModel):
prompt: str
class GenerateResponse(BaseModel):
model: str
response: str
class GeneratePageRequestDTO(BaseModel):
"""Contexte envoyé par le Core Java pour remplir une page via le LLM."""
lore_name: str
folder_name: str
template_name: str
template_fields: list[str] = Field(min_length=1)
page_title: str
lore_description: str | None = None
class GeneratePageResponseDTO(BaseModel):
"""Retour : une valeur textuelle par champ du template (clé = field name)."""
values: dict[str, str]
class ChatMessageDTO(BaseModel):
"""Un message de la conversation. Rôles acceptés : user, assistant, system."""
role: str = Field(pattern="^(user|assistant|system)$")
content: str
class PageSummaryDTO(BaseModel):
"""Résumé enrichi d'une page : identité + contenu + interconnexions.
Depuis b9 : values/tags/related_page_titles sont optionnels côté JSON —
le Core Java ne les sérialise que s'ils sont non-vides (payload léger
pour un Lore avec beaucoup de pages vierges).
"""
title: str
template_name: str
values: dict[str, str] = Field(default_factory=dict)
tags: list[str] = Field(default_factory=list)
related_page_titles: list[str] = Field(default_factory=list)
class LoreContextDTO(BaseModel):
"""Carte structurelle du Lore avec contenu des pages (b9+)."""
lore_name: str
lore_description: str | None = None
folders: dict[str, list[PageSummaryDTO]] = Field(default_factory=dict)
tags: list[str] = Field(default_factory=list)
class PageContextDTO(BaseModel):
"""Contexte d'une page spécifique pour focaliser le chat (optionnel)."""
title: str
template_name: str
template_fields: list[str] = Field(default_factory=list)
values: dict[str, str] = Field(default_factory=dict)
class SceneBranchHintDTO(BaseModel):
"""Indice d'une branche narrative (le Core a deja resolu le nom cible)."""
label: str
target_scene_name: str
condition: str | None = None
class SceneSummaryDTO(BaseModel):
"""Résumé d'une scène : nom + description courte (synopsis)."""
name: str
description: str | None = None
# Optionnel : le Core Java ne serialise illustration_count QUE si > 0
# (payload plus leger). Defaut 0 = pas d'illustrations ou champ absent.
illustration_count: int = 0
# Branches narratives sortantes, omises cote Core si vides.
branches: list[SceneBranchHintDTO] = Field(default_factory=list)
class ChapterSummaryDTO(BaseModel):
"""Résumé d'un chapitre : nom + description courte + ses scènes."""
name: str
description: str | None = None
scenes: list[SceneSummaryDTO] = Field(default_factory=list)
illustration_count: int = 0
class ArcSummaryDTO(BaseModel):
"""Résumé d'un arc narratif : nom + description courte + ses chapitres."""
name: str
description: str | None = None
chapters: list[ChapterSummaryDTO] = Field(default_factory=list)
illustration_count: int = 0
class CharacterSummaryDTO(BaseModel):
"""Résumé d'un PJ : nom + snippet. Pas de fiche complète au niveau résumé."""
name: str
snippet: str = ""
class CampaignContextDTO(BaseModel):
"""Carte narrative enrichie : arcs → chapitres → scènes avec synopsis."""
campaign_name: str
campaign_description: str | None = None
arcs: list[ArcSummaryDTO] = Field(default_factory=list)
characters: list[CharacterSummaryDTO] = Field(default_factory=list)
class NarrativeEntityDTO(BaseModel):
"""Entité narrative (arc/chapter/scene/character) en cours d'édition — focus optionnel."""
entity_type: str = Field(pattern="^(arc|chapter|scene|character)$")
title: str
fields: dict[str, str] = Field(default_factory=dict)
class GameSystemContextDTO(BaseModel):
"""Règles de JDR présélectionnées par le Core (filtrées par intent).
Les sections sont un dict titre_H2 → contenu_markdown. Peuvent être
vides si aucune section ne matchait l'intent de génération courant.
"""
system_name: str
system_description: str | None = None
sections: dict[str, str] = Field(default_factory=dict)
class ChatStreamRequestDTO(BaseModel):
"""Requête de chat streamé : historique + contextes structurels.
Les 4 contextes (lore, page, campaign, narrative_entity) sont optionnels,
mais au moins l'un des deux "niveaux haut" (lore_context ou
campaign_context) doit être fourni. Le validateur `check_scope` applique
cette règle à la frontière HTTP.
"""
messages: list[ChatMessageDTO] = Field(min_length=1)
lore_context: LoreContextDTO | None = None
page_context: PageContextDTO | None = None
campaign_context: CampaignContextDTO | None = None
narrative_entity: NarrativeEntityDTO | None = None
game_system_context: GameSystemContextDTO | None = None
def has_scope(self) -> bool:
"""Vrai si au moins un contexte racine (Lore ou Campagne) est fourni."""
return self.lore_context is not None or self.campaign_context is not None
# --- Factories d'injection de dépendance ---
def get_llm_provider(
settings: Annotated[Settings, Depends(get_settings)],
) -> LLMProvider:
"""Factory d'adapter — point d'inversion de dépendance.
C'est ici (et uniquement ici) qu'on choisit QUEL adapter concret
incarne le port, en fonction du champ `llm_provider` des Settings
(modifiable a chaud depuis l'ecran Parametres de l'UI).
"""
try:
if settings.llm_provider == "onemin":
return OneMinAiLLMProvider(settings)
return OllamaLLMProvider(settings)
except LLMProviderError as exc:
# Ex : cle 1min.ai manquante. On renvoie du 400 plutot que du 500
# pour que le frontend puisse afficher un message actionnable.
raise HTTPException(status_code=400, detail=str(exc)) from exc
def get_generate_page_use_case(
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
) -> GeneratePageUseCase:
"""Factory du use case — injecte le port LLMProvider sans connaître l'adapter."""
return GeneratePageUseCase(llm=llm)
def get_chat_use_case(
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
) -> ChatUseCase:
"""Factory du use case chat.
L'adapter OllamaLLMProvider satisfait les deux protocoles (LLMProvider
et LLMChatProvider) par duck typing ; on lui passe la même instance.
"""
return ChatUseCase(llm=llm) # type: ignore[arg-type]
# --- Endpoints ---
@app.get("/health")
def health() -> dict[str, str]:
"""Sonde de santé — permet au Core Java de vérifier que le Brain répond."""
return {"status": "ok", "service": "brain"}
@app.post("/generate", response_model=GenerateResponse)
async def generate(
body: GenerateRequest,
settings: Annotated[Settings, Depends(get_settings)],
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
) -> GenerateResponse:
"""Endpoint libre : prompt → texte brut. Utile pour debug et exploration."""
try:
text = await llm.generate(body.prompt)
except LLMProviderError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return GenerateResponse(model=settings.llm_model, response=text)
@app.post("/generate-page", response_model=GeneratePageResponseDTO)
async def generate_page(
body: GeneratePageRequestDTO,
use_case: Annotated[
GeneratePageUseCase, Depends(get_generate_page_use_case)
],
) -> GeneratePageResponseDTO:
"""Endpoint métier : contexte LoreMind → valeurs structurées par champ.
Branche tout le use case `GeneratePageUseCase`. Ce controller ne fait
que le mapping DTO ↔ dataclass et la traduction d'erreur domaine → HTTP.
@app.on_event("startup")
async def _auto_install_embedding_model() -> None:
"""Au démarrage : si le provider d'embeddings est Ollama et que le modèle n'est
pas installé, on le télécharge EN ARRIÈRE-PLAN → le RAG marche d'emblée pour un
nouvel utilisateur, sans bloquer le démarrage du Brain. Best-effort (Ollama peut
être absent / la connexion limitée) ; désactivable via `auto_pull_embedding_model`.
"""
context = PageGenerationContext(
lore_name=body.lore_name,
lore_description=body.lore_description,
folder_name=body.folder_name,
template_name=body.template_name,
template_fields=body.template_fields,
page_title=body.page_title,
)
try:
result = await use_case.execute(context)
except LLMProviderError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return GeneratePageResponseDTO(values=result.values)
@app.post("/chat/stream")
async def chat_stream(
body: ChatStreamRequestDTO,
use_case: Annotated[ChatUseCase, Depends(get_chat_use_case)],
) -> StreamingResponse:
"""Chat streamé (Server-Sent Events) avec Structural Context.
Accepte jusqu'à 4 contextes optionnels (Lore, Page focalisée, Campagne,
entité narrative focalisée). Au moins un contexte racine (Lore ou
Campagne) est requis pour que la requête ait du sens.
Format de flux :
- Chaque token : `data: {"token": "..."}\\n\\n`
- Fin normale : `event: done\\ndata: {}\\n\\n`
- Erreur LLM : `event: error\\ndata: {"message": "..."}\\n\\n`
"""
if not body.has_scope():
raise HTTPException(
status_code=422,
detail="Au moins un des deux contextes racines (lore_context ou campaign_context) est requis.",
)
messages = [ChatMessage(role=m.role, content=m.content) for m in body.messages]
lore_context = _to_lore_context(body.lore_context)
page_context = _to_page_context(body.page_context)
campaign_context = _to_campaign_context(body.campaign_context)
narrative_entity = _to_narrative_entity(body.narrative_entity)
game_system_context = _to_game_system_context(body.game_system_context)
# --- Comptage tokens pour la jauge de contexte frontend ---
# On construit le system prompt une fois ici pour le compter — le use case
# le reconstruira à l'identique en interne (coût négligeable : concat de str).
# Cette duplication évite de complexifier le contrat stream() avec un
# paramètre optionnel system_prompt précalculé.
system_prompt_preview = use_case.build_system_prompt(
lore_context=lore_context,
page_context=page_context,
campaign_context=campaign_context,
narrative_entity=narrative_entity,
game_system_context=game_system_context,
)
# Dernier message = "current" (souvent user), le reste = historique accumulé.
current_msg = messages[-1] if messages else None
history_msgs = messages[:-1] if messages else []
settings = get_settings()
usage_payload = {
"system": _count_tokens(system_prompt_preview),
"history": sum(_count_tokens(m.content) for m in history_msgs),
"current": _count_tokens(current_msg.content) if current_msg else 0,
"max": settings.llm_num_ctx,
}
async def event_stream() -> AsyncIterator[str]:
# Event 'usage' émis en tout premier : le frontend peut afficher la
# jauge avant même le premier token de réponse.
yield f"event: usage\ndata: {json.dumps(usage_payload, ensure_ascii=False)}\n\n"
try:
async for token in use_case.stream(
messages,
lore_context=lore_context,
page_context=page_context,
campaign_context=campaign_context,
narrative_entity=narrative_entity,
game_system_context=game_system_context,
):
# json.dumps avec ensure_ascii=False pour préserver les accents
yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n"
yield "event: done\ndata: {}\n\n"
except LLMProviderError as exc:
yield f"event: error\ndata: {json.dumps({'message': str(exc)})}\n\n"
return StreamingResponse(event_stream(), media_type="text/event-stream")
if not settings.auto_pull_embedding_model or settings.embedding_provider != "ollama":
return
asyncio.create_task(ensure_ollama_embedding_model(
settings.ollama_base_url, settings.ollama_embedding_model))
# --- Auto-titre d'une conversation persistee --------------------------------
class SummarizeTitleMessageDTO(BaseModel):
role: Literal["user", "assistant", "system"]
content: str
class SummarizeTitleRequestDTO(BaseModel):
"""Premiers messages d'une conversation pour auto-generer un titre court."""
messages: list[SummarizeTitleMessageDTO] = Field(default_factory=list)
class SummarizeTitleResponseDTO(BaseModel):
title: str
_TITLE_SYSTEM_PROMPT = (
"Tu generes un titre court (4 a 7 mots max) qui resume le sujet de la "
"conversation ci-dessous. Reponds UNIQUEMENT par le titre, sans guillemets, "
"sans ponctuation finale, sans prefixe type 'Titre :'. Le titre doit etre "
"en francais et capturer le sujet metier (pas 'Conversation IA')."
)
@app.post("/summarize/conversation-title", response_model=SummarizeTitleResponseDTO)
async def summarize_conversation_title(
body: SummarizeTitleRequestDTO,
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
) -> SummarizeTitleResponseDTO:
"""Genere un titre court a partir des premiers echanges de la conversation.
Appele par le core apres le 1er couple user/assistant, pour remplacer le
titre provisoire "Nouvelle conversation" par quelque chose de parlant.
"""
if not body.messages:
raise HTTPException(status_code=422, detail="Au moins un message requis")
transcript = "\n".join(f"{m.role.upper()}: {m.content}" for m in body.messages[:6])
prompt = f"{_TITLE_SYSTEM_PROMPT}\n\nConversation :\n{transcript}\n\nTitre :"
try:
raw = await llm.generate(prompt)
except LLMProviderError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
title = raw.strip().splitlines()[0].strip().strip('"').strip("'").rstrip(".")
if len(title) > 80:
title = title[:80].rstrip()
if not title:
title = "Nouvelle conversation"
return SummarizeTitleResponseDTO(title=title)
# --- Mapping DTO → domaine (frontière HTTP) ---------------------------------
def _to_lore_context(dto: LoreContextDTO | None) -> LoreStructuralContext | None:
if dto is None:
return None
return LoreStructuralContext(
lore_name=dto.lore_name,
lore_description=dto.lore_description,
folders={
folder: [_to_page_summary(p) for p in pages]
for folder, pages in dto.folders.items()
},
tags=dto.tags,
)
def _to_page_summary(dto: PageSummaryDTO) -> PageSummary:
return PageSummary(
title=dto.title,
template_name=dto.template_name,
values=dict(dto.values),
tags=list(dto.tags),
related_page_titles=list(dto.related_page_titles),
)
def _to_page_context(dto: PageContextDTO | None) -> PageContext | None:
if dto is None:
return None
return PageContext(
title=dto.title,
template_name=dto.template_name,
template_fields=dto.template_fields,
values=dto.values,
)
def _to_campaign_context(dto: CampaignContextDTO | None) -> CampaignStructuralContext | None:
if dto is None:
return None
arcs = [
ArcSummary(
name=arc.name,
description=arc.description,
illustration_count=arc.illustration_count,
chapters=[
ChapterSummary(
name=ch.name,
description=ch.description,
illustration_count=ch.illustration_count,
scenes=[
SceneSummary(
name=sc.name,
description=sc.description,
illustration_count=sc.illustration_count,
branches=[
SceneBranchHint(
label=br.label,
target_scene_name=br.target_scene_name,
condition=br.condition,
)
for br in sc.branches
],
)
for sc in ch.scenes
],
)
for ch in arc.chapters
],
)
for arc in dto.arcs
]
characters = [
CharacterSummary(name=c.name, snippet=c.snippet)
for c in dto.characters
]
return CampaignStructuralContext(
campaign_name=dto.campaign_name,
campaign_description=dto.campaign_description,
arcs=arcs,
characters=characters,
)
# --- Settings (parametrage runtime depuis l'UI) ------------------------------
class SettingsDTO(BaseModel):
"""Vue serialisable des settings modifiables depuis l'UI.
Expose uniquement les champs que l'utilisateur peut changer a chaud.
Les secrets (onemin_api_key) sont masques en lecture.
"""
llm_provider: Literal["ollama", "onemin"]
ollama_base_url: str
llm_model: str
onemin_model: str
# True si une cle 1min.ai est deja configuree — pas de leak de la cle elle-meme.
onemin_api_key_set: bool
# Fenetre de contexte effective passee au modele (num_ctx Ollama) — sert
# aussi de plafond a la jauge de contexte UI.
llm_num_ctx: int
class SettingsUpdateDTO(BaseModel):
"""Patch partiel des settings. Tous les champs sont optionnels."""
llm_provider: Literal["ollama", "onemin"] | None = None
ollama_base_url: str | None = None
llm_model: str | None = None
onemin_model: str | None = None
# Chaine vide => on efface la cle. None => pas de changement.
onemin_api_key: str | None = None
llm_num_ctx: int | None = None
def _to_settings_dto(s: Settings) -> SettingsDTO:
return SettingsDTO(
llm_provider=s.llm_provider,
ollama_base_url=s.ollama_base_url,
llm_model=s.llm_model,
onemin_model=s.onemin_model,
onemin_api_key_set=bool(s.onemin_api_key),
llm_num_ctx=s.llm_num_ctx,
)
@app.get("/settings", response_model=SettingsDTO)
def read_settings(settings: Annotated[Settings, Depends(get_settings)]) -> SettingsDTO:
"""Retourne la config courante (secrets masques)."""
return _to_settings_dto(settings)
@app.put("/settings", response_model=SettingsDTO)
def update_settings(patch: SettingsUpdateDTO) -> SettingsDTO:
"""Applique un patch partiel aux settings et persiste les overrides.
Toute requete HTTP suivante verra les nouvelles valeurs (pas de cache).
"""
overrides = {k: v for k, v in patch.model_dump().items() if v is not None}
if overrides:
save_overrides(overrides)
# Relit .env + overrides fusionnes pour confirmation.
return _to_settings_dto(get_settings())
@app.get("/models/ollama")
async def list_ollama_models(
settings: Annotated[Settings, Depends(get_settings)],
) -> dict[str, list[str]]:
"""Liste les modeles disponibles sur le serveur Ollama configure.
Retourne une liste vide si Ollama est injoignable — l'UI affichera un
message plutot qu'une 500.
"""
url = f"{settings.ollama_base_url}/api/tags"
try:
async with httpx.AsyncClient(timeout=5) as client:
response = await client.get(url)
response.raise_for_status()
data = response.json()
except httpx.HTTPError:
return {"models": []}
models = [m.get("name", "") for m in data.get("models", []) if m.get("name")]
return {"models": sorted(models)}
class OllamaModelInfoDTO(BaseModel):
"""Info utile extraite de /api/show pour un modele Ollama donne.
`context_length` = fenetre de contexte max supportee par le modele
(extraite des metadonnees GGUF). 0 si inconnue. Le frontend s'en sert
pour borner le slider de num_ctx dans les Parametres.
"""
context_length: int = 0
@app.post("/models/ollama/info", response_model=OllamaModelInfoDTO)
async def get_ollama_model_info(
body: dict[str, str],
settings: Annotated[Settings, Depends(get_settings)],
) -> OllamaModelInfoDTO:
"""Retourne les metadonnees d'un modele Ollama via /api/show.
On passe par POST (et pas GET /models/ollama/{name}) parce que les noms
Ollama contiennent souvent un `:` (ex: `gemma3:e2b`) qui se segmente
mal dans une URL — le body JSON evite le probleme d'escaping.
Le champ qui nous interesse est `model_info["<arch>.context_length"]`
(ex: `gemma3.context_length: 131072`). L'arch varie selon le modele, on
scanne donc tous les champs finissant par `.context_length`.
"""
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name requis")
url = f"{settings.ollama_base_url}/api/show"
try:
async with httpx.AsyncClient(timeout=5) as client:
response = await client.post(url, json={"model": name})
response.raise_for_status()
data = response.json()
except httpx.HTTPError:
return OllamaModelInfoDTO(context_length=0)
model_info = data.get("model_info") or {}
for key, value in model_info.items():
if key.endswith(".context_length") and isinstance(value, int):
return OllamaModelInfoDTO(context_length=value)
return OllamaModelInfoDTO(context_length=0)
@app.get("/models/onemin")
def list_onemin_models() -> dict[str, list[dict[str, object]]]:
"""Catalogue statique des modeles 1min.ai, groupes par fournisseur.
Liste construite par probing direct de l'endpoint chat-with-ai avec
une vraie cle API (avril 2026) : chaque ID renvoie 200, les IDs
absents renvoient 400 UNSUPPORTED_MODEL.
Nota : les IDs Anthropic utilisent la nomenclature propre a 1min.ai
(`claude-<family>-<version>`), pas la convention officielle Anthropic.
"""
return {
"groups": [
{
"provider": "Anthropic",
"models": ["claude-opus-4-6", "claude-sonnet-4-6"],
},
{
"provider": "OpenAI",
"models": [
"gpt-5",
"gpt-5-mini",
"gpt-5-nano",
"gpt-4.1",
"gpt-4.1-mini",
"gpt-4.1-nano",
"gpt-4o",
"gpt-4o-mini",
"gpt-4-turbo",
"gpt-3.5-turbo",
"o3",
"o3-pro",
"o3-mini",
"o4-mini",
],
},
{
"provider": "Google",
"models": ["gemini-2.5-pro", "gemini-2.5-flash"],
},
{
"provider": "Mistral",
"models": [
"mistral-large-latest",
"mistral-medium-latest",
"mistral-small-latest",
"open-mistral-nemo",
],
},
{
"provider": "DeepSeek",
"models": ["deepseek-chat", "deepseek-reasoner"],
},
{
"provider": "xAI",
"models": ["grok-3", "grok-3-mini"],
},
{
"provider": "Meta",
"models": [
"meta/meta-llama-3.1-405b-instruct",
"meta/meta-llama-3-70b-instruct",
],
},
{
"provider": "Alibaba",
"models": ["qwen-plus", "qwen3-max"],
},
{
"provider": "Perplexity",
"models": ["sonar", "sonar-pro"],
},
]
}
def _to_narrative_entity(dto: NarrativeEntityDTO | None) -> NarrativeEntityContext | None:
if dto is None:
return None
return NarrativeEntityContext(
entity_type=dto.entity_type,
title=dto.title,
fields=dict(dto.fields),
)
def _to_game_system_context(dto: GameSystemContextDTO | None) -> GameSystemContext | None:
if dto is None:
return None
return GameSystemContext(
system_name=dto.system_name,
system_description=dto.system_description,
sections=dict(dto.sections),
)
# Un router par responsabilité (SRP) — chemins identiques à l'ancien monolithe.
app.include_router(generation.router)
app.include_router(chat.router)
app.include_router(tables.router)
app.include_router(imports.router)
app.include_router(notebooks.router)
app.include_router(settings_router.router)
app.include_router(models.router)

7
brain/pytest.ini Normal file
View File

@@ -0,0 +1,7 @@
[pytest]
# Tests unitaires du brain. asyncio_mode=auto : les coroutines de test sont
# exécutées sans décorateur @pytest.mark.asyncio explicite.
asyncio_mode = auto
# Ajoute la racine du brain au sys.path pour `import app...` sans installation.
pythonpath = .
testpaths = tests

View File

@@ -0,0 +1,11 @@
# Dépendances de TEST uniquement (non embarquées dans l'image / le bundle desktop).
# Installer avec : .venv/Scripts/python -m pip install -r requirements-dev.txt
-r requirements.txt
pytest>=8,<9
pytest-asyncio>=0.24,<1
# Mock du transport httpx (intercepte les appels aux API LLM dans les tests).
respx>=0.21,<1
# Couverture de tests (équivalent JaCoCo) : `pytest --cov=app --cov-report=html`
# → rapport HTML dans htmlcov/. La CI ajoute --cov-fail-under pour le plancher.
pytest-cov>=5,<7

View File

@@ -1,7 +1,13 @@
fastapi==0.115.*
fastapi==0.136.*
# Pin EXPLICITE : fastapi n'exige que starlette>=0.46.0 — sans ce pin, un
# environnement existant peut garder une starlette vulnérable.
# >= 0.49.1 : corrige CVE-2025-54121 et CVE-2025-62727.
starlette>=0.49.1,<1.0
uvicorn[standard]==0.32.*
httpx==0.27.*
pydantic-settings==2.6.*
# Requis par FastAPI pour les uploads multipart (UploadFile) — import de PDF.
python-multipart==0.0.*
pydantic
@@ -10,3 +16,14 @@ pydantic
# la plupart des modeles Llama/Gemma/Mistral (~5-10% d'ecart) — suffisant
# pour une jauge visuelle.
tiktoken==0.8.*
# Import de PDF de regles (-> GameSystem). Extraction de la couche texte
# (born-digital) avec repli OCR par page pour les scans.
# - pymupdf : extraction texte + rasterisation des pages (pas besoin de poppler)
# - pytesseract + Pillow : OCR Tesseract sur les pages sans couche texte
# (le binaire tesseract-ocr est installe dans le Dockerfile, langues fra+eng)
pymupdf==1.24.*
pytesseract==0.3.*
# 12.2+ : corrige 6 CVE de parsing d'images (surface critique : pages de PDF
# uploadés par l'utilisateur rasterisées puis passées à l'OCR).
Pillow==12.2.*

41
brain/run_local.py Normal file
View File

@@ -0,0 +1,41 @@
"""Point d'entree LOCAL du Brain (hors Docker).
Lance le serveur uvicorn sur 127.0.0.1:8000 — l'equivalent autonome de la
commande Docker `uvicorn app.main:app --host 0.0.0.0 --port 8000`, mais en
n'ecoutant QUE sur la boucle locale (mono-utilisateur, jamais expose au reseau).
Empaquete avec le Python *embeddable* officiel (signe par la PSF) dans
l'application de bureau : on evite ainsi tout executable "gele" type PyInstaller
que les antivirus prennent souvent pour un trojan (bootloader packe).
Le Core le lance via : python\\python.exe run_local.py
On insere le dossier de CE fichier dans sys.path pour que le package `app`
soit importable quel que soit le repertoire de travail (le Core fixe le cwd
ailleurs, sous ~/.loremind/brain, pour y ecrire le dossier data/).
"""
import os
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _HERE)
# OCR : si un Tesseract est bundlé à côté (mode desktop), on y pointe pytesseract
# AVANT que l'app n'importe le pdf_extractor (qui détecte la version au chargement).
# tessdata (fra+eng) est embarqué dans tesseract/tessdata. Sans ce bloc, l'OCR
# reste désactivé en dégradation gracieuse (PDF born-digital OK, scans signalés).
_TESS = os.path.join(_HERE, "tesseract", "tesseract.exe")
if os.path.exists(_TESS):
os.environ.setdefault("TESSDATA_PREFIX", os.path.join(_HERE, "tesseract"))
try:
import pytesseract
pytesseract.pytesseract.tesseract_cmd = _TESS
except ImportError:
pass
import uvicorn # noqa: E402
from app.main import app # noqa: E402
if __name__ == "__main__":
# host 127.0.0.1 : accessible uniquement depuis le Core sur la meme machine.
uvicorn.run(app, host="127.0.0.1", port=8000, log_level="info")

View File

@@ -0,0 +1,84 @@
# -*- coding: utf-8 -*-
"""Sanity check temporaire : overlap du chunking + recherche hybride du vector store."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.application.chunking import chunk_text
# --- 1. Chunking avec overlap ---
paras = [f"Paragraphe {i} : " + ("lorem ipsum dolor sit amet " * 8) for i in range(12)]
text = "\n\n".join(paras)
no_overlap = chunk_text(text, target_tokens=200)
with_overlap = chunk_text(text, target_tokens=200, overlap_tokens=40)
assert len(with_overlap) >= len(no_overlap), "l'overlap ne doit pas réduire le nb de chunks"
# Chaque chunk (sauf le 1er) doit commencer par la fin du précédent
overlapped = 0
for prev, cur in zip(with_overlap, with_overlap[1:]):
first_para = cur.split("\n\n")[0]
if first_para in prev:
overlapped += 1
assert overlapped >= len(with_overlap) - 2, f"overlap absent: {overlapped}/{len(with_overlap)-1}"
# Pas de chunk composé uniquement de l'overlap (dernier chunk dupliqué)
assert with_overlap[-1] != with_overlap[-2], "dernier chunk = pur overlap (dupliqué)"
# overlap_tokens=0 → comportement identique à l'ancien
assert no_overlap == chunk_text(text, target_tokens=200, overlap_tokens=0)
print(f"[OK] chunking : {len(no_overlap)} chunks sans overlap, {len(with_overlap)} avec ({overlapped} recouvrements)")
# --- Paragraphe géant ---
giant = "mot " * 2000
sub = chunk_text(giant, target_tokens=300, overlap_tokens=50)
assert len(sub) > 1
print(f"[OK] paragraphe géant coupé en {len(sub)} sous-blocs")
# --- 2. Vector store : hybride + seuil + cache ---
import tempfile, os
from app.infrastructure import vector_store
with tempfile.TemporaryDirectory() as tmp:
vector_store._STORE_DIR = Path(tmp)
chunks = [
"Strahd von Zarovich règne sur la sombre vallée de Barovia depuis son château.",
"Les règles de combat utilisent un d20 plus le modificateur de caractéristique.",
"La taverne du village sert un ragoût de navets aux voyageurs fatigués.",
]
# Vecteurs factices : chunk 0 et 1 proches de la query, chunk 2 orthogonal
vectors = [[1.0, 0.1, 0.0], [0.9, 0.4, 0.1], [0.0, 0.0, 1.0]]
vector_store.save("src1", chunks, vectors, pages=[10, 20, 30])
q = [1.0, 0.2, 0.0]
# Sans seuil ni texte : 3 résultats, ordre cosinus
r = vector_store.search(["src1"], q, top_k=10)
assert len(r) == 3 and r[0]["page"] == 10
# Avec seuil : le chunk orthogonal (cos~0) est écarté
r = vector_store.search(["src1"], q, top_k=10, min_score=0.30)
assert len(r) == 2, f"seuil non appliqué: {len(r)}"
print(f"[OK] seuil : 2/3 extraits gardés (orthogonal écarté)")
# Bonus lexical : la query mentionne « Strahd Barovia » → chunk 0 doit dominer
r = vector_store.search(["src1"], q, top_k=10, query_text="Parle-moi de Strahd et de Barovia", min_score=0.30)
assert r[0]["text"].startswith("Strahd"), r[0]["text"]
assert r[0]["score"] > vector_store._cosine(q, vectors[0]), "bonus lexical absent"
print(f"[OK] hybride : bonus lexical appliqué (score={r[0]['score']:.3f})")
# Le set "_words" mémoïsé ne doit PAS fuiter dans les résultats
assert all("_words" not in res for res in r)
# Cache : 2e recherche sert depuis la mémoire (même objet liste)
c1 = vector_store._load("src1")
c2 = vector_store._load("src1")
assert c1 is c2, "cache mtime inopérant"
# save() invalide le cache
vector_store.save("src1", chunks[:1], vectors[:1], pages=[10])
c3 = vector_store._load("src1")
assert len(c3) == 1, "cache non invalidé après save"
# delete() purge cache + fichier
vector_store.delete("src1")
assert vector_store._load("src1") == []
print("[OK] cache mémoire : hit, invalidation save, purge delete")
print("\nTous les sanity checks passent.")

View File

@@ -0,0 +1,107 @@
"""CLI de test pour l'import de règles PDF — boucle de feedback rapide.
But : tester l'extraction + la structuration en sections sur un VRAI PDF
(ex: livre Nimble), SANS passer par HTTP, le Core ou l'UI.
Usage (depuis le dossier brain/, venv activé) :
python scripts/test_import_rules.py "chemin/vers/regles.pdf"
Le provider LLM utilisé est celui configuré (.env + overrides de l'écran
Paramètres) — donc 1min.ai si tu l'as sélectionné. Le script :
1. extrait le texte (et dit, page par page, si l'OCR s'est déclenché),
2. découpe + structure via le LLM,
3. affiche un résumé des sections,
4. écrit le markdown complet dans "<pdf>.rules.md" à côté du PDF.
"""
from __future__ import annotations
import asyncio
import logging
import sys
from pathlib import Path
# Permet `import app...` quel que soit le cwd : on ajoute la racine brain/.
_BRAIN_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(_BRAIN_ROOT))
from app.application.import_rules import ImportRulesUseCase # noqa: E402
from app.core.config import get_settings # noqa: E402
from app.domain.ports import LLMProvider, LLMProviderError, PdfExtractionError # noqa: E402
from app.infrastructure.ollama_adapter import OllamaLLMProvider # noqa: E402
from app.infrastructure.onemin_adapter import OneMinAiLLMProvider # noqa: E402
from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor # noqa: E402
def _build_provider() -> LLMProvider:
"""Réplique la factory de main.py (choix provider selon les settings)."""
settings = get_settings()
print(f"→ Provider LLM : {settings.llm_provider} "
f"(modèle : {settings.onemin_model if settings.llm_provider == 'onemin' else settings.llm_model})")
if settings.llm_provider == "onemin":
return OneMinAiLLMProvider(settings)
return OllamaLLMProvider(settings)
async def _run(pdf_path: Path) -> None:
pdf_bytes = pdf_path.read_bytes()
print(f"→ PDF chargé : {pdf_path.name} ({len(pdf_bytes) // 1024} Ko)\n")
extractor = PyMuPdfTextExtractor()
# 1. Extraction seule d'abord, pour le diagnostic page/OCR avant tout LLM.
try:
doc = extractor.extract(pdf_bytes)
except PdfExtractionError as exc:
print(f"✗ Extraction impossible : {exc}")
return
print(f"=== Extraction : {doc.page_count} page(s), "
f"{doc.ocr_page_count} via OCR, "
f"{len(doc.full_text)} caractères ===")
if doc.ocr_page_count == 0:
print(" → PDF born-digital détecté (couche texte présente, OCR non nécessaire).")
else:
print(f"{doc.ocr_page_count} page(s) sans couche texte : OCR déclenché.")
if not doc.full_text.strip():
print("\n✗ Aucun texte extrait. Si le PDF est un scan, vérifie que Tesseract "
"est installé (sinon l'OCR est désactivé).")
return
# 2. Structuration via le LLM (réutilise l'extraction déjà faite indirectement :
# le use case ré-extrait, coût négligeable vs l'appel LLM).
print("\n=== Structuration via LLM (peut prendre un moment selon le nombre de morceaux)… ===")
use_case = ImportRulesUseCase(llm=_build_provider(), extractor=extractor)
try:
result = await use_case.execute(pdf_bytes)
except LLMProviderError as exc:
print(f"✗ Échec LLM : {exc}")
return
if not result.sections:
print("\n✗ Aucune section proposée (le modèle n'a rien renvoyé d'exploitable).")
return
print(f"\n=== {len(result.sections)} section(s) proposée(s) ===")
for title, content in result.sections.items():
preview = content.strip().replace("\n", " ")[:90]
print(f"{title} ({len(content)} car.) — {preview}")
out_path = pdf_path.with_suffix(".rules.md")
out_path.write_text(result.to_markdown(), encoding="utf-8")
print(f"\n✓ Markdown complet écrit dans : {out_path}")
def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s%(message)s")
if len(sys.argv) != 2:
print("Usage : python scripts/test_import_rules.py \"chemin/vers/regles.pdf\"")
sys.exit(1)
pdf_path = Path(sys.argv[1]).expanduser()
if not pdf_path.is_file():
print(f"✗ Fichier introuvable : {pdf_path}")
sys.exit(1)
asyncio.run(_run(pdf_path))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,72 @@
"""Tests du use case de conseils d'adaptation (app.application.adapt_campaign)."""
from __future__ import annotations
import pytest
from app.application.adapt_campaign import AdaptCampaignUseCase
from app.domain.models import ChatMessage, ExtractedDocument, ExtractedPage
from app.domain.ports import PdfExtractionError
class FakeExtractor:
def __init__(self, doc: ExtractedDocument) -> None:
self._doc = doc
def extract(self, pdf_bytes: bytes) -> ExtractedDocument:
return self._doc
class FakeChatLLM:
def __init__(self, tokens: list[str]) -> None:
self._tokens = tokens
self.system_prompt: str | None = None
self.messages: list[ChatMessage] | None = None
async def stream_chat(self, messages, *, system_prompt=None, temperature=None):
self.messages = messages
self.system_prompt = system_prompt
for t in self._tokens:
yield t
def _doc(text: str) -> ExtractedDocument:
return ExtractedDocument(pages=[ExtractedPage(index=0, text=text, used_ocr=False)])
async def test_stream_yields_tokens_and_builds_context():
llm = FakeChatLLM(["con", "seil"])
uc = AdaptCampaignUseCase(llm, FakeExtractor(_doc("contenu du pdf")))
out = [t async for t in uc.stream(b"x", "mon brief de campagne",
[ChatMessage(role="user", content="aide")])]
assert out == ["con", "seil"]
assert "mon brief de campagne" in llm.system_prompt
assert "contenu du pdf" in llm.system_prompt
async def test_stream_empty_pdf_text_raises():
uc = AdaptCampaignUseCase(FakeChatLLM([]), FakeExtractor(_doc(" ")))
with pytest.raises(PdfExtractionError):
[t async for t in uc.stream(b"x", "brief", [])]
async def test_stream_injects_default_request_when_no_messages():
llm = FakeChatLLM(["ok"])
uc = AdaptCampaignUseCase(llm, FakeExtractor(_doc("texte du pdf")))
_ = [t async for t in uc.stream(b"x", "", [])]
assert llm.messages[0].role == "user"
assert "campagne" in llm.messages[0].content.lower()
def test_fit_pdf_short_text_not_truncated():
uc = AdaptCampaignUseCase(None, None, max_input_tokens=10000)
text, truncated = uc._fit_pdf_to_budget("court texte", "brief")
assert truncated is False
assert text == "court texte"
def test_fit_pdf_long_text_is_truncated():
uc = AdaptCampaignUseCase(None, None, max_input_tokens=2100)
long_text = "mot " * 5000
text, truncated = uc._fit_pdf_to_budget(long_text, "")
assert truncated is True
assert len(text) < len(long_text)

129
brain/tests/test_chat.py Normal file
View File

@@ -0,0 +1,129 @@
"""Tests de la construction du system prompt du chat (app.application.chat).
Assertions par INCLUSION (présence des données/sections clés) plutôt que sur le
texte exact des consignes : robuste aux retouches de formulation, tout en
vérifiant que chaque contexte est bien injecté.
"""
from __future__ import annotations
from app.application.chat import ChatUseCase
from app.domain.models import (
ArcSummary,
CampaignStructuralContext,
ChapterSummary,
CharacterSummary,
ChatMessage,
GameSystemContext,
JournalEntrySummary,
LoreStructuralContext,
NarrativeEntityContext,
NpcSummary,
PageContext,
PageSummary,
SceneSummary,
SessionContext,
)
def _build(**kw) -> str:
return ChatUseCase(None).build_system_prompt(**kw)
def _empty_lore() -> LoreStructuralContext:
return LoreStructuralContext(lore_name="L", lore_description=None, folders={}, tags=[])
def test_base_prompt_without_context_is_non_empty():
assert len(_build()) > 0
def test_lore_block_renders_pages_with_values_tags_and_links():
lore = LoreStructuralContext(
lore_name="Eldoria", lore_description="un monde sombre",
folders={"PNJ": [PageSummary(
title="Aragorn", template_name="Personnage",
values={"apparence": "grand et noble"}, tags=["héros"],
related_page_titles=["Gondor"])]},
tags=["dark-fantasy"])
p = _build(lore_context=lore)
assert "Eldoria" in p
assert "un monde sombre" in p
assert "Aragorn" in p
assert "apparence" in p and "grand et noble" in p
assert "héros" in p
assert "Gondor" in p
def test_empty_lore_signals_vide():
assert "Lore vide" in _build(lore_context=_empty_lore())
def test_page_context_block_lists_fields_and_empty_marker():
page = PageContext(title="Aragorn", template_name="Personnage",
template_fields=["apparence", "histoire"],
values={"apparence": "grand"})
p = _build(page_context=page)
assert "PAGE EN COURS" in p
assert "Aragorn" in p
assert "apparence" in p
assert "(vide)" in p # 'histoire' sans valeur
def test_campaign_block_with_arc_and_empty_pj_npc_and_no_lore_note():
camp = CampaignStructuralContext(
campaign_name="La Malédiction", campaign_description="horreur gothique",
arcs=[ArcSummary(name="Acte I", description="intro",
chapters=[ChapterSummary(name="Ch1", description="",
scenes=[SceneSummary(name="Sc1", description="")])])],
characters=[], npcs=[])
p = _build(campaign_context=camp)
assert "CAMPAGNE COURANTE" in p
assert "La Malédiction" in p
assert "Acte I" in p
assert "aucune fiche" in p # pas de PJ
assert "aucun univers" in p # pas de lore lié
def test_campaign_with_characters_npcs_and_lore_present_note():
camp = CampaignStructuralContext(
campaign_name="C", campaign_description=None, arcs=[],
characters=[CharacterSummary(name="Tav", snippet="roublarde")],
npcs=[NpcSummary(name="Strahd", snippet="vampire de Barovia")])
p = _build(campaign_context=camp, lore_context=_empty_lore())
assert "Tav" in p and "roublarde" in p
assert "Strahd" in p and "vampire de Barovia" in p
assert "liée à l'univers" in p
def test_game_system_narrative_and_session_sections_injected():
gs = GameSystemContext(system_name="Nimble", system_description=None,
sections={"Combat": "règles de combat"})
narr = NarrativeEntityContext(entity_type="scene", title="L'auberge du Portail",
fields={"ambiance": "tendue"})
sess = SessionContext(
session_name="Séance 3", active=True, started_at=None,
entries=[JournalEntrySummary(type="EVENT", content="Le pont s'effondre", occurred_at=None)],
previous_events=[])
p = _build(lore_context=_empty_lore(), game_system_context=gs,
narrative_entity=narr, session_context=sess)
assert "Nimble" in p
assert "L'auberge du Portail" in p
assert "Séance 3" in p
assert "Le pont s'effondre" in p
async def test_stream_passes_built_prompt_to_llm():
class FakeChatLLM:
def __init__(self) -> None:
self.system_prompt = None
async def stream_chat(self, messages, *, system_prompt=None, temperature=None):
self.system_prompt = system_prompt
yield "tok"
llm = FakeChatLLM()
out = [t async for t in ChatUseCase(llm).stream(
[ChatMessage(role="user", content="salut")],
lore_context=LoreStructuralContext("Eldoria", None, {}, []))]
assert out == ["tok"]
assert "Eldoria" in llm.system_prompt

View File

@@ -0,0 +1,72 @@
"""Tests du découpage de texte (app.application.chunking).
Vérifie le découpage par paragraphes vers une cible de tokens, le découpage des
paragraphes géants, le recouvrement (overlap), et le split_in_half du repli
anti-troncature. tiktoken (cl100k_base) est déterministe → assertions stables.
"""
from __future__ import annotations
from app.application.chunking import chunk_text, split_in_half
def test_empty_text_returns_no_chunk():
assert chunk_text("") == []
assert chunk_text(" \n\n ") == []
def test_short_text_stays_single_chunk():
chunks = chunk_text("Paragraphe un.\n\nParagraphe deux.", target_tokens=1000)
assert len(chunks) == 1
assert "Paragraphe un." in chunks[0]
assert "Paragraphe deux." in chunks[0]
def test_splits_into_several_chunks_when_exceeding_target():
paras = [f"Paragraphe numero {i} avec un peu de contenu." for i in range(20)]
full = "\n\n".join(paras)
chunks = chunk_text(full, target_tokens=20)
assert len(chunks) > 1
# Aucun paragraphe perdu : tous présents quelque part.
joined = "\n\n".join(chunks)
for p in paras:
assert p in joined
def test_oversized_single_paragraph_is_split():
# Un seul paragraphe (aucun "\n\n") plus gros que la cible → plusieurs sous-blocs.
huge = "mot " * 500
chunks = chunk_text(huge, target_tokens=50)
assert len(chunks) > 1
def test_overlap_repeats_content_without_losing_paragraphs():
paras = [f"Bloc {i} de texte distinct." for i in range(12)]
full = "\n\n".join(paras)
chunks = chunk_text(full, target_tokens=20, overlap_tokens=10)
assert len(chunks) > 1
joined = "\n\n".join(chunks)
for p in paras:
assert p in joined
# --- split_in_half -------------------------------------------------------------
def test_split_in_half_too_short_returns_empty():
assert split_in_half("court") == ("", "")
def test_split_in_half_splits_on_newline_near_middle():
text = "A" * 300 + "\n" + "B" * 300
left, right = split_in_half(text)
assert left and right
assert left.startswith("A")
assert right.startswith("B")
def test_split_in_half_halves_cover_all_content():
text = "\n".join(f"ligne {i} " + "x" * 20 for i in range(40))
left, right = split_in_half(text)
assert left and right
# Le découpage ne perd rien : la concaténation contient début et fin.
assert "ligne 0" in left
assert "ligne 39" in right

View File

@@ -0,0 +1,102 @@
"""Tests des adapters d'embeddings (Mistral cloud + Ollama local)."""
from __future__ import annotations
import json
import httpx
import pytest
import respx
from app.application.embeddings import EmbeddingError
from app.core.config import Settings
from app.infrastructure.mistral_embedding_adapter import MistralEmbeddingProvider
from app.infrastructure.ollama_embedding_adapter import OllamaEmbeddingProvider
_MISTRAL = "https://api.mistral.ai/v1/embeddings"
_OLLAMA = "http://ollama:11434/api/embed"
def _settings(**kw) -> Settings:
base = dict(_env_file=None, llm_timeout_seconds=30, ollama_base_url="http://ollama:11434")
base.update(kw)
return Settings(**base)
# --- Mistral -------------------------------------------------------------------
def test_mistral_missing_key_raises_at_construction():
with pytest.raises(EmbeddingError):
MistralEmbeddingProvider(_settings(mistral_api_key=""))
async def test_mistral_empty_texts_short_circuits():
svc = MistralEmbeddingProvider(_settings(mistral_api_key="k"))
assert await svc.embed([]) == []
@respx.mock
async def test_mistral_returns_vectors():
respx.post(_MISTRAL).mock(return_value=httpx.Response(200, json={
"data": [{"embedding": [0.1, 0.2]}, {"embedding": [0.3, 0.4]}]
}))
svc = MistralEmbeddingProvider(_settings(mistral_api_key="k", mistral_embedding_model="mistral-embed"))
vectors = await svc.embed(["texte un", "texte deux"])
assert vectors == [[0.1, 0.2], [0.3, 0.4]]
@respx.mock
async def test_mistral_http_error_raises():
respx.post(_MISTRAL).mock(return_value=httpx.Response(429, text="rate limit"))
svc = MistralEmbeddingProvider(_settings(mistral_api_key="k"))
with pytest.raises(EmbeddingError) as exc:
await svc.embed(["x"])
assert "429" in str(exc.value)
@respx.mock
async def test_mistral_size_mismatch_raises():
respx.post(_MISTRAL).mock(return_value=httpx.Response(200, json={"data": [{"embedding": [0.1]}]}))
svc = MistralEmbeddingProvider(_settings(mistral_api_key="k"))
with pytest.raises(EmbeddingError):
await svc.embed(["a", "b"]) # 2 demandés, 1 reçu
# --- Ollama --------------------------------------------------------------------
async def test_ollama_empty_texts_short_circuits():
svc = OllamaEmbeddingProvider(_settings(ollama_embedding_model="nomic-embed-text"))
assert await svc.embed([]) == []
@respx.mock
async def test_ollama_returns_vectors():
respx.post(_OLLAMA).mock(return_value=httpx.Response(200, json={"embeddings": [[0.1], [0.2]]}))
svc = OllamaEmbeddingProvider(_settings(ollama_embedding_model="mxbai-embed-large"))
assert await svc.embed(["a", "b"]) == [[0.1], [0.2]]
@respx.mock
async def test_ollama_applies_nomic_task_prefix():
route = respx.post(_OLLAMA).mock(return_value=httpx.Response(200, json={"embeddings": [[0.0]]}))
svc = OllamaEmbeddingProvider(_settings(ollama_embedding_model="nomic-embed-text"))
await svc.embed(["question ?"], kind="query")
sent = json.loads(route.calls.last.request.content)["input"]
assert sent == ["search_query: question ?"]
@respx.mock
async def test_ollama_no_prefix_for_non_nomic_model():
route = respx.post(_OLLAMA).mock(return_value=httpx.Response(200, json={"embeddings": [[0.0]]}))
svc = OllamaEmbeddingProvider(_settings(ollama_embedding_model="mxbai-embed-large"))
await svc.embed(["doc"], kind="document")
sent = json.loads(route.calls.last.request.content)["input"]
assert sent == ["doc"]
@respx.mock
async def test_ollama_http_error_mentions_pull_hint():
respx.post(_OLLAMA).mock(return_value=httpx.Response(404, text="model not found"))
svc = OllamaEmbeddingProvider(_settings(ollama_embedding_model="nomic-embed-text"))
with pytest.raises(EmbeddingError) as exc:
await svc.embed(["x"])
assert "ollama pull" in str(exc.value)

View File

@@ -0,0 +1,65 @@
"""Tests du use case de génération de page (app.application.generate_page)."""
from __future__ import annotations
import pytest
from app.application.generate_page import GeneratePageUseCase
from app.domain.models import PageGenerationContext
from app.domain.ports import LLMProviderError
_CTX = PageGenerationContext(
lore_name="Eldoria",
folder_name="PNJ",
template_name="Personnage",
template_fields=["apparence", "histoire"],
page_title="Aragorn",
lore_description="un monde sombre",
)
def test_build_prompt_includes_context_and_fields():
p = GeneratePageUseCase._build_prompt(_CTX, "fr")
assert "Eldoria" in p
assert "Aragorn" in p
assert '"apparence"' in p
assert "un monde sombre" in p
def test_build_prompt_omits_lore_description_when_absent():
ctx = PageGenerationContext("L", "F", "T", ["a"], "Titre", None)
assert "Description de l'univers" not in GeneratePageUseCase._build_prompt(ctx)
def test_parse_values_keeps_only_expected_fields():
out = GeneratePageUseCase._parse_values(
'{"apparence":"grand","histoire":"longue","extra":"ignoré"}',
["apparence", "histoire"])
assert out == {"apparence": "grand", "histoire": "longue"}
def test_parse_values_missing_field_becomes_empty_string():
out = GeneratePageUseCase._parse_values('{"apparence":"grand"}', ["apparence", "histoire"])
assert out == {"apparence": "grand", "histoire": ""}
def test_parse_values_casts_to_str_and_strips():
out = GeneratePageUseCase._parse_values('{"n": 42, "s": " x "}', ["n", "s"])
assert out == {"n": "42", "s": "x"}
def test_parse_values_bad_json_raises():
with pytest.raises(LLMProviderError):
GeneratePageUseCase._parse_values("pas du json", ["a"])
def test_parse_values_non_object_raises():
with pytest.raises(LLMProviderError):
GeneratePageUseCase._parse_values("[1, 2]", ["a"])
async def test_execute_returns_filtered_result():
class FakeLLM:
async def generate(self, prompt, *, output_format=None, temperature=None):
return '{"apparence":"grand","histoire":"épique","parasite":"x"}'
result = await GeneratePageUseCase(FakeLLM()).execute(_CTX)
assert result.values == {"apparence": "grand", "histoire": "épique"}

View File

@@ -0,0 +1,95 @@
"""Tests des parseurs robustes des use cases d'import (méthodes statiques).
_parse_payload (campagne), _parse_sections / _parse_anchors (règles) : transforment
la réponse brute du LLM en structure exploitable + un drapeau « tronqué » qui
déclenche le re-découpage.
"""
from __future__ import annotations
from app.application.import_campaign import ImportCampaignUseCase
from app.application.import_rules import ImportRulesUseCase
_parse_payload = ImportCampaignUseCase._parse_payload
_parse_sections = ImportRulesUseCase._parse_sections
_parse_anchors = ImportRulesUseCase._parse_anchors
# --- campagne : _parse_payload -------------------------------------------------
def test_parse_payload_valid():
payload, truncated = _parse_payload('{"arcs":[{"name":"A"}],"npcs":[{"name":"N"}]}', index=0)
assert truncated is False
assert payload == {"arcs": [{"name": "A"}], "npcs": [{"name": "N"}]}
def test_parse_payload_truncated_flags_recut():
payload, truncated = _parse_payload('{"arcs":[{"name":"A"', index=0)
assert truncated is True
assert payload == {"arcs": [], "npcs": []}
def test_parse_payload_prose_is_empty_not_truncated():
payload, truncated = _parse_payload('juste de la prose sans json', index=0)
assert truncated is False
assert payload == {"arcs": [], "npcs": []}
def test_parse_payload_recovers_truncated_array():
raw = '{"arcs":[{"name":"A"},{"name":"B"},{"name":'
payload, truncated = _parse_payload(raw, index=0)
assert truncated is True
assert payload == {"arcs": [{"name": "A"}, {"name": "B"}], "npcs": []}
def test_parse_payload_coerces_non_list_fields():
payload, _ = _parse_payload('{"arcs":"oops","npcs":null}', index=0)
assert payload == {"arcs": [], "npcs": []}
# --- règles : _parse_sections --------------------------------------------------
def test_parse_sections_valid_and_normalized():
sections, truncated = _parse_sections('{"sections":{"Combat":"texte"}}', index=0)
assert truncated is False
assert sections == {"Combat": "texte"}
def test_parse_sections_truncated():
sections, truncated = _parse_sections('{"Combat":"texte non termin', index=0)
assert truncated is True
assert sections == {}
def test_parse_sections_prose_is_empty():
sections, truncated = _parse_sections('pas de json ici', index=0)
assert truncated is False
assert sections == {}
# --- règles (mode segmentation) : _parse_anchors -------------------------------
def test_parse_anchors_locates_and_splits_text():
text = "Préambule.\nLE COMBAT commence ici, brutal.\nLA MAGIE ensuite, subtile."
raw = ('{"sections":[{"titre":"Combat","debut":"LE COMBAT commence"},'
'{"titre":"Magie","debut":"LA MAGIE ensuite"}]}')
sections, truncated = _parse_anchors(raw, text, index=0)
assert truncated is False
assert "Combat" in sections and "Magie" in sections
# La 1re section absorbe le préambule (avant la 1re ancre).
assert "Préambule." in sections["Combat"]
assert "LE COMBAT commence ici, brutal." in sections["Combat"]
assert "LA MAGIE ensuite, subtile." in sections["Magie"]
def test_parse_anchors_unparseable_returns_empty():
sections, truncated = _parse_anchors("pas du json", "texte", index=0)
assert sections == {}
assert truncated is False
def test_parse_anchors_anchor_not_found_is_dropped():
text = "Seulement ce paragraphe existe."
raw = '{"sections":[{"titre":"Fantôme","debut":"ancre absente du texte"}]}'
sections, _ = _parse_anchors(raw, text, index=0)
# Aucune ancre localisée → aucune section.
assert sections == {}

View File

@@ -0,0 +1,30 @@
"""Tests du canal de statut d'import (app.application.import_status)."""
from __future__ import annotations
import asyncio
from app.application import import_status
def test_notify_is_noop_without_queue():
# Hors import (aucune queue installée) : ne lève pas, ne fait rien.
import_status.notify_status("personne n'écoute") # ne doit pas lever
def test_notify_publishes_when_queue_installed():
queue: asyncio.Queue = asyncio.Queue()
token = import_status.set_status_queue(queue)
try:
import_status.notify_status("morceau re-découpé")
assert queue.get_nowait() == "morceau re-découpé"
finally:
import_status.reset_status_queue(token)
def test_reset_restores_noop():
queue: asyncio.Queue = asyncio.Queue()
token = import_status.set_status_queue(queue)
import_status.reset_status_queue(token)
# Après reset : plus de queue active → no-op, la queue reste vide.
import_status.notify_status("ignoré")
assert queue.empty()

View File

@@ -0,0 +1,157 @@
"""Tests des use cases d'import via FAKES (ports LLM + extracteur PDF).
Exerce la chaîne map-reduce complète (extraction → chunking → MAP → REDUCE →
streaming d'événements) SANS réseau ni vrai PDF. `chunk_text` est monkeypatché
pour un découpage déterministe (le chunking est testé à part). `asyncio.sleep`
est neutralisé pour que les backoffs de retry n'imposent aucune attente.
"""
from __future__ import annotations
import pytest
from app.application.import_campaign import ImportCampaignUseCase
from app.application.import_rules import ImportRulesUseCase
from app.domain.models import ExtractedDocument, ExtractedPage
from app.domain.ports import LLMProviderError
# --- fakes ---------------------------------------------------------------------
class FakeExtractor:
def __init__(self, doc: ExtractedDocument) -> None:
self._doc = doc
def extract(self, pdf_bytes: bytes) -> ExtractedDocument:
return self._doc
class ScriptedLLM:
"""Rejoue une réponse par appel (la dernière est répétée si on dépasse)."""
def __init__(self, responses: list) -> None:
self._responses = list(responses)
self.calls = 0
async def generate(self, prompt: str, *, output_format=None, temperature=None) -> str:
r = self._responses[min(self.calls, len(self._responses) - 1)]
self.calls += 1
if isinstance(r, Exception):
raise r
return r
class ContentLLM:
"""Répond selon le CONTENU du prompt (chunk) : (sous-chaîne → réponse/exception)."""
def __init__(self, rules: list) -> None:
self._rules = rules
async def generate(self, prompt: str, *, output_format=None, temperature=None) -> str:
for sub, r in self._rules:
if sub in prompt:
if isinstance(r, Exception):
raise r
return r
raise AssertionError(f"aucune règle ContentLLM ne matche : {prompt[:60]!r}")
def _doc(text: str = "Texte du PDF.", *, ocr: bool = False) -> ExtractedDocument:
return ExtractedDocument(pages=[ExtractedPage(index=0, text=text, used_ocr=ocr)])
@pytest.fixture
def no_sleep(monkeypatch):
async def _noop(_d):
return None
monkeypatch.setattr("asyncio.sleep", _noop)
@pytest.fixture
def one_chunk(monkeypatch):
monkeypatch.setattr("app.application.import_rules.chunk_text", lambda *a, **k: ["chunk"])
monkeypatch.setattr("app.application.import_campaign.chunk_text", lambda *a, **k: ["chunk"])
# --- import de règles ----------------------------------------------------------
async def test_rules_execute_returns_merged_sections(one_chunk):
llm = ScriptedLLM(['{"Combat":"## Combat\\nrègles de combat"}'])
uc = ImportRulesUseCase(llm, FakeExtractor(_doc(ocr=True)))
result = await uc.execute(b"pdf")
assert result.sections == {"Combat": "## Combat\nrègles de combat"}
assert result.page_count == 1
assert result.ocr_page_count == 1
async def test_rules_stream_emits_extracting_start_progress_done(one_chunk):
llm = ScriptedLLM(['{"Magie":"sorts"}'])
uc = ImportRulesUseCase(llm, FakeExtractor(_doc()))
events = [e async for e in uc.stream(b"pdf")]
types = [e["type"] for e in events]
assert types[0] == "extracting"
assert types[1] == "start"
assert "progress" in types
done = events[-1]
assert done["type"] == "done"
assert done["sections"] == {"Magie": "sorts"}
async def test_rules_stream_skips_failed_chunk_but_continues(monkeypatch, no_sleep):
monkeypatch.setattr("app.application.import_rules.chunk_text",
lambda *a, **k: ["AAA premier", "BBB second"])
llm = ContentLLM([
("AAA premier", LLMProviderError("HTTP 503 saturé")),
("BBB second", '{"Magie":"sorts"}'),
])
uc = ImportRulesUseCase(llm, FakeExtractor(_doc()))
events = [e async for e in uc.stream(b"pdf")]
types = [e["type"] for e in events]
assert "chunk_failed" in types
done = events[-1]
assert done["type"] == "done"
assert done["sections"] == {"Magie": "sorts"}
assert done["skipped"] == 1
async def test_rules_stream_all_chunks_fail_emits_error(one_chunk, no_sleep):
llm = ScriptedLLM([LLMProviderError("HTTP 500 panne")])
uc = ImportRulesUseCase(llm, FakeExtractor(_doc()))
events = [e async for e in uc.stream(b"pdf")]
assert events[-1]["type"] == "error"
assert "échoué" in events[-1]["message"]
# --- import de campagne --------------------------------------------------------
_TREE = ('{"arcs":[{"name":"Acte I","description":"intro",'
'"chapters":[{"name":"Ch1","scenes":[{"name":"Sc1"}]}]}],'
'"npcs":[{"name":"Gandalf","description":"magicien"}]}')
async def test_campaign_execute_builds_tree_and_npcs(one_chunk):
uc = ImportCampaignUseCase(ScriptedLLM([_TREE]), FakeExtractor(_doc()))
result = await uc.execute(b"pdf")
assert result.counts() == (1, 1, 1)
assert result.arcs[0].name == "Acte I"
assert result.arcs[0].chapters[0].scenes[0].name == "Sc1"
assert [n.name for n in result.npcs] == ["Gandalf"]
async def test_campaign_stream_emits_done_with_serialized_tree(one_chunk):
uc = ImportCampaignUseCase(ScriptedLLM([_TREE]), FakeExtractor(_doc()))
events = [e async for e in uc.stream(b"pdf")]
types = [e["type"] for e in events]
assert types[0] == "extracting"
assert types[1] == "start"
assert "progress" in types
done = events[-1]
assert done["type"] == "done"
assert done["arcs"][0]["name"] == "Acte I"
assert done["arcs"][0]["chapters"][0]["scenes"][0]["name"] == "Sc1"
assert done["npcs"] == [{"name": "Gandalf", "description": "magicien"}]
async def test_campaign_stream_all_fail_emits_error(one_chunk, no_sleep):
uc = ImportCampaignUseCase(ScriptedLLM([LLMProviderError("502")]), FakeExtractor(_doc()))
events = [e async for e in uc.stream(b"pdf")]
assert events[-1]["type"] == "error"

View File

@@ -0,0 +1,39 @@
"""Tests de la normalisation de langue (app.core.language)."""
from __future__ import annotations
import pytest
from app.core import language
@pytest.mark.parametrize("raw, expected", [
("fr", "fr"),
("en", "en"),
("EN", "en"),
("en-US", "en"),
("fr-FR,fr;q=0.9,en;q=0.8", "fr"),
("en-GB,en;q=0.9", "en"),
("de", "fr"), # non supporté → défaut
("", "fr"),
(None, "fr"),
(" EN-gb ", "en"), # casse + espaces tolérés
])
def test_normalize(raw, expected):
assert language.normalize(raw) == expected
def test_language_name_known_and_fallback():
assert language.language_name("fr") == "français"
assert language.language_name("en") == "anglais"
# Code inconnu → nom de la langue par défaut.
assert language.language_name("xx") == "français"
def test_instruction_mentions_target_language():
assert "anglais" in language.instruction("en")
assert "français" in language.instruction("fr")
def test_get_user_language_uses_normalize():
assert language.get_user_language("en-US") == "en"
assert language.get_user_language(None) == "fr"

View File

@@ -0,0 +1,130 @@
"""Tests de la lecture robuste de JSON depuis une réponse LLM (app.application.llm_json).
Couvre l'extraction du premier objet équilibré (en ignorant les accolades dans
les chaînes), la réparation d'un JSON tronqué, la détection « ça ressemble à du
JSON coupé », et le strip des blocs de raisonnement <think>…</think>.
"""
from __future__ import annotations
from app.application.llm_json import (
extract_json_object,
load_json_object,
looks_like_truncated_json,
repair_truncated_json,
)
# --- extract_json_object -------------------------------------------------------
def test_extract_simple_object():
assert extract_json_object('{"a": 1}') == '{"a": 1}'
def test_extract_ignores_surrounding_prose_and_fences():
raw = 'Voici le JSON :\n```json\n{"a": 1}\n```\nMerci.'
assert extract_json_object(raw) == '{"a": 1}'
def test_extract_stops_at_first_balanced_object():
assert extract_json_object('{"a": 1} et puis {"b": 2}') == '{"a": 1}'
def test_extract_keeps_nested_object_whole():
assert extract_json_object('{"a": {"b": 1}}') == '{"a": {"b": 1}}'
def test_extract_ignores_braces_inside_strings():
raw = '{"a": "}{ pas du json "}'
assert extract_json_object(raw) == raw
def test_extract_handles_escaped_quote_in_string():
raw = '{"a": "x\\"y"}'
assert extract_json_object(raw) == raw
def test_extract_returns_none_when_unclosed():
assert extract_json_object('{"a": 1') is None
def test_extract_returns_none_without_brace():
assert extract_json_object('aucune accolade ici') is None
def test_extract_returns_none_on_empty():
assert extract_json_object('') is None
# --- load_json_object ----------------------------------------------------------
def test_load_valid_object_not_recovered():
obj, recovered = load_json_object('{"x": 42}')
assert obj == {"x": 42}
assert recovered is False
def test_load_tolerates_raw_control_chars_in_strings():
# Retour à la ligne BRUT dans une chaîne : invalide en strict, accepté ici.
obj, recovered = load_json_object('{"a": "ligne1\nligne2"}')
assert obj == {"a": "ligne1\nligne2"}
assert recovered is False
def test_load_strips_reasoning_block_before_parsing():
raw = '<think>je réfléchis { ] [ }</think>{"ok": true}'
obj, recovered = load_json_object(raw)
assert obj == {"ok": True}
assert recovered is False
def test_load_repairs_truncated_array_and_flags_recovered():
raw = '{"items": [{"a": 1}, {"b": 2}, {"c":'
obj, recovered = load_json_object(raw)
assert obj == {"items": [{"a": 1}, {"b": 2}]}
assert recovered is True
def test_load_returns_none_on_garbage():
obj, recovered = load_json_object('juste de la prose sans json')
assert obj is None
assert recovered is False
# --- looks_like_truncated_json -------------------------------------------------
def test_truncated_detection_no_brace_is_false():
assert looks_like_truncated_json('rien') is False
def test_truncated_detection_short_object_start_unbalanced_is_true():
# Démarre par '{' et déséquilibré → coupé net, même très court.
assert looks_like_truncated_json('{"') is True
def test_truncated_detection_balanced_object_is_false():
assert looks_like_truncated_json('{"a": 1}') is False
def test_truncated_detection_short_prose_with_braces_is_false():
assert looks_like_truncated_json('texte { incomplet') is False
def test_truncated_detection_long_prose_unbalanced_is_true():
raw = 'prose ' * 30 + '{ structure ouverte mais jamais refermée'
assert len(raw) >= 100
assert looks_like_truncated_json(raw) is True
# --- repair_truncated_json -----------------------------------------------------
def test_repair_closes_open_containers_after_last_complete_element():
repaired = repair_truncated_json('{"items": [{"a": 1}, {"b": 2}, {"c":')
assert repaired == '{"items": [{"a": 1}, {"b": 2}]}'
def test_repair_returns_none_when_nothing_complete():
assert repair_truncated_json('{"a": "jamais fermé') is None
def test_repair_returns_none_without_brace():
assert repair_truncated_json('pas de json') is None

View File

@@ -0,0 +1,112 @@
"""Tests du retry des appels LLM one-shot (app.application.llm_retry).
`asyncio.sleep` est neutralisé (et enregistré) pour que les backoffs n'imposent
aucune attente réelle tout en vérifiant les durées choisies.
"""
from __future__ import annotations
import pytest
from app.application import llm_retry
from app.application.llm_retry import (
_is_daily_quota,
_is_rate_limit,
_suggested_retry_after,
generate_with_retry,
)
from app.domain.ports import LLMGenerationTimeout, LLMProviderError
class FakeLLM:
"""LLM factice : rejoue une liste de comportements (exception ou texte)."""
def __init__(self, behaviors: list) -> None:
self._behaviors = list(behaviors)
self.calls = 0
async def generate(self, prompt: str, *, output_format=None, temperature=None) -> str:
b = self._behaviors[self.calls]
self.calls += 1
if isinstance(b, Exception):
raise b
return b
@pytest.fixture
def slept(monkeypatch):
"""Neutralise asyncio.sleep et enregistre les durées demandées."""
recorded: list[float] = []
async def fake_sleep(d):
recorded.append(d)
monkeypatch.setattr("asyncio.sleep", fake_sleep)
return recorded
# --- helpers de classification -------------------------------------------------
def test_is_rate_limit():
assert _is_rate_limit(LLMProviderError("HTTP 429 Too Many Requests"))
assert _is_rate_limit(LLMProviderError("rate limit reached"))
assert not _is_rate_limit(LLMProviderError("HTTP 500"))
def test_is_daily_quota():
assert _is_daily_quota(LLMProviderError("free-models-per-day limit"))
assert _is_daily_quota(LLMProviderError("quota per day exceeded"))
assert not _is_daily_quota(LLMProviderError("429 per-minute"))
def test_suggested_retry_after():
assert _suggested_retry_after(LLMProviderError('{"retry_after_seconds": 8}')) == 8.0
assert _suggested_retry_after(LLMProviderError('Retry-After: 12')) == 12.0
assert _suggested_retry_after(LLMProviderError("pas de hint")) is None
# --- generate_with_retry -------------------------------------------------------
async def test_returns_on_first_success(slept):
llm = FakeLLM(["réponse"])
assert await generate_with_retry(llm, "p") == "réponse"
assert llm.calls == 1
assert slept == []
async def test_retries_transient_error_then_succeeds(slept):
llm = FakeLLM([LLMProviderError("HTTP 503"), "ok"])
assert await generate_with_retry(llm, "p") == "ok"
assert llm.calls == 2
assert slept == [3.0] # _BASE_DELAY_SECONDS
async def test_timeout_raises_immediately_without_retry(slept):
llm = FakeLLM([LLMGenerationTimeout("trop lent")])
with pytest.raises(LLMGenerationTimeout):
await generate_with_retry(llm, "p")
assert llm.calls == 1
assert slept == []
async def test_daily_quota_aborts_immediately(slept):
llm = FakeLLM([LLMProviderError("free-models-per-day exceeded")])
with pytest.raises(LLMProviderError):
await generate_with_retry(llm, "p")
assert llm.calls == 1
assert slept == []
async def test_exhausts_attempts_then_raises_last(slept):
llm = FakeLLM([LLMProviderError("503 a"), LLMProviderError("503 b"), LLMProviderError("503 c")])
with pytest.raises(LLMProviderError, match="503 c"):
await generate_with_retry(llm, "p")
assert llm.calls == 3
# 2 attentes entre 3 tentatives (backoff exponentiel 3s puis 6s).
assert slept == [3.0, 6.0]
async def test_rate_limit_respects_suggested_retry_after(slept):
llm = FakeLLM([LLMProviderError('429 {"retry_after_seconds": 8}'), "ok"])
assert await generate_with_retry(llm, "p") == "ok"
# min(8 + 2, 60) = 10
assert slept == [10.0]

View File

@@ -0,0 +1,52 @@
"""Tests de la logique portée par les modèles de domaine (app.domain.models)."""
from __future__ import annotations
from app.domain.models import (
ArcProposal,
CampaignImportResult,
ChapterProposal,
ExtractedDocument,
ExtractedPage,
RulesImportResult,
SceneProposal,
)
def test_extracted_document_properties():
doc = ExtractedDocument(pages=[
ExtractedPage(index=0, text="page un", used_ocr=False),
ExtractedPage(index=1, text="page deux", used_ocr=True),
ExtractedPage(index=2, text=" ", used_ocr=False), # vide → exclue de full_text
])
assert doc.page_count == 3
assert doc.ocr_page_count == 1
assert doc.full_text == "page un\n\npage deux"
def test_rules_import_result_to_markdown():
result = RulesImportResult(
sections={"Combat": "règles de combat", "Magie": "règles de magie"},
page_count=10, ocr_page_count=0,
)
md = result.to_markdown()
assert "## Combat\n\nrègles de combat" in md
assert "## Magie\n\nrègles de magie" in md
assert md.endswith("\n")
def test_campaign_import_result_counts():
arcs = [
ArcProposal("A1", "", chapters=[
ChapterProposal("C1", "", scenes=[SceneProposal("S1", ""), SceneProposal("S2", "")]),
ChapterProposal("C2", "", scenes=[SceneProposal("S3", "")]),
]),
ArcProposal("A2", "", chapters=[]),
]
result = CampaignImportResult(arcs=arcs, page_count=1, ocr_page_count=0)
assert result.counts() == (2, 2, 3)
def test_arc_proposal_defaults():
arc = ArcProposal("Acte", "synopsis")
assert arc.arc_type == "LINEAR"
assert arc.chapters == []

View File

@@ -0,0 +1,95 @@
"""Tests de caractérisation de l'adapter Ollama (protocole propre : /api/generate
one-shot + /api/chat NDJSON streamé)."""
from __future__ import annotations
import json
import httpx
import pytest
import respx
from app.core.config import Settings
from app.domain.models import ChatMessage
from app.domain.ports import LLMGenerationTimeout, LLMProviderError
from app.infrastructure.ollama_adapter import OllamaLLMProvider
_GEN = "http://ollama:11434/api/generate"
_CHAT = "http://ollama:11434/api/chat"
def _svc() -> OllamaLLMProvider:
s = Settings(_env_file=None, ollama_base_url="http://ollama:11434",
llm_model="gemma", llm_timeout_seconds=30, llm_num_ctx=8192)
return OllamaLLMProvider(s)
@respx.mock
async def test_generate_returns_response_field():
respx.post(_GEN).mock(return_value=httpx.Response(200, json={"response": "texte", "done_reason": "stop"}))
assert await _svc().generate("prompt") == "texte"
@respx.mock
async def test_generate_payload_always_sends_num_ctx_and_omits_temperature():
route = respx.post(_GEN).mock(return_value=httpx.Response(200, json={"response": "x"}))
await _svc().generate("p")
body = json.loads(route.calls.last.request.content)
assert body["model"] == "gemma"
assert body["stream"] is False
assert body["options"] == {"num_ctx": 8192}
assert "format" not in body
@respx.mock
async def test_generate_payload_includes_temperature_and_format_when_given():
route = respx.post(_GEN).mock(return_value=httpx.Response(200, json={"response": "x"}))
await _svc().generate("p", output_format="json", temperature=0.1)
body = json.loads(route.calls.last.request.content)
assert body["options"]["temperature"] == 0.1
assert body["format"] == "json"
@respx.mock
async def test_generate_http_error_surfaces_ollama_message():
respx.post(_GEN).mock(return_value=httpx.Response(404, json={"error": "model 'x' not found"}))
with pytest.raises(LLMProviderError) as exc:
await _svc().generate("p")
assert "not found" in str(exc.value)
assert "404" in str(exc.value)
@respx.mock
async def test_generate_read_timeout_is_generation_timeout():
respx.post(_GEN).mock(side_effect=httpx.ReadTimeout("trop lent"))
with pytest.raises(LLMGenerationTimeout):
await _svc().generate("p")
@respx.mock
async def test_generate_connect_timeout_is_provider_error():
respx.post(_GEN).mock(side_effect=httpx.ConnectTimeout("injoignable"))
with pytest.raises(LLMProviderError) as exc:
await _svc().generate("p")
assert not isinstance(exc.value, LLMGenerationTimeout)
@respx.mock
async def test_stream_chat_yields_tokens_until_done():
body = (
'{"message":{"content":"Bon"},"done":false}\n'
'{"message":{"content":"jour"},"done":false}\n'
'{"done":true}\n'
)
respx.post(_CHAT).mock(return_value=httpx.Response(200, text=body))
tokens = [t async for t in _svc().stream_chat([ChatMessage(role="user", content="hi")])]
assert tokens == ["Bon", "jour"]
@respx.mock
async def test_stream_chat_prepends_system_prompt():
route = respx.post(_CHAT).mock(return_value=httpx.Response(200, text='{"done":true}\n'))
_ = [t async for t in _svc().stream_chat(
[ChatMessage(role="user", content="Q")], system_prompt="SYS")]
body = json.loads(route.calls.last.request.content)
assert body["messages"][0] == {"role": "system", "content": "SYS"}
assert body["messages"][-1] == {"role": "user", "content": "Q"}

View File

@@ -0,0 +1,110 @@
"""Tests de l'adapter 1min.ai (API propriétaire : prompt unique aplati, SSE
`event: content`/`data:{content}`)."""
from __future__ import annotations
import httpx
import pytest
import respx
from app.core.config import Settings
from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError
from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
_URL = "https://api.1min.ai/api/chat-with-ai?isStreaming=true"
def _svc() -> OneMinAiLLMProvider:
s = Settings(_env_file=None, onemin_api_key="k", onemin_model="gpt-4o-mini",
llm_timeout_seconds=30)
return OneMinAiLLMProvider(s)
def _sse(*blocks: str) -> str:
return "".join(blocks)
# --- streaming -----------------------------------------------------------------
@respx.mock
async def test_generate_collects_content_chunks():
body = _sse(
"event: content\ndata: {\"content\": \"Bon\"}\n\n",
"event: content\ndata: {\"content\": \"jour\"}\n\n",
"event: done\ndata: {}\n\n",
)
respx.post(_URL).mock(return_value=httpx.Response(200, text=body))
assert await _svc().generate("salut") == "Bonjour"
@respx.mock
async def test_generate_sends_api_key_header_and_prompt_payload():
route = respx.post(_URL).mock(return_value=httpx.Response(
200, text="event: done\ndata: {}\n\n"))
await _svc().generate("ma question")
req = route.calls.last.request
assert req.headers["API-KEY"] == "k"
import json
body = json.loads(req.content)
assert body["model"] == "gpt-4o-mini"
assert body["promptObject"]["prompt"] == "ma question"
@respx.mock
async def test_error_event_raises_provider_error():
body = "event: error\ndata: {\"message\": \"quota dépassé\"}\n\n"
respx.post(_URL).mock(return_value=httpx.Response(200, text=body))
with pytest.raises(LLMProviderError) as exc:
await _svc().generate("p")
assert "quota dépassé" in str(exc.value)
@respx.mock
async def test_http_error_is_translated():
respx.post(_URL).mock(return_value=httpx.Response(502, text="bad gateway"))
with pytest.raises(LLMProviderError) as exc:
await _svc().generate("p")
assert "1min.ai" in str(exc.value)
@respx.mock
async def test_stream_chat_flattens_and_streams():
route = respx.post(_URL).mock(return_value=httpx.Response(
200, text="event: content\ndata: {\"content\": \"R\"}\n\nevent: done\ndata: {}\n\n"))
tokens = [t async for t in _svc().stream_chat(
[ChatMessage(role="user", content="Q")], system_prompt="SYS")]
assert tokens == ["R"]
import json
prompt = json.loads(route.calls.last.request.content)["promptObject"]["prompt"]
assert "[SYSTEM]" in prompt and "SYS" in prompt
assert "[USER]" in prompt and "Q" in prompt
# --- helpers purs --------------------------------------------------------------
def test_flatten_messages_structure():
out = OneMinAiLLMProvider._flatten_messages(
[ChatMessage(role="user", content="Q1"), ChatMessage(role="assistant", content="R1")],
"instructions système",
)
assert "[SYSTEM]\ninstructions système" in out
assert "[USER]\nQ1" in out
assert "[ASSISTANT]\nR1" in out
assert out.rstrip().endswith("[ASSISTANT]")
def test_extract_content_chunk_json_and_fallback():
assert OneMinAiLLMProvider._extract_content_chunk('{"content": "x"}') == "x"
assert OneMinAiLLMProvider._extract_content_chunk('{"token": "y"}') == "y"
# Non-JSON : filet de sécurité, on renvoie le brut.
assert OneMinAiLLMProvider._extract_content_chunk("texte brut") == "texte brut"
def test_extract_result_reads_nested_result_object():
payload = {"aiRecord": {"aiRecordDetail": {"resultObject": ["partie 1", "partie 2"]}}}
assert OneMinAiLLMProvider._extract_result(payload) == "partie 1partie 2"
def test_extract_result_raises_on_unexpected_schema():
with pytest.raises(LLMProviderError):
OneMinAiLLMProvider._extract_result({"unexpected": True})

View File

@@ -0,0 +1,204 @@
"""Tests de caractérisation des adapters LLM « OpenAI-compatible »
(OpenRouter, Gemini, Mistral).
But : VERROUILLER le comportement observable AVANT d'extraire une classe de base
commune (les trois adapters partageaient ~80 % de code). On couvre via respx
(mock du transport httpx) : collecte du stream, payload envoyé, en-têtes, parsing
SSE, et traduction des erreurs HTTP — sans aucun appel réseau réel.
Ces tests doivent rester verts à l'identique après le refactor.
"""
from __future__ import annotations
import json
import httpx
import pytest
import respx
from app.core.config import Settings
from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError
from app.infrastructure.gemini_adapter import GeminiLLMProvider
from app.infrastructure.mistral_adapter import MistralLLMProvider
from app.infrastructure.openrouter_adapter import OpenRouterLLMProvider
def _settings(**kw) -> Settings:
return Settings(_env_file=None, llm_timeout_seconds=30, **kw)
def _sse(*contents: str) -> str:
"""Construit un corps SSE OpenAI : une trame `data: {choices:[{delta:{content}}]}`
par fragment, terminé par `data: [DONE]`."""
lines: list[str] = []
for c in contents:
lines.append("data: " + json.dumps({"choices": [{"delta": {"content": c}}]}))
lines.append("")
lines += ["data: [DONE]", ""]
return "\n".join(lines)
# (id, classe, url, kwargs settings (clé+modèle), supporte response_format=json_object)
CASES = [
pytest.param(
OpenRouterLLMProvider,
"https://openrouter.ai/api/v1/chat/completions",
dict(openrouter_api_key="k", openrouter_model="m"),
False,
"OpenRouter",
id="openrouter",
),
pytest.param(
GeminiLLMProvider,
"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
dict(gemini_api_key="k", gemini_model="m"),
True,
"Gemini",
id="gemini",
),
pytest.param(
MistralLLMProvider,
"https://api.mistral.ai/v1/chat/completions",
dict(mistral_api_key="k", mistral_model="m"),
True,
"Mistral",
id="mistral",
),
]
@pytest.mark.parametrize("cls, url, skw, supports_json, label", CASES)
@respx.mock
async def test_generate_collects_full_stream(cls, url, skw, supports_json, label):
respx.post(url).mock(return_value=httpx.Response(200, text=_sse("Bonjour", " le", " monde")))
svc = cls(_settings(**skw))
assert await svc.generate("salut") == "Bonjour le monde"
@pytest.mark.parametrize("cls, url, skw, supports_json, label", CASES)
@respx.mock
async def test_stream_chat_yields_tokens(cls, url, skw, supports_json, label):
respx.post(url).mock(return_value=httpx.Response(200, text=_sse("A", "B", "C")))
svc = cls(_settings(**skw))
tokens = [t async for t in svc.stream_chat([ChatMessage(role="user", content="hi")])]
assert tokens == ["A", "B", "C"]
@pytest.mark.parametrize("cls, url, skw, supports_json, label", CASES)
@respx.mock
async def test_payload_system_prompt_and_temperature(cls, url, skw, supports_json, label):
route = respx.post(url).mock(return_value=httpx.Response(200, text=_sse("x")))
svc = cls(_settings(**skw))
_ = [t async for t in svc.stream_chat(
[ChatMessage(role="user", content="Q")],
system_prompt="SYS",
temperature=0.5,
)]
body = json.loads(route.calls.last.request.content)
assert body["model"] == "m"
assert body["stream"] is True
assert body["messages"][0] == {"role": "system", "content": "SYS"}
assert body["messages"][-1] == {"role": "user", "content": "Q"}
assert body["temperature"] == 0.5
@pytest.mark.parametrize("cls, url, skw, supports_json, label", CASES)
@respx.mock
async def test_payload_omits_temperature_when_none(cls, url, skw, supports_json, label):
route = respx.post(url).mock(return_value=httpx.Response(200, text=_sse("x")))
svc = cls(_settings(**skw))
await svc.generate("p")
body = json.loads(route.calls.last.request.content)
assert "temperature" not in body
# Sans system_prompt, generate envoie un unique message user.
assert body["messages"] == [{"role": "user", "content": "p"}]
@pytest.mark.parametrize("cls, url, skw, supports_json, label", CASES)
@respx.mock
async def test_response_format_json_only_when_supported(cls, url, skw, supports_json, label):
route = respx.post(url).mock(return_value=httpx.Response(200, text=_sse("{}")))
svc = cls(_settings(**skw))
await svc.generate("p", output_format="json")
body = json.loads(route.calls.last.request.content)
if supports_json:
assert body["response_format"] == {"type": "json_object"}
else:
assert "response_format" not in body
@pytest.mark.parametrize("cls, url, skw, supports_json, label", CASES)
@respx.mock
async def test_authorization_header_bearer(cls, url, skw, supports_json, label):
route = respx.post(url).mock(return_value=httpx.Response(200, text=_sse("x")))
svc = cls(_settings(**skw))
await svc.generate("p")
assert route.calls.last.request.headers["Authorization"] == "Bearer k"
@respx.mock
async def test_openrouter_attribution_headers():
route = respx.post("https://openrouter.ai/api/v1/chat/completions").mock(
return_value=httpx.Response(200, text=_sse("x")))
svc = OpenRouterLLMProvider(_settings(openrouter_api_key="k", openrouter_model="m"))
await svc.generate("p")
headers = route.calls.last.request.headers
assert headers["HTTP-Referer"] == "https://loremind.app"
assert headers["X-Title"] == "LoreMind"
@pytest.mark.parametrize("cls, url, skw, supports_json, label", CASES)
@respx.mock
async def test_http_error_translated_to_provider_error(cls, url, skw, supports_json, label):
respx.post(url).mock(return_value=httpx.Response(429, text="quota exceeded"))
svc = cls(_settings(**skw))
with pytest.raises(LLMProviderError) as exc:
await svc.generate("p")
msg = str(exc.value)
assert label in msg
assert "429" in msg
assert "quota exceeded" in msg
@respx.mock
async def test_gemini_rejected_key_gives_actionable_message():
respx.post(
"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions"
).mock(return_value=httpx.Response(403, text="API key not valid"))
svc = GeminiLLMProvider(_settings(gemini_api_key="k", gemini_model="m"))
with pytest.raises(LLMProviderError) as exc:
await svc.generate("p")
assert "refusée par Google" in str(exc.value)
@pytest.mark.parametrize("cls, url, skw, supports_json, label", CASES)
@respx.mock
async def test_sse_skips_keepalive_and_malformed_lines(cls, url, skw, supports_json, label):
body = "\n".join([
": OPENROUTER PROCESSING", # commentaire keep-alive
"",
"data: not-json", # JSON invalide -> ignoré
"",
"data: " + json.dumps({"choices": []}), # pas de choix -> ignoré
"",
"data: " + json.dumps({"choices": [{"delta": {}}]}), # delta sans content -> ignoré
"",
"data: " + json.dumps({"choices": [{"delta": {"content": "OK"}}]}),
"",
"data: [DONE]",
"",
])
respx.post(url).mock(return_value=httpx.Response(200, text=body))
svc = cls(_settings(**skw))
assert await svc.generate("p") == "OK"
@pytest.mark.parametrize("cls, skw", [
pytest.param(OpenRouterLLMProvider, dict(openrouter_api_key=""), id="openrouter"),
pytest.param(GeminiLLMProvider, dict(gemini_api_key=""), id="gemini"),
pytest.param(MistralLLMProvider, dict(mistral_api_key=""), id="mistral"),
])
def test_missing_api_key_raises_at_construction(cls, skw):
with pytest.raises(LLMProviderError):
cls(_settings(**skw))

View File

@@ -0,0 +1,54 @@
"""Tests de la réécriture de question autonome (app.application.query_rewrite)."""
from __future__ import annotations
from app.application.query_rewrite import standalone_question
from app.domain.models import ChatMessage
class FakeLLM:
def __init__(self, response: str | None = None, exc: Exception | None = None) -> None:
self.response = response
self.exc = exc
self.called = False
async def generate(self, prompt, *, temperature=None, output_format=None) -> str:
self.called = True
if self.exc:
raise self.exc
return self.response
async def test_single_turn_returns_last_user_without_calling_llm():
llm = FakeLLM()
q = await standalone_question(llm, [ChatMessage(role="user", content="Qui est Strahd ?")])
assert q == "Qui est Strahd ?"
assert llm.called is False
async def test_multi_turn_uses_llm_rewrite_and_strips_quotes():
llm = FakeLLM(response='"Quelles sont les faiblesses de Strahd ?"')
msgs = [
ChatMessage(role="user", content="Qui est Strahd ?"),
ChatMessage(role="assistant", content="Un vampire."),
ChatMessage(role="user", content="Et ses faiblesses ?"),
]
assert await standalone_question(llm, msgs) == "Quelles sont les faiblesses de Strahd ?"
assert llm.called is True
async def test_llm_failure_falls_back_to_last_user():
llm = FakeLLM(exc=RuntimeError("LLM HS"))
msgs = [ChatMessage(role="user", content="A"), ChatMessage(role="user", content="B")]
assert await standalone_question(llm, msgs) == "B"
async def test_suspiciously_long_rewrite_falls_back():
llm = FakeLLM(response="x" * 500)
msgs = [ChatMessage(role="user", content="A"), ChatMessage(role="user", content="B")]
assert await standalone_question(llm, msgs) == "B"
async def test_empty_messages_returns_empty_string():
llm = FakeLLM()
assert await standalone_question(llm, []) == ""
assert llm.called is False

View File

@@ -0,0 +1,60 @@
"""Tests du reranking LLM des passages RAG (app.application.rerank)."""
from __future__ import annotations
from app.application.rerank import pool_size, rerank
class FakeLLM:
def __init__(self, response: str | None = None, exc: Exception | None = None) -> None:
self.response = response
self.exc = exc
async def generate(self, prompt, *, temperature=None, output_format=None) -> str:
if self.exc:
raise self.exc
return self.response
def test_pool_size():
assert pool_size(8) == 24 # min(max(24, 8), 24)
assert pool_size(4) == 12 # 4 * 3
assert pool_size(10) == 24 # plafonné à POOL_MAX
assert pool_size(1) == 3
async def test_rerank_skips_when_pool_not_larger_than_top_k():
passages = [{"text": "a"}, {"text": "b"}]
# len <= top_k → renvoyé tel quel, sans appel LLM.
assert await rerank(FakeLLM(exc=AssertionError("ne doit pas être appelé")),
"q", passages, top_k=3) == passages
async def test_rerank_reorders_by_llm_scores():
passages = [{"text": "a"}, {"text": "b"}, {"text": "c"}]
out = await rerank(FakeLLM(response='{"scores":[1, 9, 5]}'), "q", passages, top_k=2)
assert [p["text"] for p in out] == ["b", "c"]
async def test_rerank_stable_on_score_ties():
passages = [{"text": "a"}, {"text": "b"}, {"text": "c"}]
# Notes égales → ordre cosinus d'origine préservé.
out = await rerank(FakeLLM(response='{"scores":[5, 5, 5]}'), "q", passages, top_k=2)
assert [p["text"] for p in out] == ["a", "b"]
async def test_rerank_llm_failure_falls_back_to_cosine_order():
passages = [{"text": "a"}, {"text": "b"}, {"text": "c"}]
out = await rerank(FakeLLM(exc=RuntimeError("LLM HS")), "q", passages, top_k=2)
assert [p["text"] for p in out] == ["a", "b"]
async def test_rerank_wrong_score_count_falls_back():
passages = [{"text": "a"}, {"text": "b"}, {"text": "c"}]
out = await rerank(FakeLLM(response='{"scores":[1, 2]}'), "q", passages, top_k=2)
assert [p["text"] for p in out] == ["a", "b"]
async def test_rerank_non_numeric_scores_fall_back():
passages = [{"text": "a"}, {"text": "b"}, {"text": "c"}]
out = await rerank(FakeLLM(response='{"scores":["x","y","z"]}'), "q", passages, top_k=2)
assert [p["text"] for p in out] == ["a", "b"]

View File

@@ -0,0 +1,107 @@
"""Tests des helpers de l'import de règles (app.application.import_rules) :
_SectionMerger, _normalize_sections, _coerce_markdown, _find_anchor, _combine_sections.
"""
from __future__ import annotations
from app.application.import_rules import (
_SectionMerger,
_coerce_markdown,
_combine_sections,
_find_anchor,
_normalize_sections,
)
# --- _SectionMerger ------------------------------------------------------------
def test_section_merger_case_insensitive_and_joins():
m = _SectionMerger()
touched = m.add({"Combat": "règle A", "combat": "règle B"})
assert touched == ["Combat"] # clé canonique = 1re vue
res = m.result()
assert list(res.keys()) == ["Combat"]
assert res["Combat"] == "règle A\n\nrègle B"
def test_section_merger_skips_empty_title_or_content():
m = _SectionMerger()
touched = m.add({"": "x", "Titre": " ", "Vrai": "contenu"})
assert touched == ["Vrai"]
assert m.result() == {"Vrai": "contenu"}
def test_section_merger_accumulates_across_chunks():
m = _SectionMerger()
m.add({"Combat": "p1"})
m.add({"Combat": "p2", "Magie": "sorts"})
res = m.result()
assert res["Combat"] == "p1\n\np2"
assert res["Magie"] == "sorts"
# --- _normalize_sections -------------------------------------------------------
def test_normalize_unwraps_known_envelope():
assert _normalize_sections({"sections": {"Combat": "x"}}) == {"Combat": "x"}
assert _normalize_sections({"règles": {"A": "y"}}) == {"A": "y"}
def test_normalize_title_content_schema():
assert _normalize_sections({"title": "Combat", "content": "texte"}) == {"Combat": "texte"}
def test_normalize_strips_meta_keys():
assert _normalize_sections({"Combat": "x", "thought": "bla", "notes": "y"}) == {"Combat": "x"}
def test_normalize_passthrough_plain_sections():
assert _normalize_sections({"A": "1", "B": "2"}) == {"A": "1", "B": "2"}
# --- _coerce_markdown ----------------------------------------------------------
def test_coerce_markdown_string_passthrough():
assert _coerce_markdown("texte") == "texte"
def test_coerce_markdown_none_is_empty():
assert _coerce_markdown(None) == ""
def test_coerce_markdown_list_joined():
assert _coerce_markdown(["a", "b"]) == "a\n\nb"
def test_coerce_markdown_dict_flattened():
out = _coerce_markdown({"Sous-titre": "contenu"})
assert "Sous-titre" in out
assert "contenu" in out
# --- _find_anchor --------------------------------------------------------------
def test_find_anchor_exact():
text = "Chapitre 1. Le héros entre."
assert _find_anchor(text, "Le héros entre", 0) == text.index("Le héros entre")
def test_find_anchor_whitespace_flexible():
text = "Le héros\nentre dans la taverne."
# Espaces multiples / saut de ligne dans le texte source, anchor normalisé.
assert _find_anchor(text, "Le héros entre dans la taverne", 0) is not None
def test_find_anchor_case_insensitive():
assert _find_anchor("LE DONJON s'ouvre", "le donjon", 0) is not None
def test_find_anchor_not_found():
assert _find_anchor("texte quelconque", "introuvable xyz", 0) is None
# --- _combine_sections ---------------------------------------------------------
def test_combine_sections_case_insensitive_concat():
out = _combine_sections({"Combat": "p1"}, {"combat": "p2", "Magie": "sorts"})
assert out["Combat"] == "p1\n\np2"
assert out["Magie"] == "sorts"

View File

@@ -0,0 +1,57 @@
"""Tests des overrides runtime persistés (app.core.settings_store).
Le chemin du fichier est redirigé vers un tmp_path pour isoler chaque test.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from app.core import settings_store
@pytest.fixture(autouse=True)
def isolated_store(tmp_path, monkeypatch):
monkeypatch.setattr(settings_store, "_OVERRIDES_PATH", tmp_path / "settings.json")
return tmp_path / "settings.json"
def test_load_missing_file_returns_empty():
assert settings_store.load_overrides() == {}
def test_save_filters_to_allowlist_and_persists(isolated_store):
result = settings_store.save_overrides({
"llm_model": "gemma3:12b",
"internal_shared_secret": "HACK", # hors allow-list → ignoré
"champ_inconnu": "x", # hors allow-list → ignoré
})
assert result == {"llm_model": "gemma3:12b"}
on_disk = json.loads(Path(isolated_store).read_text(encoding="utf-8"))
assert on_disk == {"llm_model": "gemma3:12b"}
def test_save_merges_with_existing():
settings_store.save_overrides({"llm_model": "a"})
merged = settings_store.save_overrides({"llm_provider": "ollama"})
assert merged == {"llm_model": "a", "llm_provider": "ollama"}
def test_load_ignores_non_allowlisted_keys_on_disk(isolated_store):
Path(isolated_store).write_text(
json.dumps({"llm_model": "ok", "internal_shared_secret": "leak"}),
encoding="utf-8",
)
assert settings_store.load_overrides() == {"llm_model": "ok"}
def test_load_corrupted_file_returns_empty(isolated_store):
Path(isolated_store).write_text("{ pas du json", encoding="utf-8")
assert settings_store.load_overrides() == {}
def test_load_non_dict_json_returns_empty(isolated_store):
Path(isolated_store).write_text("[1, 2, 3]", encoding="utf-8")
assert settings_store.load_overrides() == {}

View File

@@ -0,0 +1,48 @@
"""Tests des heartbeats SSE (app.application.streaming.with_heartbeat)."""
from __future__ import annotations
import asyncio
import pytest
from app.application.streaming import with_heartbeat
async def _collect(agen) -> list[tuple[str, object]]:
return [ev async for ev in agen]
async def test_fast_coro_emits_only_result():
async def quick() -> int:
return 42
events = await _collect(with_heartbeat(quick(), interval=0.05))
assert events == [("result", 42)]
async def test_slow_coro_emits_heartbeats_then_result():
async def slow() -> str:
await asyncio.sleep(0.06)
return "fini"
events = await _collect(with_heartbeat(slow(), interval=0.02))
assert ("heartbeat", None) in events
assert events[-1] == ("result", "fini")
async def test_relays_status_messages_from_queue():
queue: asyncio.Queue = asyncio.Queue()
async def work() -> str:
await asyncio.sleep(0.05)
return "ok"
queue.put_nowait("fournisseur saturé, nouvel essai")
events = await _collect(with_heartbeat(work(), interval=0.02, status_queue=queue))
assert ("status", "fournisseur saturé, nouvel essai") in events
assert events[-1] == ("result", "ok")
async def test_propagates_coro_exception():
async def boom() -> None:
raise ValueError("échec interne")
with pytest.raises(ValueError, match="échec interne"):
await _collect(with_heartbeat(boom(), interval=0.05))

View File

@@ -0,0 +1,149 @@
"""Tests du _TreeMerger de l'import de campagne (app.application.import_campaign).
Cœur du REDUCE : fusion par nom (insensible à la casse) des sous-arbres
arc→chapitre→scène→pièce produits morceau par morceau, + accumulation des PNJ.
"""
from __future__ import annotations
from app.application.import_campaign import _TreeMerger
def test_single_chunk_builds_full_tree():
m = _TreeMerger()
m.add([{
"name": "Acte I", "description": "intro",
"chapters": [{
"name": "Ch1", "description": "d",
"scenes": [{
"name": "Sc1", "description": "s",
"player_narration": "PN", "gm_notes": "GM",
"rooms": [{"name": "R1", "description": "rd", "enemies": "gob", "loot": "or"}],
}],
}],
}])
arcs = m.result()
assert len(arcs) == 1
arc = arcs[0]
assert arc.name == "Acte I"
assert arc.arc_type == "LINEAR"
sc = arc.chapters[0].scenes[0]
assert sc.player_narration == "PN"
assert sc.gm_notes == "GM"
room = sc.rooms[0]
assert (room.name, room.enemies, room.loot) == ("R1", "gob", "or")
def test_case_insensitive_arc_and_chapter_merge():
m = _TreeMerger()
m.add([{"name": "Acte I", "chapters": [{"name": "Ch1", "scenes": []}]}])
m.add([{"name": "acte i", "chapters": [{"name": "ch1", "scenes": []},
{"name": "Ch2", "scenes": []}]}])
arcs = m.result()
assert len(arcs) == 1
assert {c.name for c in arcs[0].chapters} == {"Ch1", "Ch2"}
def test_description_first_non_empty_wins():
m = _TreeMerger()
m.add([{"name": "A", "description": "", "chapters": []}])
m.add([{"name": "A", "description": "vraie", "chapters": []}])
m.add([{"name": "A", "description": "autre", "chapters": []}])
assert m.result()[0].description == "vraie"
def test_hub_type_wins_if_any_chunk_signals_it():
m = _TreeMerger()
m.add([{"name": "A", "type": "LINEAR", "chapters": []}])
m.add([{"name": "A", "type": "HUB", "chapters": []}])
assert m.result()[0].arc_type == "HUB"
def _scene(narr=None, gm=None):
s = {"name": "S"}
if narr is not None:
s["player_narration"] = narr
if gm is not None:
s["gm_notes"] = gm
return {"name": "A", "chapters": [{"name": "C", "scenes": [s]}]}
def test_scene_narration_concatenated_across_chunks():
m = _TreeMerger()
m.add([_scene(narr="début")])
m.add([_scene(narr="suite")])
sc = m.result()[0].chapters[0].scenes[0]
assert sc.player_narration == "début\n\nsuite"
def test_scene_field_dedups_exact_overlap():
m = _TreeMerger()
m.add([_scene(gm="texte identique")])
m.add([_scene(gm="texte identique")])
assert m.result()[0].chapters[0].scenes[0].gm_notes == "texte identique"
def test_scene_field_takes_superset_version():
m = _TreeMerger()
m.add([_scene(gm="court")])
m.add([_scene(gm="court et bien plus long")])
assert m.result()[0].chapters[0].scenes[0].gm_notes == "court et bien plus long"
def test_npcs_longest_description_wins():
m = _TreeMerger()
m.add_npcs([{"name": "Thorin", "description": "court"}])
m.add_npcs([{"name": "thorin", "description": "une description bien plus complète"}])
npcs = m.npcs()
assert len(npcs) == 1
assert npcs[0].name == "Thorin"
assert npcs[0].description == "une description bien plus complète"
def test_counts():
m = _TreeMerger()
m.add([{"name": "A", "chapters": [
{"name": "C1", "scenes": [{"name": "S1"}, {"name": "S2"}]},
{"name": "C2", "scenes": []},
]}])
assert m.counts() == (1, 2, 2)
def test_blank_names_are_skipped():
m = _TreeMerger()
m.add([{"name": "", "chapters": []},
{"name": " ", "chapters": []},
{"name": "OK", "chapters": [{"name": "", "scenes": []}]}])
arcs = m.result()
assert len(arcs) == 1
assert arcs[0].name == "OK"
assert arcs[0].chapters == []
def test_merge_chapters_consolidation():
m = _TreeMerger()
m.add([{"name": "A", "chapters": [
{"name": "Intro", "scenes": [{"name": "S1"}]},
{"name": "Introduction", "scenes": [{"name": "S2"}]},
]}])
assert m.merge_chapters("Intro", ["Introduction"]) is True
chapters = m.result()[0].chapters
assert len(chapters) == 1
assert {s.name for s in chapters[0].scenes} == {"S1", "S2"}
def test_merge_chapters_unknown_target_returns_false():
m = _TreeMerger()
m.add([{"name": "A", "chapters": [{"name": "Intro", "scenes": []}]}])
assert m.merge_chapters("Inexistant", ["Intro"]) is False
def test_merge_scenes_consolidation():
m = _TreeMerger()
m.add([{"name": "A", "chapters": [{"name": "C", "scenes": [
{"name": "Combat", "gm_notes": "x"},
{"name": "Le combat", "gm_notes": "y"},
]}]}])
assert m.merge_scenes("C", "Combat", ["Le combat"]) is True
scenes = m.result()[0].chapters[0].scenes
assert len(scenes) == 1
assert scenes[0].name == "Combat"

View File

@@ -0,0 +1,119 @@
"""Tests du stockage vectoriel fichier + recherche hybride (app.infrastructure.vector_store).
Le répertoire de stockage est redirigé vers un tmp_path et le cache mémoire est
vidé avant chaque test pour une isolation totale.
"""
from __future__ import annotations
import pytest
from app.infrastructure import vector_store
@pytest.fixture(autouse=True)
def isolated_store(tmp_path, monkeypatch):
monkeypatch.setattr(vector_store, "_STORE_DIR", tmp_path)
vector_store._CACHE.clear()
yield
vector_store._CACHE.clear()
# --- cosinus -------------------------------------------------------------------
def test_cosine_identical_is_one():
assert vector_store._cosine([1.0, 0.0], [2.0, 0.0]) == pytest.approx(1.0)
def test_cosine_orthogonal_is_zero():
assert vector_store._cosine([1.0, 0.0], [0.0, 1.0]) == 0.0
def test_cosine_mismatched_or_zero_is_zero():
assert vector_store._cosine([1.0], [1.0, 2.0]) == 0.0
assert vector_store._cosine([0.0, 0.0], [1.0, 1.0]) == 0.0
assert vector_store.cosine_similarity([], [1.0]) == 0.0 # alias public
# --- mots significatifs --------------------------------------------------------
def test_significant_words_filters_stopwords_and_short():
words = vector_store._significant_words("Le dragon DORT dans la caverne avec les gobelins")
assert "dragon" in words
assert "caverne" in words
assert "gobelins" in words
assert "les" not in words and "avec" not in words and "la" not in words
# --- save / exists / delete ----------------------------------------------------
def test_save_then_exists_and_delete():
vector_store.save("src1", ["chunk a"], [[1.0, 0.0]])
assert vector_store.exists("src1") is True
vector_store.delete("src1")
assert vector_store.exists("src1") is False
def test_save_rejects_mismatched_lengths():
with pytest.raises(ValueError):
vector_store.save("s", ["a", "b"], [[1.0]])
with pytest.raises(ValueError):
vector_store.save("s", ["a"], [[1.0]], pages=[1, 2])
def test_all_chunks_returns_text_and_page():
vector_store.save("s", ["t1", "t2"], [[1.0], [2.0]], pages=[3, 7])
chunks = vector_store.all_chunks("s")
assert chunks == [{"text": "t1", "page": 3}, {"text": "t2", "page": 7}]
# --- recherche -----------------------------------------------------------------
def test_search_ranks_by_cosine():
vector_store.save("s", ["proche", "loin"], [[1.0, 0.0], [0.0, 1.0]])
results = vector_store.search(["s"], [1.0, 0.0], top_k=2)
assert [r["text"] for r in results] == ["proche", "loin"]
assert results[0]["score"] > results[1]["score"]
def test_search_respects_top_k():
vector_store.save("s", ["a", "b", "c"], [[1.0], [0.9], [0.8]])
assert len(vector_store.search(["s"], [1.0], top_k=2)) == 2
def test_search_min_score_filters_out_weak_matches():
vector_store.save("s", ["proche", "orthogonal"], [[1.0, 0.0], [0.0, 1.0]])
results = vector_store.search(["s"], [1.0, 0.0], top_k=5, min_score=0.5)
assert [r["text"] for r in results] == ["proche"]
def test_search_lexical_bonus_promotes_exact_term_match():
# Deux extraits de cosinus IDENTIQUE : le bonus lexical départage celui qui
# contient le mot exact de la question.
vector_store.save(
"s",
["Strahd règne sur Barovia", "un texte neutre sans rapport"],
[[1.0, 0.0], [1.0, 0.0]],
)
results = vector_store.search(["s"], [1.0, 0.0], top_k=2, query_text="Strahd")
assert results[0]["text"] == "Strahd règne sur Barovia"
assert results[0]["score"] > results[1]["score"]
def test_search_includes_source_id_and_page():
vector_store.save("livre", ["extrait"], [[1.0]], pages=[42])
[res] = vector_store.search(["livre"], [1.0], top_k=1)
assert res["source_id"] == "livre"
assert res["page"] == 42
# --- résumés (analyse approfondie) ---------------------------------------------
def test_summaries_roundtrip_keyed_by_batch_tokens():
vector_store.save_summaries("s", 1000, [{"summary": "résumé", "vector": [1.0]}])
assert vector_store.load_summaries("s", 1000) == [{"summary": "résumé", "vector": [1.0]}]
# Taille de lot différente → invalidé (le découpage ne correspondrait plus).
assert vector_store.load_summaries("s", 2000) is None
def test_load_summaries_absent_returns_none():
assert vector_store.load_summaries("inconnu", 1000) is None

View File

@@ -0,0 +1,3 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip

295
core/mvnw vendored Normal file
View File

@@ -0,0 +1,295 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.4
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
# Find the actual extracted directory name (handles snapshots where filename != directory name)
actualDistributionDir=""
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"

189
core/mvnw.cmd vendored Normal file
View File

@@ -0,0 +1,189 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"

View File

@@ -8,18 +8,22 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
<version>3.5.14</version>
<relativePath/>
</parent>
<groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId>
<version>0.6.6</version>
<version>0.16.2</version>
<name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description>
<properties>
<java.version>17</java.version>
<!-- Override de la transitive minio → commons-compress → commons-lang3 3.17.
>= 3.18 : corrige CVE-2025-48924 (recursion infinie ClassUtils.getClass).
Propriete reconnue par le BOM Spring Boot → s'applique partout. -->
<commons-lang3.version>3.20.0</commons-lang3.version>
</properties>
<dependencies>
@@ -56,11 +60,31 @@
<scope>runtime</scope>
</dependency>
<!-- H2 Database pour les tests -->
<!-- Flyway : migrations de schema versionnees (remplace ddl-auto=update).
Un SEUL jeu de migrations en SQL PostgreSQL sert les deux bases :
- Postgres (Docker/serveur) nativement ;
- H2 (mode local-first) via MODE=PostgreSQL dans l'URL JDBC.
flyway-database-postgresql : module requis depuis Flyway 10 (DBs
externalisees du core). H2 reste supporte par flyway-core. -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<!-- H2 Database :
- tests (toujours) ;
- RUNTIME du profil "local" (mode local-first / jpackage) : base
fichier embarquee a la place de Postgres, donc le driver doit etre
sur le classpath d'execution. Scope runtime (jamais compile contre)
=> present a l'execution + tests, ~2,5 Mo inutilises cote Docker. -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
<scope>runtime</scope>
</dependency>
<!-- Lombok (réduit le code boilerplate) -->
@@ -77,11 +101,52 @@
<scope>test</scope>
</dependency>
<!-- MinIO — client S3-compatible pour le stockage d'images (Shared Kernel images). -->
<!-- MinIO — client S3-compatible pour le stockage d'images (Shared Kernel images).
8.6.x = derniere ligne 8.x (la 9.x change l'API) ; transitives a jour. -->
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.11</version>
<version>8.6.0</version>
<exclusions>
<!-- OkHttp 5 : l'artefact `okhttp` est un alias multiplateforme dont la
resolution vers les classes JVM passe par les metadonnees Gradle —
que Maven ignore. On exclut l'alias et on declare `okhttp-jvm`
(les vraies classes) explicitement ci-dessous. -->
<exclusion>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp-jvm</artifactId>
<version>5.1.0</version>
</dependency>
<!-- Nimbus JOSE+JWT — verification des JWT Ed25519 (EdDSA) emis par le relais
Patreon. Supporte nativement les cles Ed25519 via BouncyCastle.
>= 10.0.2 : corrige CVE-2025-53864 (DoS par JSON profondement imbrique
dans le claim set — surface critique : JWT colle par l'utilisateur). -->
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>10.9.1</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>1.84</version>
</dependency>
<!-- Google Tink : runtime requis par com.nimbusds.jose.crypto.Ed25519Verifier
(depuis Nimbus 9.x, la verification EdDSA delegue a Tink.subtle.Ed25519Verify).
Tink n'est PAS une dependance transitive de nimbus-jose-jwt → il faut
l'ajouter explicitement, sinon NoClassDefFoundError au premier verify().
>= 1.15 : embarque un protobuf-java corrige (CVE-2024-7254). -->
<dependency>
<groupId>com.google.crypto.tink</groupId>
<artifactId>tink</artifactId>
<version>1.21.0</version>
</dependency>
</dependencies>
@@ -98,6 +163,16 @@
</exclude>
</excludes>
</configuration>
<executions>
<!-- Genere META-INF/build-info.properties (project.version)
consomme par Spring BuildProperties pour exposer la
version courante a l'application (UpdateCheckService). -->
<execution>
<goals>
<goal>build-info</goal>
</goals>
</execution>
</executions>
</plugin>
<!-- JaCoCo : rapport de couverture des tests unitaires.
@@ -120,8 +195,79 @@
<goal>report</goal>
</goals>
</execution>
<!-- Plancher ANTI-REGRESSION : `mvn test` echoue si la couverture
d'instructions du bundle passe sous 60% (mesure actuelle ~68%).
A remonter au fil du temps. N'impacte PAS le build Docker
(qui passe -DskipTests) : le gating est porte par la CI. -->
<execution>
<id>check</id>
<phase>test</phase>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>INSTRUCTION</counter>
<value>COVEREDRATIO</value>
<minimum>0.60</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<profiles>
<!-- =================================================================
Profil "desktop" : build local-first (application de bureau).
Active avec : mvn -Pdesktop package
Embarque le build Angular dans le jar (classpath:/static/) pour que
le Core serve lui-meme le front (cf. LocalWebConfig, profil Spring
"local"). Le build Docker normal (sans ce profil) reste une API pure :
le front y est servi par le conteneur nginx, donc rien n'est copie.
================================================================= -->
<profile>
<id>desktop</id>
<properties>
<!-- Sortie du `ng build` (builder browser) : web/dist/web. -->
<frontend.dist>${project.basedir}/../web/dist/web</frontend.dist>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<executions>
<execution>
<id>copy-frontend</id>
<!-- Avant le repackage Spring Boot : on injecte le
front dans les classes compilees -> embarque
dans le fat jar sous /static. -->
<phase>prepare-package</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>${project.build.outputDirectory}/static</outputDirectory>
<resources>
<resource>
<directory>${frontend.dist}</directory>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>

View File

@@ -1,16 +1,43 @@
package com.loremind;
import com.loremind.infrastructure.desktop.DesktopSingleInstance;
import com.loremind.infrastructure.desktop.DesktopUserConfig;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* Classe principale de l'application LoreMind.
* Point d'entrée Spring Boot qui démarre l'application.
*/
@SpringBootApplication
@EnableScheduling
public class LoreMindApplication {
public static void main(String[] args) {
SpringApplication.run(LoreMindApplication.class, args);
// Mode bureau (profil "local") : garde-fou instance unique. Si l'app
// tourne deja, on ouvre juste le navigateur et on sort proprement (code 0)
// au lieu de demarrer un 2e serveur qui echouerait sur le verrou H2 — ce
// qui evite le trompeur « Failed to launch JVM » du launcher jpackage.
boolean local = DesktopSingleInstance.isLocalProfile(args);
if (local && !DesktopSingleInstance.tryAcquire()) {
DesktopSingleInstance.openAppInBrowser();
return;
}
SpringApplication app = new SpringApplication(LoreMindApplication.class);
if (local) {
// Mode bureau : on a besoin d'AWT (icone de la zone de notification,
// cf. SystemTrayManager). Spring Boot force headless=true par defaut,
// ce qui leverait HeadlessException — on le desactive ici. En mode
// serveur/Docker, on reste en headless (defaut), aucun impact.
app.setHeadless(false);
// Config utilisateur editable (~/.loremind/loremind.properties) : creee
// au 1er lancement (port + identifiants admin). Puis resolution du port :
// celui configure s'il est libre, sinon un port libre (evite l'echec de
// demarrage si 8080 est deja pris). Publie server.port + ~/.loremind/.port.
DesktopUserConfig.ensureExists();
DesktopUserConfig.resolveAndPublishPort();
}
app.run(args);
}
}

Some files were not shown because too many files have changed in this diff Show More