Compare commits

..

9 Commits

Author SHA1 Message Date
5aa08a3a27 Correction du worflow release : on ne fait plus les tests (évite le doublons de test avec le ci.yml).
Some checks failed
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 21s
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 30s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Successful in 2m22s
Build & Push Images / build (brain) (push) Successful in 1m16s
E2E Tests / e2e (push) Failing after 5m24s
Build & Push Images / build (core) (push) Successful in 3m5s
Build & Push Images / build-switcher (push) Successful in 50s
Build & Push Images / build (web) (push) Successful in 1m56s
Correction de tests coté java + config
Suppression de classes inutilisées coté Angular
2026-06-19 00:36:30 +02:00
13f4b994ab Ajout de postgres sur git et gitea en conteneur docker pour pouvoir exécuter les tests unitaires concernant la BDD
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Successful in 1m51s
Build & Push Images / tests (push) Successful in 2m25s
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 21s
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 22s
Build & Push Images / build (brain) (push) Successful in 1m16s
Build & Push Images / build (core) (push) Successful in 3m20s
Build & Push Images / build (web) (push) Successful in 1m41s
Build & Push Images / build-switcher (push) Successful in 39s
2026-06-18 16:28:10 +02:00
d1653b8bea Ajout du mvn wrapper dans le workflow ; ajout d'un gate keeper coté workflow git afin d'éviter de build le .msi si des tests échoues
Some checks failed
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Failing after 1m40s
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 19s
Build & Push Images / tests (push) Failing after 1m26s
Build & Push Images / build (brain) (push) Has been skipped
Build & Push Images / build (core) (push) Has been skipped
Build & Push Images / build (web) (push) Has been skipped
E2E Tests / e2e (push) Has been cancelled
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 24s
Build & Push Images / build-switcher (push) Has been skipped
2026-06-18 16:13:36 +02:00
4d049274f9 Refacto du code coté Python, java et coté angular afin de mieux séparer les responsabilité et d'avoir moins de répétitivité dans le code.
Some checks failed
E2E Tests / e2e (push) Waiting to run
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 26s
Build & Push Images / tests (push) Failing after 13s
Build & Push Images / build (brain) (push) Has been skipped
Build & Push Images / build (core) (push) Has been skipped
Build & Push Images / build (web) (push) Has been skipped
Build & Push Images / build-switcher (push) Has been skipped
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 1m12s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Failing after 1m28s
Mise en place de tests unitaires coté Python et Angular
Mise en place de la couverture de test directement dans le workflow : le programme ne build pas si jamais un test échoue
Passage en v0.16.2 en conséquence
2026-06-18 15:59:10 +02:00
eb78a75621 Ajout du binaire tesseract pour la reconnaissance OCR des PDF pour l'installation local
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m10s
Build & Push Images / build (core) (push) Successful in 3m8s
Build & Push Images / build (web) (push) Successful in 1m41s
Build & Push Images / build-switcher (push) Successful in 39s
Correction d'un problème d'écrasement de BDD à la réinstallation
2026-06-18 10:28:31 +02:00
f04ecf1021 Passage v0.16.0
Some checks are pending
E2E Tests / e2e (push) Waiting to run
Build & Push Images / build (brain) (push) Successful in 1m14s
Build & Push Images / build (core) (push) Successful in 3m16s
Build & Push Images / build (web) (push) Successful in 1m57s
Build & Push Images / build-switcher (push) Successful in 40s
2026-06-18 09:52:53 +02:00
72fe5e6215 Mise en place de l'import / export des données pour pouvoir sauvegarder les lores / campagnes 2026-06-18 09:49:34 +02:00
7dfa9c3655 Mise à jour v0.15.1
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m36s
Build & Push Images / build (web) (push) Successful in 1m43s
Build & Push Images / build-switcher (push) Successful in 41s
Build & Push Images / build (core) (push) Successful in 3m10s
2026-06-17 18:24:51 +02:00
7aa174d75a Mise à jour du workflow pour forcer le declenchement à la main si par malheur il ne tourne pas à l'avenir automatiquement 2026-06-17 18:24:18 +02:00
127 changed files with 6826 additions and 1404 deletions

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

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

View File

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

View File

@@ -22,15 +22,97 @@ name: Desktop installers
on: on:
push: push:
tags: ['v*'] 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: permissions:
contents: write # requis pour creer la Release et y attacher le .msi contents: write # requis pour creer la Release et y attacher le .msi
jobs: jobs:
windows: # GATE : les 3 suites unitaires (Java / Python / Angular) doivent passer avant
runs-on: windows-latest # 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: steps:
- uses: actions/checkout@v4 - 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. # Apporte jpackage (lanceur d'empaquetage natif) dans le PATH.
- name: Set up JDK 21 - name: Set up JDK 21
@@ -57,24 +139,44 @@ jobs:
shell: pwsh shell: pwsh
run: choco install wixtoolset -y --no-progress run: choco install wixtoolset -y --no-progress
# Version de l'installeur = version du tag (numerique, sans suffixe -beta). # Tesseract OCR : non preinstalle sur windows-latest. Requis pour que
- name: Derive version from tag # 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 id: ver
shell: pwsh shell: pwsh
run: | run: |
$full = "${{ github.ref_name }}" -replace '^v','' # ex: 0.15.0-beta.1 if ('${{ github.event_name }}' -eq 'workflow_dispatch') {
$num = ($full -split '-')[0] # ex: 0.15.0 $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 "version=$num" >> $env:GITHUB_OUTPUT
"tag=v$raw" >> $env:GITHUB_OUTPUT
"isbeta=$isbeta" >> $env:GITHUB_OUTPUT
- name: Build Windows installer - name: Build Windows installer
shell: pwsh shell: pwsh
run: .\installers\desktop\build-windows.ps1 -Version ${{ steps.ver.outputs.version }} run: .\installers\desktop\build-windows.ps1 -Version ${{ steps.ver.outputs.version }}
# STABLE uniquement : Release GitHub publique avec le .msi. # 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) - name: Publish installer to GitHub Release (stable)
if: ${{ !contains(github.ref_name, '-beta') }} if: ${{ steps.ver.outputs.isbeta == 'false' }}
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ steps.ver.outputs.tag }}
files: core/target/dist-out/*.msi files: core/target/dist-out/*.msi
fail_on_unmatched_files: true fail_on_unmatched_files: true
generate_release_notes: true generate_release_notes: true
@@ -82,7 +184,7 @@ jobs:
# BETA uniquement : artefact PRIVE (pas de release publique). A recuperer # BETA uniquement : artefact PRIVE (pas de release publique). A recuperer
# via l'onglet Actions puis a joindre a un post Patreon gate par palier. # via l'onglet Actions puis a joindre a un post Patreon gate par palier.
- name: Upload installer as private artifact (beta) - name: Upload installer as private artifact (beta)
if: ${{ contains(github.ref_name, '-beta') }} if: ${{ steps.ver.outputs.isbeta == 'true' }}
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: loremind-beta-${{ steps.ver.outputs.version }}-msi name: loremind-beta-${{ steps.ver.outputs.version }}-msi

5
.gitignore vendored
View File

@@ -123,3 +123,8 @@ brain/data/notebooks/5.json
# ============================================================================ # ============================================================================
docusaurus/loremind-patreon/ docusaurus/loremind-patreon/
installers/desktop/README.md 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__/ __pycache__/
*.pyc *.pyc
.env .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. """Adapter Google Gemini — implémente les ports LLMProvider / LLMChatProvider.
Gemini expose un endpoint COMPATIBLE OpenAI Gemini expose un endpoint COMPATIBLE OpenAI
(POST {base}/openai/chat/completions, SSE), donc cet adapter est un client (POST {base}/openai/chat/completions, SSE) : client "OpenAI-compatible" qui hérite
"OpenAI-compatible" — même structure que les adapters OpenRouter / Mistral. de BaseOpenAICompatibleAdapter et ne fournit que ses spécificités (dont un message
dédié quand Google refuse la clé en 401/403).
Tier GRATUIT : clé API sur aistudio.google.com (sans CB). Atout majeur pour Tier GRATUIT : clé API sur aistudio.google.com (sans CB). Atout majeur pour
l'extraction de PDF : un CONTEXTE de ~1M tokens → un livre entier tient en 1-2 l'extraction de PDF : un CONTEXTE de ~1M tokens → un livre entier tient en 1-2
appels, donc quasi aucun morceau perdu et peu de requêtes (limites jamais appels. Modèle conseillé : `gemini-2.0-flash` (rapide, gros contexte, fidèle).
atteintes). Modèle conseillé : `gemini-2.0-flash` (rapide, gros contexte, fidèle).
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import json
import logging
from typing import AsyncIterator
import httpx
from app.core.config import Settings from app.core.config import Settings
from app.domain.models import ChatMessage from app.domain.ports import LLMProviderError
from app.domain.ports import LLMGenerationTimeout, LLMProviderError from app.infrastructure.base_openai_adapter import (
_FIRST_TOKEN_TIMEOUT_SECONDS,
logger = logging.getLogger(__name__) BaseOpenAICompatibleAdapter,
)
_API_URL = "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions"
# Délai max pour le PREMIER token de contenu (échec rapide si le modèle ne produit
# rien). Gemini répond vite ; 120s est large.
_FIRST_TOKEN_TIMEOUT_SECONDS = 120.0
class GeminiLLMProvider: class GeminiLLMProvider(BaseOpenAICompatibleAdapter):
"""Adapter Gemini (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider.""" """Adapter Gemini (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider."""
_provider_label = "Gemini"
_api_url = "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions"
_supports_json_object = True
def __init__(self, settings: Settings) -> None: def __init__(self, settings: Settings) -> None:
if not settings.gemini_api_key: if not settings.gemini_api_key:
raise LLMProviderError( raise LLMProviderError(
"Clé API Gemini manquante. Configure-la depuis l'écran Paramètres " "Clé API Gemini manquante. Configure-la depuis l'écran Paramètres "
"(clé gratuite sur aistudio.google.com)." "(clé gratuite sur aistudio.google.com)."
) )
self._api_key = settings.gemini_api_key super().__init__(
self._model = settings.gemini_model settings.gemini_api_key, settings.gemini_model, settings.llm_timeout_seconds
self._timeout = settings.llm_timeout_seconds
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
"Accept": "application/json",
}
async def generate(
self,
prompt: str,
*,
output_format: str | None = None,
temperature: float | None = None,
) -> str:
"""One-shot via streaming (puis recollage), avec garde-fous au temps écoulé."""
return await self._collect_with_timeouts(
[ChatMessage(role="user", content=prompt)], temperature, output_format
) )
async def _collect_with_timeouts( def _headers(self) -> dict[str, str]:
self, return {**super()._headers(), "Accept": "application/json"}
messages: list[ChatMessage],
temperature: float | None, def _first_token_timeout_message(self) -> str:
output_format: str | None, return (
) -> str:
"""Collecte le stream avec deux garde-fous : 1er token borné (échec rapide
si rien ne sort) + ceiling global `self._timeout`."""
async def _collect() -> str:
chunks: list[str] = []
agen = self._stream(messages, None, temperature, output_format)
try:
while True:
first = _FIRST_TOKEN_TIMEOUT_SECONDS if not chunks else None
try:
token = await asyncio.wait_for(agen.__anext__(), timeout=first)
except StopAsyncIteration:
break
except asyncio.TimeoutError:
raise LLMProviderError(
f"Erreur Gemini : aucun contenu produit en " f"Erreur Gemini : aucun contenu produit en "
f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s. Réessayez ou vérifiez " f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s. Réessayez ou vérifiez "
"votre quota gratuit." "votre quota gratuit."
) )
chunks.append(token)
finally:
await agen.aclose()
return "".join(chunks)
try: def _generation_timeout_message(self) -> str:
return await asyncio.wait_for(_collect(), timeout=self._timeout) return (
except asyncio.TimeoutError as exc:
raise LLMGenerationTimeout(
f"Erreur Gemini : génération non terminée en {self._timeout}s. Réduisez la " 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." "taille des morceaux d'import ou augmentez le timeout."
) from exc )
async def stream_chat( def _error_for_status(self, status_code: int, detail: str) -> LLMProviderError:
self, # 401/403 = clé rejetée par GOOGLE (pas un problème LoreMind) : message
messages: list[ChatMessage], # actionnable plutôt que le JSON brut de l'API.
*, if status_code in (401, 403):
system_prompt: str | None = None, return LLMProviderError(
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(
"Erreur Gemini : clé API refusée par Google " "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 " "de aistudio.google.com (« Get API key ») et qu'elle n'a pas de "
"restrictions (API ou adresse IP) dans la Google Cloud Console. " "restrictions (API ou adresse IP) dans la Google Cloud Console. "
f"Détail : {detail[:300]}" f"Détail : {detail[:300]}"
) )
raise LLMProviderError( return super()._error_for_status(status_code, detail)
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}"

View File

@@ -1,195 +1,48 @@
"""Adapter Mistral — implémente les ports LLMProvider / LLMChatProvider. """Adapter Mistral — implémente les ports LLMProvider / LLMChatProvider.
Mistral (La Plateforme) expose l'API OpenAI standard (POST {base}/chat/completions, Mistral (La Plateforme) expose l'API OpenAI standard (POST {base}/chat/completions,
SSE), donc cet adapter est un client "OpenAI-compatible" — même structure que SSE) : client "OpenAI-compatible" qui hérite de BaseOpenAICompatibleAdapter et ne
l'adapter OpenRouter. Le `generate` one-shot passe par le streaming (puis fournit que ses spécificités.
recollage) avec un timeout au temps écoulé pour ne jamais pendre à l'infini.
Tier GRATUIT : compte sur console.mistral.ai (tier « Experiment »), clé API à Tier GRATUIT : compte sur console.mistral.ai (tier « Experiment »), clé API à
coller dans l'écran Paramètres. Modèles conseillés pour l'extraction : un grand coller dans l'écran Paramètres. Modèles conseillés pour l'extraction : un grand
contexte fidèle comme `mistral-large-latest` (128k) ou `mistral-small-latest`. contexte fidèle comme `mistral-large-latest` (128k) ou `mistral-small-latest`.
Mode JSON natif : TOUS les modèles Mistral le supportent (`_supports_json_object`).
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import json
import logging
from typing import AsyncIterator
import httpx
from app.core.config import Settings from app.core.config import Settings
from app.domain.models import ChatMessage from app.domain.ports import LLMProviderError
from app.domain.ports import LLMGenerationTimeout, LLMProviderError from app.infrastructure.base_openai_adapter import (
_FIRST_TOKEN_TIMEOUT_SECONDS,
logger = logging.getLogger(__name__) BaseOpenAICompatibleAdapter,
)
_API_URL = "https://api.mistral.ai/v1/chat/completions"
# Délai max pour le PREMIER token de contenu (échec rapide si le modèle est en file
# d'attente et n'envoie que des keep-alive). Généreux car la file d'un tier gratuit
# peut être longue.
_FIRST_TOKEN_TIMEOUT_SECONDS = 120.0
class MistralLLMProvider: class MistralLLMProvider(BaseOpenAICompatibleAdapter):
"""Adapter Mistral (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider.""" """Adapter Mistral (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider."""
_provider_label = "Mistral"
_api_url = "https://api.mistral.ai/v1/chat/completions"
_supports_json_object = True
def __init__(self, settings: Settings) -> None: def __init__(self, settings: Settings) -> None:
if not settings.mistral_api_key: if not settings.mistral_api_key:
raise LLMProviderError( raise LLMProviderError(
"Clé API Mistral manquante. Configure-la depuis l'écran Paramètres." "Clé API Mistral manquante. Configure-la depuis l'écran Paramètres."
) )
self._api_key = settings.mistral_api_key super().__init__(
self._model = settings.mistral_model settings.mistral_api_key, settings.mistral_model, settings.llm_timeout_seconds
self._timeout = settings.llm_timeout_seconds
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
"Accept": "application/json",
}
async def generate(
self,
prompt: str,
*,
output_format: str | None = None,
temperature: float | None = None,
) -> str:
"""One-shot via streaming (puis recollage) pour robustesse sur longues sorties.
Timeout au TEMPS ÉCOULÉ (asyncio) en plus du timeout réseau d'httpx :
si le provider envoyait des keep-alive sans contenu, l'appel pendrait à
l'infini. Ici on coupe net après `self._timeout` secondes.
"""
return await self._collect_with_timeouts(
[ChatMessage(role="user", content=prompt)], temperature, output_format
) )
async def _collect_with_timeouts( def _headers(self) -> dict[str, str]:
self, return {**super()._headers(), "Accept": "application/json"}
messages: list[ChatMessage],
temperature: float | None, def _first_token_timeout_message(self) -> str:
output_format: str | None, return (
) -> str:
"""Collecte le stream avec deux garde-fous au temps écoulé : 1er token borné
(file d'attente → échec rapide) + ceiling global `self._timeout`."""
async def _collect() -> str:
chunks: list[str] = []
agen = self._stream(messages, None, temperature, output_format)
try:
while True:
first = _FIRST_TOKEN_TIMEOUT_SECONDS if not chunks else None
try:
token = await asyncio.wait_for(agen.__anext__(), timeout=first)
except StopAsyncIteration:
break
except asyncio.TimeoutError:
raise LLMProviderError(
f"Erreur Mistral : aucun contenu produit en " f"Erreur Mistral : aucun contenu produit en "
f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s — le modèle est probablement " 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 " "en file d'attente (tier gratuit, 2 req/min). Réessayez plus tard ou "
"choisissez un modèle plus disponible." "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. """Adapter OpenRouter — implémente les ports LLMProvider / LLMChatProvider.
OpenRouter expose l'API OpenAI standard (POST {base}/chat/completions, SSE), donc OpenRouter expose l'API OpenAI standard (POST {base}/chat/completions, SSE), donc
cet adapter est en réalité un client "OpenAI-compatible". Le `generate` one-shot cet adapter est un client "OpenAI-compatible" : il hérite de toute la mécanique de
passe lui aussi par le streaming (puis recollage) pour éviter les coupures de BaseOpenAICompatibleAdapter et ne fournit que ses spécificités (URL, en-têtes
passerelle sur les longues générations (cf. 1min.ai / Cloudflare 524). d'attribution, messages, lecture de config).
Modèles GRATUITS : utiliser un id finissant par `:free` (ex. Modèles GRATUITS : utiliser un id finissant par `:free` (ex.
`meta-llama/llama-3.3-70b-instruct:free`) ou le routeur `openrouter/free` (défaut) `meta-llama/llama-3.3-70b-instruct:free`) ou le routeur `openrouter/free` (défaut)
qui choisit automatiquement un modèle gratuit — aucun crédit consommé. qui choisit automatiquement un modèle gratuit — aucun crédit consommé.
NB : on n'impose PAS `response_format=json_object` (`_supports_json_object=False`).
Beaucoup de modèles/providers GRATUITS ne le supportent pas et renvoient une
réponse VIDE. On laisse le modèle répondre librement ; l'extraction JSON en aval
(load_json_object + nettoyage du raisonnement) récupère le JSON dans la prose.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import json
import logging
from typing import AsyncIterator
import httpx
from app.core.config import Settings from app.core.config import Settings
from app.domain.models import ChatMessage from app.domain.ports import LLMProviderError
from app.infrastructure.base_openai_adapter import (
logger = logging.getLogger(__name__) _FIRST_TOKEN_TIMEOUT_SECONDS,
from app.domain.ports import LLMGenerationTimeout, LLMProviderError BaseOpenAICompatibleAdapter,
)
_API_URL = "https://openrouter.ai/api/v1/chat/completions"
# Délai max pour le PREMIER token de contenu. Un modèle gratuit "en file d'attente"
# n'envoie que des keep-alive (aucun contenu) → on échoue vite et clairement au lieu
# de pendre. Généreux (2 min) car la file d'attente d'un tier gratuit peut être longue.
_FIRST_TOKEN_TIMEOUT_SECONDS = 120.0
class OpenRouterLLMProvider: class OpenRouterLLMProvider(BaseOpenAICompatibleAdapter):
"""Adapter OpenRouter (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider.""" """Adapter OpenRouter (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider."""
_provider_label = "OpenRouter"
_api_url = "https://openrouter.ai/api/v1/chat/completions"
_supports_json_object = False
def __init__(self, settings: Settings) -> None: def __init__(self, settings: Settings) -> None:
if not settings.openrouter_api_key: if not settings.openrouter_api_key:
raise LLMProviderError( raise LLMProviderError(
"Clé API OpenRouter manquante. Configure-la depuis l'écran Paramètres." "Clé API OpenRouter manquante. Configure-la depuis l'écran Paramètres."
) )
self._api_key = settings.openrouter_api_key super().__init__(
self._model = settings.openrouter_model settings.openrouter_api_key, settings.openrouter_model, settings.llm_timeout_seconds
self._timeout = settings.llm_timeout_seconds )
def _headers(self) -> dict[str, str]: def _headers(self) -> dict[str, str]:
return { return {
"Authorization": f"Bearer {self._api_key}", **super()._headers(),
"Content-Type": "application/json",
# Attribution facultative (classement OpenRouter) — sans impact fonctionnel. # Attribution facultative (classement OpenRouter) — sans impact fonctionnel.
"HTTP-Referer": "https://loremind.app", "HTTP-Referer": "https://loremind.app",
"X-Title": "LoreMind", "X-Title": "LoreMind",
} }
async def generate( def _first_token_timeout_message(self) -> str:
self, return (
prompt: str, f"Erreur OpenRouter : aucun contenu produit en "
*,
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 "
f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s — le modèle gratuit est " f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s — le modèle gratuit est "
"probablement en file d'attente / saturé. Réessayez plus tard ou " "probablement en file d'attente / saturé. Réessayez plus tard ou "
"choisissez un autre modèle (1min.ai, ou payant)." "choisissez un autre modèle (1min.ai, ou payant)."
) )
chunks.append(token)
finally:
await agen.aclose()
return "".join(chunks)
try:
return await asyncio.wait_for(_collect(), timeout=self._timeout)
except asyncio.TimeoutError as exc:
raise LLMGenerationTimeout(
f"Erreur {provider} : génération non terminée en {self._timeout}s. Réduisez la "
"taille des morceaux d'import, augmentez le timeout, ou changez de modèle."
) from exc
async def stream_chat(
self,
messages: list[ChatMessage],
*,
system_prompt: str | None = None,
temperature: float | None = None,
) -> AsyncIterator[str]:
async for token in self._stream(messages, system_prompt, temperature):
yield token
async def _stream(
self,
messages: list[ChatMessage],
system_prompt: str | None,
temperature: float | None,
output_format: str | None = None,
) -> AsyncIterator[str]:
payload_messages: list[dict[str, str]] = []
if system_prompt:
payload_messages.append({"role": "system", "content": system_prompt})
for m in messages:
payload_messages.append({"role": m.role, "content": m.content})
body: dict[str, object] = {
"model": self._model,
"messages": payload_messages,
"stream": True,
}
if temperature is not None:
body["temperature"] = temperature
# NB : on n'impose PAS `response_format=json_object`. Beaucoup de modèles/
# providers GRATUITS ne le supportent pas et renvoient une réponse VIDE.
# On laisse le modèle répondre librement ; l'extraction JSON en aval
# (load_json_object + nettoyage du raisonnement) récupère le JSON dans la prose.
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
async with client.stream(
"POST", _API_URL, headers=self._headers(), json=body
) as response:
if response.status_code >= 400:
# En streaming, le corps n'est pas lu automatiquement : on le
# lit pour exposer le détail d'OpenRouter (ex. le 429 précise
# "free-models-per-day" vs "per-minute"), sinon on n'a que le
# code HTTP nu et le diagnostic est impossible.
detail = (await response.aread()).decode("utf-8", "replace").strip()
raise LLMProviderError(
f"Erreur OpenRouter (HTTP {response.status_code})"
+ (f" : {detail[:500]}" if detail else "")
)
async for token in self._parse_sse(response):
yield token
except httpx.HTTPError as exc:
raise LLMProviderError(self._format_http_error(exc)) from exc
@staticmethod
async def _parse_sse(response: httpx.Response) -> AsyncIterator[str]:
"""SSE OpenAI : lignes `data: {json}`, fin sur `data: [DONE]`."""
async for line in response.aiter_lines():
if not line or not line.startswith("data:"):
continue # lignes vides ou commentaires keep-alive (`: ...`)
data = line[len("data:"):].strip()
if data == "[DONE]":
return
try:
obj = json.loads(data)
except json.JSONDecodeError:
continue
choices = obj.get("choices")
if not choices:
continue
delta = choices[0].get("delta") or {}
content = delta.get("content")
if content:
yield content
def _format_http_error(self, exc: httpx.HTTPError) -> str:
"""Message lisible (timeout, quota 429, crédits 402, modèle inconnu…)."""
if isinstance(exc, httpx.TimeoutException):
return (
f"Erreur OpenRouter : délai dépassé (timeout {self._timeout}s). Le modèle a "
"mis trop de temps — réduis la taille des morceaux d'import ou augmente le timeout."
)
detail = str(exc) or exc.__class__.__name__
return f"Erreur OpenRouter ({exc.__class__.__name__}) : {detail}"

View File

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

7
brain/pytest.ini Normal file
View File

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

View File

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

View File

@@ -16,7 +16,21 @@ ailleurs, sous ~/.loremind/brain, pour y ecrire le dossier data/).
import os import os
import sys import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) _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 import uvicorn # noqa: E402

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

295
core/mvnw vendored Normal file
View File

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

189
core/mvnw.cmd vendored Normal file
View File

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

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId> <groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId> <artifactId>loremind-core</artifactId>
<version>0.15.0</version> <version>0.16.3</version>
<name>LoreMind Core</name> <name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description> <description>Backend Core - Architecture Hexagonale</description>
@@ -152,6 +152,45 @@
<build> <build>
<plugins> <plugins>
<!-- Declare EXPLICITEMENT le processor d'annotations Lombok : sinon javac
avertit que l'annotation processing implicite (processeur trouve sur le
classpath sans etre declare) sera desactivee dans un futur JDK. -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<!-- Expose le chemin du jar mockito-core dans la propriete
${org.mockito:mockito-core:jar}, consommee par surefire ci-dessous. -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>properties</goal>
</goals>
</execution>
</executions>
</plugin>
<!-- Charge Mockito comme un VRAI -javaagent (au lieu de l'auto-attachement
dynamique de byte-buddy, qui imprime un avertissement et sera interdit
dans un futur JDK). `@{argLine}` preserve l'argLine pose par JaCoCo. -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>@{argLine} -javaagent:${org.mockito:mockito-core:jar} -Xshare:off</argLine>
</configuration>
</plugin>
<plugin> <plugin>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId> <artifactId>spring-boot-maven-plugin</artifactId>
@@ -195,6 +234,31 @@
<goal>report</goal> <goal>report</goal>
</goals> </goals>
</execution> </execution>
<!-- Plancher ANTI-REGRESSION : `mvn test` echoue si la couverture
d'instructions du bundle passe sous 60% (mesure actuelle ~68%).
A remonter au fil du temps. N'impacte PAS le build Docker
(qui passe -DskipTests) : le gating est porte par la CI. -->
<execution>
<id>check</id>
<phase>test</phase>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>INSTRUCTION</counter>
<value>COVEREDRATIO</value>
<minimum>0.60</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions> </executions>
</plugin> </plugin>
</plugins> </plugins>

View File

@@ -1,6 +1,7 @@
package com.loremind; package com.loremind;
import com.loremind.infrastructure.desktop.DesktopSingleInstance; import com.loremind.infrastructure.desktop.DesktopSingleInstance;
import com.loremind.infrastructure.desktop.DesktopUserConfig;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
@@ -30,6 +31,12 @@ public class LoreMindApplication {
// ce qui leverait HeadlessException — on le desactive ici. En mode // ce qui leverait HeadlessException — on le desactive ici. En mode
// serveur/Docker, on reste en headless (defaut), aucun impact. // serveur/Docker, on reste en headless (defaut), aucun impact.
app.setHeadless(false); 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); app.run(args);
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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

@@ -37,8 +37,8 @@ public class RestTemplateConfig {
@Value("${brain.timeout-seconds}") long timeoutSeconds, @Value("${brain.timeout-seconds}") long timeoutSeconds,
@Value("${brain.internal-secret}") String internalSecret) { @Value("${brain.internal-secret}") String internalSecret) {
return builder return builder
.setConnectTimeout(Duration.ofSeconds(10)) .connectTimeout(Duration.ofSeconds(10))
.setReadTimeout(Duration.ofSeconds(timeoutSeconds)) .readTimeout(Duration.ofSeconds(timeoutSeconds))
.additionalInterceptors((request, body, execution) -> { .additionalInterceptors((request, body, execution) -> {
if (internalSecret != null && !internalSecret.isBlank()) { if (internalSecret != null && !internalSecret.isBlank()) {
request.getHeaders().set(INTERNAL_SECRET_HEADER, internalSecret); request.getHeaders().set(INTERNAL_SECRET_HEADER, internalSecret);
@@ -61,8 +61,8 @@ public class RestTemplateConfig {
@Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds, @Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds,
@Value("${brain.internal-secret}") String internalSecret) { @Value("${brain.internal-secret}") String internalSecret) {
return builder return builder
.setConnectTimeout(Duration.ofSeconds(10)) .connectTimeout(Duration.ofSeconds(10))
.setReadTimeout(Duration.ofSeconds(importTimeoutSeconds)) .readTimeout(Duration.ofSeconds(importTimeoutSeconds))
.additionalInterceptors((request, body, execution) -> { .additionalInterceptors((request, body, execution) -> {
if (internalSecret != null && !internalSecret.isBlank()) { if (internalSecret != null && !internalSecret.isBlank()) {
request.getHeaders().set(INTERNAL_SECRET_HEADER, internalSecret); request.getHeaders().set(INTERNAL_SECRET_HEADER, internalSecret);

View File

@@ -80,9 +80,9 @@ public final class DesktopSingleInstance {
} }
} }
/** Ouvre le navigateur par defaut sur l'URL de l'application locale. */ /** Ouvre le navigateur par defaut sur l'URL de l'application locale (port reel). */
public static void openAppInBrowser() { public static void openAppInBrowser() {
openUrl("http://localhost:" + System.getProperty("server.port", "8080") + "/"); openUrl("http://localhost:" + DesktopUserConfig.runningPort() + "/");
} }
/** Ouvre le navigateur par defaut sur une URL quelconque (sans dependance AWT). */ /** Ouvre le navigateur par defaut sur une URL quelconque (sans dependance AWT). */
@@ -105,6 +105,45 @@ public final class DesktopSingleInstance {
} }
} }
/** 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() { private static Path loremindHome() {
String home = System.getProperty("loremind.home"); String home = System.getProperty("loremind.home");
if (home != null && !home.isBlank()) return Path.of(home); if (home != null && !home.isBlank()) return Path.of(home);

View File

@@ -50,8 +50,8 @@ public class DesktopUpdateService {
@Value("${desktop.update.releases-api-url:https://api.github.com/repos/IGMLcreation/LoreMind/releases/latest}") String releasesApiUrl, @Value("${desktop.update.releases-api-url:https://api.github.com/repos/IGMLcreation/LoreMind/releases/latest}") String releasesApiUrl,
@Nullable BuildProperties buildProperties) { @Nullable BuildProperties buildProperties) {
this.http = builder this.http = builder
.setConnectTimeout(Duration.ofSeconds(5)) .connectTimeout(Duration.ofSeconds(5))
.setReadTimeout(Duration.ofSeconds(10)) .readTimeout(Duration.ofSeconds(10))
.build(); .build();
this.enabled = enabled; this.enabled = enabled;
this.releasesApiUrl = releasesApiUrl; this.releasesApiUrl = releasesApiUrl;

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

@@ -69,6 +69,22 @@ public class SystemTrayManager {
popup.addSeparator(); 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"); MenuItem quit = new MenuItem("Quitter LoreMind");
quit.addActionListener(e -> quit()); quit.addActionListener(e -> quit());
popup.add(quit); popup.add(quit);

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ import java.io.InputStream;
import java.io.UncheckedIOException; import java.io.UncheckedIOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.UUID; import java.util.UUID;
/** /**
@@ -59,6 +60,18 @@ public class FilesystemImageStorageAdapter implements ImageStorage {
} }
} }
@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 @Override
public InputStream download(String storageKey) { public InputStream download(String storageKey) {
Path source = resolveKey(storageKey); Path source = resolveKey(storageKey);

View File

@@ -4,6 +4,8 @@ import io.minio.BucketExistsArgs;
import io.minio.MakeBucketArgs; import io.minio.MakeBucketArgs;
import io.minio.MinioClient; import io.minio.MinioClient;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
@@ -24,6 +26,8 @@ import org.springframework.context.annotation.Configuration;
@ConditionalOnProperty(name = "storage.backend", havingValue = "minio", matchIfMissing = true) @ConditionalOnProperty(name = "storage.backend", havingValue = "minio", matchIfMissing = true)
public class MinioConfig { public class MinioConfig {
private static final Logger log = LoggerFactory.getLogger(MinioConfig.class);
@Value("${minio.endpoint}") @Value("${minio.endpoint}")
private String endpoint; private String endpoint;
@@ -66,12 +70,11 @@ public class MinioConfig {
boolean exists = client.bucketExists(BucketExistsArgs.builder().bucket(bucket).build()); boolean exists = client.bucketExists(BucketExistsArgs.builder().bucket(bucket).build());
if (!exists) { if (!exists) {
client.makeBucket(MakeBucketArgs.builder().bucket(bucket).build()); client.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
System.out.println("[MinIO] Bucket '" + bucket + "' cree."); log.info("[MinIO] Bucket '{}' cree.", bucket);
} }
} catch (Exception e) { } catch (Exception e) {
System.err.println("[MinIO] Initialisation impossible (endpoint=" + endpoint log.warn("[MinIO] Initialisation impossible (endpoint={}). Les uploads d'images "
+ "). Les uploads d'images echoueront tant que MinIO n'est pas joignable. " + "echoueront tant que MinIO n'est pas joignable. Cause : {}", endpoint, e.getMessage());
+ "Cause : " + e.getMessage());
} }
} }
} }

View File

@@ -54,6 +54,22 @@ public class MinioImageStorageAdapter implements ImageStorage {
} }
} }
@Override
public void store(String storageKey, String contentType, InputStream data, long sizeBytes) {
try {
minioClient.putObject(
PutObjectArgs.builder()
.bucket(bucket)
.object(storageKey)
.stream(data, sizeBytes, -1)
.contentType(contentType)
.build()
);
} catch (Exception e) {
throw new RuntimeException("Echec du store MinIO (cle imposee) : " + e.getMessage(), e);
}
}
@Override @Override
public InputStream download(String storageKey) { public InputStream download(String storageKey) {
try { try {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Base64; import java.util.Base64;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@@ -75,8 +74,8 @@ public class UpdateCheckService {
LicenseService licenseService, LicenseService licenseService,
@Nullable BuildProperties buildProperties) { @Nullable BuildProperties buildProperties) {
this.http = builder this.http = builder
.setConnectTimeout(Duration.ofSeconds(5)) .connectTimeout(Duration.ofSeconds(5))
.setReadTimeout(Duration.ofSeconds(15)) .readTimeout(Duration.ofSeconds(15))
.build(); .build();
this.registry = normalizeRegistry(registry); this.registry = normalizeRegistry(registry);
this.images = parseImages(imagesCsv); this.images = parseImages(imagesCsv);
@@ -114,32 +113,9 @@ public class UpdateCheckService {
return new UpdateStatus(true, false, true, null, statuses, Instant.now()); return new UpdateStatus(true, false, true, null, statuses, Instant.now());
} }
List<ImageStatus> statuses = new ArrayList<>(); ImagesEvaluation ev = evaluateImages(images, registry, null);
boolean anyUpdate = false; return new UpdateStatus(true, ev.anyUpdate(), ev.anyUnknown(),
boolean anyUnknown = false; currentVersion, ev.statuses(), Instant.now());
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());
} }
/** /**
@@ -169,21 +145,37 @@ public class UpdateCheckService {
(creds.get().username() + ":" + creds.get().password()).getBytes(StandardCharsets.UTF_8)); (creds.get().username() + ":" + creds.get().password()).getBytes(StandardCharsets.UTF_8));
String betaRegistry = normalizeRegistry(creds.get().registry()); 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<>(); List<ImageStatus> statuses = new ArrayList<>();
boolean anyUpdate = false; boolean anyUpdate = false;
boolean anyUnknown = false; boolean anyUnknown = false;
for (String image : betaImages) { for (String image : imageList) {
String latest = null; String latest = null;
try { try {
latest = fetchLatestSemverTag(betaRegistry, image, basicAuth); latest = fetchLatestSemverTag(registryUrl, image, authHeader);
} catch (Exception e) { } catch (Exception e) {
log.warn("Beta tags fetch failed for {}: {}", image, e.getMessage()); log.warn("Tags fetch failed for {}: {}", image, e.getMessage());
} }
ImageStatusKind kind; ImageStatusKind kind;
if (latest == null) { if (latest == null) {
kind = ImageStatusKind.UNKNOWN; kind = ImageStatusKind.UNKNOWN;
anyUnknown = true; anyUnknown = true;
} else if (currentVersion != null && compareSemver(currentVersion, latest) >= 0) { } else if (currentVersion != null && SemverComparator.compareSemver(currentVersion, latest) >= 0) {
kind = ImageStatusKind.UP_TO_DATE; kind = ImageStatusKind.UP_TO_DATE;
} else { } else {
kind = ImageStatusKind.UPDATE_AVAILABLE; kind = ImageStatusKind.UPDATE_AVAILABLE;
@@ -191,7 +183,7 @@ public class UpdateCheckService {
} }
statuses.add(new ImageStatus(image, currentVersion, latest, kind)); 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() { public void apply() {
@@ -209,6 +201,10 @@ public class UpdateCheckService {
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Registry HTTP API v2 - tags listing + auth bearer // 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); body = tagsCall(url, bearerHeaders);
} }
if (body == null || body.tags == null || body.tags.isEmpty()) return null; 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) { private TagsListResponse tagsCall(String url, HttpHeaders headers) {
@@ -253,69 +249,6 @@ public class UpdateCheckService {
return resp.getBody(); 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 * Suit le challenge {@code WWW-Authenticate: Bearer realm="..."} pour obtenir
* un token. Si {@code basicAuth} est fourni, l'utilise pour l'echange (cas * 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; if (wwwAuth == null) return null;
String prefix = "Bearer "; String prefix = "Bearer ";
if (!wwwAuth.regionMatches(true, 0, prefix, 0, prefix.length())) return null; 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"); String realm = params.get("realm");
if (realm == null) return null; if (realm == null) return null;
StringBuilder url = new StringBuilder(realm); 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) { private static String normalizeRegistry(String value) {
if (value == null || value.isBlank()) return ""; if (value == null || value.isBlank()) return "";
String v = value.trim(); 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

@@ -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.ChatStreamCampaignRequestDTO;
import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamRequestDTO; import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamRequestDTO;
import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamSessionRequestDTO; 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.beans.factory.annotation.Qualifier;
import org.springframework.core.task.TaskExecutor; import org.springframework.core.task.TaskExecutor;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@@ -162,7 +163,7 @@ public class AiChatController {
private void sendToken(SseEmitter emitter, String token) { private void sendToken(SseEmitter emitter, String token) {
try { try {
emitter.send(SseEmitter.event() emitter.send(SseEmitter.event()
.data("{\"token\":" + jsonEscape(token) + "}")); .data("{\"token\":" + SseJson.escape(token) + "}"));
} catch (IOException e) { } catch (IOException e) {
emitter.completeWithError(e); emitter.completeWithError(e);
} }
@@ -182,7 +183,7 @@ public class AiChatController {
String message = error.getMessage() != null ? error.getMessage() : error.getClass().getSimpleName(); String message = error.getMessage() != null ? error.getMessage() : error.getClass().getSimpleName();
emitter.send(SseEmitter.event() emitter.send(SseEmitter.event()
.name("error") .name("error")
.data("{\"message\":" + jsonEscape(message) + "}")); .data("{\"message\":" + SseJson.escape(message) + "}"));
emitter.complete(); emitter.complete();
} catch (IOException ioe) { } catch (IOException ioe) {
emitter.completeWithError(ioe); emitter.completeWithError(ioe);
@@ -191,31 +192,6 @@ public class AiChatController {
// --- Utilitaires -------------------------------------------------------- // --- 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) { private List<ChatMessage> toDomainMessages(List<ChatMessageDTO> dtos) {
if (dtos == null) return List.of(); if (dtos == null) return List.of();
return dtos.stream() 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.GameSystem;
import com.loremind.domain.gamesystemcontext.RulesImportResult; import com.loremind.domain.gamesystemcontext.RulesImportResult;
import com.loremind.domain.gamesystemcontext.ports.RulesImportException; import com.loremind.domain.gamesystemcontext.ports.RulesImportException;
import com.loremind.domain.shared.template.TemplateField;
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO; import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
import com.loremind.infrastructure.web.dto.gamesystemcontext.RulesImportResponseDTO; 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.GameSystemMapper;
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper; import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -23,7 +21,6 @@ import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
@@ -261,18 +258,11 @@ public class GameSystemController {
dto.getName(), dto.getName(),
dto.getDescription(), dto.getDescription(),
dto.getRulesMarkdown(), dto.getRulesMarkdown(),
toDomainFields(dto.getCharacterTemplate()), templateFieldMapper.toDomainList(dto.getCharacterTemplate()),
toDomainFields(dto.getNpcTemplate()), templateFieldMapper.toDomainList(dto.getNpcTemplate()),
toDomainFields(dto.getEnemyTemplate()), templateFieldMapper.toDomainList(dto.getEnemyTemplate()),
dto.getAuthor(), dto.getAuthor(),
dto.isPublic() 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.NotebookSource;
import com.loremind.domain.campaigncontext.ports.NotebookChatStreamer; import com.loremind.domain.campaigncontext.ports.NotebookChatStreamer;
import com.loremind.domain.campaigncontext.ports.NotebookException; import com.loremind.domain.campaigncontext.ports.NotebookException;
import com.loremind.infrastructure.web.sse.SseJson;
import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.task.TaskExecutor; import org.springframework.core.task.TaskExecutor;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@@ -211,7 +212,7 @@ public class NotebookController {
private void sendToken(SseEmitter emitter, String token) { private void sendToken(SseEmitter emitter, String token) {
try { 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) { } catch (IOException | IllegalStateException e) {
// flux fermé/expiré : on cesse d'écrire // flux fermé/expiré : on cesse d'écrire
} }
@@ -247,32 +248,13 @@ public class NotebookController {
private void fail(SseEmitter emitter, Throwable error) { private void fail(SseEmitter emitter, Throwable error) {
try { try {
String message = error.getMessage() != null ? error.getMessage() : error.getClass().getSimpleName(); 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(); emitter.complete();
} catch (IOException | IllegalStateException e) { } catch (IOException | IllegalStateException e) {
// flux déjà fermé/expiré : rien à envoyer // 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 CreateRequest(String campaignId, String name) {}
public record RenameRequest(String name) {} public record RenameRequest(String name) {}
/** /**

View File

@@ -1,14 +1,9 @@
package com.loremind.infrastructure.web.mapper; package com.loremind.infrastructure.web.mapper;
import com.loremind.domain.gamesystemcontext.GameSystem; 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.gamesystemcontext.GameSystemDTO;
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
@Component @Component
public class GameSystemMapper { public class GameSystemMapper {
@@ -25,9 +20,9 @@ public class GameSystemMapper {
dto.setName(g.getName()); dto.setName(g.getName());
dto.setDescription(g.getDescription()); dto.setDescription(g.getDescription());
dto.setRulesMarkdown(g.getRulesMarkdown()); dto.setRulesMarkdown(g.getRulesMarkdown());
dto.setCharacterTemplate(toDTOList(g.getCharacterTemplate())); dto.setCharacterTemplate(fieldMapper.toDTOList(g.getCharacterTemplate()));
dto.setNpcTemplate(toDTOList(g.getNpcTemplate())); dto.setNpcTemplate(fieldMapper.toDTOList(g.getNpcTemplate()));
dto.setEnemyTemplate(toDTOList(g.getEnemyTemplate())); dto.setEnemyTemplate(fieldMapper.toDTOList(g.getEnemyTemplate()));
dto.setAuthor(g.getAuthor()); dto.setAuthor(g.getAuthor());
dto.setPublic(g.isPublic()); dto.setPublic(g.isPublic());
return dto; return dto;
@@ -40,25 +35,11 @@ public class GameSystemMapper {
.name(dto.getName()) .name(dto.getName())
.description(dto.getDescription()) .description(dto.getDescription())
.rulesMarkdown(dto.getRulesMarkdown()) .rulesMarkdown(dto.getRulesMarkdown())
.characterTemplate(toDomainList(dto.getCharacterTemplate())) .characterTemplate(fieldMapper.toDomainList(dto.getCharacterTemplate()))
.npcTemplate(toDomainList(dto.getNpcTemplate())) .npcTemplate(fieldMapper.toDomainList(dto.getNpcTemplate()))
.enemyTemplate(toDomainList(dto.getEnemyTemplate())) .enemyTemplate(fieldMapper.toDomainList(dto.getEnemyTemplate()))
.author(dto.getAuthor()) .author(dto.getAuthor())
.isPublic(dto.isPublic()) .isPublic(dto.isPublic())
.build(); .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); 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

@@ -10,6 +10,22 @@
# Surcharge UNIQUEMENT ce qui differe du profil par defaut (application.properties) : # Surcharge UNIQUEMENT ce qui differe du profil par defaut (application.properties) :
# le reste (timeouts, multipart, licensing...) est herite tel quel. # 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 --------- # --- Base de donnees : H2 en mode fichier, compatibilite PostgreSQL ---------
# MODE=PostgreSQL : H2 interprete le SQL PostgreSQL -> les MEMES migrations # MODE=PostgreSQL : H2 interprete le SQL PostgreSQL -> les MEMES migrations
# Flyway (ecrites en SQL Postgres) tournent ici comme sur la prod Postgres. # Flyway (ecrites en SQL Postgres) tournent ici comme sur la prod Postgres.
@@ -56,7 +72,8 @@ brain.sidecar.command=${BRAIN_SIDECAR_COMMAND:}
# --- Admin (localhost uniquement) ------------------------------------------ # --- Admin (localhost uniquement) ------------------------------------------
# Application mono-utilisateur sur le poste : l'utilisateur EST l'admin. # Application mono-utilisateur sur le poste : l'utilisateur EST l'admin.
# Identifiants par defaut surchargables ; non exposes hors de la machine. # Defauts admin/admin, SURCHARGEABLES par l'utilisateur dans
# ~/.loremind/loremind.properties (cf. spring.config.import ci-dessus).
admin.username=${ADMIN_USERNAME:admin} admin.username=${ADMIN_USERNAME:admin}
admin.password=${ADMIN_PASSWORD:admin} admin.password=${ADMIN_PASSWORD:admin}

View File

@@ -29,6 +29,9 @@ spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.hibernate.ddl-auto=validate spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=true spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true spring.jpa.properties.hibernate.format_sql=true
# Fixe explicitement open-in-view (defaut Spring = true) : meme comportement,
# mais supprime l'avertissement "spring.jpa.open-in-view is enabled by default".
spring.jpa.open-in-view=true
# ============================================================================ # ============================================================================
# Flyway : migrations de schema versionnees (src/main/resources/db/migration). # Flyway : migrations de schema versionnees (src/main/resources/db/migration).

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; package com.loremind.infrastructure.updates;
import com.loremind.application.licensing.LicenseService;
import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatus; import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatus;
import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatusKind; import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatusKind;
import com.loremind.infrastructure.updates.UpdateCheckService.TagsListResponse; import com.loremind.infrastructure.updates.UpdateCheckService.TagsListResponse;
@@ -194,52 +193,6 @@ public class UpdateCheckServiceTest {
} }
} }
// ----------------------------------------------------------------- // NB : les utilitaires semver (parseSemver / findMaxSemver / compareSemver) sont
// Utilitaires semver // désormais dans SemverComparator et testés par SemverComparatorTest.
// -----------------------------------------------------------------
@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()));
}
} }

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

@@ -15,7 +15,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.core.task.TaskExecutor; import org.springframework.core.task.TaskExecutor;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
@@ -53,10 +53,10 @@ class AiChatControllerTest {
@Autowired private MockMvc mockMvc; @Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper; @Autowired private ObjectMapper objectMapper;
@MockBean private StreamChatForLoreUseCase loreUseCase; @MockitoBean private StreamChatForLoreUseCase loreUseCase;
@MockBean private StreamChatForCampaignUseCase campaignUseCase; @MockitoBean private StreamChatForCampaignUseCase campaignUseCase;
@MockBean private StreamChatForSessionUseCase sessionUseCase; @MockitoBean private StreamChatForSessionUseCase sessionUseCase;
@MockBean(name = "applicationTaskExecutor") private TaskExecutor taskExecutor; @MockitoBean(name = "applicationTaskExecutor") private TaskExecutor taskExecutor;
@BeforeEach @BeforeEach
void setUp() { void setUp() {

View File

@@ -6,7 +6,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.core.task.TaskExecutor; import org.springframework.core.task.TaskExecutor;
import org.springframework.mock.web.MockMultipartFile; import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
@@ -42,8 +42,8 @@ class CampaignAdaptControllerTest {
@Autowired private MockMvc mockMvc; @Autowired private MockMvc mockMvc;
@MockBean private CampaignAdaptService campaignAdaptService; @MockitoBean private CampaignAdaptService campaignAdaptService;
@MockBean(name = "applicationTaskExecutor") private TaskExecutor taskExecutor; @MockitoBean(name = "applicationTaskExecutor") private TaskExecutor taskExecutor;
@BeforeEach @BeforeEach
void setUp() { void setUp() {

View File

@@ -10,7 +10,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.core.task.TaskExecutor; import org.springframework.core.task.TaskExecutor;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile; import org.springframework.mock.web.MockMultipartFile;
@@ -58,8 +58,8 @@ class CampaignImportControllerTest {
@Autowired private MockMvc mockMvc; @Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper; @Autowired private ObjectMapper objectMapper;
@MockBean private CampaignImportService campaignImportService; @MockitoBean private CampaignImportService campaignImportService;
@MockBean(name = "applicationTaskExecutor") private TaskExecutor taskExecutor; @MockitoBean(name = "applicationTaskExecutor") private TaskExecutor taskExecutor;
private static final String CAMPAIGN_ID = "camp-1"; private static final String CAMPAIGN_ID = "camp-1";

View File

@@ -5,7 +5,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -25,7 +25,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
class ConfigControllerTest { class ConfigControllerTest {
@Autowired private MockMvc mockMvc; @Autowired private MockMvc mockMvc;
@MockBean private UpdateCheckService updates; @MockitoBean private UpdateCheckService updates;
@Test @Test
void getPublicConfig_returns200_updateCheckEnabledTrue() throws Exception { void getPublicConfig_returns200_updateCheckEnabledTrue() throws Exception {

View File

@@ -11,7 +11,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -41,7 +41,7 @@ class ConversationControllerTest {
@Autowired private ObjectMapper objectMapper; @Autowired private ObjectMapper objectMapper;
@Autowired private ConversationRepository conversationRepository; @Autowired private ConversationRepository conversationRepository;
@MockBean private ConversationTitleGenerator titleGenerator; @MockitoBean private ConversationTitleGenerator titleGenerator;
@Test @Test
void create_withLoreAnchor_returns200() throws Exception { void create_withLoreAnchor_returns200() throws Exception {

View File

@@ -12,7 +12,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.core.task.TaskExecutor; import org.springframework.core.task.TaskExecutor;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile; import org.springframework.mock.web.MockMultipartFile;
@@ -52,8 +52,8 @@ class GameSystemControllerTest {
@Autowired private MockMvc mockMvc; @Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper; @Autowired private ObjectMapper objectMapper;
@MockBean private RulesPdfImporter rulesPdfImporter; @MockitoBean private RulesPdfImporter rulesPdfImporter;
@MockBean(name = "applicationTaskExecutor") private TaskExecutor taskExecutor; @MockitoBean(name = "applicationTaskExecutor") private TaskExecutor taskExecutor;
@BeforeEach @BeforeEach
void setUp() { void setUp() {

View File

@@ -6,7 +6,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.mock.web.MockMultipartFile; import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
@@ -42,7 +42,7 @@ class ImageControllerTest {
@Autowired private MockMvc mockMvc; @Autowired private MockMvc mockMvc;
@MockBean private ImageService imageService; @MockitoBean private ImageService imageService;
private Image sampleImage() { private Image sampleImage() {
return Image.builder() return Image.builder()

View File

@@ -15,7 +15,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -49,7 +49,7 @@ class ItemCatalogControllerTest {
@Autowired private ItemCatalogRepository catalogRepository; @Autowired private ItemCatalogRepository catalogRepository;
@Autowired private CampaignRepository campaignRepository; @Autowired private CampaignRepository campaignRepository;
@MockBean private ItemCatalogGenerator generator; @MockitoBean private ItemCatalogGenerator generator;
private String campaignId; private String campaignId;

View File

@@ -10,7 +10,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
@@ -54,8 +54,8 @@ class LicenseControllerTest {
@Autowired private MockMvc mockMvc; @Autowired private MockMvc mockMvc;
@MockBean private LicenseService licenseService; @MockitoBean private LicenseService licenseService;
@MockBean private ChannelSwitcherService channelSwitcher; @MockitoBean private ChannelSwitcherService channelSwitcher;
private LicenseSnapshot validSnapshot() { private LicenseSnapshot validSnapshot() {
return new LicenseSnapshot( return new LicenseSnapshot(

View File

@@ -14,7 +14,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.core.task.TaskExecutor; import org.springframework.core.task.TaskExecutor;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile; import org.springframework.mock.web.MockMultipartFile;
@@ -59,9 +59,9 @@ class NotebookControllerTest {
@Autowired private NotebookRepository notebookRepository; @Autowired private NotebookRepository notebookRepository;
@Autowired private CampaignRepository campaignRepository; @Autowired private CampaignRepository campaignRepository;
@MockBean private NotebookIndexer indexer; @MockitoBean private NotebookIndexer indexer;
@MockBean private NotebookChatStreamer chatStreamer; @MockitoBean private NotebookChatStreamer chatStreamer;
@MockBean(name = "applicationTaskExecutor") private TaskExecutor taskExecutor; @MockitoBean(name = "applicationTaskExecutor") private TaskExecutor taskExecutor;
private String campaignId; private String campaignId;

View File

@@ -6,7 +6,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import java.util.Map; import java.util.Map;
@@ -36,7 +36,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
class PageGenerationControllerTest { class PageGenerationControllerTest {
@Autowired private MockMvc mockMvc; @Autowired private MockMvc mockMvc;
@MockBean private GeneratePageValuesUseCase generatePageValuesUseCase; @MockitoBean private GeneratePageValuesUseCase generatePageValuesUseCase;
@Test @Test
void generate_returns200_withSuggestions() throws Exception { void generate_returns200_withSuggestions() throws Exception {

View File

@@ -16,7 +16,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -50,7 +50,7 @@ class RandomTableControllerTest {
@Autowired private RandomTableRepository tableRepository; @Autowired private RandomTableRepository tableRepository;
@Autowired private CampaignRepository campaignRepository; @Autowired private CampaignRepository campaignRepository;
@MockBean private RandomTableGenerator generator; @MockitoBean private RandomTableGenerator generator;
private String campaignId; private String campaignId;

View File

@@ -5,7 +5,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
@@ -36,7 +36,7 @@ class UpdatesControllerDemoModeTest {
.encodeToString("test-admin:test-admin-password".getBytes(StandardCharsets.UTF_8)); .encodeToString("test-admin:test-admin-password".getBytes(StandardCharsets.UTF_8));
@Autowired private MockMvc mockMvc; @Autowired private MockMvc mockMvc;
@MockBean private UpdateCheckService updates; @MockitoBean private UpdateCheckService updates;
@Test @Test
void check_returns403_inDemoMode() throws Exception { void check_returns403_inDemoMode() throws Exception {

View File

@@ -9,7 +9,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
@@ -42,7 +42,7 @@ class UpdatesControllerTest {
.encodeToString("test-admin:test-admin-password".getBytes(StandardCharsets.UTF_8)); .encodeToString("test-admin:test-admin-password".getBytes(StandardCharsets.UTF_8));
@Autowired private MockMvc mockMvc; @Autowired private MockMvc mockMvc;
@MockBean private UpdateCheckService updates; @MockitoBean private UpdateCheckService updates;
private UpdateStatus sampleUpdate() { private UpdateStatus sampleUpdate() {
return new UpdateStatus(true, true, false, "1.0.0", return new UpdateStatus(true, true, false, "1.0.0",

View File

@@ -10,7 +10,6 @@ spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:loremind_test}
spring.datasource.driver-class-name=org.postgresql.Driver spring.datasource.driver-class-name=org.postgresql.Driver
# Configuration JPA pour les tests : schema recree a chaque run, pas de logs SQL. # Configuration JPA pour les tests : schema recree a chaque run, pas de logs SQL.
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.hibernate.ddl-auto=create-drop spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=false spring.jpa.show-sql=false
@@ -19,7 +18,7 @@ spring.jpa.show-sql=false
spring.flyway.enabled=false spring.flyway.enabled=false
# Pool Hikari volontairement minuscule en test : la suite cree de nombreux # Pool Hikari volontairement minuscule en test : la suite cree de nombreux
# contextes Spring distincts (combinaisons de @MockBean / @TestPropertySource), # contextes Spring distincts (combinaisons de @MockitoBean / @TestPropertySource),
# tous gardes en cache simultanement. Avec le pool par defaut (10), on epuisait # tous gardes en cache simultanement. Avec le pool par defaut (10), on epuisait
# les connexions Postgres ("remaining connection slots are reserved"). 2 par # les connexions Postgres ("remaining connection slots are reserved"). 2 par
# contexte suffit (tests sequentiels) et borne le total bien sous max_connections. # contexte suffit (tests sequentiels) et borne le total bien sous max_connections.
@@ -40,3 +39,27 @@ minio.endpoint=http://localhost:9000
minio.access-key=test minio.access-key=test
minio.secret-key=test minio.secret-key=test
minio.bucket=test-bucket minio.bucket=test-bucket
# open-in-view fixe explicitement (defaut true) : supprime l'avertissement au boot.
spring.jpa.open-in-view=true
# Le daemon de refresh de licence (@Scheduled) n'a rien a faire pendant les tests :
# son tick echouait en arriere-plan (table licenses droppee par le create-drop
# partage entre contextes) => stacktraces parasites. On le desactive ici.
licensing.refresh.enabled=false
# --- Sortie de test LISIBLE ---------------------------------------------------
# Pas de banniere Spring (repetee a chaque contexte), et seuls les WARN/ERROR
# remontent : la masse d'INFO de demarrage (Spring/Hibernate/Hikari, "Started
# XxxTest", seeder...) n'apporte rien et noyait les vrais signaux.
spring.main.banner-mode=off
logging.level.root=WARN
# Tests de CHEMIN D'ERREUR : les controleurs loggent VOLONTAIREMENT l'echec (ex:
# Watchtower injoignable -> 502, Brain down -> 502) AVANT de renvoyer le bon statut
# HTTP teste. L'exception est attrapee, jamais propagee : ce sont des logs attendus.
logging.level.com.loremind.infrastructure.web.controller=OFF
# WARN attendus sur des tests de robustesse (registry injoignable, suppression best-effort).
logging.level.com.loremind.infrastructure.updates.UpdateCheckService=ERROR
logging.level.com.loremind.infrastructure.ai.BrainNotebookIndexClient=ERROR
# Le warning MinIO au @PostConstruct (serveur absent en test) est ATTENDU.
logging.level.com.loremind.infrastructure.storage=ERROR

View File

@@ -137,6 +137,27 @@ if (-not $SkipBrain) {
Copy-Item (Join-Path $BrainDir 'app') (Join-Path $BrainEmbed 'app') -Recurse 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') 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)" Write-Ok "Brain prepare (brain/dist-embed : python embeddable + deps + sources)"
} else { Write-Step "Brain : saute (--SkipBrain)" } } else { Write-Step "Brain : saute (--SkipBrain)" }
@@ -183,6 +204,13 @@ New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
# ne contient de virgule) sont bindes en List<String> par Spring. # ne contient de virgule) sont bindes en List<String> par Spring.
$brainCmd = '$APPDIR\brain\python\python.exe,$APPDIR\brain\run_local.py' $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 # 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. # etre rediriges vers un fichier via une option ulterieure si besoin de debug.
jpackage ` jpackage `
@@ -197,6 +225,7 @@ jpackage `
--java-options '-Dspring.profiles.active=local' ` --java-options '-Dspring.profiles.active=local' `
--java-options "-Dbrain.sidecar.command=$brainCmd" ` --java-options "-Dbrain.sidecar.command=$brainCmd" `
--win-per-user-install ` --win-per-user-install `
--win-upgrade-uuid 'a7c4e1d2-9b3f-4e6a-8d05-1f2c3b4a5e6d' `
--win-menu --win-menu-group 'LoreMind' ` --win-menu --win-menu-group 'LoreMind' `
--win-shortcut --win-dir-chooser --win-shortcut --win-dir-chooser

View File

@@ -52,7 +52,8 @@
"development": { "development": {
"optimization": false, "optimization": false,
"sourceMap": true, "sourceMap": true,
"namedChunks": true "namedChunks": true,
"outputHashing": "none"
} }
}, },
"defaultConfiguration": "production" "defaultConfiguration": "production"

View File

@@ -35,7 +35,7 @@ test.describe('NPC edit', () => {
await page.getByLabel(/Nom du PNJ/i).fill(newName); await page.getByLabel(/Nom du PNJ/i).fill(newName);
await page.getByRole('button', { name: /^Enregistrer$/i }).click(); await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
// Retour à la campagne après save // Retour à la campagne après save
await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`)); await expect(page).toHaveURL(new RegExp(`/campaigns/${campaign.id}$`));
@@ -48,7 +48,7 @@ test.describe('NPC edit', () => {
await page.goto(`/campaigns/${campaign.id}/npcs/${npc.id}/edit`); await page.goto(`/campaigns/${campaign.id}/npcs/${npc.id}/edit`);
const nameField = page.getByLabel(/Nom du PNJ/i); const nameField = page.getByLabel(/Nom du PNJ/i);
const saveBtn = page.getByRole('button', { name: /^Enregistrer$/i }); const saveBtn = page.getByRole('button', { name: /^Sauvegarder$/i });
await expect(saveBtn).toBeEnabled(); await expect(saveBtn).toBeEnabled();
await nameField.fill(''); await nameField.fill('');

View File

@@ -42,7 +42,7 @@ test.describe('GameSystem edit', () => {
await page.getByLabel(/^Nom/i).fill(newName); await page.getByLabel(/^Nom/i).fill(newName);
await page.getByLabel(/Description courte/i).fill(newDescription); await page.getByLabel(/Description courte/i).fill(newDescription);
await page.getByRole('button', { name: /^Enregistrer$/i }).click(); await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
// Retour a la liste apres save. // Retour a la liste apres save.
await expect(page).toHaveURL(/\/game-systems$/); await expect(page).toHaveURL(/\/game-systems$/);
@@ -57,7 +57,7 @@ test.describe('GameSystem edit', () => {
await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name); await expect(page.getByLabel(/^Nom/i)).toHaveValue(gs.name);
const nameField = page.getByLabel(/^Nom/i); const nameField = page.getByLabel(/^Nom/i);
const saveBtn = page.getByRole('button', { name: /^Enregistrer$/i }); const saveBtn = page.getByRole('button', { name: /^Sauvegarder$/i });
await expect(saveBtn).toBeEnabled(); await expect(saveBtn).toBeEnabled();
await nameField.fill(''); await nameField.fill('');

View File

@@ -38,7 +38,7 @@ test.describe('GameSystem rule sections editor', () => {
await card.locator('.section-content').fill(sectionContent); await card.locator('.section-content').fill(sectionContent);
// Save + retour a la liste. // Save + retour a la liste.
await page.getByRole('button', { name: /^Enregistrer$/i }).click(); await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
await expect(page).toHaveURL(/\/game-systems$/); await expect(page).toHaveURL(/\/game-systems$/);
// Verification cote API : le markdown contient bien la section + son contenu. // Verification cote API : le markdown contient bien la section + son contenu.

View File

@@ -44,7 +44,7 @@ test.describe('GameSystem template fields editor (PJ / PNJ)', () => {
await expect(row.locator('.tfe-name')).toHaveValue('Histoire'); await expect(row.locator('.tfe-name')).toHaveValue('Histoire');
// Save → retour a la liste. // Save → retour a la liste.
await page.getByRole('button', { name: /^Enregistrer$/i }).click(); await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
await expect(page).toHaveURL(/\/game-systems$/); await expect(page).toHaveURL(/\/game-systems$/);
// Verification API : le champ est bien dans characterTemplate. // Verification API : le champ est bien dans characterTemplate.
@@ -88,7 +88,7 @@ test.describe('GameSystem template fields editor (PJ / PNJ)', () => {
await expect(tfe(page, 'PJ').locator('.tfe-item').first().locator('.tfe-name')).toHaveValue('Histoire'); await expect(tfe(page, 'PJ').locator('.tfe-item').first().locator('.tfe-name')).toHaveValue('Histoire');
await expect(tfe(page, 'PNJ').locator('.tfe-item').first().locator('.tfe-name')).toHaveValue('Motivation'); await expect(tfe(page, 'PNJ').locator('.tfe-item').first().locator('.tfe-name')).toHaveValue('Motivation');
await page.getByRole('button', { name: /^Enregistrer$/i }).click(); await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
await expect(page).toHaveURL(/\/game-systems$/); await expect(page).toHaveURL(/\/game-systems$/);
const persisted = await request.get(`/api/game-systems/${gs.id}`).then((r) => r.json()); const persisted = await request.get(`/api/game-systems/${gs.id}`).then((r) => r.json());

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