Compare commits

...

13 Commits

Author SHA1 Message Date
13f4b994ab Ajout de postgres sur git et gitea en conteneur docker pour pouvoir exécuter les tests unitaires concernant la BDD
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Successful in 1m51s
Build & Push Images / tests (push) Successful in 2m25s
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 21s
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 22s
Build & Push Images / build (brain) (push) Successful in 1m16s
Build & Push Images / build (core) (push) Successful in 3m20s
Build & Push Images / build (web) (push) Successful in 1m41s
Build & Push Images / build-switcher (push) Successful in 39s
2026-06-18 16:28:10 +02:00
d1653b8bea Ajout du mvn wrapper dans le workflow ; ajout d'un gate keeper coté workflow git afin d'éviter de build le .msi si des tests échoues
Some checks failed
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Failing after 1m40s
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 19s
Build & Push Images / tests (push) Failing after 1m26s
Build & Push Images / build (brain) (push) Has been skipped
Build & Push Images / build (core) (push) Has been skipped
Build & Push Images / build (web) (push) Has been skipped
E2E Tests / e2e (push) Has been cancelled
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 24s
Build & Push Images / build-switcher (push) Has been skipped
2026-06-18 16:13:36 +02:00
4d049274f9 Refacto du code coté Python, java et coté angular afin de mieux séparer les responsabilité et d'avoir moins de répétitivité dans le code.
Some checks failed
E2E Tests / e2e (push) Waiting to run
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 26s
Build & Push Images / tests (push) Failing after 13s
Build & Push Images / build (brain) (push) Has been skipped
Build & Push Images / build (core) (push) Has been skipped
Build & Push Images / build (web) (push) Has been skipped
Build & Push Images / build-switcher (push) Has been skipped
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 1m12s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Failing after 1m28s
Mise en place de tests unitaires coté Python et Angular
Mise en place de la couverture de test directement dans le workflow : le programme ne build pas si jamais un test échoue
Passage en v0.16.2 en conséquence
2026-06-18 15:59:10 +02:00
eb78a75621 Ajout du binaire tesseract pour la reconnaissance OCR des PDF pour l'installation local
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m10s
Build & Push Images / build (core) (push) Successful in 3m8s
Build & Push Images / build (web) (push) Successful in 1m41s
Build & Push Images / build-switcher (push) Successful in 39s
Correction d'un problème d'écrasement de BDD à la réinstallation
2026-06-18 10:28:31 +02:00
f04ecf1021 Passage v0.16.0
Some checks are pending
E2E Tests / e2e (push) Waiting to run
Build & Push Images / build (brain) (push) Successful in 1m14s
Build & Push Images / build (core) (push) Successful in 3m16s
Build & Push Images / build (web) (push) Successful in 1m57s
Build & Push Images / build-switcher (push) Successful in 40s
2026-06-18 09:52:53 +02:00
72fe5e6215 Mise en place de l'import / export des données pour pouvoir sauvegarder les lores / campagnes 2026-06-18 09:49:34 +02:00
7dfa9c3655 Mise à jour v0.15.1
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m36s
Build & Push Images / build (web) (push) Successful in 1m43s
Build & Push Images / build-switcher (push) Successful in 41s
Build & Push Images / build (core) (push) Successful in 3m10s
2026-06-17 18:24:51 +02:00
7aa174d75a Mise à jour du workflow pour forcer le declenchement à la main si par malheur il ne tourne pas à l'avenir automatiquement 2026-06-17 18:24:18 +02:00
48baa08cfb Mise en place d'un installeur pour la version bureau sans passer par Docker.
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m25s
Build & Push Images / build (core) (push) Successful in 2m55s
Build & Push Images / build (web) (push) Successful in 1m49s
Build & Push Images / build-switcher (push) Successful in 39s
Permet d'utiliser Loremind sans passer par Docker et sans lancer tous les conteneurs
Passage en v0.15.0
2026-06-17 18:04:33 +02:00
f1c68634f7 Merge branch 'beta'
Some checks failed
E2E Tests / e2e (push) Failing after 1h1m2s
2026-06-16 15:16:19 +02:00
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
109 changed files with 8574 additions and 1223 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,7 +12,71 @@ env:
GHCR_NAMESPACE: igmlcreation
jobs:
# GATE : aucune image n'est build/push tant que les 3 suites unitaires
# (Java / Python / Angular) ne passent pas. Un tag posé sur un commit aux
# tests rouges ne publiera donc PAS d'images.
tests:
runs-on: ubuntu-latest
# Base PostgreSQL réelle pour les tests Core (joignable par le nom `postgres`
# depuis le conteneur du job Gitea).
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: loremind_test
POSTGRES_USER: loremind_test
POSTGRES_PASSWORD: loremind_test
options: >-
--health-cmd "pg_isready -U loremind_test -d loremind_test"
--health-interval 10s
--health-timeout 5s
--health-retries 10
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
cache: maven
- name: Core — mvn test (+ JaCoCo check)
working-directory: core
env:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/loremind_test
SPRING_DATASOURCE_USERNAME: loremind_test
SPRING_DATASOURCE_PASSWORD: loremind_test
run: |
chmod +x ./mvnw
./mvnw -B test
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
cache-dependency-path: brain/requirements-dev.txt
- name: Brain — pytest (+ couverture)
working-directory: brain
run: |
pip install -r requirements-dev.txt
pytest --cov=app --cov-report=term-missing --cov-fail-under=50
- name: Set up Node 20
uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: web/package-lock.json
- name: Web — vitest (+ couverture)
working-directory: web
run: |
npm ci --no-audit --no-fund
npm run test:unit:coverage
build:
needs: tests
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -93,6 +157,7 @@ jobs:
# donc uniquement sur les releases stables — pas la peine de re-publier
# une variante beta du switcher, c'est une infrastructure neutre.
build-switcher:
needs: tests
runs-on: ubuntu-latest
steps:
- name: Checkout

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.

12
.gitignore vendored
View File

@@ -45,6 +45,12 @@ env/
.coverage
htmlcov/
# Artefacts du build bureau (cf. installers/desktop)
.venv-build/
brain/build/
brain/dist-embed/
*.spec
# ============================================================================
# Angular / Node (Web)
# ============================================================================
@@ -116,3 +122,9 @@ brain/data/notebooks/5.json
# 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

15
brain/.coveragerc Normal file
View File

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

5
brain/.gitignore vendored
View File

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

View File

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

View File

@@ -1,195 +1,48 @@
"""Adapter Mistral — implémente les ports LLMProvider / LLMChatProvider.
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
l'adapter OpenRouter. Le `generate` one-shot passe par le streaming (puis
recollage) avec un timeout au temps écoulé pour ne jamais pendre à l'infini.
SSE) : client "OpenAI-compatible" qui hérite de BaseOpenAICompatibleAdapter et ne
fournit que ses spécificités.
Tier GRATUIT : compte sur console.mistral.ai (tier « Experiment »), clé API à
coller dans l'écran Paramètres. Modèles conseillés pour l'extraction : un grand
contexte fidèle comme `mistral-large-latest` (128k) ou `mistral-small-latest`.
Mode JSON natif : TOUS les modèles Mistral le supportent (`_supports_json_object`).
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import AsyncIterator
import httpx
from app.core.config import Settings
from app.domain.models import ChatMessage
from app.domain.ports import LLMGenerationTimeout, LLMProviderError
logger = logging.getLogger(__name__)
_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
from app.domain.ports import LLMProviderError
from app.infrastructure.base_openai_adapter import (
_FIRST_TOKEN_TIMEOUT_SECONDS,
BaseOpenAICompatibleAdapter,
)
class MistralLLMProvider:
class MistralLLMProvider(BaseOpenAICompatibleAdapter):
"""Adapter Mistral (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider."""
_provider_label = "Mistral"
_api_url = "https://api.mistral.ai/v1/chat/completions"
_supports_json_object = True
def __init__(self, settings: Settings) -> None:
if not settings.mistral_api_key:
raise LLMProviderError(
"Clé API Mistral manquante. Configure-la depuis l'écran Paramètres."
)
self._api_key = settings.mistral_api_key
self._model = settings.mistral_model
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
super().__init__(
settings.mistral_api_key, settings.mistral_model, settings.llm_timeout_seconds
)
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é
(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(
def _headers(self) -> dict[str, str]:
return {**super()._headers(), "Accept": "application/json"}
def _first_token_timeout_message(self) -> str:
return (
f"Erreur Mistral : aucun contenu produit en "
f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s — le modèle est probablement "
"en file d'attente (tier gratuit, 2 req/min). Réessayez plus tard ou "
"choisissez un modèle plus disponible."
)
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 Mistral : 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
# 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.
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
passe lui aussi par le streaming (puis recollage) pour éviter les coupures de
passerelle sur les longues générations (cf. 1min.ai / Cloudflare 524).
cet adapter est un client "OpenAI-compatible" : il hérite de toute la mécanique de
BaseOpenAICompatibleAdapter et ne fournit que ses spécificités (URL, en-têtes
d'attribution, messages, lecture de config).
Modèles GRATUITS : utiliser un id finissant par `:free` (ex.
`meta-llama/llama-3.3-70b-instruct:free`) ou le routeur `openrouter/free` (défaut)
qui choisit automatiquement un modèle gratuit — aucun crédit consommé.
NB : on n'impose PAS `response_format=json_object` (`_supports_json_object=False`).
Beaucoup de modèles/providers GRATUITS ne le supportent pas et renvoient une
réponse VIDE. On laisse le modèle répondre librement ; l'extraction JSON en aval
(load_json_object + nettoyage du raisonnement) récupère le JSON dans la prose.
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import AsyncIterator
import httpx
from app.core.config import Settings
from app.domain.models import ChatMessage
logger = logging.getLogger(__name__)
from app.domain.ports import LLMGenerationTimeout, LLMProviderError
_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
from app.domain.ports import LLMProviderError
from app.infrastructure.base_openai_adapter import (
_FIRST_TOKEN_TIMEOUT_SECONDS,
BaseOpenAICompatibleAdapter,
)
class OpenRouterLLMProvider:
class OpenRouterLLMProvider(BaseOpenAICompatibleAdapter):
"""Adapter OpenRouter (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider."""
_provider_label = "OpenRouter"
_api_url = "https://openrouter.ai/api/v1/chat/completions"
_supports_json_object = False
def __init__(self, settings: Settings) -> None:
if not settings.openrouter_api_key:
raise LLMProviderError(
"Clé API OpenRouter manquante. Configure-la depuis l'écran Paramètres."
)
self._api_key = settings.openrouter_api_key
self._model = settings.openrouter_model
self._timeout = settings.llm_timeout_seconds
super().__init__(
settings.openrouter_api_key, settings.openrouter_model, settings.llm_timeout_seconds
)
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
**super()._headers(),
# Attribution facultative (classement OpenRouter) — sans impact fonctionnel.
"HTTP-Referer": "https://loremind.app",
"X-Title": "LoreMind",
}
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 : 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 "
def _first_token_timeout_message(self) -> str:
return (
f"Erreur OpenRouter : aucun contenu produit en "
f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s — le modèle gratuit est "
"probablement en file d'attente / saturé. Réessayez plus tard ou "
"choisissez un autre modèle (1min.ai, ou payant)."
)
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(
title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.",
version="0.14.0-beta",
version="0.16.2",
)
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>
<artifactId>loremind-core</artifactId>
<version>0.14.0-beta</version>
<version>0.16.2</version>
<name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description>
@@ -60,11 +60,31 @@
<scope>runtime</scope>
</dependency>
<!-- H2 Database pour les tests -->
<!-- Flyway : migrations de schema versionnees (remplace ddl-auto=update).
Un SEUL jeu de migrations en SQL PostgreSQL sert les deux bases :
- Postgres (Docker/serveur) nativement ;
- H2 (mode local-first) via MODE=PostgreSQL dans l'URL JDBC.
flyway-database-postgresql : module requis depuis Flyway 10 (DBs
externalisees du core). H2 reste supporte par flyway-core. -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<!-- H2 Database :
- tests (toujours) ;
- RUNTIME du profil "local" (mode local-first / jpackage) : base
fichier embarquee a la place de Postgres, donc le driver doit etre
sur le classpath d'execution. Scope runtime (jamais compile contre)
=> present a l'execution + tests, ~2,5 Mo inutilises cote Docker. -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
<scope>runtime</scope>
</dependency>
<!-- Lombok (réduit le code boilerplate) -->
@@ -175,8 +195,79 @@
<goal>report</goal>
</goals>
</execution>
<!-- Plancher ANTI-REGRESSION : `mvn test` echoue si la couverture
d'instructions du bundle passe sous 60% (mesure actuelle ~68%).
A remonter au fil du temps. N'impacte PAS le build Docker
(qui passe -DskipTests) : le gating est porte par la CI. -->
<execution>
<id>check</id>
<phase>test</phase>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>INSTRUCTION</counter>
<value>COVEREDRATIO</value>
<minimum>0.60</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<profiles>
<!-- =================================================================
Profil "desktop" : build local-first (application de bureau).
Active avec : mvn -Pdesktop package
Embarque le build Angular dans le jar (classpath:/static/) pour que
le Core serve lui-meme le front (cf. LocalWebConfig, profil Spring
"local"). Le build Docker normal (sans ce profil) reste une API pure :
le front y est servi par le conteneur nginx, donc rien n'est copie.
================================================================= -->
<profile>
<id>desktop</id>
<properties>
<!-- Sortie du `ng build` (builder browser) : web/dist/web. -->
<frontend.dist>${project.basedir}/../web/dist/web</frontend.dist>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<executions>
<execution>
<id>copy-frontend</id>
<!-- Avant le repackage Spring Boot : on injecte le
front dans les classes compilees -> embarque
dans le fat jar sous /static. -->
<phase>prepare-package</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>${project.build.outputDirectory}/static</outputDirectory>
<resources>
<resource>
<directory>${frontend.dist}</directory>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>

View File

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

View File

@@ -40,26 +40,33 @@ public class LicenseService {
private final LicenseRelay relay;
private final long gracePeriodSeconds;
private final long refreshBeforeExpirySeconds;
private final boolean licensingEnabled;
public LicenseService(
LicenseRepository repository,
JwtVerifier jwtVerifier,
LicenseRelay relay,
@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.jwtVerifier = jwtVerifier;
this.relay = relay;
this.gracePeriodSeconds = (long) gracePeriodDays * 86_400L;
this.refreshBeforeExpirySeconds = (long) refreshBeforeExpiryDays * 86_400L;
this.licensingEnabled = licensingEnabled;
}
/**
* @return true si le verifier est configure (cle publique presente).
* L'UI peut masquer toute la section Patreon si false.
* @return true si le licensing Patreon est actif : il faut a la fois que la
* 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() {
return jwtVerifier.isConfigured();
return licensingEnabled && jwtVerifier.isConfigured();
}
/**

View File

@@ -26,6 +26,21 @@ public interface ImageStorage {
*/
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. */
InputStream download(String storageKey);

View File

@@ -14,7 +14,6 @@ import com.loremind.domain.campaigncontext.ports.CampaignPdfImporter;
import com.loremind.infrastructure.web.config.UserLanguageHolder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.MediaType;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.http.codec.ServerSentEvent;
@@ -23,7 +22,6 @@ import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
@@ -43,7 +41,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
new ParameterizedTypeReference<>() {};
private final WebClient webClient;
private final ObjectMapper objectMapper;
private final BrainSseImportSupport sse;
private final long importTimeoutSeconds;
public BrainCampaignImportClient(
@@ -52,7 +50,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
@Value("${brain.base-url}") String baseUrl,
@Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds) {
this.webClient = webClientBuilder.baseUrl(baseUrl).build();
this.objectMapper = objectMapper;
this.sse = new BrainSseImportSupport(objectMapper);
this.importTimeoutSeconds = importTimeoutSeconds;
}
@@ -67,7 +65,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
Consumer<Throwable> onError) {
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);
Flux<ServerSentEvent<String>> flux = webClient.post()
@@ -83,31 +81,16 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
int[] ocrPageCount = {0};
boolean[] terminated = {false};
try {
flux
.timeout(Duration.ofSeconds(importTimeoutSeconds))
.doOnNext(sse -> handleEvent(
sse, pageCount, ocrPageCount, terminated,
onProgress, onHeartbeat, onStatus, onDone, onError))
.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));
}
}
sse.runStream(
flux, importTimeoutSeconds, terminated,
event -> handleEvent(
event, pageCount, ocrPageCount, terminated,
onProgress, onHeartbeat, onStatus, onDone, onError),
onError, CampaignImportException::new);
}
private void handleEvent(
ServerSentEvent<String> sse,
ServerSentEvent<String> ssEvent,
int[] pageCount,
int[] ocrPageCount,
boolean[] terminated,
@@ -117,8 +100,8 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> onError) {
String event = sse.event();
String data = sse.data() == null ? "" : sse.data();
String event = ssEvent.event();
String data = ssEvent.data() == null ? "" : ssEvent.data();
if ("heartbeat".equals(event)) {
// Keep-alive du Brain pendant un appel LLM long : à PROPAGER jusqu'au
@@ -129,23 +112,17 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
if ("status".equals(event)) {
// Message d'attente lisible (retry sur fournisseur saturé, morceau
// 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;
}
if ("chunk_failed".equals(event)) {
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;
onStatus.accept("Morceau " + current + "/" + total + " ignoré"
+ (msg.isEmpty() ? "." : " : " + msg));
onStatus.accept(sse.chunkFailedStatus(data));
return;
}
if ("error".equals(event)) {
terminated[0] = true;
onError.accept(new CampaignImportException(
"Le Brain a signalé une erreur : " + readMessage(data)));
"Le Brain a signalé une erreur : " + sse.readMessage(data)));
return;
}
if ("extracting".equals(event)) {
@@ -153,7 +130,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
return;
}
JsonNode node = readJson(data);
JsonNode node = sse.readJson(data);
if (node == null) return;
if ("start".equals(event)) {
@@ -246,33 +223,8 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
// --- 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) {
JsonNode v = node.path(field);
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.put("name", q.name());
map.put("arc_name", q.arcName());
if (q.description() != null && !q.description().isBlank()) {
map.put("description", q.description());
}
putIfText(map, "description", q.description());
return map;
}
@@ -121,18 +119,14 @@ public class BrainChatPayloadBuilder {
if (e.occurredAt() != null) {
map.put("occurred_at", e.occurredAt().toString());
}
if (e.sourceSessionName() != null && !e.sourceSessionName().isBlank()) {
map.put("source_session_name", e.sourceSessionName());
}
putIfText(map, "source_session_name", e.sourceSessionName());
return map;
}
private Map<String, Object> gameSystemContextToMap(GameSystemContext gs) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("system_name", gs.systemName());
if (gs.systemDescription() != null && !gs.systemDescription().isBlank()) {
map.put("system_description", gs.systemDescription());
}
putIfText(map, "system_description", gs.systemDescription());
map.put("sections", gs.sections() != null ? gs.sections() : Map.of());
return map;
}
@@ -211,18 +205,14 @@ public class BrainChatPayloadBuilder {
private Map<String, Object> characterSummaryToMap(CharacterSummary c) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("name", c.name());
if (c.snippet() != null && !c.snippet().isBlank()) {
map.put("snippet", c.snippet());
}
putIfText(map, "snippet", c.snippet());
return map;
}
private Map<String, Object> npcSummaryToMap(NpcSummary n) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("name", n.name());
if (n.snippet() != null && !n.snippet().isBlank()) {
map.put("snippet", n.snippet());
}
putIfText(map, "snippet", n.snippet());
return map;
}
@@ -300,9 +290,7 @@ public class BrainChatPayloadBuilder {
Map<String, Object> map = new LinkedHashMap<>();
map.put("label", b.label());
map.put("target_scene_name", b.targetSceneName());
if (b.condition() != null && !b.condition().isBlank()) {
map.put("condition", b.condition());
}
putIfText(map, "condition", b.condition());
return map;
}
@@ -310,14 +298,14 @@ public class BrainChatPayloadBuilder {
Map<String, Object> map = new LinkedHashMap<>();
map.put("name", r.name());
if (r.floor() != null) map.put("floor", r.floor());
if (r.description() != null && !r.description().isBlank()) map.put("description", r.description());
if (r.enemies() != null && !r.enemies().isBlank()) map.put("enemies", r.enemies());
putIfText(map, "description", r.description());
putIfText(map, "enemies", r.enemies());
if (r.branches() != null && !r.branches().isEmpty()) {
map.put("branches", r.branches().stream().map(b -> {
Map<String, Object> bm = new LinkedHashMap<>();
bm.put("label", b.label());
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;
}).collect(Collectors.toList()));
}
@@ -331,4 +319,15 @@ public class BrainChatPayloadBuilder {
map.put("fields", ne.fields());
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

@@ -10,7 +10,6 @@ import com.loremind.infrastructure.web.config.UserLanguageHolder;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
@@ -26,7 +25,6 @@ import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
@@ -56,7 +54,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
private final RestTemplate restTemplate;
private final WebClient webClient;
private final ObjectMapper objectMapper;
private final BrainSseImportSupport sse;
private final String baseUrl;
private final long importTimeoutSeconds;
@@ -68,7 +66,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
@Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds) {
this.restTemplate = restTemplate;
this.webClient = webClientBuilder.baseUrl(baseUrl).build();
this.objectMapper = objectMapper;
this.sse = new BrainSseImportSupport(objectMapper);
this.baseUrl = baseUrl;
this.importTimeoutSeconds = importTimeoutSeconds;
}
@@ -81,7 +79,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
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);
try {
@@ -121,7 +119,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
Consumer<Throwable> onError) {
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);
Flux<ServerSentEvent<String>> flux = webClient.post()
@@ -139,33 +137,16 @@ public class BrainRulesImportClient implements RulesPdfImporter {
int[] ocrPageCount = {0};
boolean[] terminated = {false};
try {
flux
.timeout(Duration.ofSeconds(importTimeoutSeconds))
.doOnNext(sse -> handleEvent(
sse, pageCount, ocrPageCount, terminated,
onProgress, onHeartbeat, onStatus, onDone, onError))
.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));
}
}
sse.runStream(
flux, importTimeoutSeconds, terminated,
event -> handleEvent(
event, pageCount, ocrPageCount, terminated,
onProgress, onHeartbeat, onStatus, onDone, onError),
onError, RulesImportException::new);
}
private void handleEvent(
ServerSentEvent<String> sse,
ServerSentEvent<String> ssEvent,
int[] pageCount,
int[] ocrPageCount,
boolean[] terminated,
@@ -175,8 +156,8 @@ public class BrainRulesImportClient implements RulesPdfImporter {
Consumer<RulesImportResult> onDone,
Consumer<Throwable> onError) {
String event = sse.event();
String data = sse.data() == null ? "" : sse.data();
String event = ssEvent.event();
String data = ssEvent.data() == null ? "" : ssEvent.data();
if ("heartbeat".equals(event)) {
// Keep-alive du Brain pendant un appel LLM long : à PROPAGER jusqu'au
@@ -188,23 +169,17 @@ public class BrainRulesImportClient implements RulesPdfImporter {
if ("status".equals(event)) {
// Message d'attente lisible (retry sur fournisseur saturé, morceau
// 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;
}
if ("chunk_failed".equals(event)) {
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;
onStatus.accept("Morceau " + current + "/" + total + " ignoré"
+ (msg.isEmpty() ? "." : " : " + msg));
onStatus.accept(sse.chunkFailedStatus(data));
return;
}
if ("error".equals(event)) {
terminated[0] = true;
onError.accept(new RulesImportException(
"Le Brain a signalé une erreur : " + readMessage(data)));
"Le Brain a signalé une erreur : " + sse.readMessage(data)));
return;
}
if ("extracting".equals(event)) {
@@ -213,7 +188,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
return;
}
JsonNode node = readJson(data);
JsonNode node = sse.readJson(data);
if (node == null) return;
if ("start".equals(event)) {
@@ -239,32 +214,6 @@ public class BrainRulesImportClient implements RulesPdfImporter {
// --- 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) {
List<String> out = new ArrayList<>();
if (array != null && array.isArray()) {

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

@@ -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
.setConnectTimeout(Duration.ofSeconds(5))
.setReadTimeout(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

@@ -4,10 +4,14 @@ import com.loremind.infrastructure.persistence.entity.ImageJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/**
* Repository Spring Data JPA pour ImageJpaEntity.
* Ne contient aucune requete custom pour l'instant : CRUD standard suffit.
*/
@Repository
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

@@ -5,6 +5,7 @@ import io.minio.MakeBucketArgs;
import io.minio.MinioClient;
import jakarta.annotation.PostConstruct;
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.Configuration;
@@ -14,8 +15,13 @@ import org.springframework.context.annotation.Configuration;
* Expose un bean MinioClient singleton injecte dans MinioImageStorageAdapter.
* S'assure au demarrage que le bucket configure existe (filet de securite :
* 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
@ConditionalOnProperty(name = "storage.backend", havingValue = "minio", matchIfMissing = true)
public class MinioConfig {
@Value("${minio.endpoint}")

View File

@@ -7,6 +7,7 @@ import io.minio.PutObjectArgs;
import io.minio.RemoveObjectArgs;
import io.minio.errors.ErrorResponseException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.io.InputStream;
@@ -17,8 +18,13 @@ import java.util.UUID;
* MinIO (compatible S3) comme backend de stockage d'objets.
* <p>
* 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
@ConditionalOnProperty(name = "storage.backend", havingValue = "minio", matchIfMissing = true)
public class MinioImageStorageAdapter implements ImageStorage {
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
public InputStream download(String storageKey) {
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
) {}
}

View File

@@ -0,0 +1,81 @@
package com.loremind.infrastructure.updates;
import org.springframework.lang.Nullable;
import java.util.List;
/**
* Parsing et comparaison de versions semver (stratégie de {@link UpdateCheckService}).
* <p>
* Volontairement tolérant : préfixe {@code v}/{@code V} accepté, pré-release
* ({@code -beta.1}) et build metadata ({@code +build.42}) strippés avant comparaison.
* Un tag non parsable est simplement ignoré (jamais d'exception). Fonctions PURES.
*/
final class SemverComparator {
private SemverComparator() {
}
/**
* Parcourt la liste des tags, garde uniquement ceux qui parsent en semver
* (1 à 3 chiffres séparés par des points, préfixe {@code v} optionnel),
* retourne le plus élevé, ou {@code null} si aucun n'est valide.
*/
@Nullable
static String findMaxSemver(List<String> tags) {
String maxTag = null;
int[] maxParts = null;
for (String t : tags) {
if (t == null || t.isBlank()) continue;
int[] parts = parseSemver(t);
if (parts == null) continue;
if (maxParts == null || compareParts(parts, maxParts) > 0) {
maxParts = parts;
maxTag = t;
}
}
return maxTag;
}
/** @return {@code [major, minor, patch]} ou {@code null} si non parsable. */
@Nullable
static int[] parseSemver(String tag) {
if (tag == null) return null;
String s = tag.trim();
if (s.isEmpty()) return null;
if (s.startsWith("v") || s.startsWith("V")) s = s.substring(1);
int dashIdx = s.indexOf('-');
if (dashIdx > 0) s = s.substring(0, dashIdx);
int plusIdx = s.indexOf('+');
if (plusIdx > 0) s = s.substring(0, plusIdx);
String[] parts = s.split("\\.");
if (parts.length < 1 || parts.length > 3) return null;
int[] result = new int[]{0, 0, 0};
for (int i = 0; i < parts.length; i++) {
try {
int v = Integer.parseInt(parts[i]);
if (v < 0) return null;
result[i] = v;
} catch (NumberFormatException e) {
return null;
}
}
return result;
}
/** Compare deux versions semver brutes (préfixe toléré). Négatif si {@code a < b}. */
static int compareSemver(String a, String b) {
int[] aParts = parseSemver(a);
int[] bParts = parseSemver(b);
if (aParts == null || bParts == null) return 0;
return compareParts(aParts, bParts);
}
private static int compareParts(int[] a, int[] b) {
for (int i = 0; i < 3; i++) {
int diff = Integer.compare(a[i], b[i]);
if (diff != 0) return diff;
}
return 0;
}
}

View File

@@ -25,7 +25,6 @@ import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -114,32 +113,9 @@ public class UpdateCheckService {
return new UpdateStatus(true, false, true, null, statuses, Instant.now());
}
List<ImageStatus> statuses = new ArrayList<>();
boolean anyUpdate = false;
boolean anyUnknown = false;
for (String image : images) {
String latest = null;
try {
latest = fetchLatestSemverTag(registry, image, null);
} catch (Exception e) {
log.warn("Tags fetch failed for {}: {}", image, e.getMessage());
}
ImageStatusKind kind;
if (latest == null) {
kind = ImageStatusKind.UNKNOWN;
anyUnknown = true;
} else {
int cmp = compareSemver(currentVersion, latest);
if (cmp >= 0) {
kind = ImageStatusKind.UP_TO_DATE;
} else {
kind = ImageStatusKind.UPDATE_AVAILABLE;
anyUpdate = true;
}
}
statuses.add(new ImageStatus(image, currentVersion, latest, kind));
}
return new UpdateStatus(true, anyUpdate, anyUnknown, currentVersion, statuses, Instant.now());
ImagesEvaluation ev = evaluateImages(images, registry, null);
return new UpdateStatus(true, ev.anyUpdate(), ev.anyUnknown(),
currentVersion, ev.statuses(), Instant.now());
}
/**
@@ -169,21 +145,37 @@ public class UpdateCheckService {
(creds.get().username() + ":" + creds.get().password()).getBytes(StandardCharsets.UTF_8));
String betaRegistry = normalizeRegistry(creds.get().registry());
ImagesEvaluation ev = evaluateImages(betaImages, betaRegistry, basicAuth);
return new BetaStatus(true, ev.anyUpdate(), ev.anyUnknown(),
ev.statuses(), Instant.now(), null);
}
/** Résultat de l'évaluation d'une liste d'images : statuts + drapeaux agrégés. */
private record ImagesEvaluation(List<ImageStatus> statuses, boolean anyUpdate, boolean anyUnknown) {
}
/**
* Boucle commune à {@link #check()} et {@link #checkBeta()} : pour chaque image,
* récupère le tag semver le plus haut et le compare à la version courante.
* Une image dont les tags sont injoignables ou non semver → {@code UNKNOWN}.
*/
private ImagesEvaluation evaluateImages(List<String> imageList, String registryUrl,
@Nullable String authHeader) {
List<ImageStatus> statuses = new ArrayList<>();
boolean anyUpdate = false;
boolean anyUnknown = false;
for (String image : betaImages) {
for (String image : imageList) {
String latest = null;
try {
latest = fetchLatestSemverTag(betaRegistry, image, basicAuth);
latest = fetchLatestSemverTag(registryUrl, image, authHeader);
} catch (Exception e) {
log.warn("Beta tags fetch failed for {}: {}", image, e.getMessage());
log.warn("Tags fetch failed for {}: {}", image, e.getMessage());
}
ImageStatusKind kind;
if (latest == null) {
kind = ImageStatusKind.UNKNOWN;
anyUnknown = true;
} else if (currentVersion != null && compareSemver(currentVersion, latest) >= 0) {
} else if (currentVersion != null && SemverComparator.compareSemver(currentVersion, latest) >= 0) {
kind = ImageStatusKind.UP_TO_DATE;
} else {
kind = ImageStatusKind.UPDATE_AVAILABLE;
@@ -191,7 +183,7 @@ public class UpdateCheckService {
}
statuses.add(new ImageStatus(image, currentVersion, latest, kind));
}
return new BetaStatus(true, anyUpdate, anyUnknown, statuses, Instant.now(), null);
return new ImagesEvaluation(statuses, anyUpdate, anyUnknown);
}
public void apply() {
@@ -209,6 +201,10 @@ public class UpdateCheckService {
// -----------------------------------------------------------------------
// Registry HTTP API v2 - tags listing + auth bearer
//
// NB : le RestTemplate (champ `http`) reste porté par ce service — les tests
// l'injectent par réflexion. Le parsing semver et le parsing du challenge
// WWW-Authenticate sont, eux, externalisés (SemverComparator, WwwAuthenticate).
// -----------------------------------------------------------------------
/**
@@ -244,7 +240,7 @@ public class UpdateCheckService {
body = tagsCall(url, bearerHeaders);
}
if (body == null || body.tags == null || body.tags.isEmpty()) return null;
return findMaxSemver(body.tags);
return SemverComparator.findMaxSemver(body.tags);
}
private TagsListResponse tagsCall(String url, HttpHeaders headers) {
@@ -253,69 +249,6 @@ public class UpdateCheckService {
return resp.getBody();
}
/**
* Parcourt la liste des tags, garde uniquement ceux qui parsent en semver
* (1 a 3 chiffres separes par des points, optionnel prefix "v"), retourne le max.
* Pre-release / build metadata sont strippes pour la comparaison.
*/
@Nullable
static String findMaxSemver(List<String> tags) {
String maxTag = null;
int[] maxParts = null;
for (String t : tags) {
if (t == null || t.isBlank()) continue;
int[] parts = parseSemver(t);
if (parts == null) continue;
if (maxParts == null || compareParts(parts, maxParts) > 0) {
maxParts = parts;
maxTag = t;
}
}
return maxTag;
}
/** @return [major, minor, patch] ou null si non parsable. */
@Nullable
static int[] parseSemver(String tag) {
if (tag == null) return null;
String s = tag.trim();
if (s.isEmpty()) return null;
if (s.startsWith("v") || s.startsWith("V")) s = s.substring(1);
int dashIdx = s.indexOf('-');
if (dashIdx > 0) s = s.substring(0, dashIdx);
int plusIdx = s.indexOf('+');
if (plusIdx > 0) s = s.substring(0, plusIdx);
String[] parts = s.split("\\.");
if (parts.length < 1 || parts.length > 3) return null;
int[] result = new int[]{0, 0, 0};
for (int i = 0; i < parts.length; i++) {
try {
int v = Integer.parseInt(parts[i]);
if (v < 0) return null;
result[i] = v;
} catch (NumberFormatException e) {
return null;
}
}
return result;
}
/** Compare deux versions semver brutes (sans prefix). Negatif si a < b. */
static int compareSemver(String a, String b) {
int[] aParts = parseSemver(a);
int[] bParts = parseSemver(b);
if (aParts == null || bParts == null) return 0;
return compareParts(aParts, bParts);
}
private static int compareParts(int[] a, int[] b) {
for (int i = 0; i < 3; i++) {
int diff = Integer.compare(a[i], b[i]);
if (diff != 0) return diff;
}
return 0;
}
/**
* Suit le challenge {@code WWW-Authenticate: Bearer realm="..."} pour obtenir
* un token. Si {@code basicAuth} est fourni, l'utilise pour l'echange (cas
@@ -326,7 +259,7 @@ public class UpdateCheckService {
if (wwwAuth == null) return null;
String prefix = "Bearer ";
if (!wwwAuth.regionMatches(true, 0, prefix, 0, prefix.length())) return null;
Map<String, String> params = parseAuthParams(wwwAuth.substring(prefix.length()));
Map<String, String> params = WwwAuthenticate.parseParams(wwwAuth.substring(prefix.length()));
String realm = params.get("realm");
if (realm == null) return null;
StringBuilder url = new StringBuilder(realm);
@@ -359,34 +292,6 @@ public class UpdateCheckService {
}
}
/** Parser minimaliste pour {@code key="value", key2="value2"}. */
private static Map<String, String> parseAuthParams(String s) {
Map<String, String> out = new HashMap<>();
int i = 0;
int n = s.length();
while (i < n) {
while (i < n && (s.charAt(i) == ',' || s.charAt(i) == ' ')) i++;
int eq = s.indexOf('=', i);
if (eq < 0) break;
String key = s.substring(i, eq).trim();
int valStart = eq + 1;
String val;
if (valStart < n && s.charAt(valStart) == '"') {
int valEnd = s.indexOf('"', valStart + 1);
if (valEnd < 0) break;
val = s.substring(valStart + 1, valEnd);
i = valEnd + 1;
} else {
int valEnd = s.indexOf(',', valStart);
if (valEnd < 0) valEnd = n;
val = s.substring(valStart, valEnd).trim();
i = valEnd;
}
out.put(key, val);
}
return out;
}
private static String normalizeRegistry(String value) {
if (value == null || value.isBlank()) return "";
String v = value.trim();

View File

@@ -0,0 +1,46 @@
package com.loremind.infrastructure.updates;
import java.util.HashMap;
import java.util.Map;
/**
* Parseur minimaliste des paramètres d'un challenge HTTP
* {@code WWW-Authenticate: Bearer realm="...", service="...", scope="..."}
* (cf. le flux de token du registry Docker dans {@link UpdateCheckService}).
* <p>
* Accepte les valeurs entre guillemets ({@code key="value"}) comme non quotées
* ({@code key=value}), séparées par des virgules et/ou espaces. Fonction PURE.
*/
final class WwwAuthenticate {
private WwwAuthenticate() {
}
/** Parse {@code key="value", key2=value2} en map clé→valeur (sans les guillemets). */
static Map<String, String> parseParams(String s) {
Map<String, String> out = new HashMap<>();
int i = 0;
int n = s.length();
while (i < n) {
while (i < n && (s.charAt(i) == ',' || s.charAt(i) == ' ')) i++;
int eq = s.indexOf('=', i);
if (eq < 0) break;
String key = s.substring(i, eq).trim();
int valStart = eq + 1;
String val;
if (valStart < n && s.charAt(valStart) == '"') {
int valEnd = s.indexOf('"', valStart + 1);
if (valEnd < 0) break;
val = s.substring(valStart + 1, valEnd);
i = valEnd + 1;
} else {
int valEnd = s.indexOf(',', valStart);
if (valEnd < 0) valEnd = n;
val = s.substring(valStart, valEnd).trim();
i = valEnd;
}
out.put(key, val);
}
return out;
}
}

View File

@@ -0,0 +1,86 @@
package com.loremind.infrastructure.web.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.http.CacheControl;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.resource.PathResourceResolver;
import java.io.IOException;
/**
* Service du front Angular en statique par le Core, en mode local-first.
* <p>
* En deploiement Docker, le front est servi par un conteneur nginx dedie
* (service {@code web}) et le Core reste une API pure. En mode local
* (application de bureau empaquetee), il n'y a pas de nginx : le build Angular
* est copie dans {@code classpath:/static/} (cf. profil Maven de packaging) et
* le Core le sert lui-meme sur la meme origine que l'API.
* <p>
* Active uniquement sous le profil {@code local} pour ne RIEN changer au
* comportement du conteneur Core en production.
* <p>
* Fallback SPA : toute route qui ne correspond pas a un fichier statique reel
* (et qui n'est pas une route d'API) renvoie {@code index.html}, afin que le
* routing cote Angular (deep links, rechargement de page) fonctionne.
*/
@Configuration
@Profile("local")
public class LocalWebConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// Fichiers de traduction (assets/i18n/*.json) : nom STABLE (non hashe),
// donc jamais mis en cache, sinon le navigateur ressert un JSON perime
// (voire un 304 sur un corps en cache obsolete) apres une mise a jour.
// Symptome observe : les libelles restent en cles brutes pour une langue.
// Motif plus specifique que "/**" => prioritaire pour ces chemins.
registry.addResourceHandler("/assets/i18n/**")
.addResourceLocations("classpath:/static/assets/i18n/")
.setCacheControl(CacheControl.noStore());
registry.addResourceHandler("/**")
.addResourceLocations("classpath:/static/")
.resourceChain(true)
.addResolver(new PathResourceResolver() {
@Override
protected Resource getResource(String resourcePath, Resource location) throws IOException {
Resource requested = location.createRelative(resourcePath);
if (requested.exists() && requested.isReadable()) {
return requested;
}
// Ne JAMAIS rabattre les routes techniques sur index.html :
// une API inexistante doit rester un 404, pas du HTML.
if (resourcePath.startsWith("api/") || resourcePath.startsWith("actuator/")) {
return null;
}
// Un ASSET manquant (chemin avec extension : .json, .js, .css,
// .png...) doit renvoyer 404 — surtout PAS index.html. Sinon un
// loader JSON (ex. ngx-translate chargeant assets/i18n/fr.json)
// recevrait du HTML en 200 et echouerait au parse, donnant des
// cles brutes a l'ecran. On ne rabat sur la coquille SPA que les
// vraies routes applicatives (sans extension de fichier).
if (hasFileExtension(resourcePath)) {
return null;
}
// Route applicative Angular -> on sert la coquille SPA.
Resource index = new ClassPathResource("/static/index.html");
return index.exists() ? index : null;
}
});
}
/**
* Vrai si le dernier segment du chemin contient un point (donc une extension
* de fichier : {@code assets/i18n/fr.json}, {@code main.js}...). Les routes
* applicatives Angular ({@code settings}, {@code campaigns/42}) n'en ont pas.
*/
private static boolean hasFileExtension(String resourcePath) {
int lastSlash = resourcePath.lastIndexOf('/');
String lastSegment = resourcePath.substring(lastSlash + 1);
return lastSegment.contains(".");
}
}

View File

@@ -9,6 +9,7 @@ import com.loremind.infrastructure.web.dto.generationcontext.ChatMessageDTO;
import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamCampaignRequestDTO;
import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamRequestDTO;
import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamSessionRequestDTO;
import com.loremind.infrastructure.web.sse.SseJson;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.task.TaskExecutor;
import org.springframework.http.MediaType;
@@ -162,7 +163,7 @@ public class AiChatController {
private void sendToken(SseEmitter emitter, String token) {
try {
emitter.send(SseEmitter.event()
.data("{\"token\":" + jsonEscape(token) + "}"));
.data("{\"token\":" + SseJson.escape(token) + "}"));
} catch (IOException e) {
emitter.completeWithError(e);
}
@@ -182,7 +183,7 @@ public class AiChatController {
String message = error.getMessage() != null ? error.getMessage() : error.getClass().getSimpleName();
emitter.send(SseEmitter.event()
.name("error")
.data("{\"message\":" + jsonEscape(message) + "}"));
.data("{\"message\":" + SseJson.escape(message) + "}"));
emitter.complete();
} catch (IOException ioe) {
emitter.completeWithError(ioe);
@@ -191,31 +192,6 @@ public class AiChatController {
// --- Utilitaires --------------------------------------------------------
/** Encadre une chaîne de guillemets et échappe les caractères JSON dangereux. */
private String jsonEscape(String raw) {
if (raw == null) return "\"\"";
StringBuilder sb = new StringBuilder(raw.length() + 2);
sb.append('"');
for (int i = 0; i < raw.length(); i++) {
char c = raw.charAt(i);
switch (c) {
case '"': sb.append("\\\""); break;
case '\\': sb.append("\\\\"); break;
case '\n': sb.append("\\n"); break;
case '\r': sb.append("\\r"); break;
case '\t': sb.append("\\t"); break;
default:
if (c < 0x20) {
sb.append(String.format("\\u%04x", (int) c));
} else {
sb.append(c);
}
}
}
sb.append('"');
return sb.toString();
}
private List<ChatMessage> toDomainMessages(List<ChatMessageDTO> dtos) {
if (dtos == null) return List.of();
return dtos.stream()

View File

@@ -0,0 +1,85 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.infrastructure.transfer.ExportService;
import com.loremind.infrastructure.transfer.ImportResult;
import com.loremind.infrastructure.transfer.ImportService;
import com.loremind.infrastructure.transfer.dto.ContentExport;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.time.Instant;
/**
* Endpoints d'EXPORT / IMPORT du "contenu" (admin).
* <p>
* - {@code GET /api/admin/data/export} : telecharge un .zip portable.<br>
* - {@code POST /api/admin/data/import} : importe un .zip en mode FUSION.
* <p>
* Garde le mode demo coherent avec {@code SettingsController} : ces operations
* sont desactivees en demo (donnees partagees, pas d'ecriture massive).
*/
@RestController
@RequestMapping("/api/admin/data")
public class DataTransferController {
private final ExportService exportService;
private final ImportService importService;
private final boolean demoMode;
public DataTransferController(ExportService exportService,
ImportService importService,
@Value("${app.demo-mode:false}") boolean demoMode) {
this.exportService = exportService;
this.importService = importService;
this.demoMode = demoMode;
}
@GetMapping(value = "/export", produces = "application/zip")
public ResponseEntity<StreamingResponseBody> export() {
guardDemoMode();
// Stamp de l'horodatage cote requete (PAS dans un init statique).
String exportedAt = Instant.now().toString();
ContentExport content = exportService.buildExport(exportedAt);
StreamingResponseBody body = out -> exportService.writeZip(content, out);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"loremind-export.zip\"")
.contentType(MediaType.parseMediaType("application/zip"))
.body(body);
}
@PostMapping(value = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ImportResult> importData(@RequestParam("file") MultipartFile file) {
guardDemoMode();
if (file == null || file.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Fichier d'import vide");
}
try {
ImportResult result = importService.importZip(file.getInputStream());
return ResponseEntity.ok(result);
} catch (IOException e) {
throw new UncheckedIOException("Echec de lecture du fichier d'import", e);
}
}
private void guardDemoMode() {
if (demoMode) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Data transfer disabled in demo mode");
}
}
}

View File

@@ -5,10 +5,8 @@ import com.loremind.application.gamesystemcontext.GameSystemService;
import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.gamesystemcontext.RulesImportResult;
import com.loremind.domain.gamesystemcontext.ports.RulesImportException;
import com.loremind.domain.shared.template.TemplateField;
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
import com.loremind.infrastructure.web.dto.gamesystemcontext.RulesImportResponseDTO;
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
import com.loremind.infrastructure.web.mapper.GameSystemMapper;
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
import org.slf4j.Logger;
@@ -23,7 +21,6 @@ import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -261,18 +258,11 @@ public class GameSystemController {
dto.getName(),
dto.getDescription(),
dto.getRulesMarkdown(),
toDomainFields(dto.getCharacterTemplate()),
toDomainFields(dto.getNpcTemplate()),
toDomainFields(dto.getEnemyTemplate()),
templateFieldMapper.toDomainList(dto.getCharacterTemplate()),
templateFieldMapper.toDomainList(dto.getNpcTemplate()),
templateFieldMapper.toDomainList(dto.getEnemyTemplate()),
dto.getAuthor(),
dto.isPublic()
);
}
private List<TemplateField> toDomainFields(List<TemplateFieldDTO> dtos) {
if (dtos == null) return new ArrayList<>();
List<TemplateField> out = new ArrayList<>(dtos.size());
for (TemplateFieldDTO d : dtos) out.add(templateFieldMapper.toDomain(d));
return out;
}
}

View File

@@ -5,6 +5,7 @@ import com.loremind.domain.campaigncontext.Notebook;
import com.loremind.domain.campaigncontext.NotebookSource;
import com.loremind.domain.campaigncontext.ports.NotebookChatStreamer;
import com.loremind.domain.campaigncontext.ports.NotebookException;
import com.loremind.infrastructure.web.sse.SseJson;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.task.TaskExecutor;
import org.springframework.http.HttpStatus;
@@ -211,7 +212,7 @@ public class NotebookController {
private void sendToken(SseEmitter emitter, String token) {
try {
emitter.send(SseEmitter.event().name("token").data("{\"token\":" + jsonEscape(token) + "}"));
emitter.send(SseEmitter.event().name("token").data("{\"token\":" + SseJson.escape(token) + "}"));
} catch (IOException | IllegalStateException e) {
// flux fermé/expiré : on cesse d'écrire
}
@@ -247,32 +248,13 @@ public class NotebookController {
private void fail(SseEmitter emitter, Throwable error) {
try {
String message = error.getMessage() != null ? error.getMessage() : error.getClass().getSimpleName();
emitter.send(SseEmitter.event().name("error").data("{\"message\":" + jsonEscape(message) + "}"));
emitter.send(SseEmitter.event().name("error").data("{\"message\":" + SseJson.escape(message) + "}"));
emitter.complete();
} catch (IOException | IllegalStateException e) {
// flux déjà fermé/expiré : rien à envoyer
}
}
private String jsonEscape(String raw) {
if (raw == null) return "\"\"";
StringBuilder sb = new StringBuilder(raw.length() + 2).append('"');
for (int i = 0; i < raw.length(); i++) {
char c = raw.charAt(i);
switch (c) {
case '"': sb.append("\\\""); break;
case '\\': sb.append("\\\\"); break;
case '\n': sb.append("\\n"); break;
case '\r': sb.append("\\r"); break;
case '\t': sb.append("\\t"); break;
default:
if (c < 0x20) sb.append(String.format("\\u%04x", (int) c));
else sb.append(c);
}
}
return sb.append('"').toString();
}
public record CreateRequest(String campaignId, String name) {}
public record RenameRequest(String name) {}
/**

View File

@@ -1,14 +1,9 @@
package com.loremind.infrastructure.web.mapper;
import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.shared.template.TemplateField;
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
@Component
public class GameSystemMapper {
@@ -25,9 +20,9 @@ public class GameSystemMapper {
dto.setName(g.getName());
dto.setDescription(g.getDescription());
dto.setRulesMarkdown(g.getRulesMarkdown());
dto.setCharacterTemplate(toDTOList(g.getCharacterTemplate()));
dto.setNpcTemplate(toDTOList(g.getNpcTemplate()));
dto.setEnemyTemplate(toDTOList(g.getEnemyTemplate()));
dto.setCharacterTemplate(fieldMapper.toDTOList(g.getCharacterTemplate()));
dto.setNpcTemplate(fieldMapper.toDTOList(g.getNpcTemplate()));
dto.setEnemyTemplate(fieldMapper.toDTOList(g.getEnemyTemplate()));
dto.setAuthor(g.getAuthor());
dto.setPublic(g.isPublic());
return dto;
@@ -40,25 +35,11 @@ public class GameSystemMapper {
.name(dto.getName())
.description(dto.getDescription())
.rulesMarkdown(dto.getRulesMarkdown())
.characterTemplate(toDomainList(dto.getCharacterTemplate()))
.npcTemplate(toDomainList(dto.getNpcTemplate()))
.enemyTemplate(toDomainList(dto.getEnemyTemplate()))
.characterTemplate(fieldMapper.toDomainList(dto.getCharacterTemplate()))
.npcTemplate(fieldMapper.toDomainList(dto.getNpcTemplate()))
.enemyTemplate(fieldMapper.toDomainList(dto.getEnemyTemplate()))
.author(dto.getAuthor())
.isPublic(dto.isPublic())
.build();
}
private List<TemplateFieldDTO> toDTOList(List<TemplateField> fields) {
if (fields == null) return new ArrayList<>();
List<TemplateFieldDTO> out = new ArrayList<>(fields.size());
for (TemplateField f : fields) out.add(fieldMapper.toDTO(f));
return out;
}
private List<TemplateField> toDomainList(List<TemplateFieldDTO> dtos) {
if (dtos == null) return new ArrayList<>();
List<TemplateField> out = new ArrayList<>(dtos.size());
for (TemplateFieldDTO d : dtos) out.add(fieldMapper.toDomain(d));
return out;
}
}

View File

@@ -60,4 +60,20 @@ public class TemplateFieldMapper {
}
return new TemplateField(dto.getName(), type, layout, labels);
}
/** Mappe une liste de champs domaine → DTO ({@code null} → liste vide). */
public List<TemplateFieldDTO> toDTOList(List<TemplateField> fields) {
if (fields == null) return new ArrayList<>();
List<TemplateFieldDTO> out = new ArrayList<>(fields.size());
for (TemplateField f : fields) out.add(toDTO(f));
return out;
}
/** Mappe une liste de champs DTO → domaine ({@code null} → liste vide). */
public List<TemplateField> toDomainList(List<TemplateFieldDTO> dtos) {
if (dtos == null) return new ArrayList<>();
List<TemplateField> out = new ArrayList<>(dtos.size());
for (TemplateFieldDTO d : dtos) out.add(toDomain(d));
return out;
}
}

View File

@@ -0,0 +1,45 @@
package com.loremind.infrastructure.web.sse;
/**
* Petits utilitaires d'écriture JSON pour les payloads SSE bâtis à la main par
* les controllers de streaming (chat IA, notebook…).
* <p>
* Volontairement minimaliste (pas de Jackson) : les payloads concernés sont des
* objets plats à un ou deux champs ({@code {"token":"…"}}, {@code {"message":"…"}})
* écrits dans la boucle de streaming, où l'on veut éviter l'allocation d'un
* ObjectMapper par token.
*/
public final class SseJson {
private SseJson() {
}
/**
* Encadre {@code raw} de guillemets et échappe les caractères JSON dangereux
* (guillemet, antislash, retours chariot, tabulation, caractères de contrôle).
* Renvoie {@code "\"\""} (chaîne JSON vide) si {@code raw} est {@code null}.
*/
public static String escape(String raw) {
if (raw == null) return "\"\"";
StringBuilder sb = new StringBuilder(raw.length() + 2);
sb.append('"');
for (int i = 0; i < raw.length(); i++) {
char c = raw.charAt(i);
switch (c) {
case '"': sb.append("\\\""); break;
case '\\': sb.append("\\\\"); break;
case '\n': sb.append("\\n"); break;
case '\r': sb.append("\\r"); break;
case '\t': sb.append("\\t"); break;
default:
if (c < 0x20) {
sb.append(String.format("\\u%04x", (int) c));
} else {
sb.append(c);
}
}
}
sb.append('"');
return sb.toString();
}
}

View File

@@ -0,0 +1,103 @@
# ============================================================================
# Profil "local" — mode local-first (application de bureau, sans Docker)
# ============================================================================
# Active via : --spring.profiles.active=local (ou SPRING_PROFILES_ACTIVE=local)
#
# Objectif : faire tourner le Core sur le poste de l'utilisateur SANS aucune
# infrastructure externe (ni Postgres, ni MinIO, ni Docker). Toutes les donnees
# vivent sous loremind.home (defaut : ~/.loremind).
#
# Surcharge UNIQUEMENT ce qui differe du profil par defaut (application.properties) :
# le reste (timeouts, multipart, licensing...) est herite tel quel.
# --- Config utilisateur editable (port + identifiants admin) ----------------
# Charge ~/.loremind/loremind.properties s'il existe (cree au 1er lancement par
# DesktopUserConfig). "optional:" = pas d'erreur s'il manque. Les valeurs y
# definies (server.port, admin.username, admin.password) SURCHARGENT les defauts
# ci-dessous (un import a une priorite superieure au fichier qui l'importe).
# NB : le port effectif est de toute facon fixe par DesktopUserConfig au demarrage
# (propriete systeme server.port, avec repli si le port est occupe).
spring.config.import=optional:file:${user.home}/.loremind/loremind.properties
# --- Serveur : ecoute UNIQUEMENT sur la boucle locale -----------------------
# App de bureau mono-utilisateur : jamais exposee au reseau (securite). Et ca
# rend coherent le test "port libre" de DesktopUserConfig (qui teste 127.0.0.1)
# avec l'adresse de bind reelle de Tomcat -> le repli sur port libre fonctionne
# meme si une autre appli occupe le port sur une autre interface.
server.address=127.0.0.1
# --- Base de donnees : H2 en mode fichier, compatibilite PostgreSQL ---------
# MODE=PostgreSQL : H2 interprete le SQL PostgreSQL -> les MEMES migrations
# Flyway (ecrites en SQL Postgres) tournent ici comme sur la prod Postgres.
# Aligne aussi la casse des identifiants (minuscules, comme PG).
# DB_CLOSE_ON_EXIT=FALSE : Spring/Hikari pilote la fermeture (pas le shutdown
# hook H2). AUTO_SERVER volontairement absent (interdit avec DB_CLOSE_ON_EXIT,
# et inutile pour un process unique).
# NON_KEYWORDS=VALUE : PostgreSQL accepte `value` comme nom de colonne non-quote
# (colonne de playthrough_flag), mais H2 le reserve par defaut -> on le relache
# pour que le baseline SQL Postgres (non-quote) passe aussi sur H2. Ajouter
# d'autres mots ici si une migration future utilise un identifiant reserve H2.
spring.datasource.url=jdbc:h2:file:${loremind.home}/db/loremind;MODE=PostgreSQL;DB_CLOSE_ON_EXIT=FALSE;NON_KEYWORDS=VALUE
spring.datasource.username=sa
spring.datasource.password=
spring.datasource.driver-class-name=org.h2.Driver
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
# Schema gere par Flyway (cf. application.properties) ; Hibernate valide seulement.
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=false
# --- Stockage des images : systeme de fichiers -----------------------------
storage.backend=filesystem
storage.filesystem.path=${loremind.home}/images
# --- Brain : service IA Python lance en sidecar sur le poste ---------------
brain.base-url=http://localhost:8000
# Secret partage Core <-> Brain. En local il n'a pas de role securitaire fort
# (tout est sur localhost, mono-utilisateur) mais le Brain est fail-closed :
# il DOIT etre defini des deux cotes. Le lanceur de sidecar (BrainSidecar)
# propage cette meme valeur au process Brain via la variable BRAIN_INTERNAL_SECRET.
brain.internal-secret=${BRAIN_INTERNAL_SECRET:loremind-local-secret}
# --- Brain en sidecar (lance par le Core) ----------------------------------
# Le Core demarre lui-meme le process Brain (pas de Docker en local).
brain.sidecar.enabled=${BRAIN_SIDECAR_ENABLED:true}
# Le Brain ecrit son dossier data/ (index vectoriel, settings) sous ce dossier.
brain.sidecar.working-dir=${loremind.home}/brain
# Commande de lancement. VIDE par defaut : le dev qui lance le Brain a la main
# n'est pas perturbe. Le packaging (jpackage) la renseigne vers l'exe PyInstaller.
# - Empaquete : chemin de l'exe, ex. C:/Program Files/LoreMind/brain/loremind-brain.exe
# - Dev : python,-m,uvicorn,app.main:app,--host,127.0.0.1,--port,8000
# (avec brain.sidecar.working-dir pointant sur le dossier brain/)
brain.sidecar.command=${BRAIN_SIDECAR_COMMAND:}
# --- Admin (localhost uniquement) ------------------------------------------
# Application mono-utilisateur sur le poste : l'utilisateur EST l'admin.
# Defauts admin/admin, SURCHARGEABLES par l'utilisateur dans
# ~/.loremind/loremind.properties (cf. spring.config.import ci-dessus).
admin.username=${ADMIN_USERNAME:admin}
admin.password=${ADMIN_PASSWORD:admin}
# --- Front servi par le Core (meme origine) --------------------------------
# Le front Angular est servi en statique par le Core (cf. LocalWebConfig),
# donc tout est sur http://localhost:8080 : CORS sans objet, mais on autorise
# l'origine locale par securite si un dev lance Angular separement.
spring.web.cors.allowed-origins=http://localhost:8080,http://localhost:4200
# Mode demo desactive (instance personnelle complete).
app.demo-mode=false
# --- Licensing / Patreon : desactive en mode bureau ------------------------
# Le gating par IMAGE Docker (canal beta tire d'un registry prive) n'a aucun
# sens hors Docker. On coupe toute la machinerie : daemon de refresh no-op,
# /api/license/* renvoie enabled=false, et l'UI masque la section Patreon.
# La distribution beta desktop se fait par installeur beta via Patreon, pas
# par une connexion in-app.
licensing.enabled=false
# --- Verification des mises a jour (bureau) --------------------------------
# Interroge l'API GitHub Releases (derniere release STABLE) au demarrage ; si
# une version plus recente existe, l'icone systray le signale (bulle + item
# « Telecharger »). Pas de mise a jour auto : l'utilisateur telecharge et lance
# le nouvel installeur. Mettre a false pour desactiver toute requete sortante.
desktop.update.enabled=${DESKTOP_UPDATE_CHECK:true}
desktop.update.releases-api-url=https://api.github.com/repos/IGMLcreation/LoreMind/releases/latest

View File

@@ -22,10 +22,26 @@ spring.datasource.driver-class-name=org.postgresql.Driver
# Configuration JPA / Hibernate
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.hibernate.ddl-auto=update
# Le schema est desormais gere par Flyway (migrations versionnees), plus par
# Hibernate. ddl-auto=validate : Hibernate ne MODIFIE plus le schema, il verifie
# juste au demarrage que les entites correspondent au schema cree par Flyway
# (filet de securite contre les derives entites<->migrations).
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
# ============================================================================
# Flyway : migrations de schema versionnees (src/main/resources/db/migration).
# ============================================================================
# baseline-on-migrate : sur une base EXISTANTE (prod deja peuplee, sans table
# d'historique Flyway), Flyway l'estampille a la version baseline SANS rejouer
# V1 -> les donnees existantes sont preservees. Sur une base VIDE (install
# neuve), Flyway joue V1__baseline.sql normalement pour creer le schema.
spring.flyway.enabled=true
spring.flyway.baseline-on-migrate=true
spring.flyway.baseline-version=1
spring.flyway.baseline-description=Baseline (schema pre-Flyway, gere jusque-la par ddl-auto)
# Configuration CORS pour autoriser le Frontend Angular
spring.web.cors.allowed-origins=${CORS_ALLOWED_ORIGINS:http://localhost:4200}
spring.web.cors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS
@@ -48,6 +64,17 @@ brain.internal-secret=${BRAIN_INTERNAL_SECRET:}
admin.username=${ADMIN_USERNAME:admin}
admin.password=${ADMIN_PASSWORD:}
# Repertoire de base de l'instance (donnees locales hors Docker).
# Utilise par le profil "local" (H2 fichier, stockage images filesystem...).
loremind.home=${LOREMIND_HOME:${user.home}/.loremind}
# Backend de stockage des binaires d'images (port ImageStorage) :
# - minio : MinIO/S3 (defaut — deploiement Docker/serveur)
# - filesystem : systeme de fichiers local (mode local-first / jpackage)
storage.backend=${STORAGE_BACKEND:minio}
# Racine du stockage filesystem (ignoree si storage.backend=minio).
storage.filesystem.path=${STORAGE_FS_PATH:${loremind.home}/images}
# Configuration MinIO (Shared Kernel images - Object Storage)
# Le bucket est cree automatiquement par le service minio-init (docker-compose up -d).
# Defaults OK pour dev local ; overrides en prod via env.

View File

@@ -0,0 +1,424 @@
create table arcs (
"order" integer not null,
campaign_id bigint not null,
created_at timestamp(6) not null,
id bigint generated by default as identity,
updated_at timestamp(6) not null,
type varchar(16) default 'LINEAR' not null check (type in ('LINEAR','HUB')),
description TEXT,
gm_notes TEXT,
icon varchar(255),
illustration_image_ids TEXT,
map_image_ids TEXT,
name varchar(255) not null,
related_page_ids TEXT,
resolution TEXT,
rewards TEXT,
stakes TEXT,
themes TEXT,
primary key (id)
);
create table campaigns (
arcs_count integer not null,
created_at timestamp(6) not null,
id bigint generated by default as identity,
updated_at timestamp(6) not null,
description TEXT,
game_system_id varchar(255),
lore_id varchar(255),
name varchar(255) not null,
primary key (id)
);
create table catalog_items (
position integer not null,
catalog_id bigint not null,
id bigint generated by default as identity,
price varchar(64),
category varchar(128),
description TEXT,
name varchar(255) not null,
primary key (id)
);
create table chapters (
"order" integer not null,
arc_id bigint not null,
created_at timestamp(6) not null,
id bigint generated by default as identity,
updated_at timestamp(6) not null,
description TEXT,
gm_notes TEXT,
icon varchar(255),
illustration_image_ids TEXT,
map_image_ids TEXT,
name varchar(255) not null,
narrative_stakes TEXT,
player_objectives TEXT,
prerequisites TEXT,
related_page_ids TEXT,
primary key (id)
);
create table characters (
"order" integer not null,
campaign_id bigint,
created_at timestamp(6) not null,
id bigint generated by default as identity,
playthrough_id bigint,
updated_at timestamp(6) not null,
field_values TEXT,
header_image_id varchar(255),
image_values TEXT,
key_value_values TEXT,
name varchar(255) not null,
portrait_image_id varchar(255),
primary key (id)
);
create table conversation_messages (
conversation_id bigint not null,
created_at timestamp(6) not null,
id bigint generated by default as identity,
role varchar(16) not null,
content TEXT not null,
primary key (id)
);
create table conversations (
created_at timestamp(6) not null,
id bigint generated by default as identity,
updated_at timestamp(6) not null,
campaign_id varchar(255),
entity_id varchar(255),
entity_type varchar(255),
lore_id varchar(255),
title varchar(255) not null,
primary key (id)
);
create table enemies (
"order" integer not null,
campaign_id bigint not null,
created_at timestamp(6) not null,
id bigint generated by default as identity,
updated_at timestamp(6) not null,
field_values TEXT,
folder varchar(255),
header_image_id varchar(255),
image_values TEXT,
key_value_values TEXT,
level varchar(255),
name varchar(255) not null,
portrait_image_id varchar(255),
primary key (id)
);
create table game_systems (
is_public boolean not null,
created_at timestamp(6) not null,
id bigint generated by default as identity,
updated_at timestamp(6) not null,
author varchar(255),
character_template TEXT,
description TEXT,
enemy_template TEXT,
name varchar(255) not null,
npc_template TEXT,
rules_markdown TEXT,
primary key (id)
);
create table images (
id bigint generated by default as identity,
size_bytes bigint not null,
uploaded_at timestamp(6) not null,
content_type varchar(255) not null,
filename varchar(255) not null,
storage_key varchar(255) not null unique,
primary key (id)
);
create table item_catalogs (
"order" integer not null,
campaign_id bigint not null,
created_at timestamp(6) not null,
id bigint generated by default as identity,
updated_at timestamp(6) not null,
icon varchar(64),
description TEXT,
name varchar(255) not null,
primary key (id)
);
create table licenses (
beta_channel_enabled boolean not null,
last_refresh_succeeded boolean not null,
created_at timestamp(6) with time zone not null,
expires_at timestamp(6) with time zone not null,
issued_at timestamp(6) with time zone not null,
last_refresh_attempt_at timestamp(6) with time zone,
updated_at timestamp(6) with time zone not null,
id varchar(255) not null,
instance_id varchar(255) not null,
patreon_user_id varchar(255) not null,
raw_jwt TEXT not null,
tier_id varchar(255) not null,
primary key (id)
);
create table lore_nodes (
created_at timestamp(6) not null,
id bigint generated by default as identity,
lore_id bigint not null,
parent_id bigint,
updated_at timestamp(6) not null,
icon varchar(64),
name varchar(255) not null,
primary key (id)
);
create table lores (
node_count integer not null,
page_count integer not null,
created_at timestamp(6) not null,
id bigint generated by default as identity,
updated_at timestamp(6) not null,
description TEXT,
name varchar(255) not null,
primary key (id)
);
create table notebook_messages (
archived_at timestamp(6),
created_at timestamp(6) not null,
id bigint generated by default as identity,
notebook_id bigint not null,
role varchar(16) not null,
content TEXT not null,
primary key (id)
);
create table notebook_sources (
chunk_count integer not null,
page_count integer not null,
created_at timestamp(6) not null,
id bigint generated by default as identity,
notebook_id bigint not null,
status varchar(16) not null,
filename varchar(255) not null,
primary key (id)
);
create table notebooks (
campaign_id bigint not null,
created_at timestamp(6) not null,
id bigint generated by default as identity,
updated_at timestamp(6) not null,
name varchar(255) not null,
primary key (id)
);
create table npcs (
"order" integer not null,
campaign_id bigint not null,
created_at timestamp(6) not null,
id bigint generated by default as identity,
updated_at timestamp(6) not null,
field_values TEXT,
folder varchar(255),
header_image_id varchar(255),
image_values TEXT,
key_value_values TEXT,
name varchar(255) not null,
portrait_image_id varchar(255),
related_page_ids TEXT,
primary key (id)
);
create table pages (
created_at timestamp(6) not null,
id bigint generated by default as identity,
lore_id bigint not null,
node_id bigint not null,
template_id bigint,
updated_at timestamp(6) not null,
image_values_json TEXT,
key_value_values TEXT,
notes TEXT,
related_page_ids TEXT,
table_values TEXT,
tags TEXT,
title varchar(255) not null,
values_json TEXT,
primary key (id)
);
create table playthrough_flag (
value boolean not null,
id bigint generated by default as identity,
playthrough_id bigint not null,
name varchar(128) not null,
primary key (id),
constraint uk_playthrough_flag_name unique (playthrough_id, name)
);
create table playthroughs (
campaign_id bigint not null,
created_at timestamp(6) not null,
id bigint generated by default as identity,
updated_at timestamp(6) not null,
description TEXT,
name varchar(255) not null,
primary key (id)
);
create table quest_progression (
chapter_id bigint not null,
id bigint generated by default as identity,
playthrough_id bigint not null,
status varchar(16) not null check (status in ('NOT_STARTED','IN_PROGRESS','COMPLETED')),
primary key (id),
constraint uk_quest_progression_unique unique (playthrough_id, chapter_id)
);
create table random_table_entries (
max_roll integer not null,
min_roll integer not null,
position integer not null,
id bigint generated by default as identity,
random_table_id bigint not null,
detail TEXT,
label varchar(255) not null,
primary key (id)
);
create table random_tables (
"order" integer not null,
campaign_id bigint not null,
created_at timestamp(6) not null,
id bigint generated by default as identity,
updated_at timestamp(6) not null,
dice_formula varchar(32) not null,
icon varchar(64),
description TEXT,
name varchar(255) not null,
primary key (id)
);
create table scenes (
"order" integer not null,
chapter_id bigint not null,
created_at timestamp(6) not null,
id bigint generated by default as identity,
updated_at timestamp(6) not null,
atmosphere TEXT,
branches TEXT,
choices_consequences TEXT,
combat_difficulty TEXT,
description TEXT,
enemies TEXT,
enemy_ids TEXT,
gm_secret_notes TEXT,
icon varchar(255),
illustration_image_ids TEXT,
location TEXT,
map_image_ids TEXT,
name varchar(255) not null,
player_narration TEXT,
related_page_ids TEXT,
rooms TEXT,
timing TEXT,
primary key (id)
);
create table session_entries (
created_at timestamp(6) not null,
id bigint generated by default as identity,
occurred_at timestamp(6) not null,
updated_at timestamp(6) not null,
type varchar(32) not null check (type in ('NOTE','EVENT','DICE_ROLL','PLAYER_ACTION')),
content TEXT not null,
session_id varchar(255) not null,
primary key (id)
);
create table sessions (
created_at timestamp(6) not null,
ended_at timestamp(6),
id bigint generated by default as identity,
playthrough_id bigint,
started_at timestamp(6) not null,
updated_at timestamp(6) not null,
campaign_id varchar(255),
name varchar(255) not null,
primary key (id)
);
create table templates (
created_at timestamp(6) not null,
default_node_id bigint,
id bigint generated by default as identity,
lore_id bigint not null,
updated_at timestamp(6) not null,
description TEXT,
fields TEXT,
name varchar(255) not null,
primary key (id)
);
create index idx_catalog_items_catalog_id
on catalog_items (catalog_id);
create index idx_conv_lore_entity
on conversations (lore_id, entity_type, entity_id, updated_at);
create index idx_conv_campaign_entity
on conversations (campaign_id, entity_type, entity_id, updated_at);
create index idx_enemies_campaign_id
on enemies (campaign_id);
create index idx_item_catalogs_campaign_id
on item_catalogs (campaign_id);
create index idx_notebook_messages_notebook_id
on notebook_messages (notebook_id);
create index idx_notebook_sources_notebook_id
on notebook_sources (notebook_id);
create index idx_notebooks_campaign_id
on notebooks (campaign_id);
create index ix_playthrough_flag_playthrough
on playthrough_flag (playthrough_id);
create index ix_playthrough_campaign
on playthroughs (campaign_id);
create index ix_quest_progression_playthrough
on quest_progression (playthrough_id);
create index idx_random_table_entries_table_id
on random_table_entries (random_table_id);
create index idx_session_entries_session_id
on session_entries (session_id);
alter table if exists catalog_items
add constraint FK7tuoq6mwuc00yv0hhpiw4aoni
foreign key (catalog_id)
references item_catalogs;
alter table if exists conversation_messages
add constraint FKcr8qqgnqnaqq2hw3gr4wtfe2a
foreign key (conversation_id)
references conversations;
alter table if exists random_table_entries
add constraint FKly4syvpfit2kibfjut67xxrrq
foreign key (random_table_id)
references random_tables;

View File

@@ -0,0 +1,143 @@
package com.loremind.infrastructure.transfer;
import com.loremind.domain.campaigncontext.ArcType;
import com.loremind.domain.campaigncontext.Prerequisite;
import com.loremind.domain.campaigncontext.SceneBranch;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
/**
* Tests unitaires PURS (sans Spring/DB) de {@link IdRemapper}, la logique de
* remapping {@code oldId → newId} de l'import en mode FUSION. Verrouille les
* invariants critiques : une référence absente de la map est CONSERVÉE, jamais
* perdue ni remplacée par null.
*/
class IdRemapperTest {
private static final Map<Long, Long> MAP = Map.of(1L, 100L, 2L, 200L);
// --- remapId (FK Long) ---------------------------------------------------
@Test
void remapId_null_resteNull() {
assertNull(IdRemapper.remapId(MAP, null));
}
@Test
void remapId_present_estRemappe() {
assertEquals(100L, IdRemapper.remapId(MAP, 1L));
}
@Test
void remapId_absent_conserveLAncienneValeur() {
assertEquals(42L, IdRemapper.remapId(MAP, 42L));
}
// --- remapStringId (ref faible String) -----------------------------------
@Test
void remapStringId_null_resteNull() {
assertNull(IdRemapper.remapStringId(MAP, null));
}
@Test
void remapStringId_blanc_resteInchange() {
assertEquals(" ", IdRemapper.remapStringId(MAP, " "));
}
@Test
void remapStringId_numeriquePresent_estRemappe() {
assertEquals("200", IdRemapper.remapStringId(MAP, "2"));
}
@Test
void remapStringId_numeriqueAvecEspaces_estTrimmeEtRemappe() {
assertEquals("100", IdRemapper.remapStringId(MAP, " 1 "));
}
@Test
void remapStringId_numeriqueAbsent_conserveLAncien() {
assertEquals("999", IdRemapper.remapStringId(MAP, "999"));
}
@Test
void remapStringId_nonNumerique_resteInchange() {
assertEquals("abc-uuid", IdRemapper.remapStringId(MAP, "abc-uuid"));
}
// --- remapStringList -----------------------------------------------------
@Test
void remapStringList_null_resteNull() {
assertNull(IdRemapper.remapStringList(MAP, null));
}
@Test
void remapStringList_remappeChaqueElement() {
assertEquals(List.of("100", "999", "200"),
IdRemapper.remapStringList(MAP, List.of("1", "999", "2")));
}
// --- remapPrerequisites --------------------------------------------------
@Test
void remapPrerequisites_null_resteNull() {
assertNull(IdRemapper.remapPrerequisites(MAP, null));
}
@Test
void remapPrerequisites_questCompleted_remappeLeQuestId() {
List<Prerequisite> out = IdRemapper.remapPrerequisites(
MAP, List.of(new Prerequisite.QuestCompleted("1")));
assertEquals("100", ((Prerequisite.QuestCompleted) out.get(0)).questId());
}
@Test
void remapPrerequisites_autresTypes_inchanges() {
Prerequisite flag = new Prerequisite.FlagSet("porte_ouverte");
Prerequisite session = new Prerequisite.SessionReached(3);
List<Prerequisite> out = IdRemapper.remapPrerequisites(MAP, List.of(flag, session));
// FlagSet / SessionReached ne sont pas réécrits : même instance.
assertSame(flag, out.get(0));
assertSame(session, out.get(1));
}
// --- remapBranches -------------------------------------------------------
@Test
void remapBranches_null_resteNull() {
assertNull(IdRemapper.remapBranches(MAP, null));
}
@Test
void remapBranches_remappeTargetSceneId_etPreserveLabelEtCondition() {
SceneBranch in = new SceneBranch("Si attaque", "2", "notes MJ");
SceneBranch out = IdRemapper.remapBranches(MAP, List.of(in)).get(0);
assertEquals("Si attaque", out.label());
assertEquals("200", out.targetSceneId());
assertEquals("notes MJ", out.condition());
}
// --- parseArcType --------------------------------------------------------
@Test
void parseArcType_null_donneLinear() {
assertEquals(ArcType.LINEAR, IdRemapper.parseArcType(null));
}
@Test
void parseArcType_valeurValide_estParsee() {
assertEquals(ArcType.HUB, IdRemapper.parseArcType("HUB"));
}
@Test
void parseArcType_valeurInconnue_retombeSurLinear() {
assertEquals(ArcType.LINEAR, IdRemapper.parseArcType("INEXISTANT"));
}
}

View File

@@ -0,0 +1,62 @@
package com.loremind.infrastructure.updates;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests des utilitaires semver (extraits de UpdateCheckServiceTest lors de la
* séparation de {@link SemverComparator}).
*/
class SemverComparatorTest {
@Test
void parseSemver_acceptsCommonFormats() {
assertArrayEquals(new int[]{0, 8, 0}, SemverComparator.parseSemver("0.8.0"));
assertArrayEquals(new int[]{0, 8, 0}, SemverComparator.parseSemver("v0.8.0"));
assertArrayEquals(new int[]{1, 0, 0}, SemverComparator.parseSemver("1.0.0"));
assertArrayEquals(new int[]{0, 8, 0}, SemverComparator.parseSemver("0.8.0-beta.1"));
assertArrayEquals(new int[]{0, 8, 0}, SemverComparator.parseSemver("0.8.0+build.42"));
}
@Test
void parseSemver_rejectsInvalid() {
assertNull(SemverComparator.parseSemver(null));
assertNull(SemverComparator.parseSemver(""));
assertNull(SemverComparator.parseSemver("latest"));
assertNull(SemverComparator.parseSemver("stable"));
assertNull(SemverComparator.parseSemver("0.8.0.1.2"));
assertNull(SemverComparator.parseSemver("0.x.0"));
}
@Test
void compareSemver_basic() {
assertTrue(SemverComparator.compareSemver("0.7.2", "0.8.0") < 0);
assertTrue(SemverComparator.compareSemver("0.8.0", "0.7.2") > 0);
assertEquals(0, SemverComparator.compareSemver("0.8.0", "0.8.0"));
assertEquals(0, SemverComparator.compareSemver("v0.8.0", "0.8.0"));
assertTrue(SemverComparator.compareSemver("0.8.0", "0.10.0") < 0);
assertTrue(SemverComparator.compareSemver("1.0.0", "0.99.99") > 0);
}
@Test
void findMaxSemver_picksHighest() {
assertEquals("0.8.0", SemverComparator.findMaxSemver(
List.of("0.7.0", "0.7.1", "0.7.2", "0.8.0", "latest")));
assertEquals("0.10.0", SemverComparator.findMaxSemver(
List.of("0.8.0", "0.10.0", "0.9.5")));
assertEquals("v1.0.0", SemverComparator.findMaxSemver(
List.of("v0.8.0", "v1.0.0", "latest")));
}
@Test
void findMaxSemver_returnsNullWhenNoValidTag() {
assertNull(SemverComparator.findMaxSemver(List.of("latest", "stable", "main")));
assertNull(SemverComparator.findMaxSemver(List.of()));
}
}

View File

@@ -1,6 +1,5 @@
package com.loremind.infrastructure.updates;
import com.loremind.application.licensing.LicenseService;
import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatus;
import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatusKind;
import com.loremind.infrastructure.updates.UpdateCheckService.TagsListResponse;
@@ -194,52 +193,6 @@ public class UpdateCheckServiceTest {
}
}
// -----------------------------------------------------------------
// Utilitaires semver
// -----------------------------------------------------------------
@Test
void parseSemver_acceptsCommonFormats() {
assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("0.8.0"));
assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("v0.8.0"));
assertArrayEquals(new int[]{1, 0, 0}, UpdateCheckService.parseSemver("1.0.0"));
assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("0.8.0-beta.1"));
assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("0.8.0+build.42"));
}
@Test
void parseSemver_rejectsInvalid() {
assertNull(UpdateCheckService.parseSemver(null));
assertNull(UpdateCheckService.parseSemver(""));
assertNull(UpdateCheckService.parseSemver("latest"));
assertNull(UpdateCheckService.parseSemver("stable"));
assertNull(UpdateCheckService.parseSemver("0.8.0.1.2"));
assertNull(UpdateCheckService.parseSemver("0.x.0"));
}
@Test
void compareSemver_basic() {
assertTrue(UpdateCheckService.compareSemver("0.7.2", "0.8.0") < 0);
assertTrue(UpdateCheckService.compareSemver("0.8.0", "0.7.2") > 0);
assertEquals(0, UpdateCheckService.compareSemver("0.8.0", "0.8.0"));
assertEquals(0, UpdateCheckService.compareSemver("v0.8.0", "0.8.0"));
assertTrue(UpdateCheckService.compareSemver("0.8.0", "0.10.0") < 0);
assertTrue(UpdateCheckService.compareSemver("1.0.0", "0.99.99") > 0);
}
@Test
void findMaxSemver_picksHighest() {
assertEquals("0.8.0", UpdateCheckService.findMaxSemver(
List.of("0.7.0", "0.7.1", "0.7.2", "0.8.0", "latest")));
assertEquals("0.10.0", UpdateCheckService.findMaxSemver(
List.of("0.8.0", "0.10.0", "0.9.5")));
assertEquals("v1.0.0", UpdateCheckService.findMaxSemver(
List.of("v0.8.0", "v1.0.0", "latest")));
}
@Test
void findMaxSemver_returnsNullWhenNoValidTag() {
assertNull(UpdateCheckService.findMaxSemver(List.of("latest", "stable", "main")));
assertNull(UpdateCheckService.findMaxSemver(List.of()));
}
// NB : les utilitaires semver (parseSemver / findMaxSemver / compareSemver) sont
// désormais dans SemverComparator et testés par SemverComparatorTest.
}

View File

@@ -0,0 +1,45 @@
package com.loremind.infrastructure.updates;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests du parseur de challenge {@code WWW-Authenticate} ({@link WwwAuthenticate}),
* jusque-là non couvert quand il était privé dans UpdateCheckService.
*/
class WwwAuthenticateTest {
@Test
void parse_challengeGhcrComplet() {
// Forme typique renvoyée par ghcr.io / Docker Hub.
Map<String, String> p = WwwAuthenticate.parseParams(
"realm=\"https://ghcr.io/token\",service=\"ghcr.io\",scope=\"repository:org/img:pull\"");
assertEquals("https://ghcr.io/token", p.get("realm"));
assertEquals("ghcr.io", p.get("service"));
assertEquals("repository:org/img:pull", p.get("scope"));
}
@Test
void parse_valeursNonQuotees() {
Map<String, String> p = WwwAuthenticate.parseParams("realm=https://r/token, service=reg");
assertEquals("https://r/token", p.get("realm"));
assertEquals("reg", p.get("service"));
}
@Test
void parse_chaineVide_donneMapVide() {
assertTrue(WwwAuthenticate.parseParams("").isEmpty());
}
@Test
void parse_guillemetNonFerme_sArreteProprement() {
// Valeur ouverte sans guillemet fermant : on s'arrête sans planter.
Map<String, String> p = WwwAuthenticate.parseParams("realm=\"https://r/token");
assertNull(p.get("realm"));
}
}

View File

@@ -14,6 +14,10 @@ spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=false
# Flyway desactive en test : le schema est gere par Hibernate create-drop
# (recree a chaque run), pas par les migrations. Evite tout conflit Flyway/DDL.
spring.flyway.enabled=false
# Pool Hikari volontairement minuscule en test : la suite cree de nombreux
# contextes Spring distincts (combinaisons de @MockBean / @TestPropertySource),
# tous gardes en cache simultanement. Avec le pool par defaut (10), on epuisait

View File

@@ -1,7 +1,10 @@
# Copie en .env sur le serveur (jamais commite).
# Registre et tag des images core / brain a spawner par session.
REGISTRY=git.igmlcreation.fr
# Registre, namespace et tag des images core / brain a spawner par session.
# Doit pointer sur le registre ou la CI publie reellement (ghcr.io).
# Le slash final de IMAGE_NAMESPACE est important : image = REGISTRY/NAMESPACEcore:TAG
REGISTRY=ghcr.io
IMAGE_NAMESPACE=igmlcreation/loremind-
TAG=latest
# Secret partage entre core et brain (genere aleatoirement au build de chaque

View File

@@ -10,6 +10,7 @@ import (
// Config centralise les parametres lus depuis les variables d'env au boot.
type Config struct {
Registry string
Namespace string
Tag string
MaxSessions int
SessionTTL time.Duration
@@ -27,7 +28,12 @@ type Config struct {
func loadConfig() *Config {
return &Config{
Registry: envStr("REGISTRY", "git.igmlcreation.fr"),
// Aligne sur la nouvelle convention de publication des images
// (ghcr.io/igmlcreation/loremind-{core,brain}). L'ancien registre Gitea
// (git.igmlcreation.fr/ietm64/*) n'est plus republie : il restait gele
// sur une vieille version. Le slash final de Namespace est volontaire.
Registry: envStr("REGISTRY", "ghcr.io"),
Namespace: envStr("IMAGE_NAMESPACE", "igmlcreation/loremind-"),
Tag: envStr("TAG", "latest"),
MaxSessions: envInt("MAX_SESSIONS", 10),
SessionTTL: time.Duration(envInt("SESSION_TTL_MINUTES", 20)) * time.Minute,

View File

@@ -111,7 +111,7 @@ func (d *DockerClient) SpawnTrio(ctx context.Context, sessionID string, cfg *Con
if err := d.runContainer(ctx, runSpec{
Name: brainName,
Image: cfg.Registry + "/ietm64/brain:" + cfg.Tag,
Image: cfg.Registry + "/" + cfg.Namespace + "brain:" + cfg.Tag,
Env: []string{
"INTERNAL_SHARED_SECRET=" + brainSecret,
// Pas de provider LLM configure en demo : les features IA echoueront
@@ -129,7 +129,7 @@ func (d *DockerClient) SpawnTrio(ctx context.Context, sessionID string, cfg *Con
if err := d.runContainer(ctx, runSpec{
Name: coreName,
Image: cfg.Registry + "/ietm64/core:" + cfg.Tag,
Image: cfg.Registry + "/" + cfg.Namespace + "core:" + cfg.Tag,
Env: []string{
"SPRING_DATASOURCE_URL=jdbc:postgresql://" + pgName + ":5432/loremind",
"SPRING_DATASOURCE_USERNAME=loremind",

View File

@@ -252,7 +252,11 @@ services:
# API HTTP pour declenchement manuel via le bouton UI (Core -> Watchtower).
WATCHTOWER_HTTP_API_UPDATE: "true"
WATCHTOWER_HTTP_API_PERIODIC_POLLS: "true"
WATCHTOWER_HTTP_API_TOKEN: "${WATCHTOWER_TOKEN:?set WATCHTOWER_TOKEN in .env (re-run installer)}"
# Pas de ":?" ici : Compose interpole TOUT le fichier avant de filtrer
# par profile, donc un ":?" planterait le "up" meme quand autoupdate est
# inactif. L'installeur genere toujours WATCHTOWER_TOKEN ; defaut vide
# pour ne rien casser sur les deploiements sans autoupdate.
WATCHTOWER_HTTP_API_TOKEN: "${WATCHTOWER_TOKEN:-}"
WATCHTOWER_TIMEOUT: 60s
WATCHTOWER_NOTIFICATIONS_LEVEL: info
TZ: ${TZ:-Europe/Paris}

View File

@@ -0,0 +1,243 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Construit l'installeur de BUREAU Windows de LoreMind (.msi), sans Docker.
.DESCRIPTION
Pipeline complet "local-first" :
1. Build du front Angular (web/ -> web/dist/web)
2. Prep du Brain (Python embeddable) (brain/ -> dist-embed : python.exe signe PSF + deps + sources)
3. Build du Core en fat jar + front (core/ -> target/*.jar, profil Maven "desktop")
4. Assemblage de la charge utile (jar + brain) dans un dossier d'entree jpackage
5. jpackage -> installeur .msi avec JRE embarque
L'app resultante se lance d'un double-clic : le Core demarre en profil Spring
"local" (H2 fichier + stockage filesystem) et lance lui-meme le Brain en sidecar.
Aucune dependance externe a installer cote utilisateur (ni Docker, ni Java, ni Python).
L'IA fonctionne via le cloud (1min.ai / Gemini / Mistral...) ou via Ollama si present.
.PARAMETER Version
Version de l'installeur (X.Y.Z, numerique). Defaut : derivee de core/pom.xml
(le suffixe -beta est retire car les MSI n'acceptent qu'une version numerique).
.PARAMETER SkipFront / SkipBrain / SkipJar
Sauter une etape (build incrementaux pendant la mise au point).
.PREREQUIS (sur la machine de build uniquement, PAS chez l'utilisateur final)
- JDK 21+ avec jpackage dans le PATH (Temurin OK).
- WiX Toolset v3 (https://github.com/wixtoolset/wix3/releases) — requis par
jpackage pour produire un .msi sur Windows.
- Node.js + npm (build Angular).
- Python + pip (telecharge les wheels cp312 du Brain ; toute version 3.x convient,
on cible explicitement 3.12 via --python-version) + acces Internet (python.org).
.NOTES
Projet : LoreMind — assistant pour Maitres de Jeu de JDR
Licence : AGPL-3.0
#>
[CmdletBinding()]
param(
[string]$Version,
[switch]$SkipFront,
[switch]$SkipBrain,
[switch]$SkipJar
)
$ErrorActionPreference = 'Stop'
function Write-Step($m) { Write-Host "==> $m" -ForegroundColor Cyan }
function Write-Ok($m) { Write-Host " OK $m" -ForegroundColor Green }
function Write-Err($m) { Write-Host " XX $m" -ForegroundColor Red }
# --- Chemins ---------------------------------------------------------------
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$WebDir = Join-Path $RepoRoot 'web'
$BrainDir = Join-Path $RepoRoot 'brain'
$CoreDir = Join-Path $RepoRoot 'core'
$StageDir = Join-Path $CoreDir 'target\dist-input' # charge utile jpackage
$OutDir = Join-Path $CoreDir 'target\dist-out' # .msi produit
# --- Version (numerique pour MSI) ------------------------------------------
if (-not $Version) {
$pom = Get-Content (Join-Path $CoreDir 'pom.xml') -Raw
# IMPORTANT : viser la version DU PROJET, pas le premier <version> du fichier
# (qui est celui du parent spring-boot-starter-parent). On l'ancre sur
# l'artifactId du projet, puis on garde la partie numerique (MSI = X.Y.Z).
if ($pom -match '<artifactId>loremind-core</artifactId>\s*<version>([0-9]+\.[0-9]+\.[0-9]+)') {
$Version = $Matches[1]
} else {
$Version = '0.0.0'
}
}
Write-Host ""
Write-Host "============================================================" -ForegroundColor Magenta
Write-Host " LoreMind - Build installeur bureau Windows (v$Version)" -ForegroundColor Magenta
Write-Host "============================================================" -ForegroundColor Magenta
# --- Verif outils ----------------------------------------------------------
if (-not (Get-Command jpackage -ErrorAction SilentlyContinue)) {
Write-Err "jpackage introuvable dans le PATH. Installez un JDK 21+ (Temurin)."
exit 1
}
# --- 1. Front Angular ------------------------------------------------------
if (-not $SkipFront) {
Write-Step "Build du front Angular"
Push-Location $WebDir
try {
if (-not (Test-Path 'node_modules')) { npm ci }
npm run build
if ($LASTEXITCODE -ne 0) { throw "Echec du build Angular" }
} finally { Pop-Location }
Write-Ok "Front construit (web/dist/web)"
} else { Write-Step "Front : saute (--SkipFront)" }
# --- 2. Brain (Python embeddable officiel, PAS d'exe gele) -----------------
# On embarque le Python *embeddable* de python.org (python.exe SIGNE par la PSF)
# + les dependances + les sources .py. Aucun executable "gele" type PyInstaller
# -> plus de faux positif antivirus (le bootloader packe etait pris pour un
# trojan). Le Core lancera : brain\python\python.exe brain\run_local.py
$PyVersion = '3.12.8' # doit matcher python:3.12 du Docker
$PyTag = 'python312' # prefixe du fichier ._pth
$BrainEmbed = Join-Path $BrainDir 'dist-embed' # staging du brain empaquete
if (-not $SkipBrain) {
Write-Step "Preparation du Brain (Python embeddable $PyVersion)"
if (Test-Path $BrainEmbed) { Remove-Item $BrainEmbed -Recurse -Force }
$PyDir = Join-Path $BrainEmbed 'python'
New-Item -ItemType Directory -Force -Path $PyDir | Out-Null
# a) Telecharger + extraire le Python embeddable (zip officiel).
$zip = Join-Path $BrainEmbed 'python-embed.zip'
$url = "https://www.python.org/ftp/python/$PyVersion/python-$PyVersion-embed-amd64.zip"
Write-Host " Telechargement $url"
Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing
Expand-Archive -Path $zip -DestinationPath $PyDir -Force
Remove-Item $zip -Force
# b) Activer site-packages dans le ._pth (sinon les deps ne sont pas importees).
$pth = Join-Path $PyDir "$PyTag._pth"
Set-Content -Path $pth -Encoding ascii -Value @(
"$PyTag.zip", '.', 'Lib\site-packages', 'import site'
)
# c) Installer les deps en wheels cp312 dans python\Lib\site-packages.
# --python-version 3.12 + --only-binary : on telecharge les roues 3.12
# meme si le pip courant tourne sous une autre version de Python.
$site = Join-Path $PyDir 'Lib\site-packages'
New-Item -ItemType Directory -Force -Path $site | Out-Null
Push-Location $BrainDir
try {
python -m pip install --upgrade pip *> $null
python -m pip install --target $site --only-binary=:all: --python-version 3.12 -r requirements.txt
if ($LASTEXITCODE -ne 0) { throw "Echec pip install (deps Brain)" }
} finally { Pop-Location }
# d) Copier les sources du Brain + le point d'entree.
Copy-Item (Join-Path $BrainDir 'app') (Join-Path $BrainEmbed 'app') -Recurse
Copy-Item (Join-Path $BrainDir 'run_local.py') (Join-Path $BrainEmbed 'run_local.py')
# e) Tesseract (OCR des PDF scannes). Pas de zip portable officiel : on COPIE
# l'install UB-Mannheim (tesseract.exe + DLL), prerequis a installer une
# fois via `choco install tesseract`. Langues fra+eng tirees de tessdata_fast
# (leger, coherent). Si Tesseract n'est pas installe -> skip gracieux :
# l'app reste fonctionnelle, seul l'OCR des scans manquera (cf. run_local.py
# + pdf_extractor.py qui detectent l'absence et degradent proprement).
$tessSrc = Join-Path $env:ProgramFiles 'Tesseract-OCR'
if (Test-Path (Join-Path $tessSrc 'tesseract.exe')) {
$tessDst = Join-Path $BrainEmbed 'tesseract'
Copy-Item $tessSrc $tessDst -Recurse
New-Item -ItemType Directory -Force -Path (Join-Path $tessDst 'tessdata') | Out-Null
foreach ($lang in @('fra','eng')) {
Invoke-WebRequest -UseBasicParsing `
-Uri "https://github.com/tesseract-ocr/tessdata_fast/raw/main/$lang.traineddata" `
-OutFile (Join-Path $tessDst "tessdata\$lang.traineddata")
}
Write-Ok "Tesseract bundle (OCR des scans actif)"
} else {
Write-Host " !! Tesseract introuvable ($tessSrc) -> OCR des scans NON bundle (degradation gracieuse). Pour l'activer : choco install tesseract" -ForegroundColor Yellow
}
Write-Ok "Brain prepare (brain/dist-embed : python embeddable + deps + sources)"
} else { Write-Step "Brain : saute (--SkipBrain)" }
# --- 3. Core (fat jar avec front embarque) ---------------------------------
if (-not $SkipJar) {
Write-Step "Build du Core (fat jar, profil desktop)"
Push-Location $CoreDir
try {
# -Pdesktop : copie web/dist/web dans le jar (classpath:/static).
cmd /c "mvn -q -Pdesktop -DskipTests clean package"
if ($LASTEXITCODE -ne 0) { throw "Echec du build Maven" }
} finally { Pop-Location }
Write-Ok "Core construit (core/target)"
} else { Write-Step "Core : saute (--SkipJar)" }
# --- 4. Assemblage de la charge utile --------------------------------------
Write-Step "Assemblage de la charge utile jpackage"
if (Test-Path $StageDir) { Remove-Item $StageDir -Recurse -Force }
New-Item -ItemType Directory -Force -Path $StageDir | Out-Null
# Le jar repackage Spring Boot (executable) — on ignore le *.jar.original.
$jar = Get-ChildItem (Join-Path $CoreDir 'target') -Filter 'loremind-core-*.jar' |
Where-Object { $_.Name -notlike '*.original' } | Select-Object -First 1
if (-not $jar) { Write-Err "Jar introuvable dans core/target. Lancez sans --SkipJar."; exit 1 }
Copy-Item $jar.FullName (Join-Path $StageDir 'loremind-core.jar')
# Le Brain (python embeddable + deps + sources) -> stage/brain/
# stage/brain/python/python.exe , stage/brain/app , stage/brain/run_local.py
if (-not (Test-Path $BrainEmbed)) { Write-Err "Brain introuvable (brain/dist-embed). Lancez sans --SkipBrain."; exit 1 }
$stageBrain = Join-Path $StageDir 'brain'
New-Item -ItemType Directory -Force -Path $stageBrain | Out-Null
Copy-Item (Join-Path $BrainEmbed '*') $stageBrain -Recurse
Write-Ok "Charge utile prete ($StageDir)"
# --- 5. jpackage -> .msi ---------------------------------------------------
Write-Step "Generation de l'installeur (.msi) via jpackage"
if (Test-Path $OutDir) { Remove-Item $OutDir -Recurse -Force }
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
# $APPDIR est substitue par jpackage au LANCEMENT par le dossier 'app' de
# l'install (qui contient le jar ET le dossier brain copie depuis --input).
# Le Brain se lance via le python embeddable + run_local.py. brain.sidecar.command
# est une liste : les deux chemins separes par une virgule (aucun chemin Windows
# ne contient de virgule) sont bindes en List<String> par Spring.
$brainCmd = '$APPDIR\brain\python\python.exe,$APPDIR\brain\run_local.py'
# --win-upgrade-uuid : UUID STABLE du produit. Permet a Windows Installer de
# reconnaitre qu'une nouvelle version est une MISE A JOUR du meme produit -> upgrade
# en place propre (pas besoin de desinstaller avant, pas de doublon). Les donnees
# (base H2, images) vivent de toute facon dans ~/.loremind, HORS du dossier
# d'installation, donc jamais touchees par l'installeur.
# /!\ NE JAMAIS CHANGER cet UUID : le modifier casserait la chaine d'upgrade.
#
# Note : pas de --win-console (app de bureau). Les logs Spring/Brain peuvent
# etre rediriges vers un fichier via une option ulterieure si besoin de debug.
jpackage `
--type msi `
--name LoreMind `
--app-version $Version `
--vendor 'IGML Creation' `
--input $StageDir `
--main-jar loremind-core.jar `
--main-class org.springframework.boot.loader.launch.JarLauncher `
--dest $OutDir `
--java-options '-Dspring.profiles.active=local' `
--java-options "-Dbrain.sidecar.command=$brainCmd" `
--win-per-user-install `
--win-upgrade-uuid 'a7c4e1d2-9b3f-4e6a-8d05-1f2c3b4a5e6d' `
--win-menu --win-menu-group 'LoreMind' `
--win-shortcut --win-dir-chooser
if ($LASTEXITCODE -ne 0) {
Write-Err "Echec jpackage. Cause probable : WiX Toolset v3 absent."
Write-Err "Installez-le : https://github.com/wixtoolset/wix3/releases"
exit 1
}
$msi = Get-ChildItem $OutDir -Filter '*.msi' | Select-Object -First 1
Write-Host ""
Write-Host "============================================================" -ForegroundColor Green
Write-Host " Installeur genere !" -ForegroundColor Green
Write-Host " $($msi.FullName)" -ForegroundColor Green
Write-Host "============================================================" -ForegroundColor Green

View File

@@ -34,6 +34,18 @@ server {
try_files $uri =404;
}
# Fichiers de traduction (assets/i18n/*.json) : nom STABLE (non hashe),
# donc JAMAIS caches — sinon le navigateur ressert un vieux fr.json apres une
# mise a jour et affiche des cles brutes (ex : settings.data.* en clair).
# Aligne sur le mode desktop (LocalWebConfig sert deja l'i18n en no-cache).
# ^~ : ce prefixe l'emporte sur la regex d'assets ci-dessous.
location ^~ /assets/i18n/ {
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header Pragma "no-cache" always;
expires 0;
try_files $uri =404;
}
# Assets Angular avec hash dans le nom (main.<hash>.js, etc.) :
# immuables, peuvent etre caches longtemps.
location ~* \.(?:js|css|woff2?|ttf|svg|png|jpg|jpeg|webp|ico)$ {

505
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "loremind-web",
"version": "0.14.0-beta",
"version": "0.16.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "loremind-web",
"version": "0.14.0-beta",
"version": "0.16.2",
"dependencies": {
"@angular/animations": "^21.2.16",
"@angular/common": "^21.2.16",
@@ -32,7 +32,9 @@
"@emnapi/core": "^1.11.0",
"@emnapi/runtime": "^1.11.0",
"@playwright/test": "^1.59.1",
"typescript": "~5.9.3"
"@vitest/coverage-v8": "^4.1.9",
"typescript": "~5.9.3",
"vitest": "^4.1.9"
}
},
"node_modules/@algolia/abtesting": {
@@ -2518,6 +2520,16 @@
"node": ">=6.9.0"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@discoveryjs/json-ext": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz",
@@ -5988,6 +6000,17 @@
"@types/node": "*"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
@@ -6009,6 +6032,13 @@
"@types/node": "*"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/eslint": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@@ -6101,7 +6131,6 @@
"integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
@@ -6210,6 +6239,158 @@
"vite": "^6.0.0 || ^7.0.0"
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz",
"integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.9",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.2.0",
"magicast": "^0.5.2",
"obug": "^2.1.1",
"std-env": "^4.0.0-rc.1",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.1.9",
"vitest": "4.1.9"
},
"peerDependenciesMeta": {
"@vitest/browser": {
"optional": true
}
}
},
"node_modules/@vitest/expect": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
"integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.9",
"@vitest/utils": "4.1.9",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
"integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.9",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
"integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
"integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.9",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
"integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.9",
"@vitest/utils": "4.1.9",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
"integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
"integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.9",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils/node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT"
},
"node_modules/@webassemblyjs/ast": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
@@ -6678,6 +6859,35 @@
"node": ">=12.0.0"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/ast-v8-to-istanbul": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz",
"integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"estree-walker": "^3.0.3",
"js-tokens": "^10.0.0"
}
},
"node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
"dev": true,
"license": "MIT"
},
"node_modules/autoprefixer": {
"version": "10.4.27",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
@@ -7099,6 +7309,16 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
@@ -8136,6 +8356,16 @@
"node": ">=4.0"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -8196,6 +8426,16 @@
"node": ">=18.0.0"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/exponential-backoff": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
@@ -8747,6 +8987,13 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
@@ -9287,6 +9534,64 @@
"node": ">=10"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-lib-report/node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/istanbul-lib-report/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/jest-worker": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz",
@@ -9308,7 +9613,6 @@
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
@@ -9804,6 +10108,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/magicast": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz",
"integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.3",
"@babel/types": "^7.29.0",
"source-map-js": "^1.2.1"
}
},
"node_modules/make-dir": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz",
@@ -10612,6 +10928,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/obug": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
"integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -11009,6 +11339,13 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -11798,7 +12135,6 @@
"integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
@@ -12281,6 +12617,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -12527,6 +12870,13 @@
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -12537,6 +12887,13 @@
"node": ">= 0.8"
}
},
"node_modules/std-env": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/stdin-discarder": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz",
@@ -12669,7 +13026,6 @@
"integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.15.0",
@@ -12768,6 +13124,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -12785,6 +13158,16 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -13173,6 +13556,97 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/vitest": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
"integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.1.9",
"@vitest/mocker": "4.1.9",
"@vitest/pretty-format": "4.1.9",
"@vitest/runner": "4.1.9",
"@vitest/snapshot": "4.1.9",
"@vitest/spy": "4.1.9",
"@vitest/utils": "4.1.9",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.1.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.9",
"@vitest/browser-preview": "4.1.9",
"@vitest/browser-webdriverio": "4.1.9",
"@vitest/coverage-istanbul": "4.1.9",
"@vitest/coverage-v8": "4.1.9",
"@vitest/ui": "4.1.9",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/watchpack": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz",
@@ -13892,6 +14366,23 @@
"node": ">= 8"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wildcard": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "loremind-web",
"version": "0.14.0-beta",
"version": "0.16.2",
"description": "LoreMind Frontend - Angular",
"scripts": {
"ng": "ng",
@@ -8,6 +8,9 @@
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"test:unit": "vitest run",
"test:unit:watch": "vitest",
"test:unit:coverage": "vitest run --coverage",
"e2e": "playwright test",
"e2e:ui": "playwright test --ui",
"e2e:headed": "playwright test --headed",
@@ -39,6 +42,8 @@
"@emnapi/core": "^1.11.0",
"@emnapi/runtime": "^1.11.0",
"@playwright/test": "^1.59.1",
"typescript": "~5.9.3"
"@vitest/coverage-v8": "^4.1.9",
"typescript": "~5.9.3",
"vitest": "^4.1.9"
}
}

View File

@@ -0,0 +1,81 @@
import { describe, it, expect } from 'vitest';
import { buildCampaignTree, CampaignTreeData } from './campaign-tree.helper';
import { TranslateService } from '@ngx-translate/core';
// TranslateService factice : renvoie la clé telle quelle (suffisant pour vérifier
// la structure de l'arbre sans dépendre des traductions).
const t = { instant: (k: string) => k } as unknown as TranslateService;
function data(partial: Partial<CampaignTreeData> = {}): CampaignTreeData {
return {
arcs: [], chaptersByArc: {}, scenesByChapter: {},
characters: [], npcs: [], randomTables: [], enemies: [],
...partial,
};
}
describe('buildCampaignTree', () => {
it('produit les nœuds fixes (PNJ, ennemis, tables, ateliers, catalogues, import) quand vide', () => {
const tree = buildCampaignTree('c1', data(), t);
expect(tree.map((n) => n.id)).toEqual([
'npcs-root', 'enemies-root', 'random-tables-root',
'notebooks-root', 'item-catalogs-root', 'import-pdf-root',
]);
});
it('trie les arcs en ordre NUMÉRIQUE naturel (1, 2, 10)', () => {
const arcs = [
{ id: 'a', name: '10. Final' },
{ id: 'b', name: '2. Voyage' },
{ id: 'c', name: '1. Intro' },
];
const tree = buildCampaignTree('c1', data({ arcs: arcs as never }), t);
const arcLabels = tree.filter((n) => n.id?.startsWith('arc-')).map((n) => n.label);
expect(arcLabels).toEqual(['1. Intro', '2. Voyage', '10. Final']);
});
it('imbrique chapitres et scènes et marque les chapitres à prérequis', () => {
const tree = buildCampaignTree('camp', data({
arcs: [{ id: 'a1', name: 'Arc', type: 'LINEAR' }] as never,
chaptersByArc: { a1: [{ id: 'c1', name: 'Chap', prerequisites: [{}] }] } as never,
scenesByChapter: { c1: [{ id: 's1', name: 'Scene' }] } as never,
}), t);
const arc = tree.find((n) => n.id === 'arc-a1')!;
const chapter = arc.children![0];
expect(chapter.id).toBe('chapter-c1');
expect(chapter.meta).toBe('🔒'); // cadenas si prérequis
expect(chapter.children![0].id).toBe('scene-s1');
});
it('regroupe les PNJ par dossier et laisse les non classés à la racine', () => {
const npcs = [
{ id: 'n1', name: 'Alice', folder: 'Ville' },
{ id: 'n2', name: 'Bob', folder: 'Ville' },
{ id: 'n3', name: 'Carl' },
];
const tree = buildCampaignTree('c', data({ npcs: npcs as never }), t);
const npcsRoot = tree.find((n) => n.id === 'npcs-root')!;
expect(npcsRoot.meta).toBe('3');
const folder = npcsRoot.children!.find((c) => c.id === 'npc-folder-Ville')!;
expect(folder.children!.map((c) => c.label)).toEqual(['Alice', 'Bob']);
expect(npcsRoot.children!.some((c) => c.id === 'npc-n3')).toBe(true);
});
it("affiche le niveau de l'ennemi en méta", () => {
const tree = buildCampaignTree('c', data({
enemies: [{ id: 'e1', name: 'Gobelin', level: 3 }] as never,
}), t);
const enemiesRoot = tree.find((n) => n.id === 'enemies-root')!;
expect(enemiesRoot.children![0].meta).toBe('Niv. 3');
});
it('libelle « nouvelle quête » dans un arc HUB (vs « nouveau chapitre »)', () => {
const hub = buildCampaignTree('c', data({ arcs: [{ id: 'a', name: 'Hub', type: 'HUB' }] as never }), t)
.find((n) => n.id === 'arc-a')!;
expect(hub.createActions![0].label).toBe('campaignTree.newQuest');
const linear = buildCampaignTree('c', data({ arcs: [{ id: 'a', name: 'Lin', type: 'LINEAR' }] as never }), t)
.find((n) => n.id === 'arc-a')!;
expect(linear.createActions![0].label).toBe('campaignTree.newChapter');
});
});

View File

@@ -329,7 +329,16 @@ export class GameSystemEditComponent implements OnInit {
const valid = this.sections.filter(s => s.title.trim() || s.content.trim());
if (valid.length === 0) return null;
return valid
.map(s => `## ${s.title.trim() || 'Sans titre'}\n${s.content.trim()}`)
.map(s => {
// Le "## " est RESERVE aux titres de section (parseMarkdown decoupe dessus,
// tout comme le parser Java cote backend). Or le contenu genere par l'IA
// contient souvent des sous-titres "## " : sans rien faire, ils seraient
// re-interpretes comme de nouvelles sections au rechargement -> explosion
// de sections + sections vides. On les retrograde donc en "### " (un niveau
// plus bas) pour garder un round-trip stable. (### / #### ne collisionnent pas.)
const content = s.content.trim().replace(/^##[ \t]+/gm, '### ');
return `## ${s.title.trim() || 'Sans titre'}\n${content}`;
})
.join('\n\n');
}
}

View File

@@ -1,6 +1,5 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { LanguageService } from '../services/language.service';
import { STORAGE_KEY } from '../services/language.service';
/**
* Ajoute l'entête `X-User-Language` (langue choisie dans l'UI : `fr`/`en`) à
@@ -9,10 +8,27 @@ import { LanguageService } from '../services/language.service';
*
* NB : les appels SSE en `fetch()` (chat, imports, notebooks) ne passent PAS par
* les intercepteurs Angular — ils ajoutent l'entête manuellement de leur côté.
*
* IMPORTANT — on lit la langue directement depuis localStorage et NON via
* LanguageService/TranslateService. Injecter ces services ici créerait une
* dépendance circulaire fatale au démarrage : provideTranslateService charge la
* langue de repli (`fr.json`) PENDANT la construction de TranslateService ; cette
* requête passe par cet intercepteur ; si l'intercepteur injectait LanguageService
* (qui injecte TranslateService, en cours de construction) → cycle → la requête
* `fr.json` échoue → ngx-translate marque `fr` comme chargé-mais-vide → toutes les
* clés s'affichent brutes (l'anglais, n'étant pas la langue de repli, se chargeait
* après la construction et fonctionnait). localStorage est de toute façon la
* source de vérité (persistée par LanguageService.use()).
*/
export const languageInterceptor: HttpInterceptorFn = (req, next) => {
const language = inject(LanguageService);
return next(
req.clone({ setHeaders: { 'X-User-Language': language.current } })
);
let language = 'fr';
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
language = stored;
}
} catch {
// localStorage indisponible (mode privé strict) : on garde le défaut.
}
return next(req.clone({ setHeaders: { 'X-User-Language': language } }));
};

View File

@@ -1,7 +1,8 @@
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { Observable, Subscriber } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
import { LanguageService } from './language.service';
import { parseSseStream, sseFetch } from '../shared/sse.util';
/**
* Un message d'une conversation IA (vue front).
@@ -105,108 +106,43 @@ export class AiChatService {
/** Plumbing SSE mutualisé entre les endpoints (Lore / Campaign / Session). */
private streamSse(endpoint: string, body: Record<string, unknown>): Observable<ChatStreamEvent> {
return new Observable<ChatStreamEvent>((subscriber) => {
const controller = new AbortController();
fetch(endpoint, {
return sseFetch<ChatStreamEvent>(
endpoint,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
'X-User-Language': this.language.current
},
body: JSON.stringify(body),
signal: controller.signal
})
.then(async (response) => {
if (!response.ok || !response.body) {
subscriber.error(new Error(`HTTP ${response.status}`));
return;
}
await this.consumeSseStream(response.body, subscriber);
})
.catch((err) => {
if (controller.signal.aborted) return; // annulation volontaire, silencieuse
subscriber.error(err);
});
return () => controller.abort();
});
body: JSON.stringify(body)
},
(responseBody, subscriber) => this.consumeSseStream(responseBody, subscriber)
);
}
/**
* Consomme un ReadableStream SSE ligne par ligne.
* Format attendu (un événement = un bloc séparé par `\n\n`) :
* event: done (optionnel, défaut = 'message')
* data: {...} (une ou plusieurs lignes, concaténées avec '\n')
* <ligne vide> (séparateur d'événements)
*/
/** Mappe les événements SSE bruts vers les `ChatStreamEvent` typés. */
private async consumeSseStream(
body: ReadableStream<Uint8Array>,
subscriber: { next: (e: ChatStreamEvent) => void; error: (e: unknown) => void; complete: () => void }
subscriber: Subscriber<ChatStreamEvent>
): Promise<void> {
const reader = body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
// Événement SSE en cours de construction (accumulé entre lignes vides).
let currentEvent: string | null = null;
let currentData = '';
const dispatchCurrentEvent = () => {
const eventName = currentEvent ?? 'message';
// DEBUG jauge de contexte — à retirer une fois stabilisé.
if (eventName !== 'message') {
console.log('[AiChatService] SSE event:', eventName, 'data:', currentData);
}
if (eventName === 'error') {
const message = this.safeParseMessage(currentData);
subscriber.error(new Error(message));
} else if (eventName === 'done') {
try {
await parseSseStream(body, ({ event, data }) => {
if (event === 'error') {
subscriber.error(new Error(this.safeParseMessage(data)));
} else if (event === 'done') {
subscriber.next({ type: 'done' });
subscriber.complete();
} else if (eventName === 'usage') {
const usage = this.safeParseUsage(currentData);
} else if (event === 'usage') {
const usage = this.safeParseUsage(data);
if (usage) subscriber.next({ type: 'usage', usage });
} else {
// Événement 'message' (défaut) : JSON {"token": "..."}
const token = this.safeParseToken(currentData);
const token = this.safeParseToken(data);
if (token) subscriber.next({ type: 'token', value: token });
}
currentEvent = null;
currentData = '';
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// On découpe par lignes ; la dernière (potentiellement incomplète) reste dans buffer.
let newlineIdx: number;
while ((newlineIdx = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, newlineIdx).replace(/\r$/, '');
buffer = buffer.slice(newlineIdx + 1);
if (line === '') {
// Ligne vide = fin d'un événement SSE : on dispatch ce qu'on a accumulé.
if (currentEvent !== null || currentData !== '') {
dispatchCurrentEvent();
}
continue;
}
if (line.startsWith('event:')) {
currentEvent = line.slice(6).trim();
} else if (line.startsWith('data:')) {
const chunk = line.slice(5).replace(/^ /, '');
currentData = currentData ? `${currentData}\n${chunk}` : chunk;
}
// Autres champs SSE (id:, retry:) ignorés pour le MVP.
}
}
});
// Fin de stream côté réseau sans event: done explicite → on complete quand même.
if (currentEvent !== null || currentData !== '') dispatchCurrentEvent();
subscriber.complete();
} catch (err) {
subscriber.error(err);

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Observable, Subscriber } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
import {
CampaignImportApplyResult,
@@ -8,6 +8,7 @@ import {
CampaignImportStreamEvent
} from './campaign-import.model';
import { LanguageService } from './language.service';
import { parseSseStream, sseFetch } from '../shared/sse.util';
/**
* Service HTTP pour l'import d'un PDF de campagne.
@@ -21,31 +22,17 @@ export class CampaignImportService {
constructor(private http: HttpClient, private translate: TranslateService, private language: LanguageService) {}
importStructureStream(campaignId: string, file: File): Observable<CampaignImportStreamEvent> {
return new Observable<CampaignImportStreamEvent>((subscriber) => {
const controller = new AbortController();
const form = new FormData();
form.append('file', file);
fetch(`/api/campaigns/${campaignId}/import-structure/stream`, {
return sseFetch<CampaignImportStreamEvent>(
`/api/campaigns/${campaignId}/import-structure/stream`,
{
method: 'POST',
headers: { 'Accept': 'text/event-stream', 'X-User-Language': this.language.current },
body: form,
signal: controller.signal
})
.then(async (response) => {
if (!response.ok || !response.body) {
subscriber.error(new Error(`HTTP ${response.status}`));
return;
}
await this.consumeSse(response.body, subscriber);
})
.catch((err) => {
if (controller.signal.aborted) return;
subscriber.error(err);
});
return () => controller.abort();
});
body: form
},
(body, subscriber) => this.consumeSse(body, subscriber)
);
}
applyStructure(campaignId: string, proposal: CampaignImportProposal): Observable<CampaignImportApplyResult> {
@@ -53,39 +40,33 @@ export class CampaignImportService {
`/api/campaigns/${campaignId}/import-structure/apply`, proposal);
}
/** Décode un ReadableStream SSE et émet les évènements d'import typés. */
/** Mappe les évènements SSE bruts vers les évènements d'import typés. */
private async consumeSse(
body: ReadableStream<Uint8Array>,
subscriber: { next: (e: CampaignImportStreamEvent) => void; error: (e: unknown) => void; complete: () => void }
subscriber: Subscriber<CampaignImportStreamEvent>
): Promise<void> {
const reader = body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let currentEvent: string | null = null;
let currentData = '';
// Le flux s'est-il terminé PROPREMENT (évènement done ou error reçu) ?
// Sans ce suivi, une connexion coupée en plein import (timeout serveur,
// proxy, Core redémarré) terminait l'Observable en silence : barre de
// progression figée et aucun message pour l'utilisateur.
let terminated = false;
const dispatch = () => {
const name = currentEvent ?? 'message';
if (name === 'error') {
const dispatch = ({ event, data }: { event: string; data: string }) => {
if (event === 'error') {
let message = this.translate.instant('services.importFailed');
try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* défaut */ }
try { message = (JSON.parse(data) as { message?: string }).message ?? message; } catch { /* défaut */ }
terminated = true;
subscriber.error(new Error(message));
} else if (name === 'status') {
} else if (event === 'status') {
// Message d'attente lisible (fournisseur saturé, morceau re-découpé…).
try {
const obj = JSON.parse(currentData) as { message?: string };
const obj = JSON.parse(data) as { message?: string };
if (obj.message) subscriber.next({ type: 'status', message: obj.message });
} catch { /* bloc malformé ignoré */ }
} else if (name === 'progress' || name === 'done') {
} else if (event === 'progress' || event === 'done') {
try {
const obj = JSON.parse(currentData);
if (name === 'done') {
const obj = JSON.parse(data);
if (event === 'done') {
terminated = true;
subscriber.next({ type: 'done', arcs: obj.arcs ?? [], npcs: obj.npcs ?? [] });
subscriber.complete();
@@ -94,32 +75,10 @@ export class CampaignImportService {
}
} catch { /* bloc malformé ignoré */ }
}
currentEvent = null;
currentData = '';
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let idx: number;
while ((idx = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, idx).replace(/\r$/, '');
buffer = buffer.slice(idx + 1);
if (line === '') {
if (currentEvent !== null || currentData !== '') dispatch();
continue;
}
if (line.startsWith('event:')) {
currentEvent = line.slice(6).trim();
} else if (line.startsWith('data:')) {
const chunk = line.slice(5).replace(/^ /, '');
currentData = currentData ? `${currentData}\n${chunk}` : chunk;
}
}
}
if (currentEvent !== null || currentData !== '') dispatch();
await parseSseStream(body, dispatch);
if (!terminated) {
subscriber.error(new Error(
this.translate.instant('services.importInterrupted')));

View File

@@ -1,9 +1,10 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Observable, Subscriber } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
import { GameSystem, GameSystemCreate, RulesImportResponse, RulesImportStreamEvent } from './game-system.model';
import { LanguageService } from './language.service';
import { parseSseStream, sseFetch } from '../shared/sse.util';
/**
* Service HTTP pour les GameSystems (systèmes de JDR).
@@ -57,66 +58,46 @@ export class GameSystemService {
* subscription annule le fetch (AbortController).
*/
importRulesStream(file: File): Observable<RulesImportStreamEvent> {
return new Observable<RulesImportStreamEvent>((subscriber) => {
const controller = new AbortController();
const form = new FormData();
form.append('file', file);
fetch(`${this.apiUrl}/import-rules/stream`, {
return sseFetch<RulesImportStreamEvent>(
`${this.apiUrl}/import-rules/stream`,
{
method: 'POST',
headers: { 'Accept': 'text/event-stream', 'X-User-Language': this.language.current },
body: form,
signal: controller.signal
})
.then(async (response) => {
if (!response.ok || !response.body) {
subscriber.error(new Error(`HTTP ${response.status}`));
return;
}
await this.consumeImportSse(response.body, subscriber);
})
.catch((err) => {
if (controller.signal.aborted) return;
subscriber.error(err);
});
return () => controller.abort();
});
body: form
},
(body, subscriber) => this.consumeImportSse(body, subscriber)
);
}
/** Décode un ReadableStream SSE et émet les évènements d'import typés. */
/** Mappe les évènements SSE bruts vers les évènements d'import typés. */
private async consumeImportSse(
body: ReadableStream<Uint8Array>,
subscriber: { next: (e: RulesImportStreamEvent) => void; error: (e: unknown) => void; complete: () => void }
subscriber: Subscriber<RulesImportStreamEvent>
): Promise<void> {
const reader = body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let currentEvent: string | null = null;
let currentData = '';
// Le flux s'est-il terminé PROPREMENT (évènement done ou error reçu) ?
// Sans ce suivi, une connexion coupée en plein import (timeout serveur,
// proxy, Core redémarré) terminait l'Observable en silence : barre de
// progression figée et aucun message pour l'utilisateur.
let terminated = false;
const dispatch = () => {
const name = currentEvent ?? 'message';
if (name === 'error') {
const dispatch = ({ event, data }: { event: string; data: string }) => {
if (event === 'error') {
let message = this.translate.instant('services.importFailed');
try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* garde le défaut */ }
try { message = (JSON.parse(data) as { message?: string }).message ?? message; } catch { /* garde le défaut */ }
terminated = true;
subscriber.error(new Error(message));
} else if (name === 'status') {
} else if (event === 'status') {
// Message d'attente lisible (fournisseur saturé, morceau re-découpé…).
try {
const obj = JSON.parse(currentData) as { message?: string };
const obj = JSON.parse(data) as { message?: string };
if (obj.message) subscriber.next({ type: 'status', message: obj.message });
} catch { /* bloc malformé ignoré */ }
} else if (name === 'progress' || name === 'done') {
} else if (event === 'progress' || event === 'done') {
try {
const obj = JSON.parse(currentData);
if (name === 'done') {
const obj = JSON.parse(data);
if (event === 'done') {
terminated = true;
subscriber.next({ type: 'done', ...obj });
subscriber.complete();
@@ -125,32 +106,10 @@ export class GameSystemService {
}
} catch { /* bloc malformé ignoré */ }
}
currentEvent = null;
currentData = '';
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let idx: number;
while ((idx = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, idx).replace(/\r$/, '');
buffer = buffer.slice(idx + 1);
if (line === '') {
if (currentEvent !== null || currentData !== '') dispatch();
continue;
}
if (line.startsWith('event:')) {
currentEvent = line.slice(6).trim();
} else if (line.startsWith('data:')) {
const chunk = line.slice(5).replace(/^ /, '');
currentData = currentData ? `${currentData}\n${chunk}` : chunk;
}
}
}
if (currentEvent !== null || currentData !== '') dispatch();
await parseSseStream(body, dispatch);
if (!terminated) {
subscriber.error(new Error(
this.translate.instant('services.importInterrupted')));

View File

@@ -22,7 +22,13 @@ export interface AppLanguage {
flag: string;
}
const STORAGE_KEY = 'loremind.lang';
/**
* Clé localStorage du choix de langue (par appareil). Exportée pour que le
* languageInterceptor lise la langue SANS injecter LanguageService/TranslateService
* (sinon dépendance circulaire au démarrage : le chargement initial de la langue
* de repli passe par l'intercepteur pendant la construction de TranslateService).
*/
export const STORAGE_KEY = 'loremind.lang';
@Injectable({ providedIn: 'root' })
export class LanguageService {

View File

@@ -4,6 +4,7 @@ import { Observable } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
import { Notebook, NotebookArchive, NotebookDetail, NotebookSource, NotebookChatEvent } from './notebook.model';
import { LanguageService } from './language.service';
import { parseSseStream } from '../shared/sse.util';
/**
* Service des notebooks (atelier RAG) : CRUD, upload/indexation de sources,
@@ -88,20 +89,13 @@ export class NotebookService {
this.zone.run(() => subscriber.complete());
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let currentEvent: string | null = null;
let currentData = '';
const dispatch = () => {
const name = currentEvent ?? 'message';
if (name === 'token') {
try { emit({ type: 'token', value: (JSON.parse(currentData) as { token?: string }).token ?? '' }); }
await parseSseStream(response.body, ({ event, data }) => {
if (event === 'token') {
try { emit({ type: 'token', value: (JSON.parse(data) as { token?: string }).token ?? '' }); }
catch { /* ignore */ }
} else if (name === 'sources') {
} else if (event === 'sources') {
try {
const o = JSON.parse(currentData) as { sources?: { source_id?: string; page?: number | null; score?: number }[] };
const o = JSON.parse(data) as { sources?: { source_id?: string; page?: number | null; score?: number }[] };
emit({
type: 'sources',
sources: (o.sources ?? []).map(s => ({
@@ -109,39 +103,19 @@ export class NotebookService {
}))
});
} catch { /* ignore */ }
} else if (name === 'progress') {
} else if (event === 'progress') {
try {
const o = JSON.parse(currentData) as { current?: number; total?: number };
const o = JSON.parse(data) as { current?: number; total?: number };
emit({ type: 'progress', current: o.current ?? 0, total: o.total ?? 0 });
} catch { /* ignore */ }
} else if (name === 'done') {
} else if (event === 'done') {
emit({ type: 'done' });
} else if (name === 'error') {
} else if (event === 'error') {
let msg = 'Erreur du chat.';
try { msg = (JSON.parse(currentData) as { message?: string }).message ?? msg; } catch { /* garde */ }
try { msg = (JSON.parse(data) as { message?: string }).message ?? msg; } catch { /* garde */ }
emit({ type: 'error', message: msg });
}
currentEvent = null;
currentData = '';
};
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let idx: number;
while ((idx = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, idx).replace(/\r$/, '');
buffer = buffer.slice(idx + 1);
if (line === '') { if (currentEvent !== null || currentData !== '') dispatch(); continue; }
if (line.startsWith('event:')) currentEvent = line.slice(6).trim();
else if (line.startsWith('data:')) {
const chunk = line.slice(5).replace(/^ /, '');
currentData = currentData ? `${currentData}\n${chunk}` : chunk;
}
}
}
if (currentEvent !== null || currentData !== '') dispatch();
});
this.zone.run(() => subscriber.complete());
} catch (err) {
if ((err as Error).name !== 'AbortError') {

View File

@@ -66,6 +66,13 @@ export interface OllamaModelInfo {
context_length: number;
}
/** Resultat d'un import de donnees (compteurs par type + images). */
export interface ImportResult {
created: Record<string, number>;
imagesUploaded: number;
imagesReused: number;
}
@Injectable({ providedIn: 'root' })
export class SettingsService {
private readonly apiUrl = '/api/settings';
@@ -85,6 +92,21 @@ export class SettingsService {
return this.http.put<AppSettings>(this.apiUrl, patch, this.authOptions);
}
// --- Export / Import des donnees (sauvegarde & transfert d'instance) -------
/** Telecharge l'export complet du contenu (zip : data.json + images). */
exportData(): Observable<Blob> {
return this.http.get('/api/admin/data/export',
{ withCredentials: true, responseType: 'blob' });
}
/** Importe un zip d'export en mode FUSION (ajoute, ne remplace pas). */
importData(file: File): Observable<ImportResult> {
const form = new FormData();
form.append('file', file);
return this.http.post<ImportResult>('/api/admin/data/import', form, this.authOptions);
}
listOllamaModels(): Observable<{ models: string[] }> {
return this.http.get<{ models: string[] }>(`${this.apiUrl}/models/ollama`, this.authOptions);
}

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