Compare commits
10 Commits
v0.12.1-be
...
v0.13.0-be
| Author | SHA1 | Date | |
|---|---|---|---|
| c77c0bc994 | |||
| 6035df262d | |||
| 809e00ce49 | |||
| bc0cbb0f7b | |||
| 6740ed2177 | |||
| 8cc90bd24d | |||
| 14fc1c28fe | |||
| 7f519588b6 | |||
| 0799c850ec | |||
| 113df6a391 |
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +515,11 @@ class ImportCampaignUseCase:
|
|||||||
skipped = 0
|
skipped = 0
|
||||||
last_error: str | None = None
|
last_error: str | None = None
|
||||||
done_count = 0
|
done_count = 0
|
||||||
|
# Canal de statut : les couches profondes (retry LLM, re-découpage) y
|
||||||
|
# publient des messages destinés à l'UI — cf. import_status.notify_status.
|
||||||
|
status_queue: asyncio.Queue = asyncio.Queue()
|
||||||
|
status_token = set_status_queue(status_queue)
|
||||||
|
try:
|
||||||
# PARALLÉLISME : les morceaux sont traités par VAGUES de `map_concurrency`
|
# PARALLÉLISME : les morceaux sont traités par VAGUES de `map_concurrency`
|
||||||
# appels simultanés. L'ordre narratif est préservé : la fusion se fait
|
# appels simultanés. L'ordre narratif est préservé : la fusion se fait
|
||||||
# vague par vague, dans l'ordre du livre.
|
# vague par vague, dans l'ordre du livre.
|
||||||
@@ -443,9 +536,12 @@ class ImportCampaignUseCase:
|
|||||||
return_exceptions=True,
|
return_exceptions=True,
|
||||||
)
|
)
|
||||||
results: list | None = None
|
results: list | None = None
|
||||||
async for kind, payload in with_heartbeat(gathered):
|
async for kind, payload in with_heartbeat(gathered, status_queue=status_queue):
|
||||||
if kind == "heartbeat":
|
if kind == "heartbeat":
|
||||||
yield {"type": "heartbeat", "current": done_count + 1, "total": total}
|
yield {"type": "heartbeat", "current": done_count + 1, "total": total}
|
||||||
|
elif kind == "status":
|
||||||
|
yield {"type": "status", "message": payload,
|
||||||
|
"current": done_count + 1, "total": total}
|
||||||
else:
|
else:
|
||||||
results = payload
|
results = payload
|
||||||
for (i, _), res in zip(wave, results or []):
|
for (i, _), res in zip(wave, results or []):
|
||||||
@@ -480,13 +576,29 @@ class ImportCampaignUseCase:
|
|||||||
f"Dernier message : {last_error or 'inconnu'}"}
|
f"Dernier message : {last_error or 'inconnu'}"}
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if total > 0 and merger.counts()[0] == 0 and not merger.npcs():
|
||||||
|
# Le texte a été extrait mais le modèle n'a produit AUCUNE structure
|
||||||
|
# exploitable : sans ce signal, l'UI reçoit un `done` vide et
|
||||||
|
# l'utilisateur conclut à tort que le PDF est illisible.
|
||||||
|
yield {"type": "error",
|
||||||
|
"message": "Le texte du PDF a été extrait, mais le modèle n'a produit "
|
||||||
|
"aucune structure exploitable (réponses JSON vides ou coupées). "
|
||||||
|
"Réduisez la taille des morceaux d'import, augmentez la fenêtre "
|
||||||
|
"de contexte (num_ctx) ou essayez un autre modèle."}
|
||||||
|
return
|
||||||
|
|
||||||
# Consolidation finale : fusion des quasi-doublons inter-morceaux
|
# Consolidation finale : fusion des quasi-doublons inter-morceaux
|
||||||
# (best-effort, voir _consolidate). Inutile sur un import mono-morceau.
|
# (best-effort, voir _consolidate). Inutile sur un import mono-morceau.
|
||||||
if total > 1:
|
if total > 1:
|
||||||
yield {"type": "consolidating", "total": total}
|
yield {"type": "consolidating", "total": total}
|
||||||
async for kind, _ in with_heartbeat(self._consolidate(merger)):
|
async for kind, payload in with_heartbeat(
|
||||||
|
self._consolidate(merger), status_queue=status_queue
|
||||||
|
):
|
||||||
if kind == "heartbeat":
|
if kind == "heartbeat":
|
||||||
yield {"type": "heartbeat", "current": total, "total": total}
|
yield {"type": "heartbeat", "current": total, "total": total}
|
||||||
|
elif kind == "status":
|
||||||
|
yield {"type": "status", "message": payload,
|
||||||
|
"current": total, "total": total}
|
||||||
|
|
||||||
yield {
|
yield {
|
||||||
"type": "done",
|
"type": "done",
|
||||||
@@ -496,6 +608,8 @@ class ImportCampaignUseCase:
|
|||||||
"ocr_page_count": doc.ocr_page_count,
|
"ocr_page_count": doc.ocr_page_count,
|
||||||
"skipped": skipped,
|
"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."
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
raw = await generate_with_retry(
|
raw = await generate_with_retry(
|
||||||
self._llm, prompt, output_format="json", temperature=_TEMPERATURE)
|
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(
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -39,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.
|
||||||
@@ -61,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).
|
||||||
@@ -72,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é.
|
||||||
@@ -107,6 +178,40 @@ 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:
|
def _coerce_markdown(value: object) -> str:
|
||||||
"""Convertit une valeur de section renvoyée par le LLM en markdown plat.
|
"""Convertit une valeur de section renvoyée par le LLM en markdown plat.
|
||||||
|
|
||||||
@@ -130,6 +235,27 @@ def _coerce_markdown(value: object) -> str:
|
|||||||
return "" if value is None else str(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é).
|
||||||
|
|
||||||
@@ -156,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."""
|
||||||
@@ -207,6 +339,11 @@ class ImportRulesUseCase:
|
|||||||
merger = _SectionMerger()
|
merger = _SectionMerger()
|
||||||
skipped = 0
|
skipped = 0
|
||||||
last_error: str | None = None
|
last_error: str | None = None
|
||||||
|
# Canal de statut : les couches profondes (retry LLM, re-découpage) y
|
||||||
|
# publient des messages destinés à l'UI — cf. import_status.notify_status.
|
||||||
|
status_queue: asyncio.Queue = asyncio.Queue()
|
||||||
|
status_token = set_status_queue(status_queue)
|
||||||
|
try:
|
||||||
for i, chunk in enumerate(chunks):
|
for i, chunk in enumerate(chunks):
|
||||||
# RÉSILIENCE : un morceau qui échoue est SAUTÉ, l'import continue.
|
# RÉSILIENCE : un morceau qui échoue est SAUTÉ, l'import continue.
|
||||||
# Abandon seulement si AUCUN morceau ne passe (cf. après la boucle).
|
# Abandon seulement si AUCUN morceau ne passe (cf. après la boucle).
|
||||||
@@ -216,10 +353,14 @@ class ImportRulesUseCase:
|
|||||||
try:
|
try:
|
||||||
sections: dict[str, str] | None = None
|
sections: dict[str, str] | None = None
|
||||||
async for kind, payload in with_heartbeat(
|
async for kind, payload in with_heartbeat(
|
||||||
self._map_chunk(chunk, index=i, total=total)
|
self._map_chunk(chunk, index=i, total=total),
|
||||||
|
status_queue=status_queue,
|
||||||
):
|
):
|
||||||
if kind == "heartbeat":
|
if kind == "heartbeat":
|
||||||
yield {"type": "heartbeat", "current": i + 1, "total": total}
|
yield {"type": "heartbeat", "current": i + 1, "total": total}
|
||||||
|
elif kind == "status":
|
||||||
|
yield {"type": "status", "message": payload,
|
||||||
|
"current": i + 1, "total": total}
|
||||||
else:
|
else:
|
||||||
sections = payload
|
sections = payload
|
||||||
new_titles = merger.add(sections or {})
|
new_titles = merger.add(sections or {})
|
||||||
@@ -236,6 +377,8 @@ class ImportRulesUseCase:
|
|||||||
"new_sections": new_titles,
|
"new_sections": new_titles,
|
||||||
"skipped": skipped,
|
"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",
|
||||||
@@ -243,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,
|
||||||
@@ -262,8 +417,10 @@ 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"
|
||||||
@@ -271,7 +428,7 @@ class ImportRulesUseCase:
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
raw = await generate_with_retry(
|
raw = await generate_with_retry(
|
||||||
self._llm, prompt, output_format="json", temperature=_TEMPERATURE)
|
self._llm, prompt, output_format=schema, temperature=_TEMPERATURE)
|
||||||
except LLMGenerationTimeout:
|
except LLMGenerationTimeout:
|
||||||
# Le modèle générait mais trop lentement pour réécrire tout le morceau
|
# 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).
|
# dans le temps imparti (fréquent sur tier gratuit + gros morceaux).
|
||||||
@@ -284,9 +441,15 @@ class ImportRulesUseCase:
|
|||||||
logger.info(
|
logger.info(
|
||||||
"Morceau %s : timeout de génération → re-découpage en 2 moitiés (niveau %s).",
|
"Morceau %s : timeout de génération → re-découpage en 2 moitiés (niveau %s).",
|
||||||
index, depth + 1)
|
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)
|
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)
|
||||||
|
if self._segment_only:
|
||||||
|
sections, truncated = self._parse_anchors(raw, text, index=index)
|
||||||
|
else:
|
||||||
sections, truncated = self._parse_sections(raw, index=index)
|
sections, truncated = self._parse_sections(raw, index=index)
|
||||||
|
|
||||||
if truncated and depth < _MAX_SPLIT_DEPTH:
|
if truncated and depth < _MAX_SPLIT_DEPTH:
|
||||||
@@ -295,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)
|
||||||
@@ -303,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."""
|
||||||
@@ -320,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): _coerce_markdown(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
|
||||||
|
|||||||
39
brain/app/application/import_status.py
Normal file
39
brain/app/application/import_status.py
Normal 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)
|
||||||
@@ -57,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:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
from app.application.import_status import notify_status
|
||||||
from app.domain.ports import LLMGenerationTimeout, LLMProvider, LLMProviderError
|
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).
|
||||||
@@ -103,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
|
||||||
|
|||||||
@@ -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":"..."}}]}}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 :
|
||||||
|
|||||||
@@ -131,8 +131,10 @@ 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) :
|
# Mode JSON natif (supporté par l'endpoint OpenAI-compatible de Gemini) :
|
||||||
# supprime fences ```json et JSON invalide, principale cause de morceaux ignorés.
|
# supprime fences ```json et JSON invalide, principale cause de morceaux
|
||||||
if output_format == "json":
|
# 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"}
|
body["response_format"] = {"type": "json_object"}
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||||
@@ -142,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 "")
|
||||||
|
|||||||
@@ -138,8 +138,10 @@ class MistralLLMProvider:
|
|||||||
body["temperature"] = temperature
|
body["temperature"] = temperature
|
||||||
# Mode JSON natif : TOUS les modèles Mistral le supportent → plus de fences
|
# 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),
|
# ```json ni de JSON invalide (retours à la ligne bruts dans les chaînes),
|
||||||
# principale cause de morceaux d'import ignorés.
|
# principale cause de morceaux d'import ignorés. Un SCHÉMA (dict) est
|
||||||
if output_format == "json":
|
# 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"}
|
body["response_format"] = {"type": "json_object"}
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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
|
||||||
@@ -13,6 +14,8 @@ from app.core.config import Settings
|
|||||||
from app.domain.models import ChatMessage
|
from app.domain.models import ChatMessage
|
||||||
from app.domain.ports import LLMGenerationTimeout, LLMProviderError
|
from app.domain.ports import LLMGenerationTimeout, LLMProviderError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class OllamaLLMProvider:
|
class OllamaLLMProvider:
|
||||||
"""Implémentation des ports LLM — appelle un serveur Ollama via HTTP.
|
"""Implémentation des ports LLM — appelle un serveur Ollama via HTTP.
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -92,7 +99,24 @@ class OllamaLLMProvider:
|
|||||||
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,
|
||||||
|
|||||||
@@ -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.1-beta",
|
version="0.13.0-beta",
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<groupId>com.loremind</groupId>
|
<groupId>com.loremind</groupId>
|
||||||
<artifactId>loremind-core</artifactId>
|
<artifactId>loremind-core</artifactId>
|
||||||
<version>0.12.1-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>
|
||||||
|
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -65,10 +65,11 @@ public class CampaignImportService {
|
|||||||
String filename,
|
String filename,
|
||||||
Consumer<CampaignImportProgress> onProgress,
|
Consumer<CampaignImportProgress> onProgress,
|
||||||
Runnable onHeartbeat,
|
Runnable onHeartbeat,
|
||||||
|
Consumer<String> onStatus,
|
||||||
Consumer<CampaignImportProposal> onDone,
|
Consumer<CampaignImportProposal> onDone,
|
||||||
Consumer<Throwable> onError) {
|
Consumer<Throwable> onError) {
|
||||||
campaignPdfImporter.importCampaignStreaming(
|
campaignPdfImporter.importCampaignStreaming(
|
||||||
pdfBytes, filename, onProgress, onHeartbeat, onDone, onError);
|
pdfBytes, filename, onProgress, onHeartbeat, onStatus, onDone, onError);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -163,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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -40,10 +40,11 @@ public class GameSystemService {
|
|||||||
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,
|
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(
|
rulesPdfImporter.importRulesStreaming(
|
||||||
pdfBytes, filename, onProgress, onHeartbeat, onDone, onError);
|
pdfBytes, filename, onProgress, onHeartbeat, onStatus, onDone, onError);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,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
|
||||||
) {}
|
) {}
|
||||||
@@ -70,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ public interface CampaignPdfImporter {
|
|||||||
* @param onHeartbeat invoqué périodiquement pendant un appel LLM long (aucune
|
* @param onHeartbeat invoqué périodiquement pendant un appel LLM long (aucune
|
||||||
* avancée à afficher, mais le canal SSE vers le navigateur
|
* avancée à afficher, mais le canal SSE vers le navigateur
|
||||||
* doit rester actif — sinon un proxy intermédiaire le coupe).
|
* 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 onDone invoqué une fois avec l'arbre proposé (non persisté).
|
||||||
* @param onError invoqué si l'extraction/structuration échoue.
|
* @param onError invoqué si l'extraction/structuration échoue.
|
||||||
*/
|
*/
|
||||||
@@ -27,6 +31,7 @@ public interface CampaignPdfImporter {
|
|||||||
String filename,
|
String filename,
|
||||||
Consumer<CampaignImportProgress> onProgress,
|
Consumer<CampaignImportProgress> onProgress,
|
||||||
Runnable onHeartbeat,
|
Runnable onHeartbeat,
|
||||||
|
Consumer<String> onStatus,
|
||||||
Consumer<CampaignImportProposal> onDone,
|
Consumer<CampaignImportProposal> onDone,
|
||||||
Consumer<Throwable> onError);
|
Consumer<Throwable> onError);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ public interface RulesPdfImporter {
|
|||||||
* @param onHeartbeat invoqué périodiquement pendant un appel LLM long (aucune
|
* @param onHeartbeat invoqué périodiquement pendant un appel LLM long (aucune
|
||||||
* avancée à afficher, mais le canal SSE vers le navigateur
|
* avancée à afficher, mais le canal SSE vers le navigateur
|
||||||
* doit rester actif — sinon un proxy intermédiaire le coupe).
|
* 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 onDone invoqué une fois avec le résultat final.
|
||||||
* @param onError invoqué si l'extraction/structuration échoue.
|
* @param onError invoqué si l'extraction/structuration échoue.
|
||||||
*/
|
*/
|
||||||
@@ -38,6 +42,7 @@ public interface RulesPdfImporter {
|
|||||||
String filename,
|
String filename,
|
||||||
Consumer<RulesImportProgress> onProgress,
|
Consumer<RulesImportProgress> onProgress,
|
||||||
Runnable onHeartbeat,
|
Runnable onHeartbeat,
|
||||||
|
Consumer<String> onStatus,
|
||||||
Consumer<RulesImportResult> onDone,
|
Consumer<RulesImportResult> onDone,
|
||||||
Consumer<Throwable> onError);
|
Consumer<Throwable> onError);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
|||||||
String filename,
|
String filename,
|
||||||
Consumer<CampaignImportProgress> onProgress,
|
Consumer<CampaignImportProgress> onProgress,
|
||||||
Runnable onHeartbeat,
|
Runnable onHeartbeat,
|
||||||
|
Consumer<String> onStatus,
|
||||||
Consumer<CampaignImportProposal> onDone,
|
Consumer<CampaignImportProposal> onDone,
|
||||||
Consumer<Throwable> onError) {
|
Consumer<Throwable> onError) {
|
||||||
|
|
||||||
@@ -85,7 +86,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
|||||||
.timeout(Duration.ofSeconds(importTimeoutSeconds))
|
.timeout(Duration.ofSeconds(importTimeoutSeconds))
|
||||||
.doOnNext(sse -> handleEvent(
|
.doOnNext(sse -> handleEvent(
|
||||||
sse, pageCount, ocrPageCount, terminated,
|
sse, pageCount, ocrPageCount, terminated,
|
||||||
onProgress, onHeartbeat, onDone, onError))
|
onProgress, onHeartbeat, onStatus, onDone, onError))
|
||||||
.blockLast();
|
.blockLast();
|
||||||
if (!terminated[0]) {
|
if (!terminated[0]) {
|
||||||
onError.accept(new CampaignImportException(
|
onError.accept(new CampaignImportException(
|
||||||
@@ -110,6 +111,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
|||||||
boolean[] terminated,
|
boolean[] terminated,
|
||||||
Consumer<CampaignImportProgress> onProgress,
|
Consumer<CampaignImportProgress> onProgress,
|
||||||
Runnable onHeartbeat,
|
Runnable onHeartbeat,
|
||||||
|
Consumer<String> onStatus,
|
||||||
Consumer<CampaignImportProposal> onDone,
|
Consumer<CampaignImportProposal> onDone,
|
||||||
Consumer<Throwable> onError) {
|
Consumer<Throwable> onError) {
|
||||||
|
|
||||||
@@ -122,6 +124,22 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
|||||||
onHeartbeat.run();
|
onHeartbeat.run();
|
||||||
return;
|
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(
|
||||||
|
|||||||
@@ -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) -> {
|
||||||
|
// Vocabulaire UI : les chapitres d'un arc HUB sont des « quêtes ».
|
||||||
|
if (arc.hub()) {
|
||||||
|
map.put("arc_type", "HUB");
|
||||||
|
}
|
||||||
|
map.put("chapters", arc.chapters().stream()
|
||||||
.map(this::chapterSummaryToMap)
|
.map(this::chapterSummaryToMap)
|
||||||
.collect(Collectors.toList())));
|
.collect(Collectors.toList()));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> chapterSummaryToMap(ChapterSummary c) {
|
private Map<String, Object> chapterSummaryToMap(ChapterSummary c) {
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
|||||||
String filename,
|
String filename,
|
||||||
Consumer<RulesImportProgress> onProgress,
|
Consumer<RulesImportProgress> onProgress,
|
||||||
Runnable onHeartbeat,
|
Runnable onHeartbeat,
|
||||||
|
Consumer<String> onStatus,
|
||||||
Consumer<RulesImportResult> onDone,
|
Consumer<RulesImportResult> onDone,
|
||||||
Consumer<Throwable> onError) {
|
Consumer<Throwable> onError) {
|
||||||
|
|
||||||
@@ -141,7 +142,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
|||||||
.timeout(Duration.ofSeconds(importTimeoutSeconds))
|
.timeout(Duration.ofSeconds(importTimeoutSeconds))
|
||||||
.doOnNext(sse -> handleEvent(
|
.doOnNext(sse -> handleEvent(
|
||||||
sse, pageCount, ocrPageCount, terminated,
|
sse, pageCount, ocrPageCount, terminated,
|
||||||
onProgress, onHeartbeat, onDone, onError))
|
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]) {
|
||||||
@@ -168,6 +169,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
|||||||
boolean[] terminated,
|
boolean[] terminated,
|
||||||
Consumer<RulesImportProgress> onProgress,
|
Consumer<RulesImportProgress> onProgress,
|
||||||
Runnable onHeartbeat,
|
Runnable onHeartbeat,
|
||||||
|
Consumer<String> onStatus,
|
||||||
Consumer<RulesImportResult> onDone,
|
Consumer<RulesImportResult> onDone,
|
||||||
Consumer<Throwable> onError) {
|
Consumer<Throwable> onError) {
|
||||||
|
|
||||||
@@ -181,6 +183,22 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
|||||||
onHeartbeat.run();
|
onHeartbeat.run();
|
||||||
return;
|
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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<>();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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<>())
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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<>())
|
||||||
|
|||||||
@@ -31,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;
|
||||||
@@ -66,7 +71,15 @@ public class CampaignImportController {
|
|||||||
// amont (ClientGoneException remonte dans le doOnNext du WebClient →
|
// amont (ClientGoneException remonte dans le doOnNext du WebClient →
|
||||||
// annule la souscription → le Brain voit la coupure et stoppe le LLM).
|
// annule la souscription → le Brain voit la coupure et stoppe le LLM).
|
||||||
AtomicBoolean clientGone = new AtomicBoolean(false);
|
AtomicBoolean clientGone = new AtomicBoolean(false);
|
||||||
emitter.onTimeout(() -> clientGone.set(true));
|
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));
|
emitter.onError(e -> clientGone.set(true));
|
||||||
|
|
||||||
taskExecutor.execute(() -> {
|
taskExecutor.execute(() -> {
|
||||||
@@ -75,6 +88,8 @@ public class CampaignImportController {
|
|||||||
bytes, filename,
|
bytes, filename,
|
||||||
progress -> sendEvent(emitter, clientGone, "progress", progress),
|
progress -> sendEvent(emitter, clientGone, "progress", progress),
|
||||||
() -> sendHeartbeat(emitter, clientGone),
|
() -> sendHeartbeat(emitter, clientGone),
|
||||||
|
status -> sendEvent(emitter, clientGone, "status",
|
||||||
|
Map.of("message", status != null ? status : "")),
|
||||||
proposal -> {
|
proposal -> {
|
||||||
sendEvent(emitter, clientGone, "done", proposal);
|
sendEvent(emitter, clientGone, "done", proposal);
|
||||||
emitter.complete();
|
emitter.complete();
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
|||||||
@@ -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) {}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -148,7 +153,15 @@ public class GameSystemController {
|
|||||||
// amont (l'exception ClientGone remonte dans le doOnNext du WebClient →
|
// amont (l'exception ClientGone remonte dans le doOnNext du WebClient →
|
||||||
// annule la souscription → le Brain voit la coupure et stoppe le LLM).
|
// annule la souscription → le Brain voit la coupure et stoppe le LLM).
|
||||||
AtomicBoolean clientGone = new AtomicBoolean(false);
|
AtomicBoolean clientGone = new AtomicBoolean(false);
|
||||||
emitter.onTimeout(() -> clientGone.set(true));
|
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));
|
emitter.onError(e -> clientGone.set(true));
|
||||||
|
|
||||||
taskExecutor.execute(() -> {
|
taskExecutor.execute(() -> {
|
||||||
@@ -157,6 +170,8 @@ public class GameSystemController {
|
|||||||
bytes, filename,
|
bytes, filename,
|
||||||
progress -> sendImportEvent(emitter, clientGone, "progress", progress),
|
progress -> sendImportEvent(emitter, clientGone, "progress", progress),
|
||||||
() -> sendImportHeartbeat(emitter, clientGone),
|
() -> sendImportHeartbeat(emitter, clientGone),
|
||||||
|
status -> sendImportEvent(emitter, clientGone, "status",
|
||||||
|
Map.of("message", status != null ? status : "")),
|
||||||
result -> {
|
result -> {
|
||||||
sendImportEvent(emitter, clientGone, "done", result);
|
sendImportEvent(emitter, clientGone, "done", result);
|
||||||
emitter.complete();
|
emitter.complete();
|
||||||
@@ -248,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()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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<>();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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()))
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.12.1-beta",
|
"version": "0.13.0-beta",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.12.1-beta",
|
"version": "0.13.0-beta",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^21.2.16",
|
"@angular/animations": "^21.2.16",
|
||||||
"@angular/common": "^21.2.16",
|
"@angular/common": "^21.2.16",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.12.1-beta",
|
"version": "0.13.0-beta",
|
||||||
"description": "LoreMind Frontend - Angular",
|
"description": "LoreMind Frontend - Angular",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
|
|||||||
@@ -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) },
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { GameSystem } from '../../../services/game-system.model';
|
|||||||
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 { SessionService } from '../../../services/session.service';
|
import { SessionService } from '../../../services/session.service';
|
||||||
import { PlaythroughService } from '../../../services/playthrough.service';
|
import { PlaythroughService } from '../../../services/playthrough.service';
|
||||||
import { Playthrough } from '../../../services/campaign.model';
|
import { Playthrough } from '../../../services/campaign.model';
|
||||||
@@ -95,6 +96,7 @@ export class CampaignDetailComponent 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 sessionService: SessionService,
|
private sessionService: SessionService,
|
||||||
private playthroughService: PlaythroughService,
|
private playthroughService: PlaythroughService,
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
@@ -112,8 +114,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
switchMap(id => forkJoin({
|
switchMap(id => forkJoin({
|
||||||
campaign: this.campaignService.getCampaignById(id),
|
campaign: this.campaignService.getCampaignById(id),
|
||||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService, this.randomTableService).pipe(
|
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService, this.randomTableService, this.enemyService).pipe(
|
||||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [] } as CampaignTreeData))
|
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [], enemies: [] } as CampaignTreeData))
|
||||||
),
|
),
|
||||||
playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[])))
|
playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[])))
|
||||||
}))
|
}))
|
||||||
@@ -149,8 +151,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
forkJoin({
|
forkJoin({
|
||||||
campaign: this.campaignService.getCampaignById(id),
|
campaign: this.campaignService.getCampaignById(id),
|
||||||
allCampaigns: this.campaignService.getAllCampaigns(),
|
allCampaigns: this.campaignService.getAllCampaigns(),
|
||||||
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService, this.randomTableService).pipe(
|
treeData: loadCampaignTreeData(this.campaignService, id, this.characterService, this.npcService, this.randomTableService, this.enemyService).pipe(
|
||||||
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [] } as CampaignTreeData))
|
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, characters: [], npcs: [], randomTables: [], enemies: [] } as CampaignTreeData))
|
||||||
),
|
),
|
||||||
playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[])))
|
playthroughs: this.playthroughService.listByCampaign(id).pipe(catchError(() => of([] as Playthrough[])))
|
||||||
}).subscribe(({ campaign, allCampaigns, treeData, playthroughs }) => {
|
}).subscribe(({ campaign, allCampaigns, treeData, playthroughs }) => {
|
||||||
|
|||||||
@@ -31,6 +31,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@if (importStatus) {
|
||||||
|
<p class="import-status" role="status">{{ importStatus }}</p>
|
||||||
|
}
|
||||||
@if (importCounts) {
|
@if (importCounts) {
|
||||||
<p class="import-counts">
|
<p class="import-counts">
|
||||||
Trouvé jusqu'ici : {{ importCounts.arcs }} arc(s) · {{ importCounts.chapters }} chapitre(s) · {{ importCounts.scenes }} scène(s) · {{ importCounts.npcs }} PNJ
|
Trouvé jusqu'ici : {{ importCounts.arcs }} arc(s) · {{ importCounts.chapters }} chapitre(s) · {{ importCounts.scenes }} scène(s) · {{ importCounts.npcs }} PNJ
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user