Compare commits

..

18 Commits

Author SHA1 Message Date
5aa08a3a27 Correction du worflow release : on ne fait plus les tests (évite le doublons de test avec le ci.yml).
Some checks failed
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 21s
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 30s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Successful in 2m22s
Build & Push Images / build (brain) (push) Successful in 1m16s
E2E Tests / e2e (push) Failing after 5m24s
Build & Push Images / build (core) (push) Successful in 3m5s
Build & Push Images / build-switcher (push) Successful in 50s
Build & Push Images / build (web) (push) Successful in 1m56s
Correction de tests coté java + config
Suppression de classes inutilisées coté Angular
2026-06-19 00:36:30 +02:00
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
176 changed files with 9562 additions and 1892 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

@@ -12,6 +12,9 @@ env:
GHCR_NAMESPACE: igmlcreation GHCR_NAMESPACE: igmlcreation
jobs: jobs:
# NB : pas de job de test ici. Le gate qualité vit dans ci.yml (push main + PR) ;
# avec la branch protection Gitea (checks requis sur main), on ne tague donc que
# du code déjà vert. release.yml ne fait que builder/pousser les images.
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:

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.

19
.gitignore vendored
View File

@@ -45,6 +45,12 @@ env/
.coverage .coverage
htmlcov/ htmlcov/
# Artefacts du build bureau (cf. installers/desktop)
.venv-build/
brain/build/
brain/dist-embed/
*.spec
# ============================================================================ # ============================================================================
# Angular / Node (Web) # Angular / Node (Web)
# ============================================================================ # ============================================================================
@@ -109,3 +115,16 @@ docker-compose.override.yml
relay/ relay/
scripts/bump-version.mjs scripts/bump-version.mjs
brain/data/notebooks/5.json 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

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,66 +1,68 @@
# LoreMind # LoreMind
> Application web auto-hébergeable pour MJ qui veulent centraliser leur univers, leurs campagnes et leurs personnages — avec un assistant IA contextuel. **English** · [Français](README.fr.md)
[![Licence: AGPL v3](https://img.shields.io/badge/Licence-AGPL%20v3-blue.svg)](LICENSE) > A self-hostable web app for game masters who want to centralize their world, campaigns and characters — with a context-aware AI assistant.
[![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 [![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)
[![Présentation LoreMind](https://img.youtube.com/vi/llJkmlotbB8/maxresdefault.jpg)](https://www.youtube.com/watch?v=llJkmlotbB8) ## See LoreMind in action
![Tableau de bord](https://raw.githubusercontent.com/IGMLcreation/loremind-docs/main/static/img/screenshots/dashboard.png) [![LoreMind overview](https://img.youtube.com/vi/llJkmlotbB8/maxresdefault.jpg)](https://www.youtube.com/watch?v=llJkmlotbB8)
## Ce que ça fait ![Dashboard](https://raw.githubusercontent.com/IGMLcreation/loremind-docs/main/static/img/screenshots/dashboard.png)
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. ## What it does
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.
### Lore ### 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. 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.
### Game System ### 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. 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.
### Campaign ### 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. 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.
### Assistant IA ### AI Assistant
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. 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.
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. The AI runs **locally via [Ollama](https://ollama.com/)** or via **[1min.ai](https://1min.ai/)**. More engines will be supported in the future.
## Documentation ## Documentation
Toute la documentation (installation, configuration, prise en main) est sur **[loremind-docs.igmlcreation.fr](https://loremind-docs.igmlcreation.fr/)**. The full documentation (installation, configuration, getting started) lives at **[loremind-docs.igmlcreation.fr/en](https://loremind-docs.igmlcreation.fr/en/)**.
## Démo en ligne ## Live demo
Une instance de démonstration est disponible sur **[loremind-demo.igmlcreation.fr](https://loremind-demo.igmlcreation.fr/)**. A demo instance is available at **[loremind-demo.igmlcreation.fr](https://loremind-demo.igmlcreation.fr/)**.
Quelques limites à connaître : A few limitations to be aware of:
- 10 utilisateurs maximum simultanés (instances isolées) - 10 concurrent users maximum (isolated instances)
- Session limitée à 20 minutes avant réinitialisation - Sessions limited to 20 minutes before reset
- Partie IA non incluse dans la démo (nécessite Ollama ou 1min.ai côté serveur) - The AI part is not included in the demo (requires Ollama or 1min.ai server-side)
## Soutenir le projet ## Support the project
LoreMind est **et restera gratuit en auto-hébergement**. Le développement avance plus vite avec votre soutien : LoreMind is **and will remain free when self-hosted**. Development moves faster with your support:
- **[Patreon](https://www.patreon.com/c/IGMLCreation)** — accès anticipé aux features, vote sur la roadmap, devlogs exclusifs - **[Patreon](https://www.patreon.com/c/IGMLCreation)** — early access to features, roadmap voting, exclusive devlogs
- **[Discord](https://discord.gg/cPpFzCjEzQ)** — annonces, support, retours utilisateurs - **[Discord](https://discord.gg/cPpFzCjEzQ)** — announcements, support, user feedback
## Licence ## License
LoreMind est distribué sous licence **[GNU AGPL v3](LICENSE)**. LoreMind is distributed under the **[GNU AGPL v3](LICENSE)** license.
En pratique : In practice:
- Vous pouvez l'utiliser gratuitement, l'héberger, la modifier, la redistribuer. - You can use it for free, host it, modify it, and redistribute it.
- 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. - 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.
- 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. - 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__/ __pycache__/
*.pyc *.pyc
.env .env
# Couverture de tests (pytest-cov)
htmlcov/
.coverage
.pytest_cache/

View File

@@ -18,6 +18,7 @@ from app.api.chat_mapping import (
from app.api.deps import get_chat_use_case from app.api.deps import get_chat_use_case
from app.application.chat import ChatUseCase from app.application.chat import ChatUseCase
from app.core.config import get_settings from app.core.config import get_settings
from app.core.language import get_user_language
from app.domain.models import ChatMessage from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError from app.domain.ports import LLMProviderError
@@ -44,6 +45,7 @@ def _count_tokens(text: str | None) -> int:
async def chat_stream( async def chat_stream(
body: ChatStreamRequestDTO, body: ChatStreamRequestDTO,
use_case: Annotated[ChatUseCase, Depends(get_chat_use_case)], use_case: Annotated[ChatUseCase, Depends(get_chat_use_case)],
language: Annotated[str, Depends(get_user_language)],
) -> StreamingResponse: ) -> StreamingResponse:
"""Chat streamé (Server-Sent Events) avec Structural Context. """Chat streamé (Server-Sent Events) avec Structural Context.
@@ -82,6 +84,7 @@ async def chat_stream(
narrative_entity=narrative_entity, narrative_entity=narrative_entity,
game_system_context=game_system_context, game_system_context=game_system_context,
session_context=session_context, session_context=session_context,
language=language,
) )
# Dernier message = "current" (souvent user), le reste = historique accumulé. # Dernier message = "current" (souvent user), le reste = historique accumulé.
current_msg = messages[-1] if messages else None current_msg = messages[-1] if messages else None
@@ -109,6 +112,7 @@ async def chat_stream(
narrative_entity=narrative_entity, narrative_entity=narrative_entity,
game_system_context=game_system_context, game_system_context=game_system_context,
session_context=session_context, session_context=session_context,
language=language,
): ):
# json.dumps avec ensure_ascii=False pour préserver les accents # json.dumps avec ensure_ascii=False pour préserver les accents
yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n" yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n"

View File

@@ -6,7 +6,9 @@ from pydantic import BaseModel, Field
from app.api.deps import get_generate_page_use_case, get_llm_provider from app.api.deps import get_generate_page_use_case, get_llm_provider
from app.application.generate_page import GeneratePageUseCase 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.config import Settings, get_settings
from app.core.language import get_user_language
from app.domain.models import PageGenerationContext from app.domain.models import PageGenerationContext
from app.domain.ports import LLMProvider, LLMProviderError from app.domain.ports import LLMProvider, LLMProviderError
@@ -60,6 +62,7 @@ async def generate_page(
use_case: Annotated[ use_case: Annotated[
GeneratePageUseCase, Depends(get_generate_page_use_case) GeneratePageUseCase, Depends(get_generate_page_use_case)
], ],
language: Annotated[str, Depends(get_user_language)],
) -> GeneratePageResponseDTO: ) -> GeneratePageResponseDTO:
"""Endpoint métier : contexte LoreMind → valeurs structurées par champ. """Endpoint métier : contexte LoreMind → valeurs structurées par champ.
@@ -76,7 +79,7 @@ async def generate_page(
) )
try: try:
result = await use_case.execute(context) result = await use_case.execute(context, language=language)
except LLMProviderError as exc: except LLMProviderError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc raise HTTPException(status_code=502, detail=str(exc)) from exc
@@ -101,18 +104,11 @@ class SummarizeTitleResponseDTO(BaseModel):
title: str 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')."
)
@router.post("/summarize/conversation-title", response_model=SummarizeTitleResponseDTO) @router.post("/summarize/conversation-title", response_model=SummarizeTitleResponseDTO)
async def summarize_conversation_title( async def summarize_conversation_title(
body: SummarizeTitleRequestDTO, body: SummarizeTitleRequestDTO,
llm: Annotated[LLMProvider, Depends(get_llm_provider)], llm: Annotated[LLMProvider, Depends(get_llm_provider)],
language: Annotated[str, Depends(get_user_language)],
) -> SummarizeTitleResponseDTO: ) -> SummarizeTitleResponseDTO:
"""Genere un titre court a partir des premiers echanges de la conversation. """Genere un titre court a partir des premiers echanges de la conversation.
@@ -123,7 +119,7 @@ async def summarize_conversation_title(
raise HTTPException(status_code=422, detail="Au moins un message requis") 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]) 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 :" prompt = f"{title_prompts.title_system_prompt(language)}\n\nConversation :\n{transcript}\n\nTitre :"
try: try:
raw = await llm.generate(prompt) raw = await llm.generate(prompt)
except LLMProviderError as exc: except LLMProviderError as exc:
@@ -133,5 +129,5 @@ async def summarize_conversation_title(
if len(title) > 80: if len(title) > 80:
title = title[:80].rstrip() title = title[:80].rstrip()
if not title: if not title:
title = "Nouvelle conversation" title = title_prompts.TITLE_FALLBACK.get(language, title_prompts.TITLE_FALLBACK["fr"])
return SummarizeTitleResponseDTO(title=title) return SummarizeTitleResponseDTO(title=title)

View File

@@ -16,6 +16,7 @@ from app.api.deps import (
from app.application.adapt_campaign import AdaptCampaignUseCase from app.application.adapt_campaign import AdaptCampaignUseCase
from app.application.import_campaign import ImportCampaignUseCase from app.application.import_campaign import ImportCampaignUseCase
from app.application.import_rules import ImportRulesUseCase from app.application.import_rules import ImportRulesUseCase
from app.core.language import get_user_language
from app.domain.models import ChatMessage from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError, PdfExtractionError from app.domain.ports import LLMProviderError, PdfExtractionError
@@ -40,6 +41,7 @@ class RulesImportResponseDTO(BaseModel):
@router.post("/import/rules", response_model=RulesImportResponseDTO) @router.post("/import/rules", response_model=RulesImportResponseDTO)
async def import_rules( async def import_rules(
use_case: Annotated[ImportRulesUseCase, Depends(get_import_rules_use_case)], use_case: Annotated[ImportRulesUseCase, Depends(get_import_rules_use_case)],
language: Annotated[str, Depends(get_user_language)],
file: UploadFile = File(...), file: UploadFile = File(...),
) -> RulesImportResponseDTO: ) -> RulesImportResponseDTO:
"""Import d'un PDF de règles → sections markdown structurées (proposition). """Import d'un PDF de règles → sections markdown structurées (proposition).
@@ -58,7 +60,7 @@ async def import_rules(
) )
try: try:
result = await use_case.execute(content) result = await use_case.execute(content, language=language)
except PdfExtractionError as exc: except PdfExtractionError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
except LLMProviderError as exc: except LLMProviderError as exc:
@@ -74,6 +76,7 @@ async def import_rules(
@router.post("/import/rules/stream") @router.post("/import/rules/stream")
async def import_rules_stream( async def import_rules_stream(
use_case: Annotated[ImportRulesUseCase, Depends(get_import_rules_use_case)], use_case: Annotated[ImportRulesUseCase, Depends(get_import_rules_use_case)],
language: Annotated[str, Depends(get_user_language)],
file: UploadFile = File(...), file: UploadFile = File(...),
) -> StreamingResponse: ) -> StreamingResponse:
"""Import streamé : émet l'avancement (SSE) puis le résultat final. """Import streamé : émet l'avancement (SSE) puis le résultat final.
@@ -93,7 +96,7 @@ async def import_rules_stream(
yield sse_event("error", {"message": upload_error}) yield sse_event("error", {"message": upload_error})
return return
try: try:
async for ev in use_case.stream(content): async for ev in use_case.stream(content, language=language):
event_type = ev.pop("type") event_type = ev.pop("type")
yield sse_event(event_type, ev) yield sse_event(event_type, ev)
except PdfExtractionError as exc: except PdfExtractionError as exc:
@@ -146,6 +149,7 @@ async def import_campaign_stream(
@router.post("/adapt/campaign/stream") @router.post("/adapt/campaign/stream")
async def adapt_campaign_stream( async def adapt_campaign_stream(
use_case: Annotated[AdaptCampaignUseCase, Depends(get_adapt_campaign_use_case)], use_case: Annotated[AdaptCampaignUseCase, Depends(get_adapt_campaign_use_case)],
language: Annotated[str, Depends(get_user_language)],
file: UploadFile = File(...), file: UploadFile = File(...),
brief: str = Form(""), brief: str = Form(""),
messages: str = Form("[]"), messages: str = Form("[]"),
@@ -173,7 +177,7 @@ async def adapt_campaign_stream(
yield sse_event("error", {"message": upload_error}) yield sse_event("error", {"message": upload_error})
return return
try: try:
async for token in use_case.stream(content, brief, convo): async for token in use_case.stream(content, brief, convo, language=language):
yield sse_event("token", {"token": token}) yield sse_event("token", {"token": token})
yield sse_event("done", {}) yield sse_event("done", {})
except PdfExtractionError as exc: except PdfExtractionError as exc:

View File

@@ -17,6 +17,7 @@ from app.application.notebook_chat import NotebookChatUseCase
from app.application.notebook_deep import NotebookDeepUseCase from app.application.notebook_deep import NotebookDeepUseCase
from app.application.notebook_rag import NotebookRagUseCase from app.application.notebook_rag import NotebookRagUseCase
from app.core.config import Settings, get_settings 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.models import ChatMessage
from app.domain.ports import LLMProviderError, PdfExtractionError from app.domain.ports import LLMProviderError, PdfExtractionError
from app.infrastructure import vector_store from app.infrastructure import vector_store
@@ -77,6 +78,7 @@ async def chat_notebook_stream(
body: NotebookChatRequestDTO, body: NotebookChatRequestDTO,
use_case: Annotated[NotebookChatUseCase, Depends(get_notebook_chat_use_case)], use_case: Annotated[NotebookChatUseCase, Depends(get_notebook_chat_use_case)],
settings: Annotated[Settings, Depends(get_settings)], settings: Annotated[Settings, Depends(get_settings)],
language: Annotated[str, Depends(get_user_language)],
) -> StreamingResponse: ) -> StreamingResponse:
"""Chat ANCRÉ sur les sources (RAG) : récupère les passages pertinents puis """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}.""" streame la réponse. Évènements SSE : `token` {token}, `done` {}, `error` {message}."""
@@ -85,7 +87,7 @@ async def chat_notebook_stream(
async def event_stream() -> AsyncIterator[str]: async def event_stream() -> AsyncIterator[str]:
try: try:
async for ev in use_case.stream(body.source_ids, messages, context=body.context, top_k=top_k): 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["type"] == "token":
if ev.get("token"): if ev.get("token"):
yield sse_event("token", {"token": ev["token"]}) yield sse_event("token", {"token": ev["token"]})
@@ -107,6 +109,7 @@ async def chat_notebook_stream(
async def chat_notebook_deep_stream( async def chat_notebook_deep_stream(
body: NotebookChatRequestDTO, body: NotebookChatRequestDTO,
use_case: Annotated[NotebookDeepUseCase, Depends(get_notebook_deep_use_case)], use_case: Annotated[NotebookDeepUseCase, Depends(get_notebook_deep_use_case)],
language: Annotated[str, Depends(get_user_language)],
) -> StreamingResponse: ) -> StreamingResponse:
"""Analyse APPROFONDIE (map-reduce sur tout le document). Évènements SSE : """Analyse APPROFONDIE (map-reduce sur tout le document). Évènements SSE :
`progress` {current,total} pendant la lecture, puis `token` {token}, puis `done`.""" `progress` {current,total} pendant la lecture, puis `token` {token}, puis `done`."""
@@ -118,7 +121,7 @@ async def chat_notebook_deep_stream(
yield sse_event("error", {"message": "Question vide."}) yield sse_event("error", {"message": "Question vide."})
return return
try: try:
async for ev in use_case.stream(body.source_ids, messages, context=body.context): async for ev in use_case.stream(body.source_ids, messages, context=body.context, language=language):
ev_type = ev.pop("type") ev_type = ev.pop("type")
yield sse_event(ev_type, ev) yield sse_event(ev_type, ev)
except (LLMProviderError, EmbeddingError) as exc: except (LLMProviderError, EmbeddingError) as exc:

View File

@@ -8,6 +8,8 @@ from pydantic import BaseModel, Field
from app.api.deps import get_llm_provider from app.api.deps import get_llm_provider
from app.application.llm_json import load_json_object from app.application.llm_json import load_json_object
from app.application.llm_retry import generate_with_retry 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 from app.domain.ports import LLMProvider, LLMProviderError
router = APIRouter() router = APIRouter()
@@ -51,28 +53,15 @@ class GenerateTableResponseDTO(BaseModel):
async def generate_random_table( async def generate_random_table(
body: GenerateTableRequestDTO, body: GenerateTableRequestDTO,
llm: Annotated[LLMProvider, Depends(get_llm_provider)], llm: Annotated[LLMProvider, Depends(get_llm_provider)],
language: Annotated[str, Depends(get_user_language)],
) -> GenerateTableResponseDTO: ) -> GenerateTableResponseDTO:
"""Génère une table aléatoire (entrées par plage) couvrant la formule de dé.""" """Génère une table aléatoire (entrées par plage) couvrant la formule de dé."""
rng = _dice_total_range(body.dice_formula) rng = _dice_total_range(body.dice_formula)
if rng is None: if rng is None:
raise HTTPException(status_code=422, detail="Formule de dé invalide (ex. 1d20, 2d6, d100).") raise HTTPException(status_code=422, detail="Formule de dé invalide (ex. 1d20, 2d6, d100).")
lo, hi = rng lo, hi = rng
context_block = f"\nContexte de la campagne :\n{body.context.strip()}\n" if body.context.strip() else "" prompt = prompts.random_table_prompt(
prompt = ( body.description, body.dice_formula, lo, hi, body.context, language)
"Tu es un assistant de jeu de rôle. Génère une TABLE ALÉATOIRE évocatrice.\n"
f"Dé : {body.dice_formula} (résultats possibles de {lo} à {hi}).\n"
f"Sujet : {body.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"
"- En français. 'label' = résultat bref ; 'detail' = description/effet concret.\n"
"Renvoie maintenant le JSON."
)
try: try:
raw = await generate_with_retry(llm, prompt, output_format="json", temperature=0.7) raw = await generate_with_retry(llm, prompt, output_format="json", temperature=0.7)
except LLMProviderError as exc: except LLMProviderError as exc:
@@ -124,17 +113,11 @@ class ImproviseRollResponseDTO(BaseModel):
async def improvise_table_roll( async def improvise_table_roll(
body: ImproviseRollRequestDTO, body: ImproviseRollRequestDTO,
llm: Annotated[LLMProvider, Depends(get_llm_provider)], llm: Annotated[LLMProvider, Depends(get_llm_provider)],
language: Annotated[str, Depends(get_user_language)],
) -> ImproviseRollResponseDTO: ) -> ImproviseRollResponseDTO:
"""Brode un court récit (2-3 phrases) sur un résultat tiré, pour lancer la scène.""" """Brode un court récit (2-3 phrases) sur un résultat tiré, pour lancer la scène."""
detail = f" ({body.result_detail.strip()})" if body.result_detail.strip() else "" prompt = prompts.improvise_roll_prompt(
context_block = f"\nContexte : {body.context.strip()}" if body.context.strip() else "" body.table_name, body.result_label, body.result_detail, body.context, language)
prompt = (
"Tu es le Maître du Jeu. Les joueurs viennent de tirer sur la table "
f"« {body.table_name.strip()} » et ont obtenu : « {body.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. "
"Pas de méta, pas d'options : juste la narration, en français."
)
try: try:
raw = await llm.generate(prompt, temperature=0.8) raw = await llm.generate(prompt, temperature=0.8)
except LLMProviderError as exc: except LLMProviderError as exc:
@@ -167,22 +150,10 @@ class GenerateCatalogResponseDTO(BaseModel):
async def generate_item_catalog( async def generate_item_catalog(
body: GenerateCatalogRequestDTO, body: GenerateCatalogRequestDTO,
llm: Annotated[LLMProvider, Depends(get_llm_provider)], llm: Annotated[LLMProvider, Depends(get_llm_provider)],
language: Annotated[str, Depends(get_user_language)],
) -> GenerateCatalogResponseDTO: ) -> GenerateCatalogResponseDTO:
"""Génère un catalogue d'objets (boutique, butin…) — nom, prix, catégorie, description.""" """Génère un catalogue d'objets (boutique, butin…) — nom, prix, catégorie, description."""
context_block = f"\nContexte de la campagne :\n{body.context.strip()}\n" if body.context.strip() else "" prompt = prompts.item_catalog_prompt(body.description, body.context, language)
prompt = (
"Tu es un assistant de jeu de rôle. Génère un CATALOGUE D'OBJETS (boutique, butin, trésor…).\n"
f"Sujet : {body.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…) ; "
"'description' = effet/détails en une phrase. En français.\n"
"Renvoie maintenant le JSON."
)
try: try:
raw = await generate_with_retry(llm, prompt, output_format="json", temperature=0.7) raw = await generate_with_retry(llm, prompt, output_format="json", temperature=0.7)
except LLMProviderError as exc: except LLMProviderError as exc:

View File

@@ -13,6 +13,8 @@ from __future__ import annotations
import logging import logging
from typing import AsyncIterator 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.models import ChatMessage
from app.domain.ports import LLMChatProvider, PdfExtractionError, PdfTextExtractor from app.domain.ports import LLMChatProvider, PdfExtractionError, PdfTextExtractor
@@ -21,27 +23,6 @@ logger = logging.getLogger(__name__)
# Plus créatif que l'import (tâche de structuration) : ici on conseille/adapte. # Plus créatif que l'import (tâche de structuration) : ici on conseille/adapte.
_TEMPERATURE = 0.7 _TEMPERATURE = 0.7
_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."
)
_SYSTEM_SUFFIX = (
"Produis des CONSEILS D'ADAPTATION concrets, actionnables et en FRANÇAIS, "
"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."
)
class AdaptCampaignUseCase: class AdaptCampaignUseCase:
"""Génère (en streaming) des conseils d'adaptation d'un PDF à une campagne.""" """Génère (en streaming) des conseils d'adaptation d'un PDF à une campagne."""
@@ -64,6 +45,7 @@ class AdaptCampaignUseCase:
pdf_bytes: bytes, pdf_bytes: bytes,
brief: str, brief: str,
messages: list[ChatMessage], messages: list[ChatMessage],
language: str = _DEFAULT_LANG,
) -> AsyncIterator[str]: ) -> AsyncIterator[str]:
"""Conversationnel : le PDF + la campagne sont le CONTEXTE (system prompt), """Conversationnel : le PDF + la campagne sont le CONTEXTE (system prompt),
`messages` est l'échange (demande initiale, puis feedbacks de l'utilisateur).""" `messages` est l'échange (demande initiale, puis feedbacks de l'utilisateur)."""
@@ -87,12 +69,12 @@ class AdaptCampaignUseCase:
) )
# Concaténation (pas .format) : brief/PDF peuvent contenir des { } littéraux. # Concaténation (pas .format) : brief/PDF peuvent contenir des { } littéraux.
system_prompt = ( system_prompt = (
f"{_SYSTEM_PREFIX}\n\n" f"{prompts.SYSTEM_PREFIX}\n\n"
"--- CAMPAGNE EXISTANTE DE L'UTILISATEUR ---\n" "--- CAMPAGNE EXISTANTE DE L'UTILISATEUR ---\n"
f"{brief.strip() or '(campagne encore vide)'}\n\n" f"{brief.strip() or '(campagne encore vide)'}\n\n"
"--- CONTENU DU PDF À ADAPTER ---\n" "--- CONTENU DU PDF À ADAPTER ---\n"
f"{pdf_text}{trunc_note}\n\n" f"{pdf_text}{trunc_note}\n\n"
f"{_SYSTEM_SUFFIX}\n\n" f"{prompts.system_suffix(language)}\n\n"
"Tu es en CONVERSATION : à chaque message de l'utilisateur, ajuste, corrige " "Tu es en CONVERSATION : à chaque message de l'utilisateur, ajuste, corrige "
"ou propose des alternatives en gardant tout ce contexte à l'esprit." "ou propose des alternatives en gardant tout ce contexte à l'esprit."
) )

View File

@@ -31,6 +31,8 @@ from app.domain.models import (
QuestSummary, QuestSummary,
SessionContext, SessionContext,
) )
from app.application.prompts import chat as prompts
from app.core.language import DEFAULT as _DEFAULT_LANG
from app.domain.ports import LLMChatProvider from app.domain.ports import LLMChatProvider
@@ -40,21 +42,6 @@ from app.domain.ports import LLMChatProvider
_DEFAULT_TEMPERATURE = 0.7 _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: class ChatUseCase:
"""Orchestre un tour de conversation avec le LLM + contextes structurels.""" """Orchestre un tour de conversation avec le LLM + contextes structurels."""
@@ -71,16 +58,18 @@ class ChatUseCase:
narrative_entity: NarrativeEntityContext | None = None, narrative_entity: NarrativeEntityContext | None = None,
game_system_context: GameSystemContext | None = None, game_system_context: GameSystemContext | None = None,
session_context: SessionContext | None = None, session_context: SessionContext | None = None,
language: str = _DEFAULT_LANG,
) -> AsyncIterator[str]: ) -> AsyncIterator[str]:
"""Streame les tokens de la réponse assistant pour le dernier message user. """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 Les contextes sont tous optionnels, mais au moins l'un des deux
"niveaux haut" (lore_context ou campaign_context) doit être fourni "niveaux haut" (lore_context ou campaign_context) doit être fourni
pour que le prompt ait du sens. Le controller (main.py) applique 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( system_prompt = self._build_system_prompt(
lore_context, page_context, campaign_context, narrative_entity, game_system_context, session_context lore_context, page_context, campaign_context, narrative_entity,
game_system_context, session_context, language,
) )
async for token in self._llm.stream_chat( async for token in self._llm.stream_chat(
messages, messages,
@@ -97,12 +86,14 @@ class ChatUseCase:
narrative_entity: NarrativeEntityContext | None = None, narrative_entity: NarrativeEntityContext | None = None,
game_system_context: GameSystemContext | None = None, game_system_context: GameSystemContext | None = None,
session_context: SessionContext | None = None, session_context: SessionContext | None = None,
language: str = _DEFAULT_LANG,
) -> str: ) -> str:
"""Version publique — utilisée par le controller HTTP pour compter """Version publique — utilisée par le controller HTTP pour compter
les tokens du system prompt avant de streamer (jauge de contexte). les tokens du system prompt avant de streamer (jauge de contexte).
""" """
return self._build_system_prompt( return self._build_system_prompt(
lore_context, page_context, campaign_context, narrative_entity, game_system_context, session_context lore_context, page_context, campaign_context, narrative_entity,
game_system_context, session_context, language,
) )
# --- Construction du system prompt -------------------------------------- # --- Construction du system prompt --------------------------------------
@@ -115,8 +106,9 @@ class ChatUseCase:
narrative: NarrativeEntityContext | None, narrative: NarrativeEntityContext | None,
game_system: GameSystemContext | None = None, game_system: GameSystemContext | None = None,
session: SessionContext | None = None, session: SessionContext | None = None,
language: str = _DEFAULT_LANG,
) -> str: ) -> str:
sections = [_BASE_SYSTEM] sections = [prompts.base_system(language)]
if lore is not None: if lore is not None:
sections.append(self._format_lore(lore)) sections.append(self._format_lore(lore))
if campaign is not None: if campaign is not None:

View File

@@ -8,9 +8,13 @@ permet de tester ce use case avec un FakeLLMProvider, sans Ollama qui tourne.
""" """
import json import json
from app.application.prompts import generate_page as prompts
from app.domain.models import PageGenerationContext, PageGenerationResult from app.domain.models import PageGenerationContext, PageGenerationResult
from app.domain.ports import LLMProvider, LLMProviderError 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. # 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 # 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 _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: class GeneratePageUseCase:
"""Orchestre la génération d'une page LoreMind via un LLM.""" """Orchestre la génération d'une page LoreMind via un LLM."""
@@ -42,8 +31,9 @@ class GeneratePageUseCase:
async def execute( async def execute(
self, self,
context: PageGenerationContext, context: PageGenerationContext,
language: str = _DEFAULT_LANG,
) -> PageGenerationResult: ) -> PageGenerationResult:
prompt = self._build_prompt(context) prompt = self._build_prompt(context, language)
raw = await self._llm.generate( raw = await self._llm.generate(
prompt, prompt,
output_format="json", output_format="json",
@@ -53,7 +43,7 @@ class GeneratePageUseCase:
return PageGenerationResult(values=values) return PageGenerationResult(values=values)
@staticmethod @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) fields_block = "\n".join(f'- "{field}"' for field in context.template_fields)
lore_desc_line = ( lore_desc_line = (
f"\nDescription de l'univers : {context.lore_description}" f"\nDescription de l'univers : {context.lore_description}"
@@ -62,7 +52,7 @@ class GeneratePageUseCase:
) )
return ( return (
f"{_SYSTEM_INSTRUCTIONS}\n\n" f"{prompts.system_instructions(language)}\n\n"
f"Univers : {context.lore_name}" f"Univers : {context.lore_name}"
f"{lore_desc_line}\n" f"{lore_desc_line}\n"
f"Catégorie (dossier) : {context.folder_name}\n" f"Catégorie (dossier) : {context.folder_name}\n"

View File

@@ -21,6 +21,7 @@ from app.application.import_status import (
) )
from app.application.llm_json import load_json_object, looks_like_truncated_json 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.llm_retry import generate_with_retry
from app.application.prompts import import_campaign as prompts
from app.application.streaming import with_heartbeat from app.application.streaming import with_heartbeat
# Repli anti-troncature : si la sortie d'un morceau est coupée, on le retraite en # Repli anti-troncature : si la sortie d'un morceau est coupée, on le retraite en
@@ -47,76 +48,11 @@ logger = logging.getLogger(__name__)
# Plus la valeur est haute, plus le modèle "brode" (invente du contenu absent). # Plus la valeur est haute, plus le modèle "brode" (invente du contenu absent).
_TEMPERATURE = 0.1 _TEMPERATURE = 0.1
# Nom de l'arc unique quand le livre n'est pas découpé en actes/parties.
_DEFAULT_ARC_NAME = "Aventure principale"
# Morceaux PLUS GROS que pour les règles : l'IA voit une quête/un chapitre entier # 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 # 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). # fragmenter en dizaines de scènes. Adapté aux providers à grand contexte (1min.ai).
_CHUNK_TARGET_TOKENS = 10000 _CHUNK_TARGET_TOKENS = 10000
_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": []}}."""
# Schéma de l'arbre attendu, passé aux providers à sorties structurées (Ollama # 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 # 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 # inventées, d'objets bavards type "thought" ni de texte hors JSON). Les
@@ -195,47 +131,12 @@ _TREE_SCHEMA: dict = {
"additionalProperties": False, "additionalProperties": False,
} }
# 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."""
# Garde-fou prompt : une TOC de gros livre peut compter des centaines d'entrées # 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. # (sous-sous-sections). On la limite aux niveaux hauts et à un nombre raisonnable.
_TOC_MAX_LEVEL = 2 _TOC_MAX_LEVEL = 2
_TOC_MAX_ENTRIES = 80 _TOC_MAX_ENTRIES = 80
# 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": []}}"""
def _format_toc(toc) -> str: def _format_toc(toc) -> str:
"""Formate la TOC du PDF en liste indentée, bornée (niveaux hauts d'abord).""" """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] entries = [e for e in toc if e.level <= _TOC_MAX_LEVEL][:_TOC_MAX_ENTRIES]
@@ -626,7 +527,7 @@ class ImportCampaignUseCase:
skeleton = merger.skeleton_text() skeleton = merger.skeleton_text()
try: try:
raw = await generate_with_retry( raw = await generate_with_retry(
self._llm, _CONSOLIDATE_PROMPT.format(skeleton=skeleton), self._llm, prompts.CONSOLIDATE_PROMPT.format(skeleton=skeleton),
output_format="json", temperature=0.0) output_format="json", temperature=0.0)
except Exception as exc: # noqa: BLE001 — best-effort STRICT : une erreur ici except Exception as exc: # noqa: BLE001 — best-effort STRICT : une erreur ici
# (LLM, réseau, bug) ne doit JAMAIS faire perdre un import terminé. # (LLM, réseau, bug) ne doit JAMAIS faire perdre un import terminé.
@@ -664,9 +565,9 @@ class ImportCampaignUseCase:
"""Extrait l'arborescence + les PNJ d'un texte. Si la SORTIE est tronquée, """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 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é).""" dédoublonne par nom (un arc/chapitre coupé entre les moitiés est recollé)."""
toc_section = _TOC_BLOCK.format(toc=toc_block) if toc_block else "" toc_section = prompts.TOC_BLOCK.format(toc=toc_block) if toc_block else ""
prompt = ( prompt = (
_MAP_SYSTEM.format(default_arc=_DEFAULT_ARC_NAME) prompts.MAP_SYSTEM.format(default_arc=prompts.DEFAULT_ARC_NAME)
+ toc_section + toc_section
+ f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n" + f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n"
"Renvoie maintenant le JSON de l'arborescence." "Renvoie maintenant le JSON de l'arborescence."

View File

@@ -25,7 +25,9 @@ from app.application.import_status import (
) )
from app.application.llm_json import load_json_object, looks_like_truncated_json 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.llm_retry import generate_with_retry
from app.application.prompts import import_rules as prompts
from app.application.streaming import with_heartbeat 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 # 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 # pas tout réécrire en une réponse), on retraite ce morceau en 2 moitiés. Borné en
@@ -57,43 +59,6 @@ _SECTIONS_SCHEMA: dict = {
"additionalProperties": {"type": "string"}, "additionalProperties": {"type": "string"},
} }
# 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 français).
- 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)."""
# --- Mode SEGMENTATION (modèles locaux) -------------------------------------- # --- Mode SEGMENTATION (modèles locaux) --------------------------------------
# Réécrire tout le texte en JSON impose une SORTIE ≈ taille de l'ENTRÉE : à # 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 # ~100 tokens/s en local, un livre = des dizaines de minutes et des troncatures
@@ -102,25 +67,6 @@ Règles impératives :
# qui découpons le texte original. ~50× plus rapide, fidélité parfaite du # qui découpons le texte original. ~50× plus rapide, fidélité parfaite du
# contenu (texte source intact), plus de troncature possible. # contenu (texte source intact), plus de troncature possible.
_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 français.
- Pages de garde, sommaires, crédits : n'en fais pas des sections. Si l'extrait n'est que ça,
renvoie {{"sections": []}}."""
# Schéma passé à Ollama (structured outputs) : un objet {"sections": [...]}. # Schéma passé à Ollama (structured outputs) : un objet {"sections": [...]}.
# Racine objet (pas tableau) car l'extraction côté Brain repère le premier {…}. # Racine objet (pas tableau) car l'extraction côté Brain repère le premier {…}.
_ANCHORS_SCHEMA: dict = { _ANCHORS_SCHEMA: dict = {
@@ -293,7 +239,7 @@ class ImportRulesUseCase:
self._chunk_target_tokens = chunk_target_tokens self._chunk_target_tokens = chunk_target_tokens
self._segment_only = segment_only self._segment_only = segment_only
async def execute(self, pdf_bytes: bytes) -> RulesImportResult: async def execute(self, pdf_bytes: bytes, language: str = _DEFAULT_LANG) -> RulesImportResult:
"""Variante non-streamée : traite tout puis renvoie le résultat complet.""" """Variante non-streamée : traite tout puis renvoie le résultat complet."""
doc = self._extractor.extract(pdf_bytes) doc = self._extractor.extract(pdf_bytes)
chunks = chunk_text(doc.full_text, self._chunk_target_tokens) chunks = chunk_text(doc.full_text, self._chunk_target_tokens)
@@ -303,14 +249,14 @@ class ImportRulesUseCase:
) )
merger = _SectionMerger() merger = _SectionMerger()
for i, chunk in enumerate(chunks): for i, chunk in enumerate(chunks):
merger.add(await self._map_chunk(chunk, index=i, total=len(chunks))) merger.add(await self._map_chunk(chunk, index=i, total=len(chunks), language=language))
return RulesImportResult( return RulesImportResult(
sections=merger.result(), sections=merger.result(),
page_count=doc.page_count, page_count=doc.page_count,
ocr_page_count=doc.ocr_page_count, ocr_page_count=doc.ocr_page_count,
) )
async def stream(self, pdf_bytes: bytes): 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. """Variante streamée : yield des évènements d'avancement au fil de l'eau.
Évènements (dicts) : {"type": "extracting"}, puis Évènements (dicts) : {"type": "extracting"}, puis
@@ -353,7 +299,7 @@ class ImportRulesUseCase:
try: try:
sections: dict[str, str] | None = None sections: dict[str, str] | None = None
async for kind, payload in with_heartbeat( async for kind, payload in with_heartbeat(
self._map_chunk(chunk, index=i, total=total), self._map_chunk(chunk, index=i, total=total, language=language),
status_queue=status_queue, status_queue=status_queue,
): ):
if kind == "heartbeat": if kind == "heartbeat":
@@ -408,20 +354,24 @@ class ImportRulesUseCase:
# --- MAP : un morceau → sections ----------------------------------------- # --- MAP : un morceau → sections -----------------------------------------
async def _map_chunk(self, chunk: str, *, index: int, total: int) -> dict[str, str]: async def _map_chunk(self, chunk: str, *, index: int, total: int,
return await self._extract_sections(chunk, index=index, total=total, depth=0) 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( async def _extract_sections(
self, text: str, *, index: int, total: int, depth: int self, text: str, *, index: int, total: int, depth: int,
language: str = _DEFAULT_LANG,
) -> dict[str, str]: ) -> dict[str, str]:
"""Extrait les sections d'un texte. Si la SORTIE est tronquée, retraite le """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 — 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.""" ainsi aucune section n'est perdue, quel que soit le plafond de sortie."""
system = _SEGMENT_SYSTEM if self._segment_only else _MAP_SYSTEM system = prompts.SEGMENT_SYSTEM if self._segment_only else prompts.MAP_SYSTEM
schema = _ANCHORS_SCHEMA if self._segment_only else _SECTIONS_SCHEMA schema = _ANCHORS_SCHEMA if self._segment_only else _SECTIONS_SCHEMA
prompt = ( prompt = (
system.format( system.format(
canonical="\n".join(f" - {s}" for s in _CANONICAL_SECTIONS) 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" + f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n"
"Renvoie maintenant le JSON des sections." "Renvoie maintenant le JSON des sections."
@@ -444,8 +394,8 @@ class ImportRulesUseCase:
notify_status( notify_status(
f"Le modèle est trop lent sur le morceau {index + 1} : " f"Le modèle est trop lent sur le morceau {index + 1} : "
"re-découpage en 2 moitiés plus digestes…") "re-découpage en 2 moitiés plus digestes…")
a = await self._extract_sections(left, index=index, total=total, depth=depth + 1) 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) b = await self._extract_sections(right, index=index, total=total, depth=depth + 1, language=language)
return _combine_sections(a, b) return _combine_sections(a, b)
if self._segment_only: if self._segment_only:
sections, truncated = self._parse_anchors(raw, text, index=index) sections, truncated = self._parse_anchors(raw, text, index=index)
@@ -461,8 +411,8 @@ class ImportRulesUseCase:
notify_status( notify_status(
f"Réponse du modèle coupée sur le morceau {index + 1} : " f"Réponse du modèle coupée sur le morceau {index + 1} : "
"re-découpage en 2 moitiés plus digestes…") "re-découpage en 2 moitiés plus digestes…")
a = await self._extract_sections(left, index=index, total=total, depth=depth + 1) 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) b = await self._extract_sections(right, index=index, total=total, depth=depth + 1, language=language)
return _combine_sections(a, b) return _combine_sections(a, b)
if truncated: if truncated:
logger.warning( logger.warning(

View File

@@ -9,90 +9,13 @@ from __future__ import annotations
from typing import AsyncIterator from typing import AsyncIterator
from app.application.notebook_rag import NotebookRagUseCase 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.query_rewrite import standalone_question
from app.application.rerank import pool_size, rerank 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.models import ChatMessage
from app.domain.ports import LLMChatProvider from app.domain.ports import LLMChatProvider
_SYSTEM_PROMPT = """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 français, de façon utile et concise. Mets le texte explicatif AVANT les blocs d'action."""
class NotebookChatUseCase: class NotebookChatUseCase:
def __init__( def __init__(
@@ -109,6 +32,7 @@ class NotebookChatUseCase:
messages: list[ChatMessage], messages: list[ChatMessage],
context: str = "", context: str = "",
top_k: int = 6, top_k: int = 6,
language: str = _DEFAULT_LANG,
) -> AsyncIterator[dict]: ) -> AsyncIterator[dict]:
"""Yield des évènements : {type:'sources', sources:[…]} (une fois, avant la """Yield des évènements : {type:'sources', sources:[…]} (une fois, avant la
réponse — transparence sur les passages utilisés), puis {type:'token', token}.""" réponse — transparence sur les passages utilisés), puis {type:'token', token}."""
@@ -143,8 +67,9 @@ class NotebookChatUseCase:
f"--- TA CAMPAGNE ---\n{context.strip()}\n--- FIN CAMPAGNE ---\n\n" 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" if context.strip() else "--- TA CAMPAGNE ---\n(aucune donnée de campagne)\n--- FIN CAMPAGNE ---\n\n"
) )
system_prompt = _SYSTEM_PROMPT.format( system_prompt = prompts.CHAT_SYSTEM.format(
context_block=context_block, sources_block=sources_block) 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): async for token in self._llm.stream_chat(messages, system_prompt=system_prompt):
yield {"type": "token", "token": token} yield {"type": "token", "token": token}

View File

@@ -20,7 +20,9 @@ from typing import AsyncIterator
import tiktoken import tiktoken
from app.application.llm_retry import generate_with_retry 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.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.models import ChatMessage
from app.domain.ports import LLMChatProvider, LLMProvider, LLMProviderError from app.domain.ports import LLMChatProvider, LLMProvider, LLMProviderError
from app.infrastructure import vector_store from app.infrastructure import vector_store
@@ -36,15 +38,6 @@ _MAP_TEMPERATURE = 0.2
# à la question par embedding, et seuls les lots plausiblement pertinents sont # à la question par embedding, et seuls les lots plausiblement pertinents sont
# relus. Sélection volontairement CONSERVATRICE (on préfère relire un lot de # 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. # trop que rater une mention) ; désactivable via deep_summary_filter=False.
_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é :"""
# Un lot est gardé si son score est proche du meilleur (marge) OU bon dans # 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. # l'absolu ; et on garde toujours au moins _MIN_KEPT lots.
@@ -52,38 +45,6 @@ _SELECT_MARGIN = 0.10
_SELECT_FLOOR = 0.5 _SELECT_FLOOR = 0.5
_MIN_KEPT = 3 _MIN_KEPT = 3
_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 français."""
class NotebookDeepUseCase: class NotebookDeepUseCase:
def __init__( def __init__(
@@ -109,6 +70,7 @@ class NotebookDeepUseCase:
messages: list[ChatMessage], messages: list[ChatMessage],
context: str = "", context: str = "",
history_limit: int = 8, history_limit: int = 8,
language: str = _DEFAULT_LANG,
) -> AsyncIterator[dict]: ) -> AsyncIterator[dict]:
"""Yield des évènements : {type:'progress',current,total}, {type:'token',token}, """Yield des évènements : {type:'progress',current,total}, {type:'token',token},
{type:'done'}. (Les erreurs LLM des lots sont tolérées : lot ignoré.) {type:'done'}. (Les erreurs LLM des lots sont tolérées : lot ignoré.)
@@ -175,7 +137,9 @@ class NotebookDeepUseCase:
f"--- TA CAMPAGNE (structure, PNJ, univers) ---\n{context.strip()}\n--- FIN CAMPAGNE ---\n\n" f"--- TA CAMPAGNE (structure, PNJ, univers) ---\n{context.strip()}\n--- FIN CAMPAGNE ---\n\n"
if context.strip() else "" if context.strip() else ""
) )
system_prompt = _REDUCE_SYSTEM.format(context_block=context_block, notes_block=notes_block) 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 # Historique récent pour la cohérence des relances ; on garantit que le
# dernier message est bien la question courante. # dernier message est bien la question courante.
reduce_messages = messages[-history_limit:] if messages else [ChatMessage(role="user", content=question)] reduce_messages = messages[-history_limit:] if messages else [ChatMessage(role="user", content=question)]
@@ -251,7 +215,7 @@ class NotebookDeepUseCase:
async def _summarize_batch(self, batch: list[dict]) -> str: async def _summarize_batch(self, batch: list[dict]) -> str:
excerpt = "\n\n".join(c.get("text", "").strip() for c in batch) excerpt = "\n\n".join(c.get("text", "").strip() for c in batch)
raw = await generate_with_retry( raw = await generate_with_retry(
self._llm, _SUMMARY_PROMPT.format(excerpt=excerpt), temperature=_MAP_TEMPERATURE) self._llm, prompts.SUMMARY_PROMPT.format(excerpt=excerpt), temperature=_MAP_TEMPERATURE)
return (raw or "").strip() return (raw or "").strip()
async def _map_batch(self, question: str, batch: list[dict]) -> str: async def _map_batch(self, question: str, batch: list[dict]) -> str:
@@ -260,7 +224,7 @@ class NotebookDeepUseCase:
f"(p. {c['page']}) {c['text'].strip()}" if c.get("page") else c["text"].strip() f"(p. {c['page']}) {c['text'].strip()}" if c.get("page") else c["text"].strip()
for c in batch for c in batch
) )
prompt = _MAP_PROMPT.format(no_match=_NO_MATCH, question=question, excerpt=excerpt) prompt = prompts.MAP_PROMPT.format(no_match=_NO_MATCH, question=question, excerpt=excerpt)
raw = await generate_with_retry(self._llm, prompt, temperature=_MAP_TEMPERATURE) raw = await generate_with_retry(self._llm, prompt, temperature=_MAP_TEMPERATURE)
answer = raw.strip() answer = raw.strip()
if answer and answer.upper().rstrip(".") != _NO_MATCH: if answer and answer.upper().rstrip(".") != _NO_MATCH:

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

@@ -11,6 +11,7 @@ from __future__ import annotations
import logging import logging
from app.application.prompts import query_rewrite as prompts
from app.domain.models import ChatMessage from app.domain.models import ChatMessage
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -23,22 +24,6 @@ _MAX_HISTORY = 6
# modèle a divagué) → on retombe sur la question brute. # modèle a divagué) → on retombe sur la question brute.
_MAX_REWRITE_CHARS = 400 _MAX_REWRITE_CHARS = 400
_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 :"""
async def standalone_question(llm, messages: list[ChatMessage]) -> str: async def standalone_question(llm, messages: list[ChatMessage]) -> str:
"""Condense `messages` en une question autonome pour la RECHERCHE. """Condense `messages` en une question autonome pour la RECHERCHE.
@@ -56,7 +41,7 @@ async def standalone_question(llm, messages: list[ChatMessage]) -> str:
conversation = "\n".join(f"{m.role.upper()}: {m.content.strip()}" for m in recent) conversation = "\n".join(f"{m.role.upper()}: {m.content.strip()}" for m in recent)
try: try:
raw = await llm.generate( raw = await llm.generate(
_REWRITE_PROMPT.format(conversation=conversation), temperature=0.0) 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 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) logger.warning("Réécriture de question ignorée (échec LLM) : %s", exc)
return last_user return last_user

View File

@@ -11,6 +11,7 @@ from __future__ import annotations
import logging import logging
from app.application.llm_json import load_json_object from app.application.llm_json import load_json_object
from app.application.prompts import rerank as prompts
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -23,16 +24,6 @@ POOL_MAX = 24
# prompt sans changer le jugement de pertinence. # prompt sans changer le jugement de pertinence.
_EXCERPT_CHARS = 600 _EXCERPT_CHARS = 600
_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."""
def pool_size(top_k: int) -> int: def pool_size(top_k: int) -> int:
"""Taille du pool à récupérer avant reranking.""" """Taille du pool à récupérer avant reranking."""
@@ -52,7 +43,7 @@ async def rerank(llm, question: str, passages: list[dict], top_k: int) -> list[d
f"--- EXTRAIT {i + 1} ---\n{(p.get('text') or '')[:_EXCERPT_CHARS]}" f"--- EXTRAIT {i + 1} ---\n{(p.get('text') or '')[:_EXCERPT_CHARS]}"
for i, p in enumerate(passages) for i, p in enumerate(passages)
) )
prompt = _RERANK_PROMPT.format( prompt = prompts.RERANK_PROMPT.format(
question=question, passages=numbered, count=len(passages)) question=question, passages=numbered, count=len(passages))
try: try:
raw = await llm.generate(prompt, temperature=0.0) raw = await llm.generate(prompt, temperature=0.0)

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

@@ -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

@@ -1,194 +1,66 @@
"""Adapter Google Gemini — implémente les ports LLMProvider / LLMChatProvider. """Adapter Google Gemini — implémente les ports LLMProvider / LLMChatProvider.
Gemini expose un endpoint COMPATIBLE OpenAI Gemini expose un endpoint COMPATIBLE OpenAI
(POST {base}/openai/chat/completions, SSE), donc cet adapter est un client (POST {base}/openai/chat/completions, SSE) : client "OpenAI-compatible" qui hérite
"OpenAI-compatible" — même structure que les adapters OpenRouter / Mistral. 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 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 l'extraction de PDF : un CONTEXTE de ~1M tokens → un livre entier tient en 1-2
appels, donc quasi aucun morceau perdu et peu de requêtes (limites jamais appels. Modèle conseillé : `gemini-2.0-flash` (rapide, gros contexte, fidèle).
atteintes). Modèle conseillé : `gemini-2.0-flash` (rapide, gros contexte, fidèle).
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import json
import logging
from typing import AsyncIterator
import httpx
from app.core.config import Settings 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 from app.infrastructure.base_openai_adapter import (
_FIRST_TOKEN_TIMEOUT_SECONDS,
logger = logging.getLogger(__name__) BaseOpenAICompatibleAdapter,
)
_API_URL = "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions"
# Délai max pour le PREMIER token de contenu (échec rapide si le modèle ne produit
# rien). Gemini répond vite ; 120s est large.
_FIRST_TOKEN_TIMEOUT_SECONDS = 120.0
class GeminiLLMProvider: class GeminiLLMProvider(BaseOpenAICompatibleAdapter):
"""Adapter Gemini (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider.""" """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: def __init__(self, settings: Settings) -> None:
if not settings.gemini_api_key: if not settings.gemini_api_key:
raise LLMProviderError( raise LLMProviderError(
"Clé API Gemini manquante. Configure-la depuis l'écran Paramètres " "Clé API Gemini manquante. Configure-la depuis l'écran Paramètres "
"(clé gratuite sur aistudio.google.com)." "(clé gratuite sur aistudio.google.com)."
) )
self._api_key = settings.gemini_api_key super().__init__(
self._model = settings.gemini_model settings.gemini_api_key, settings.gemini_model, settings.llm_timeout_seconds
self._timeout = settings.llm_timeout_seconds
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
"Accept": "application/json",
}
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 _collect_with_timeouts( def _headers(self) -> dict[str, str]:
self, return {**super()._headers(), "Accept": "application/json"}
messages: list[ChatMessage],
temperature: float | None,
output_format: str | None,
) -> str:
"""Collecte le stream avec deux garde-fous : 1er token borné (échec rapide
si rien ne sort) + ceiling global `self._timeout`."""
async def _collect() -> str:
chunks: list[str] = []
agen = self._stream(messages, None, temperature, output_format)
try:
while True:
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(
f"Erreur Gemini : aucun contenu produit en "
f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s. Réessayez ou vérifiez "
"votre quota gratuit."
)
chunks.append(token)
finally:
await agen.aclose()
return "".join(chunks)
try: def _first_token_timeout_message(self) -> str:
return await asyncio.wait_for(_collect(), timeout=self._timeout) return (
except asyncio.TimeoutError as exc: f"Erreur Gemini : aucun contenu produit en "
raise LLMGenerationTimeout( f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s. Réessayez ou vérifiez "
f"Erreur Gemini : génération non terminée en {self._timeout}s. Réduisez la " "votre quota gratuit."
"taille des morceaux d'import ou augmentez le timeout." )
) from exc
async def stream_chat( def _generation_timeout_message(self) -> str:
self, return (
messages: list[ChatMessage], 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."
system_prompt: str | None = None, )
temperature: float | None = None,
) -> AsyncIterator[str]:
async for token in self._stream(messages, system_prompt, temperature):
yield token
async def _stream( def _error_for_status(self, status_code: int, detail: str) -> LLMProviderError:
self, # 401/403 = clé rejetée par GOOGLE (pas un problème LoreMind) : message
messages: list[ChatMessage], # actionnable plutôt que le JSON brut de l'API.
system_prompt: str | None, if status_code in (401, 403):
temperature: float | None, return LLMProviderError(
output_format: str | None = None, "Erreur Gemini : clé API refusée par Google "
) -> AsyncIterator[str]: f"(HTTP {status_code}). Vérifiez que la clé vient bien "
payload_messages: list[dict[str, str]] = [] "de aistudio.google.com (« Get API key ») et qu'elle n'a pas de "
if system_prompt: "restrictions (API ou adresse IP) dans la Google Cloud Console. "
payload_messages.append({"role": "system", "content": system_prompt}) f"Détail : {detail[:300]}"
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 (supporté par l'endpoint OpenAI-compatible de Gemini) :
# supprime fences ```json et JSON invalide, principale cause de morceaux
# 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.
if output_format is not None:
body["response_format"] = {"type": "json_object"}
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
async with client.stream(
"POST", _API_URL, headers=self._headers(), json=body
) as response:
if response.status_code >= 400:
detail = (await response.aread()).decode("utf-8", "replace").strip()
# 401/403 = clé rejetée par GOOGLE (pas un problème LoreMind) :
# message actionnable plutôt que le JSON brut de l'API.
if response.status_code in (401, 403):
raise LLMProviderError(
"Erreur Gemini : clé API refusée par Google "
f"(HTTP {response.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]}"
)
raise LLMProviderError(
f"Erreur Gemini (HTTP {response.status_code})"
+ (f" : {detail[:500]}" if detail else "")
)
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
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:
if isinstance(exc, httpx.TimeoutException):
return (
f"Erreur Gemini : 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 super()._error_for_status(status_code, detail)
return f"Erreur Gemini ({exc.__class__.__name__}) : {detail}"

View File

@@ -1,195 +1,48 @@
"""Adapter Mistral — implémente les ports LLMProvider / LLMChatProvider. """Adapter Mistral — implémente les ports LLMProvider / LLMChatProvider.
Mistral (La Plateforme) expose l'API OpenAI standard (POST {base}/chat/completions, Mistral (La Plateforme) expose l'API OpenAI standard (POST {base}/chat/completions,
SSE), donc cet adapter est un client "OpenAI-compatible" — même structure que SSE) : client "OpenAI-compatible" qui hérite de BaseOpenAICompatibleAdapter et ne
l'adapter OpenRouter. Le `generate` one-shot passe par le streaming (puis fournit que ses spécificités.
recollage) avec un timeout au temps écoulé pour ne jamais pendre à l'infini.
Tier GRATUIT : compte sur console.mistral.ai (tier « Experiment »), clé API à 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 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`. 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 __future__ import annotations
import asyncio
import json
import logging
from typing import AsyncIterator
import httpx
from app.core.config import Settings 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 from app.infrastructure.base_openai_adapter import (
_FIRST_TOKEN_TIMEOUT_SECONDS,
logger = logging.getLogger(__name__) BaseOpenAICompatibleAdapter,
)
_API_URL = "https://api.mistral.ai/v1/chat/completions"
# Délai max pour le PREMIER token de contenu (échec rapide si le modèle est en file
# d'attente et n'envoie que des keep-alive). Généreux car la file d'un tier gratuit
# peut être longue.
_FIRST_TOKEN_TIMEOUT_SECONDS = 120.0
class MistralLLMProvider: class MistralLLMProvider(BaseOpenAICompatibleAdapter):
"""Adapter Mistral (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider.""" """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: def __init__(self, settings: Settings) -> None:
if not settings.mistral_api_key: if not settings.mistral_api_key:
raise LLMProviderError( raise LLMProviderError(
"Clé API Mistral manquante. Configure-la depuis l'écran Paramètres." "Clé API Mistral manquante. Configure-la depuis l'écran Paramètres."
) )
self._api_key = settings.mistral_api_key super().__init__(
self._model = settings.mistral_model settings.mistral_api_key, settings.mistral_model, settings.llm_timeout_seconds
self._timeout = settings.llm_timeout_seconds
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
"Accept": "application/json",
}
async def generate(
self,
prompt: str,
*,
output_format: str | None = None,
temperature: float | None = None,
) -> str:
"""One-shot via streaming (puis recollage) pour robustesse sur longues sorties.
Timeout au TEMPS ÉCOULÉ (asyncio) en plus du timeout réseau d'httpx :
si le provider envoyait des keep-alive sans contenu, l'appel pendrait à
l'infini. Ici on coupe net après `self._timeout` secondes.
"""
return await self._collect_with_timeouts(
[ChatMessage(role="user", content=prompt)], temperature, output_format
) )
async def _collect_with_timeouts( def _headers(self) -> dict[str, str]:
self, return {**super()._headers(), "Accept": "application/json"}
messages: list[ChatMessage],
temperature: float | None,
output_format: str | None,
) -> str:
"""Collecte le stream avec deux garde-fous au temps écoulé : 1er token borné
(file d'attente → échec rapide) + ceiling global `self._timeout`."""
async def _collect() -> str:
chunks: list[str] = []
agen = self._stream(messages, None, temperature, output_format)
try:
while True:
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(
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."
)
chunks.append(token)
finally:
await agen.aclose()
return "".join(chunks)
try: def _first_token_timeout_message(self) -> str:
return await asyncio.wait_for(_collect(), timeout=self._timeout) return (
except asyncio.TimeoutError as exc: f"Erreur Mistral : aucun contenu produit en "
raise LLMGenerationTimeout( f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s — le modèle est probablement "
f"Erreur Mistral : génération non terminée en {self._timeout}s. Réduisez la " "en file d'attente (tier gratuit, 2 req/min). Réessayez plus tard ou "
"taille des morceaux d'import, augmentez le timeout, ou changez de modèle." "choisissez un modèle plus disponible."
) from exc )
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
async def _stream(
self,
messages: list[ChatMessage],
system_prompt: str | None,
temperature: float | None,
output_format: str | None = None,
) -> AsyncIterator[str]:
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 : TOUS les modèles Mistral le supportent → plus de fences
# ```json ni de JSON invalide (retours à la ligne bruts dans les chaînes),
# principale cause de morceaux d'import ignorés. Un SCHÉMA (dict) est
# traduit en json_object : suffisant ici, les grands modèles cloud
# respectent la structure demandée par le prompt.
if output_format is not None:
body["response_format"] = {"type": "json_object"}
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
async with client.stream(
"POST", _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 de Mistral (modèle inconnu, clé
# invalide 401, quota 429…), sinon on n'a que le code HTTP nu.
detail = (await response.aread()).decode("utf-8", "replace").strip()
raise LLMProviderError(
f"Erreur Mistral (HTTP {response.status_code})"
+ (f" : {detail[:500]}" if detail else "")
)
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 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, clé invalide 401, modèle inconnu…)."""
if isinstance(exc, httpx.TimeoutException):
return (
f"Erreur Mistral : 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 Mistral ({exc.__class__.__name__}) : {detail}"

View File

@@ -1,205 +1,57 @@
"""Adapter OpenRouter — implémente les ports LLMProvider / LLMChatProvider. """Adapter OpenRouter — implémente les ports LLMProvider / LLMChatProvider.
OpenRouter expose l'API OpenAI standard (POST {base}/chat/completions, SSE), donc OpenRouter expose l'API OpenAI standard (POST {base}/chat/completions, SSE), donc
cet adapter est en réalité un client "OpenAI-compatible". Le `generate` one-shot cet adapter est un client "OpenAI-compatible" : il hérite de toute la mécanique de
passe lui aussi par le streaming (puis recollage) pour éviter les coupures de BaseOpenAICompatibleAdapter et ne fournit que ses spécificités (URL, en-têtes
passerelle sur les longues générations (cf. 1min.ai / Cloudflare 524). d'attribution, messages, lecture de config).
Modèles GRATUITS : utiliser un id finissant par `:free` (ex. 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) `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é. 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 __future__ import annotations
import asyncio
import json
import logging
from typing import AsyncIterator
import httpx
from app.core.config import Settings from app.core.config import Settings
from app.domain.models import ChatMessage from app.domain.ports import LLMProviderError
from app.infrastructure.base_openai_adapter import (
logger = logging.getLogger(__name__) _FIRST_TOKEN_TIMEOUT_SECONDS,
from app.domain.ports import LLMGenerationTimeout, LLMProviderError BaseOpenAICompatibleAdapter,
)
_API_URL = "https://openrouter.ai/api/v1/chat/completions"
# Délai max pour le PREMIER token de contenu. Un modèle gratuit "en file d'attente"
# n'envoie que des keep-alive (aucun contenu) → on échoue vite et clairement au lieu
# de pendre. Généreux (2 min) car la file d'attente d'un tier gratuit peut être longue.
_FIRST_TOKEN_TIMEOUT_SECONDS = 120.0
class OpenRouterLLMProvider: class OpenRouterLLMProvider(BaseOpenAICompatibleAdapter):
"""Adapter OpenRouter (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider.""" """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: def __init__(self, settings: Settings) -> None:
if not settings.openrouter_api_key: if not settings.openrouter_api_key:
raise LLMProviderError( raise LLMProviderError(
"Clé API OpenRouter manquante. Configure-la depuis l'écran Paramètres." "Clé API OpenRouter manquante. Configure-la depuis l'écran Paramètres."
) )
self._api_key = settings.openrouter_api_key super().__init__(
self._model = settings.openrouter_model settings.openrouter_api_key, settings.openrouter_model, settings.llm_timeout_seconds
self._timeout = settings.llm_timeout_seconds )
def _headers(self) -> dict[str, str]: def _headers(self) -> dict[str, str]:
return { return {
"Authorization": f"Bearer {self._api_key}", **super()._headers(),
"Content-Type": "application/json",
# Attribution facultative (classement OpenRouter) — sans impact fonctionnel. # Attribution facultative (classement OpenRouter) — sans impact fonctionnel.
"HTTP-Referer": "https://loremind.app", "HTTP-Referer": "https://loremind.app",
"X-Title": "LoreMind", "X-Title": "LoreMind",
} }
async def generate( def _first_token_timeout_message(self) -> str:
self, return (
prompt: str, f"Erreur OpenRouter : aucun contenu produit en "
*, f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s — le modèle gratuit est "
output_format: str | None = None, "probablement en file d'attente / saturé. Réessayez plus tard ou "
temperature: float | None = None, "choisissez un autre modèle (1min.ai, ou payant)."
) -> str:
"""One-shot via streaming (puis recollage) pour robustesse sur longues sorties.
Timeout au TEMPS ÉCOULÉ (asyncio) en plus du timeout réseau d'httpx : un
modèle gratuit saturé/en file d'attente envoie des keep-alive (`: OPENROUTER
PROCESSING`) mais AUCUN contenu → httpx ne déclenche jamais son read-timeout
(des octets arrivent) et l'appel pendrait à l'infini. Ici on coupe net après
`self._timeout` secondes, quoi qu'il arrive.
"""
return await self._collect_with_timeouts(
[ChatMessage(role="user", content=prompt)], temperature, output_format, "OpenRouter"
) )
async def _collect_with_timeouts(
self,
messages: list[ChatMessage],
temperature: float | None,
output_format: str | None,
provider: str,
) -> 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.
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.
"""
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 (file d'attente) ; 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(
f"Erreur {provider} : 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)."
)
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(
f"Erreur {provider} : 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."
) from exc
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
async def _stream(
self,
messages: list[ChatMessage],
system_prompt: str | None,
temperature: float | None,
output_format: str | None = None,
) -> AsyncIterator[str]:
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
# NB : on n'impose PAS `response_format=json_object`. 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.
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
async with client.stream(
"POST", _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 d'OpenRouter (ex. le 429 précise
# "free-models-per-day" vs "per-minute"), sinon on n'a que le
# code HTTP nu et le diagnostic est impossible.
detail = (await response.aread()).decode("utf-8", "replace").strip()
raise LLMProviderError(
f"Erreur OpenRouter (HTTP {response.status_code})"
+ (f" : {detail[:500]}" if detail else "")
)
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 OpenRouter : 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 OpenRouter ({exc.__class__.__name__}) : {detail}"

View File

@@ -26,7 +26,7 @@ from app.infrastructure.ollama_model_installer import ensure_ollama_embedding_mo
app = FastAPI( app = FastAPI(
title="LoreMind Brain", title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.", description="Backend IA pour la génération de contenu narratif.",
version="0.14.0-beta", version="0.16.3",
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

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

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,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

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId> <groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId> <artifactId>loremind-core</artifactId>
<version>0.14.0-beta</version> <version>0.16.3</version>
<name>LoreMind Core</name> <name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description> <description>Backend Core - Architecture Hexagonale</description>
@@ -60,11 +60,31 @@
<scope>runtime</scope> <scope>runtime</scope>
</dependency> </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> <dependency>
<groupId>com.h2database</groupId> <groupId>com.h2database</groupId>
<artifactId>h2</artifactId> <artifactId>h2</artifactId>
<scope>test</scope> <scope>runtime</scope>
</dependency> </dependency>
<!-- Lombok (réduit le code boilerplate) --> <!-- Lombok (réduit le code boilerplate) -->
@@ -132,6 +152,45 @@
<build> <build>
<plugins> <plugins>
<!-- Declare EXPLICITEMENT le processor d'annotations Lombok : sinon javac
avertit que l'annotation processing implicite (processeur trouve sur le
classpath sans etre declare) sera desactivee dans un futur JDK. -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<!-- Expose le chemin du jar mockito-core dans la propriete
${org.mockito:mockito-core:jar}, consommee par surefire ci-dessous. -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>properties</goal>
</goals>
</execution>
</executions>
</plugin>
<!-- Charge Mockito comme un VRAI -javaagent (au lieu de l'auto-attachement
dynamique de byte-buddy, qui imprime un avertissement et sera interdit
dans un futur JDK). `@{argLine}` preserve l'argLine pose par JaCoCo. -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>@{argLine} -javaagent:${org.mockito:mockito-core:jar} -Xshare:off</argLine>
</configuration>
</plugin>
<plugin> <plugin>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId> <artifactId>spring-boot-maven-plugin</artifactId>
@@ -175,8 +234,79 @@
<goal>report</goal> <goal>report</goal>
</goals> </goals>
</execution> </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> </executions>
</plugin> </plugin>
</plugins> </plugins>
</build> </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> </project>

View File

@@ -1,5 +1,7 @@
package com.loremind; package com.loremind;
import com.loremind.infrastructure.desktop.DesktopSingleInstance;
import com.loremind.infrastructure.desktop.DesktopUserConfig;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
@@ -13,6 +15,29 @@ import org.springframework.scheduling.annotation.EnableScheduling;
public class LoreMindApplication { public class LoreMindApplication {
public static void main(String[] args) { 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);
} }
} }

View File

@@ -40,26 +40,33 @@ public class LicenseService {
private final LicenseRelay relay; private final LicenseRelay relay;
private final long gracePeriodSeconds; private final long gracePeriodSeconds;
private final long refreshBeforeExpirySeconds; private final long refreshBeforeExpirySeconds;
private final boolean licensingEnabled;
public LicenseService( public LicenseService(
LicenseRepository repository, LicenseRepository repository,
JwtVerifier jwtVerifier, JwtVerifier jwtVerifier,
LicenseRelay relay, LicenseRelay relay,
@Value("${licensing.grace-period-days:14}") int gracePeriodDays, @Value("${licensing.grace-period-days:14}") int gracePeriodDays,
@Value("${licensing.refresh-before-expiry-days:2}") int refreshBeforeExpiryDays) { @Value("${licensing.refresh-before-expiry-days:2}") int refreshBeforeExpiryDays,
@Value("${licensing.enabled:true}") boolean licensingEnabled) {
this.repository = repository; this.repository = repository;
this.jwtVerifier = jwtVerifier; this.jwtVerifier = jwtVerifier;
this.relay = relay; this.relay = relay;
this.gracePeriodSeconds = (long) gracePeriodDays * 86_400L; this.gracePeriodSeconds = (long) gracePeriodDays * 86_400L;
this.refreshBeforeExpirySeconds = (long) refreshBeforeExpiryDays * 86_400L; this.refreshBeforeExpirySeconds = (long) refreshBeforeExpiryDays * 86_400L;
this.licensingEnabled = licensingEnabled;
} }
/** /**
* @return true si le verifier est configure (cle publique presente). * @return true si le licensing Patreon est actif : il faut a la fois que la
* L'UI peut masquer toute la section Patreon si false. * feature soit activee ({@code licensing.enabled}, faux en mode
* bureau/local ou le gating par image Docker n'a aucun sens) ET que
* le verifier soit configure (cle publique presente). Faux => l'UI
* masque toute la section Patreon, le daemon de refresh est no-op,
* et le canal beta est desactive.
*/ */
public boolean isLicensingEnabled() { public boolean isLicensingEnabled() {
return jwtVerifier.isConfigured(); return licensingEnabled && jwtVerifier.isConfigured();
} }
/** /**

View File

@@ -10,6 +10,9 @@ import java.util.List;
*/ */
public interface ConversationTitleGenerator { public interface ConversationTitleGenerator {
/** Renvoie un titre en francais (4-7 mots max). Jamais null ni vide. */ /**
* Renvoie un titre court (4-7 mots max), dans la langue de l'utilisateur
* (relayee au Brain via l'entete X-User-Language). Jamais null ni vide.
*/
String generate(List<ConversationMessage> firstMessages); String generate(List<ConversationMessage> firstMessages);
} }

View File

@@ -26,6 +26,21 @@ public interface ImageStorage {
*/ */
String upload(String filename, String contentType, InputStream data, long sizeBytes); String upload(String filename, String contentType, InputStream data, long sizeBytes);
/**
* Stocke un flux binaire SOUS UNE CLE IMPOSEE (pas de generation).
* <p>
* Utilise par l'import de contenu pour reinjecter une image sous sa cle
* d'origine, garantissant que les references {@code storageKey} portees par
* les entites restent valides apres un transfert inter-instance.
* Ecrase si la cle existe deja.
*
* @param storageKey cle opaque exacte sous laquelle stocker (ex: images/UUID.ext)
* @param contentType MIME type
* @param data flux binaire a stocker
* @param sizeBytes taille en octets (requis par certains backends comme S3)
*/
void store(String storageKey, String contentType, InputStream data, long sizeBytes);
/** Recupere le flux binaire associe a une cle, ou null si inexistante. */ /** Recupere le flux binaire associe a une cle, ou null si inexistante. */
InputStream download(String storageKey); InputStream download(String storageKey);

View File

@@ -4,6 +4,7 @@ import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage; import com.loremind.domain.generationcontext.ChatUsage;
import com.loremind.domain.generationcontext.ports.AiChatProvider; import com.loremind.domain.generationcontext.ports.AiChatProvider;
import com.loremind.domain.generationcontext.ports.AiProviderException; import com.loremind.domain.generationcontext.ports.AiProviderException;
import com.loremind.infrastructure.web.config.UserLanguageHolder;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@@ -64,6 +65,7 @@ public class BrainAiChatClient implements AiChatProvider {
Flux<ServerSentEvent<String>> flux = webClient.post() Flux<ServerSentEvent<String>> flux = webClient.post()
.uri(CHAT_STREAM_PATH) .uri(CHAT_STREAM_PATH)
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.TEXT_EVENT_STREAM) .accept(MediaType.TEXT_EVENT_STREAM)
.bodyValue(payload) .bodyValue(payload)

View File

@@ -3,6 +3,7 @@ package com.loremind.infrastructure.ai;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.ports.CampaignPdfAdvisor; import com.loremind.domain.campaigncontext.ports.CampaignPdfAdvisor;
import com.loremind.infrastructure.web.config.UserLanguageHolder;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.ByteArrayResource;
@@ -65,6 +66,7 @@ public class BrainCampaignAdaptClient implements CampaignPdfAdvisor {
Flux<ServerSentEvent<String>> flux = webClient.post() Flux<ServerSentEvent<String>> flux = webClient.post()
.uri(ADAPT_PATH) .uri(ADAPT_PATH)
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
.contentType(MediaType.MULTIPART_FORM_DATA) .contentType(MediaType.MULTIPART_FORM_DATA)
.accept(MediaType.TEXT_EVENT_STREAM) .accept(MediaType.TEXT_EVENT_STREAM)
.body(BodyInserters.fromMultipartData(parts.build())) .body(BodyInserters.fromMultipartData(parts.build()))

View File

@@ -11,9 +11,9 @@ import com.loremind.domain.campaigncontext.CampaignImportProposal.RoomProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.SceneProposal; import com.loremind.domain.campaigncontext.CampaignImportProposal.SceneProposal;
import com.loremind.domain.campaigncontext.ports.CampaignImportException; import com.loremind.domain.campaigncontext.ports.CampaignImportException;
import com.loremind.domain.campaigncontext.ports.CampaignPdfImporter; import com.loremind.domain.campaigncontext.ports.CampaignPdfImporter;
import com.loremind.infrastructure.web.config.UserLanguageHolder;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.client.MultipartBodyBuilder; import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.http.codec.ServerSentEvent; import org.springframework.http.codec.ServerSentEvent;
@@ -22,7 +22,6 @@ import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.function.Consumer; import java.util.function.Consumer;
@@ -42,7 +41,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
new ParameterizedTypeReference<>() {}; new ParameterizedTypeReference<>() {};
private final WebClient webClient; private final WebClient webClient;
private final ObjectMapper objectMapper; private final BrainSseImportSupport sse;
private final long importTimeoutSeconds; private final long importTimeoutSeconds;
public BrainCampaignImportClient( public BrainCampaignImportClient(
@@ -51,7 +50,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
@Value("${brain.base-url}") String baseUrl, @Value("${brain.base-url}") String baseUrl,
@Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds) { @Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds) {
this.webClient = webClientBuilder.baseUrl(baseUrl).build(); this.webClient = webClientBuilder.baseUrl(baseUrl).build();
this.objectMapper = objectMapper; this.sse = new BrainSseImportSupport(objectMapper);
this.importTimeoutSeconds = importTimeoutSeconds; this.importTimeoutSeconds = importTimeoutSeconds;
} }
@@ -66,11 +65,12 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
Consumer<Throwable> onError) { Consumer<Throwable> onError) {
MultipartBodyBuilder parts = new MultipartBodyBuilder(); MultipartBodyBuilder parts = new MultipartBodyBuilder();
parts.part("file", filePart(pdfBytes, filename)) parts.part("file", sse.filePart(pdfBytes, filename, "campaign.pdf"))
.filename(filename == null || filename.isBlank() ? "campaign.pdf" : filename); .filename(filename == null || filename.isBlank() ? "campaign.pdf" : filename);
Flux<ServerSentEvent<String>> flux = webClient.post() Flux<ServerSentEvent<String>> flux = webClient.post()
.uri(IMPORT_CAMPAIGN_STREAM_PATH) .uri(IMPORT_CAMPAIGN_STREAM_PATH)
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
.contentType(MediaType.MULTIPART_FORM_DATA) .contentType(MediaType.MULTIPART_FORM_DATA)
.accept(MediaType.TEXT_EVENT_STREAM) .accept(MediaType.TEXT_EVENT_STREAM)
.body(BodyInserters.fromMultipartData(parts.build())) .body(BodyInserters.fromMultipartData(parts.build()))
@@ -81,31 +81,16 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
int[] ocrPageCount = {0}; int[] ocrPageCount = {0};
boolean[] terminated = {false}; boolean[] terminated = {false};
try { sse.runStream(
flux flux, importTimeoutSeconds, terminated,
.timeout(Duration.ofSeconds(importTimeoutSeconds)) event -> handleEvent(
.doOnNext(sse -> handleEvent( event, pageCount, ocrPageCount, terminated,
sse, pageCount, ocrPageCount, terminated, onProgress, onHeartbeat, onStatus, onDone, onError),
onProgress, onHeartbeat, onStatus, onDone, onError)) onError, CampaignImportException::new);
.blockLast();
if (!terminated[0]) {
onError.accept(new CampaignImportException(
"Le flux d'import s'est interrompu avant la fin."));
}
} catch (Exception e) {
if (!terminated[0]) {
// On EXPOSE la cause réelle (type + message) : sans ça, le diagnostic
// est impossible (timeout WebClient, connexion coupée, réponse non-2xx…).
String cause = e.getClass().getSimpleName()
+ (e.getMessage() != null ? "" + e.getMessage() : "");
onError.accept(new CampaignImportException(
"Erreur lors du streaming d'import depuis le Brain : " + cause, e));
}
}
} }
private void handleEvent( private void handleEvent(
ServerSentEvent<String> sse, ServerSentEvent<String> ssEvent,
int[] pageCount, int[] pageCount,
int[] ocrPageCount, int[] ocrPageCount,
boolean[] terminated, boolean[] terminated,
@@ -115,8 +100,8 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
Consumer<CampaignImportProposal> onDone, Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> onError) { Consumer<Throwable> onError) {
String event = sse.event(); String event = ssEvent.event();
String data = sse.data() == null ? "" : sse.data(); String data = ssEvent.data() == null ? "" : ssEvent.data();
if ("heartbeat".equals(event)) { if ("heartbeat".equals(event)) {
// Keep-alive du Brain pendant un appel LLM long : à PROPAGER jusqu'au // Keep-alive du Brain pendant un appel LLM long : à PROPAGER jusqu'au
@@ -127,23 +112,17 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
if ("status".equals(event)) { if ("status".equals(event)) {
// Message d'attente lisible (retry sur fournisseur saturé, morceau // Message d'attente lisible (retry sur fournisseur saturé, morceau
// re-découpé…) : affiché par l'UI au lieu de n'exister qu'en logs. // re-découpé…) : affiché par l'UI au lieu de n'exister qu'en logs.
onStatus.accept(readMessage(data)); onStatus.accept(sse.readMessage(data));
return; return;
} }
if ("chunk_failed".equals(event)) { if ("chunk_failed".equals(event)) {
JsonNode node = readJson(data); onStatus.accept(sse.chunkFailedStatus(data));
String msg = node != null && node.hasNonNull("message")
? node.get("message").asText() : "";
int current = node != null ? node.path("current").asInt() : 0;
int total = node != null ? node.path("total").asInt() : 0;
onStatus.accept("Morceau " + current + "/" + total + " ignoré"
+ (msg.isEmpty() ? "." : " : " + msg));
return; return;
} }
if ("error".equals(event)) { if ("error".equals(event)) {
terminated[0] = true; terminated[0] = true;
onError.accept(new CampaignImportException( onError.accept(new CampaignImportException(
"Le Brain a signalé une erreur : " + readMessage(data))); "Le Brain a signalé une erreur : " + sse.readMessage(data)));
return; return;
} }
if ("extracting".equals(event)) { if ("extracting".equals(event)) {
@@ -151,7 +130,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
return; return;
} }
JsonNode node = readJson(data); JsonNode node = sse.readJson(data);
if (node == null) return; if (node == null) return;
if ("start".equals(event)) { if ("start".equals(event)) {
@@ -244,33 +223,8 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
// --- Helpers ------------------------------------------------------------- // --- Helpers -------------------------------------------------------------
private ByteArrayResource filePart(byte[] pdfBytes, String filename) {
return new ByteArrayResource(pdfBytes) {
@Override
public String getFilename() {
return (filename == null || filename.isBlank()) ? "campaign.pdf" : filename;
}
};
}
private static String text(JsonNode node, String field) { private static String text(JsonNode node, String field) {
JsonNode v = node.path(field); JsonNode v = node.path(field);
return v.isMissingNode() || v.isNull() ? "" : v.asText(); return v.isMissingNode() || v.isNull() ? "" : v.asText();
} }
private JsonNode readJson(String data) {
try {
return objectMapper.readTree(data);
} catch (Exception e) {
return null;
}
}
private String readMessage(String data) {
JsonNode node = readJson(data);
if (node != null && node.hasNonNull("message")) {
return node.get("message").asText();
}
return data;
}
} }

View File

@@ -108,9 +108,7 @@ public class BrainChatPayloadBuilder {
Map<String, Object> map = new LinkedHashMap<>(); Map<String, Object> map = new LinkedHashMap<>();
map.put("name", q.name()); map.put("name", q.name());
map.put("arc_name", q.arcName()); map.put("arc_name", q.arcName());
if (q.description() != null && !q.description().isBlank()) { putIfText(map, "description", q.description());
map.put("description", q.description());
}
return map; return map;
} }
@@ -121,18 +119,14 @@ public class BrainChatPayloadBuilder {
if (e.occurredAt() != null) { if (e.occurredAt() != null) {
map.put("occurred_at", e.occurredAt().toString()); map.put("occurred_at", e.occurredAt().toString());
} }
if (e.sourceSessionName() != null && !e.sourceSessionName().isBlank()) { putIfText(map, "source_session_name", e.sourceSessionName());
map.put("source_session_name", e.sourceSessionName());
}
return map; return map;
} }
private Map<String, Object> gameSystemContextToMap(GameSystemContext gs) { private Map<String, Object> gameSystemContextToMap(GameSystemContext gs) {
Map<String, Object> map = new LinkedHashMap<>(); Map<String, Object> map = new LinkedHashMap<>();
map.put("system_name", gs.systemName()); map.put("system_name", gs.systemName());
if (gs.systemDescription() != null && !gs.systemDescription().isBlank()) { putIfText(map, "system_description", gs.systemDescription());
map.put("system_description", gs.systemDescription());
}
map.put("sections", gs.sections() != null ? gs.sections() : Map.of()); map.put("sections", gs.sections() != null ? gs.sections() : Map.of());
return map; return map;
} }
@@ -211,18 +205,14 @@ public class BrainChatPayloadBuilder {
private Map<String, Object> characterSummaryToMap(CharacterSummary c) { private Map<String, Object> characterSummaryToMap(CharacterSummary c) {
Map<String, Object> map = new LinkedHashMap<>(); Map<String, Object> map = new LinkedHashMap<>();
map.put("name", c.name()); map.put("name", c.name());
if (c.snippet() != null && !c.snippet().isBlank()) { putIfText(map, "snippet", c.snippet());
map.put("snippet", c.snippet());
}
return map; return map;
} }
private Map<String, Object> npcSummaryToMap(NpcSummary n) { private Map<String, Object> npcSummaryToMap(NpcSummary n) {
Map<String, Object> map = new LinkedHashMap<>(); Map<String, Object> map = new LinkedHashMap<>();
map.put("name", n.name()); map.put("name", n.name());
if (n.snippet() != null && !n.snippet().isBlank()) { putIfText(map, "snippet", n.snippet());
map.put("snippet", n.snippet());
}
return map; return map;
} }
@@ -300,9 +290,7 @@ public class BrainChatPayloadBuilder {
Map<String, Object> map = new LinkedHashMap<>(); Map<String, Object> map = new LinkedHashMap<>();
map.put("label", b.label()); map.put("label", b.label());
map.put("target_scene_name", b.targetSceneName()); map.put("target_scene_name", b.targetSceneName());
if (b.condition() != null && !b.condition().isBlank()) { putIfText(map, "condition", b.condition());
map.put("condition", b.condition());
}
return map; return map;
} }
@@ -310,14 +298,14 @@ public class BrainChatPayloadBuilder {
Map<String, Object> map = new LinkedHashMap<>(); Map<String, Object> map = new LinkedHashMap<>();
map.put("name", r.name()); map.put("name", r.name());
if (r.floor() != null) map.put("floor", r.floor()); if (r.floor() != null) map.put("floor", r.floor());
if (r.description() != null && !r.description().isBlank()) map.put("description", r.description()); putIfText(map, "description", r.description());
if (r.enemies() != null && !r.enemies().isBlank()) map.put("enemies", r.enemies()); putIfText(map, "enemies", r.enemies());
if (r.branches() != null && !r.branches().isEmpty()) { if (r.branches() != null && !r.branches().isEmpty()) {
map.put("branches", r.branches().stream().map(b -> { map.put("branches", r.branches().stream().map(b -> {
Map<String, Object> bm = new LinkedHashMap<>(); Map<String, Object> bm = new LinkedHashMap<>();
bm.put("label", b.label()); bm.put("label", b.label());
bm.put("target_room_name", b.targetRoomName()); bm.put("target_room_name", b.targetRoomName());
if (b.condition() != null && !b.condition().isBlank()) bm.put("condition", b.condition()); putIfText(bm, "condition", b.condition());
return bm; return bm;
}).collect(Collectors.toList())); }).collect(Collectors.toList()));
} }
@@ -331,4 +319,15 @@ public class BrainChatPayloadBuilder {
map.put("fields", ne.fields()); map.put("fields", ne.fields());
return map; return map;
} }
/**
* Ajoute {@code key → value} uniquement si {@code value} est une chaîne non
* nulle et non blanche. Centralise l'omission des champs Optional « texte »
* pour s'aligner sur le schéma Pydantic du Brain (champ absent si vide).
*/
private static void putIfText(Map<String, Object> map, String key, String value) {
if (value != null && !value.isBlank()) {
map.put(key, value);
}
}
} }

View File

@@ -2,6 +2,7 @@ package com.loremind.infrastructure.ai;
import com.loremind.domain.conversationcontext.ConversationMessage; import com.loremind.domain.conversationcontext.ConversationMessage;
import com.loremind.domain.conversationcontext.ports.ConversationTitleGenerator; import com.loremind.domain.conversationcontext.ports.ConversationTitleGenerator;
import com.loremind.infrastructure.web.config.UserLanguageHolder;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -50,6 +51,7 @@ public class BrainConversationTitleClient implements ConversationTitleGenerator
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
Map<String, Object> resp = webClient.post() Map<String, Object> resp = webClient.post()
.uri(PATH) .uri(PATH)
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.bodyValue(payload) .bodyValue(payload)
.retrieve() .retrieve()

View File

@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.ports.NotebookChatStreamer; import com.loremind.domain.campaigncontext.ports.NotebookChatStreamer;
import com.loremind.domain.campaigncontext.ports.NotebookException; import com.loremind.domain.campaigncontext.ports.NotebookException;
import com.loremind.infrastructure.web.config.UserLanguageHolder;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@@ -66,6 +67,7 @@ public class BrainNotebookChatClient implements NotebookChatStreamer {
Flux<ServerSentEvent<String>> flux = webClient.post() Flux<ServerSentEvent<String>> flux = webClient.post()
.uri(deep ? DEEP_PATH : PATH) .uri(deep ? DEEP_PATH : PATH)
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.TEXT_EVENT_STREAM) .accept(MediaType.TEXT_EVENT_STREAM)
.bodyValue(payload) .bodyValue(payload)

View File

@@ -6,10 +6,10 @@ import com.loremind.domain.gamesystemcontext.RulesImportProgress;
import com.loremind.domain.gamesystemcontext.RulesImportResult; import com.loremind.domain.gamesystemcontext.RulesImportResult;
import com.loremind.domain.gamesystemcontext.ports.RulesImportException; import com.loremind.domain.gamesystemcontext.ports.RulesImportException;
import com.loremind.domain.gamesystemcontext.ports.RulesPdfImporter; import com.loremind.domain.gamesystemcontext.ports.RulesPdfImporter;
import com.loremind.infrastructure.web.config.UserLanguageHolder;
import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpEntity; import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@@ -25,7 +25,6 @@ import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
@@ -55,7 +54,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
private final RestTemplate restTemplate; private final RestTemplate restTemplate;
private final WebClient webClient; private final WebClient webClient;
private final ObjectMapper objectMapper; private final BrainSseImportSupport sse;
private final String baseUrl; private final String baseUrl;
private final long importTimeoutSeconds; private final long importTimeoutSeconds;
@@ -67,7 +66,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
@Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds) { @Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds) {
this.restTemplate = restTemplate; this.restTemplate = restTemplate;
this.webClient = webClientBuilder.baseUrl(baseUrl).build(); this.webClient = webClientBuilder.baseUrl(baseUrl).build();
this.objectMapper = objectMapper; this.sse = new BrainSseImportSupport(objectMapper);
this.baseUrl = baseUrl; this.baseUrl = baseUrl;
this.importTimeoutSeconds = importTimeoutSeconds; this.importTimeoutSeconds = importTimeoutSeconds;
} }
@@ -80,7 +79,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
headers.setContentType(MediaType.MULTIPART_FORM_DATA); headers.setContentType(MediaType.MULTIPART_FORM_DATA);
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>(); MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("file", filePart(pdfBytes, filename)); body.add("file", sse.filePart(pdfBytes, filename, "rules.pdf"));
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers); HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
try { try {
@@ -120,11 +119,12 @@ public class BrainRulesImportClient implements RulesPdfImporter {
Consumer<Throwable> onError) { Consumer<Throwable> onError) {
MultipartBodyBuilder parts = new MultipartBodyBuilder(); MultipartBodyBuilder parts = new MultipartBodyBuilder();
parts.part("file", filePart(pdfBytes, filename)) parts.part("file", sse.filePart(pdfBytes, filename, "rules.pdf"))
.filename(filename == null || filename.isBlank() ? "rules.pdf" : filename); .filename(filename == null || filename.isBlank() ? "rules.pdf" : filename);
Flux<ServerSentEvent<String>> flux = webClient.post() Flux<ServerSentEvent<String>> flux = webClient.post()
.uri(IMPORT_RULES_STREAM_PATH) .uri(IMPORT_RULES_STREAM_PATH)
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
.contentType(MediaType.MULTIPART_FORM_DATA) .contentType(MediaType.MULTIPART_FORM_DATA)
.accept(MediaType.TEXT_EVENT_STREAM) .accept(MediaType.TEXT_EVENT_STREAM)
.body(BodyInserters.fromMultipartData(parts.build())) .body(BodyInserters.fromMultipartData(parts.build()))
@@ -137,33 +137,16 @@ public class BrainRulesImportClient implements RulesPdfImporter {
int[] ocrPageCount = {0}; int[] ocrPageCount = {0};
boolean[] terminated = {false}; boolean[] terminated = {false};
try { sse.runStream(
flux flux, importTimeoutSeconds, terminated,
.timeout(Duration.ofSeconds(importTimeoutSeconds)) event -> handleEvent(
.doOnNext(sse -> handleEvent( event, pageCount, ocrPageCount, terminated,
sse, pageCount, ocrPageCount, terminated, onProgress, onHeartbeat, onStatus, onDone, onError),
onProgress, onHeartbeat, onStatus, onDone, onError)) onError, RulesImportException::new);
.blockLast();
// Flux terminé sans event done/error (ex: connexion coupée) → on signale.
if (!terminated[0]) {
onError.accept(new RulesImportException(
"Le flux d'import s'est interrompu avant la fin."));
}
} catch (Exception e) {
if (!terminated[0]) {
// On EXPOSE la cause réelle (type + message) : sans ça, l'UI n'a qu'un
// message générique et le diagnostic est impossible (timeout WebClient,
// connexion coupée, réponse non-2xx du Brain, etc.).
String cause = e.getClass().getSimpleName()
+ (e.getMessage() != null ? "" + e.getMessage() : "");
onError.accept(new RulesImportException(
"Erreur lors du streaming d'import depuis le Brain : " + cause, e));
}
}
} }
private void handleEvent( private void handleEvent(
ServerSentEvent<String> sse, ServerSentEvent<String> ssEvent,
int[] pageCount, int[] pageCount,
int[] ocrPageCount, int[] ocrPageCount,
boolean[] terminated, boolean[] terminated,
@@ -173,8 +156,8 @@ public class BrainRulesImportClient implements RulesPdfImporter {
Consumer<RulesImportResult> onDone, Consumer<RulesImportResult> onDone,
Consumer<Throwable> onError) { Consumer<Throwable> onError) {
String event = sse.event(); String event = ssEvent.event();
String data = sse.data() == null ? "" : sse.data(); String data = ssEvent.data() == null ? "" : ssEvent.data();
if ("heartbeat".equals(event)) { if ("heartbeat".equals(event)) {
// Keep-alive du Brain pendant un appel LLM long : à PROPAGER jusqu'au // Keep-alive du Brain pendant un appel LLM long : à PROPAGER jusqu'au
@@ -186,23 +169,17 @@ public class BrainRulesImportClient implements RulesPdfImporter {
if ("status".equals(event)) { if ("status".equals(event)) {
// Message d'attente lisible (retry sur fournisseur saturé, morceau // Message d'attente lisible (retry sur fournisseur saturé, morceau
// re-découpé…) : affiché par l'UI au lieu de n'exister qu'en logs. // re-découpé…) : affiché par l'UI au lieu de n'exister qu'en logs.
onStatus.accept(readMessage(data)); onStatus.accept(sse.readMessage(data));
return; return;
} }
if ("chunk_failed".equals(event)) { if ("chunk_failed".equals(event)) {
JsonNode node = readJson(data); onStatus.accept(sse.chunkFailedStatus(data));
String msg = node != null && node.hasNonNull("message")
? node.get("message").asText() : "";
int current = node != null ? node.path("current").asInt() : 0;
int total = node != null ? node.path("total").asInt() : 0;
onStatus.accept("Morceau " + current + "/" + total + " ignoré"
+ (msg.isEmpty() ? "." : " : " + msg));
return; return;
} }
if ("error".equals(event)) { if ("error".equals(event)) {
terminated[0] = true; terminated[0] = true;
onError.accept(new RulesImportException( onError.accept(new RulesImportException(
"Le Brain a signalé une erreur : " + readMessage(data))); "Le Brain a signalé une erreur : " + sse.readMessage(data)));
return; return;
} }
if ("extracting".equals(event)) { if ("extracting".equals(event)) {
@@ -211,7 +188,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
return; return;
} }
JsonNode node = readJson(data); JsonNode node = sse.readJson(data);
if (node == null) return; if (node == null) return;
if ("start".equals(event)) { if ("start".equals(event)) {
@@ -237,32 +214,6 @@ public class BrainRulesImportClient implements RulesPdfImporter {
// --- Helpers ------------------------------------------------------------- // --- Helpers -------------------------------------------------------------
/** ByteArrayResource avec nom de fichier : sans nom, l'upload n'est pas vu comme un fichier. */
private ByteArrayResource filePart(byte[] pdfBytes, String filename) {
return new ByteArrayResource(pdfBytes) {
@Override
public String getFilename() {
return (filename == null || filename.isBlank()) ? "rules.pdf" : filename;
}
};
}
private JsonNode readJson(String data) {
try {
return objectMapper.readTree(data);
} catch (Exception e) {
return null; // morceau de flux non-JSON inattendu : on l'ignore.
}
}
private String readMessage(String data) {
JsonNode node = readJson(data);
if (node != null && node.hasNonNull("message")) {
return node.get("message").asText();
}
return data;
}
private List<String> toStringList(JsonNode array) { private List<String> toStringList(JsonNode array) {
List<String> out = new ArrayList<>(); List<String> out = new ArrayList<>();
if (array != null && array.isArray()) { if (array != null && array.isArray()) {
@@ -274,7 +225,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
private Map<String, String> toStringMap(JsonNode object) { private Map<String, String> toStringMap(JsonNode object) {
Map<String, String> out = new LinkedHashMap<>(); Map<String, String> out = new LinkedHashMap<>();
if (object != null && object.isObject()) { if (object != null && object.isObject()) {
object.fields().forEachRemaining(e -> out.put(e.getKey(), e.getValue().asText())); object.properties().forEach(e -> out.put(e.getKey(), e.getValue().asText()));
} }
return out; return out;
} }

View File

@@ -0,0 +1,126 @@
package com.loremind.infrastructure.ai;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.annotation.Profile;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.TimeUnit;
/**
* Lance le Brain (service IA Python) comme SOUS-PROCESSUS du Core, en mode
* local-first (application de bureau empaquetee, sans Docker).
* <p>
* Cycle de vie calque sur celui du Core :
* <ul>
* <li>demarrage : a {@link ApplicationReadyEvent} (le serveur HTTP du Core
* est deja pret) ;</li>
* <li>arret : a {@link PreDestroy} (fermeture du contexte Spring) — on arrete
* proprement le Brain pour ne pas laisser de process orphelin.</li>
* </ul>
* <p>
* Tolerance aux pannes : si le Brain ne peut pas etre lance (exe absent,
* commande non configuree...), on LOGGUE sans faire echouer le Core. L'app
* reste utilisable (Lore, Campagnes, Systeme de jeu) ; seules les fonctions IA
* sont indisponibles jusqu'a correction.
*/
@Component
@Profile("local")
public class BrainSidecar {
private static final Logger log = LoggerFactory.getLogger(BrainSidecar.class);
private final BrainSidecarProperties props;
private final String internalSecret;
private volatile Process process;
public BrainSidecar(BrainSidecarProperties props,
@Value("${brain.internal-secret:}") String internalSecret) {
this.props = props;
this.internalSecret = internalSecret;
}
@EventListener(ApplicationReadyEvent.class)
public void start() {
if (!props.isEnabled()) {
log.info("[Brain] Sidecar desactive (brain.sidecar.enabled=false).");
return;
}
if (props.getCommand() == null || props.getCommand().isEmpty()) {
log.warn("[Brain] Aucune commande configuree (brain.sidecar.command) : "
+ "le Brain n'est pas lance. Les fonctions IA seront indisponibles.");
return;
}
try {
ProcessBuilder pb = new ProcessBuilder(props.getCommand());
pb.redirectErrorStream(true);
pb.redirectOutput(ProcessBuilder.Redirect.INHERIT);
File workingDir = resolveWorkingDir();
if (workingDir != null) {
pb.directory(workingDir);
}
// Secret partage Core <-> Brain : le Brain est fail-closed sans lui.
// (cf. Settings.internal_shared_secret cote Python -> env INTERNAL_SHARED_SECRET)
pb.environment().put("INTERNAL_SHARED_SECRET", internalSecret);
this.process = pb.start();
log.info("[Brain] Sidecar demarre (pid={}, cwd={}).",
process.pid(), workingDir != null ? workingDir : "<heritee>");
} catch (IOException e) {
log.error("[Brain] Echec du lancement du sidecar (commande={}). "
+ "Les fonctions IA seront indisponibles. Cause : {}",
props.getCommand(), e.getMessage());
}
}
@PreDestroy
public void stop() {
Process p = this.process;
if (p == null || !p.isAlive()) {
return;
}
log.info("[Brain] Arret du sidecar (pid={})...", p.pid());
p.destroy();
try {
if (!p.waitFor(10, TimeUnit.SECONDS)) {
log.warn("[Brain] Arret propre depasse (10s) : kill force.");
p.destroyForcibly();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
p.destroyForcibly();
}
}
/**
* Resout (et cree au besoin) le repertoire de travail du Brain. Le Brain y
* ecrit son dossier {@code data/} (index vectoriel, settings.json).
*/
private File resolveWorkingDir() {
String dir = props.getWorkingDir();
if (dir == null || dir.isBlank()) {
return null; // herite du cwd du Core
}
Path path = Path.of(dir).toAbsolutePath().normalize();
try {
Files.createDirectories(path);
} catch (IOException e) {
log.warn("[Brain] Impossible de creer le repertoire de travail {} : {}. "
+ "Lancement avec le cwd herite.", path, e.getMessage());
return null;
}
return path.toFile();
}
}

View File

@@ -0,0 +1,53 @@
package com.loremind.infrastructure.ai;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* Configuration du lancement du Brain (service IA Python) en SIDECAR, c.-a-d.
* comme sous-processus du Core, en mode local-first.
* <p>
* En deploiement Docker, le Brain est un conteneur independant : ce mecanisme
* est inactif (profil {@code local} uniquement). En application de bureau
* empaquetee, il n'y a pas de Docker : le Core demarre lui-meme le Brain.
*
* @see BrainSidecar
*/
@Component
@Profile("local")
@ConfigurationProperties(prefix = "brain.sidecar")
public class BrainSidecarProperties {
/** Active le lancement du Brain par le Core. */
private boolean enabled = false;
/**
* Commande de lancement (programme + arguments). Vide = ne rien lancer
* (cas du dev qui demarre le Brain a la main).
* <ul>
* <li>Mode empaquete (jpackage) : chemin de l'exe PyInstaller, ex.
* {@code C:\Program Files\LoreMind\brain\loremind-brain.exe}</li>
* <li>Dev : {@code python,-m,uvicorn,app.main:app,--host,127.0.0.1,--port,8000}</li>
* </ul>
*/
private List<String> command = List.of();
/**
* Repertoire de travail du process Brain. Le Brain ecrit ses donnees (index
* vectoriel, settings.json) sous {@code data/} RELATIF a ce dossier : on le
* place donc sous loremind.home pour que tout vive au meme endroit.
*/
private String workingDir;
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public List<String> getCommand() { return command; }
public void setCommand(List<String> command) { this.command = command; }
public String getWorkingDir() { return workingDir; }
public void setWorkingDir(String workingDir) { this.workingDir = workingDir; }
}

View File

@@ -0,0 +1,107 @@
package com.loremind.infrastructure.ai;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.codec.ServerSentEvent;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.function.BiFunction;
import java.util.function.Consumer;
/**
* Helper d'infrastructure mutualisé entre les clients d'import SSE du Brain
* ({@link BrainRulesImportClient} et {@link BrainCampaignImportClient}), qui
* partagent la même mécanique de transport (multipart → flux SSE WebClient) et
* les mêmes événements transverses (heartbeat / status / chunk_failed).
* <p>
* Volontairement instancié en interne par chaque client (et non injecté) pour
* préserver leurs signatures de constructeur. Le parsing métier des événements
* {@code start} / {@code progress} / {@code done} reste propre à chaque client.
*/
final class BrainSseImportSupport {
private final ObjectMapper objectMapper;
BrainSseImportSupport(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
/** ByteArrayResource avec nom de fichier : sans nom, l'upload n'est pas vu comme un fichier. */
ByteArrayResource filePart(byte[] bytes, String filename, String defaultName) {
return new ByteArrayResource(bytes) {
@Override
public String getFilename() {
return (filename == null || filename.isBlank()) ? defaultName : filename;
}
};
}
/** Parse le JSON, ou {@code null} si illisible (morceau de flux non-JSON inattendu : ignoré). */
JsonNode readJson(String data) {
try {
return objectMapper.readTree(data);
} catch (Exception e) {
return null;
}
}
/** Champ {@code message} du JSON, ou la {@code data} brute si non-JSON / champ absent. */
String readMessage(String data) {
JsonNode node = readJson(data);
if (node != null && node.hasNonNull("message")) {
return node.get("message").asText();
}
return data;
}
/** Statut lisible « Morceau x/y ignoré[ : message] » depuis un payload {@code chunk_failed}. */
String chunkFailedStatus(String data) {
JsonNode node = readJson(data);
String msg = node != null && node.hasNonNull("message")
? node.get("message").asText() : "";
int current = node != null ? node.path("current").asInt() : 0;
int total = node != null ? node.path("total").asInt() : 0;
return "Morceau " + current + "/" + total + " ignoré"
+ (msg.isEmpty() ? "." : " : " + msg);
}
/**
* Consomme le flux SSE jusqu'au bout ({@code blockLast}) en dispatchant chaque
* événement vers {@code handler}, et traduit les fins anormales en {@code onError} :
* <ul>
* <li>flux clos sans event {@code done}/{@code error} ({@code terminated[0]==false}) ;</li>
* <li>exception de transport (timeout WebClient, connexion coupée, réponse non-2xx)
* — la cause réelle (type + message) est exposée dans le message.</li>
* </ul>
* {@code errorFactory} fabrique l'exception de domaine à partir d'un message et
* d'une cause (nullable pour l'interruption silencieuse).
*/
void runStream(
Flux<ServerSentEvent<String>> flux,
long timeoutSeconds,
boolean[] terminated,
Consumer<ServerSentEvent<String>> handler,
Consumer<Throwable> onError,
BiFunction<String, Throwable, ? extends RuntimeException> errorFactory) {
try {
flux
.timeout(Duration.ofSeconds(timeoutSeconds))
.doOnNext(handler)
.blockLast();
// Flux terminé sans event done/error (ex: connexion coupée) → on signale.
if (!terminated[0]) {
onError.accept(errorFactory.apply(
"Le flux d'import s'est interrompu avant la fin.", null));
}
} catch (Exception e) {
if (!terminated[0]) {
String cause = e.getClass().getSimpleName()
+ (e.getMessage() != null ? "" + e.getMessage() : "");
onError.accept(errorFactory.apply(
"Erreur lors du streaming d'import depuis le Brain : " + cause, e));
}
}
}
}

View File

@@ -1,5 +1,6 @@
package com.loremind.infrastructure.ai; package com.loremind.infrastructure.ai;
import com.loremind.infrastructure.web.config.UserLanguageHolder;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.boot.web.reactive.function.client.WebClientCustomizer; import org.springframework.boot.web.reactive.function.client.WebClientCustomizer;
@@ -17,6 +18,12 @@ import java.time.Duration;
* <p> * <p>
* Sans cette entete, le Brain refuse la requete (401) — defense contre * Sans cette entete, le Brain refuse la requete (401) — defense contre
* l'acces direct au Brain depuis un attaquant qui atteindrait son port. * l'acces direct au Brain depuis un attaquant qui atteindrait son port.
* <p>
* Relaie aussi l'entete X-User-Language (langue choisie dans l'UI, capturee par
* {@link com.loremind.infrastructure.web.config.UserLanguageFilter}) pour que le
* Brain redige ses reponses IA dans la langue de l'utilisateur. Lu depuis le
* ThreadLocal au moment de l'execution de la requete (thread servlet) — d'ou
* l'usage d'un interceptor (et non d'un defaultHeader fige au demarrage).
*/ */
@Configuration @Configuration
public class RestTemplateConfig { public class RestTemplateConfig {
@@ -30,12 +37,13 @@ public class RestTemplateConfig {
@Value("${brain.timeout-seconds}") long timeoutSeconds, @Value("${brain.timeout-seconds}") long timeoutSeconds,
@Value("${brain.internal-secret}") String internalSecret) { @Value("${brain.internal-secret}") String internalSecret) {
return builder return builder
.setConnectTimeout(Duration.ofSeconds(10)) .connectTimeout(Duration.ofSeconds(10))
.setReadTimeout(Duration.ofSeconds(timeoutSeconds)) .readTimeout(Duration.ofSeconds(timeoutSeconds))
.additionalInterceptors((request, body, execution) -> { .additionalInterceptors((request, body, execution) -> {
if (internalSecret != null && !internalSecret.isBlank()) { if (internalSecret != null && !internalSecret.isBlank()) {
request.getHeaders().set(INTERNAL_SECRET_HEADER, internalSecret); request.getHeaders().set(INTERNAL_SECRET_HEADER, internalSecret);
} }
request.getHeaders().set(UserLanguageHolder.HEADER, UserLanguageHolder.get());
return execution.execute(request, body); return execution.execute(request, body);
}) })
.build(); .build();
@@ -53,12 +61,13 @@ public class RestTemplateConfig {
@Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds, @Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds,
@Value("${brain.internal-secret}") String internalSecret) { @Value("${brain.internal-secret}") String internalSecret) {
return builder return builder
.setConnectTimeout(Duration.ofSeconds(10)) .connectTimeout(Duration.ofSeconds(10))
.setReadTimeout(Duration.ofSeconds(importTimeoutSeconds)) .readTimeout(Duration.ofSeconds(importTimeoutSeconds))
.additionalInterceptors((request, body, execution) -> { .additionalInterceptors((request, body, execution) -> {
if (internalSecret != null && !internalSecret.isBlank()) { if (internalSecret != null && !internalSecret.isBlank()) {
request.getHeaders().set(INTERNAL_SECRET_HEADER, internalSecret); request.getHeaders().set(INTERNAL_SECRET_HEADER, internalSecret);
} }
request.getHeaders().set(UserLanguageHolder.HEADER, UserLanguageHolder.get());
return execution.execute(request, body); return execution.execute(request, body);
}) })
.build(); .build();

View File

@@ -0,0 +1,31 @@
package com.loremind.infrastructure.desktop;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.annotation.Profile;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
/**
* En mode bureau (profil "local"), ouvre le navigateur par defaut sur
* l'application des que le serveur est pret. L'app n'ayant pas de fenetre
* native, c'est ce qui donne a l'utilisateur un retour visuel immediat apres
* le double-clic.
* <p>
* Concerne uniquement l'instance qui a effectivement demarre le serveur :
* l'instance « perdante » du verrou unique ouvre le navigateur des le {@code main}
* puis sort (cf. {@link DesktopSingleInstance}).
*/
@Component
@Profile("local")
public class DesktopBrowserOpener {
private static final Logger log = LoggerFactory.getLogger(DesktopBrowserOpener.class);
@EventListener(ApplicationReadyEvent.class)
public void onReady() {
log.info("[Desktop] Application prete — ouverture du navigateur.");
DesktopSingleInstance.openAppInBrowser();
}
}

View File

@@ -0,0 +1,152 @@
package com.loremind.infrastructure.desktop;
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
/**
* Utilitaires du mode BUREAU (profil "local", application empaquetee jpackage).
* <p>
* Resout deux problemes specifiques au lancement par double-clic :
* <ol>
* <li><b>Instance unique</b> : un serveur web n'ouvre pas de fenetre. Un
* utilisateur qui ne voit rien re-double-clique souvent — la 2e instance
* trouvait la base H2 verrouillee et sortait en erreur, ce que le launcher
* jpackage traduit par un trompeur « Failed to launch JVM ». On detecte
* donc tres tot (avant Spring) qu'une instance tourne deja, et on se
* contente d'ouvrir le navigateur puis de sortir proprement (code 0).</li>
* <li><b>Ouverture du navigateur</b> : l'app n'ayant pas de fenetre native,
* on ouvre le navigateur par defaut sur l'URL locale pour que l'utilisateur
* voie l'application immediatement.</li>
* </ol>
* Volontairement sans dependance a {@code java.awt.Desktop} : ce module
* ({@code java.desktop}) pourrait etre absent du runtime reduit par jlink.
* On passe donc par la commande systeme d'ouverture d'URL.
*/
public final class DesktopSingleInstance {
/** Conserve le verrou ouvert pour TOUTE la duree de vie du process (sinon GC = relache). */
@SuppressWarnings("unused")
private static FileChannel lockChannel;
private static FileLock lock;
private DesktopSingleInstance() {}
/** Vrai si le profil Spring actif inclut "local" (cas de l'app de bureau). */
public static boolean isLocalProfile(String[] args) {
String prop = System.getProperty("spring.profiles.active", "");
String env = System.getenv().getOrDefault("SPRING_PROFILES_ACTIVE", "");
if (containsLocal(prop) || containsLocal(env)) return true;
if (args != null) {
for (String a : args) {
if (a.startsWith("--spring.profiles.active=") && containsLocal(a)) return true;
}
}
return false;
}
private static boolean containsLocal(String s) {
for (String p : s.split("[,=]")) {
if (p.trim().equals("local")) return true;
}
return false;
}
/**
* Tente de prendre le verrou d'instance unique (fichier {@code .instance.lock}
* sous loremind.home). Retourne {@code true} si on est la PREMIERE instance
* (verrou obtenu, on doit demarrer le serveur), {@code false} si une autre
* instance le detient deja.
* <p>
* En cas d'erreur d'E/S inattendue, on retourne {@code true} (degradation
* prudente : mieux vaut tenter de demarrer que bloquer l'app).
*/
public static boolean tryAcquire() {
try {
Path dir = loremindHome();
Files.createDirectories(dir);
Path lockFile = dir.resolve(".instance.lock");
lockChannel = FileChannel.open(lockFile,
StandardOpenOption.CREATE, StandardOpenOption.WRITE);
lock = lockChannel.tryLock();
return lock != null; // null = deja verrouille par une autre instance
} catch (IOException e) {
System.err.println("[Desktop] Verrou d'instance indisponible (" + e.getMessage()
+ ") — on tente de demarrer quand meme.");
return true;
}
}
/** Ouvre le navigateur par defaut sur l'URL de l'application locale (port reel). */
public static void openAppInBrowser() {
openUrl("http://localhost:" + DesktopUserConfig.runningPort() + "/");
}
/** Ouvre le navigateur par defaut sur une URL quelconque (sans dependance AWT). */
public static void openUrl(String url) {
try {
String os = System.getProperty("os.name", "").toLowerCase();
ProcessBuilder pb;
if (os.contains("win")) {
// rundll32 : ouverture d'URL fiable sans dependance graphique Java.
pb = new ProcessBuilder("rundll32", "url.dll,FileProtocolHandler", url);
} else if (os.contains("mac")) {
pb = new ProcessBuilder("open", url);
} else {
pb = new ProcessBuilder("xdg-open", url);
}
pb.start();
} catch (IOException e) {
System.err.println("[Desktop] Impossible d'ouvrir le navigateur sur " + url
+ " : " + e.getMessage() + ". Ouvrez-le manuellement.");
}
}
/** Ouvre un dossier dans le gestionnaire de fichiers du systeme. */
public static void openFolder(Path dir) {
try {
Files.createDirectories(dir);
String os = System.getProperty("os.name", "").toLowerCase();
ProcessBuilder pb;
if (os.contains("win")) {
pb = new ProcessBuilder("explorer.exe", dir.toString());
} else if (os.contains("mac")) {
pb = new ProcessBuilder("open", dir.toString());
} else {
pb = new ProcessBuilder("xdg-open", dir.toString());
}
pb.start();
} catch (IOException e) {
System.err.println("[Desktop] Ouverture du dossier impossible : " + e.getMessage());
}
}
/** Ouvre un fichier texte dans l'editeur par defaut (Bloc-notes sous Windows). */
public static void openInEditor(Path file) {
try {
String os = System.getProperty("os.name", "").toLowerCase();
ProcessBuilder pb;
if (os.contains("win")) {
// notepad : toujours present, ouvre proprement un .properties
// (dont l'association par defaut n'est pas garantie).
pb = new ProcessBuilder("notepad.exe", file.toString());
} else if (os.contains("mac")) {
pb = new ProcessBuilder("open", "-t", file.toString());
} else {
pb = new ProcessBuilder("xdg-open", file.toString());
}
pb.start();
} catch (IOException e) {
System.err.println("[Desktop] Ouverture du fichier impossible : " + e.getMessage());
}
}
private static Path loremindHome() {
String home = System.getProperty("loremind.home");
if (home != null && !home.isBlank()) return Path.of(home);
return Path.of(System.getProperty("user.home"), ".loremind");
}
}

View File

@@ -0,0 +1,129 @@
package com.loremind.infrastructure.desktop;
import com.fasterxml.jackson.databind.JsonNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.info.BuildProperties;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
import java.util.Optional;
/**
* Verification des mises a jour pour l'application de BUREAU (profil "local").
* <p>
* Contrairement au mode Docker (registry + Watchtower, cf.
* {@link com.loremind.infrastructure.updates.UpdateCheckService}), il n'y a pas
* de mise a jour automatique : on interroge l'API <b>GitHub Releases</b> pour la
* derniere release STABLE, on compare a la version courante du binaire, et si
* une version plus recente existe on le signale (via l'icone systray, cf.
* {@link SystemTrayManager}). L'utilisateur telecharge puis lance le nouvel
* installeur (MSI de meme UpgradeCode = mise a jour en place).
* <p>
* {@code /releases/latest} ne renvoie que les releases stables (pas les
* prereleases) : les utilisateurs stables ne sont donc pas notifies des betas.
*/
@Service
@Profile("local")
public class DesktopUpdateService {
private static final Logger log = LoggerFactory.getLogger(DesktopUpdateService.class);
private final RestTemplate http;
private final boolean enabled;
private final String releasesApiUrl;
/** Version semver du binaire courant (ex: "0.14.0"), ou null en dev sans build-info. */
private final String currentVersion;
public DesktopUpdateService(
RestTemplateBuilder builder,
@Value("${desktop.update.enabled:true}") boolean enabled,
@Value("${desktop.update.releases-api-url:https://api.github.com/repos/IGMLcreation/LoreMind/releases/latest}") String releasesApiUrl,
@Nullable BuildProperties buildProperties) {
this.http = builder
.connectTimeout(Duration.ofSeconds(5))
.readTimeout(Duration.ofSeconds(10))
.build();
this.enabled = enabled;
this.releasesApiUrl = releasesApiUrl;
this.currentVersion = buildProperties != null ? buildProperties.getVersion() : null;
}
/**
* Interroge GitHub Releases. Retourne les infos de mise a jour SI une version
* plus recente que la version courante existe, sinon {@code Optional.empty()}
* (a jour, desactive, ou verification impossible — jamais d'exception propagee).
*/
public Optional<UpdateInfo> checkForUpdate() {
if (!enabled || currentVersion == null) {
return Optional.empty();
}
try {
HttpHeaders headers = new HttpHeaders();
// GitHub exige un User-Agent ; l'Accept versionne l'API.
headers.set(HttpHeaders.USER_AGENT, "LoreMind-Desktop");
headers.set(HttpHeaders.ACCEPT, "application/vnd.github+json");
ResponseEntity<JsonNode> resp = http.exchange(
releasesApiUrl, HttpMethod.GET, new HttpEntity<>(headers), JsonNode.class);
JsonNode body = resp.getBody();
if (body == null || body.path("tag_name").isMissingNode()) {
return Optional.empty();
}
String tag = body.path("tag_name").asText(""); // ex: "v0.15.0"
String releaseUrl = body.path("html_url").asText(null); // page de la release
String latest = tag.startsWith("v") ? tag.substring(1) : tag;
if (!latest.isBlank() && compareSemver(currentVersion, latest) < 0) {
log.info("[Update] Nouvelle version disponible : {} (courante : {})", latest, currentVersion);
return Optional.of(new UpdateInfo(currentVersion, latest, releaseUrl));
}
log.info("[Update] A jour (courante : {}, derniere release : {}).", currentVersion, latest);
return Optional.empty();
} catch (Exception e) {
// Hors-ligne, rate-limit GitHub, etc. : non bloquant, on ne notifie juste pas.
log.info("[Update] Verification GitHub Releases impossible : {}", e.getMessage());
return Optional.empty();
}
}
/** Infos d'une mise a jour disponible. */
public record UpdateInfo(String currentVersion, String latestVersion, String releaseUrl) {}
/**
* Compare deux versions MAJOR.MINOR.PATCH (suffixe -beta/-rc ignore).
* @return &lt;0 si a&lt;b, 0 si egales, &gt;0 si a&gt;b.
*/
static int compareSemver(String a, String b) {
int[] va = parse(a);
int[] vb = parse(b);
for (int i = 0; i < 3; i++) {
int cmp = Integer.compare(va[i], vb[i]);
if (cmp != 0) return cmp;
}
return 0;
}
private static int[] parse(String version) {
String core = version.split("[-+]", 2)[0]; // retire -beta, -rc, +build...
String[] parts = core.split("\\.");
int[] out = new int[3];
for (int i = 0; i < 3 && i < parts.length; i++) {
try {
out[i] = Integer.parseInt(parts[i].trim());
} catch (NumberFormatException ignored) {
out[i] = 0;
}
}
return out;
}
}

View File

@@ -0,0 +1,166 @@
package com.loremind.infrastructure.desktop;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Properties;
/**
* Configuration UTILISATEUR du mode bureau, dans un fichier éditable
* {@code ~/.loremind/loremind.properties} (port HTTP, identifiants admin).
* <p>
* Pourquoi un fichier et pas l'UI : le port et le mot de passe admin sont requis
* AVANT que le serveur (et donc l'UI web) ne démarre — impossible de les régler
* depuis l'app elle-même. Un fichier texte simple, lu au démarrage, est le
* pattern standard d'une app de bureau. Les modifs prennent effet au prochain
* lancement.
* <p>
* - {@code server.port} : lu ici (résolution du port) ET par Spring.
* - {@code admin.username} / {@code admin.password} : chargés par Spring via
* {@code spring.config.import} (cf. application-local.properties) — ils
* surchargent les défauts du profil local.
* <p>
* Repli de port : si le port configuré est occupé, on en choisit un libre
* automatiquement (évite un échec de démarrage cryptique sur conflit de port)
* et on écrit le port réellement utilisé dans {@code ~/.loremind/.port} pour que
* l'ouverture du navigateur (instance gagnante OU 2e double-clic) cible la bonne URL.
*/
public final class DesktopUserConfig {
private DesktopUserConfig() {}
private static final String DEFAULT_TEMPLATE = """
# ============================================================
# Configuration locale de LoreMind (mode bureau)
# ------------------------------------------------------------
# Modifiez ces valeurs puis RELANCEZ LoreMind pour appliquer.
# ============================================================
# Port HTTP local de l'application (http://localhost:<port>).
# Si ce port est deja occupe par une autre application, LoreMind
# choisira automatiquement un autre port libre au demarrage.
server.port=8080
# Identifiants de la page Parametres (acces admin).
# Accessible uniquement en local sur cette machine.
admin.username=admin
admin.password=admin
""";
/** Chemin du fichier de config utilisateur (pour l'ouvrir depuis le menu systray). */
public static Path getConfigFile() {
return configFile();
}
/** Dossier de données/config de l'instance ({@code ~/.loremind}). */
public static Path getHomeDir() {
return loremindHome();
}
/** Crée le fichier de config avec des valeurs par défaut commentées s'il n'existe pas. */
public static void ensureExists() {
Path file = configFile();
if (Files.exists(file)) {
return;
}
try {
Files.createDirectories(file.getParent());
Files.writeString(file, DEFAULT_TEMPLATE);
System.out.println("[Desktop] Config utilisateur creee : " + file);
} catch (IOException e) {
System.err.println("[Desktop] Impossible de creer " + file + " : " + e.getMessage()
+ " — defauts utilises (port 8080, admin/admin).");
}
}
/**
* Résout le port à utiliser : le port configuré s'il est libre, sinon un port
* libre choisi automatiquement. Publie le résultat dans la propriété système
* {@code server.port} (que Spring lira en priorité) et dans {@code ~/.loremind/.port}.
*
* @return le port effectivement retenu.
*/
public static int resolveAndPublishPort() {
int wanted = configuredPort();
int chosen = isPortFree(wanted) ? wanted : findFreePort(wanted);
if (chosen != wanted) {
System.out.println("[Desktop] Port " + wanted + " occupe — repli sur le port libre " + chosen + ".");
}
System.setProperty("server.port", String.valueOf(chosen));
try {
Files.writeString(portFile(), String.valueOf(chosen));
} catch (IOException e) {
System.err.println("[Desktop] Ecriture du port impossible (" + e.getMessage() + ").");
}
return chosen;
}
/**
* Port sur lequel l'application répond réellement (pour ouvrir le navigateur).
* Ordre : propriété système (instance gagnante) → fichier .port (2e double-clic)
* → port configuré → 8080.
*/
public static int runningPort() {
String sys = System.getProperty("server.port");
if (sys != null && !sys.isBlank()) {
try { return Integer.parseInt(sys.trim()); } catch (NumberFormatException ignored) { /* fallthrough */ }
}
try {
Path p = portFile();
if (Files.exists(p)) {
return Integer.parseInt(Files.readString(p).trim());
}
} catch (IOException | NumberFormatException ignored) { /* fallthrough */ }
return configuredPort();
}
/** Port lu dans le fichier de config utilisateur (défaut 8080). */
private static int configuredPort() {
Path file = configFile();
if (Files.exists(file)) {
Properties props = new Properties();
try (InputStream in = Files.newInputStream(file)) {
props.load(in);
String v = props.getProperty("server.port");
if (v != null && !v.isBlank()) {
return Integer.parseInt(v.trim());
}
} catch (IOException | NumberFormatException ignored) { /* défaut */ }
}
return 8080;
}
private static boolean isPortFree(int port) {
try (ServerSocket s = new ServerSocket(port, 1, InetAddress.getByName("127.0.0.1"))) {
s.setReuseAddress(true);
return true;
} catch (IOException e) {
return false;
}
}
private static int findFreePort(int fallbackIfNone) {
try (ServerSocket s = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))) {
return s.getLocalPort();
} catch (IOException e) {
return fallbackIfNone; // tres improbable ; on retente le port voulu
}
}
private static Path configFile() {
return loremindHome().resolve("loremind.properties");
}
private static Path portFile() {
return loremindHome().resolve(".port");
}
private static Path loremindHome() {
String home = System.getProperty("loremind.home");
if (home != null && !home.isBlank()) return Path.of(home);
return Path.of(System.getProperty("user.home"), ".loremind");
}
}

View File

@@ -0,0 +1,170 @@
package com.loremind.infrastructure.desktop;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Profile;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import java.awt.Color;
import java.awt.Font;
import java.awt.Graphics2D;
import java.awt.Image;
import java.awt.MenuItem;
import java.awt.PopupMenu;
import java.awt.RenderingHints;
import java.awt.SystemTray;
import java.awt.TrayIcon;
import java.awt.image.BufferedImage;
/**
* Icone dans la zone de notification (barre des taches) en mode bureau
* (profil "local"). Donne a l'utilisateur un controle visible de l'application,
* qui tourne sinon en serveur sans fenetre : impossible autrement de la fermer
* proprement (fermer l'onglet du navigateur laisse le Core et le Brain tourner).
* <p>
* Menu : « Ouvrir LoreMind » (ouvre le navigateur) et « Quitter LoreMind »
* (ferme le contexte Spring — ce qui declenche le {@code @PreDestroy} de
* {@link com.loremind.infrastructure.ai.BrainSidecar} et arrete donc aussi le
* Brain — puis termine le process).
* <p>
* Necessite que le mode headless soit desactive (cf. LoreMindApplication.main,
* qui appelle {@code setHeadless(false)} en profil local). Le module
* {@code java.desktop} est embarque dans le runtime jpackage.
*/
@Component
@Profile("local")
public class SystemTrayManager {
private static final Logger log = LoggerFactory.getLogger(SystemTrayManager.class);
private final ConfigurableApplicationContext context;
private final DesktopUpdateService updateService;
private TrayIcon trayIcon;
private PopupMenu popup;
public SystemTrayManager(ConfigurableApplicationContext context,
DesktopUpdateService updateService) {
this.context = context;
this.updateService = updateService;
}
@EventListener(ApplicationReadyEvent.class)
public void install() {
if (!SystemTray.isSupported()) {
log.warn("[Tray] Zone de notification non supportee sur ce systeme — "
+ "pas d'icone. Pour quitter : menu de la fenetre console, ou gestionnaire des taches.");
return;
}
try {
popup = new PopupMenu();
MenuItem open = new MenuItem("Ouvrir LoreMind");
open.addActionListener(e -> DesktopSingleInstance.openAppInBrowser());
popup.add(open);
popup.addSeparator();
// Reglages : ouvre loremind.properties (port, identifiants admin) dans
// l'editeur. Les changements prennent effet au prochain demarrage.
MenuItem editConfig = new MenuItem("Modifier la configuration (port, identifiants…)");
editConfig.addActionListener(e -> {
DesktopUserConfig.ensureExists();
DesktopSingleInstance.openInEditor(DesktopUserConfig.getConfigFile());
});
popup.add(editConfig);
// Acces au dossier de donnees (~/.loremind : base, images, config…).
MenuItem openFolder = new MenuItem("Ouvrir le dossier LoreMind");
openFolder.addActionListener(e -> DesktopSingleInstance.openFolder(DesktopUserConfig.getHomeDir()));
popup.add(openFolder);
popup.addSeparator();
MenuItem quit = new MenuItem("Quitter LoreMind");
quit.addActionListener(e -> quit());
popup.add(quit);
trayIcon = new TrayIcon(createIcon(), "LoreMind", popup);
trayIcon.setImageAutoSize(true);
// Double-clic sur l'icone : ouvre l'application dans le navigateur.
trayIcon.addActionListener(e -> DesktopSingleInstance.openAppInBrowser());
SystemTray.getSystemTray().add(trayIcon);
log.info("[Tray] Icone installee dans la zone de notification.");
// Verification de mise a jour en arriere-plan (appel reseau GitHub) :
// ne bloque pas le demarrage ; met a jour le menu/notifie si dispo.
new Thread(this::checkForUpdate, "loremind-update-check").start();
} catch (Exception e) {
// Echec non bloquant : l'app reste utilisable, seul le confort de l'icone manque.
log.warn("[Tray] Installation de l'icone impossible : {}", e.getMessage());
}
}
/**
* Interroge GitHub Releases ; si une version plus recente existe, ajoute un
* item de menu « Telecharger » et affiche une bulle de notification. L'item
* ouvre la page de la release dans le navigateur (telechargement manuel du
* nouvel installeur).
*/
private void checkForUpdate() {
updateService.checkForUpdate().ifPresent(info -> {
String label = "⬇ Telecharger la mise a jour (v" + info.latestVersion() + ")";
MenuItem update = new MenuItem(label);
update.addActionListener(e -> DesktopSingleInstance.openUrl(info.releaseUrl()));
// En tete de menu pour la visibilite, suivi d'un separateur.
popup.insert(update, 0);
popup.insertSeparator(1);
trayIcon.displayMessage(
"LoreMind — mise a jour disponible",
"Version " + info.latestVersion() + " disponible (vous avez " + info.currentVersion()
+ "). Menu de l'icone → Telecharger.",
TrayIcon.MessageType.INFO);
});
}
/**
* Arret propre depuis le menu « Quitter » : on retire l'icone puis on ferme
* le contexte Spring dans un thread dedie (l'action s'execute sur l'EDT AWT ;
* fermer le contexte + arreter Tomcat/Brain depuis l'EDT pourrait le bloquer).
*/
private void quit() {
log.info("[Tray] Demande de fermeture de l'application.");
new Thread(() -> {
int code = SpringApplication.exit(context, () -> 0);
System.exit(code);
}, "loremind-shutdown").start();
}
/** Retire l'icone si le contexte se ferme par une autre voie (ex. Ctrl+C). */
@PreDestroy
public void remove() {
if (trayIcon != null) {
SystemTray.getSystemTray().remove(trayIcon);
}
}
/**
* Genere une petite icone (carre arrondi violet « L ») sans dependre d'un
* fichier image — robuste quel que soit l'empaquetage.
*/
private Image createIcon() {
int size = 16;
BufferedImage img = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = img.createGraphics();
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setColor(new Color(0x8B, 0x5C, 0xF6)); // violet de marque LoreMind
g.fillRoundRect(0, 0, size, size, 5, 5);
g.setColor(Color.WHITE);
g.setFont(new Font("SansSerif", Font.BOLD, 12));
g.drawString("L", 4, 13);
g.dispose();
return img;
}
}

View File

@@ -40,8 +40,8 @@ public class HttpLicenseRelay implements LicenseRelay {
RestTemplateBuilder builder, RestTemplateBuilder builder,
@Value("${licensing.relay.base-url:}") String baseUrl) { @Value("${licensing.relay.base-url:}") String baseUrl) {
this.http = builder this.http = builder
.setConnectTimeout(Duration.ofSeconds(5)) .connectTimeout(Duration.ofSeconds(5))
.setReadTimeout(Duration.ofSeconds(15)) .readTimeout(Duration.ofSeconds(15))
.build(); .build();
this.baseUrl = stripTrailingSlash(baseUrl); this.baseUrl = stripTrailingSlash(baseUrl);
} }

View File

@@ -7,6 +7,7 @@ import com.loremind.domain.licensing.RegistryCredentials;
import com.loremind.domain.licensing.ports.DockerConfigWriter; import com.loremind.domain.licensing.ports.DockerConfigWriter;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -25,6 +26,8 @@ import java.util.Optional;
* la plupart du temps. * la plupart du temps.
*/ */
@Component @Component
// Desactivable (ex: tests) : sans cette propriete (prod), le daemon tourne.
@ConditionalOnProperty(name = "licensing.refresh.enabled", matchIfMissing = true)
public class LicenseRefreshDaemon { public class LicenseRefreshDaemon {
private static final Logger log = LoggerFactory.getLogger(LicenseRefreshDaemon.class); private static final Logger log = LoggerFactory.getLogger(LicenseRefreshDaemon.class);

View File

@@ -4,10 +4,14 @@ import com.loremind.infrastructure.persistence.entity.ImageJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.Optional;
/** /**
* Repository Spring Data JPA pour ImageJpaEntity. * Repository Spring Data JPA pour ImageJpaEntity.
* Ne contient aucune requete custom pour l'instant : CRUD standard suffit.
*/ */
@Repository @Repository
public interface ImageJpaRepository extends JpaRepository<ImageJpaEntity, Long> { public interface ImageJpaRepository extends JpaRepository<ImageJpaEntity, Long> {
/** Recherche par cle de stockage (unique). Utilise par l'import de contenu. */
Optional<ImageJpaEntity> findByStorageKey(String storageKey);
} }

View File

@@ -0,0 +1,125 @@
package com.loremind.infrastructure.storage;
import com.loremind.domain.images.ports.ImageStorage;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.UUID;
/**
* Adaptateur d'infrastructure : implemente le port ImageStorage en stockant
* les binaires sur le SYSTEME DE FICHIERS local.
* <p>
* Pendant a {@link MinioImageStorageAdapter} pour le mode "local-first"
* (application de bureau empaquetee via jpackage, sans Docker ni MinIO).
* Active uniquement quand {@code storage.backend=filesystem} ; en l'absence
* de cette propriete, c'est l'adaptateur MinIO qui prend le relais (defaut).
* <p>
* On reutilise EXACTEMENT le meme schema de cle que MinIO ({@code images/UUID.ext})
* pour que les cles restent interchangeables entre les deux backends : une base
* migree de l'un vers l'autre continue de fonctionner sans reecriture.
*/
@Component
@ConditionalOnProperty(name = "storage.backend", havingValue = "filesystem")
public class FilesystemImageStorageAdapter implements ImageStorage {
private final Path root;
public FilesystemImageStorageAdapter(@Value("${storage.filesystem.path}") String basePath) {
this.root = Path.of(basePath).toAbsolutePath().normalize();
}
@PostConstruct
void ensureRootExists() {
try {
Files.createDirectories(root);
System.out.println("[Storage] Backend filesystem actif — racine : " + root);
} catch (IOException e) {
throw new UncheckedIOException("Impossible de creer le dossier de stockage : " + root, e);
}
}
@Override
public String upload(String filename, String contentType, InputStream data, long sizeBytes) {
String storageKey = generateStorageKey(filename);
Path target = resolveKey(storageKey);
try {
Files.createDirectories(target.getParent());
Files.copy(data, target);
return storageKey;
} catch (IOException e) {
throw new UncheckedIOException("Echec de l'ecriture de l'image sur disque : " + target, e);
}
}
@Override
public void store(String storageKey, String contentType, InputStream data, long sizeBytes) {
Path target = resolveKey(storageKey);
try {
Files.createDirectories(target.getParent());
// Ecrase si la cle existe deja (contrat de store-avec-cle).
Files.copy(data, target, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
throw new UncheckedIOException("Echec de l'ecriture de l'image sur disque (cle imposee) : " + target, e);
}
}
@Override
public InputStream download(String storageKey) {
Path source = resolveKey(storageKey);
if (!Files.exists(source)) {
// Cle orpheline : meme contrat que MinIO (null plutot qu'exception).
return null;
}
try {
return Files.newInputStream(source);
} catch (IOException e) {
throw new UncheckedIOException("Echec de la lecture de l'image : " + source, e);
}
}
@Override
public void delete(String storageKey) {
try {
Files.deleteIfExists(resolveKey(storageKey));
} catch (IOException e) {
// Suppression idempotente : on loggue mais on ne propage pas (cf. MinIO).
System.err.println("[Storage] Erreur suppression (non bloquante) : " + e.getMessage());
}
}
/**
* Resout une cle opaque en chemin physique, en se premunissant contre la
* traversee de repertoire : le chemin resolu DOIT rester sous {@code root}
* (une cle malveillante du type {@code ../../etc/passwd} est rejetee).
*/
private Path resolveKey(String storageKey) {
Path resolved = root.resolve(storageKey).normalize();
if (!resolved.startsWith(root)) {
throw new IllegalArgumentException("Cle de stockage invalide (hors racine) : " + storageKey);
}
return resolved;
}
/** Identique a MinioImageStorageAdapter : cle unique + extension d'origine. */
private String generateStorageKey(String originalFilename) {
return "images/" + UUID.randomUUID() + extractExtension(originalFilename);
}
private String extractExtension(String filename) {
if (filename == null) return "";
int dot = filename.lastIndexOf('.');
if (dot < 0 || dot == filename.length() - 1) return "";
String ext = filename.substring(dot).toLowerCase();
// On n'accepte que les extensions connues pour eviter les injections de path.
return ext.matches("\\.(jpg|jpeg|png|webp|gif)") ? ext : "";
}
}

View File

@@ -4,7 +4,10 @@ import io.minio.BucketExistsArgs;
import io.minio.MakeBucketArgs; import io.minio.MakeBucketArgs;
import io.minio.MinioClient; import io.minio.MinioClient;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@@ -14,10 +17,17 @@ import org.springframework.context.annotation.Configuration;
* Expose un bean MinioClient singleton injecte dans MinioImageStorageAdapter. * Expose un bean MinioClient singleton injecte dans MinioImageStorageAdapter.
* S'assure au demarrage que le bucket configure existe (filet de securite : * S'assure au demarrage que le bucket configure existe (filet de securite :
* normalement docker-compose/minio-init l'a deja cree). * normalement docker-compose/minio-init l'a deja cree).
* <p>
* Desactive en mode local-first ({@code storage.backend=filesystem}) : aucun
* client MinIO n'est alors instancie, donc aucune tentative de connexion au
* boot. Defaut = actif (propriete absente ou {@code minio}).
*/ */
@Configuration @Configuration
@ConditionalOnProperty(name = "storage.backend", havingValue = "minio", matchIfMissing = true)
public class MinioConfig { public class MinioConfig {
private static final Logger log = LoggerFactory.getLogger(MinioConfig.class);
@Value("${minio.endpoint}") @Value("${minio.endpoint}")
private String endpoint; private String endpoint;
@@ -60,12 +70,11 @@ public class MinioConfig {
boolean exists = client.bucketExists(BucketExistsArgs.builder().bucket(bucket).build()); boolean exists = client.bucketExists(BucketExistsArgs.builder().bucket(bucket).build());
if (!exists) { if (!exists) {
client.makeBucket(MakeBucketArgs.builder().bucket(bucket).build()); client.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
System.out.println("[MinIO] Bucket '" + bucket + "' cree."); log.info("[MinIO] Bucket '{}' cree.", bucket);
} }
} catch (Exception e) { } catch (Exception e) {
System.err.println("[MinIO] Initialisation impossible (endpoint=" + endpoint log.warn("[MinIO] Initialisation impossible (endpoint={}). Les uploads d'images "
+ "). Les uploads d'images echoueront tant que MinIO n'est pas joignable. " + "echoueront tant que MinIO n'est pas joignable. Cause : {}", endpoint, e.getMessage());
+ "Cause : " + e.getMessage());
} }
} }
} }

View File

@@ -7,6 +7,7 @@ import io.minio.PutObjectArgs;
import io.minio.RemoveObjectArgs; import io.minio.RemoveObjectArgs;
import io.minio.errors.ErrorResponseException; import io.minio.errors.ErrorResponseException;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.io.InputStream; import java.io.InputStream;
@@ -17,8 +18,13 @@ import java.util.UUID;
* MinIO (compatible S3) comme backend de stockage d'objets. * MinIO (compatible S3) comme backend de stockage d'objets.
* <p> * <p>
* Le domaine ne sait rien de MinIO : il manipule juste des cles opaques. * Le domaine ne sait rien de MinIO : il manipule juste des cles opaques.
* <p>
* Backend par defaut ({@code storage.backend=minio} ou propriete absente).
* Le mode local-first le remplace par {@link FilesystemImageStorageAdapter}
* via {@code storage.backend=filesystem}.
*/ */
@Component @Component
@ConditionalOnProperty(name = "storage.backend", havingValue = "minio", matchIfMissing = true)
public class MinioImageStorageAdapter implements ImageStorage { public class MinioImageStorageAdapter implements ImageStorage {
private final MinioClient minioClient; private final MinioClient minioClient;
@@ -48,6 +54,22 @@ public class MinioImageStorageAdapter implements ImageStorage {
} }
} }
@Override
public void store(String storageKey, String contentType, InputStream data, long sizeBytes) {
try {
minioClient.putObject(
PutObjectArgs.builder()
.bucket(bucket)
.object(storageKey)
.stream(data, sizeBytes, -1)
.contentType(contentType)
.build()
);
} catch (Exception e) {
throw new RuntimeException("Echec du store MinIO (cle imposee) : " + e.getMessage(), e);
}
}
@Override @Override
public InputStream download(String storageKey) { public InputStream download(String storageKey) {
try { try {

View File

@@ -0,0 +1,350 @@
package com.loremind.infrastructure.transfer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.loremind.domain.images.ports.ImageStorage;
import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter;
import com.loremind.infrastructure.persistence.entity.*;
import com.loremind.infrastructure.persistence.jpa.*;
import com.loremind.infrastructure.transfer.dto.ContentExport;
import org.springframework.boot.info.BuildProperties;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UncheckedIOException;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
/**
* Service d'EXPORT du "contenu" en format logique (JSON) portable.
* <p>
* Construit un {@link ContentExport} a partir des entites JPA puis le serialise
* dans un .zip contenant :
* <ul>
* <li>{@code manifest.json} — metadonnees (version de format, version app, date)</li>
* <li>{@code data.json} — tout le contenu (pretty-printed)</li>
* <li>{@code images/<storageKey>} — un binaire par image referencee</li>
* </ul>
* Volontairement DECOUPLE de la base : c'est un export logique, pas un dump,
* pour fonctionner entre Postgres (Docker) et H2 (local).
*/
@Service
public class ExportService {
private static final int FORMAT_VERSION = 1;
// Réutilise le converter JPA pour (dé)sérialiser les prérequis dans le MÊME
// format que la base (discriminant "kind"), au lieu de Jackson polymorphe.
private static final PrerequisiteListJsonConverter PREREQ_CONVERTER = new PrerequisiteListJsonConverter();
private final GameSystemJpaRepository gameSystemRepo;
private final LoreJpaRepository loreRepo;
private final LoreNodeJpaRepository loreNodeRepo;
private final TemplateJpaRepository templateRepo;
private final PageJpaRepository pageRepo;
private final CampaignJpaRepository campaignRepo;
private final ArcJpaRepository arcRepo;
private final ChapterJpaRepository chapterRepo;
private final SceneJpaRepository sceneRepo;
private final CharacterJpaRepository characterRepo;
private final NpcJpaRepository npcRepo;
private final EnemyJpaRepository enemyRepo;
private final ItemCatalogJpaRepository itemCatalogRepo;
private final RandomTableJpaRepository randomTableRepo;
private final ImageJpaRepository imageRepo;
private final ImageStorage imageStorage;
private final ObjectMapper objectMapper;
private final String appVersion;
public ExportService(GameSystemJpaRepository gameSystemRepo,
LoreJpaRepository loreRepo,
LoreNodeJpaRepository loreNodeRepo,
TemplateJpaRepository templateRepo,
PageJpaRepository pageRepo,
CampaignJpaRepository campaignRepo,
ArcJpaRepository arcRepo,
ChapterJpaRepository chapterRepo,
SceneJpaRepository sceneRepo,
CharacterJpaRepository characterRepo,
NpcJpaRepository npcRepo,
EnemyJpaRepository enemyRepo,
ItemCatalogJpaRepository itemCatalogRepo,
RandomTableJpaRepository randomTableRepo,
ImageJpaRepository imageRepo,
ImageStorage imageStorage,
ObjectMapper objectMapper,
@Nullable BuildProperties buildProperties) {
this.gameSystemRepo = gameSystemRepo;
this.loreRepo = loreRepo;
this.loreNodeRepo = loreNodeRepo;
this.templateRepo = templateRepo;
this.pageRepo = pageRepo;
this.campaignRepo = campaignRepo;
this.arcRepo = arcRepo;
this.chapterRepo = chapterRepo;
this.sceneRepo = sceneRepo;
this.characterRepo = characterRepo;
this.npcRepo = npcRepo;
this.enemyRepo = enemyRepo;
this.itemCatalogRepo = itemCatalogRepo;
this.randomTableRepo = randomTableRepo;
this.imageRepo = imageRepo;
this.imageStorage = imageStorage;
this.objectMapper = objectMapper;
this.appVersion = buildProperties != null ? buildProperties.getVersion() : "dev";
}
/**
* Charge toutes les entites du perimetre et les mappe vers les DTO plats.
*
* @param exportedAt horodatage ISO stampe par la couche appelante (controller)
*/
public ContentExport buildExport(String exportedAt) {
ContentExport.Manifest manifest =
new ContentExport.Manifest(FORMAT_VERSION, appVersion, exportedAt);
List<ContentExport.GameSystemDto> gameSystems = gameSystemRepo.findAll().stream()
.map(this::toGameSystemDto).toList();
List<ContentExport.LoreDto> lores = loreRepo.findAll().stream()
.map(this::toLoreDto).toList();
List<ContentExport.LoreNodeDto> loreNodes = loreNodeRepo.findAll().stream()
.map(this::toLoreNodeDto).toList();
List<ContentExport.TemplateDto> templates = templateRepo.findAll().stream()
.map(this::toTemplateDto).toList();
List<ContentExport.PageDto> pages = pageRepo.findAll().stream()
.map(this::toPageDto).toList();
List<ContentExport.CampaignDto> campaigns = campaignRepo.findAll().stream()
.map(this::toCampaignDto).toList();
List<ContentExport.ArcDto> arcs = arcRepo.findAll().stream()
.map(this::toArcDto).toList();
List<ContentExport.ChapterDto> chapters = chapterRepo.findAll().stream()
.map(this::toChapterDto).toList();
List<ContentExport.SceneDto> scenes = sceneRepo.findAll().stream()
.map(this::toSceneDto).toList();
List<ContentExport.CharacterDto> characters = characterRepo.findAll().stream()
.map(this::toCharacterDto).toList();
List<ContentExport.NpcDto> npcs = npcRepo.findAll().stream()
.map(this::toNpcDto).toList();
List<ContentExport.EnemyDto> enemies = enemyRepo.findAll().stream()
.map(this::toEnemyDto).toList();
List<ContentExport.ItemCatalogDto> itemCatalogs = itemCatalogRepo.findAll().stream()
.map(this::toItemCatalogDto).toList();
List<ContentExport.RandomTableDto> randomTables = randomTableRepo.findAll().stream()
.map(this::toRandomTableDto).toList();
List<ContentExport.ImageDto> images = imageRepo.findAll().stream()
.map(this::toImageDto).toList();
return new ContentExport(manifest, gameSystems, lores, loreNodes, templates,
pages, campaigns, arcs, chapters, scenes, characters, npcs, enemies,
itemCatalogs, randomTables, images);
}
/**
* Serialise un export dans le flux fourni au format .zip.
* <p>
* Les binaires d'images ne sont ecrits que pour les storageKeys REFERENCES
* par les entites exportees (illustration/map/portrait/header/imageValues),
* pas pour toute la table images — on evite de trimballer des orphelins.
*/
public void writeZip(ContentExport export, OutputStream out) {
try (ZipOutputStream zip = new ZipOutputStream(out)) {
// manifest.json
zip.putNextEntry(new ZipEntry("manifest.json"));
zip.write(objectMapper.writerWithDefaultPrettyPrinter()
.writeValueAsBytes(export.manifest()));
zip.closeEntry();
// data.json
zip.putNextEntry(new ZipEntry("data.json"));
zip.write(objectMapper.copy()
.enable(SerializationFeature.INDENT_OUTPUT)
.writeValueAsBytes(export));
zip.closeEntry();
// Binaires images : uniquement ceux reellement references.
Set<String> referenced = collectReferencedStorageKeys(export);
Set<String> written = new LinkedHashSet<>();
for (String key : referenced) {
if (key == null || key.isBlank() || !written.add(key)) {
continue;
}
try (InputStream data = imageStorage.download(key)) {
if (data == null) {
continue; // cle orpheline : on ignore silencieusement
}
zip.putNextEntry(new ZipEntry("images/" + key));
data.transferTo(zip);
zip.closeEntry();
}
}
} catch (IOException e) {
throw new UncheckedIOException("Echec de la generation du zip d'export", e);
}
}
/**
* Collecte tous les storageKeys references par le contenu exporte :
* Arc/Chapter/Scene (illustration + map), Character/Npc/Enemy
* (portrait + header + imageValues), Page (imageValues).
*/
private Set<String> collectReferencedStorageKeys(ContentExport export) {
Set<String> keys = new LinkedHashSet<>();
for (ContentExport.ArcDto a : export.arcs()) {
addAll(keys, a.illustrationImageIds());
addAll(keys, a.mapImageIds());
}
for (ContentExport.ChapterDto c : export.chapters()) {
addAll(keys, c.illustrationImageIds());
addAll(keys, c.mapImageIds());
}
for (ContentExport.SceneDto s : export.scenes()) {
addAll(keys, s.illustrationImageIds());
addAll(keys, s.mapImageIds());
}
for (ContentExport.CharacterDto c : export.characters()) {
add(keys, c.portraitImageId());
add(keys, c.headerImageId());
addImageValues(keys, c.imageValues());
}
for (ContentExport.NpcDto n : export.npcs()) {
add(keys, n.portraitImageId());
add(keys, n.headerImageId());
addImageValues(keys, n.imageValues());
}
for (ContentExport.EnemyDto e : export.enemies()) {
add(keys, e.portraitImageId());
add(keys, e.headerImageId());
addImageValues(keys, e.imageValues());
}
for (ContentExport.PageDto p : export.pages()) {
addImageValues(keys, p.imageValues());
}
return keys;
}
private void add(Set<String> keys, String key) {
if (key != null && !key.isBlank()) keys.add(key);
}
private void addAll(Set<String> keys, List<String> list) {
if (list != null) list.forEach(k -> add(keys, k));
}
private void addImageValues(Set<String> keys, java.util.Map<String, List<String>> imageValues) {
if (imageValues != null) imageValues.values().forEach(l -> addAll(keys, l));
}
// ----- Mappers entite -> DTO -----
private ContentExport.GameSystemDto toGameSystemDto(GameSystemJpaEntity e) {
return new ContentExport.GameSystemDto(e.getId(), e.getName(), e.getDescription(),
e.getRulesMarkdown(), e.getCharacterTemplate(), e.getNpcTemplate(),
e.getEnemyTemplate(), e.getAuthor(), e.isPublic());
}
private ContentExport.LoreDto toLoreDto(LoreJpaEntity e) {
return new ContentExport.LoreDto(e.getId(), e.getName(), e.getDescription(),
e.getNodeCount(), e.getPageCount());
}
private ContentExport.LoreNodeDto toLoreNodeDto(LoreNodeJpaEntity e) {
return new ContentExport.LoreNodeDto(e.getId(), e.getName(), e.getIcon(),
e.getParentId(), e.getLoreId());
}
private ContentExport.TemplateDto toTemplateDto(TemplateJpaEntity e) {
return new ContentExport.TemplateDto(e.getId(), e.getLoreId(), e.getName(),
e.getDescription(), e.getDefaultNodeId(), e.getFields());
}
private ContentExport.PageDto toPageDto(PageJpaEntity e) {
return new ContentExport.PageDto(e.getId(), e.getLoreId(), e.getNodeId(),
e.getTemplateId(), e.getTitle(), e.getValues(), e.getImageValues(),
e.getKeyValueValues(), e.getTableValues(), e.getNotes(), e.getTags(),
e.getRelatedPageIds());
}
private ContentExport.CampaignDto toCampaignDto(CampaignJpaEntity e) {
return new ContentExport.CampaignDto(e.getId(), e.getName(), e.getDescription(),
e.getArcsCount(), e.getLoreId(), e.getGameSystemId());
}
private ContentExport.ArcDto toArcDto(ArcJpaEntity e) {
return new ContentExport.ArcDto(e.getId(), e.getName(), e.getDescription(),
e.getCampaignId(), e.getOrder(),
e.getType() != null ? e.getType().name() : null,
e.getIcon(), e.getThemes(), e.getStakes(), e.getGmNotes(),
e.getRewards(), e.getResolution(), e.getRelatedPageIds(),
e.getIllustrationImageIds(), e.getMapImageIds());
}
private ContentExport.ChapterDto toChapterDto(ChapterJpaEntity e) {
return new ContentExport.ChapterDto(e.getId(), e.getName(), e.getDescription(),
e.getArcId(), e.getOrder(), PREREQ_CONVERTER.convertToDatabaseColumn(e.getPrerequisites()), e.getIcon(),
e.getGmNotes(), e.getPlayerObjectives(), e.getNarrativeStakes(),
e.getRelatedPageIds(), e.getIllustrationImageIds(), e.getMapImageIds());
}
private ContentExport.SceneDto toSceneDto(SceneJpaEntity e) {
return new ContentExport.SceneDto(e.getId(), e.getName(), e.getDescription(),
e.getChapterId(), e.getOrder(), e.getIcon(), e.getLocation(),
e.getTiming(), e.getAtmosphere(), e.getPlayerNarration(),
e.getGmSecretNotes(), e.getChoicesConsequences(), e.getCombatDifficulty(),
e.getEnemies(), e.getEnemyIds(), e.getRelatedPageIds(),
e.getIllustrationImageIds(), e.getMapImageIds(), e.getBranches(), e.getRooms());
}
private ContentExport.CharacterDto toCharacterDto(CharacterJpaEntity e) {
return new ContentExport.CharacterDto(e.getId(), e.getName(), e.getPortraitImageId(),
e.getHeaderImageId(), e.getValues(), e.getImageValues(), e.getKeyValueValues(),
e.getCampaignId(), e.getPlaythroughId(), e.getOrder());
}
private ContentExport.NpcDto toNpcDto(NpcJpaEntity e) {
return new ContentExport.NpcDto(e.getId(), e.getName(), e.getPortraitImageId(),
e.getHeaderImageId(), e.getValues(), e.getImageValues(), e.getKeyValueValues(),
e.getCampaignId(), e.getRelatedPageIds(), e.getFolder(), e.getOrder());
}
private ContentExport.EnemyDto toEnemyDto(EnemyJpaEntity e) {
return new ContentExport.EnemyDto(e.getId(), e.getName(), e.getLevel(), e.getFolder(),
e.getPortraitImageId(), e.getHeaderImageId(), e.getValues(), e.getImageValues(),
e.getKeyValueValues(), e.getCampaignId(), e.getOrder());
}
private ContentExport.ItemCatalogDto toItemCatalogDto(ItemCatalogJpaEntity e) {
List<ContentExport.CatalogItemDto> items = new ArrayList<>();
if (e.getItems() != null) {
for (CatalogItemJpaEntity i : e.getItems()) {
items.add(new ContentExport.CatalogItemDto(i.getId(), i.getName(),
i.getPrice(), i.getCategory(), i.getDescription(), i.getPosition()));
}
}
return new ContentExport.ItemCatalogDto(e.getId(), e.getName(), e.getDescription(),
e.getIcon(), e.getCampaignId(), e.getOrder(), items);
}
private ContentExport.RandomTableDto toRandomTableDto(RandomTableJpaEntity e) {
List<ContentExport.RandomTableEntryDto> entries = new ArrayList<>();
if (e.getEntries() != null) {
for (RandomTableEntryJpaEntity en : e.getEntries()) {
entries.add(new ContentExport.RandomTableEntryDto(en.getId(), en.getMinRoll(),
en.getMaxRoll(), en.getLabel(), en.getDetail(), en.getPosition()));
}
}
return new ContentExport.RandomTableDto(e.getId(), e.getName(), e.getDescription(),
e.getDiceFormula(), e.getIcon(), e.getCampaignId(), e.getOrder(), entries);
}
private ContentExport.ImageDto toImageDto(ImageJpaEntity e) {
return new ContentExport.ImageDto(e.getId(), e.getFilename(), e.getContentType(),
e.getSizeBytes(), e.getStorageKey());
}
}

View File

@@ -0,0 +1,81 @@
package com.loremind.infrastructure.transfer;
import com.loremind.domain.campaigncontext.ArcType;
import com.loremind.domain.campaigncontext.Prerequisite;
import com.loremind.domain.campaigncontext.SceneBranch;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* Logique de remapping des identifiants pour l'import en mode FUSION
* (cf. {@link ImportService}).
* <p>
* Fonctions PURES (sans état ni I/O) : chaque entité importée reçoit un nouvel id,
* et toutes les références — FK {@code Long} comme refs faibles stockées en
* {@code String} — sont réécrites {@code oldId → newId} via les maps fournies.
* Une référence absente de la map est CONSERVÉE telle quelle (choix : ne jamais
* perdre d'info, ne jamais planter sur une référence hors périmètre d'export).
*/
final class IdRemapper {
private IdRemapper() {
}
/** Remap d'une FK Long : si absente de la map, on garde l'ancienne valeur ; {@code null → null}. */
static Long remapId(Map<Long, Long> map, Long oldId) {
if (oldId == null) return null;
return map.getOrDefault(oldId, oldId);
}
/** Remap d'un id stocké en String ({@code "oldLong" → "newLong"}) via une map Long. */
static String remapStringId(Map<Long, Long> map, String oldId) {
if (oldId == null || oldId.isBlank()) return oldId;
try {
Long newId = map.get(Long.parseLong(oldId.trim()));
return newId != null ? String.valueOf(newId) : oldId;
} catch (NumberFormatException ex) {
return oldId; // pas un Long : on laisse tel quel
}
}
static List<String> remapStringList(Map<Long, Long> map, List<String> ids) {
if (ids == null) return null;
List<String> out = new ArrayList<>(ids.size());
for (String id : ids) out.add(remapStringId(map, id));
return out;
}
static List<Prerequisite> remapPrerequisites(Map<Long, Long> chapterMap, List<Prerequisite> prereqs) {
if (prereqs == null) return null;
List<Prerequisite> out = new ArrayList<>(prereqs.size());
for (Prerequisite p : prereqs) {
if (p instanceof Prerequisite.QuestCompleted qc) {
out.add(new Prerequisite.QuestCompleted(remapStringId(chapterMap, qc.questId())));
} else {
out.add(p); // FlagSet / SessionReached : inchangés
}
}
return out;
}
static List<SceneBranch> remapBranches(Map<Long, Long> sceneMap, List<SceneBranch> branches) {
if (branches == null) return null;
List<SceneBranch> out = new ArrayList<>(branches.size());
for (SceneBranch b : branches) {
out.add(new SceneBranch(b.label(), remapStringId(sceneMap, b.targetSceneId()), b.condition()));
}
return out;
}
/** Parse un {@link ArcType} tolérant : type inconnu ou {@code null} → {@code LINEAR}. */
static ArcType parseArcType(String type) {
if (type == null) return ArcType.LINEAR;
try {
return ArcType.valueOf(type);
} catch (IllegalArgumentException ex) {
return ArcType.LINEAR;
}
}
}

View File

@@ -0,0 +1,91 @@
package com.loremind.infrastructure.transfer;
import com.loremind.domain.images.ports.ImageStorage;
import com.loremind.infrastructure.persistence.entity.ImageJpaEntity;
import com.loremind.infrastructure.persistence.jpa.ImageJpaRepository;
import com.loremind.infrastructure.transfer.dto.ContentExport;
import org.springframework.stereotype.Component;
import java.io.ByteArrayInputStream;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Réécriture des images lors d'un import (cf. {@link ImportService}).
* <p>
* Les binaires sont stockés sous LEUR CLÉ D'ORIGINE (pas de remapping de clé) :
* une image dont la clé existe déjà est RÉUTILISÉE (pas de réupload), pour éviter
* les doublons quand on agrège plusieurs exports dans la même base.
*/
@Component
class ImageImporter {
private final ImageJpaRepository imageRepo;
private final ImageStorage imageStorage;
ImageImporter(ImageJpaRepository imageRepo, ImageStorage imageStorage) {
this.imageRepo = imageRepo;
this.imageStorage = imageStorage;
}
/**
* Réécrit les binaires d'images (clé préservée) et leurs métadonnées.
*
* @param export contenu importé (source des métadonnées par clé)
* @param imageBinaries {@code storageKey → binaire} lus depuis le zip
* @param result compteurs d'images (uploadées / réutilisées) à incrémenter
*/
void importImages(ContentExport export,
Map<String, byte[]> imageBinaries,
ImportResult.Builder result) {
// Index des métadonnées d'image par clé (depuis le data.json).
Map<String, ContentExport.ImageDto> metaByKey = new HashMap<>();
for (ContentExport.ImageDto img : nullSafe(export.images())) {
if (img.storageKey() != null) metaByKey.put(img.storageKey(), img);
}
for (Map.Entry<String, byte[]> bin : imageBinaries.entrySet()) {
String storageKey = bin.getKey();
byte[] data = bin.getValue();
if (imageRepo.findByStorageKey(storageKey).isPresent()) {
// Image déjà présente : on réutilise, pas de réupload (éviter doublon).
result.imageReused();
continue;
}
ContentExport.ImageDto meta = metaByKey.get(storageKey);
String contentType = meta != null && meta.contentType() != null
? meta.contentType() : guessContentType(storageKey);
long size = meta != null ? meta.sizeBytes() : data.length;
imageStorage.store(storageKey, contentType, new ByteArrayInputStream(data), data.length);
ImageJpaEntity e = new ImageJpaEntity();
e.setFilename(meta != null && meta.filename() != null
? meta.filename() : fileNameOf(storageKey));
e.setContentType(contentType);
e.setSizeBytes(size);
e.setStorageKey(storageKey);
imageRepo.save(e);
result.imageUploaded();
}
}
private static <T> List<T> nullSafe(List<T> list) {
return list != null ? list : List.of();
}
private static String fileNameOf(String storageKey) {
int slash = storageKey.lastIndexOf('/');
return slash >= 0 ? storageKey.substring(slash + 1) : storageKey;
}
private static String guessContentType(String storageKey) {
String lower = storageKey.toLowerCase();
if (lower.endsWith(".png")) return "image/png";
if (lower.endsWith(".gif")) return "image/gif";
if (lower.endsWith(".webp")) return "image/webp";
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
return "application/octet-stream";
}
}

View File

@@ -0,0 +1,29 @@
package com.loremind.infrastructure.transfer;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Resume d'un import : nombre d'entites creees par type, + nombre d'images
* reuploadees / reutilisees.
*/
public record ImportResult(Map<String, Integer> created, int imagesUploaded, int imagesReused) {
public static class Builder {
private final Map<String, Integer> created = new LinkedHashMap<>();
private int imagesUploaded;
private int imagesReused;
public void count(String type, int n) {
created.merge(type, n, Integer::sum);
}
public void imageUploaded() { imagesUploaded++; }
public void imageReused() { imagesReused++; }
public ImportResult build() {
return new ImportResult(created, imagesUploaded, imagesReused);
}
}
}

View File

@@ -0,0 +1,510 @@
package com.loremind.infrastructure.transfer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter;
import com.loremind.infrastructure.persistence.entity.*;
import com.loremind.infrastructure.persistence.jpa.*;
import com.loremind.infrastructure.transfer.dto.ContentExport;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
/**
* Service d'IMPORT de contenu en mode FUSION.
* <p>
* On NE remplace PAS l'existant : chaque entite importee est reinseree avec un
* NOUVEL id auto-genere, et toutes les references (FK Long et refs faibles
* String) sont remappees oldId -> newId. Cela permet d'agreger plusieurs exports
* dans une meme base sans collision, entre Postgres et H2.
* <p>
* Algorithme :
* <ol>
* <li>Parse le zip : {@code data.json} -> {@link ContentExport}, binaires gardes en memoire.</li>
* <li>Reecrit les images sous LEUR CLE D'ORIGINE (pas de remapping de cle) ;
* skip si une ImageJpaEntity avec cette cle existe deja.</li>
* <li>Insere top-down en construisant les maps de remapping par type.</li>
* <li>2e passe : remappe les references qui pointent vers des types inserees
* plus tard (parentId, defaultNodeId, refs faibles String) puis re-save.</li>
* </ol>
* Les references vers un id absent des maps (ex. relatedPageId hors export) sont
* CONSERVEES telles quelles (choix : ne pas perdre d'info, ne jamais planter).
*/
@Service
public class ImportService {
// (Dé)sérialise les prérequis dans le format "kind" du converter JPA (Prerequisite
// est scellé, non sérialisable en polymorphe par l'ObjectMapper standard).
private static final PrerequisiteListJsonConverter PREREQ_CONVERTER = new PrerequisiteListJsonConverter();
private final GameSystemJpaRepository gameSystemRepo;
private final LoreJpaRepository loreRepo;
private final LoreNodeJpaRepository loreNodeRepo;
private final TemplateJpaRepository templateRepo;
private final PageJpaRepository pageRepo;
private final CampaignJpaRepository campaignRepo;
private final ArcJpaRepository arcRepo;
private final ChapterJpaRepository chapterRepo;
private final SceneJpaRepository sceneRepo;
private final CharacterJpaRepository characterRepo;
private final NpcJpaRepository npcRepo;
private final EnemyJpaRepository enemyRepo;
private final ItemCatalogJpaRepository itemCatalogRepo;
private final RandomTableJpaRepository randomTableRepo;
private final ImageImporter imageImporter;
private final ObjectMapper objectMapper;
public ImportService(GameSystemJpaRepository gameSystemRepo,
LoreJpaRepository loreRepo,
LoreNodeJpaRepository loreNodeRepo,
TemplateJpaRepository templateRepo,
PageJpaRepository pageRepo,
CampaignJpaRepository campaignRepo,
ArcJpaRepository arcRepo,
ChapterJpaRepository chapterRepo,
SceneJpaRepository sceneRepo,
CharacterJpaRepository characterRepo,
NpcJpaRepository npcRepo,
EnemyJpaRepository enemyRepo,
ItemCatalogJpaRepository itemCatalogRepo,
RandomTableJpaRepository randomTableRepo,
ImageImporter imageImporter,
ObjectMapper objectMapper) {
this.gameSystemRepo = gameSystemRepo;
this.loreRepo = loreRepo;
this.loreNodeRepo = loreNodeRepo;
this.templateRepo = templateRepo;
this.pageRepo = pageRepo;
this.campaignRepo = campaignRepo;
this.arcRepo = arcRepo;
this.chapterRepo = chapterRepo;
this.sceneRepo = sceneRepo;
this.characterRepo = characterRepo;
this.npcRepo = npcRepo;
this.enemyRepo = enemyRepo;
this.itemCatalogRepo = itemCatalogRepo;
this.randomTableRepo = randomTableRepo;
this.imageImporter = imageImporter;
this.objectMapper = objectMapper;
}
@Transactional
public ImportResult importZip(InputStream zipStream) {
// 1. Parse du zip.
ParsedArchive archive = parseArchive(zipStream);
ContentExport export = archive.export();
ImportResult.Builder result = new ImportResult.Builder();
// 2. Reecriture des images (cle preservee).
imageImporter.importImages(export, archive.imageBinaries(), result);
// 3. Insertion top-down + maps de remapping.
Map<Long, Long> gameSystemMap = new HashMap<>();
Map<Long, Long> loreMap = new HashMap<>();
Map<Long, Long> loreNodeMap = new HashMap<>();
Map<Long, Long> templateMap = new HashMap<>();
Map<Long, Long> pageMap = new HashMap<>();
Map<Long, Long> campaignMap = new HashMap<>();
Map<Long, Long> arcMap = new HashMap<>();
Map<Long, Long> chapterMap = new HashMap<>();
Map<Long, Long> npcMap = new HashMap<>();
Map<Long, Long> enemyMap = new HashMap<>();
Map<Long, Long> characterMap = new HashMap<>();
Map<Long, Long> sceneMap = new HashMap<>();
// -- GameSystem
for (ContentExport.GameSystemDto d : nullSafe(export.gameSystems())) {
GameSystemJpaEntity e = new GameSystemJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setRulesMarkdown(d.rulesMarkdown());
e.setCharacterTemplate(d.characterTemplate());
e.setNpcTemplate(d.npcTemplate());
e.setEnemyTemplate(d.enemyTemplate());
e.setAuthor(d.author());
e.setPublic(d.isPublic());
gameSystemMap.put(d.id(), gameSystemRepo.save(e).getId());
}
result.count("gameSystems", gameSystemMap.size());
// -- Lore
for (ContentExport.LoreDto d : nullSafe(export.lores())) {
LoreJpaEntity e = new LoreJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setNodeCount(d.nodeCount());
e.setPageCount(d.pageCount());
loreMap.put(d.id(), loreRepo.save(e).getId());
}
result.count("lores", loreMap.size());
// -- LoreNode (parentId remappe en 2e passe)
List<LoreNodeJpaEntity> loreNodesToFix = new ArrayList<>();
for (ContentExport.LoreNodeDto d : nullSafe(export.loreNodes())) {
LoreNodeJpaEntity e = new LoreNodeJpaEntity();
e.setName(d.name());
e.setIcon(d.icon());
e.setParentId(d.parentId()); // remappe plus bas
e.setLoreId(IdRemapper.remapId(loreMap, d.loreId()));
LoreNodeJpaEntity saved = loreNodeRepo.save(e);
loreNodeMap.put(d.id(), saved.getId());
if (d.parentId() != null) loreNodesToFix.add(saved);
}
result.count("loreNodes", loreNodeMap.size());
// -- Template (defaultNodeId remappe en 2e passe)
List<ContentExport.TemplateDto> templatesWithDefaultNode = new ArrayList<>();
for (ContentExport.TemplateDto d : nullSafe(export.templates())) {
TemplateJpaEntity e = new TemplateJpaEntity();
e.setLoreId(IdRemapper.remapId(loreMap, d.loreId()));
e.setName(d.name());
e.setDescription(d.description());
e.setDefaultNodeId(d.defaultNodeId()); // remappe plus bas
e.setFields(d.fields());
templateMap.put(d.id(), templateRepo.save(e).getId());
if (d.defaultNodeId() != null) templatesWithDefaultNode.add(d);
}
result.count("templates", templateMap.size());
// -- Page (relatedPageIds remappe en 2e passe)
for (ContentExport.PageDto d : nullSafe(export.pages())) {
PageJpaEntity e = new PageJpaEntity();
e.setLoreId(IdRemapper.remapId(loreMap, d.loreId()));
e.setNodeId(IdRemapper.remapId(loreNodeMap, d.nodeId()));
e.setTemplateId(IdRemapper.remapId(templateMap, d.templateId()));
e.setTitle(d.title());
e.setValues(d.values());
e.setImageValues(d.imageValues());
e.setKeyValueValues(d.keyValueValues());
e.setTableValues(d.tableValues());
e.setNotes(d.notes());
e.setTags(d.tags());
e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas
pageMap.put(d.id(), pageRepo.save(e).getId());
}
result.count("pages", pageMap.size());
// -- Campaign (loreId/gameSystemId String remappes en 2e passe)
for (ContentExport.CampaignDto d : nullSafe(export.campaigns())) {
CampaignJpaEntity e = new CampaignJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setArcsCount(d.arcsCount());
e.setLoreId(d.loreId()); // remappe plus bas
e.setGameSystemId(d.gameSystemId()); // remappe plus bas
campaignMap.put(d.id(), campaignRepo.save(e).getId());
}
result.count("campaigns", campaignMap.size());
// -- Arc (relatedPageIds remappe en 2e passe)
for (ContentExport.ArcDto d : nullSafe(export.arcs())) {
ArcJpaEntity e = new ArcJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
e.setOrder(d.order());
e.setType(IdRemapper.parseArcType(d.type()));
e.setIcon(d.icon());
e.setThemes(d.themes());
e.setStakes(d.stakes());
e.setGmNotes(d.gmNotes());
e.setRewards(d.rewards());
e.setResolution(d.resolution());
e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas
e.setIllustrationImageIds(d.illustrationImageIds());
e.setMapImageIds(d.mapImageIds());
arcMap.put(d.id(), arcRepo.save(e).getId());
}
result.count("arcs", arcMap.size());
// -- ItemCatalog (+ items en cascade)
int catalogCount = 0, itemCount = 0;
for (ContentExport.ItemCatalogDto d : nullSafe(export.itemCatalogs())) {
ItemCatalogJpaEntity e = new ItemCatalogJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setIcon(d.icon());
e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
e.setOrder(d.order());
List<CatalogItemJpaEntity> items = new ArrayList<>();
for (ContentExport.CatalogItemDto i : nullSafe(d.items())) {
CatalogItemJpaEntity item = new CatalogItemJpaEntity();
item.setName(i.name());
item.setPrice(i.price());
item.setCategory(i.category());
item.setDescription(i.description());
item.setPosition(i.position());
item.setCatalog(e); // lien parent requis pour la cascade
items.add(item);
itemCount++;
}
e.setItems(items);
itemCatalogRepo.save(e);
catalogCount++;
}
result.count("itemCatalogs", catalogCount);
result.count("catalogItems", itemCount);
// -- RandomTable (+ entries en cascade)
int tableCount = 0, entryCount = 0;
for (ContentExport.RandomTableDto d : nullSafe(export.randomTables())) {
RandomTableJpaEntity e = new RandomTableJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setDiceFormula(d.diceFormula());
e.setIcon(d.icon());
e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
e.setOrder(d.order());
List<RandomTableEntryJpaEntity> entries = new ArrayList<>();
for (ContentExport.RandomTableEntryDto en : nullSafe(d.entries())) {
RandomTableEntryJpaEntity entryE = new RandomTableEntryJpaEntity();
entryE.setMinRoll(en.minRoll());
entryE.setMaxRoll(en.maxRoll());
entryE.setLabel(en.label());
entryE.setDetail(en.detail());
entryE.setPosition(en.position());
entryE.setRandomTable(e);
entries.add(entryE);
entryCount++;
}
e.setEntries(entries);
randomTableRepo.save(e);
tableCount++;
}
result.count("randomTables", tableCount);
result.count("randomTableEntries", entryCount);
// -- Chapter (prerequisites + relatedPageIds remappes en 2e passe)
for (ContentExport.ChapterDto d : nullSafe(export.chapters())) {
ChapterJpaEntity e = new ChapterJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setArcId(IdRemapper.remapId(arcMap, d.arcId()));
e.setOrder(d.order());
e.setPrerequisites(PREREQ_CONVERTER.convertToEntityAttribute(d.prerequisitesJson())); // remappe plus bas
e.setIcon(d.icon());
e.setGmNotes(d.gmNotes());
e.setPlayerObjectives(d.playerObjectives());
e.setNarrativeStakes(d.narrativeStakes());
e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas
e.setIllustrationImageIds(d.illustrationImageIds());
e.setMapImageIds(d.mapImageIds());
chapterMap.put(d.id(), chapterRepo.save(e).getId());
}
result.count("chapters", chapterMap.size());
// -- Npc (relatedPageIds remappe en 2e passe)
for (ContentExport.NpcDto d : nullSafe(export.npcs())) {
NpcJpaEntity e = new NpcJpaEntity();
e.setName(d.name());
e.setPortraitImageId(d.portraitImageId());
e.setHeaderImageId(d.headerImageId());
e.setValues(d.values());
e.setImageValues(d.imageValues());
e.setKeyValueValues(d.keyValueValues());
e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas
e.setFolder(d.folder());
e.setOrder(d.order());
npcMap.put(d.id(), npcRepo.save(e).getId());
}
result.count("npcs", npcMap.size());
// -- Enemy
for (ContentExport.EnemyDto d : nullSafe(export.enemies())) {
EnemyJpaEntity e = new EnemyJpaEntity();
e.setName(d.name());
e.setLevel(d.level());
e.setFolder(d.folder());
e.setPortraitImageId(d.portraitImageId());
e.setHeaderImageId(d.headerImageId());
e.setValues(d.values());
e.setImageValues(d.imageValues());
e.setKeyValueValues(d.keyValueValues());
e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
e.setOrder(d.order());
enemyMap.put(d.id(), enemyRepo.save(e).getId());
}
result.count("enemies", enemyMap.size());
// -- Character (playthroughId mis a null : hors perimetre)
for (ContentExport.CharacterDto d : nullSafe(export.characters())) {
CharacterJpaEntity e = new CharacterJpaEntity();
e.setName(d.name());
e.setPortraitImageId(d.portraitImageId());
e.setHeaderImageId(d.headerImageId());
e.setValues(d.values());
e.setImageValues(d.imageValues());
e.setKeyValueValues(d.keyValueValues());
e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
e.setPlaythroughId(null); // Playthrough hors perimetre d'export
e.setOrder(d.order());
characterMap.put(d.id(), characterRepo.save(e).getId());
}
result.count("characters", characterMap.size());
// -- Scene (enemyIds + relatedPageIds + branches remappes en 2e passe)
for (ContentExport.SceneDto d : nullSafe(export.scenes())) {
SceneJpaEntity e = new SceneJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setChapterId(IdRemapper.remapId(chapterMap, d.chapterId()));
e.setOrder(d.order());
e.setIcon(d.icon());
e.setLocation(d.location());
e.setTiming(d.timing());
e.setAtmosphere(d.atmosphere());
e.setPlayerNarration(d.playerNarration());
e.setGmSecretNotes(d.gmSecretNotes());
e.setChoicesConsequences(d.choicesConsequences());
e.setCombatDifficulty(d.combatDifficulty());
e.setEnemies(d.enemies());
e.setEnemyIds(d.enemyIds()); // remappe plus bas
e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas
e.setIllustrationImageIds(d.illustrationImageIds());
e.setMapImageIds(d.mapImageIds());
e.setBranches(d.branches()); // remappe plus bas
e.setRooms(d.rooms()); // Rooms: UUID, non remappes
sceneMap.put(d.id(), sceneRepo.save(e).getId());
}
result.count("scenes", sceneMap.size());
// 4. 2e PASSE de remapping.
// LoreNode.parentId
for (LoreNodeJpaEntity e : loreNodesToFix) {
Long newParent = loreNodeMap.get(e.getParentId());
if (newParent != null) {
e.setParentId(newParent);
loreNodeRepo.save(e);
}
}
// Template.defaultNodeId
for (ContentExport.TemplateDto d : templatesWithDefaultNode) {
Long newTemplateId = templateMap.get(d.id());
Long newNode = loreNodeMap.get(d.defaultNodeId());
if (newTemplateId != null && newNode != null) {
templateRepo.findById(newTemplateId).ifPresent(t -> {
t.setDefaultNodeId(newNode);
templateRepo.save(t);
});
}
}
// Campaign.loreId & gameSystemId (refs faibles String -> remap via maps Long).
for (Long newCampaignId : campaignMap.values()) {
campaignRepo.findById(newCampaignId).ifPresent(c -> {
String newLore = IdRemapper.remapStringId(loreMap, c.getLoreId());
String newGs = IdRemapper.remapStringId(gameSystemMap, c.getGameSystemId());
c.setLoreId(newLore);
c.setGameSystemId(newGs);
campaignRepo.save(c);
});
}
// Page.relatedPageIds
for (Long newPageId : pageMap.values()) {
pageRepo.findById(newPageId).ifPresent(p -> {
p.setRelatedPageIds(IdRemapper.remapStringList(pageMap, p.getRelatedPageIds()));
pageRepo.save(p);
});
}
// Arc.relatedPageIds
for (Long newArcId : arcMap.values()) {
arcRepo.findById(newArcId).ifPresent(a -> {
a.setRelatedPageIds(IdRemapper.remapStringList(pageMap, a.getRelatedPageIds()));
arcRepo.save(a);
});
}
// Chapter.relatedPageIds + prerequisites(QuestCompleted -> map Chapter)
for (Long newChapterId : chapterMap.values()) {
chapterRepo.findById(newChapterId).ifPresent(c -> {
c.setRelatedPageIds(IdRemapper.remapStringList(pageMap, c.getRelatedPageIds()));
c.setPrerequisites(IdRemapper.remapPrerequisites(chapterMap, c.getPrerequisites()));
chapterRepo.save(c);
});
}
// Npc.relatedPageIds
for (Long newNpcId : npcMap.values()) {
npcRepo.findById(newNpcId).ifPresent(n -> {
n.setRelatedPageIds(IdRemapper.remapStringList(pageMap, n.getRelatedPageIds()));
npcRepo.save(n);
});
}
// Scene.relatedPageIds + enemyIds(map Enemy) + branches.targetSceneId(map Scene)
for (Long newSceneId : sceneMap.values()) {
sceneRepo.findById(newSceneId).ifPresent(s -> {
s.setRelatedPageIds(IdRemapper.remapStringList(pageMap, s.getRelatedPageIds()));
s.setEnemyIds(IdRemapper.remapStringList(enemyMap, s.getEnemyIds()));
s.setBranches(IdRemapper.remapBranches(sceneMap, s.getBranches()));
sceneRepo.save(s);
});
}
return result.build();
}
// ----- Lecture de l'archive -----
/** Contenu déballé d'un zip d'import : le {@code data.json} parsé + les binaires d'images. */
private record ParsedArchive(ContentExport export, Map<String, byte[]> imageBinaries) {
}
/**
* Déballe le zip : {@code data.json → ContentExport} et {@code images/<clé> → binaire}
* (le {@code manifest.json} est ignoré, info seulement). Lève si {@code data.json} manque.
*/
private ParsedArchive parseArchive(InputStream zipStream) {
ContentExport export = null;
Map<String, byte[]> imageBinaries = new LinkedHashMap<>(); // storageKey -> binaire
try (ZipInputStream zip = new ZipInputStream(zipStream)) {
ZipEntry entry;
while ((entry = zip.getNextEntry()) != null) {
String name = entry.getName();
if ("data.json".equals(name)) {
export = objectMapper.readValue(readAll(zip), ContentExport.class);
} else if (name.startsWith("images/") && !entry.isDirectory()) {
// La cle de stockage est le chemin sans le prefixe "images/" du zip,
// c'est-a-dire EXACTEMENT le storageKey d'origine ("images/UUID.ext").
String storageKey = name.substring("images/".length());
imageBinaries.put(storageKey, readAll(zip));
}
zip.closeEntry();
}
} catch (IOException e) {
throw new UncheckedIOException("Echec de lecture du zip d'import", e);
}
if (export == null) {
throw new IllegalArgumentException("Archive invalide : data.json introuvable");
}
return new ParsedArchive(export, imageBinaries);
}
// ----- Helpers divers -----
private static <T> List<T> nullSafe(List<T> list) {
return list != null ? list : List.of();
}
private static byte[] readAll(InputStream in) throws IOException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
in.transferTo(buffer);
return buffer.toByteArray();
}
}

View File

@@ -0,0 +1,265 @@
package com.loremind.infrastructure.transfer.dto;
import com.loremind.domain.campaigncontext.Room;
import com.loremind.domain.campaigncontext.SceneBranch;
import com.loremind.domain.shared.template.TemplateField;
import java.util.List;
import java.util.Map;
/**
* Format d'export LOGIQUE (JSON) du "contenu" de LoreMind.
* <p>
* Records plats, miroirs des champs des entites JPA — volontairement DECOUPLES
* des entites pour la stabilite inter-versions (le schema JPA peut bouger sans
* casser un .zip exporte). Chaque record porte son {@code id} d'origine (Long)
* afin que l'import puisse construire les maps de remapping oldId -> newId.
* <p>
* Les sous-objets riches (TemplateField, Prerequisite, SceneBranch, Room)
* reutilisent les types domaine existants : Jackson sait les (de)serialiser
* (records ou Lombok), et on evite de dupliquer la structure.
*/
public record ContentExport(
Manifest manifest,
List<GameSystemDto> gameSystems,
List<LoreDto> lores,
List<LoreNodeDto> loreNodes,
List<TemplateDto> templates,
List<PageDto> pages,
List<CampaignDto> campaigns,
List<ArcDto> arcs,
List<ChapterDto> chapters,
List<SceneDto> scenes,
List<CharacterDto> characters,
List<NpcDto> npcs,
List<EnemyDto> enemies,
List<ItemCatalogDto> itemCatalogs,
List<RandomTableDto> randomTables,
List<ImageDto> images
) {
/**
* Metadonnees de l'export. {@code exportedAt} est passe en parametre depuis
* la couche requete (PAS Instant.now() ici, pour rester deterministe et
* testable).
*/
public record Manifest(
int formatVersion,
String appVersion,
String exportedAt
) {}
public record GameSystemDto(
Long id,
String name,
String description,
String rulesMarkdown,
List<TemplateField> characterTemplate,
List<TemplateField> npcTemplate,
List<TemplateField> enemyTemplate,
String author,
boolean isPublic
) {}
public record LoreDto(
Long id,
String name,
String description,
int nodeCount,
int pageCount
) {}
public record LoreNodeDto(
Long id,
String name,
String icon,
Long parentId,
Long loreId
) {}
public record TemplateDto(
Long id,
Long loreId,
String name,
String description,
Long defaultNodeId,
List<TemplateField> fields
) {}
public record PageDto(
Long id,
Long loreId,
Long nodeId,
Long templateId,
String title,
Map<String, String> values,
Map<String, List<String>> imageValues,
Map<String, Map<String, String>> keyValueValues,
Map<String, List<Map<String, String>>> tableValues,
String notes,
List<String> tags,
List<String> relatedPageIds
) {}
public record CampaignDto(
Long id,
String name,
String description,
int arcsCount,
String loreId,
String gameSystemId
) {}
public record ArcDto(
Long id,
String name,
String description,
Long campaignId,
int order,
String type,
String icon,
String themes,
String stakes,
String gmNotes,
String rewards,
String resolution,
List<String> relatedPageIds,
List<String> illustrationImageIds,
List<String> mapImageIds
) {}
public record ChapterDto(
Long id,
String name,
String description,
Long arcId,
int order,
// Prerequisites en JSON brut (format "kind" du converter JPA) : Prerequisite
// est un type scellé SANS annotations Jackson, donc impossible à (dé)sérialiser
// en polymorphe via l'ObjectMapper standard. On réutilise
// PrerequisiteListJsonConverter (format on-disk stable, identique à la base).
String prerequisitesJson,
String icon,
String gmNotes,
String playerObjectives,
String narrativeStakes,
List<String> relatedPageIds,
List<String> illustrationImageIds,
List<String> mapImageIds
) {}
public record SceneDto(
Long id,
String name,
String description,
Long chapterId,
int order,
String icon,
String location,
String timing,
String atmosphere,
String playerNarration,
String gmSecretNotes,
String choicesConsequences,
String combatDifficulty,
String enemies,
List<String> enemyIds,
List<String> relatedPageIds,
List<String> illustrationImageIds,
List<String> mapImageIds,
List<SceneBranch> branches,
List<Room> rooms
) {}
public record CharacterDto(
Long id,
String name,
String portraitImageId,
String headerImageId,
Map<String, String> values,
Map<String, List<String>> imageValues,
Map<String, Map<String, String>> keyValueValues,
Long campaignId,
Long playthroughId,
int order
) {}
public record NpcDto(
Long id,
String name,
String portraitImageId,
String headerImageId,
Map<String, String> values,
Map<String, List<String>> imageValues,
Map<String, Map<String, String>> keyValueValues,
Long campaignId,
List<String> relatedPageIds,
String folder,
int order
) {}
public record EnemyDto(
Long id,
String name,
String level,
String folder,
String portraitImageId,
String headerImageId,
Map<String, String> values,
Map<String, List<String>> imageValues,
Map<String, Map<String, String>> keyValueValues,
Long campaignId,
int order
) {}
public record ItemCatalogDto(
Long id,
String name,
String description,
String icon,
Long campaignId,
int order,
List<CatalogItemDto> items
) {}
public record CatalogItemDto(
Long id,
String name,
String price,
String category,
String description,
int position
) {}
public record RandomTableDto(
Long id,
String name,
String description,
String diceFormula,
String icon,
Long campaignId,
int order,
List<RandomTableEntryDto> entries
) {}
public record RandomTableEntryDto(
Long id,
int minRoll,
int maxRoll,
String label,
String detail,
int position
) {}
/**
* Metadonnees d'une image. Le binaire voyage a part dans le zip sous
* {@code images/<storageKey>}. La cle est PRESERVEE telle quelle a l'import.
*/
public record ImageDto(
Long id,
String filename,
String contentType,
long sizeBytes,
String storageKey
) {}
}

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