Compare commits

...

14 Commits

Author SHA1 Message Date
c77c0bc994 référencement des ennemis dans les lieux d'une quête ou d'un chapitre
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m33s
Build & Push Images / build (core) (push) Successful in 1m58s
Build & Push Images / build-switcher (push) Successful in 17s
Build & Push Images / build (web) (push) Successful in 1m53s
2026-06-13 11:08:16 +02:00
6035df262d Ajout de la possibilité de faire des stats blocs pour tout ce qui est ennemis / créatures adverses.
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m32s
Build & Push Images / build (core) (push) Successful in 1m52s
Build & Push Images / build-switcher (push) Successful in 20s
Build & Push Images / build (web) (push) Successful in 1m48s
Dorénavant, l'IA est capable de prendre en compte le format des quêtes, chapitres, Arc.... pour proposer des blocs plus complets.
Les ennemis sont également référençables directement dans la campagne.
Les références vers les ennemis dans la partie "donjon" est en cours d'ajout
2026-06-12 23:38:43 +02:00
809e00ce49 Ajout de la possibilité d'archiver le chat dans l'atelier PDF + IA, ainsi que de référencer l'archive dans la conversation actuelle.
All checks were successful
Build & Push Images / build (core) (push) Successful in 1m47s
Build & Push Images / build (brain) (push) Successful in 1m52s
Build & Push Images / build-switcher (push) Successful in 27s
Build & Push Images / build (web) (push) Successful in 1m57s
Le chat est limité à 16 000 caractères pour l'archive et le début est tronqué pour laisser plutôt la conclusion en visibilité.
Passage bêta 0.12.6
2026-06-12 16:57:57 +02:00
bc0cbb0f7b Correction sur le NotebookController....
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m34s
Build & Push Images / build (core) (push) Successful in 2m1s
Build & Push Images / build-switcher (push) Successful in 20s
Build & Push Images / build (web) (push) Successful in 1m50s
2026-06-12 16:05:34 +02:00
6740ed2177 Mise en place de la sélection des source que l'on souhaite que ce soit la partie RAG ou la partie analyse approfondie : on est plus obligé d'envoyer tous les PDFs qu'on a dans la partie atelier PDF + IA.
Some checks failed
Build & Push Images / build (brain) (push) Successful in 1m41s
Build & Push Images / build (core) (push) Failing after 1m46s
Build & Push Images / build-switcher (push) Successful in 26s
Build & Push Images / build (web) (push) Successful in 1m54s
Les réponses ne ce baseront que sur les sources que l'on aura cocher au préalable
2026-06-12 15:58:45 +02:00
8cc90bd24d Amélioration du feedback pendant les imports sur les PDF
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m38s
Build & Push Images / build (core) (push) Successful in 1m59s
Build & Push Images / build-switcher (push) Successful in 17s
Build & Push Images / build (web) (push) Successful in 1m54s
passage en 0.12.4-beta
2026-06-12 14:35:10 +02:00
14fc1c28fe Ajout de tableaux dans la partie templates / pages de lore : possibilité d'ajouter un tableau multiligne (par exemple pour faire des tableaux d'objets dans les boutiques) ; tableau type liste clé / valeur (pour des statistiques et ce genre de chose).
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m36s
Build & Push Images / build (core) (push) Successful in 1m53s
Build & Push Images / build-switcher (push) Successful in 25s
Build & Push Images / build (web) (push) Successful in 1m59s
Ajout de la possibilité de lié un PNJ à une page de lore
Ajout d'un graphe de liaison entre lore / PNJs
Passage en v.0.12.3-beta
2026-06-12 13:23:46 +02:00
7f519588b6 Amélioration de l'exploitation des PDF par l'IA
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m44s
Build & Push Images / build (core) (push) Successful in 2m0s
Build & Push Images / build-switcher (push) Successful in 23s
Build & Push Images / build (web) (push) Successful in 1m50s
Amélioration des feedbacks en cas d'erreur d'exploitation des PDF
2026-06-12 01:28:45 +02:00
0799c850ec On essai de contraindre le modèle utilisé par ollama à répondre dans un certain format et ne plus mettre à l'interieur sa "réflexion"
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m30s
Build & Push Images / build (core) (push) Successful in 1m56s
Build & Push Images / build-switcher (push) Successful in 26s
Build & Push Images / build (web) (push) Successful in 1m44s
2026-06-11 15:51:22 +02:00
113df6a391 amélioration import ollama
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m59s
Build & Push Images / build (core) (push) Successful in 1m59s
Build & Push Images / build-switcher (push) Successful in 29s
Build & Push Images / build (web) (push) Successful in 1m59s
2026-06-11 15:29:28 +02:00
a1f3b9b796 Améliorations sur l'utilisation de l'IA pour l'exploitation des PDF, que ce soit la partie cloud ou la partie ollama + montée en version
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m43s
Build & Push Images / build (core) (push) Successful in 1m51s
Build & Push Images / build-switcher (push) Successful in 26s
Build & Push Images / build (web) (push) Successful in 1m46s
2026-06-11 01:31:24 +02:00
cff2ceb0b9 Ajout des dépendance dans le package.json pour forcer les dépendances sur les bonnes versions
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m45s
Build & Push Images / build (core) (push) Successful in 1m58s
Build & Push Images / build-switcher (push) Successful in 25s
Build & Push Images / build (web) (push) Successful in 1m51s
2026-06-10 16:38:25 +02:00
d1a11823bc Regénération du package-lock avec les bonnes versions
Some checks failed
Build & Push Images / build (brain) (push) Successful in 1m34s
Build & Push Images / build (core) (push) Successful in 1m57s
Build & Push Images / build-switcher (push) Successful in 20s
Build & Push Images / build (web) (push) Failing after 54s
2026-06-10 16:32:37 +02:00
e1da369cfa Regénération du package-lock pour résorber les problème sur git actions
Some checks failed
Build & Push Images / build (brain) (push) Successful in 1m42s
Build & Push Images / build (core) (push) Successful in 1m58s
Build & Push Images / build-switcher (push) Successful in 24s
Build & Push Images / build (web) (push) Failing after 46s
2026-06-10 16:22:46 +02:00
181 changed files with 6427 additions and 812 deletions

View File

@@ -5,6 +5,7 @@ port (LLM, embeddings, extracteur PDF), en fonction des Settings — modifiables
à chaud depuis l'écran Paramètres de l'UI. Les routers ne connaissent que les à chaud depuis l'écran Paramètres de l'UI. Les routers ne connaissent que les
ports et les use cases, jamais Ollama/Mistral/etc. ports et les use cases, jamais Ollama/Mistral/etc.
""" """
import logging
from typing import Annotated from typing import Annotated
from fastapi import Depends, HTTPException from fastapi import Depends, HTTPException
@@ -29,11 +30,39 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
from app.infrastructure.openrouter_adapter import OpenRouterLLMProvider from app.infrastructure.openrouter_adapter import OpenRouterLLMProvider
from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor
logger = logging.getLogger(__name__)
# Extracteur PDF partagé : la détection OCR (version Tesseract) a un coût # Extracteur PDF partagé : la détection OCR (version Tesseract) a un coût
# (subprocess) qu'on ne veut pas payer à chaque requête → singleton module. # (subprocess) qu'on ne veut pas payer à chaque requête → singleton module.
_PDF_EXTRACTOR = PyMuPdfTextExtractor() _PDF_EXTRACTOR = PyMuPdfTextExtractor()
def _effective_import_chunk_tokens(settings: Settings) -> int:
"""Taille de morceau réellement utilisable pour l'import.
Avec Ollama, le morceau (entrée) ET sa réécriture en sections (sortie ≈ même
taille) doivent tenir ensemble dans `num_ctx` — sinon Ollama remplit la fenêtre
avec le prompt et la génération s'arrête après quelques tokens (JSON coupé net,
morceau perdu). Budget : entrée×~1.3 (les morceaux sont mesurés en tokens
cl100k, plus compacts que les tokenizers locaux) + consignes + sortie×~1.4
≤ num_ctx → morceau ≤ (num_ctx 800) / 2.7. On plafonne, avec un log pour
rester transparent. Les providers cloud (gros contexte) ne sont pas plafonnés.
"""
requested = settings.import_chunk_tokens
if settings.llm_provider != "ollama":
return requested
cap = max(1000, int((settings.llm_num_ctx - 800) / 2.7))
if requested > cap:
logger.warning(
"Taille de morceau d'import réduite de %s à %s tokens : avec num_ctx=%s, "
"un morceau plus gros ne laisserait pas la place à la sortie du modèle "
"(génération coupée). Augmentez num_ctx pour utiliser de plus gros morceaux.",
requested, cap, settings.llm_num_ctx,
)
return cap
return requested
def get_llm_provider( def get_llm_provider(
settings: Annotated[Settings, Depends(get_settings)], settings: Annotated[Settings, Depends(get_settings)],
) -> LLMProvider: ) -> LLMProvider:
@@ -82,8 +111,14 @@ def get_import_rules_use_case(
settings: Annotated[Settings, Depends(get_settings)], settings: Annotated[Settings, Depends(get_settings)],
) -> ImportRulesUseCase: ) -> ImportRulesUseCase:
"""Factory du use case d'import de règles PDF (extraction + structuration).""" """Factory du use case d'import de règles PDF (extraction + structuration)."""
# Modèle LOCAL → mode segmentation : le LLM ne renvoie que les frontières des
# sections (~200 tokens) et le texte original est découpé localement. Réécrire
# tout le contenu à ~100 tokens/s prendrait des dizaines de minutes par livre.
# Les providers cloud (rapides, grand contexte) gardent la réécriture nettoyée.
return ImportRulesUseCase( return ImportRulesUseCase(
llm=llm, extractor=_PDF_EXTRACTOR, chunk_target_tokens=settings.import_chunk_tokens) llm=llm, extractor=_PDF_EXTRACTOR,
chunk_target_tokens=_effective_import_chunk_tokens(settings),
segment_only=settings.llm_provider == "ollama")
def get_import_campaign_use_case( def get_import_campaign_use_case(
@@ -94,7 +129,7 @@ def get_import_campaign_use_case(
return ImportCampaignUseCase( return ImportCampaignUseCase(
llm=llm, llm=llm,
extractor=_PDF_EXTRACTOR, extractor=_PDF_EXTRACTOR,
chunk_target_tokens=settings.import_chunk_tokens, chunk_target_tokens=_effective_import_chunk_tokens(settings),
map_concurrency=settings.llm_map_concurrency, map_concurrency=settings.llm_map_concurrency,
) )

View File

@@ -14,6 +14,11 @@ import asyncio
import logging import logging
from app.application.chunking import chunk_text, split_in_half from app.application.chunking import chunk_text, split_in_half
from app.application.import_status import (
notify_status,
reset_status_queue,
set_status_queue,
)
from app.application.llm_json import load_json_object, looks_like_truncated_json from app.application.llm_json import load_json_object, looks_like_truncated_json
from app.application.llm_retry import generate_with_retry from app.application.llm_retry import generate_with_retry
from app.application.streaming import with_heartbeat from app.application.streaming import with_heartbeat
@@ -29,7 +34,12 @@ from app.domain.models import (
RoomProposal, RoomProposal,
SceneProposal, SceneProposal,
) )
from app.domain.ports import LLMProvider, LLMProviderError, PdfTextExtractor from app.domain.ports import (
LLMGenerationTimeout,
LLMProvider,
LLMProviderError,
PdfTextExtractor,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -107,6 +117,84 @@ Format de réponse :
- N'invente pas de contenu : tu réorganises et recopies ce qui est présent dans l'extrait. - N'invente pas de contenu : tu réorganises et recopies ce qui est présent dans l'extrait.
- Si l'extrait ne contient aucune matière narrative, renvoie {{"arcs": []}}.""" - Si l'extrait ne contient aucune matière narrative, renvoie {{"arcs": []}}."""
# Schéma de l'arbre attendu, passé aux providers à sorties structurées (Ollama
# contraint la grammaire : un modèle local ne PEUT plus produire de clés
# inventées, d'objets bavards type "thought" ni de texte hors JSON). Les
# adapters cloud le traduisent en mode JSON natif. Seuls les "name" sont
# requis : le _TreeMerger tolère déjà tous les champs absents.
_TREE_SCHEMA: dict = {
"type": "object",
"properties": {
"arcs": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
"type": {"type": "string", "enum": ["LINEAR", "HUB"]},
"chapters": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
"scenes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
"player_narration": {"type": "string"},
"gm_notes": {"type": "string"},
"rooms": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
"enemies": {"type": "string"},
"loot": {"type": "string"},
},
"required": ["name"],
"additionalProperties": False,
},
},
},
"required": ["name"],
"additionalProperties": False,
},
},
},
"required": ["name"],
"additionalProperties": False,
},
},
},
"required": ["name"],
"additionalProperties": False,
},
},
"npcs": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
},
"required": ["name"],
"additionalProperties": False,
},
},
},
"required": ["arcs"],
"additionalProperties": False,
}
# Bloc TOC injecté quand le PDF a des bookmarks : les morceaux étant traités # Bloc TOC injecté quand le PDF a des bookmarks : les morceaux étant traités
# séparément, c'est CE référentiel commun qui garantit que tous nomment les # séparément, c'est CE référentiel commun qui garantit que tous nomment les
# mêmes chapitres à l'identique → la fusion par nom du _TreeMerger recolle # mêmes chapitres à l'identique → la fusion par nom du _TreeMerger recolle
@@ -427,75 +515,101 @@ class ImportCampaignUseCase:
skipped = 0 skipped = 0
last_error: str | None = None last_error: str | None = None
done_count = 0 done_count = 0
# PARALLÉLISME : les morceaux sont traités par VAGUES de `map_concurrency` # Canal de statut : les couches profondes (retry LLM, re-découpage) y
# appels simultanés. L'ordre narratif est préservé : la fusion se fait # publient des messages destinés à l'UI — cf. import_status.notify_status.
# vague par vague, dans l'ordre du livre. status_queue: asyncio.Queue = asyncio.Queue()
# RÉSILIENCE : un morceau qui échoue (provider saturé, quota, etc.) est status_token = set_status_queue(status_queue)
# SAUTÉ — on ne perd pas tout l'import pour autant. On n'abandonne que try:
# si AUCUN morceau ne passe (cf. après la boucle). # PARALLÉLISME : les morceaux sont traités par VAGUES de `map_concurrency`
# HEARTBEAT : keep-alive pendant la vague d'appels LLM pour ne jamais # appels simultanés. L'ordre narratif est préservé : la fusion se fait
# laisser le flux SSE silencieux (sinon le Core coupe sur inactivité). # vague par vague, dans l'ordre du livre.
for start in range(0, total, self._map_concurrency): # RÉSILIENCE : un morceau qui échoue (provider saturé, quota, etc.) est
wave = list(enumerate(chunks))[start:start + self._map_concurrency] # SAUTÉ — on ne perd pas tout l'import pour autant. On n'abandonne que
gathered = asyncio.gather( # si AUCUN morceau ne passe (cf. après la boucle).
*(self._map_chunk(c, index=i, total=total, toc_block=toc_block) # HEARTBEAT : keep-alive pendant la vague d'appels LLM pour ne jamais
for i, c in wave), # laisser le flux SSE silencieux (sinon le Core coupe sur inactivité).
return_exceptions=True, for start in range(0, total, self._map_concurrency):
) wave = list(enumerate(chunks))[start:start + self._map_concurrency]
results: list | None = None gathered = asyncio.gather(
async for kind, payload in with_heartbeat(gathered): *(self._map_chunk(c, index=i, total=total, toc_block=toc_block)
if kind == "heartbeat": for i, c in wave),
yield {"type": "heartbeat", "current": done_count + 1, "total": total} return_exceptions=True,
else: )
results = payload results: list | None = None
for (i, _), res in zip(wave, results or []): async for kind, payload in with_heartbeat(gathered, status_queue=status_queue):
done_count += 1 if kind == "heartbeat":
if isinstance(res, LLMProviderError): yield {"type": "heartbeat", "current": done_count + 1, "total": total}
skipped += 1 elif kind == "status":
last_error = str(res) yield {"type": "status", "message": payload,
logger.warning("Morceau %s/%s ignoré (échec LLM) : %s", i + 1, total, res) "current": done_count + 1, "total": total}
yield {"type": "chunk_failed", "current": i + 1, "total": total, else:
"message": str(res)[:300]} results = payload
elif isinstance(res, BaseException): for (i, _), res in zip(wave, results or []):
raise res # bug inattendu : ne pas l'avaler en silence done_count += 1
else: if isinstance(res, LLMProviderError):
merger.add((res or {}).get("arcs") or []) skipped += 1
merger.add_npcs((res or {}).get("npcs") or []) last_error = str(res)
arcs, chapters, scenes = merger.counts() logger.warning("Morceau %s/%s ignoré (échec LLM) : %s", i + 1, total, res)
yield { yield {"type": "chunk_failed", "current": i + 1, "total": total,
"type": "progress", "message": str(res)[:300]}
"current": done_count, elif isinstance(res, BaseException):
"total": total, raise res # bug inattendu : ne pas l'avaler en silence
"arc_count": arcs, else:
"chapter_count": chapters, merger.add((res or {}).get("arcs") or [])
"scene_count": scenes, merger.add_npcs((res or {}).get("npcs") or [])
"npc_count": len(merger.npcs()), arcs, chapters, scenes = merger.counts()
"skipped": skipped, yield {
} "type": "progress",
"current": done_count,
"total": total,
"arc_count": arcs,
"chapter_count": chapters,
"scene_count": scenes,
"npc_count": len(merger.npcs()),
"skipped": skipped,
}
if total > 0 and skipped == total: if total > 0 and skipped == total:
# Tout a échoué : "done" vide serait trompeur → erreur explicite. # Tout a échoué : "done" vide serait trompeur → erreur explicite.
yield {"type": "error", yield {"type": "error",
"message": "Tous les morceaux ont échoué auprès du fournisseur IA. " "message": "Tous les morceaux ont échoué auprès du fournisseur IA. "
f"Dernier message : {last_error or 'inconnu'}"} f"Dernier message : {last_error or 'inconnu'}"}
return return
# Consolidation finale : fusion des quasi-doublons inter-morceaux if total > 0 and merger.counts()[0] == 0 and not merger.npcs():
# (best-effort, voir _consolidate). Inutile sur un import mono-morceau. # Le texte a été extrait mais le modèle n'a produit AUCUNE structure
if total > 1: # exploitable : sans ce signal, l'UI reçoit un `done` vide et
yield {"type": "consolidating", "total": total} # l'utilisateur conclut à tort que le PDF est illisible.
async for kind, _ in with_heartbeat(self._consolidate(merger)): yield {"type": "error",
if kind == "heartbeat": "message": "Le texte du PDF a été extrait, mais le modèle n'a produit "
yield {"type": "heartbeat", "current": total, "total": total} "aucune structure exploitable (réponses JSON vides ou coupées). "
"Réduisez la taille des morceaux d'import, augmentez la fenêtre "
"de contexte (num_ctx) ou essayez un autre modèle."}
return
yield { # Consolidation finale : fusion des quasi-doublons inter-morceaux
"type": "done", # (best-effort, voir _consolidate). Inutile sur un import mono-morceau.
"arcs": _serialize_arcs(merger.result()), if total > 1:
"npcs": [{"name": n.name, "description": n.description} for n in merger.npcs()], yield {"type": "consolidating", "total": total}
"page_count": doc.page_count, async for kind, payload in with_heartbeat(
"ocr_page_count": doc.ocr_page_count, self._consolidate(merger), status_queue=status_queue
"skipped": skipped, ):
} if kind == "heartbeat":
yield {"type": "heartbeat", "current": total, "total": total}
elif kind == "status":
yield {"type": "status", "message": payload,
"current": total, "total": total}
yield {
"type": "done",
"arcs": _serialize_arcs(merger.result()),
"npcs": [{"name": n.name, "description": n.description} for n in merger.npcs()],
"page_count": doc.page_count,
"ocr_page_count": doc.ocr_page_count,
"skipped": skipped,
}
finally:
reset_status_queue(status_token)
# --- Consolidation finale (fusion des quasi-doublons) --------------------- # --- Consolidation finale (fusion des quasi-doublons) ---------------------
@@ -557,8 +671,29 @@ class ImportCampaignUseCase:
+ f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n" + f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n"
"Renvoie maintenant le JSON de l'arborescence." "Renvoie maintenant le JSON de l'arborescence."
) )
raw = await generate_with_retry( try:
self._llm, prompt, output_format="json", temperature=_TEMPERATURE) raw = await generate_with_retry(
self._llm, prompt, output_format=_TREE_SCHEMA, temperature=_TEMPERATURE)
except LLMGenerationTimeout:
# Génération trop lente pour la taille demandée (fréquent en local /
# tier gratuit) : même remède que la troncature, deux moitiés →
# sortie 2× plus courte. Re-lever si plus découpable.
if depth >= _MAX_SPLIT_DEPTH:
raise
left, right = split_in_half(text)
if not left or not right:
raise
logger.info(
"Morceau %s : timeout de génération → re-découpage en 2 moitiés (niveau %s).",
index, depth + 1)
notify_status(
f"Le modèle est trop lent sur le morceau {index + 1} : "
"re-découpage en 2 moitiés plus digestes…")
a = await self._extract_payload(
left, index=index, total=total, depth=depth + 1, toc_block=toc_block)
b = await self._extract_payload(
right, index=index, total=total, depth=depth + 1, toc_block=toc_block)
return {"arcs": a["arcs"] + b["arcs"], "npcs": a["npcs"] + b["npcs"]}
payload, truncated = self._parse_payload(raw, index=index) payload, truncated = self._parse_payload(raw, index=index)
if truncated and depth < _MAX_SPLIT_DEPTH: if truncated and depth < _MAX_SPLIT_DEPTH:
@@ -567,6 +702,9 @@ class ImportCampaignUseCase:
logger.info( logger.info(
"Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).", "Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).",
index, depth + 1) index, depth + 1)
notify_status(
f"Réponse du modèle coupée sur le morceau {index + 1} : "
"re-découpage en 2 moitiés plus digestes…")
a = await self._extract_payload( a = await self._extract_payload(
left, index=index, total=total, depth=depth + 1, toc_block=toc_block) left, index=index, total=total, depth=depth + 1, toc_block=toc_block)
b = await self._extract_payload( b = await self._extract_payload(

View File

@@ -13,8 +13,16 @@ Ne dépend que des abstractions du domaine (ports LLMProvider + PdfTextExtractor
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
import asyncio
from app.application.chunking import CHUNK_TARGET_TOKENS, chunk_text, split_in_half from app.application.chunking import CHUNK_TARGET_TOKENS, chunk_text, split_in_half
from app.application.import_status import (
notify_status,
reset_status_queue,
set_status_queue,
)
from app.application.llm_json import load_json_object, looks_like_truncated_json from app.application.llm_json import load_json_object, looks_like_truncated_json
from app.application.llm_retry import generate_with_retry from app.application.llm_retry import generate_with_retry
from app.application.streaming import with_heartbeat from app.application.streaming import with_heartbeat
@@ -25,7 +33,12 @@ from app.application.streaming import with_heartbeat
# 1-2 niveaux suffisent en pratique, le reste est un garde-fou). # 1-2 niveaux suffisent en pratique, le reste est un garde-fou).
_MAX_SPLIT_DEPTH = 3 _MAX_SPLIT_DEPTH = 3
from app.domain.models import RulesImportResult from app.domain.models import RulesImportResult
from app.domain.ports import LLMProvider, LLMProviderError, PdfTextExtractor from app.domain.ports import (
LLMGenerationTimeout,
LLMProvider,
LLMProviderError,
PdfTextExtractor,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -34,6 +47,16 @@ logger = logging.getLogger(__name__)
# Plus la valeur est haute, plus le modèle "brode" (invente du contenu absent). # Plus la valeur est haute, plus le modèle "brode" (invente du contenu absent).
_TEMPERATURE = 0.1 _TEMPERATURE = 0.1
# Schéma de la sortie attendue : objet PLAT {titre: markdown}. Passé tel quel à
# Ollama (structured outputs : la grammaire interdit physiquement les objets
# imbriqués, les clés "thought" à valeur non-string, le bavardage hors JSON…
# indispensable pour les petits modèles locaux qui ne suivent pas les consignes).
# Les adapters cloud le traduisent en mode JSON natif (json_object).
_SECTIONS_SCHEMA: dict = {
"type": "object",
"additionalProperties": {"type": "string"},
}
# Taxonomie canonique suggérée au modèle pour homogénéiser les titres entre # Taxonomie canonique suggérée au modèle pour homogénéiser les titres entre
# morceaux (sinon "Combat" / "Le combat" / "Règles de combat" se dispersent). # morceaux (sinon "Combat" / "Le combat" / "Règles de combat" se dispersent).
# Le modèle reste libre d'en créer d'autres si rien ne correspond. # Le modèle reste libre d'en créer d'autres si rien ne correspond.
@@ -56,9 +79,13 @@ On te donne un EXTRAIT brut d'un PDF de règles (texte parfois mal coupé par la
Ta tâche : répartir le contenu de cet extrait dans des SECTIONS THÉMATIQUES. Ta tâche : répartir le contenu de cet extrait dans des SECTIONS THÉMATIQUES.
Format EXACT attendu — un objet JSON plat {{titre de section: contenu markdown}} :
{{"Combat": "## Initiative\\n\\nChaque participant lance 1d20...", "Magie et sorts": "## Sorts\\n\\n..."}}
Règles impératives : Règles impératives :
- Tu réponds UNIQUEMENT par un objet JSON valide, sans markdown ni commentaire autour. - Tu réponds UNIQUEMENT par cet objet JSON, sans texte avant ni après.
- Les CLÉS sont des titres de section (texte court). Les VALEURS sont le contenu de la règle en markdown. - Les CLÉS sont des titres de section (texte court). Les VALEURS sont le contenu de la règle en markdown (chaîne de caractères, jamais un objet ou une liste).
- INTERDIT : des clés génériques comme "title", "content", "sections", "thought" ou "notes" ; des objets imbriqués ; tout commentaire sur ta démarche ou ton raisonnement.
- Utilise EN PRIORITÉ ces titres canoniques quand le contenu y correspond : - Utilise EN PRIORITÉ ces titres canoniques quand le contenu y correspond :
{canonical} {canonical}
- Si un contenu ne rentre dans aucun, crée un titre clair et concis (en français). - Si un contenu ne rentre dans aucun, crée un titre clair et concis (en français).
@@ -67,6 +94,55 @@ Règles impératives :
- N'INVENTE AUCUNE règle, ne résume pas abusivement : tu réorganises, tu ne réécris pas le fond. - N'INVENTE AUCUNE règle, ne résume pas abusivement : tu réorganises, tu ne réécris pas le fond.
- Ignore les pages de garde, sommaires, crédits, pages vides (renvoie {{}} si l'extrait n'a aucune règle).""" - Ignore les pages de garde, sommaires, crédits, pages vides (renvoie {{}} si l'extrait n'a aucune règle)."""
# --- Mode SEGMENTATION (modèles locaux) --------------------------------------
# Réécrire tout le texte en JSON impose une SORTIE ≈ taille de l'ENTRÉE : à
# ~100 tokens/s en local, un livre = des dizaines de minutes et des troncatures
# en cascade. Ici le modèle ne renvoie que les FRONTIÈRES des sections (titre +
# premiers mots exacts) — ~200 tokens quel que soit le morceau — et c'est NOUS
# qui découpons le texte original. ~50× plus rapide, fidélité parfaite du
# contenu (texte source intact), plus de troncature possible.
_SEGMENT_SYSTEM = """Tu analyses un EXTRAIT brut d'un livre de règles de jeu de rôle.
Ta tâche : repérer où COMMENCENT les sections thématiques. Tu ne réécris RIEN.
Format EXACT attendu :
{{"sections": [{{"titre": "Combat", "debut": "Le combat se déroule en tours de"}}, ...]}}
Règles impératives :
- "debut" = les 5 à 10 PREMIERS MOTS du passage où la section commence, COPIÉS À L'IDENTIQUE
depuis l'extrait (même orthographe, même ponctuation, même langue). JAMAIS un résumé.
- La PREMIÈRE entrée commence aux tout premiers mots de l'extrait (même si le contenu
poursuit une section entamée avant cet extrait).
- Les entrées suivent l'ordre du texte. Vise des sections LARGES (un thème), pas un titre
par paragraphe : un extrait contient typiquement 1 à 6 sections.
- Titres : EN PRIORITÉ parmi :
{canonical}
sinon un titre court et clair en français.
- Pages de garde, sommaires, crédits : n'en fais pas des sections. Si l'extrait n'est que ça,
renvoie {{"sections": []}}."""
# Schéma passé à Ollama (structured outputs) : un objet {"sections": [...]}.
# Racine objet (pas tableau) car l'extraction côté Brain repère le premier {…}.
_ANCHORS_SCHEMA: dict = {
"type": "object",
"properties": {
"sections": {
"type": "array",
"items": {
"type": "object",
"properties": {
"titre": {"type": "string"},
"debut": {"type": "string"},
},
"required": ["titre", "debut"],
"additionalProperties": False,
},
},
},
"required": ["sections"],
"additionalProperties": False,
}
class _SectionMerger: class _SectionMerger:
"""Fusionne les sections issues des différents morceaux, ordre préservé. """Fusionne les sections issues des différents morceaux, ordre préservé.
@@ -102,6 +178,84 @@ class _SectionMerger:
return {title: "\n\n".join(parts) for title, parts in self._merged.items()} return {title: "\n\n".join(parts) for title, parts in self._merged.items()}
# Clés "méta" que certains modèles glissent dans le JSON (fuite de raisonnement,
# schéma title/content inventé…) : jamais des titres de section voulus.
_META_KEYS = frozenset({
"thought", "thoughts", "thinking", "reasoning", "raisonnement",
"comment", "commentaire", "commentaires", "note", "notes", "explanation",
})
def _normalize_sections(parsed: dict) -> dict:
"""Ramène les formes déviantes courantes au format attendu {titre: contenu}.
Observé sur les petits modèles locaux (gemma 12b) malgré les consignes :
- enveloppe {"sections": {...}} ou {"règles": {...}} autour du vrai contenu ;
- schéma inventé {"title": "...", "content": "...", "thought": "..."} →
une seule section dont le titre est la valeur de "title" ;
- clés méta ("thought", "notes"…) mêlées aux vraies sections → retirées.
"""
by_lower = {str(k).strip().lower(): k for k in parsed}
# Enveloppe : un unique conteneur connu dont la valeur est l'objet attendu.
if len(parsed) == 1:
only_key, only_val = next(iter(parsed.items()))
if (isinstance(only_val, dict)
and str(only_key).strip().lower() in {"sections", "règles", "regles", "rules"}):
return _normalize_sections(only_val)
# Schéma {"title": ..., "content": ...} : le titre est une VALEUR, pas une clé.
if "title" in by_lower and "content" in by_lower:
title = str(parsed[by_lower["title"]]).strip()
content = parsed[by_lower["content"]]
if title and not isinstance(content, dict):
return {title: content}
return {k: v for k, v in parsed.items()
if str(k).strip().lower() not in _META_KEYS}
def _coerce_markdown(value: object) -> str:
"""Convertit une valeur de section renvoyée par le LLM en markdown plat.
Malgré la consigne « valeurs = markdown », certains modèles nichent des
sous-sections ({titre: {sous-titre: contenu}}) ou des listes. Un `str(v)`
naïf produirait du repr Python ({'k': 'v'}) ; on aplatit récursivement à la
place pour ne perdre aucun contenu.
"""
if isinstance(value, str):
return value
if isinstance(value, dict):
parts = []
for k, v in value.items():
content = _coerce_markdown(v)
# Clé = sous-titre (cas normal) ; si la "valeur" est vide, la clé
# elle-même porte le contenu (dérive observée sur certains modèles).
parts.append(f"{k}\n\n{content}".strip() if content else str(k))
return "\n\n".join(parts)
if isinstance(value, list):
return "\n\n".join(_coerce_markdown(v) for v in value)
return "" if value is None else str(value)
def _find_anchor(text: str, anchor: str, start: int) -> int | None:
"""Position de `anchor` dans `text` à partir de `start`, ou None.
Le modèle recopie les premiers mots d'un passage, mais le texte extrait du
PDF contient des sauts de ligne/espaces multiples au même endroit, et le
modèle normalise parfois la casse. Trois passes, de la plus stricte à la
plus tolérante : exacte → espaces≈\\s+ → idem insensible à la casse."""
pos = text.find(anchor, start)
if pos != -1:
return pos
words = anchor.split()
if not words:
return None
pattern = r"\s+".join(re.escape(w) for w in words)
match = re.compile(pattern).search(text, start)
if match:
return match.start()
match = re.compile(pattern, re.IGNORECASE).search(text, start)
return match.start() if match else None
def _combine_sections(a: dict[str, str], b: dict[str, str]) -> dict[str, str]: def _combine_sections(a: dict[str, str], b: dict[str, str]) -> dict[str, str]:
"""Fusionne deux dicts de sections (issus des 2 moitiés d'un morceau re-découpé). """Fusionne deux dicts de sections (issus des 2 moitiés d'un morceau re-découpé).
@@ -128,10 +282,16 @@ class ImportRulesUseCase:
llm: LLMProvider, llm: LLMProvider,
extractor: PdfTextExtractor, extractor: PdfTextExtractor,
chunk_target_tokens: int = CHUNK_TARGET_TOKENS, chunk_target_tokens: int = CHUNK_TARGET_TOKENS,
segment_only: bool = False,
) -> None: ) -> None:
"""`segment_only=True` (modèles locaux) : le LLM ne renvoie que les
frontières des sections (titre + premiers mots) et le texte original est
découpé localement — sortie minuscule, pas de réécriture. False (cloud) :
le LLM réécrit le contenu en sections markdown nettoyées."""
self._llm = llm self._llm = llm
self._extractor = extractor self._extractor = extractor
self._chunk_target_tokens = chunk_target_tokens self._chunk_target_tokens = chunk_target_tokens
self._segment_only = segment_only
async def execute(self, pdf_bytes: bytes) -> RulesImportResult: async def execute(self, pdf_bytes: bytes) -> RulesImportResult:
"""Variante non-streamée : traite tout puis renvoie le résultat complet.""" """Variante non-streamée : traite tout puis renvoie le résultat complet."""
@@ -179,35 +339,46 @@ class ImportRulesUseCase:
merger = _SectionMerger() merger = _SectionMerger()
skipped = 0 skipped = 0
last_error: str | None = None last_error: str | None = None
for i, chunk in enumerate(chunks): # Canal de statut : les couches profondes (retry LLM, re-découpage) y
# RÉSILIENCE : un morceau qui échoue est SAUTÉ, l'import continue. # publient des messages destinés à l'UI — cf. import_status.notify_status.
# Abandon seulement si AUCUN morceau ne passe (cf. après la boucle). status_queue: asyncio.Queue = asyncio.Queue()
# HEARTBEAT : on émet des keep-alive pendant l'appel LLM (long sur un status_token = set_status_queue(status_queue)
# provider lent) pour que le flux SSE ne soit jamais coupé par le Core. try:
new_titles: list[str] = [] for i, chunk in enumerate(chunks):
try: # RÉSILIENCE : un morceau qui échoue est SAUTÉ, l'import continue.
sections: dict[str, str] | None = None # Abandon seulement si AUCUN morceau ne passe (cf. après la boucle).
async for kind, payload in with_heartbeat( # HEARTBEAT : on émet des keep-alive pendant l'appel LLM (long sur un
self._map_chunk(chunk, index=i, total=total) # provider lent) pour que le flux SSE ne soit jamais coupé par le Core.
): new_titles: list[str] = []
if kind == "heartbeat": try:
yield {"type": "heartbeat", "current": i + 1, "total": total} sections: dict[str, str] | None = None
else: async for kind, payload in with_heartbeat(
sections = payload self._map_chunk(chunk, index=i, total=total),
new_titles = merger.add(sections or {}) status_queue=status_queue,
except LLMProviderError as exc: ):
skipped += 1 if kind == "heartbeat":
last_error = str(exc) yield {"type": "heartbeat", "current": i + 1, "total": total}
logger.warning("Morceau %s/%s ignoré (échec LLM) : %s", i + 1, total, exc) elif kind == "status":
yield {"type": "chunk_failed", "current": i + 1, "total": total, yield {"type": "status", "message": payload,
"message": str(exc)[:300]} "current": i + 1, "total": total}
yield { else:
"type": "progress", sections = payload
"current": i + 1, new_titles = merger.add(sections or {})
"total": total, except LLMProviderError as exc:
"new_sections": new_titles, skipped += 1
"skipped": skipped, last_error = str(exc)
} logger.warning("Morceau %s/%s ignoré (échec LLM) : %s", i + 1, total, exc)
yield {"type": "chunk_failed", "current": i + 1, "total": total,
"message": str(exc)[:300]}
yield {
"type": "progress",
"current": i + 1,
"total": total,
"new_sections": new_titles,
"skipped": skipped,
}
finally:
reset_status_queue(status_token)
if total > 0 and skipped == total: if total > 0 and skipped == total:
yield {"type": "error", yield {"type": "error",
@@ -215,9 +386,21 @@ class ImportRulesUseCase:
f"Dernier message : {last_error or 'inconnu'}"} f"Dernier message : {last_error or 'inconnu'}"}
return return
sections = merger.result()
if total > 0 and not sections:
# Le texte a bien été extrait mais AUCUN morceau n'a produit de JSON
# exploitable (sorties coupées/illisibles). Sans ce signal, l'UI reçoit
# un `done` vide et l'utilisateur conclut à tort que le PDF est illisible.
yield {"type": "error",
"message": "Le texte du PDF a été extrait, mais le modèle n'a produit "
"aucune section exploitable (réponses JSON vides ou coupées). "
"Réduisez la taille des morceaux d'import, augmentez la fenêtre "
"de contexte (num_ctx) ou essayez un autre modèle."}
return
yield { yield {
"type": "done", "type": "done",
"sections": merger.result(), "sections": sections,
"page_count": doc.page_count, "page_count": doc.page_count,
"ocr_page_count": doc.ocr_page_count, "ocr_page_count": doc.ocr_page_count,
"skipped": skipped, "skipped": skipped,
@@ -234,16 +417,40 @@ class ImportRulesUseCase:
"""Extrait les sections d'un texte. Si la SORTIE est tronquée, retraite le """Extrait les sections d'un texte. Si la SORTIE est tronquée, retraite le
texte en DEUX moitiés (chacune produit une réponse complète) et fusionne — texte en DEUX moitiés (chacune produit une réponse complète) et fusionne —
ainsi aucune section n'est perdue, quel que soit le plafond de sortie.""" ainsi aucune section n'est perdue, quel que soit le plafond de sortie."""
system = _SEGMENT_SYSTEM if self._segment_only else _MAP_SYSTEM
schema = _ANCHORS_SCHEMA if self._segment_only else _SECTIONS_SCHEMA
prompt = ( prompt = (
_MAP_SYSTEM.format( system.format(
canonical="\n".join(f" - {s}" for s in _CANONICAL_SECTIONS) canonical="\n".join(f" - {s}" for s in _CANONICAL_SECTIONS)
) )
+ f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n" + f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n"
"Renvoie maintenant le JSON des sections." "Renvoie maintenant le JSON des sections."
) )
raw = await generate_with_retry( try:
self._llm, prompt, output_format="json", temperature=_TEMPERATURE) raw = await generate_with_retry(
sections, truncated = self._parse_sections(raw, index=index) self._llm, prompt, output_format=schema, temperature=_TEMPERATURE)
except LLMGenerationTimeout:
# Le modèle générait mais trop lentement pour réécrire tout le morceau
# dans le temps imparti (fréquent sur tier gratuit + gros morceaux).
# Même remède que la troncature : deux moitiés → sortie 2× plus courte.
if depth >= _MAX_SPLIT_DEPTH:
raise
left, right = split_in_half(text)
if not left or not right:
raise
logger.info(
"Morceau %s : timeout de génération → re-découpage en 2 moitiés (niveau %s).",
index, depth + 1)
notify_status(
f"Le modèle est trop lent sur le morceau {index + 1} : "
"re-découpage en 2 moitiés plus digestes…")
a = await self._extract_sections(left, index=index, total=total, depth=depth + 1)
b = await self._extract_sections(right, index=index, total=total, depth=depth + 1)
return _combine_sections(a, b)
if self._segment_only:
sections, truncated = self._parse_anchors(raw, text, index=index)
else:
sections, truncated = self._parse_sections(raw, index=index)
if truncated and depth < _MAX_SPLIT_DEPTH: if truncated and depth < _MAX_SPLIT_DEPTH:
left, right = split_in_half(text) left, right = split_in_half(text)
@@ -251,6 +458,9 @@ class ImportRulesUseCase:
logger.info( logger.info(
"Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).", "Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).",
index, depth + 1) index, depth + 1)
notify_status(
f"Réponse du modèle coupée sur le morceau {index + 1} : "
"re-découpage en 2 moitiés plus digestes…")
a = await self._extract_sections(left, index=index, total=total, depth=depth + 1) a = await self._extract_sections(left, index=index, total=total, depth=depth + 1)
b = await self._extract_sections(right, index=index, total=total, depth=depth + 1) b = await self._extract_sections(right, index=index, total=total, depth=depth + 1)
return _combine_sections(a, b) return _combine_sections(a, b)
@@ -259,6 +469,68 @@ class ImportRulesUseCase:
"Morceau %s : sortie tronquée, profondeur max atteinte — partiel conservé.", index) "Morceau %s : sortie tronquée, profondeur max atteinte — partiel conservé.", index)
return sections return sections
@staticmethod
def _parse_anchors(raw: str, text: str, *, index: int) -> tuple[dict[str, str], bool]:
"""Mode segmentation : réponse {"sections": [{titre, debut}, …]} → on localise
chaque `debut` dans le texte ORIGINAL et on découpe entre les ancres.
Une ancre introuvable est abandonnée (son contenu reste dans la section
précédente — aucun texte n'est perdu). Le texte avant la première ancre
trouvée est rattaché à la première section (le prompt demande au modèle de
faire démarrer la première entrée aux premiers mots de l'extrait)."""
parsed, recovered = load_json_object(raw)
if parsed is None:
truncated = looks_like_truncated_json(raw)
if not truncated:
logger.warning(
"Morceau %s : aucun objet JSON exploitable (segmentation), ignoré. "
"Début de la réponse du modèle : %r",
index, (raw or "").strip()[:300] or "(réponse VIDE)")
return {}, truncated
entries = parsed.get("sections") if isinstance(parsed, dict) else None
if not isinstance(entries, list):
logger.warning("Morceau %s : pas de liste 'sections' exploitable, ignoré.", index)
return {}, False
# Localisation séquentielle : chaque ancre est cherchée APRÈS la précédente
# (préserve l'ordre du texte, évite qu'une phrase répétée matche trop tôt).
located: list[tuple[str, int]] = []
cursor = 0
dropped = 0
for entry in entries:
if not isinstance(entry, dict):
continue
title = str(entry.get("titre") or "").strip()
anchor = str(entry.get("debut") or "").strip()
if not title or not anchor:
continue
pos = _find_anchor(text, anchor, cursor)
if pos is None:
dropped += 1
continue
located.append((title, pos))
cursor = pos + 1
if dropped:
logger.info(
"Morceau %s : %s ancre(s) de section introuvable(s) — contenu rattaché "
"à la section précédente.", index, dropped)
if not located:
return {}, False
# Découpe entre ancres ; le préambule éventuel rejoint la première section.
located[0] = (located[0][0], 0)
sections: dict[str, str] = {}
for i, (title, start) in enumerate(located):
end = located[i + 1][1] if i + 1 < len(located) else len(text)
content = text[start:end].strip()
if not content:
continue
if title in sections:
sections[title] = f"{sections[title]}\n\n{content}"
else:
sections[title] = content
return sections, recovered
@staticmethod @staticmethod
def _parse_sections(raw: str, *, index: int) -> tuple[dict[str, str], bool]: def _parse_sections(raw: str, *, index: int) -> tuple[dict[str, str], bool]:
"""Parse robuste → (sections, tronqué). `tronqué`=True si récupération partielle.""" """Parse robuste → (sections, tronqué). `tronqué`=True si récupération partielle."""
@@ -276,4 +548,5 @@ class ImportRulesUseCase:
if not isinstance(parsed, dict): if not isinstance(parsed, dict):
logger.warning("Morceau %s : le LLM n'a pas renvoyé un objet, ignoré.", index) logger.warning("Morceau %s : le LLM n'a pas renvoyé un objet, ignoré.", index)
return {}, False return {}, False
return {str(k): str(v) for k, v in parsed.items()}, recovered normalized = _normalize_sections(parsed)
return {str(k): _coerce_markdown(v) for k, v in normalized.items()}, recovered

View File

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

View File

@@ -38,13 +38,16 @@ def load_json_object(raw: str) -> tuple[object | None, bool]:
obj = extract_json_object(raw) obj = extract_json_object(raw)
if obj is not None: if obj is not None:
try: try:
return json.loads(obj), False # strict=False : tolère les caractères de contrôle BRUTS (retours à la
# ligne non échappés…) dans les chaînes — erreur fréquente des LLM hors
# mode JSON natif, qui invalidait toute la réponse.
return json.loads(obj, strict=False), False
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
repaired = repair_truncated_json(raw) repaired = repair_truncated_json(raw)
if repaired is not None: if repaired is not None:
try: try:
return json.loads(repaired), True return json.loads(repaired, strict=False), True
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
return None, False return None, False
@@ -54,12 +57,20 @@ def looks_like_truncated_json(raw: str) -> bool:
"""La sortie ressemble-t-elle à un JSON COUPÉ (accolades/crochets non refermés) """La sortie ressemble-t-elle à un JSON COUPÉ (accolades/crochets non refermés)
plutôt qu'à de la prose ? Sert à déclencher un re-découpage même quand RIEN n'a plutôt qu'à de la prose ? Sert à déclencher un re-découpage même quand RIEN n'a
pu être récupéré (cas où le 1er contenu est si long qu'il est coupé avant toute pu être récupéré (cas où le 1er contenu est si long qu'il est coupé avant toute
sous-structure complète). On exige un contenu substantiel pour éviter les sous-structure complète).
faux positifs sur une courte réponse non-JSON."""
s = (raw or "").strip() Une réponse qui COMMENCE par `{` est jugée sur le seul équilibre des accolades,
if "{" not in s or len(s) < 100: même très courte : en mode JSON un `{"` de 2 caractères est une génération
interrompue net (contexte plein, plafond de sortie), pas de la prose — c'est le
signal de re-découpage. Pour le reste (prose contenant des accolades), on exige
un contenu substantiel pour éviter les faux positifs."""
s = _strip_reasoning(raw or "").strip()
if "{" not in s:
return False return False
return s.count("{") > s.count("}") or s.count("[") > s.count("]") unbalanced = s.count("{") > s.count("}") or s.count("[") > s.count("]")
if s.startswith("{"):
return unbalanced
return len(s) >= 100 and unbalanced
def extract_json_object(raw: str) -> str | None: def extract_json_object(raw: str) -> str | None:

View File

@@ -14,7 +14,8 @@ import asyncio
import logging import logging
import re import re
from app.domain.ports import LLMProvider, LLMProviderError from app.application.import_status import notify_status
from app.domain.ports import LLMGenerationTimeout, LLMProvider, LLMProviderError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -60,7 +61,7 @@ async def generate_with_retry(
llm: LLMProvider, llm: LLMProvider,
prompt: str, prompt: str,
*, *,
output_format: str | None = None, output_format: str | dict | None = None,
temperature: float | None = None, temperature: float | None = None,
) -> str: ) -> str:
"""Comme `llm.generate`, mais réessaie les erreurs transitoires (backoff). """Comme `llm.generate`, mais réessaie les erreurs transitoires (backoff).
@@ -74,6 +75,12 @@ async def generate_with_retry(
for attempt in range(_ATTEMPTS): for attempt in range(_ATTEMPTS):
try: try:
return await llm.generate(prompt, output_format=output_format, temperature=temperature) return await llm.generate(prompt, output_format=output_format, temperature=temperature)
except LLMGenerationTimeout:
# Timeout de DÉBIT (génération trop lente pour la sortie demandée) :
# rejouer le même prompt re-timeoutera à l'identique — on a déjà perdu
# `timeout` secondes. On remonte tout de suite : l'appelant (import)
# sait re-découper le morceau en deux pour réduire la sortie.
raise
except LLMProviderError as exc: except LLMProviderError as exc:
last_error = exc last_error = exc
# Quota JOURNALIER épuisé : inutile d'insister, on remonte tout de suite # Quota JOURNALIER épuisé : inutile d'insister, on remonte tout de suite
@@ -97,6 +104,14 @@ async def generate_with_retry(
attempt + 1, _ATTEMPTS, " [rate limit]" if _is_rate_limit(exc) else "", attempt + 1, _ATTEMPTS, " [rate limit]" if _is_rate_limit(exc) else "",
exc, wait, exc, wait,
) )
# Remonte aussi l'info à l'UI (flux d'import) : sans ça l'utilisateur
# voit une barre figée sans savoir que le fournisseur est saturé.
notify_status(
("Fournisseur IA saturé (rate limit)" if _is_rate_limit(exc)
else "Appel IA échoué")
+ f" — tentative {attempt + 1}/{_ATTEMPTS}, nouvel essai dans {int(wait)}s. "
+ str(exc)[:160]
)
await asyncio.sleep(wait) await asyncio.sleep(wait)
assert last_error is not None assert last_error is not None
raise last_error raise last_error

View File

@@ -34,23 +34,59 @@ Règles :
PROPOSITIONS D'INTÉGRATION (IMPORTANT) : PROPOSITIONS D'INTÉGRATION (IMPORTANT) :
Quand l'utilisateur veut CRÉER ou ADAPTER un élément concret pour sa campagne (un PNJ, Quand l'utilisateur veut CRÉER ou ADAPTER un élément concret pour sa campagne (un PNJ,
une scène, un chapitre, un arc, une table aléatoire), termine ta réponse par un ou une scène, un chapitre, une quête, un arc, une table aléatoire), termine ta réponse par
plusieurs BLOCS D'ACTION — un objet JSON par bloc, dans une clôture ```loremind-action. un ou plusieurs BLOCS D'ACTION — un objet JSON par bloc, dans une clôture
L'interface les transformera en boutons « Créer dans la campagne ». N'en mets que si ```loremind-action. L'interface les transformera en boutons « Créer dans la campagne ».
c'est pertinent et explicitement souhaité. Formats acceptés : Si l'utilisateur demande PLUSIEURS éléments (« propose-moi 3 quêtes »), produis UN bloc
par élément. N'en mets pas si l'utilisateur pose une simple question.
VOCABULAIRE DE LA CAMPAGNE : une « quête » n'est PAS un type à part — c'est un CHAPITRE
rangé dans un arc de type HUB (quêtes parallèles, sans ordre imposé), tandis qu'un arc
LINEAR contient des chapitres joués en séquence. Donc :
- demande de QUÊTE → action "chapter" (l'utilisateur la placera dans son arc HUB) ;
s'il n'a aucun arc HUB dans sa campagne, propose AUSSI une action "arc" avec
"arcType": "HUB" pour les accueillir.
- demande de CHAPITRE → action "chapter" (destinée plutôt à un arc LINEAR).
RÈGLE CLÉ : remplis TOUS les champs pour lesquels tu as de la matière — pas seulement
le résumé ou les notes MJ. Chaque champ rempli atterrit au bon endroit de la fiche ;
un champ laissé vide est une fiche que l'utilisateur devra compléter à la main. Vise
2 à 5 phrases concrètes par champ narratif, tirées de la source et de la campagne.
Omets simplement un champ si tu n'as rien de précis à y mettre. Formats acceptés :
```loremind-action ```loremind-action
{{"type": "npc", "name": "Nom", "description": "Fiche en quelques phrases."}} {{"type": "npc", "name": "Nom",
"description": "Résumé du PNJ (rôle, apparence, motivation).",
"values": {{"<champ de la fiche PNJ>": "contenu", "<autre champ>": "contenu"}}}}
```
(`values` : utilise comme clés les CHAMPS DE LA FICHE PNJ listés dans le contexte
campagne s'ils y figurent — ex. "Histoire", "Apparence" — sinon omets `values`.)
```loremind-action
{{"type": "scene", "name": "Nom",
"description": "Résumé court de la scène.",
"location": "Lieu précis", "timing": "Quand elle survient",
"atmosphere": "Ambiance sensorielle (sons, odeurs, lumière…)",
"playerNarration": "Texte d'ambiance À LIRE AUX JOUEURS, immersif, à la 2e personne.",
"gmSecretNotes": "Secrets, vérités cachées, notes pour le MJ uniquement.",
"choicesConsequences": "Choix offerts aux joueurs et leurs conséquences.",
"combatDifficulty": "Difficulté du combat éventuel", "enemies": "Ennemis présents (effectifs, tactiques)"}}
``` ```
```loremind-action ```loremind-action
{{"type": "scene", "name": "Nom", "description": "Résumé", "content": "Déroulé détaillé."}} {{"type": "chapter", "name": "Nom",
"description": "Résumé du chapitre (ou de la quête).",
"playerObjectives": "Objectifs tels que les joueurs les perçoivent.",
"narrativeStakes": "Enjeux narratifs (ce qui se joue vraiment).",
"gmNotes": "Notes MJ : fils à tirer, points d'attention."}}
``` ```
```loremind-action ```loremind-action
{{"type": "chapter", "name": "Nom", "description": "Résumé du chapitre."}} {{"type": "arc", "name": "Nom", "description": "Résumé", "arcType": "LINEAR",
``` "themes": "Thèmes de l'arc", "stakes": "Enjeux",
```loremind-action "rewards": "Récompenses attendues", "resolution": "Issues possibles",
{{"type": "arc", "name": "Nom", "description": "Résumé", "arcType": "LINEAR"}} "gmNotes": "Notes MJ."}}
``` ```
(`arcType` : "LINEAR" pour des chapitres en séquence, "HUB" pour un recueil de
quêtes parallèles.)
```loremind-action ```loremind-action
{{"type": "table", "name": "Nom", "diceFormula": "1d8", "entries": [{{"minRoll":1,"maxRoll":4,"label":"...","detail":"..."}}]}} {{"type": "table", "name": "Nom", "diceFormula": "1d8", "entries": [{{"minRoll":1,"maxRoll":4,"label":"...","detail":"..."}}]}}
``` ```

View File

@@ -25,21 +25,43 @@ async def with_heartbeat(
coro: Awaitable[Any], coro: Awaitable[Any],
*, *,
interval: float = HEARTBEAT_INTERVAL_SECONDS, interval: float = HEARTBEAT_INTERVAL_SECONDS,
status_queue: "asyncio.Queue | None" = None,
) -> AsyncIterator[tuple[str, Any]]: ) -> AsyncIterator[tuple[str, Any]]:
"""Exécute `coro` en émettant ('heartbeat', None) toutes les `interval`s tant """Exécute `coro` en émettant ('heartbeat', None) toutes les `interval`s tant
qu'elle n'est pas terminée, puis ('result', valeur). qu'elle n'est pas terminée, puis ('result', valeur).
Si `status_queue` est fournie, les messages qui y sont publiés pendant
l'exécution (cf. import_status.notify_status : retry LLM, re-découpage…)
sont émis AU FIL DE L'EAU sous forme ('status', message) — c'est ce qui
permet à l'UI d'expliquer une attente au lieu d'une barre figée.
L'exception éventuelle de `coro` est propagée (re-levée par `task.result()`), L'exception éventuelle de `coro` est propagée (re-levée par `task.result()`),
donc l'appelant peut l'attraper normalement. Si l'itération est abandonnée donc l'appelant peut l'attraper normalement. Si l'itération est abandonnée
(client déconnecté), la tâche sous-jacente est annulée. (client déconnecté), la tâche sous-jacente est annulée.
""" """
task: asyncio.Task = asyncio.ensure_future(coro) task: asyncio.Task = asyncio.ensure_future(coro)
getter: asyncio.Task | None = None
try: try:
while not task.done(): while not task.done():
done, _ = await asyncio.wait({task}, timeout=interval) waiters: set[asyncio.Task] = {task}
if status_queue is not None and getter is None:
getter = asyncio.ensure_future(status_queue.get())
if getter is not None:
waiters.add(getter)
done, _ = await asyncio.wait(
waiters, timeout=interval, return_when=asyncio.FIRST_COMPLETED)
if getter is not None and getter in done:
yield ("status", getter.result())
getter = None # un nouveau get() sera créé au tour suivant
if not done: if not done:
yield ("heartbeat", None) yield ("heartbeat", None)
# Vide les statuts restés en file (publiés juste avant la fin de la tâche).
if status_queue is not None:
while not status_queue.empty():
yield ("status", status_queue.get_nowait())
yield ("result", task.result()) yield ("result", task.result())
finally: finally:
if getter is not None and not getter.done():
getter.cancel()
if not task.done(): if not task.done():
task.cancel() task.cancel()

View File

@@ -24,17 +24,20 @@ class LLMProvider(Protocol):
self, self,
prompt: str, prompt: str,
*, *,
output_format: str | None = None, output_format: str | dict | None = None,
temperature: float | None = None, temperature: float | None = None,
) -> str: ) -> str:
"""Génère une réponse textuelle à partir d'un prompt donné. """Génère une réponse textuelle à partir d'un prompt donné.
Args: Args:
prompt: le texte envoyé au modèle. prompt: le texte envoyé au modèle.
output_format: contrainte de format optionnelle. Exemple : "json" output_format: contrainte de format optionnelle. "json" pour forcer
pour forcer le modèle à renvoyer du JSON valide. Les un JSON valide ; un dict = SCHÉMA JSON décrivant la structure
fournisseurs qui ne supportent pas une valeur donnée doivent attendue (les fournisseurs qui supportent les sorties
l'ignorer silencieusement ou la traduire au mieux. structurées — ex. Ollama — contraignent la génération au schéma,
les autres retombent sur leur mode JSON natif). Les fournisseurs
qui ne supportent pas une valeur donnée doivent l'ignorer
silencieusement ou la traduire au mieux.
temperature: créativité du modèle, 0.0 (déterministe/factuel) à temperature: créativité du modèle, 0.0 (déterministe/factuel) à
1.0+ (très créatif, hallucine plus facilement). None = 1.0+ (très créatif, hallucine plus facilement). None =
valeur par défaut de l'adapter. Recommandation LoreMind : valeur par défaut de l'adapter. Recommandation LoreMind :
@@ -113,3 +116,14 @@ class LLMProviderError(Exception):
Définie dans le domaine (pas dans l'infra) pour que les couches Définie dans le domaine (pas dans l'infra) pour que les couches
supérieures puissent l'attraper sans connaître l'adapter concret. supérieures puissent l'attraper sans connaître l'adapter concret.
""" """
class LLMGenerationTimeout(LLMProviderError):
"""La génération a démarré mais n'a pas FINI dans le temps imparti.
Cas distinct d'un échec transitoire (file d'attente, 503) : le modèle
produisait des tokens mais trop lentement pour la taille de sortie demandée.
Réessayer à l'identique est inutile (même entrée → même lenteur) ; la bonne
réaction est de RÉDUIRE la sortie demandée (ex. import : re-découper le
morceau en deux moitiés).
"""

View File

@@ -20,7 +20,7 @@ import httpx
from app.core.config import Settings from app.core.config import Settings
from app.domain.models import ChatMessage from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError from app.domain.ports import LLMGenerationTimeout, LLMProviderError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -95,7 +95,7 @@ class GeminiLLMProvider:
try: try:
return await asyncio.wait_for(_collect(), timeout=self._timeout) return await asyncio.wait_for(_collect(), timeout=self._timeout)
except asyncio.TimeoutError as exc: except asyncio.TimeoutError as exc:
raise LLMProviderError( 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 ) from exc
@@ -130,6 +130,12 @@ class GeminiLLMProvider:
} }
if temperature is not None: if temperature is not None:
body["temperature"] = temperature 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: async with httpx.AsyncClient(timeout=self._timeout) as client:
try: try:
@@ -138,6 +144,16 @@ class GeminiLLMProvider:
) as response: ) as response:
if response.status_code >= 400: if response.status_code >= 400:
detail = (await response.aread()).decode("utf-8", "replace").strip() detail = (await response.aread()).decode("utf-8", "replace").strip()
# 401/403 = clé rejetée par GOOGLE (pas un problème LoreMind) :
# message actionnable plutôt que le JSON brut de l'API.
if response.status_code in (401, 403):
raise LLMProviderError(
"Erreur Gemini : clé API refusée par Google "
f"(HTTP {response.status_code}). Vérifiez que la clé vient bien "
"de aistudio.google.com (« Get API key ») et qu'elle n'a pas de "
"restrictions (API ou adresse IP) dans la Google Cloud Console. "
f"Détail : {detail[:300]}"
)
raise LLMProviderError( raise LLMProviderError(
f"Erreur Gemini (HTTP {response.status_code})" f"Erreur Gemini (HTTP {response.status_code})"
+ (f" : {detail[:500]}" if detail else "") + (f" : {detail[:500]}" if detail else "")

View File

@@ -20,7 +20,7 @@ import httpx
from app.core.config import Settings from app.core.config import Settings
from app.domain.models import ChatMessage from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError from app.domain.ports import LLMGenerationTimeout, LLMProviderError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -101,7 +101,7 @@ class MistralLLMProvider:
try: try:
return await asyncio.wait_for(_collect(), timeout=self._timeout) return await asyncio.wait_for(_collect(), timeout=self._timeout)
except asyncio.TimeoutError as exc: except asyncio.TimeoutError as exc:
raise LLMProviderError( raise LLMGenerationTimeout(
f"Erreur Mistral : génération non terminée en {self._timeout}s. Réduisez la " 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." "taille des morceaux d'import, augmentez le timeout, ou changez de modèle."
) from exc ) from exc
@@ -136,6 +136,13 @@ class MistralLLMProvider:
} }
if temperature is not None: if temperature is not None:
body["temperature"] = temperature 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: async with httpx.AsyncClient(timeout=self._timeout) as client:
try: try:

View File

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

View File

@@ -22,7 +22,7 @@ from app.core.config import Settings
from app.domain.models import ChatMessage from app.domain.models import ChatMessage
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from app.domain.ports import LLMProviderError from app.domain.ports import LLMGenerationTimeout, LLMProviderError
_API_URL = "https://openrouter.ai/api/v1/chat/completions" _API_URL = "https://openrouter.ai/api/v1/chat/completions"
@@ -113,7 +113,7 @@ class OpenRouterLLMProvider:
try: try:
return await asyncio.wait_for(_collect(), timeout=self._timeout) return await asyncio.wait_for(_collect(), timeout=self._timeout)
except asyncio.TimeoutError as exc: except asyncio.TimeoutError as exc:
raise LLMProviderError( raise LLMGenerationTimeout(
f"Erreur {provider} : génération non terminée en {self._timeout}s. Réduisez la " 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." "taille des morceaux d'import, augmentez le timeout, ou changez de modèle."
) from exc ) from exc

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.12.0-beta", version="0.13.0-beta",
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId> <groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId> <artifactId>loremind-core</artifactId>
<version>0.12.0-beta</version> <version>0.13.0-beta</version>
<name>LoreMind Core</name> <name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description> <description>Backend Core - Architecture Hexagonale</description>

View File

@@ -42,15 +42,17 @@ public class CampaignBriefBuilder {
if (notBlank(cc.campaignDescription())) sb.append(cc.campaignDescription()).append("\n"); if (notBlank(cc.campaignDescription())) sb.append(cc.campaignDescription()).append("\n");
sb.append("\n## Structure (arcs → chapitres → scènes)\n"); sb.append("\n## Structure (arcs → chapitres → scènes)\n");
sb.append("_Un arc HUB contient des chapitres parallèles appelés « quêtes » ; ")
.append("un arc LINEAR contient des chapitres en séquence._\n");
if (cc.arcs().isEmpty()) { if (cc.arcs().isEmpty()) {
sb.append("_(aucun arc pour le moment)_\n"); sb.append("_(aucun arc pour le moment)_\n");
} }
for (ArcSummary arc : cc.arcs()) { for (ArcSummary arc : cc.arcs()) {
sb.append("### Arc : ").append(arc.name()); sb.append(arc.hub() ? "### Arc HUB (à quêtes) : " : "### Arc : ").append(arc.name());
if (notBlank(arc.description())) sb.append("").append(arc.description()); if (notBlank(arc.description())) sb.append("").append(arc.description());
sb.append("\n"); sb.append("\n");
for (ChapterSummary ch : arc.chapters()) { for (ChapterSummary ch : arc.chapters()) {
sb.append("- Chapitre : ").append(ch.name()); sb.append(arc.hub() ? "- Quête : " : "- Chapitre : ").append(ch.name());
if (notBlank(ch.description())) sb.append("").append(ch.description()); if (notBlank(ch.description())) sb.append("").append(ch.description());
sb.append("\n"); sb.append("\n");
for (SceneSummary sc : ch.scenes()) { for (SceneSummary sc : ch.scenes()) {

View File

@@ -64,9 +64,12 @@ public class CampaignImportService {
byte[] pdfBytes, byte[] pdfBytes,
String filename, String filename,
Consumer<CampaignImportProgress> onProgress, Consumer<CampaignImportProgress> onProgress,
Runnable onHeartbeat,
Consumer<String> onStatus,
Consumer<CampaignImportProposal> onDone, Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> onError) { Consumer<Throwable> onError) {
campaignPdfImporter.importCampaignStreaming(pdfBytes, filename, onProgress, onDone, onError); campaignPdfImporter.importCampaignStreaming(
pdfBytes, filename, onProgress, onHeartbeat, onStatus, onDone, onError);
} }
/** /**
@@ -161,7 +164,7 @@ public class CampaignImportService {
isBlank(p.description()) isBlank(p.description())
? java.util.Map.of() ? java.util.Map.of()
: java.util.Map.of("Description", p.description().trim()), : java.util.Map.of("Description", p.description().trim()),
null, null, campaignId, null, null)); null, null, campaignId, null, null, null));
created++; created++;
} }
return created; return created;

View File

@@ -78,6 +78,11 @@ public class CharacterService {
characterRepository.deleteById(id); characterRepository.deleteById(id);
} }
public List<Character> searchCharacters(String query) {
if (query == null || query.isBlank()) return List.of();
return characterRepository.searchByName(query.trim());
}
private int nextOrderFor(String playthroughId) { private int nextOrderFor(String playthroughId) {
return characterRepository.findByPlaythroughId(playthroughId).stream() return characterRepository.findByPlaythroughId(playthroughId).stream()
.mapToInt(Character::getOrder) .mapToInt(Character::getOrder)

View File

@@ -0,0 +1,102 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Enemy;
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Service d'application pour les fiches d'ennemis (bestiaire de campagne).
* Miroir de {@link NpcService} : fiche pilotée par le template ENNEMI du GameSystem.
*/
@Service
public class EnemyService {
private final EnemyRepository enemyRepository;
public EnemyService(EnemyRepository enemyRepository) {
this.enemyRepository = enemyRepository;
}
public record EnemyData(
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,
String campaignId,
Integer order
) {}
public Enemy createEnemy(EnemyData data) {
int order = data.order() != null ? data.order() : nextOrderFor(data.campaignId());
Enemy enemy = Enemy.builder()
.name(data.name())
.level(normalize(data.level()))
.folder(normalize(data.folder()))
.portraitImageId(data.portraitImageId())
.headerImageId(data.headerImageId())
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
.campaignId(data.campaignId())
.order(order)
.build();
return enemyRepository.save(enemy);
}
public Optional<Enemy> getEnemyById(String id) {
return enemyRepository.findById(id);
}
public List<Enemy> getEnemiesByCampaignId(String campaignId) {
return enemyRepository.findByCampaignId(campaignId);
}
public Enemy updateEnemy(String id, EnemyData data) {
Enemy existing = enemyRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Enemy non trouvé avec l'ID: " + id));
existing.setName(data.name());
existing.setLevel(normalize(data.level()));
existing.setFolder(normalize(data.folder()));
existing.setPortraitImageId(data.portraitImageId());
existing.setHeaderImageId(data.headerImageId());
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>());
if (data.order() != null) {
existing.setOrder(data.order());
}
return enemyRepository.save(existing);
}
public void deleteEnemy(String id) {
enemyRepository.deleteById(id);
}
public List<Enemy> searchEnemies(String query) {
if (query == null || query.isBlank()) return List.of();
return enemyRepository.searchByName(query.trim());
}
/** Trim ; chaîne vide → null (= non renseigné / non classé). */
private static String normalize(String value) {
if (value == null) return null;
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private int nextOrderFor(String campaignId) {
return enemyRepository.findByCampaignId(campaignId).stream()
.mapToInt(Enemy::getOrder)
.max()
.orElse(-1) + 1;
}
}

View File

@@ -82,6 +82,11 @@ public class ItemCatalogService {
repository.deleteById(id); repository.deleteById(id);
} }
public List<ItemCatalog> searchCatalogs(String query) {
if (query == null || query.isBlank()) return List.of();
return repository.searchByName(query.trim());
}
/** Génère une PROPOSITION de catalogue (non persistée) via l'IA, contextualisée campagne. */ /** Génère une PROPOSITION de catalogue (non persistée) via l'IA, contextualisée campagne. */
public ItemCatalog generateProposal(String campaignId, String description) { public ItemCatalog generateProposal(String campaignId, String description) {
ItemCatalogGenerator.GeneratedCatalog g = generator.generate(description, buildContext(campaignId)); ItemCatalogGenerator.GeneratedCatalog g = generator.generate(description, buildContext(campaignId));

View File

@@ -22,16 +22,19 @@ public class NotebookService {
private final NotebookIndexer indexer; private final NotebookIndexer indexer;
private final CampaignRepository campaignRepository; private final CampaignRepository campaignRepository;
private final CampaignBriefBuilder briefBuilder; private final CampaignBriefBuilder briefBuilder;
private final com.loremind.domain.gamesystemcontext.ports.GameSystemRepository gameSystemRepository;
public NotebookService( public NotebookService(
NotebookRepository repository, NotebookRepository repository,
NotebookIndexer indexer, NotebookIndexer indexer,
CampaignRepository campaignRepository, CampaignRepository campaignRepository,
CampaignBriefBuilder briefBuilder) { CampaignBriefBuilder briefBuilder,
com.loremind.domain.gamesystemcontext.ports.GameSystemRepository gameSystemRepository) {
this.repository = repository; this.repository = repository;
this.indexer = indexer; this.indexer = indexer;
this.campaignRepository = campaignRepository; this.campaignRepository = campaignRepository;
this.briefBuilder = briefBuilder; this.briefBuilder = briefBuilder;
this.gameSystemRepository = gameSystemRepository;
} }
// --- Notebooks --- // --- Notebooks ---
@@ -119,6 +122,58 @@ public class NotebookService {
.notebookId(notebookId).role(role).content(content).build()); .notebookId(notebookId).role(role).content(content).build());
} }
/** « Vider la conversation » : archive le fil actif (rien n'est supprimé). */
public void clearChat(String notebookId) {
repository.archiveMessagesByNotebookId(notebookId);
}
/** Messages archivés, chronologiques — l'appelant regroupe par {@code archivedAt}. */
public List<NotebookMessage> getArchivedMessages(String notebookId) {
return repository.findArchivedMessagesByNotebookId(notebookId);
}
// Budget total (caractères ≈ tokens/4) des archives injectées en référence :
// borne le prompt même si l'utilisateur coche plusieurs longues conversations.
private static final int ARCHIVE_CONTEXT_MAX_CHARS = 16000;
/**
* Bloc de contexte construit à partir des archives COCHÉES par l'utilisateur
* (clés = {@code archivedAt.toString()}). Injecté dans le prompt du chat pour
* que l'IA puisse s'appuyer sur d'anciennes conversations. Chaîne vide si
* aucune clé valide. Chaque archive est tronquée PAR LE DÉBUT au-delà de son
* budget : la fin d'une conversation (conclusions) est la partie utile.
*/
public String buildArchiveContext(String notebookId, List<String> archivedAtKeys) {
if (archivedAtKeys == null || archivedAtKeys.isEmpty()) return "";
var wanted = new java.util.HashSet<>(archivedAtKeys);
var groups = new java.util.LinkedHashMap<java.time.LocalDateTime, List<NotebookMessage>>();
for (NotebookMessage m : repository.findArchivedMessagesByNotebookId(notebookId)) {
if (m.getArchivedAt() != null && wanted.contains(m.getArchivedAt().toString())) {
groups.computeIfAbsent(m.getArchivedAt(), k -> new java.util.ArrayList<>()).add(m);
}
}
if (groups.isEmpty()) return "";
int budgetPerArchive = Math.max(2000, ARCHIVE_CONTEXT_MAX_CHARS / groups.size());
StringBuilder out = new StringBuilder(
"--- ANCIENNES CONVERSATIONS DE CET ATELIER (références choisies par le MJ : "
+ "tu peux t'appuyer sur leurs conclusions) ---\n");
groups.forEach((archivedAt, messages) -> {
StringBuilder convo = new StringBuilder();
for (NotebookMessage m : messages) {
convo.append("user".equals(m.getRole()) ? "MJ : " : "IA : ")
.append(m.getContent()).append('\n');
}
String text = convo.toString();
if (text.length() > budgetPerArchive) {
text = "[…début tronqué…]\n" + text.substring(text.length() - budgetPerArchive);
}
out.append("[Archive du ").append(archivedAt).append("]\n").append(text).append('\n');
});
out.append("--- FIN DES ANCIENNES CONVERSATIONS ---");
return out.toString();
}
// --- Contexte campagne (oriente l'IA) --- // --- Contexte campagne (oriente l'IA) ---
/** Brief COMPLET de la campagne (structure arcs/chapitres/scènes + PNJ + lore) : /** Brief COMPLET de la campagne (structure arcs/chapitres/scènes + PNJ + lore) :
@@ -127,6 +182,25 @@ public class NotebookService {
if (campaignId == null) return ""; if (campaignId == null) return "";
Campaign campaign = campaignRepository.findById(campaignId).orElse(null); Campaign campaign = campaignRepository.findById(campaignId).orElse(null);
if (campaign == null) return ""; if (campaign == null) return "";
return briefBuilder.build(campaign); String brief = briefBuilder.build(campaign);
// Champs TEXT de la fiche PNJ du système de jeu : permet à l'IA de remplir
// `values` des actions "npc" avec les BONS noms de champs (Histoire,
// Apparence…) au lieu de tout entasser dans une description générique.
String npcFields = npcSheetFields(campaign.getGameSystemId());
return npcFields.isEmpty() ? brief : brief + "\n\n" + npcFields;
}
private String npcSheetFields(String gameSystemId) {
if (gameSystemId == null || gameSystemId.isBlank()) return "";
var gameSystem = gameSystemRepository.findById(gameSystemId).orElse(null);
if (gameSystem == null || gameSystem.getNpcTemplate() == null) return "";
var names = gameSystem.getNpcTemplate().stream()
.filter(f -> f.getType() == com.loremind.domain.shared.template.FieldType.TEXT)
.map(com.loremind.domain.shared.template.TemplateField::getName)
.filter(n -> n != null && !n.isBlank())
.toList();
if (names.isEmpty()) return "";
return "FICHE PNJ — champs texte disponibles (clés à utiliser dans `values` "
+ "d'une action npc) : " + String.join(", ", names);
} }
} }

View File

@@ -1,9 +1,12 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Npc; import com.loremind.domain.campaigncontext.Npc;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.NpcRepository; import com.loremind.domain.campaigncontext.ports.NpcRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -16,9 +19,11 @@ import java.util.Optional;
public class NpcService { public class NpcService {
private final NpcRepository npcRepository; private final NpcRepository npcRepository;
private final CampaignRepository campaignRepository;
public NpcService(NpcRepository npcRepository) { public NpcService(NpcRepository npcRepository, CampaignRepository campaignRepository) {
this.npcRepository = npcRepository; this.npcRepository = npcRepository;
this.campaignRepository = campaignRepository;
} }
public record NpcData( public record NpcData(
@@ -29,6 +34,7 @@ public class NpcService {
Map<String, List<String>> imageValues, Map<String, List<String>> imageValues,
Map<String, Map<String, String>> keyValueValues, Map<String, Map<String, String>> keyValueValues,
String campaignId, String campaignId,
List<String> relatedPageIds,
String folder, String folder,
Integer order Integer order
) {} ) {}
@@ -45,6 +51,7 @@ public class NpcService {
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>()) .imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>()) .keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
.campaignId(data.campaignId()) .campaignId(data.campaignId())
.relatedPageIds(data.relatedPageIds() != null ? new ArrayList<>(data.relatedPageIds()) : new ArrayList<>())
.folder(normalizeFolder(data.folder())) .folder(normalizeFolder(data.folder()))
.order(order) .order(order)
.build(); .build();
@@ -59,6 +66,21 @@ public class NpcService {
return npcRepository.findByCampaignId(campaignId); return npcRepository.findByCampaignId(campaignId);
} }
/**
* PNJ de TOUTES les campagnes liées au Lore donné (via {@code campaign.loreId}).
* Sert au graphe du Lore : relier les PNJ aux pages qu'ils référencent.
* Volume faible (usage mono-utilisateur) → filtrage en mémoire assumé.
*/
public List<Npc> getNpcsByLoreId(String loreId) {
List<Npc> out = new ArrayList<>();
for (Campaign campaign : campaignRepository.findAll()) {
if (campaign.isLinkedToLore() && campaign.getLoreId().equals(loreId)) {
out.addAll(npcRepository.findByCampaignId(campaign.getId()));
}
}
return out;
}
public Npc updateNpc(String id, NpcData data) { public Npc updateNpc(String id, NpcData data) {
Npc existing = npcRepository.findById(id) Npc existing = npcRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Npc non trouvé avec l'ID: " + id)); .orElseThrow(() -> new IllegalArgumentException("Npc non trouvé avec l'ID: " + id));
@@ -68,6 +90,7 @@ public class NpcService {
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>()); existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>()); existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>()); existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>());
existing.setRelatedPageIds(data.relatedPageIds() != null ? new ArrayList<>(data.relatedPageIds()) : new ArrayList<>());
existing.setFolder(normalizeFolder(data.folder())); existing.setFolder(normalizeFolder(data.folder()));
if (data.order() != null) { if (data.order() != null) {
existing.setOrder(data.order()); existing.setOrder(data.order());
@@ -79,6 +102,11 @@ public class NpcService {
npcRepository.deleteById(id); npcRepository.deleteById(id);
} }
public List<Npc> searchNpcs(String query) {
if (query == null || query.isBlank()) return List.of();
return npcRepository.searchByName(query.trim());
}
/** Trim le dossier ; chaîne vide → null (= non classé). */ /** Trim le dossier ; chaîne vide → null (= non classé). */
private static String normalizeFolder(String folder) { private static String normalizeFolder(String folder) {
if (folder == null) return null; if (folder == null) return null;

View File

@@ -85,6 +85,11 @@ public class RandomTableService {
repository.deleteById(id); repository.deleteById(id);
} }
public List<RandomTable> searchTables(String query) {
if (query == null || query.isBlank()) return List.of();
return repository.searchByName(query.trim());
}
/** Génère une PROPOSITION de table (non persistée) via l'IA, contextualisée campagne. */ /** Génère une PROPOSITION de table (non persistée) via l'IA, contextualisée campagne. */
public RandomTable generateProposal(String campaignId, String description, String diceFormula) { public RandomTable generateProposal(String campaignId, String description, String diceFormula) {
String formula = (diceFormula == null || diceFormula.isBlank()) ? "1d20" : diceFormula; String formula = (diceFormula == null || diceFormula.isBlank()) ? "1d20" : diceFormula;

View File

@@ -39,9 +39,12 @@ public class GameSystemService {
byte[] pdfBytes, byte[] pdfBytes,
String filename, String filename,
java.util.function.Consumer<com.loremind.domain.gamesystemcontext.RulesImportProgress> onProgress, java.util.function.Consumer<com.loremind.domain.gamesystemcontext.RulesImportProgress> onProgress,
Runnable onHeartbeat,
java.util.function.Consumer<String> onStatus,
java.util.function.Consumer<RulesImportResult> onDone, java.util.function.Consumer<RulesImportResult> onDone,
java.util.function.Consumer<Throwable> onError) { java.util.function.Consumer<Throwable> onError) {
rulesPdfImporter.importRulesStreaming(pdfBytes, filename, onProgress, onDone, onError); rulesPdfImporter.importRulesStreaming(
pdfBytes, filename, onProgress, onHeartbeat, onStatus, onDone, onError);
} }
/** /**
@@ -54,6 +57,7 @@ public class GameSystemService {
String rulesMarkdown, String rulesMarkdown,
List<TemplateField> characterTemplate, List<TemplateField> characterTemplate,
List<TemplateField> npcTemplate, List<TemplateField> npcTemplate,
List<TemplateField> enemyTemplate,
String author, String author,
boolean isPublic boolean isPublic
) {} ) {}
@@ -68,6 +72,7 @@ public class GameSystemService {
.build(); .build();
gameSystem.replaceCharacterTemplate(data.characterTemplate()); gameSystem.replaceCharacterTemplate(data.characterTemplate());
gameSystem.replaceNpcTemplate(data.npcTemplate()); gameSystem.replaceNpcTemplate(data.npcTemplate());
gameSystem.replaceEnemyTemplate(data.enemyTemplate());
return gameSystemRepository.save(gameSystem); return gameSystemRepository.save(gameSystem);
} }
@@ -87,6 +92,7 @@ public class GameSystemService {
existing.setRulesMarkdown(data.rulesMarkdown()); existing.setRulesMarkdown(data.rulesMarkdown());
existing.replaceCharacterTemplate(data.characterTemplate()); existing.replaceCharacterTemplate(data.characterTemplate());
existing.replaceNpcTemplate(data.npcTemplate()); existing.replaceNpcTemplate(data.npcTemplate());
existing.replaceEnemyTemplate(data.enemyTemplate());
existing.setAuthor(normalize(data.author())); existing.setAuthor(normalize(data.author()));
existing.setPublic(data.isPublic()); existing.setPublic(data.isPublic());
return gameSystemRepository.save(existing); return gameSystemRepository.save(existing);

View File

@@ -1,6 +1,7 @@
package com.loremind.application.generationcontext; package com.loremind.application.generationcontext;
import com.loremind.domain.campaigncontext.Arc; import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.ArcType;
import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter; import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Character; import com.loremind.domain.campaigncontext.Character;
@@ -10,6 +11,7 @@ import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository; import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
import com.loremind.domain.campaigncontext.ports.NpcRepository; import com.loremind.domain.campaigncontext.ports.NpcRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.CampaignStructuralContext; import com.loremind.domain.generationcontext.CampaignStructuralContext;
@@ -48,6 +50,7 @@ public class CampaignStructuralContextBuilder {
private final SceneRepository sceneRepository; private final SceneRepository sceneRepository;
private final CharacterRepository characterRepository; private final CharacterRepository characterRepository;
private final NpcRepository npcRepository; private final NpcRepository npcRepository;
private final EnemyRepository enemyRepository;
public CampaignStructuralContextBuilder( public CampaignStructuralContextBuilder(
CampaignRepository campaignRepository, CampaignRepository campaignRepository,
@@ -55,13 +58,15 @@ public class CampaignStructuralContextBuilder {
ChapterRepository chapterRepository, ChapterRepository chapterRepository,
SceneRepository sceneRepository, SceneRepository sceneRepository,
CharacterRepository characterRepository, CharacterRepository characterRepository,
NpcRepository npcRepository) { NpcRepository npcRepository,
EnemyRepository enemyRepository) {
this.campaignRepository = campaignRepository; this.campaignRepository = campaignRepository;
this.arcRepository = arcRepository; this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository; this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository; this.sceneRepository = sceneRepository;
this.characterRepository = characterRepository; this.characterRepository = characterRepository;
this.npcRepository = npcRepository; this.npcRepository = npcRepository;
this.enemyRepository = enemyRepository;
} }
/** Longueur max du snippet de PJ/PNJ injecté dans le contexte (coût tokens maîtrisé). */ /** Longueur max du snippet de PJ/PNJ injecté dans le contexte (coût tokens maîtrisé). */
@@ -84,9 +89,17 @@ public class CampaignStructuralContextBuilder {
.orElseThrow(() -> new IllegalArgumentException( .orElseThrow(() -> new IllegalArgumentException(
"Campagne non trouvée avec l'ID: " + campaignId)); "Campagne non trouvée avec l'ID: " + campaignId));
// Libellés du bestiaire (« Nom (niveau) ») chargés UNE fois pour résoudre
// les enemyIds des pièces sans N+1 sur le repo.
Map<String, String> enemyLabelById = enemyRepository.findByCampaignId(campaignId).stream()
.collect(Collectors.toMap(
com.loremind.domain.campaigncontext.Enemy::getId,
CampaignStructuralContextBuilder::enemyLabel,
(a, b) -> a));
List<ArcSummary> arcs = arcRepository.findByCampaignId(campaignId).stream() List<ArcSummary> arcs = arcRepository.findByCampaignId(campaignId).stream()
.sorted(Comparator.comparingInt(Arc::getOrder)) .sorted(Comparator.comparingInt(Arc::getOrder))
.map(this::toArcSummary) .map(arc -> toArcSummary(arc, enemyLabelById))
.collect(Collectors.toList()); .collect(Collectors.toList());
List<CharacterSummary> characters = (playthroughId == null || playthroughId.isBlank()) List<CharacterSummary> characters = (playthroughId == null || playthroughId.isBlank())
@@ -143,19 +156,20 @@ public class CampaignStructuralContextBuilder {
return ""; return "";
} }
private ArcSummary toArcSummary(Arc arc) { private ArcSummary toArcSummary(Arc arc, Map<String, String> enemyLabelById) {
List<ChapterSummary> chapters = chapterRepository.findByArcId(arc.getId()).stream() List<ChapterSummary> chapters = chapterRepository.findByArcId(arc.getId()).stream()
.sorted(Comparator.comparingInt(Chapter::getOrder)) .sorted(Comparator.comparingInt(Chapter::getOrder))
.map(this::toChapterSummary) .map(chapter -> toChapterSummary(chapter, enemyLabelById))
.collect(Collectors.toList()); .collect(Collectors.toList());
return new ArcSummary( return new ArcSummary(
arc.getName(), arc.getName(),
arc.getDescription(), arc.getDescription(),
arc.getType() == ArcType.HUB,
countImages(arc.getIllustrationImageIds()), countImages(arc.getIllustrationImageIds()),
chapters); chapters);
} }
private ChapterSummary toChapterSummary(Chapter chapter) { private ChapterSummary toChapterSummary(Chapter chapter, Map<String, String> enemyLabelById) {
List<Scene> scenes = sceneRepository.findByChapterId(chapter.getId()).stream() List<Scene> scenes = sceneRepository.findByChapterId(chapter.getId()).stream()
.sorted(Comparator.comparingInt(Scene::getOrder)) .sorted(Comparator.comparingInt(Scene::getOrder))
.toList(); .toList();
@@ -166,7 +180,7 @@ public class CampaignStructuralContextBuilder {
.collect(Collectors.toMap(Scene::getId, Scene::getName)); .collect(Collectors.toMap(Scene::getId, Scene::getName));
List<SceneSummary> summaries = scenes.stream() List<SceneSummary> summaries = scenes.stream()
.map(s -> toSceneSummary(s, nameById)) .map(s -> toSceneSummary(s, nameById, enemyLabelById))
.collect(Collectors.toList()); .collect(Collectors.toList());
return new ChapterSummary( return new ChapterSummary(
@@ -176,7 +190,8 @@ public class CampaignStructuralContextBuilder {
summaries); summaries);
} }
private SceneSummary toSceneSummary(Scene scene, Map<String, String> nameById) { private SceneSummary toSceneSummary(
Scene scene, Map<String, String> nameById, Map<String, String> enemyLabelById) {
List<BranchHint> hints = scene.getBranches() == null List<BranchHint> hints = scene.getBranches() == null
? List.of() ? List.of()
: scene.getBranches().stream() : scene.getBranches().stream()
@@ -186,7 +201,7 @@ public class CampaignStructuralContextBuilder {
b.condition())) b.condition()))
.collect(Collectors.toList()); .collect(Collectors.toList());
List<RoomSummary> rooms = toRoomSummaries(scene); List<RoomSummary> rooms = toRoomSummaries(scene, enemyLabelById);
return new SceneSummary( return new SceneSummary(
scene.getName(), scene.getName(),
@@ -202,7 +217,7 @@ public class CampaignStructuralContextBuilder {
* connaît la structure du lieu (nom des pièces, ennemis, sorties) — c'est * connaît la structure du lieu (nom des pièces, ennemis, sorties) — c'est
* suffisant pour proposer de la narration ou anticiper les choix. * suffisant pour proposer de la narration ou anticiper les choix.
*/ */
private List<RoomSummary> toRoomSummaries(Scene scene) { private List<RoomSummary> toRoomSummaries(Scene scene, Map<String, String> enemyLabelById) {
if (scene.getRooms() == null || scene.getRooms().isEmpty()) return List.of(); if (scene.getRooms() == null || scene.getRooms().isEmpty()) return List.of();
Map<String, String> nameById = scene.getRooms().stream() Map<String, String> nameById = scene.getRooms().stream()
.collect(Collectors.toMap( .collect(Collectors.toMap(
@@ -219,11 +234,36 @@ public class CampaignStructuralContextBuilder {
nameById.getOrDefault(b.targetRoomId(), "(pièce inconnue)"), nameById.getOrDefault(b.targetRoomId(), "(pièce inconnue)"),
b.condition())) b.condition()))
.collect(Collectors.toList()); .collect(Collectors.toList());
return new RoomSummary(r.getName(), r.getFloor(), r.getDescription(), r.getEnemies(), hints); return new RoomSummary(
r.getName(), r.getFloor(), r.getDescription(),
roomEnemiesText(r, enemyLabelById), hints);
}) })
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
/**
* Texte « ennemis » d'une pièce pour le prompt : fiches du bestiaire
* référencées (libellés résolus, IDs orphelins ignorés) suivies du texte
* libre. L'un ou l'autre peut être vide.
*/
private static String roomEnemiesText(
com.loremind.domain.campaigncontext.Room room, Map<String, String> enemyLabelById) {
String linked = room.getEnemyIds() == null ? "" : room.getEnemyIds().stream()
.map(enemyLabelById::get)
.filter(l -> l != null && !l.isBlank())
.collect(Collectors.joining(", "));
String freeText = room.getEnemies() == null ? "" : room.getEnemies().strip();
if (linked.isEmpty()) return freeText;
if (freeText.isEmpty()) return linked;
return linked + "" + freeText;
}
/** Libellé court d'une fiche du bestiaire : « Nom (niveau) » ou « Nom ». */
private static String enemyLabel(com.loremind.domain.campaigncontext.Enemy enemy) {
String level = enemy.getLevel() == null ? "" : enemy.getLevel().strip();
return level.isEmpty() ? enemy.getName() : enemy.getName() + " (" + level + ")";
}
/** Helper defensif : compte les illustrations attachees (null-safe). */ /** Helper defensif : compte les illustrations attachees (null-safe). */
private static int countImages(List<String> ids) { private static int countImages(List<String> ids) {
return ids == null ? 0 : ids.size(); return ids == null ? 0 : ids.size();

View File

@@ -8,6 +8,7 @@ import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository; import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository; import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository; import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
import com.loremind.domain.campaigncontext.ports.NpcRepository; import com.loremind.domain.campaigncontext.ports.NpcRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository; import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.generationcontext.NarrativeEntityContext; import com.loremind.domain.generationcontext.NarrativeEntityContext;
@@ -32,18 +33,21 @@ public class NarrativeEntityContextBuilder {
private final SceneRepository sceneRepository; private final SceneRepository sceneRepository;
private final CharacterRepository characterRepository; private final CharacterRepository characterRepository;
private final NpcRepository npcRepository; private final NpcRepository npcRepository;
private final EnemyRepository enemyRepository;
public NarrativeEntityContextBuilder( public NarrativeEntityContextBuilder(
ArcRepository arcRepository, ArcRepository arcRepository,
ChapterRepository chapterRepository, ChapterRepository chapterRepository,
SceneRepository sceneRepository, SceneRepository sceneRepository,
CharacterRepository characterRepository, CharacterRepository characterRepository,
NpcRepository npcRepository) { NpcRepository npcRepository,
EnemyRepository enemyRepository) {
this.arcRepository = arcRepository; this.arcRepository = arcRepository;
this.chapterRepository = chapterRepository; this.chapterRepository = chapterRepository;
this.sceneRepository = sceneRepository; this.sceneRepository = sceneRepository;
this.characterRepository = characterRepository; this.characterRepository = characterRepository;
this.npcRepository = npcRepository; this.npcRepository = npcRepository;
this.enemyRepository = enemyRepository;
} }
/** /**
@@ -124,10 +128,41 @@ public class NarrativeEntityContextBuilder {
putField(fields, "choicesConsequences", s.getChoicesConsequences()); putField(fields, "choicesConsequences", s.getChoicesConsequences());
putField(fields, "combatDifficulty", s.getCombatDifficulty()); putField(fields, "combatDifficulty", s.getCombatDifficulty());
putField(fields, "enemies", s.getEnemies()); putField(fields, "enemies", s.getEnemies());
putField(fields, "linkedEnemies", resolveLinkedEnemies(s));
putField(fields, "gmSecretNotes", s.getGmSecretNotes()); putField(fields, "gmSecretNotes", s.getGmSecretNotes());
return new NarrativeEntityContext("scene", s.getName(), fields); return new NarrativeEntityContext("scene", s.getName(), fields);
} }
/**
* Résout les fiches du bestiaire référencées par la scène en une ligne par
* ennemi : « Nom (niveau) — champ: valeur ; … ». Valeurs tronquées : le
* contexte focus doit camper la rencontre, pas embarquer la fiche complète.
* Les IDs orphelins (fiche supprimée) sont ignorés silencieusement.
*/
private String resolveLinkedEnemies(Scene s) {
if (s.getEnemyIds() == null || s.getEnemyIds().isEmpty()) return "";
StringBuilder sb = new StringBuilder();
for (String enemyId : s.getEnemyIds()) {
enemyRepository.findById(enemyId).ifPresent(e -> {
if (sb.length() > 0) sb.append("\n");
sb.append("- ").append(e.getName());
if (e.getLevel() != null && !e.getLevel().isBlank()) {
sb.append(" (").append(e.getLevel().trim()).append(")");
}
String stats = e.getValues().entrySet().stream()
.filter(en -> en.getValue() != null && !en.getValue().isBlank())
.map(en -> en.getKey() + ": " + truncate(en.getValue().trim(), 100))
.collect(java.util.stream.Collectors.joining(" ; "));
if (!stats.isEmpty()) sb.append("").append(stats);
});
}
return sb.toString();
}
private static String truncate(String value, int maxLen) {
return value.length() <= maxLen ? value : value.substring(0, maxLen - 1).stripTrailing() + "";
}
private NarrativeEntityContext fromCharacter(Character c) { private NarrativeEntityContext fromCharacter(Character c) {
Map<String, String> fields = new LinkedHashMap<>(); Map<String, String> fields = new LinkedHashMap<>();
if (c.getValues() != null) { if (c.getValues() != null) {

View File

@@ -76,6 +76,8 @@ public class PageService {
existing.setNodeId(changes.getNodeId()); existing.setNodeId(changes.getNodeId());
existing.setValues(CollectionUtils.copyMap(changes.getValues())); existing.setValues(CollectionUtils.copyMap(changes.getValues()));
existing.setImageValues(CollectionUtils.copyMap(changes.getImageValues())); existing.setImageValues(CollectionUtils.copyMap(changes.getImageValues()));
existing.setKeyValueValues(CollectionUtils.copyMap(changes.getKeyValueValues()));
existing.setTableValues(CollectionUtils.copyMap(changes.getTableValues()));
existing.setNotes(changes.getNotes()); existing.setNotes(changes.getNotes());
existing.setTags(CollectionUtils.copyList(changes.getTags())); existing.setTags(CollectionUtils.copyList(changes.getTags()));
existing.setRelatedPageIds(CollectionUtils.copyList(changes.getRelatedPageIds())); existing.setRelatedPageIds(CollectionUtils.copyList(changes.getRelatedPageIds()));

View File

@@ -0,0 +1,70 @@
package com.loremind.domain.campaigncontext;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Fiche d'ennemi (monstre/créature) d'une campagne — le bestiaire du MJ.
* <p>
* Même principe de templating que {@link Npc} : champs universels hard-codés
* (nom, niveau, dossier, portrait, bandeau) + champs pilotés par le template
* ENNEMI du GameSystem ({@code GameSystem.enemyTemplate} : CA, PV, attaques…).
* Classement libre par dossier (« Démons », « Humanoïdes »…).
*/
@Data
@Builder
public class Enemy {
private String id;
private String name;
/** Niveau / FP / dangerosité — texte libre (« 5 », « FP 8 », « Boss »). Nullable. */
private String level;
/** Dossier de classement (texte libre). Null = non classé. */
private String folder;
/** ID de l'image portrait (champ universel hard-codé). Nullable. */
private String portraitImageId;
/** ID de l'image header/bannière (champ universel hard-codé). Nullable. */
private String headerImageId;
/** Valeurs TEXT/NUMBER du template ennemi. Jamais null après construction. */
private Map<String, String> values;
/** Valeurs IMAGE du template ennemi (listes d'IDs ordonnées par champ). Jamais null. */
private Map<String, List<String>> imageValues;
/** Valeurs KEY_VALUE_LIST : fieldName -> label -> value. Jamais null. */
private Map<String, Map<String, String>> keyValueValues;
/** Référence vers la Campaign parente (cross-aggregate via ID). */
private String campaignId;
/** Ordre d'affichage dans la liste. */
private int order;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public Map<String, String> getValues() {
if (values == null) values = new HashMap<>();
return values;
}
public Map<String, List<String>> getImageValues() {
if (imageValues == null) imageValues = new HashMap<>();
return imageValues;
}
public Map<String, Map<String, String>> getKeyValueValues() {
if (keyValueValues == null) keyValueValues = new HashMap<>();
return keyValueValues;
}
}

View File

@@ -17,4 +17,6 @@ public class NotebookMessage {
private String role; private String role;
private String content; private String content;
private LocalDateTime createdAt; private LocalDateTime createdAt;
/** Null = conversation active ; sinon horodatage du « vider » (lot d'archive). */
private LocalDateTime archivedAt;
} }

View File

@@ -4,6 +4,7 @@ import lombok.Builder;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -46,6 +47,13 @@ public class Npc {
/** Référence vers la Campaign parente (cross-aggregate via ID). */ /** Référence vers la Campaign parente (cross-aggregate via ID). */
private String campaignId; private String campaignId;
/**
* IDs de Pages de Lore référencées par ce PNJ (sa ville, sa faction, sa
* région…). Référence faible cross-context, même principe que sur
* Arc/Chapter/Scene — alimente notamment le graphe du Lore.
*/
private List<String> relatedPageIds;
/** Dossier de classement (texte libre, ex. « Bard's Gate »). Nullable = non classé. */ /** Dossier de classement (texte libre, ex. « Bard's Gate »). Nullable = non classé. */
private String folder; private String folder;
@@ -69,4 +77,9 @@ public class Npc {
if (keyValueValues == null) keyValueValues = new HashMap<>(); if (keyValueValues == null) keyValueValues = new HashMap<>();
return keyValueValues; return keyValueValues;
} }
public List<String> getRelatedPageIds() {
if (relatedPageIds == null) relatedPageIds = new ArrayList<>();
return relatedPageIds;
}
} }

View File

@@ -38,6 +38,13 @@ public class Room {
/** Énemis, créatures, boss éventuels (markdown libre). */ /** Énemis, créatures, boss éventuels (markdown libre). */
private String enemies; private String enemies;
/**
* IDs des fiches du bestiaire ({@link Enemy}) présentes dans la pièce
* (weak refs). Complète le texte libre {@code enemies}, comme sur Scene.
*/
@Builder.Default
private List<String> enemyIds = new ArrayList<>();
/** Loot / récompenses présentes dans la pièce. */ /** Loot / récompenses présentes dans la pièce. */
private String loot; private String loot;

View File

@@ -40,7 +40,15 @@ public class Scene {
// === Combat ou rencontre === // === Combat ou rencontre ===
private String combatDifficulty; // Difficulté estimée private String combatDifficulty; // Difficulté estimée
private String enemies; // Liste des ennemis et créatures private String enemies; // Liste des ennemis et créatures (texte libre)
/**
* IDs des fiches du bestiaire ({@link Enemy}) engagées dans cette rencontre
* (weak cross-aggregate references). Complète le texte libre `enemies` :
* l'utilisateur peut référencer ses fiches, ou tout écrire à la main, ou les deux.
*/
@Builder.Default
private List<String> enemyIds = new ArrayList<>();
/** /**
* IDs des pages du Lore associées à cette scène (weak cross-context references). * IDs des pages du Lore associées à cette scène (weak cross-context references).

View File

@@ -15,14 +15,23 @@ public interface CampaignPdfImporter {
* Variante streamée : l'import peut durer plusieurs minutes, on remonte * Variante streamée : l'import peut durer plusieurs minutes, on remonte
* l'avancement au fil de l'eau, puis la proposition finale. * l'avancement au fil de l'eau, puis la proposition finale.
* *
* @param onProgress invoqué à chaque étape (extraction, puis par morceau). * @param onProgress invoqué à chaque étape (extraction, puis par morceau).
* @param onDone invoqué une fois avec l'arbre proposé (non persisté). * @param onHeartbeat invoqué périodiquement pendant un appel LLM long (aucune
* @param onError invoqué si l'extraction/structuration échoue. * avancée à afficher, mais le canal SSE vers le navigateur
* doit rester actif — sinon un proxy intermédiaire le coupe).
* @param onStatus invoqué avec un message lisible quand quelque chose se
* passe pendant l'attente (fournisseur saturé → retry,
* morceau re-découpé, morceau ignoré…) — affiché par l'UI
* pour que l'utilisateur n'ait pas à lire les logs.
* @param onDone invoqué une fois avec l'arbre proposé (non persisté).
* @param onError invoqué si l'extraction/structuration échoue.
*/ */
void importCampaignStreaming( void importCampaignStreaming(
byte[] pdfBytes, byte[] pdfBytes,
String filename, String filename,
Consumer<CampaignImportProgress> onProgress, Consumer<CampaignImportProgress> onProgress,
Runnable onHeartbeat,
Consumer<String> onStatus,
Consumer<CampaignImportProposal> onDone, Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> onError); Consumer<Throwable> onError);
} }

View File

@@ -19,4 +19,7 @@ public interface CharacterRepository {
void deleteById(String id); void deleteById(String id);
boolean existsById(String id); boolean existsById(String id);
/** Recherche par nom (insensible à la casse) — alimente la recherche globale. */
List<Character> searchByName(String query);
} }

View File

@@ -0,0 +1,23 @@
package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.Enemy;
import java.util.List;
import java.util.Optional;
/**
* Port de sortie pour la persistance des fiches d'ennemis (bestiaire de campagne).
*/
public interface EnemyRepository {
Enemy save(Enemy enemy);
Optional<Enemy> findById(String id);
List<Enemy> findByCampaignId(String campaignId);
void deleteById(String id);
/** Recherche par nom (insensible à la casse) — alimente la recherche globale. */
List<Enemy> searchByName(String query);
}

View File

@@ -19,4 +19,7 @@ public interface ItemCatalogRepository {
void deleteById(String id); void deleteById(String id);
boolean existsById(String id); boolean existsById(String id);
/** Recherche par nom (insensible à la casse) — alimente la recherche globale. */
List<ItemCatalog> searchByName(String query);
} }

View File

@@ -28,5 +28,10 @@ public interface NotebookRepository {
// --- Messages (conversation) --- // --- Messages (conversation) ---
NotebookMessage saveMessage(NotebookMessage message); NotebookMessage saveMessage(NotebookMessage message);
/** Messages de la conversation ACTIVE (les archives sont exclues). */
List<NotebookMessage> findMessagesByNotebookId(String notebookId); List<NotebookMessage> findMessagesByNotebookId(String notebookId);
/** « Vider » : archive le fil actif en un lot horodaté (rien n'est supprimé). */
void archiveMessagesByNotebookId(String notebookId);
/** Messages archivés, chronologiques (regroupables par {@code archivedAt}). */
List<NotebookMessage> findArchivedMessagesByNotebookId(String notebookId);
} }

View File

@@ -19,4 +19,7 @@ public interface NpcRepository {
void deleteById(String id); void deleteById(String id);
boolean existsById(String id); boolean existsById(String id);
/** Recherche par nom (insensible à la casse) — alimente la recherche globale. */
List<Npc> searchByName(String query);
} }

View File

@@ -19,4 +19,7 @@ public interface RandomTableRepository {
void deleteById(String id); void deleteById(String id);
boolean existsById(String id); boolean existsById(String id);
/** Recherche par nom (insensible à la casse) — alimente la recherche globale. */
List<RandomTable> searchByName(String query);
} }

View File

@@ -50,6 +50,14 @@ public class GameSystem {
*/ */
private List<TemplateField> npcTemplate; private List<TemplateField> npcTemplate;
/**
* Template de fiche ENNEMI (monstres/créatures du bestiaire de campagne).
* Mêmes règles que {@link #characterTemplate} — distinct du template PNJ :
* un ennemi porte des stats de combat (CA, PV, attaques…), pas une
* caractérisation narrative.
*/
private List<TemplateField> enemyTemplate;
/** Auteur déclaré — futur marketplace. Nullable. */ /** Auteur déclaré — futur marketplace. Nullable. */
private String author; private String author;
@@ -98,6 +106,10 @@ public class GameSystem {
npcTemplate = validateAndCopy(fields); npcTemplate = validateAndCopy(fields);
} }
public void replaceEnemyTemplate(List<TemplateField> fields) {
enemyTemplate = validateAndCopy(fields);
}
// --- Helpers privés ---------------------------------------------------- // --- Helpers privés ----------------------------------------------------
private static List<TemplateField> appendField(List<TemplateField> current, TemplateField field) { private static List<TemplateField> appendField(List<TemplateField> current, TemplateField field) {

View File

@@ -26,14 +26,23 @@ public interface RulesPdfImporter {
* l'avancement au fil de l'eau. Les callbacks sont invoqués depuis le thread * l'avancement au fil de l'eau. Les callbacks sont invoqués depuis le thread
* d'exécution de l'adapter (synchrone jusqu'à {@code onDone}/{@code onError}). * d'exécution de l'adapter (synchrone jusqu'à {@code onDone}/{@code onError}).
* *
* @param onProgress invoqué à chaque étape (extraction, puis par morceau). * @param onProgress invoqué à chaque étape (extraction, puis par morceau).
* @param onDone invoqué une fois avec le résultat final. * @param onHeartbeat invoqué périodiquement pendant un appel LLM long (aucune
* @param onError invoqué si l'extraction/structuration échoue. * avancée à afficher, mais le canal SSE vers le navigateur
* doit rester actif — sinon un proxy intermédiaire le coupe).
* @param onStatus invoqué avec un message lisible quand quelque chose se
* passe pendant l'attente (fournisseur saturé → retry,
* morceau re-découpé, morceau ignoré…) — affiché par l'UI
* pour que l'utilisateur n'ait pas à lire les logs.
* @param onDone invoqué une fois avec le résultat final.
* @param onError invoqué si l'extraction/structuration échoue.
*/ */
void importRulesStreaming( void importRulesStreaming(
byte[] pdfBytes, byte[] pdfBytes,
String filename, String filename,
Consumer<RulesImportProgress> onProgress, Consumer<RulesImportProgress> onProgress,
Runnable onHeartbeat,
Consumer<String> onStatus,
Consumer<RulesImportResult> onDone, Consumer<RulesImportResult> onDone,
Consumer<Throwable> onError); Consumer<Throwable> onError);
} }

View File

@@ -52,11 +52,15 @@ public record CampaignStructuralContext(
/** /**
* Résumé d'un arc : nom + description courte + ses chapitres. * Résumé d'un arc : nom + description courte + ses chapitres.
* *
* @param hub true si l'arc est de type HUB : ses chapitres sont des
* « quêtes » parallèles (vocabulaire UI). L'IA doit le savoir
* pour parler de quêtes et cibler le bon arc.
* @param illustrationCount Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA). * @param illustrationCount Nombre d'illustrations attachees a cet arc (pour hint dans le prompt IA).
*/ */
public record ArcSummary( public record ArcSummary(
String name, String name,
String description, String description,
boolean hub,
int illustrationCount, int illustrationCount,
List<ChapterSummary> chapters) { List<ChapterSummary> chapters) {
} }

View File

@@ -39,6 +39,20 @@ public class Page {
*/ */
private Map<String, List<String>> imageValues; private Map<String, List<String>> imageValues;
/**
* Valeurs des champs KEY_VALUE_LIST (tableau libelle → valeur, comme sur les
* fiches de personnage) : fieldName → (label → valeur). Les labels sont
* definis par le Template ; seules les valeurs vivent sur la page.
*/
private Map<String, Map<String, String>> keyValueValues;
/**
* Valeurs des champs TABLE (colonnes figees au template, lignes libres) :
* fieldName → liste ordonnee de lignes, chaque ligne = colonne → cellule.
* Usage type : inventaire de boutique, table d'objets.
*/
private Map<String, List<Map<String, String>>> tableValues;
/** Notes privées du MJ (non exportées vers FoundryVTT). */ /** Notes privées du MJ (non exportées vers FoundryVTT). */
private String notes; private String notes;

View File

@@ -9,6 +9,11 @@ package com.loremind.domain.shared.template;
* - KEY_VALUE_LIST : liste de paires {label, value} avec labels figes au template * - KEY_VALUE_LIST : liste de paires {label, value} avec labels figes au template
* (Map<String, Map<String, String>> : fieldName -> label -> value). * (Map<String, Map<String, String>> : fieldName -> label -> value).
* Usage : stat blocks, listes de competences, traits. * Usage : stat blocks, listes de competences, traits.
* - TABLE : tableau a colonnes figees au template (TemplateField.labels =
* noms de colonnes) et lignes LIBRES ajoutees au remplissage
* (Map<String, List<Map<String, String>>> : fieldName -> lignes,
* chaque ligne = colonne -> cellule).
* Usage : inventaire de boutique, tables d'objets, listes de prix.
* <p> * <p>
* Extension future possible : RICH_TEXT, DATE, BOOLEAN, REFERENCE... * Extension future possible : RICH_TEXT, DATE, BOOLEAN, REFERENCE...
*/ */
@@ -16,5 +21,6 @@ public enum FieldType {
TEXT, TEXT,
IMAGE, IMAGE,
NUMBER, NUMBER,
KEY_VALUE_LIST KEY_VALUE_LIST,
TABLE
} }

View File

@@ -30,8 +30,9 @@ public class TemplateField {
/** Variante de rendu pour les champs IMAGE. Null = GALLERY. */ /** Variante de rendu pour les champs IMAGE. Null = GALLERY. */
private ImageLayout layout; private ImageLayout layout;
/** /**
* Labels predefinis pour les champs KEY_VALUE_LIST (ordre significatif). * Labels predefinis (ordre significatif), selon le type :
* Ex: ["FOR","DEX","CON","INT","SAG","CHA"] pour un champ "Caracteristiques". * - KEY_VALUE_LIST : libelles des lignes. Ex: ["FOR","DEX","CON","INT","SAG","CHA"].
* - TABLE : noms des COLONNES. Ex: ["Objet","Prix","Description"].
* Null/vide pour les autres types. * Null/vide pour les autres types.
*/ */
private List<String> labels; private List<String> labels;
@@ -70,4 +71,9 @@ public class TemplateField {
public static TemplateField keyValueList(String name, List<String> labels) { public static TemplateField keyValueList(String name, List<String> labels) {
return new TemplateField(name, FieldType.KEY_VALUE_LIST, null, labels); return new TemplateField(name, FieldType.KEY_VALUE_LIST, null, labels);
} }
/** Raccourci : construit un champ TABLE avec ses noms de colonnes. */
public static TemplateField table(String name, List<String> columns) {
return new TemplateField(name, FieldType.TABLE, null, columns);
}
} }

View File

@@ -60,6 +60,8 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
byte[] pdfBytes, byte[] pdfBytes,
String filename, String filename,
Consumer<CampaignImportProgress> onProgress, Consumer<CampaignImportProgress> onProgress,
Runnable onHeartbeat,
Consumer<String> onStatus,
Consumer<CampaignImportProposal> onDone, Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> onError) { Consumer<Throwable> onError) {
@@ -83,7 +85,8 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
flux flux
.timeout(Duration.ofSeconds(importTimeoutSeconds)) .timeout(Duration.ofSeconds(importTimeoutSeconds))
.doOnNext(sse -> handleEvent( .doOnNext(sse -> handleEvent(
sse, pageCount, ocrPageCount, terminated, onProgress, onDone, onError)) sse, pageCount, ocrPageCount, terminated,
onProgress, onHeartbeat, onStatus, onDone, onError))
.blockLast(); .blockLast();
if (!terminated[0]) { if (!terminated[0]) {
onError.accept(new CampaignImportException( onError.accept(new CampaignImportException(
@@ -107,12 +110,36 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
int[] ocrPageCount, int[] ocrPageCount,
boolean[] terminated, boolean[] terminated,
Consumer<CampaignImportProgress> onProgress, Consumer<CampaignImportProgress> onProgress,
Runnable onHeartbeat,
Consumer<String> onStatus,
Consumer<CampaignImportProposal> onDone, Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> onError) { Consumer<Throwable> onError) {
String event = sse.event(); String event = sse.event();
String data = sse.data() == null ? "" : sse.data(); String data = sse.data() == null ? "" : sse.data();
if ("heartbeat".equals(event)) {
// Keep-alive du Brain pendant un appel LLM long : à PROPAGER jusqu'au
// navigateur, sinon nginx (proxy_read_timeout) coupe le SSE Core→front.
onHeartbeat.run();
return;
}
if ("status".equals(event)) {
// Message d'attente lisible (retry sur fournisseur saturé, morceau
// re-découpé…) : affiché par l'UI au lieu de n'exister qu'en logs.
onStatus.accept(readMessage(data));
return;
}
if ("chunk_failed".equals(event)) {
JsonNode node = readJson(data);
String msg = node != null && node.hasNonNull("message")
? node.get("message").asText() : "";
int current = node != null ? node.path("current").asInt() : 0;
int total = node != null ? node.path("total").asInt() : 0;
onStatus.accept("Morceau " + current + "/" + total + " ignoré"
+ (msg.isEmpty() ? "." : " : " + msg));
return;
}
if ("error".equals(event)) { if ("error".equals(event)) {
terminated[0] = true; terminated[0] = true;
onError.accept(new CampaignImportException( onError.accept(new CampaignImportException(

View File

@@ -252,9 +252,15 @@ public class BrainChatPayloadBuilder {
ArcSummary::name, ArcSummary::name,
ArcSummary::description, ArcSummary::description,
ArcSummary::illustrationCount, ArcSummary::illustrationCount,
(map, arc) -> map.put("chapters", arc.chapters().stream() (map, arc) -> {
.map(this::chapterSummaryToMap) // Vocabulaire UI : les chapitres d'un arc HUB sont des « quêtes ».
.collect(Collectors.toList()))); if (arc.hub()) {
map.put("arc_type", "HUB");
}
map.put("chapters", arc.chapters().stream()
.map(this::chapterSummaryToMap)
.collect(Collectors.toList()));
});
} }
private Map<String, Object> chapterSummaryToMap(ChapterSummary c) { private Map<String, Object> chapterSummaryToMap(ChapterSummary c) {

View File

@@ -114,6 +114,8 @@ public class BrainRulesImportClient implements RulesPdfImporter {
byte[] pdfBytes, byte[] pdfBytes,
String filename, String filename,
Consumer<RulesImportProgress> onProgress, Consumer<RulesImportProgress> onProgress,
Runnable onHeartbeat,
Consumer<String> onStatus,
Consumer<RulesImportResult> onDone, Consumer<RulesImportResult> onDone,
Consumer<Throwable> onError) { Consumer<Throwable> onError) {
@@ -139,7 +141,8 @@ public class BrainRulesImportClient implements RulesPdfImporter {
flux flux
.timeout(Duration.ofSeconds(importTimeoutSeconds)) .timeout(Duration.ofSeconds(importTimeoutSeconds))
.doOnNext(sse -> handleEvent( .doOnNext(sse -> handleEvent(
sse, pageCount, ocrPageCount, terminated, onProgress, onDone, onError)) sse, pageCount, ocrPageCount, terminated,
onProgress, onHeartbeat, onStatus, onDone, onError))
.blockLast(); .blockLast();
// Flux terminé sans event done/error (ex: connexion coupée) → on signale. // Flux terminé sans event done/error (ex: connexion coupée) → on signale.
if (!terminated[0]) { if (!terminated[0]) {
@@ -165,12 +168,37 @@ public class BrainRulesImportClient implements RulesPdfImporter {
int[] ocrPageCount, int[] ocrPageCount,
boolean[] terminated, boolean[] terminated,
Consumer<RulesImportProgress> onProgress, Consumer<RulesImportProgress> onProgress,
Runnable onHeartbeat,
Consumer<String> onStatus,
Consumer<RulesImportResult> onDone, Consumer<RulesImportResult> onDone,
Consumer<Throwable> onError) { Consumer<Throwable> onError) {
String event = sse.event(); String event = sse.event();
String data = sse.data() == null ? "" : sse.data(); String data = sse.data() == null ? "" : sse.data();
if ("heartbeat".equals(event)) {
// Keep-alive du Brain pendant un appel LLM long : à PROPAGER jusqu'au
// navigateur, sinon nginx (proxy_read_timeout) coupe le SSE Core→front
// resté silencieux pendant tout le traitement du morceau.
onHeartbeat.run();
return;
}
if ("status".equals(event)) {
// Message d'attente lisible (retry sur fournisseur saturé, morceau
// re-découpé…) : affiché par l'UI au lieu de n'exister qu'en logs.
onStatus.accept(readMessage(data));
return;
}
if ("chunk_failed".equals(event)) {
JsonNode node = readJson(data);
String msg = node != null && node.hasNonNull("message")
? node.get("message").asText() : "";
int current = node != null ? node.path("current").asInt() : 0;
int total = node != null ? node.path("total").asInt() : 0;
onStatus.accept("Morceau " + current + "/" + total + " ignoré"
+ (msg.isEmpty() ? "." : " : " + msg));
return;
}
if ("error".equals(event)) { if ("error".equals(event)) {
terminated[0] = true; terminated[0] = true;
onError.accept(new RulesImportException( onError.accept(new RulesImportException(

View File

@@ -0,0 +1,51 @@
package com.loremind.infrastructure.persistence.converter;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* Convertit une Map<String, List<Map<String, String>>> en JSON et inversement.
* <p>
* Utilise pour Page.tableValues : pour chaque champ TABLE du template, stocke
* la liste ordonnee des LIGNES du tableau, chaque ligne etant une map
* colonne -> cellule. Exemple :
* {"Inventaire": [{"Objet":"Potion","Prix":"50 po"}, {"Objet":"Corde","Prix":"1 po"}]}
* <p>
* Adaptateur technique pur : le domaine ignore ce converter.
*/
@Converter
public class StringRowListMapJsonConverter
implements AttributeConverter<Map<String, List<Map<String, String>>>, String> {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final TypeReference<Map<String, List<Map<String, String>>>> TYPE_REF =
new TypeReference<>() {};
@Override
public String convertToDatabaseColumn(Map<String, List<Map<String, String>>> attribute) {
if (attribute == null || attribute.isEmpty()) return "{}";
try {
return MAPPER.writeValueAsString(attribute);
} catch (Exception e) {
throw new IllegalStateException(
"Erreur serialisation Map<String, List<Map<String,String>>> -> JSON", e);
}
}
@Override
public Map<String, List<Map<String, String>>> convertToEntityAttribute(String dbData) {
if (dbData == null || dbData.isBlank()) return Collections.emptyMap();
try {
return MAPPER.readValue(dbData, TYPE_REF);
} catch (Exception e) {
throw new IllegalStateException(
"Erreur deserialisation JSON -> Map<String, List<Map<String,String>>>", e);
}
}
}

View File

@@ -86,7 +86,7 @@ public class TemplateFieldListJsonConverter
} }
} }
List<String> labels = null; List<String> labels = null;
if (type == FieldType.KEY_VALUE_LIST) { if (type == FieldType.KEY_VALUE_LIST || type == FieldType.TABLE) {
JsonNode labelsNode = item.path("labels"); JsonNode labelsNode = item.path("labels");
if (labelsNode.isArray()) { if (labelsNode.isArray()) {
labels = new ArrayList<>(); labels = new ArrayList<>();

View File

@@ -0,0 +1,88 @@
package com.loremind.infrastructure.persistence.entity;
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringMapMapJsonConverter;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Entité JPA des fiches d'ennemis (bestiaire). Mêmes règles que NpcJpaEntity.
*/
@Entity
@Table(name = "enemies", indexes = {
@Index(name = "idx_enemies_campaign_id", columnList = "campaign_id")
})
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class EnemyJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
/** Niveau / FP — texte libre. Nullable. */
@Column(name = "level")
private String level;
/** Dossier de classement (« Démons », « Humanoïdes »…). Nullable = non classé. */
@Column(name = "folder")
private String folder;
@Column(name = "portrait_image_id")
private String portraitImageId;
@Column(name = "header_image_id")
private String headerImageId;
@Convert(converter = StringMapJsonConverter.class)
@Column(name = "field_values", columnDefinition = "TEXT")
private Map<String, String> values;
@Convert(converter = StringListMapJsonConverter.class)
@Column(name = "image_values", columnDefinition = "TEXT")
private Map<String, List<String>> imageValues;
@Convert(converter = StringMapMapJsonConverter.class)
@Column(name = "key_value_values", columnDefinition = "TEXT")
private Map<String, Map<String, String>> keyValueValues;
@Column(name = "campaign_id", nullable = false)
private Long campaignId;
@Column(name = "\"order\"", nullable = false)
private int order;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
if (values == null) values = new HashMap<>();
if (imageValues == null) imageValues = new HashMap<>();
if (keyValueValues == null) keyValueValues = new HashMap<>();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -46,6 +46,11 @@ public class GameSystemJpaEntity {
@Column(name = "npc_template", columnDefinition = "TEXT") @Column(name = "npc_template", columnDefinition = "TEXT")
private List<TemplateField> npcTemplate; private List<TemplateField> npcTemplate;
/** Template ENNEMI (bestiaire) serialise en JSON. */
@Convert(converter = TemplateFieldListJsonConverter.class)
@Column(name = "enemy_template", columnDefinition = "TEXT")
private List<TemplateField> enemyTemplate;
@Column @Column
private String author; private String author;
@@ -64,6 +69,7 @@ public class GameSystemJpaEntity {
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now();
if (characterTemplate == null) characterTemplate = new ArrayList<>(); if (characterTemplate == null) characterTemplate = new ArrayList<>();
if (npcTemplate == null) npcTemplate = new ArrayList<>(); if (npcTemplate == null) npcTemplate = new ArrayList<>();
if (enemyTemplate == null) enemyTemplate = new ArrayList<>();
} }
@PreUpdate @PreUpdate

View File

@@ -34,6 +34,14 @@ public class NotebookMessageJpaEntity {
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
/**
* Null = message de la conversation ACTIVE. Non-null = message archivé lors
* d'un « vider la conversation » ; tous les messages d'un même clear portent
* le même horodatage, qui sert d'identifiant de lot d'archive.
*/
@Column(name = "archived_at")
private LocalDateTime archivedAt;
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
if (createdAt == null) createdAt = LocalDateTime.now(); if (createdAt == null) createdAt = LocalDateTime.now();

View File

@@ -1,5 +1,6 @@
package com.loremind.infrastructure.persistence.entity; package com.loremind.infrastructure.persistence.entity;
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter; import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter; import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringMapMapJsonConverter; import com.loremind.infrastructure.persistence.converter.StringMapMapJsonConverter;
@@ -54,6 +55,11 @@ public class NpcJpaEntity {
@Column(name = "campaign_id", nullable = false) @Column(name = "campaign_id", nullable = false)
private Long campaignId; private Long campaignId;
/** IDs de Pages de Lore référencées (référence faible cross-context). JSON TEXT. */
@Convert(converter = StringListJsonConverter.class)
@Column(name = "related_page_ids", columnDefinition = "TEXT")
private List<String> relatedPageIds;
@Column(name = "folder") @Column(name = "folder")
private String folder; private String folder;

View File

@@ -3,6 +3,8 @@ package com.loremind.infrastructure.persistence.entity;
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter; import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter; import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter; import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringMapMapJsonConverter;
import com.loremind.infrastructure.persistence.converter.StringRowListMapJsonConverter;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
@@ -52,6 +54,16 @@ public class PageJpaEntity {
@Convert(converter = StringListMapJsonConverter.class) @Convert(converter = StringListMapJsonConverter.class)
private Map<String, List<String>> imageValues; private Map<String, List<String>> imageValues;
/** Valeurs des champs KEY_VALUE_LIST : fieldName → (label → valeur). JSON TEXT. */
@Column(name = "key_value_values", columnDefinition = "TEXT")
@Convert(converter = StringMapMapJsonConverter.class)
private Map<String, Map<String, String>> keyValueValues;
/** Valeurs des champs TABLE : fieldName → lignes (colonne → cellule). JSON TEXT. */
@Column(name = "table_values", columnDefinition = "TEXT")
@Convert(converter = StringRowListMapJsonConverter.class)
private Map<String, List<Map<String, String>>> tableValues;
@Column(columnDefinition = "TEXT") @Column(columnDefinition = "TEXT")
private String notes; private String notes;

View File

@@ -75,6 +75,12 @@ public class SceneJpaEntity {
@Column(columnDefinition = "TEXT") @Column(columnDefinition = "TEXT")
private String enemies; private String enemies;
/** IDs des fiches du bestiaire liées à la rencontre (JSON, weak refs). */
@Column(name = "enemy_ids", columnDefinition = "TEXT")
@Convert(converter = StringListJsonConverter.class)
@Builder.Default
private List<String> enemyIds = new ArrayList<>();
@Column(name = "related_page_ids", columnDefinition = "TEXT") @Column(name = "related_page_ids", columnDefinition = "TEXT")
@Convert(converter = StringListJsonConverter.class) @Convert(converter = StringListJsonConverter.class)
@Builder.Default @Builder.Default

View File

@@ -10,4 +10,7 @@ import java.util.List;
public interface CharacterJpaRepository extends JpaRepository<CharacterJpaEntity, Long> { public interface CharacterJpaRepository extends JpaRepository<CharacterJpaEntity, Long> {
List<CharacterJpaEntity> findByPlaythroughIdOrderByOrderAsc(Long playthroughId); List<CharacterJpaEntity> findByPlaythroughIdOrderByOrderAsc(Long playthroughId);
/** Recherche globale : bornée pour ne jamais inonder la palette de résultats. */
List<CharacterJpaEntity> findTop20ByNameContainingIgnoreCaseOrderByNameAsc(String name);
} }

View File

@@ -0,0 +1,16 @@
package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.EnemyJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface EnemyJpaRepository extends JpaRepository<EnemyJpaEntity, Long> {
List<EnemyJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
/** Recherche globale : bornée pour ne jamais inonder la palette de résultats. */
List<EnemyJpaEntity> findTop20ByNameContainingIgnoreCaseOrderByNameAsc(String name);
}

View File

@@ -10,4 +10,7 @@ import java.util.List;
public interface ItemCatalogJpaRepository extends JpaRepository<ItemCatalogJpaEntity, Long> { public interface ItemCatalogJpaRepository extends JpaRepository<ItemCatalogJpaEntity, Long> {
List<ItemCatalogJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId); List<ItemCatalogJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
/** Recherche globale : bornée pour ne jamais inonder la palette de résultats. */
List<ItemCatalogJpaEntity> findTop20ByNameContainingIgnoreCaseOrderByNameAsc(String name);
} }

View File

@@ -2,12 +2,27 @@ package com.loremind.infrastructure.persistence.jpa;
import com.loremind.infrastructure.persistence.entity.NotebookMessageJpaEntity; import com.loremind.infrastructure.persistence.entity.NotebookMessageJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@Repository @Repository
public interface NotebookMessageJpaRepository extends JpaRepository<NotebookMessageJpaEntity, Long> { public interface NotebookMessageJpaRepository extends JpaRepository<NotebookMessageJpaEntity, Long> {
List<NotebookMessageJpaEntity> findByNotebookIdOrderByCreatedAtAsc(Long notebookId); /** Messages de la conversation ACTIVE (les archives sont exclues). */
List<NotebookMessageJpaEntity> findByNotebookIdAndArchivedAtIsNullOrderByCreatedAtAsc(Long notebookId);
/** Messages archivés (tous lots confondus, l'appelant regroupe par archivedAt). */
List<NotebookMessageJpaEntity> findByNotebookIdAndArchivedAtIsNotNullOrderByCreatedAtAsc(Long notebookId);
void deleteByNotebookId(Long notebookId); void deleteByNotebookId(Long notebookId);
/** « Vider la conversation » : archive le fil actif en un lot horodaté. */
@Modifying
@Query("update NotebookMessageJpaEntity m set m.archivedAt = :now "
+ "where m.notebookId = :notebookId and m.archivedAt is null")
int archiveActiveMessages(@Param("notebookId") Long notebookId, @Param("now") LocalDateTime now);
} }

View File

@@ -10,4 +10,7 @@ import java.util.List;
public interface NpcJpaRepository extends JpaRepository<NpcJpaEntity, Long> { public interface NpcJpaRepository extends JpaRepository<NpcJpaEntity, Long> {
List<NpcJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId); List<NpcJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
/** Recherche globale : bornée pour ne jamais inonder la palette de résultats. */
List<NpcJpaEntity> findTop20ByNameContainingIgnoreCaseOrderByNameAsc(String name);
} }

View File

@@ -10,4 +10,7 @@ import java.util.List;
public interface RandomTableJpaRepository extends JpaRepository<RandomTableJpaEntity, Long> { public interface RandomTableJpaRepository extends JpaRepository<RandomTableJpaEntity, Long> {
List<RandomTableJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId); List<RandomTableJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
/** Recherche globale : bornée pour ne jamais inonder la palette de résultats. */
List<RandomTableJpaEntity> findTop20ByNameContainingIgnoreCaseOrderByNameAsc(String name);
} }

View File

@@ -49,6 +49,13 @@ public class PostgresCharacterRepository implements CharacterRepository {
return jpaRepository.existsById(Long.parseLong(id)); return jpaRepository.existsById(Long.parseLong(id));
} }
@Override
public List<Character> searchByName(String query) {
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
.map(this::toDomainEntity)
.collect(Collectors.toList());
}
private Character toDomainEntity(CharacterJpaEntity e) { private Character toDomainEntity(CharacterJpaEntity e) {
return Character.builder() return Character.builder()
.id(e.getId().toString()) .id(e.getId().toString())

View File

@@ -0,0 +1,87 @@
package com.loremind.infrastructure.persistence.postgres;
import com.loremind.domain.campaigncontext.Enemy;
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
import com.loremind.infrastructure.persistence.entity.EnemyJpaEntity;
import com.loremind.infrastructure.persistence.jpa.EnemyJpaRepository;
import org.springframework.stereotype.Repository;
import java.util.HashMap;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Repository
public class PostgresEnemyRepository implements EnemyRepository {
private final EnemyJpaRepository jpaRepository;
public PostgresEnemyRepository(EnemyJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public Enemy save(Enemy enemy) {
return toDomainEntity(jpaRepository.save(toJpaEntity(enemy)));
}
@Override
public Optional<Enemy> findById(String id) {
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomainEntity);
}
@Override
public List<Enemy> findByCampaignId(String campaignId) {
return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
.map(this::toDomainEntity)
.collect(Collectors.toList());
}
@Override
public void deleteById(String id) {
jpaRepository.deleteById(Long.parseLong(id));
}
@Override
public List<Enemy> searchByName(String query) {
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
.map(this::toDomainEntity)
.collect(Collectors.toList());
}
private Enemy toDomainEntity(EnemyJpaEntity e) {
return Enemy.builder()
.id(e.getId().toString())
.name(e.getName())
.level(e.getLevel())
.folder(e.getFolder())
.portraitImageId(e.getPortraitImageId())
.headerImageId(e.getHeaderImageId())
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
.campaignId(e.getCampaignId().toString())
.order(e.getOrder())
.createdAt(e.getCreatedAt())
.updatedAt(e.getUpdatedAt())
.build();
}
private EnemyJpaEntity toJpaEntity(Enemy n) {
return EnemyJpaEntity.builder()
.id(n.getId() != null ? Long.parseLong(n.getId()) : null)
.name(n.getName())
.level(n.getLevel())
.folder(n.getFolder())
.portraitImageId(n.getPortraitImageId())
.headerImageId(n.getHeaderImageId())
.values(n.getValues() != null ? new HashMap<>(n.getValues()) : new HashMap<>())
.imageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>())
.keyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>())
.campaignId(Long.parseLong(n.getCampaignId()))
.order(n.getOrder())
.createdAt(n.getCreatedAt())
.updatedAt(n.getUpdatedAt())
.build();
}
}

View File

@@ -67,6 +67,9 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
.npcTemplate(e.getNpcTemplate() != null .npcTemplate(e.getNpcTemplate() != null
? new java.util.ArrayList<>(e.getNpcTemplate()) ? new java.util.ArrayList<>(e.getNpcTemplate())
: new java.util.ArrayList<>()) : new java.util.ArrayList<>())
.enemyTemplate(e.getEnemyTemplate() != null
? new java.util.ArrayList<>(e.getEnemyTemplate())
: new java.util.ArrayList<>())
.author(e.getAuthor()) .author(e.getAuthor())
.isPublic(e.isPublic()) .isPublic(e.isPublic())
.createdAt(e.getCreatedAt()) .createdAt(e.getCreatedAt())
@@ -87,6 +90,9 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
.npcTemplate(g.getNpcTemplate() != null .npcTemplate(g.getNpcTemplate() != null
? new java.util.ArrayList<>(g.getNpcTemplate()) ? new java.util.ArrayList<>(g.getNpcTemplate())
: new java.util.ArrayList<>()) : new java.util.ArrayList<>())
.enemyTemplate(g.getEnemyTemplate() != null
? new java.util.ArrayList<>(g.getEnemyTemplate())
: new java.util.ArrayList<>())
.author(g.getAuthor()) .author(g.getAuthor())
.isPublic(g.isPublic()) .isPublic(g.isPublic())
.createdAt(g.getCreatedAt()) .createdAt(g.getCreatedAt())

View File

@@ -77,6 +77,14 @@ public class PostgresItemCatalogRepository implements ItemCatalogRepository {
return jpaRepository.existsById(Long.parseLong(id)); return jpaRepository.existsById(Long.parseLong(id));
} }
@Override
@Transactional(readOnly = true)
public List<ItemCatalog> searchByName(String query) {
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
.map(this::toDomainEntity)
.collect(Collectors.toList());
}
private ItemCatalog toDomainEntity(ItemCatalogJpaEntity e) { private ItemCatalog toDomainEntity(ItemCatalogJpaEntity e) {
List<CatalogItem> items = e.getItems().stream() List<CatalogItem> items = e.getItems().stream()
.map(c -> CatalogItem.builder() .map(c -> CatalogItem.builder()

View File

@@ -115,7 +115,19 @@ public class PostgresNotebookRepository implements NotebookRepository {
@Override @Override
public List<NotebookMessage> findMessagesByNotebookId(String notebookId) { public List<NotebookMessage> findMessagesByNotebookId(String notebookId) {
return messageJpa.findByNotebookIdOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream() return messageJpa.findByNotebookIdAndArchivedAtIsNullOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream()
.map(this::toMessage).collect(Collectors.toList());
}
@Override
@Transactional
public void archiveMessagesByNotebookId(String notebookId) {
messageJpa.archiveActiveMessages(Long.parseLong(notebookId), java.time.LocalDateTime.now());
}
@Override
public List<NotebookMessage> findArchivedMessagesByNotebookId(String notebookId) {
return messageJpa.findByNotebookIdAndArchivedAtIsNotNullOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream()
.map(this::toMessage).collect(Collectors.toList()); .map(this::toMessage).collect(Collectors.toList());
} }
@@ -150,6 +162,7 @@ public class PostgresNotebookRepository implements NotebookRepository {
.role(e.getRole()) .role(e.getRole())
.content(e.getContent()) .content(e.getContent())
.createdAt(e.getCreatedAt()) .createdAt(e.getCreatedAt())
.archivedAt(e.getArchivedAt())
.build(); .build();
} }
} }

View File

@@ -6,6 +6,7 @@ import com.loremind.infrastructure.persistence.entity.NpcJpaEntity;
import com.loremind.infrastructure.persistence.jpa.NpcJpaRepository; import com.loremind.infrastructure.persistence.jpa.NpcJpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -49,6 +50,13 @@ public class PostgresNpcRepository implements NpcRepository {
return jpaRepository.existsById(Long.parseLong(id)); return jpaRepository.existsById(Long.parseLong(id));
} }
@Override
public List<Npc> searchByName(String query) {
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
.map(this::toDomainEntity)
.collect(Collectors.toList());
}
private Npc toDomainEntity(NpcJpaEntity e) { private Npc toDomainEntity(NpcJpaEntity e) {
return Npc.builder() return Npc.builder()
.id(e.getId().toString()) .id(e.getId().toString())
@@ -59,6 +67,7 @@ public class PostgresNpcRepository implements NpcRepository {
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>()) .imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>()) .keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
.campaignId(e.getCampaignId().toString()) .campaignId(e.getCampaignId().toString())
.relatedPageIds(e.getRelatedPageIds() != null ? new ArrayList<>(e.getRelatedPageIds()) : new ArrayList<>())
.folder(e.getFolder()) .folder(e.getFolder())
.order(e.getOrder()) .order(e.getOrder())
.createdAt(e.getCreatedAt()) .createdAt(e.getCreatedAt())
@@ -77,6 +86,7 @@ public class PostgresNpcRepository implements NpcRepository {
.imageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>()) .imageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>())
.keyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>()) .keyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>())
.campaignId(Long.parseLong(n.getCampaignId())) .campaignId(Long.parseLong(n.getCampaignId()))
.relatedPageIds(n.getRelatedPageIds() != null ? new ArrayList<>(n.getRelatedPageIds()) : new ArrayList<>())
.folder(n.getFolder()) .folder(n.getFolder())
.order(n.getOrder()) .order(n.getOrder())
.createdAt(n.getCreatedAt()) .createdAt(n.getCreatedAt())

View File

@@ -93,6 +93,8 @@ public class PostgresPageRepository implements PageRepository {
.title(e.getTitle()) .title(e.getTitle())
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>()) .values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>()) .imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
.tableValues(e.getTableValues() != null ? new HashMap<>(e.getTableValues()) : new HashMap<>())
.notes(e.getNotes()) .notes(e.getNotes())
.tags(e.getTags() != null ? new ArrayList<>(e.getTags()) : new ArrayList<>()) .tags(e.getTags() != null ? new ArrayList<>(e.getTags()) : new ArrayList<>())
.relatedPageIds(e.getRelatedPageIds() != null ? new ArrayList<>(e.getRelatedPageIds()) : new ArrayList<>()) .relatedPageIds(e.getRelatedPageIds() != null ? new ArrayList<>(e.getRelatedPageIds()) : new ArrayList<>())
@@ -111,6 +113,8 @@ public class PostgresPageRepository implements PageRepository {
.title(p.getTitle()) .title(p.getTitle())
.values(p.getValues() != null ? new HashMap<>(p.getValues()) : new HashMap<>()) .values(p.getValues() != null ? new HashMap<>(p.getValues()) : new HashMap<>())
.imageValues(p.getImageValues() != null ? new HashMap<>(p.getImageValues()) : new HashMap<>()) .imageValues(p.getImageValues() != null ? new HashMap<>(p.getImageValues()) : new HashMap<>())
.keyValueValues(p.getKeyValueValues() != null ? new HashMap<>(p.getKeyValueValues()) : new HashMap<>())
.tableValues(p.getTableValues() != null ? new HashMap<>(p.getTableValues()) : new HashMap<>())
.notes(p.getNotes()) .notes(p.getNotes())
.tags(p.getTags() != null ? new ArrayList<>(p.getTags()) : new ArrayList<>()) .tags(p.getTags() != null ? new ArrayList<>(p.getTags()) : new ArrayList<>())
.relatedPageIds(p.getRelatedPageIds() != null ? new ArrayList<>(p.getRelatedPageIds()) : new ArrayList<>()) .relatedPageIds(p.getRelatedPageIds() != null ? new ArrayList<>(p.getRelatedPageIds()) : new ArrayList<>())

View File

@@ -81,6 +81,14 @@ public class PostgresRandomTableRepository implements RandomTableRepository {
return jpaRepository.existsById(Long.parseLong(id)); return jpaRepository.existsById(Long.parseLong(id));
} }
@Override
@Transactional(readOnly = true)
public List<RandomTable> searchByName(String query) {
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
.map(this::toDomainEntity)
.collect(Collectors.toList());
}
private RandomTable toDomainEntity(RandomTableJpaEntity e) { private RandomTable toDomainEntity(RandomTableJpaEntity e) {
List<RandomTableEntry> entries = e.getEntries().stream() List<RandomTableEntry> entries = e.getEntries().stream()
.map(c -> RandomTableEntry.builder() .map(c -> RandomTableEntry.builder()

View File

@@ -80,6 +80,9 @@ public class PostgresSceneRepository implements SceneRepository {
.choicesConsequences(jpaEntity.getChoicesConsequences()) .choicesConsequences(jpaEntity.getChoicesConsequences())
.combatDifficulty(jpaEntity.getCombatDifficulty()) .combatDifficulty(jpaEntity.getCombatDifficulty())
.enemies(jpaEntity.getEnemies()) .enemies(jpaEntity.getEnemies())
.enemyIds(jpaEntity.getEnemyIds() != null
? new ArrayList<>(jpaEntity.getEnemyIds())
: new ArrayList<>())
.relatedPageIds(jpaEntity.getRelatedPageIds() != null .relatedPageIds(jpaEntity.getRelatedPageIds() != null
? new ArrayList<>(jpaEntity.getRelatedPageIds()) ? new ArrayList<>(jpaEntity.getRelatedPageIds())
: new ArrayList<>()) : new ArrayList<>())
@@ -117,6 +120,9 @@ public class PostgresSceneRepository implements SceneRepository {
.choicesConsequences(scene.getChoicesConsequences()) .choicesConsequences(scene.getChoicesConsequences())
.combatDifficulty(scene.getCombatDifficulty()) .combatDifficulty(scene.getCombatDifficulty())
.enemies(scene.getEnemies()) .enemies(scene.getEnemies())
.enemyIds(scene.getEnemyIds() != null
? new ArrayList<>(scene.getEnemyIds())
: new ArrayList<>())
.relatedPageIds(scene.getRelatedPageIds() != null .relatedPageIds(scene.getRelatedPageIds() != null
? new ArrayList<>(scene.getRelatedPageIds()) ? new ArrayList<>(scene.getRelatedPageIds())
: new ArrayList<>()) : new ArrayList<>())

View File

@@ -10,6 +10,7 @@ import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
@@ -70,6 +71,18 @@ public class GlobalExceptionHandler {
)); ));
} }
/**
* Client HTTP parti pendant une reponse asynchrone (SSE) : le navigateur a ferme
* la connexion (onglet ferme, proxy coupe...), la reponse n'est plus utilisable.
* Ce n'est PAS une erreur serveur -> pas de log ERROR + stack trace (bruit),
* et aucune reponse a renvoyer (le canal est mort).
*/
@ExceptionHandler(AsyncRequestNotUsableException.class)
public void handleClientDisconnected(HttpServletRequest request, AsyncRequestNotUsableException ex) {
log.debug("Client deconnecte pendant la reponse asynchrone sur {} {} : {}",
request.getMethod(), request.getRequestURI(), ex.getMessage());
}
/** /**
* Fallback : tout ce qui n'a pas ete catche au-dessus -> 500, mais avec * Fallback : tout ce qui n'a pas ete catche au-dessus -> 500, mais avec
* un log ERROR explicite (path + stack trace) et un body JSON debuggable * un log ERROR explicite (path + stack trace) et un body JSON debuggable

View File

@@ -15,6 +15,7 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException; import java.io.IOException;
import java.util.Map; import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
/** /**
* REST Controller pour l'import d'un PDF de campagne → arbre arc/chapitre/scène. * REST Controller pour l'import d'un PDF de campagne → arbre arc/chapitre/scène.
@@ -30,8 +31,13 @@ public class CampaignImportController {
private static final Logger log = LoggerFactory.getLogger(CampaignImportController.class); private static final Logger log = LoggerFactory.getLogger(CampaignImportController.class);
/** Timeout SSE généreux : un import de livre entier peut durer plusieurs minutes. */ /**
private static final long IMPORT_SSE_TIMEOUT_MS = 15 * 60 * 1000L; * Timeout SSE = durée TOTALE maximale de l'import (pas un timeout d'inactivité :
* les heartbeats ne le réarment pas). Un livre entier sur un modèle local peut
* largement dépasser 15 min → 60 min. La déconnexion du client reste détectée
* immédiatement par ailleurs (échec d'envoi → interruption de l'import).
*/
private static final long IMPORT_SSE_TIMEOUT_MS = 60 * 60 * 1000L;
private final CampaignImportService campaignImportService; private final CampaignImportService campaignImportService;
private final TaskExecutor taskExecutor; private final TaskExecutor taskExecutor;
@@ -54,33 +60,65 @@ public class CampaignImportController {
@RequestParam("file") MultipartFile file) throws IOException { @RequestParam("file") MultipartFile file) throws IOException {
SseEmitter emitter = new SseEmitter(IMPORT_SSE_TIMEOUT_MS); SseEmitter emitter = new SseEmitter(IMPORT_SSE_TIMEOUT_MS);
if (file == null || file.isEmpty()) { if (file == null || file.isEmpty()) {
sendError(emitter, "Fichier PDF vide."); sendError(emitter, new AtomicBoolean(false), "Fichier PDF vide.");
return emitter; return emitter;
} }
byte[] bytes = file.getBytes(); byte[] bytes = file.getBytes();
String filename = file.getOriginalFilename(); String filename = file.getOriginalFilename();
// Suivi de la déconnexion du navigateur : dès qu'un envoi échoue (ou que
// l'emitter se termine), on cesse d'envoyer ET on interrompt le streaming
// amont (ClientGoneException remonte dans le doOnNext du WebClient →
// annule la souscription → le Brain voit la coupure et stoppe le LLM).
AtomicBoolean clientGone = new AtomicBoolean(false);
emitter.onTimeout(() -> {
// Timeout = durée totale dépassée, mais la connexion est encore vivante :
// on envoie une vraie erreur au navigateur AVANT de fermer (sinon le flux
// se termine en silence et l'UI reste figée sur la barre de progression).
sendError(emitter, clientGone,
"L'import a dépassé la durée maximale autorisée et a été interrompu. "
+ "Réessayez avec un modèle plus rapide ou un PDF plus petit.");
clientGone.set(true);
});
emitter.onError(e -> clientGone.set(true));
taskExecutor.execute(() -> { taskExecutor.execute(() -> {
try { try {
campaignImportService.importStructureStreaming( campaignImportService.importStructureStreaming(
bytes, filename, bytes, filename,
progress -> sendEvent(emitter, "progress", progress), progress -> sendEvent(emitter, clientGone, "progress", progress),
() -> sendHeartbeat(emitter, clientGone),
status -> sendEvent(emitter, clientGone, "status",
Map.of("message", status != null ? status : "")),
proposal -> { proposal -> {
sendEvent(emitter, "done", proposal); sendEvent(emitter, clientGone, "done", proposal);
emitter.complete(); emitter.complete();
}, },
error -> { error -> {
if (clientGone.get()) {
log.info("Import campagne (stream) interrompu : client déconnecté.");
return;
}
log.warn("Import campagne (stream) échoué : {}", error.getMessage()); log.warn("Import campagne (stream) échoué : {}", error.getMessage());
sendError(emitter, error.getMessage()); sendError(emitter, clientGone, error.getMessage());
}); });
} catch (ClientGoneException e) {
log.info("Import campagne (stream) interrompu : client déconnecté.");
} catch (Exception e) { } catch (Exception e) {
log.warn("Import campagne (stream) échoué : {}", e.getMessage()); log.warn("Import campagne (stream) échoué : {}", e.getMessage());
sendError(emitter, e.getMessage()); sendError(emitter, clientGone, e.getMessage());
} }
}); });
return emitter; return emitter;
} }
/** Signale que le navigateur a fermé le flux SSE : inutile de continuer l'import. */
private static final class ClientGoneException extends RuntimeException {
ClientGoneException(Throwable cause) {
super("Client SSE déconnecté.", cause);
}
}
@PostMapping(value = "/apply", consumes = MediaType.APPLICATION_JSON_VALUE) @PostMapping(value = "/apply", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<CampaignImportService.ApplyResult> apply( public ResponseEntity<CampaignImportService.ApplyResult> apply(
@PathVariable String campaignId, @PathVariable String campaignId,
@@ -96,23 +134,52 @@ public class CampaignImportController {
// --- Helpers SSE --------------------------------------------------------- // --- Helpers SSE ---------------------------------------------------------
private void sendEvent(SseEmitter emitter, String eventName, Object payload) { private void sendEvent(
SseEmitter emitter, AtomicBoolean clientGone, String eventName, Object payload) {
if (clientGone.get()) {
throw new ClientGoneException(null);
}
try { try {
emitter.send(SseEmitter.event().name(eventName).data( emitter.send(SseEmitter.event().name(eventName).data(
objectMapper.writeValueAsString(payload), MediaType.APPLICATION_JSON)); objectMapper.writeValueAsString(payload), MediaType.APPLICATION_JSON));
} catch (IOException e) { } catch (Exception e) {
// IOException OU IllegalStateException (emitter déjà terminé) : le client
// est parti — on interrompt le pipeline amont au lieu de rejouer l'échec.
clientGone.set(true);
emitter.completeWithError(e); emitter.completeWithError(e);
throw new ClientGoneException(e);
} }
} }
private void sendError(SseEmitter emitter, String message) { /**
* Keep-alive vers le navigateur pendant un appel LLM long : un commentaire SSE
* (ignoré par le front) suffit à réarmer le {@code proxy_read_timeout} de nginx.
*/
private void sendHeartbeat(SseEmitter emitter, AtomicBoolean clientGone) {
if (clientGone.get()) {
throw new ClientGoneException(null);
}
try {
emitter.send(SseEmitter.event().comment("keepalive"));
} catch (Exception e) {
clientGone.set(true);
emitter.completeWithError(e);
throw new ClientGoneException(e);
}
}
private void sendError(SseEmitter emitter, AtomicBoolean clientGone, String message) {
if (clientGone.get()) {
return; // le client n'est plus là pour lire le message d'erreur.
}
try { try {
emitter.send(SseEmitter.event().name("error").data( emitter.send(SseEmitter.event().name("error").data(
objectMapper.writeValueAsString(Map.of( objectMapper.writeValueAsString(Map.of(
"message", message != null ? message : "Erreur inconnue.")), "message", message != null ? message : "Erreur inconnue.")),
MediaType.APPLICATION_JSON)); MediaType.APPLICATION_JSON));
emitter.complete(); emitter.complete();
} catch (IOException e) { } catch (Exception e) {
clientGone.set(true);
emitter.completeWithError(e); emitter.completeWithError(e);
} }
} }

View File

@@ -16,10 +16,13 @@ public class CharacterController {
private final CharacterService characterService; private final CharacterService characterService;
private final CharacterMapper characterMapper; private final CharacterMapper characterMapper;
private final com.loremind.domain.playcontext.ports.PlaythroughRepository playthroughRepository;
public CharacterController(CharacterService characterService, CharacterMapper characterMapper) { public CharacterController(CharacterService characterService, CharacterMapper characterMapper,
com.loremind.domain.playcontext.ports.PlaythroughRepository playthroughRepository) {
this.characterService = characterService; this.characterService = characterService;
this.characterMapper = characterMapper; this.characterMapper = characterMapper;
this.playthroughRepository = playthroughRepository;
} }
@PostMapping @PostMapping
@@ -43,6 +46,31 @@ public class CharacterController {
return ResponseEntity.ok(dtos); return ResponseEntity.ok(dtos);
} }
/**
* Recherche par nom — alimente la recherche globale (Ctrl+K). Le résultat est
* enrichi du campaignId (résolu via le Playthrough) pour que le front puisse
* construire la route /campaigns/{c}/playthroughs/{p}/characters/{id}.
*/
@GetMapping("/search")
public ResponseEntity<List<CharacterSearchDTO>> search(@RequestParam("q") String query) {
List<CharacterSearchDTO> out = characterService.searchCharacters(query).stream()
.map(c -> new CharacterSearchDTO(
c.getId(),
c.getName(),
c.getPlaythroughId(),
c.getPlaythroughId() != null
? playthroughRepository.findById(c.getPlaythroughId())
.map(com.loremind.domain.playcontext.Playthrough::getCampaignId)
.orElse(null)
: null))
.filter(r -> r.campaignId() != null) // PJ orphelin (legacy) : non navigable → exclu
.collect(Collectors.toList());
return ResponseEntity.ok(out);
}
/** Résultat de recherche d'un PJ, enrichi pour la navigation. */
public record CharacterSearchDTO(String id, String name, String playthroughId, String campaignId) {}
@PutMapping("/{id}") @PutMapping("/{id}")
public ResponseEntity<CharacterDTO> updateCharacter(@PathVariable String id, @RequestBody CharacterDTO dto) { public ResponseEntity<CharacterDTO> updateCharacter(@PathVariable String id, @RequestBody CharacterDTO dto) {
Character updated = characterService.updateCharacter(id, toData(dto, dto.getOrder())); Character updated = characterService.updateCharacter(id, toData(dto, dto.getOrder()));

View File

@@ -0,0 +1,79 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.application.campaigncontext.EnemyService;
import com.loremind.domain.campaigncontext.Enemy;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* REST Controller des fiches d'ennemis (bestiaire de campagne).
* Réponses = domaine {@link Enemy} sérialisé tel quel (Lombok @Data) ;
* requêtes = record dédié (le domaine n'a pas de constructeur no-args).
*/
@RestController
@RequestMapping("/api/enemies")
public class EnemyController {
private final EnemyService enemyService;
public EnemyController(EnemyService enemyService) {
this.enemyService = enemyService;
}
@PostMapping
public ResponseEntity<Enemy> create(@RequestBody EnemyRequest req) {
return ResponseEntity.ok(enemyService.createEnemy(toData(req)));
}
@GetMapping("/{id}")
public ResponseEntity<Enemy> getById(@PathVariable String id) {
return enemyService.getEnemyById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/campaign/{campaignId}")
public ResponseEntity<List<Enemy>> getByCampaign(@PathVariable String campaignId) {
return ResponseEntity.ok(enemyService.getEnemiesByCampaignId(campaignId));
}
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
@GetMapping("/search")
public ResponseEntity<List<Enemy>> search(@RequestParam("q") String query) {
return ResponseEntity.ok(enemyService.searchEnemies(query));
}
@PutMapping("/{id}")
public ResponseEntity<Enemy> update(@PathVariable String id, @RequestBody EnemyRequest req) {
return ResponseEntity.ok(enemyService.updateEnemy(id, toData(req)));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable String id) {
enemyService.deleteEnemy(id);
return ResponseEntity.noContent().build();
}
private EnemyService.EnemyData toData(EnemyRequest req) {
return new EnemyService.EnemyData(
req.name(), req.level(), req.folder(),
req.portraitImageId(), req.headerImageId(),
req.values(), req.imageValues(), req.keyValueValues(),
req.campaignId(), req.order());
}
public record EnemyRequest(
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,
String campaignId,
Integer order) {}
}

View File

@@ -3,7 +3,6 @@ package com.loremind.infrastructure.web.controller;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.application.gamesystemcontext.GameSystemService; import com.loremind.application.gamesystemcontext.GameSystemService;
import com.loremind.domain.gamesystemcontext.GameSystem; import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.gamesystemcontext.RulesImportProgress;
import com.loremind.domain.gamesystemcontext.RulesImportResult; import com.loremind.domain.gamesystemcontext.RulesImportResult;
import com.loremind.domain.gamesystemcontext.ports.RulesImportException; import com.loremind.domain.gamesystemcontext.ports.RulesImportException;
import com.loremind.domain.shared.template.TemplateField; import com.loremind.domain.shared.template.TemplateField;
@@ -27,6 +26,7 @@ import java.io.IOException;
import java.util.ArrayList; 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.stream.Collectors; import java.util.stream.Collectors;
@RestController @RestController
@@ -35,8 +35,13 @@ public class GameSystemController {
private static final Logger log = LoggerFactory.getLogger(GameSystemController.class); private static final Logger log = LoggerFactory.getLogger(GameSystemController.class);
/** Timeout SSE généreux : un import de livre entier peut durer plusieurs minutes. */ /**
private static final long IMPORT_SSE_TIMEOUT_MS = 15 * 60 * 1000L; * Timeout SSE = durée TOTALE maximale de l'import (pas un timeout d'inactivité :
* les heartbeats ne le réarment pas). Un livre entier sur un modèle local peut
* largement dépasser 15 min → 60 min. La déconnexion du client reste détectée
* immédiatement par ailleurs (échec d'envoi → interruption de l'import).
*/
private static final long IMPORT_SSE_TIMEOUT_MS = 60 * 60 * 1000L;
private final GameSystemService gameSystemService; private final GameSystemService gameSystemService;
private final GameSystemMapper gameSystemMapper; private final GameSystemMapper gameSystemMapper;
@@ -135,7 +140,7 @@ public class GameSystemController {
public SseEmitter importRulesStream(@RequestParam("file") MultipartFile file) throws IOException { public SseEmitter importRulesStream(@RequestParam("file") MultipartFile file) throws IOException {
SseEmitter emitter = new SseEmitter(IMPORT_SSE_TIMEOUT_MS); SseEmitter emitter = new SseEmitter(IMPORT_SSE_TIMEOUT_MS);
if (file == null || file.isEmpty()) { if (file == null || file.isEmpty()) {
sendImportError(emitter, "Fichier PDF vide."); sendImportError(emitter, new AtomicBoolean(false), "Fichier PDF vide.");
return emitter; return emitter;
} }
// Les octets sont lus sur le thread servlet (le MultipartFile n'est plus // Les octets sont lus sur le thread servlet (le MultipartFile n'est plus
@@ -143,46 +148,110 @@ public class GameSystemController {
byte[] bytes = file.getBytes(); byte[] bytes = file.getBytes();
String filename = file.getOriginalFilename(); String filename = file.getOriginalFilename();
// Suivi de la déconnexion du navigateur : dès qu'un envoi échoue (ou que
// l'emitter se termine), on cesse d'envoyer ET on interrompt le streaming
// amont (l'exception ClientGone remonte dans le doOnNext du WebClient →
// annule la souscription → le Brain voit la coupure et stoppe le LLM).
AtomicBoolean clientGone = new AtomicBoolean(false);
emitter.onTimeout(() -> {
// Timeout = durée totale dépassée, mais la connexion est encore vivante :
// on envoie une vraie erreur au navigateur AVANT de fermer (sinon le flux
// se termine en silence et l'UI reste figée sur la barre de progression).
sendImportError(emitter, clientGone,
"L'import a dépassé la durée maximale autorisée et a été interrompu. "
+ "Réessayez avec un modèle plus rapide ou un PDF plus petit.");
clientGone.set(true);
});
emitter.onError(e -> clientGone.set(true));
taskExecutor.execute(() -> { taskExecutor.execute(() -> {
try { try {
gameSystemService.importRulesFromPdfStreaming( gameSystemService.importRulesFromPdfStreaming(
bytes, filename, bytes, filename,
progress -> sendImportEvent(emitter, "progress", progress), progress -> sendImportEvent(emitter, clientGone, "progress", progress),
() -> sendImportHeartbeat(emitter, clientGone),
status -> sendImportEvent(emitter, clientGone, "status",
Map.of("message", status != null ? status : "")),
result -> { result -> {
sendImportEvent(emitter, "done", result); sendImportEvent(emitter, clientGone, "done", result);
emitter.complete(); emitter.complete();
}, },
error -> { error -> {
if (clientGone.get()) {
// La "panne" amont n'est que l'écho de la déconnexion
// du navigateur : pas un échec d'import.
log.info("Import de règles (stream) interrompu : client déconnecté.");
return;
}
log.warn("Import de règles (stream) échoué : {}", error.getMessage()); log.warn("Import de règles (stream) échoué : {}", error.getMessage());
sendImportError(emitter, error.getMessage()); sendImportError(emitter, clientGone, error.getMessage());
}); });
} catch (ClientGoneException e) {
log.info("Import de règles (stream) interrompu : client déconnecté.");
} catch (Exception e) { } catch (Exception e) {
log.warn("Import de règles (stream) échoué : {}", e.getMessage()); log.warn("Import de règles (stream) échoué : {}", e.getMessage());
sendImportError(emitter, e.getMessage()); sendImportError(emitter, clientGone, e.getMessage());
} }
}); });
return emitter; return emitter;
} }
/** Signale que le navigateur a fermé le flux SSE : inutile de continuer l'import. */
private static final class ClientGoneException extends RuntimeException {
ClientGoneException(Throwable cause) {
super("Client SSE déconnecté.", cause);
}
}
/** Sérialise `payload` en JSON et l'envoie comme évènement SSE nommé. */ /** Sérialise `payload` en JSON et l'envoie comme évènement SSE nommé. */
private void sendImportEvent(SseEmitter emitter, String eventName, Object payload) { private void sendImportEvent(
SseEmitter emitter, AtomicBoolean clientGone, String eventName, Object payload) {
if (clientGone.get()) {
throw new ClientGoneException(null);
}
try { try {
emitter.send(SseEmitter.event().name(eventName).data( emitter.send(SseEmitter.event().name(eventName).data(
objectMapper.writeValueAsString(payload), MediaType.APPLICATION_JSON)); objectMapper.writeValueAsString(payload), MediaType.APPLICATION_JSON));
} catch (IOException e) { } catch (Exception e) {
// IOException OU IllegalStateException (emitter déjà terminé) : le client
// est parti. On marque l'état et on INTERROMPT le pipeline amont — sinon
// chaque évènement suivant rejouerait l'échec (bruit de logs + LLM gaspillé).
clientGone.set(true);
emitter.completeWithError(e); emitter.completeWithError(e);
throw new ClientGoneException(e);
}
}
/**
* Keep-alive vers le navigateur pendant un appel LLM long : un commentaire SSE
* (ignoré par le front) suffit à réarmer le {@code proxy_read_timeout} de nginx.
*/
private void sendImportHeartbeat(SseEmitter emitter, AtomicBoolean clientGone) {
if (clientGone.get()) {
throw new ClientGoneException(null);
}
try {
emitter.send(SseEmitter.event().comment("keepalive"));
} catch (Exception e) {
clientGone.set(true);
emitter.completeWithError(e);
throw new ClientGoneException(e);
} }
} }
/** Envoie un évènement `error` {message} puis termine le flux. */ /** Envoie un évènement `error` {message} puis termine le flux. */
private void sendImportError(SseEmitter emitter, String message) { private void sendImportError(SseEmitter emitter, AtomicBoolean clientGone, String message) {
if (clientGone.get()) {
return; // le client n'est plus là pour lire le message d'erreur.
}
try { try {
emitter.send(SseEmitter.event().name("error").data( emitter.send(SseEmitter.event().name("error").data(
objectMapper.writeValueAsString(Map.of( objectMapper.writeValueAsString(Map.of(
"message", message != null ? message : "Erreur inconnue.")), "message", message != null ? message : "Erreur inconnue.")),
MediaType.APPLICATION_JSON)); MediaType.APPLICATION_JSON));
emitter.complete(); emitter.complete();
} catch (IOException e) { } catch (Exception e) {
clientGone.set(true);
emitter.completeWithError(e); emitter.completeWithError(e);
} }
} }
@@ -194,6 +263,7 @@ public class GameSystemController {
dto.getRulesMarkdown(), dto.getRulesMarkdown(),
toDomainFields(dto.getCharacterTemplate()), toDomainFields(dto.getCharacterTemplate()),
toDomainFields(dto.getNpcTemplate()), toDomainFields(dto.getNpcTemplate()),
toDomainFields(dto.getEnemyTemplate()),
dto.getAuthor(), dto.getAuthor(),
dto.isPublic() dto.isPublic()
); );

View File

@@ -58,6 +58,14 @@ public class ItemCatalogController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
@GetMapping("/search")
public ResponseEntity<List<ItemCatalogDTO>> search(@RequestParam("q") String query) {
return ResponseEntity.ok(service.searchCatalogs(query).stream()
.map(mapper::toDTO)
.collect(java.util.stream.Collectors.toList()));
}
/** Génère une PROPOSITION de catalogue via l'IA (non persistée) — l'UI préremplit le formulaire. */ /** Génère une PROPOSITION de catalogue via l'IA (non persistée) — l'UI préremplit le formulaire. */
@PostMapping("/generate") @PostMapping("/generate")
public ResponseEntity<ItemCatalogDTO> generate(@RequestBody GenerateRequest req) { public ResponseEntity<ItemCatalogDTO> generate(@RequestBody GenerateRequest req) {

View File

@@ -105,6 +105,43 @@ public class NotebookController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
// --- Conversation : vider (= archiver) et consulter les archives ---
/**
* « Vider la conversation » : le fil actif est ARCHIVÉ en un lot horodaté,
* jamais supprimé — consultable ensuite via {@link #listArchives}.
*/
@PostMapping("/{id}/chat/clear")
public ResponseEntity<Void> clearChat(@PathVariable String id) {
if (service.getNotebook(id).isEmpty()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Notebook introuvable");
}
service.clearChat(id);
return ResponseEntity.noContent().build();
}
/** Archives de conversation, plus récentes d'abord : [{archivedAt, messages:[…]}]. */
@GetMapping("/{id}/chat/archives")
public ResponseEntity<List<Map<String, Object>>> listArchives(@PathVariable String id) {
var grouped = new java.util.TreeMap<java.time.LocalDateTime, List<Map<String, Object>>>(
java.util.Comparator.reverseOrder());
for (var m : service.getArchivedMessages(id)) {
grouped.computeIfAbsent(m.getArchivedAt(), k -> new java.util.ArrayList<>())
.add(Map.of(
"role", m.getRole(),
"content", m.getContent(),
"createdAt", m.getCreatedAt().toString()));
}
List<Map<String, Object>> out = new java.util.ArrayList<>();
grouped.forEach((archivedAt, messages) -> {
Map<String, Object> archive = new LinkedHashMap<>();
archive.put("archivedAt", archivedAt.toString());
archive.put("messages", messages);
out.add(archive);
});
return ResponseEntity.ok(out);
}
// --- Chat ancré streamé --- // --- Chat ancré streamé ---
@PostMapping(value = "/{id}/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) @PostMapping(value = "/{id}/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
@@ -124,8 +161,26 @@ public class NotebookController {
List<NotebookChatStreamer.Msg> history = service.getMessages(id).stream() List<NotebookChatStreamer.Msg> history = service.getMessages(id).stream()
.map(m -> new NotebookChatStreamer.Msg(m.getRole(), m.getContent())) .map(m -> new NotebookChatStreamer.Msg(m.getRole(), m.getContent()))
.toList(); .toList();
List<String> sourceIds = service.readySourceIds(id); // Sélection de l'UI (cases cochées) : on ne garde que les sources qui
String context = service.buildContext(nb.getCampaignId()); // appartiennent bien à CE notebook et sont prêtes — un id étranger est
// ignoré. Limite le coût (ex. analyse approfondie sur 1 PDF au lieu de 5).
// Variable finale : elle est capturée par la lambda du taskExecutor.
List<String> readyIds = service.readySourceIds(id);
final List<String> sourceIds;
if (req.sourceIds() != null) {
var wanted = new java.util.HashSet<>(req.sourceIds());
sourceIds = readyIds.stream().filter(wanted::contains).toList();
} else {
sourceIds = readyIds;
}
// Contexte = brief de campagne + archives cochées en référence (le tout
// dans une variable finale : capturée par la lambda du taskExecutor).
String campaignContext = service.buildContext(nb.getCampaignId());
String archiveContext = service.buildArchiveContext(id, req.archiveIds());
final String context = archiveContext.isEmpty()
? campaignContext
: (campaignContext.isEmpty() ? archiveContext
: campaignContext + "\n\n" + archiveContext);
boolean deep = req.deep() != null && req.deep(); boolean deep = req.deep() != null && req.deep();
taskExecutor.execute(() -> { taskExecutor.execute(() -> {
@@ -220,5 +275,14 @@ public class NotebookController {
public record CreateRequest(String campaignId, String name) {} public record CreateRequest(String campaignId, String name) {}
public record RenameRequest(String name) {} public record RenameRequest(String name) {}
public record ChatRequest(String message, Boolean deep) {} /**
* @param sourceIds Optionnel : sous-ensemble de sources à utiliser pour ce tour
* (cases cochées dans l'UI). Null = toutes les sources prêtes.
* Toujours intersecté avec les sources du notebook (sécurité).
* @param archiveIds Optionnel : archives de conversation cochées comme RÉFÉRENCE
* (clés = archivedAt). Leur contenu est injecté dans le contexte
* du prompt — toujours résolu dans CE notebook (sécurité).
*/
public record ChatRequest(String message, Boolean deep, List<String> sourceIds,
List<String> archiveIds) {}
} }

View File

@@ -43,6 +43,24 @@ public class NpcController {
return ResponseEntity.ok(dtos); return ResponseEntity.ok(dtos);
} }
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
@GetMapping("/search")
public ResponseEntity<List<NpcDTO>> search(@RequestParam("q") String query) {
List<NpcDTO> dtos = npcService.searchNpcs(query).stream()
.map(npcMapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
}
/** PNJ de toutes les campagnes liées au Lore donné — alimente le graphe du Lore. */
@GetMapping("/lore/{loreId}")
public ResponseEntity<List<NpcDTO>> getNpcsByLore(@PathVariable String loreId) {
List<NpcDTO> dtos = npcService.getNpcsByLoreId(loreId).stream()
.map(npcMapper::toDTO)
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
}
@PutMapping("/{id}") @PutMapping("/{id}")
public ResponseEntity<NpcDTO> updateNpc(@PathVariable String id, @RequestBody NpcDTO dto) { public ResponseEntity<NpcDTO> updateNpc(@PathVariable String id, @RequestBody NpcDTO dto) {
Npc updated = npcService.updateNpc(id, toData(dto, dto.getOrder())); Npc updated = npcService.updateNpc(id, toData(dto, dto.getOrder()));
@@ -64,6 +82,7 @@ public class NpcController {
dto.getImageValues(), dto.getImageValues(),
dto.getKeyValueValues(), dto.getKeyValueValues(),
dto.getCampaignId(), dto.getCampaignId(),
dto.getRelatedPageIds(),
dto.getFolder(), dto.getFolder(),
order order
); );

View File

@@ -59,6 +59,14 @@ public class RandomTableController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
/** Recherche par nom — alimente la recherche globale (Ctrl+K). */
@GetMapping("/search")
public ResponseEntity<List<RandomTableDTO>> search(@RequestParam("q") String query) {
return ResponseEntity.ok(service.searchTables(query).stream()
.map(mapper::toDTO)
.collect(java.util.stream.Collectors.toList()));
}
/** Génère une PROPOSITION de table via l'IA (non persistée) — l'UI préremplit le formulaire. */ /** Génère une PROPOSITION de table via l'IA (non persistée) — l'UI préremplit le formulaire. */
@PostMapping("/generate") @PostMapping("/generate")
public ResponseEntity<RandomTableDTO> generate(@RequestBody GenerateRequest req) { public ResponseEntity<RandomTableDTO> generate(@RequestBody GenerateRequest req) {

View File

@@ -2,6 +2,7 @@ package com.loremind.infrastructure.web.dto.campaigncontext;
import lombok.Data; import lombok.Data;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -20,6 +21,8 @@ public class NpcDTO {
private Map<String, List<String>> imageValues = new HashMap<>(); private Map<String, List<String>> imageValues = new HashMap<>();
private Map<String, Map<String, String>> keyValueValues = new HashMap<>(); private Map<String, Map<String, String>> keyValueValues = new HashMap<>();
private String campaignId; private String campaignId;
/** IDs de Pages de Lore référencées par ce PNJ (référence faible cross-context). */
private List<String> relatedPageIds = new ArrayList<>();
private String folder; private String folder;
private int order; private int order;
} }

View File

@@ -16,6 +16,8 @@ public class RoomDTO {
private String name; private String name;
private String description; private String description;
private String enemies; private String enemies;
/** IDs des fiches du bestiaire présentes dans la pièce (weak refs). */
private List<String> enemyIds = new ArrayList<>();
private String loot; private String loot;
private String traps; private String traps;
private String gmNotes; private String gmNotes;

View File

@@ -30,6 +30,9 @@ public class SceneDTO {
private String combatDifficulty; private String combatDifficulty;
private String enemies; private String enemies;
/** IDs des fiches du bestiaire engagées dans la rencontre (weak refs). */
private List<String> enemyIds = new ArrayList<>();
/** IDs des pages du Lore liées (weak cross-context references). */ /** IDs des pages du Lore liées (weak cross-context references). */
private List<String> relatedPageIds = new ArrayList<>(); private List<String> relatedPageIds = new ArrayList<>();

View File

@@ -19,6 +19,7 @@ public class GameSystemDTO {
private String rulesMarkdown; private String rulesMarkdown;
private List<TemplateFieldDTO> characterTemplate = new ArrayList<>(); private List<TemplateFieldDTO> characterTemplate = new ArrayList<>();
private List<TemplateFieldDTO> npcTemplate = new ArrayList<>(); private List<TemplateFieldDTO> npcTemplate = new ArrayList<>();
private List<TemplateFieldDTO> enemyTemplate = new ArrayList<>();
private String author; private String author;
private boolean isPublic; private boolean isPublic;
} }

View File

@@ -20,6 +20,10 @@ public class PageDTO {
private Map<String, String> values; private Map<String, String> values;
/** Pour chaque champ IMAGE du template, la liste ordonnee des IDs d'images. */ /** Pour chaque champ IMAGE du template, la liste ordonnee des IDs d'images. */
private Map<String, List<String>> imageValues; private Map<String, List<String>> imageValues;
/** Pour chaque champ KEY_VALUE_LIST du template : label → valeur. */
private Map<String, Map<String, String>> keyValueValues;
/** Pour chaque champ TABLE du template : lignes (colonne → cellule). */
private Map<String, List<Map<String, String>>> tableValues;
private String notes; private String notes;
private List<String> tags; private List<String> tags;
private List<String> relatedPageIds; private List<String> relatedPageIds;

View File

@@ -27,6 +27,7 @@ public class GameSystemMapper {
dto.setRulesMarkdown(g.getRulesMarkdown()); dto.setRulesMarkdown(g.getRulesMarkdown());
dto.setCharacterTemplate(toDTOList(g.getCharacterTemplate())); dto.setCharacterTemplate(toDTOList(g.getCharacterTemplate()));
dto.setNpcTemplate(toDTOList(g.getNpcTemplate())); dto.setNpcTemplate(toDTOList(g.getNpcTemplate()));
dto.setEnemyTemplate(toDTOList(g.getEnemyTemplate()));
dto.setAuthor(g.getAuthor()); dto.setAuthor(g.getAuthor());
dto.setPublic(g.isPublic()); dto.setPublic(g.isPublic());
return dto; return dto;
@@ -41,6 +42,7 @@ public class GameSystemMapper {
.rulesMarkdown(dto.getRulesMarkdown()) .rulesMarkdown(dto.getRulesMarkdown())
.characterTemplate(toDomainList(dto.getCharacterTemplate())) .characterTemplate(toDomainList(dto.getCharacterTemplate()))
.npcTemplate(toDomainList(dto.getNpcTemplate())) .npcTemplate(toDomainList(dto.getNpcTemplate()))
.enemyTemplate(toDomainList(dto.getEnemyTemplate()))
.author(dto.getAuthor()) .author(dto.getAuthor())
.isPublic(dto.isPublic()) .isPublic(dto.isPublic())
.build(); .build();

View File

@@ -4,6 +4,7 @@ import com.loremind.domain.campaigncontext.Npc;
import com.loremind.infrastructure.web.dto.campaigncontext.NpcDTO; import com.loremind.infrastructure.web.dto.campaigncontext.NpcDTO;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
@Component @Component
@@ -20,6 +21,7 @@ public class NpcMapper {
dto.setImageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>()); dto.setImageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>());
dto.setKeyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>()); dto.setKeyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>());
dto.setCampaignId(n.getCampaignId()); dto.setCampaignId(n.getCampaignId());
dto.setRelatedPageIds(n.getRelatedPageIds() != null ? new ArrayList<>(n.getRelatedPageIds()) : new ArrayList<>());
dto.setFolder(n.getFolder()); dto.setFolder(n.getFolder());
dto.setOrder(n.getOrder()); dto.setOrder(n.getOrder());
return dto; return dto;
@@ -36,6 +38,7 @@ public class NpcMapper {
.imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>()) .imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>())
.keyValueValues(dto.getKeyValueValues() != null ? new HashMap<>(dto.getKeyValueValues()) : new HashMap<>()) .keyValueValues(dto.getKeyValueValues() != null ? new HashMap<>(dto.getKeyValueValues()) : new HashMap<>())
.campaignId(dto.getCampaignId()) .campaignId(dto.getCampaignId())
.relatedPageIds(dto.getRelatedPageIds() != null ? new ArrayList<>(dto.getRelatedPageIds()) : new ArrayList<>())
.folder(dto.getFolder()) .folder(dto.getFolder())
.order(dto.getOrder()) .order(dto.getOrder())
.build(); .build();

View File

@@ -23,6 +23,8 @@ public class PageMapper {
dto.setTitle(page.getTitle()); dto.setTitle(page.getTitle());
dto.setValues(CollectionUtils.copyMap(page.getValues())); dto.setValues(CollectionUtils.copyMap(page.getValues()));
dto.setImageValues(CollectionUtils.copyMap(page.getImageValues())); dto.setImageValues(CollectionUtils.copyMap(page.getImageValues()));
dto.setKeyValueValues(CollectionUtils.copyMap(page.getKeyValueValues()));
dto.setTableValues(CollectionUtils.copyMap(page.getTableValues()));
dto.setNotes(page.getNotes()); dto.setNotes(page.getNotes());
dto.setTags(CollectionUtils.copyList(page.getTags())); dto.setTags(CollectionUtils.copyList(page.getTags()));
dto.setRelatedPageIds(CollectionUtils.copyList(page.getRelatedPageIds())); dto.setRelatedPageIds(CollectionUtils.copyList(page.getRelatedPageIds()));
@@ -41,6 +43,8 @@ public class PageMapper {
.title(dto.getTitle()) .title(dto.getTitle())
.values(CollectionUtils.copyMap(dto.getValues())) .values(CollectionUtils.copyMap(dto.getValues()))
.imageValues(CollectionUtils.copyMap(dto.getImageValues())) .imageValues(CollectionUtils.copyMap(dto.getImageValues()))
.keyValueValues(CollectionUtils.copyMap(dto.getKeyValueValues()))
.tableValues(CollectionUtils.copyMap(dto.getTableValues()))
.notes(dto.getNotes()) .notes(dto.getNotes())
.tags(CollectionUtils.copyList(dto.getTags())) .tags(CollectionUtils.copyList(dto.getTags()))
.relatedPageIds(CollectionUtils.copyList(dto.getRelatedPageIds())) .relatedPageIds(CollectionUtils.copyList(dto.getRelatedPageIds()))

View File

@@ -40,6 +40,9 @@ public class SceneMapper {
dto.setChoicesConsequences(scene.getChoicesConsequences()); dto.setChoicesConsequences(scene.getChoicesConsequences());
dto.setCombatDifficulty(scene.getCombatDifficulty()); dto.setCombatDifficulty(scene.getCombatDifficulty());
dto.setEnemies(scene.getEnemies()); dto.setEnemies(scene.getEnemies());
dto.setEnemyIds(scene.getEnemyIds() != null
? new ArrayList<>(scene.getEnemyIds())
: new ArrayList<>());
dto.setRelatedPageIds(scene.getRelatedPageIds() != null dto.setRelatedPageIds(scene.getRelatedPageIds() != null
? new ArrayList<>(scene.getRelatedPageIds()) ? new ArrayList<>(scene.getRelatedPageIds())
: new ArrayList<>()); : new ArrayList<>());
@@ -74,6 +77,9 @@ public class SceneMapper {
.choicesConsequences(dto.getChoicesConsequences()) .choicesConsequences(dto.getChoicesConsequences())
.combatDifficulty(dto.getCombatDifficulty()) .combatDifficulty(dto.getCombatDifficulty())
.enemies(dto.getEnemies()) .enemies(dto.getEnemies())
.enemyIds(dto.getEnemyIds() != null
? new ArrayList<>(dto.getEnemyIds())
: new ArrayList<>())
.relatedPageIds(dto.getRelatedPageIds() != null .relatedPageIds(dto.getRelatedPageIds() != null
? new ArrayList<>(dto.getRelatedPageIds()) ? new ArrayList<>(dto.getRelatedPageIds())
: new ArrayList<>()) : new ArrayList<>())
@@ -117,6 +123,9 @@ public class SceneMapper {
dto.setName(r.getName()); dto.setName(r.getName());
dto.setDescription(r.getDescription()); dto.setDescription(r.getDescription());
dto.setEnemies(r.getEnemies()); dto.setEnemies(r.getEnemies());
dto.setEnemyIds(r.getEnemyIds() != null
? new ArrayList<>(r.getEnemyIds())
: new ArrayList<>());
dto.setLoot(r.getLoot()); dto.setLoot(r.getLoot());
dto.setTraps(r.getTraps()); dto.setTraps(r.getTraps());
dto.setGmNotes(r.getGmNotes()); dto.setGmNotes(r.getGmNotes());
@@ -145,6 +154,9 @@ public class SceneMapper {
.name(d.getName()) .name(d.getName())
.description(d.getDescription()) .description(d.getDescription())
.enemies(d.getEnemies()) .enemies(d.getEnemies())
.enemyIds(d.getEnemyIds() != null
? new ArrayList<>(d.getEnemyIds())
: new ArrayList<>())
.loot(d.getLoot()) .loot(d.getLoot())
.traps(d.getTraps()) .traps(d.getTraps())
.gmNotes(d.getGmNotes()) .gmNotes(d.getGmNotes())

View File

@@ -29,7 +29,8 @@ public class TemplateFieldMapper {
layoutStr = layout.name(); layoutStr = layout.name();
} }
List<String> labels = null; List<String> labels = null;
if (field.getType() == FieldType.KEY_VALUE_LIST && field.getLabels() != null) { if ((field.getType() == FieldType.KEY_VALUE_LIST || field.getType() == FieldType.TABLE)
&& field.getLabels() != null) {
labels = new ArrayList<>(field.getLabels()); labels = new ArrayList<>(field.getLabels());
} }
return new TemplateFieldDTO(field.getName(), typeStr, layoutStr, labels); return new TemplateFieldDTO(field.getName(), typeStr, layoutStr, labels);
@@ -54,7 +55,7 @@ public class TemplateFieldMapper {
} }
} }
List<String> labels = null; List<String> labels = null;
if (type == FieldType.KEY_VALUE_LIST && dto.getLabels() != null) { if ((type == FieldType.KEY_VALUE_LIST || type == FieldType.TABLE) && dto.getLabels() != null) {
labels = new ArrayList<>(dto.getLabels()); labels = new ArrayList<>(dto.getLabels());
} }
return new TemplateField(dto.getName(), type, layout, labels); return new TemplateField(dto.getName(), type, layout, labels);

View File

@@ -1,6 +1,7 @@
package com.loremind.application.campaigncontext; package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Npc; import com.loremind.domain.campaigncontext.Npc;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.NpcRepository; import com.loremind.domain.campaigncontext.ports.NpcRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -29,6 +30,9 @@ public class NpcServiceTest {
@Mock @Mock
private NpcRepository npcRepository; private NpcRepository npcRepository;
@Mock
private CampaignRepository campaignRepository;
@InjectMocks @InjectMocks
private NpcService npcService; private NpcService npcService;
@@ -51,7 +55,7 @@ public class NpcServiceTest {
Npc result = npcService.createNpc( Npc result = npcService.createNpc(
new NpcService.NpcData("Borin le forgeron", null, null, new NpcService.NpcData("Borin le forgeron", null, null,
Map.of("Notes", "Borin"), null, null, "camp-1", null,5)); Map.of("Notes", "Borin"), null, null, "camp-1", null, null, 5));
assertNotNull(result); assertNotNull(result);
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class); ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
@@ -67,7 +71,7 @@ public class NpcServiceTest {
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(a, b)); when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(a, b));
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc); when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
npcService.createNpc(new NpcService.NpcData("Nouveau", null, null, null, null, null, "camp-1", null,null)); npcService.createNpc(new NpcService.NpcData("Nouveau", null, null, null, null, null, "camp-1", null, null, null));
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class); ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
verify(npcRepository).save(captor.capture()); verify(npcRepository).save(captor.capture());
@@ -79,7 +83,7 @@ public class NpcServiceTest {
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of()); when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc); when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
npcService.createNpc(new NpcService.NpcData("Premier", null, null, null, null, null, "camp-1", null,null)); npcService.createNpc(new NpcService.NpcData("Premier", null, null, null, null, null, "camp-1", null, null, null));
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class); ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
verify(npcRepository).save(captor.capture()); verify(npcRepository).save(captor.capture());
@@ -124,7 +128,7 @@ public class NpcServiceTest {
Npc result = npcService.updateNpc("npc-1", Npc result = npcService.updateNpc("npc-1",
new NpcService.NpcData("Borin renommé", null, null, new NpcService.NpcData("Borin renommé", null, null,
Map.of("Notes", "v2"), null, null, "camp-1", null,7)); Map.of("Notes", "v2"), null, null, "camp-1", null, null, 7));
assertEquals("Borin renommé", result.getName()); assertEquals("Borin renommé", result.getName());
assertEquals("v2", result.getValues().get("Notes")); assertEquals("v2", result.getValues().get("Notes"));
@@ -138,7 +142,7 @@ public class NpcServiceTest {
Npc result = npcService.updateNpc("npc-1", Npc result = npcService.updateNpc("npc-1",
new NpcService.NpcData("Borin", null, null, new NpcService.NpcData("Borin", null, null,
Map.of("Notes", "txt"), null, null, "camp-1", null,null)); Map.of("Notes", "txt"), null, null, "camp-1", null, null, null));
// testNpc avait order=1 → préservé // testNpc avait order=1 → préservé
assertEquals(1, result.getOrder()); assertEquals(1, result.getOrder());
@@ -150,7 +154,7 @@ public class NpcServiceTest {
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> npcService.updateNpc("missing", () -> npcService.updateNpc("missing",
new NpcService.NpcData("x", null, null, null, null, null, "camp-1", null,null))); new NpcService.NpcData("x", null, null, null, null, null, "camp-1", null, null, null)));
assertTrue(ex.getMessage().contains("missing")); assertTrue(ex.getMessage().contains("missing"));
verify(npcRepository, never()).save(any()); verify(npcRepository, never()).save(any());
} }

View File

@@ -37,6 +37,7 @@ class CampaignStructuralContextTest {
ArcSummary arc = new ArcSummary( ArcSummary arc = new ArcSummary(
"Acte I", "Acte I",
"Mise en place", "Mise en place",
false,
1, 1,
List.of(chapter)); List.of(chapter));
@@ -76,7 +77,7 @@ class CampaignStructuralContextTest {
@Test @Test
void illustrationCount_defaultsToZero_onAllSummaryTypes() { void illustrationCount_defaultsToZero_onAllSummaryTypes() {
ArcSummary arc = new ArcSummary("X", null, 0, List.of()); ArcSummary arc = new ArcSummary("X", null, false, 0, List.of());
ChapterSummary chapter = new ChapterSummary("X", null, 0, List.of()); ChapterSummary chapter = new ChapterSummary("X", null, 0, List.of());
SceneSummary scene = new SceneSummary("X", null, 0, List.of(), List.of()); SceneSummary scene = new SceneSummary("X", null, 0, List.of(), List.of());
@@ -92,6 +93,7 @@ class CampaignStructuralContextTest {
ArcSummary arc = new ArcSummary( ArcSummary arc = new ArcSummary(
"Acte I", "Acte I",
null, null,
false,
0, 0,
List.of( List.of(
new ChapterSummary("Ch1", null, 0, List.of()), new ChapterSummary("Ch1", null, 0, List.of()),

View File

@@ -165,7 +165,7 @@ class BrainChatPayloadBuilderTest {
BranchHint branch = new BranchHint("fuite", "La poursuite", "HP < 50%"); BranchHint branch = new BranchHint("fuite", "La poursuite", "HP < 50%");
SceneSummary scene = new SceneSummary("L'auberge", "Rencontre tendue", 3, List.of(branch), List.of()); SceneSummary scene = new SceneSummary("L'auberge", "Rencontre tendue", 3, List.of(branch), List.of());
ChapterSummary chapter = new ChapterSummary("L'arrivee", "...", 0, List.of(scene)); ChapterSummary chapter = new ChapterSummary("L'arrivee", "...", 0, List.of(scene));
ArcSummary arc = new ArcSummary("Acte I", "Mise en place", 1, List.of(chapter)); ArcSummary arc = new ArcSummary("Acte I", "Mise en place", false, 1, List.of(chapter));
CampaignStructuralContext camp = new CampaignStructuralContext( CampaignStructuralContext camp = new CampaignStructuralContext(
"Les Ombres", "dark fantasy", List.of(arc), List.of(), List.of()); "Les Ombres", "dark fantasy", List.of(arc), List.of(), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build(); ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
@@ -198,7 +198,7 @@ class BrainChatPayloadBuilderTest {
@Test @Test
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
void build_arcSummary_omitsIllustrationCount_whenZero() { void build_arcSummary_omitsIllustrationCount_whenZero() {
ArcSummary arc = new ArcSummary("A", "", 0, List.of()); ArcSummary arc = new ArcSummary("A", "", false, 0, List.of());
CampaignStructuralContext camp = new CampaignStructuralContext( CampaignStructuralContext camp = new CampaignStructuralContext(
"X", "", List.of(arc), List.of(), List.of()); "X", "", List.of(arc), List.of(), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build(); ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
@@ -215,7 +215,7 @@ class BrainChatPayloadBuilderTest {
void build_sceneSummary_omitsBranches_whenEmpty() { void build_sceneSummary_omitsBranches_whenEmpty() {
SceneSummary scene = new SceneSummary("S", "", 0, List.of(), List.of()); SceneSummary scene = new SceneSummary("S", "", 0, List.of(), List.of());
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene)); ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter)); ArcSummary arc = new ArcSummary("A", "", false, 0, List.of(chapter));
CampaignStructuralContext camp = new CampaignStructuralContext( CampaignStructuralContext camp = new CampaignStructuralContext(
"X", "", List.of(arc), List.of(), List.of()); "X", "", List.of(arc), List.of(), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build(); ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
@@ -234,7 +234,7 @@ class BrainChatPayloadBuilderTest {
BranchHint branch = new BranchHint("X", "Y", " "); BranchHint branch = new BranchHint("X", "Y", " ");
SceneSummary scene = new SceneSummary("S", "", 0, List.of(branch), List.of()); SceneSummary scene = new SceneSummary("S", "", 0, List.of(branch), List.of());
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene)); ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter)); ArcSummary arc = new ArcSummary("A", "", false, 0, List.of(chapter));
CampaignStructuralContext camp = new CampaignStructuralContext( CampaignStructuralContext camp = new CampaignStructuralContext(
"X", "", List.of(arc), List.of(), List.of()); "X", "", List.of(arc), List.of(), List.of());
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build(); ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();

739
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "0.12.0-beta", "version": "0.13.0-beta",
"description": "LoreMind Frontend - Angular", "description": "LoreMind Frontend - Angular",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
@@ -34,6 +34,8 @@
"@angular-devkit/build-angular": "^21.2.14", "@angular-devkit/build-angular": "^21.2.14",
"@angular/cli": "^21.2.14", "@angular/cli": "^21.2.14",
"@angular/compiler-cli": "^21.2.16", "@angular/compiler-cli": "^21.2.16",
"@emnapi/core": "^1.11.0",
"@emnapi/runtime": "^1.11.0",
"@playwright/test": "^1.59.1", "@playwright/test": "^1.59.1",
"typescript": "~5.9.3" "typescript": "~5.9.3"
} }

View File

@@ -4,6 +4,7 @@ import { hiddenInDemoGuard } from './guards/demo-mode.guard';
export const routes: Routes = [ export const routes: Routes = [
{ path: 'lore', loadComponent: () => import('./lore/lore.component').then(m => m.LoreComponent) }, { path: 'lore', loadComponent: () => import('./lore/lore.component').then(m => m.LoreComponent) },
{ path: 'lore/:id', loadComponent: () => import('./lore/lore-detail/lore-detail.component').then(m => m.LoreDetailComponent) }, { path: 'lore/:id', loadComponent: () => import('./lore/lore-detail/lore-detail.component').then(m => m.LoreDetailComponent) },
{ path: 'lore/:loreId/graph', loadComponent: () => import('./lore/lore-graph/lore-graph.component').then(m => m.LoreGraphComponent) },
{ path: 'lore/:loreId/nodes/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) }, { path: 'lore/:loreId/nodes/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) },
{ path: 'lore/:loreId/folders/:parentId/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) }, { path: 'lore/:loreId/folders/:parentId/create', loadComponent: () => import('./lore/lore-node-create/lore-node-create.component').then(m => m.LoreNodeCreateComponent) },
{ path: 'lore/:loreId/folders/:folderId', loadComponent: () => import('./lore/folder-view/folder-view.component').then(m => m.FolderViewComponent) }, { path: 'lore/:loreId/folders/:folderId', loadComponent: () => import('./lore/folder-view/folder-view.component').then(m => m.FolderViewComponent) },
@@ -22,6 +23,7 @@ export const routes: Routes = [
{ path: 'campaigns/:campaignId/playthroughs/:playthroughId/characters/create', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) }, { path: 'campaigns/:campaignId/playthroughs/:playthroughId/characters/create', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
{ path: 'campaigns/:campaignId/playthroughs/:playthroughId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) }, { path: 'campaigns/:campaignId/playthroughs/:playthroughId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
{ path: 'campaigns/:campaignId/playthroughs/:playthroughId/characters/:characterId', loadComponent: () => import('./campaigns/character/character-view/character-view.component').then(m => m.CharacterViewComponent) }, { path: 'campaigns/:campaignId/playthroughs/:playthroughId/characters/:characterId', loadComponent: () => import('./campaigns/character/character-view/character-view.component').then(m => m.CharacterViewComponent) },
{ path: 'campaigns/:campaignId/npcs', loadComponent: () => import('./campaigns/npc/npc-list/npc-list.component').then(m => m.NpcListComponent) },
{ path: 'campaigns/:campaignId/npcs/create', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) }, { path: 'campaigns/:campaignId/npcs/create', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
{ path: 'campaigns/:campaignId/npcs/:npcId/edit', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) }, { path: 'campaigns/:campaignId/npcs/:npcId/edit', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
{ path: 'campaigns/:campaignId/npcs/:npcId', loadComponent: () => import('./campaigns/npc/npc-view/npc-view.component').then(m => m.NpcViewComponent) }, { path: 'campaigns/:campaignId/npcs/:npcId', loadComponent: () => import('./campaigns/npc/npc-view/npc-view.component').then(m => m.NpcViewComponent) },
@@ -31,6 +33,10 @@ export const routes: Routes = [
{ path: 'campaigns/:campaignId/item-catalogs/create', loadComponent: () => import('./campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component').then(m => m.ItemCatalogEditComponent) }, { path: 'campaigns/:campaignId/item-catalogs/create', loadComponent: () => import('./campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component').then(m => m.ItemCatalogEditComponent) },
{ path: 'campaigns/:campaignId/item-catalogs/:catalogId/edit', loadComponent: () => import('./campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component').then(m => m.ItemCatalogEditComponent) }, { path: 'campaigns/:campaignId/item-catalogs/:catalogId/edit', loadComponent: () => import('./campaigns/item-catalog/item-catalog-edit/item-catalog-edit.component').then(m => m.ItemCatalogEditComponent) },
{ path: 'campaigns/:campaignId/item-catalogs/:catalogId', loadComponent: () => import('./campaigns/item-catalog/item-catalog-view/item-catalog-view.component').then(m => m.ItemCatalogViewComponent) }, { path: 'campaigns/:campaignId/item-catalogs/:catalogId', loadComponent: () => import('./campaigns/item-catalog/item-catalog-view/item-catalog-view.component').then(m => m.ItemCatalogViewComponent) },
{ path: 'campaigns/:campaignId/enemies', loadComponent: () => import('./campaigns/enemy/enemy-list/enemy-list.component').then(m => m.EnemyListComponent) },
{ path: 'campaigns/:campaignId/enemies/create', loadComponent: () => import('./campaigns/enemy/enemy-edit/enemy-edit.component').then(m => m.EnemyEditComponent) },
{ path: 'campaigns/:campaignId/enemies/:enemyId/edit', loadComponent: () => import('./campaigns/enemy/enemy-edit/enemy-edit.component').then(m => m.EnemyEditComponent) },
{ path: 'campaigns/:campaignId/enemies/:enemyId', loadComponent: () => import('./campaigns/enemy/enemy-view/enemy-view.component').then(m => m.EnemyViewComponent) },
{ path: 'campaigns/:campaignId/random-tables/create', loadComponent: () => import('./campaigns/random-table/random-table-edit/random-table-edit.component').then(m => m.RandomTableEditComponent) }, { path: 'campaigns/:campaignId/random-tables/create', loadComponent: () => import('./campaigns/random-table/random-table-edit/random-table-edit.component').then(m => m.RandomTableEditComponent) },
{ path: 'campaigns/:campaignId/random-tables/:tableId/edit', loadComponent: () => import('./campaigns/random-table/random-table-edit/random-table-edit.component').then(m => m.RandomTableEditComponent) }, { path: 'campaigns/:campaignId/random-tables/:tableId/edit', loadComponent: () => import('./campaigns/random-table/random-table-edit/random-table-edit.component').then(m => m.RandomTableEditComponent) },
{ path: 'campaigns/:campaignId/random-tables/:tableId', loadComponent: () => import('./campaigns/random-table/random-table-view/random-table-view.component').then(m => m.RandomTableViewComponent) }, { path: 'campaigns/:campaignId/random-tables/:tableId', loadComponent: () => import('./campaigns/random-table/random-table-view/random-table-view.component').then(m => m.RandomTableViewComponent) },

View File

@@ -8,6 +8,7 @@ import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service'; import { RandomTableService } from '../../../services/random-table.service';
import { EnemyService } from '../../../services/enemy.service';
import { LayoutService } from '../../../services/layout.service'; import { LayoutService } from '../../../services/layout.service';
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper'; import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../../campaign-tree.helper';
import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component'; import { IconPickerComponent } from '../../../shared/icon-picker/icon-picker.component';
@@ -41,6 +42,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService, private randomTableService: RandomTableService,
private enemyService: EnemyService,
private layoutService: LayoutService private layoutService: LayoutService
) { ) {
this.form = this.fb.group({ this.form = this.fb.group({
@@ -60,7 +62,7 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
forkJoin({ forkJoin({
campaign: this.campaignService.getCampaignById(this.campaignId), campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService) treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
}).subscribe(({ campaign, allCampaigns, treeData }) => { }).subscribe(({ campaign, allCampaigns, treeData }) => {
this.existingArcCount = treeData.arcs.length; this.existingArcCount = treeData.arcs.length;

View File

@@ -9,6 +9,7 @@ import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service'; import { RandomTableService } from '../../../services/random-table.service';
import { EnemyService } from '../../../services/enemy.service';
import { PageService } from '../../../services/page.service'; import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service'; import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service'; import { PageTitleService } from '../../../services/page-title.service';
@@ -78,6 +79,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService, private randomTableService: RandomTableService,
private enemyService: EnemyService,
private pageService: PageService, private pageService: PageService,
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService, private pageTitleService: PageTitleService,
@@ -117,7 +119,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId), campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
arc: this.campaignService.getArcById(this.arcId), arc: this.campaignService.getArcById(this.arcId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService) treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
}).pipe( }).pipe(
switchMap(data => { switchMap(data => {
const lid = data.campaign.loreId ?? null; const lid = data.campaign.loreId ?? null;

View File

@@ -9,6 +9,7 @@ import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service'; import { RandomTableService } from '../../../services/random-table.service';
import { EnemyService } from '../../../services/enemy.service';
import { PageService } from '../../../services/page.service'; import { PageService } from '../../../services/page.service';
import { LayoutService } from '../../../services/layout.service'; import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service'; import { PageTitleService } from '../../../services/page-title.service';
@@ -60,6 +61,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService, private randomTableService: RandomTableService,
private enemyService: EnemyService,
private pageService: PageService, private pageService: PageService,
private layoutService: LayoutService, private layoutService: LayoutService,
private pageTitleService: PageTitleService, private pageTitleService: PageTitleService,
@@ -83,7 +85,7 @@ export class ArcViewComponent implements OnInit, OnDestroy {
campaign: this.campaignService.getCampaignById(this.campaignId), campaign: this.campaignService.getCampaignById(this.campaignId),
allCampaigns: this.campaignService.getAllCampaigns(), allCampaigns: this.campaignService.getAllCampaigns(),
arc: this.campaignService.getArcById(this.arcId), arc: this.campaignService.getArcById(this.arcId),
treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService) treeData: loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService, this.randomTableService, this.enemyService)
}).pipe( }).pipe(
switchMap(data => { switchMap(data => {
const lid = data.campaign.loreId ?? null; const lid = data.campaign.loreId ?? null;

View File

@@ -4,11 +4,13 @@ import { CampaignService } from '../services/campaign.service';
import { CharacterService } from '../services/character.service'; import { CharacterService } from '../services/character.service';
import { NpcService } from '../services/npc.service'; import { NpcService } from '../services/npc.service';
import { RandomTableService } from '../services/random-table.service'; import { RandomTableService } from '../services/random-table.service';
import { EnemyService } from '../services/enemy.service';
import { TreeItem, SecondarySidebarConfig, GlobalItem } from '../services/layout.service'; import { TreeItem, SecondarySidebarConfig, GlobalItem } from '../services/layout.service';
import { Arc, Chapter, Scene, Campaign } from '../services/campaign.model'; import { Arc, Chapter, Scene, Campaign } from '../services/campaign.model';
import { Character } from '../services/character.model'; import { Character } from '../services/character.model';
import { Npc } from '../services/npc.model'; import { Npc } from '../services/npc.model';
import { RandomTable } from '../services/random-table.model'; import { RandomTable } from '../services/random-table.model';
import { Enemy } from '../services/enemy.model';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
/** /**
@@ -26,6 +28,7 @@ export interface CampaignTreeData {
characters: Character[]; characters: Character[];
npcs: Npc[]; npcs: Npc[];
randomTables: RandomTable[]; randomTables: RandomTable[];
enemies: Enemy[];
} }
export function loadCampaignTreeData( export function loadCampaignTreeData(
@@ -35,7 +38,10 @@ export function loadCampaignTreeData(
npcService: NpcService, npcService: NpcService,
// Optionnel pour ne pas casser les ~15 appelants existants : si fourni, les // Optionnel pour ne pas casser les ~15 appelants existants : si fourni, les
// tables aléatoires sont chargées et apparaissent dans la sidebar. // tables aléatoires sont chargées et apparaissent dans la sidebar.
randomTableService?: RandomTableService randomTableService?: RandomTableService,
// Optionnel (même principe) : si fourni, les ennemis sont chargés et le nœud
// « Ennemis » devient dépliable (dossiers → fiches) en plus du lien.
enemyService?: EnemyService
): Observable<CampaignTreeData> { ): Observable<CampaignTreeData> {
// Note refonte Playthrough : les PJ appartiennent désormais à une Partie, // Note refonte Playthrough : les PJ appartiennent désormais à une Partie,
// pas à la campagne — on ne les charge plus ici (les vues qui les affichent // pas à la campagne — on ne les charge plus ici (les vues qui les affichent
@@ -46,11 +52,14 @@ export function loadCampaignTreeData(
npcs: npcService.getByCampaign(campaignId), npcs: npcService.getByCampaign(campaignId),
randomTables: randomTableService randomTables: randomTableService
? randomTableService.getByCampaign(campaignId).pipe(catchError(() => of([] as RandomTable[]))) ? randomTableService.getByCampaign(campaignId).pipe(catchError(() => of([] as RandomTable[])))
: of([] as RandomTable[]) : of([] as RandomTable[]),
enemies: enemyService
? enemyService.getByCampaign(campaignId).pipe(catchError(() => of([] as Enemy[])))
: of([] as Enemy[])
}).pipe( }).pipe(
switchMap(({ arcs, characters, npcs, randomTables }) => { switchMap(({ arcs, characters, npcs, randomTables, enemies }) => {
if (arcs.length === 0) { if (arcs.length === 0) {
return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, characters, npcs, randomTables }); return of({ arcs, chaptersByArc: {}, scenesByChapter: {}, characters, npcs, randomTables, enemies });
} }
const chapterCalls = arcs.map(a => const chapterCalls = arcs.map(a =>
service.getChapters(a.id!).pipe(map(chapters => ({ arcId: a.id!, chapters }))) service.getChapters(a.id!).pipe(map(chapters => ({ arcId: a.id!, chapters })))
@@ -65,7 +74,7 @@ export function loadCampaignTreeData(
}); });
if (allChapters.length === 0) { if (allChapters.length === 0) {
return of({ arcs, chaptersByArc, scenesByChapter: {}, characters, npcs, randomTables }); return of({ arcs, chaptersByArc, scenesByChapter: {}, characters, npcs, randomTables, enemies });
} }
const sceneCalls = allChapters.map(c => const sceneCalls = allChapters.map(c =>
service.getScenes(c.id!).pipe(map(scenes => ({ chapterId: c.id!, scenes }))) service.getScenes(c.id!).pipe(map(scenes => ({ chapterId: c.id!, scenes })))
@@ -74,7 +83,7 @@ export function loadCampaignTreeData(
map(sceneResults => { map(sceneResults => {
const scenesByChapter: Record<string, Scene[]> = {}; const scenesByChapter: Record<string, Scene[]> = {};
sceneResults.forEach(r => { scenesByChapter[r.chapterId] = r.scenes; }); sceneResults.forEach(r => { scenesByChapter[r.chapterId] = r.scenes; });
return { arcs, chaptersByArc, scenesByChapter, characters, npcs, randomTables }; return { arcs, chaptersByArc, scenesByChapter, characters, npcs, randomTables, enemies };
}) })
); );
}) })
@@ -135,6 +144,9 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
iconKey: 'c-drama', iconKey: 'c-drama',
children: npcChildren, children: npcChildren,
meta: sortedNpcs.length ? String(sortedNpcs.length) : undefined, meta: sortedNpcs.length ? String(sortedNpcs.length) : undefined,
// Cliquer le LIBELLÉ ouvre la page de liste (vue d'ensemble par dossiers) ;
// cliquer le CHEVRON déplie l'arbre dans la sidebar — les deux coexistent.
route: `/campaigns/${campaignId}/npcs`,
// Porte le header de section "Personnages" (les PJ ayant migré vers la Partie). // Porte le header de section "Personnages" (les PJ ayant migré vers la Partie).
// Le filet au-dessus est masqué par CSS si c'est le tout premier item de la sidebar. // Le filet au-dessus est masqué par CSS si c'est le tout premier item de la sidebar.
sectionHeaderBefore: 'Personnages', sectionHeaderBefore: 'Personnages',
@@ -146,6 +158,40 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
}] }]
}; };
// --- Ennemis (bestiaire) : même structure que les PNJ — dossiers dépliables
// dans la sidebar + libellé cliquable vers la page de liste.
const sortedEnemies = [...(data.enemies ?? [])].sort(byName);
const enemyItem = (e: Enemy): TreeItem => ({
id: `enemy-${e.id}`,
label: e.name,
route: `/campaigns/${campaignId}/enemies/${e.id}`,
meta: e.level ? `Niv. ${e.level}` : undefined
});
const enemiesByFolder = new Map<string, Enemy[]>();
const ungroupedEnemies: Enemy[] = [];
for (const e of sortedEnemies) {
const f = (e.folder ?? '').trim();
if (f) {
if (!enemiesByFolder.has(f)) enemiesByFolder.set(f, []);
enemiesByFolder.get(f)!.push(e);
} else {
ungroupedEnemies.push(e);
}
}
const enemyFolderNodes: TreeItem[] = [...enemiesByFolder.keys()]
.sort((a, b) => a.localeCompare(b, 'fr', { sensitivity: 'base' }))
.map(folder => {
const items = enemiesByFolder.get(folder)!.map(enemyItem);
return {
id: `enemy-folder-${folder}`,
label: folder,
iconKey: 'folder',
children: items,
meta: String(items.length)
};
});
const enemyChildren: TreeItem[] = [...enemyFolderNodes, ...ungroupedEnemies.map(enemyItem)];
const sortedArcs = [...data.arcs].sort(byName); const sortedArcs = [...data.arcs].sort(byName);
const arcNodes: TreeItem[] = sortedArcs.map((arc, idx) => { const arcNodes: TreeItem[] = sortedArcs.map((arc, idx) => {
@@ -233,6 +279,24 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
route: `/campaigns/${campaignId}/item-catalogs` route: `/campaigns/${campaignId}/item-catalogs`
}; };
// Ennemis (bestiaire, fiches pilotées par le template Ennemi du GameSystem,
// classées par dossier) — rangé avec les PERSONNAGES, comme les PNJ.
// Libellé → page de liste ; chevron → arbre dépliable (dossiers → fiches).
const enemiesNode: TreeItem = {
id: 'enemies-root',
label: 'Ennemis',
iconKey: 'skull',
children: enemyChildren,
meta: sortedEnemies.length ? String(sortedEnemies.length) : undefined,
route: `/campaigns/${campaignId}/enemies`,
createActions: [{
id: 'new-enemy',
label: 'Nouvel ennemi',
route: `/campaigns/${campaignId}/enemies/create`,
actionIcon: 'plus'
}]
};
// Importer un PDF de campagne → arborescence (outil, comme tables & ateliers). // Importer un PDF de campagne → arborescence (outil, comme tables & ateliers).
const importNode: TreeItem = { const importNode: TreeItem = {
id: 'import-pdf-root', id: 'import-pdf-root',
@@ -241,7 +305,7 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
route: `/campaigns/${campaignId}/import` route: `/campaigns/${campaignId}/import`
}; };
return [...arcNodes, npcsNode, tablesNode, notebooksNode, catalogsNode, importNode]; return [...arcNodes, npcsNode, enemiesNode, tablesNode, notebooksNode, catalogsNode, importNode];
} }
/** /**

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