Compare commits

...

5 Commits

Author SHA1 Message Date
9ea43a1889 Ajout d'open router en fournisseur IA ; ajout de la possibilité de mettre des conditions de déverouillage pour les chapitres optionnels
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-06-05 00:23:19 +02:00
211e26dae1 changement de version
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-06-04 14:14:19 +02:00
53065c952b Correction de la configuration nginx pour ne pas bloquer un gros PDF 2026-06-04 14:13:41 +02:00
6d00543a59 Ajout de 2 fonctionnalitées principales : import PDF que ce soit pour les règles ou les campagnes directement.
Some checks failed
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (brain) (push) Has been cancelled
Fonctionnalité de comparaison PDF / campagne pour faire un mix et demander des conseils à l'IA
2026-06-04 13:55:27 +02:00
091de0daf7 Mise à jour des tests unitaires et passage en v0.9.2-beta en conséquence.
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-06-03 15:56:26 +02:00
92 changed files with 5655 additions and 246 deletions

View File

@@ -1,7 +1,15 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl \
# curl : healthcheck docker.
# tesseract-ocr (+ langues fra/eng) : repli OCR de l'import de PDF de regles
# pour les pages sans couche texte (scans). Inutile pour les PDF born-digital
# mais necessaire pour couvrir tous les cas.
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
tesseract-ocr \
tesseract-ocr-fra \
tesseract-ocr-eng \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .

View File

@@ -0,0 +1,125 @@
"""Use case : conseils d'adaptation d'un PDF à une campagne EXISTANTE.
L'IA connaît la campagne de l'utilisateur (un « brief » : structure arcs/chapitres/
scènes + PNJ + univers/lore), lit le contenu du PDF, et rédige des recommandations
d'INTÉGRATION/ADAPTATION (où insérer, reskins de PNJ, transposition à l'univers,
doublons à réconcilier…). Sortie en markdown, streamée token par token.
Contrairement à l'IMPORT (qui produit une arborescence à créer), ici on produit
du CONSEIL libre : rien n'est créé, l'utilisateur applique à la main.
"""
from __future__ import annotations
import logging
from typing import AsyncIterator
from app.domain.models import ChatMessage
from app.domain.ports import LLMChatProvider, PdfExtractionError, PdfTextExtractor
logger = logging.getLogger(__name__)
# Plus créatif que l'import (tâche de structuration) : ici on conseille/adapte.
_TEMPERATURE = 0.7
_SYSTEM_PREFIX = (
"Tu es un assistant pour Maître de Jeu de jeu de rôle. L'utilisateur a une "
"campagne EXISTANTE (décrite plus bas) et souhaite ADAPTER et INTÉGRER le "
"contenu d'un PDF (aventure, donjon, supplément) à CETTE campagne précise."
)
_SYSTEM_SUFFIX = (
"Produis des CONSEILS D'ADAPTATION concrets, actionnables et en FRANÇAIS, "
"en markdown structuré (titres ##, listes). Couvre notamment :\n"
"- **Où l'insérer** : à quel(s) arc(s)/chapitre(s) EXISTANT(s) rattacher ce "
"contenu, dans quel ordre, et — si l'arc est un hub — sous quelles conditions de déblocage.\n"
"- **Reskins / liens PNJ** : quels PNJ EXISTANTS de la campagne peuvent incarner "
"ou remplacer les personnages clés du PDF.\n"
"- **Adaptation à l'univers** : comment transposer lieux, factions, noms propres et "
"ton vers l'univers de l'utilisateur plutôt que le cadre d'origine du PDF.\n"
"- **Doublons / conflits** : ce qui recoupe l'existant et comment le réconcilier.\n"
"- **Ajustements de ton et de difficulté**.\n\n"
"Réfère-toi TOUJOURS aux éléments existants par leur NOM. Ne réécris PAS le PDF en "
"entier : donne des recommandations. Si une information manque, propose des options."
)
class AdaptCampaignUseCase:
"""Génère (en streaming) des conseils d'adaptation d'un PDF à une campagne."""
def __init__(
self,
llm: LLMChatProvider,
extractor: PdfTextExtractor,
max_input_tokens: int = 10000,
) -> None:
self._llm = llm
self._extractor = extractor
# L'adaptation envoie le PDF en UNE requête (pas de découpage). On plafonne
# donc l'entrée pour ne pas dépasser la taille de requête acceptée par le
# provider (sinon HTTP 400). Calé sur la taille des morceaux d'import.
self._max_input_tokens = max_input_tokens
async def stream(
self,
pdf_bytes: bytes,
brief: str,
messages: list[ChatMessage],
) -> AsyncIterator[str]:
"""Conversationnel : le PDF + la campagne sont le CONTEXTE (system prompt),
`messages` est l'échange (demande initiale, puis feedbacks de l'utilisateur)."""
doc = self._extractor.extract(pdf_bytes)
pdf_text = doc.full_text
if not pdf_text.strip():
raise PdfExtractionError("Aucun texte exploitable n'a été extrait du PDF.")
brief = brief or ""
pdf_text, truncated = self._fit_pdf_to_budget(pdf_text, brief)
logger.info(
"Adaptation campagne : %s page(s) (%s via OCR), brief %s car., PDF %s car.%s, %s message(s).",
doc.page_count, doc.ocr_page_count, len(brief), len(pdf_text),
" (tronqué)" if truncated else "", len(messages),
)
trunc_note = (
"\n[Note : PDF tronqué pour tenir dans une requête — base-toi sur ce début.]"
if truncated else ""
)
# Concaténation (pas .format) : brief/PDF peuvent contenir des { } littéraux.
system_prompt = (
f"{_SYSTEM_PREFIX}\n\n"
"--- CAMPAGNE EXISTANTE DE L'UTILISATEUR ---\n"
f"{brief.strip() or '(campagne encore vide)'}\n\n"
"--- CONTENU DU PDF À ADAPTER ---\n"
f"{pdf_text}{trunc_note}\n\n"
f"{_SYSTEM_SUFFIX}\n\n"
"Tu es en CONVERSATION : à chaque message de l'utilisateur, ajuste, corrige "
"ou propose des alternatives en gardant tout ce contexte à l'esprit."
)
# 1er tour : si aucun message, on lance la demande initiale par défaut.
convo = messages or [ChatMessage(
role="user",
content="Propose-moi comment intégrer et adapter ce PDF à ma campagne.",
)]
async for token in self._llm.stream_chat(
convo, system_prompt=system_prompt, temperature=_TEMPERATURE
):
yield token
def _fit_pdf_to_budget(self, pdf_text: str, brief: str) -> tuple[str, bool]:
"""Tronque le texte du PDF pour que (brief + PDF) tienne dans le budget tokens.
Évite un HTTP 400 « requête trop grosse » côté provider. Réserve une marge
pour le prompt système et le brief.
"""
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
brief_tokens = len(enc.encode(brief))
budget = max(2000, self._max_input_tokens - brief_tokens - 1000) # 1000 = marge système
pdf_tokens = enc.encode(pdf_text)
if len(pdf_tokens) <= budget:
return pdf_text, False
return enc.decode(pdf_tokens[:budget]), True

View File

@@ -0,0 +1,55 @@
"""Découpage d'un long texte en morceaux qui tiennent dans la fenêtre LLM.
Partagé par les imports (règles, campagne) : un livre dépasse la fenêtre de
contexte, on le découpe par paragraphes jusqu'à une cible de tokens, en coupant
les paragraphes géants si besoin. Dimensionnement via tiktoken (cl100k_base),
approximation suffisante (±10% vs tokenizer natif).
"""
from __future__ import annotations
# Cible conservatrice : tient dans une fenêtre Ollama (num_ctx 16384) en laissant
# la place au prompt + à la sortie JSON. Les providers à grand contexte (1min.ai)
# le supportent largement.
CHUNK_TARGET_TOKENS = 6000
def chunk_text(full_text: str, target_tokens: int = CHUNK_TARGET_TOKENS) -> list[str]:
"""Découpe `full_text` en morceaux ~`target_tokens` tokens (frontières de §)."""
if not full_text.strip():
return []
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
paragraphs = [p for p in full_text.split("\n\n") if p.strip()]
chunks: list[str] = []
current: list[str] = []
current_tokens = 0
for para in paragraphs:
para_tokens = len(enc.encode(para))
# Un paragraphe seul plus gros que la cible : on le coupe en sous-blocs.
if para_tokens > target_tokens:
if current:
chunks.append("\n\n".join(current))
current, current_tokens = [], 0
chunks.extend(_split_oversized(para, enc, target_tokens))
continue
if current_tokens + para_tokens > target_tokens and current:
chunks.append("\n\n".join(current))
current, current_tokens = [], 0
current.append(para)
current_tokens += para_tokens
if current:
chunks.append("\n\n".join(current))
return chunks
def _split_oversized(paragraph: str, enc, target_tokens: int) -> list[str]:
"""Coupe un paragraphe géant en sous-blocs ~`target_tokens` tokens."""
tokens = enc.encode(paragraph)
out: list[str] = []
for i in range(0, len(tokens), target_tokens):
out.append(enc.decode(tokens[i : i + target_tokens]))
return out

View File

@@ -0,0 +1,312 @@
"""Use case : import d'un PDF de campagne → arbre arc → chapitre → scène.
Couche APPLICATION. Même chaîne que l'import de règles (extraction + OCR +
chunking + map-reduce) mais la cible est une ARBORESCENCE narrative :
- MAP : chaque morceau → un sous-arbre {arcs:[{chapters:[{scenes}]}]}
- REDUCE : fusion par NOM à chaque niveau (un chapitre coupé entre 2 morceaux
est recollé ; ses scènes s'accumulent).
PROPOSITION non persistée : le Core crée les entités seulement après revue.
"""
from __future__ import annotations
import logging
from app.application.chunking import chunk_text
from app.application.llm_json import load_json_object
from app.application.llm_retry import generate_with_retry
from app.domain.models import (
ArcProposal,
CampaignImportResult,
ChapterProposal,
RoomProposal,
SceneProposal,
)
from app.domain.ports import LLMProvider, PdfTextExtractor
logger = logging.getLogger(__name__)
_TEMPERATURE = 0.2
# Nom de l'arc unique quand le livre n'est pas découpé en actes/parties.
_DEFAULT_ARC_NAME = "Aventure principale"
# Morceaux PLUS GROS que pour les règles : l'IA voit une quête/un chapitre entier
# d'un coup et le structure de façon cohérente (1 scène par lieu) au lieu de le
# fragmenter en dizaines de scènes. Adapté aux providers à grand contexte (1min.ai).
_CHUNK_TARGET_TOKENS = 10000
_MAP_SYSTEM = """Tu es un assistant qui structure un livre de campagne de jeu de rôle.
On te donne un EXTRAIT brut d'un PDF de campagne (texte parfois mal coupé par la mise en page).
Ta tâche : en dégager une ARBORESCENCE narrative à GROS GRAIN : arcs → chapitres → scènes,
et — pour les lieux explorables — leurs PIÈCES (rooms).
- Un ARC = un acte / une grande partie de la campagne (souvent un seul pour une aventure courte).
- Un CHAPITRE = une étape majeure du récit : un chapitre du livre, OU — dans une
campagne "hub" / bac-à-sable — UNE QUÊTE ou UN LIEU principal débloqué depuis le
point central (ex : Dragon of Icespire Peak → chaque quête/lieu = un chapitre).
- Une SCÈNE = un temps fort jouable du chapitre : un lieu, une rencontre clé, un moment pivot.
- Une PIÈCE (room) = une salle d'un lieu explorable (donjon, crypte, manoir...).
TYPE D'ARC ("type") :
- "HUB" si la campagne est un bac-à-sable : des quêtes/lieux optionnels, parallèles,
débloqués depuis un point central, SANS ordre fixe imposé (ex : Dragon of Icespire Peak).
- "LINEAR" si les chapitres se jouent dans un ordre séquentiel imposé.
- Dans le doute : "LINEAR".
GRANULARITÉ (évite la sur-détection) :
- Vise PEU de scènes : typiquement 1 à 6 par chapitre. PAS des dizaines.
- Un LIEU EXPLORABLE (donjon, crypte, manoir, grotte à plusieurs salles) = UNE SEULE
scène. Ses salles vont dans le tableau "rooms" de cette scène — JAMAIS en scènes séparées.
- NE crée PAS une scène par rencontre isolée, par PNJ, par monstre ou par paragraphe.
- IGNORE : blocs de stats, listes de monstres, encarts de règles, légendes de cartes,
pieds de page, sommaires, crédits.
CONTENU D'UNE SCÈNE (fidélité au livre — important) :
- `description` = synopsis de la scène, 2 à 4 phrases (plus que 1 ligne, mais pas le texte intégral).
- `player_narration` = le texte d'AMBIANCE « à lire aux joueurs » (encadrés / boxed text /
« lecture à voix haute »), recopié FIDÈLEMENT s'il existe dans l'extrait. Vide sinon.
- `gm_notes` = les informations pour le MJ : secrets, développement, ce qui se passe,
conséquences, indices cachés. Vide si rien de tel.
- Ne RÉSUME pas abusivement player_narration et gm_notes : recopie le contenu utile du livre.
PIÈCES (rooms) — uniquement pour les scènes qui sont des lieux explorables :
- Une entrée par salle numérotée/nommée du donjon (ex : "1. Entrée", "2. Salle des gardes").
- `enemies` = créatures/boss de la salle (vide si aucune). `loot` = trésor/récompense (vide si aucun).
- Pour une scène narrative classique (pas un donjon), "rooms" est un tableau vide [].
Format de réponse :
- Tu réponds UNIQUEMENT par un objet JSON valide, sans markdown ni commentaire autour.
- Schéma EXACT :
{{"arcs": [{{"name": "...", "description": "...", "type": "LINEAR",
"chapters": [{{"name": "...", "description": "...", "scenes": [
{{"name": "...", "description": "...", "player_narration": "...", "gm_notes": "...",
"rooms": [{{"name": "...", "description": "...", "enemies": "...", "loot": "..."}}]}}
]}}]}}
]}}
- Utilise les VRAIS titres du livre pour les noms (pas de paraphrase).
- Si le livre n'est PAS découpé en actes/parties, regroupe tout sous un seul arc nommé "{default_arc}".
- N'invente pas de contenu : tu réorganises et recopies ce qui est présent dans l'extrait.
- Si l'extrait ne contient aucune matière narrative, renvoie {{"arcs": []}}."""
class _TreeMerger:
"""Fusionne les sous-arbres des morceaux en un seul arbre, ordre préservé.
Clés insensibles à la casse à chaque niveau (nom d'arc / chapitre / scène).
Description : la première non-vide rencontrée l'emporte (les morceaux suivants
ne l'écrasent pas).
"""
def __init__(self) -> None:
# arc_key -> {"name", "description", "chapters": {chap_key -> {...}}}
self._arcs: dict[str, dict] = {}
def add(self, arcs_json: list[dict]) -> None:
for arc in arcs_json or []:
name = str(arc.get("name", "")).strip()
if not name:
continue
a = self._arcs.setdefault(
name.lower(), {"name": name, "description": "", "type": "LINEAR", "chapters": {}})
self._fill_desc(a, arc)
# Type d'arc : HUB l'emporte si un seul morceau le signale (propriété globale
# souvent énoncée une fois, dans l'intro du livre).
if str(arc.get("type", "")).strip().upper() == "HUB":
a["type"] = "HUB"
for chap in arc.get("chapters", []) or []:
cname = str(chap.get("name", "")).strip()
if not cname:
continue
c = a["chapters"].setdefault(cname.lower(), {"name": cname, "description": "", "scenes": {}})
self._fill_desc(c, chap)
for sc in chap.get("scenes", []) or []:
sname = str(sc.get("name", "")).strip()
if not sname:
continue
s = c["scenes"].setdefault(
sname.lower(),
{"name": sname, "description": "", "player_narration": "",
"gm_notes": "", "rooms": {}})
self._fill_desc(s, sc)
self._fill_field(s, sc, "player_narration")
self._fill_field(s, sc, "gm_notes")
for rm in sc.get("rooms", []) or []:
rname = str(rm.get("name", "")).strip()
if not rname:
continue
r = s["rooms"].setdefault(
rname.lower(),
{"name": rname, "description": "", "enemies": "", "loot": ""})
self._fill_desc(r, rm)
self._fill_field(r, rm, "enemies")
self._fill_field(r, rm, "loot")
@staticmethod
def _fill_desc(node: dict, src: dict) -> None:
if not node["description"]:
node["description"] = str(src.get("description") or "").strip()
@staticmethod
def _fill_field(node: dict, src: dict, field_name: str) -> None:
if not node[field_name]:
node[field_name] = str(src.get(field_name) or "").strip()
def result(self) -> list[ArcProposal]:
arcs: list[ArcProposal] = []
for a in self._arcs.values():
chapters: list[ChapterProposal] = []
for c in a["chapters"].values():
scenes: list[SceneProposal] = []
for s in c["scenes"].values():
rooms = [
RoomProposal(r["name"], r["description"], r["enemies"], r["loot"])
for r in s["rooms"].values()
]
scenes.append(SceneProposal(
s["name"], s["description"], s["player_narration"], s["gm_notes"], rooms))
chapters.append(ChapterProposal(c["name"], c["description"], scenes))
arcs.append(ArcProposal(a["name"], a["description"], a["type"], chapters))
return arcs
def counts(self) -> tuple[int, int, int]:
arcs = len(self._arcs)
chapters = sum(len(a["chapters"]) for a in self._arcs.values())
scenes = sum(len(c["scenes"]) for a in self._arcs.values() for c in a["chapters"].values())
return arcs, chapters, scenes
class ImportCampaignUseCase:
"""Transforme un PDF de campagne en proposition d'arbre arc→chapitre→scène."""
def __init__(
self,
llm: LLMProvider,
extractor: PdfTextExtractor,
chunk_target_tokens: int = _CHUNK_TARGET_TOKENS,
) -> None:
self._llm = llm
self._extractor = extractor
self._chunk_target_tokens = chunk_target_tokens
async def execute(self, pdf_bytes: bytes) -> CampaignImportResult:
"""Variante non-streamée : traite tout puis renvoie l'arbre complet."""
doc = self._extractor.extract(pdf_bytes)
chunks = chunk_text(doc.full_text, self._chunk_target_tokens)
merger = _TreeMerger()
for i, chunk in enumerate(chunks):
merger.add(await self._map_chunk(chunk, index=i, total=len(chunks)))
return CampaignImportResult(
arcs=merger.result(),
page_count=doc.page_count,
ocr_page_count=doc.ocr_page_count,
)
async def stream(self, pdf_bytes: bytes):
"""Variante streamée : yield des évènements d'avancement.
{"type":"extracting"}, puis {"type":"start", page_count, ocr_page_count,
total}, puis un {"type":"progress", current, total, arc_count,
chapter_count, scene_count} par morceau, et enfin
{"type":"done", arcs:[...], page_count, ocr_page_count}.
"""
yield {"type": "extracting"}
doc = self._extractor.extract(pdf_bytes)
chunks = chunk_text(doc.full_text, self._chunk_target_tokens)
total = len(chunks)
logger.info(
"Import campagne (stream) : %s page(s) (%s via OCR), %s morceau(x).",
doc.page_count, doc.ocr_page_count, total,
)
yield {
"type": "start",
"page_count": doc.page_count,
"ocr_page_count": doc.ocr_page_count,
"total": total,
}
merger = _TreeMerger()
for i, chunk in enumerate(chunks):
merger.add(await self._map_chunk(chunk, index=i, total=total))
arcs, chapters, scenes = merger.counts()
yield {
"type": "progress",
"current": i + 1,
"total": total,
"arc_count": arcs,
"chapter_count": chapters,
"scene_count": scenes,
}
yield {
"type": "done",
"arcs": _serialize_arcs(merger.result()),
"page_count": doc.page_count,
"ocr_page_count": doc.ocr_page_count,
}
# --- MAP : un morceau → sous-arbre ---------------------------------------
async def _map_chunk(self, chunk: str, *, index: int, total: int) -> list[dict]:
prompt = (
_MAP_SYSTEM.format(default_arc=_DEFAULT_ARC_NAME)
+ f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{chunk}\n\n"
"Renvoie maintenant le JSON de l'arborescence."
)
raw = await generate_with_retry(
self._llm, prompt, output_format="json", temperature=_TEMPERATURE)
return self._parse_arcs(raw, index=index)
@staticmethod
def _parse_arcs(raw: str, *, index: int) -> list[dict]:
"""Parse robuste : objet JSON équilibré, ou récupération partielle si tronqué."""
parsed, recovered = load_json_object(raw)
if parsed is None:
logger.warning("Morceau %s : aucun objet JSON exploitable, ignoré.", index)
return []
if recovered:
logger.warning(
"Morceau %s : sortie tronquée — récupération des éléments complets "
"(envisagez des morceaux plus petits).", index)
if isinstance(parsed, dict):
arcs = parsed.get("arcs", [])
return arcs if isinstance(arcs, list) else []
return []
def _serialize_arcs(arcs: list[ArcProposal]) -> list[dict]:
"""Sérialise l'arbre de dataclasses en dicts JSON pour le flux SSE."""
return [
{
"name": a.name,
"description": a.description,
"type": a.arc_type,
"chapters": [
{
"name": c.name,
"description": c.description,
"scenes": [
{
"name": s.name,
"description": s.description,
"player_narration": s.player_narration,
"gm_notes": s.gm_notes,
"rooms": [
{
"name": r.name,
"description": r.description,
"enemies": r.enemies,
"loot": r.loot,
}
for r in s.rooms
],
}
for s in c.scenes
],
}
for c in a.chapters
],
}
for a in arcs
]

View File

@@ -0,0 +1,197 @@
"""Use case : import d'un PDF de règles → sections markdown structurées.
Couche APPLICATION. Orchestre :
PDF (bytes) → extraction texte (port PdfTextExtractor)
→ CHUNKING (le texte d'un livre dépasse la fenêtre de contexte)
→ MAP : chaque morceau → {titre de section → markdown}
→ REDUCE: fusion des sections de même titre entre morceaux
→ RulesImportResult (proposition, NON persistée)
Ne dépend que des abstractions du domaine (ports LLMProvider + PdfTextExtractor)
→ testable avec des fakes, et indépendant du provider concret (Ollama/1min.ai).
"""
from __future__ import annotations
import logging
from app.application.chunking import CHUNK_TARGET_TOKENS, chunk_text
from app.application.llm_json import load_json_object
from app.application.llm_retry import generate_with_retry
from app.domain.models import RulesImportResult
from app.domain.ports import LLMProvider, LLMProviderError, PdfTextExtractor
logger = logging.getLogger(__name__)
# Température basse : tâche de tri/réécriture fidèle, pas de créativité.
_TEMPERATURE = 0.2
# Taxonomie canonique suggérée au modèle pour homogénéiser les titres entre
# morceaux (sinon "Combat" / "Le combat" / "Règles de combat" se dispersent).
# Le modèle reste libre d'en créer d'autres si rien ne correspond.
_CANONICAL_SECTIONS = [
"Règles générales",
"Création de personnage",
"Caractéristiques et tests",
"Compétences",
"Combat",
"Magie et sorts",
"Équipement et objets",
"États et conditions",
"Repos et récupération",
"Progression et niveaux",
"Conseils au Maître de Jeu",
]
_MAP_SYSTEM = """Tu es un assistant qui réorganise un livre de règles de jeu de rôle.
On te donne un EXTRAIT brut d'un PDF de règles (texte parfois mal coupé par la mise en page).
Ta tâche : répartir le contenu de cet extrait dans des SECTIONS THÉMATIQUES.
Règles impératives :
- Tu réponds UNIQUEMENT par un objet JSON valide, sans markdown ni commentaire autour.
- Les CLÉS sont des titres de section (texte court). Les VALEURS sont le contenu de la règle en markdown.
- Utilise EN PRIORITÉ ces titres canoniques quand le contenu y correspond :
{canonical}
- Si un contenu ne rentre dans aucun, crée un titre clair et concis (en français).
- Reproduis FIDÈLEMENT les règles : tu peux nettoyer la coupure des lignes, recoller les mots coupés
par un tiret en fin de ligne, retirer les en-têtes/pieds de page et numéros de page parasites.
- N'INVENTE AUCUNE règle, ne résume pas abusivement : tu réorganises, tu ne réécris pas le fond.
- Ignore les pages de garde, sommaires, crédits, pages vides (renvoie {{}} si l'extrait n'a aucune règle)."""
class _SectionMerger:
"""Fusionne les sections issues des différents morceaux, ordre préservé.
Titres insensibles à la casse ("Combat" / "combat" → une seule clé). Chaque
`add()` renvoie la liste (dé-dupliquée, ordonnée) des titres touchés par ce
morceau — sert au flux de progression pour annoncer les sections trouvées.
"""
def __init__(self) -> None:
self._merged: dict[str, list[str]] = {}
self._canonical_key: dict[str, str] = {}
def add(self, sections: dict[str, str]) -> list[str]:
touched: list[str] = []
for title, content in sections.items():
title = title.strip()
content = (content or "").strip()
if not title or not content:
continue
key = title.lower()
if key not in self._canonical_key:
self._canonical_key[key] = title
self._merged[title] = []
canonical = self._canonical_key[key]
self._merged[canonical].append(content)
touched.append(canonical)
# Dé-duplication en préservant l'ordre d'apparition.
seen: set[str] = set()
return [t for t in touched if not (t in seen or seen.add(t))]
def result(self) -> dict[str, str]:
return {title: "\n\n".join(parts) for title, parts in self._merged.items()}
class ImportRulesUseCase:
"""Transforme un PDF de règles en proposition de sections markdown."""
def __init__(
self,
llm: LLMProvider,
extractor: PdfTextExtractor,
chunk_target_tokens: int = CHUNK_TARGET_TOKENS,
) -> None:
self._llm = llm
self._extractor = extractor
self._chunk_target_tokens = chunk_target_tokens
async def execute(self, pdf_bytes: bytes) -> RulesImportResult:
"""Variante non-streamée : traite tout puis renvoie le résultat complet."""
doc = self._extractor.extract(pdf_bytes)
chunks = chunk_text(doc.full_text, self._chunk_target_tokens)
logger.info(
"Import règles : %s page(s) (%s via OCR), %s morceau(x) à traiter.",
doc.page_count, doc.ocr_page_count, len(chunks),
)
merger = _SectionMerger()
for i, chunk in enumerate(chunks):
merger.add(await self._map_chunk(chunk, index=i, total=len(chunks)))
return RulesImportResult(
sections=merger.result(),
page_count=doc.page_count,
ocr_page_count=doc.ocr_page_count,
)
async def stream(self, pdf_bytes: bytes):
"""Variante streamée : yield des évènements d'avancement au fil de l'eau.
Évènements (dicts) : {"type": "extracting"}, puis
{"type": "start", page_count, ocr_page_count, total}, puis un
{"type": "progress", current, total, new_sections:[...]} par morceau,
et enfin {"type": "done", sections, page_count, ocr_page_count}.
"""
# Émis AVANT l'extraction (potentiellement lente si OCR) pour que l'UI
# affiche tout de suite "Extraction…" plutôt qu'un écran figé.
yield {"type": "extracting"}
doc = self._extractor.extract(pdf_bytes)
chunks = chunk_text(doc.full_text, self._chunk_target_tokens)
total = len(chunks)
logger.info(
"Import règles (stream) : %s page(s) (%s via OCR), %s morceau(x).",
doc.page_count, doc.ocr_page_count, total,
)
yield {
"type": "start",
"page_count": doc.page_count,
"ocr_page_count": doc.ocr_page_count,
"total": total,
}
merger = _SectionMerger()
for i, chunk in enumerate(chunks):
new_titles = merger.add(await self._map_chunk(chunk, index=i, total=total))
yield {
"type": "progress",
"current": i + 1,
"total": total,
"new_sections": new_titles,
}
yield {
"type": "done",
"sections": merger.result(),
"page_count": doc.page_count,
"ocr_page_count": doc.ocr_page_count,
}
# --- MAP : un morceau → sections -----------------------------------------
async def _map_chunk(self, chunk: str, *, index: int, total: int) -> dict[str, str]:
prompt = (
_MAP_SYSTEM.format(
canonical="\n".join(f" - {s}" for s in _CANONICAL_SECTIONS)
)
+ f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{chunk}\n\n"
"Renvoie maintenant le JSON des sections."
)
raw = await generate_with_retry(
self._llm, prompt, output_format="json", temperature=_TEMPERATURE)
return self._parse_sections(raw, index=index)
@staticmethod
def _parse_sections(raw: str, *, index: int) -> dict[str, str]:
"""Parse robuste : objet JSON équilibré, ou récupération partielle si tronqué."""
parsed, recovered = load_json_object(raw)
if parsed is None:
logger.warning("Morceau %s : aucun objet JSON exploitable, ignoré.", index)
return {}
if recovered:
logger.warning(
"Morceau %s : sortie tronquée — récupération des sections complètes "
"(envisagez des morceaux plus petits).", index)
if not isinstance(parsed, dict):
logger.warning("Morceau %s : le LLM n'a pas renvoyé un objet, ignoré.", index)
return {}
return {str(k): str(v) for k, v in parsed.items()}

View File

@@ -0,0 +1,126 @@
"""Extraction robuste d'un objet JSON depuis une réponse LLM.
Les LLM enrobent souvent leur JSON : fences markdown ```json … ```, texte
d'introduction, commentaire de fin, voire un 2e objet. Un simple
`json.loads(raw)` ou un `raw[first_brace:last_brace]` échoue dans ces cas
("Extra data", accolade parasite dans une string, etc.).
Cette fonction scanne depuis la PREMIÈRE `{` et renvoie exactement le premier
objet `{…}` ÉQUILIBRÉ, en ignorant les accolades à l'intérieur des chaînes JSON
et tout ce qui suit. Renvoie None si aucun objet complet n'est trouvé
(sortie tronquée / accolades non refermées).
"""
from __future__ import annotations
import json
def load_json_object(raw: str) -> tuple[object | None, bool]:
"""Parse un objet JSON depuis une réponse LLM, avec récupération si tronqué.
Renvoie (objet_parsé, récupéré_partiellement) :
- d'abord on tente le 1er objet complet (extract_json_object) ;
- sinon on tente une réparation du JSON tronqué (repair_truncated_json),
auquel cas le second élément vaut True.
(None, False) si rien d'exploitable.
"""
obj = extract_json_object(raw)
if obj is not None:
try:
return json.loads(obj), False
except json.JSONDecodeError:
pass
repaired = repair_truncated_json(raw)
if repaired is not None:
try:
return json.loads(repaired), True
except json.JSONDecodeError:
pass
return None, False
def extract_json_object(raw: str) -> str | None:
if not raw:
return None
text = raw.strip()
start = text.find("{")
if start == -1:
return None
depth = 0
in_string = False
escape = False
for i in range(start, len(text)):
c = text[i]
if in_string:
if escape:
escape = False
elif c == "\\":
escape = True
elif c == '"':
in_string = False
else:
if c == '"':
in_string = True
elif c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return text[start : i + 1]
return None # accolades non refermées (réponse probablement tronquée)
# Fermeture correspondante de chaque ouvrant, pour reconstituer un JSON tronqué.
_CLOSE_OF = {"{": "}", "[": "]"}
def repair_truncated_json(raw: str) -> str | None:
"""Répare un JSON COUPÉ (sortie LLM tronquée) en gardant les éléments complets.
On scanne depuis la première `{` et on retient le DERNIER point où un conteneur
(`}` ou `]`) vient de se fermer — donc juste après une sous-structure complète
(un arc / chapitre / scène / pièce / section entièrement écrit). On coupe là et
on referme les conteneurs encore ouverts. L'élément en cours d'écriture au moment
de la troncature est abandonné, mais tous les précédents sont sauvés.
Renvoie une chaîne JSON équilibrée (à valider par json.loads) ou None.
"""
if not raw:
return None
text = raw.strip()
start = text.find("{")
if start == -1:
return None
stack: list[str] = []
in_string = False
escape = False
best_cut = -1 # index (exclusif) où couper
best_closing = "" # fermetures à ajouter pour rééquilibrer
for i in range(start, len(text)):
c = text[i]
if in_string:
if escape:
escape = False
elif c == "\\":
escape = True
elif c == '"':
in_string = False
else:
if c == '"':
in_string = True
elif c in "{[":
stack.append(c)
elif c in "}]":
if stack:
stack.pop()
# Point de coupe sûr : on vient de fermer une sous-structure complète.
best_cut = i + 1
best_closing = "".join(_CLOSE_OF[b] for b in reversed(stack))
if best_cut == -1:
return None # rien de complet à sauver
head = text[start:best_cut].rstrip().rstrip(",")
return head + best_closing

View File

@@ -0,0 +1,47 @@
"""Retry avec backoff pour les appels LLM one-shot (imports).
Les imports enchaînent de nombreux appels en série ; un échec TRANSITOIRE sur un
seul morceau (503/502 surcharge serveur, 504/524 passerelle, timeout réseau) ne
doit pas faire échouer tout l'import. On réessaie quelques fois avec une attente
croissante. Après épuisement, on relaie l'erreur (problème durable : quota, panne).
Réservé aux appels `generate` (one-shot, bufferisé) : réessayer est propre, sans
risque de doublons. À NE PAS utiliser sur le streaming (re-jouerait des tokens).
"""
from __future__ import annotations
import asyncio
import logging
from app.domain.ports import LLMProvider, LLMProviderError
logger = logging.getLogger(__name__)
_ATTEMPTS = 3
_BASE_DELAY_SECONDS = 2.0
async def generate_with_retry(
llm: LLMProvider,
prompt: str,
*,
output_format: str | None = None,
temperature: float | None = None,
) -> str:
"""Comme `llm.generate`, mais réessaie les erreurs transitoires (backoff x2)."""
delay = _BASE_DELAY_SECONDS
last_error: LLMProviderError | None = None
for attempt in range(_ATTEMPTS):
try:
return await llm.generate(prompt, output_format=output_format, temperature=temperature)
except LLMProviderError as exc:
last_error = exc
if attempt < _ATTEMPTS - 1:
logger.warning(
"Appel LLM échoué (tentative %s/%s) : %s — nouvelle tentative dans %ss.",
attempt + 1, _ATTEMPTS, exc, delay,
)
await asyncio.sleep(delay)
delay *= 2
assert last_error is not None
raise last_error

View File

@@ -25,12 +25,15 @@ class Settings(BaseSettings):
extra="ignore",
)
# Provider LLM actif. "ollama" = local ; "onemin" = 1min.ai (etage 2).
llm_provider: Literal["ollama", "onemin"] = "ollama"
# Provider LLM actif. "ollama" = local ; "onemin" = 1min.ai ; "openrouter" = OpenRouter.
llm_provider: Literal["ollama", "onemin", "openrouter"] = "ollama"
ollama_base_url: str = "http://localhost:11434"
llm_model: str = "gemma4:26b"
llm_timeout_seconds: int = 120
# Timeout HTTP des appels au LLM. Les imports/adaptations PDF génèrent de gros
# blocs (surtout avec l'extraction riche) → 120s était trop court. Surchargeable
# depuis l'UI (Paramètres) si un import lourd dépasse encore.
llm_timeout_seconds: int = 300
# Fenêtre de contexte (num_ctx Ollama). Défaut Ollama = 2048, trop étroit
# dès que le Structural Context du Lore dépasse ~10 pages (b9). On monte
@@ -44,6 +47,19 @@ class Settings(BaseSettings):
onemin_api_key: str = ""
onemin_model: str = "gpt-4o-mini"
# OpenRouter (OpenAI-compatible). Cle + modele modifiables depuis l'UI.
# Defaut = routeur `openrouter/free` : choisit un modele GRATUIT (0 credit).
# Pour un modele precis gratuit : id finissant par `:free`.
openrouter_api_key: str = ""
openrouter_model: str = "openrouter/free"
# Taille cible d'un morceau (en tokens) pour l'import de PDF (regles/campagne).
# Plus c'est gros, moins il y a de morceaux => moins de fragmentation et un
# import plus rapide, MAIS il faut que ca tienne dans la fenetre du modele.
# Defaut prudent (compatible Ollama num_ctx 16384). Sur un modele a grand
# contexte (ex: GPT-5 mini, 400k), monter a ~100000 traite un livre en 1 passe.
import_chunk_tokens: int = 10000
# Secret partage entre le Core Spring et le Brain. Le Brain n'accepte une
# requete que si l'entete X-Internal-Secret correspond. Volontairement
# non-surchargeable via settings_store (securite critique, .env-only).

View File

@@ -29,6 +29,9 @@ _ALLOWED_KEYS = frozenset({
"llm_num_ctx",
"onemin_api_key",
"onemin_model",
"openrouter_api_key",
"openrouter_model",
"import_chunk_tokens",
})

View File

@@ -301,3 +301,128 @@ class SessionContext:
in_progress_quests: list[QuestSummary] = field(default_factory=list)
locked_quest_titles: list[str] = field(default_factory=list)
active_flags: list[str] = field(default_factory=list)
# ─────────────────────── Import de PDF (règles → GameSystem) ───────────────────────
@dataclass(frozen=True)
class ExtractedPage:
"""Texte extrait d'UNE page de PDF, avec la trace de la méthode utilisée.
`used_ocr=True` signale que la page n'avait pas de couche texte exploitable
(born-digital absent) et a donc été rasterisée puis passée à l'OCR. Permet
au CLI/diagnostic de dire à l'utilisateur si son PDF est "texte" ou "scan".
"""
index: int # 0-based
text: str
used_ocr: bool
@dataclass(frozen=True)
class ExtractedDocument:
"""Résultat brut de l'extraction d'un PDF : une entrée par page."""
pages: list[ExtractedPage]
@property
def page_count(self) -> int:
return len(self.pages)
@property
def ocr_page_count(self) -> int:
return sum(1 for p in self.pages if p.used_ocr)
@property
def full_text(self) -> str:
"""Concatène le texte de toutes les pages, séparées par un saut double."""
return "\n\n".join(p.text for p in self.pages if p.text.strip())
@dataclass(frozen=True)
class RulesImportResult:
"""Proposition structurée de règles : sections markdown indexées par titre.
`sections` = {titre H2 → contenu markdown}. C'est une PROPOSITION : rien
n'est persisté côté Core tant que l'utilisateur n'a pas validé/édité.
`page_count` / `ocr_page_count` remontent au diagnostic d'extraction.
"""
sections: dict[str, str]
page_count: int
ocr_page_count: int
def to_markdown(self) -> str:
"""Assemble les sections en un markdown monolithique (## titre + contenu).
Format aligné sur `GameSystem.rulesMarkdown` côté Core (découpé par H2).
"""
blocks = [f"## {title}\n\n{content.strip()}" for title, content in self.sections.items()]
return "\n\n".join(blocks).strip() + "\n"
# ─────────────────────── Import de PDF de campagne (arbre arc→chapitre→scène) ──────────────
@dataclass(frozen=True)
class RoomProposal:
"""Pièce d'un lieu explorable (donjon) proposée pour une scène."""
name: str
description: str
enemies: str = ""
loot: str = ""
@dataclass(frozen=True)
class SceneProposal:
"""Scène proposée. `rooms` non vide => donjon/lieu explorable.
On capture aussi, quand le livre les fournit, le texte d'encadré « à lire aux
joueurs » (`player_narration`) et les secrets/développement MJ (`gm_notes`).
"""
name: str
description: str
player_narration: str = ""
gm_notes: str = ""
rooms: list[RoomProposal] = field(default_factory=list)
@dataclass(frozen=True)
class ChapterProposal:
"""Chapitre proposé : nom + synopsis + ses scènes."""
name: str
description: str
scenes: list[SceneProposal] = field(default_factory=list)
@dataclass(frozen=True)
class ArcProposal:
"""Arc proposé : nom + synopsis + type (LINEAR/HUB) + ses chapitres."""
name: str
description: str
arc_type: str = "LINEAR"
chapters: list[ChapterProposal] = field(default_factory=list)
@dataclass(frozen=True)
class CampaignImportResult:
"""Proposition d'arborescence narrative extraite d'un PDF de campagne.
PROPOSITION non persistée : l'UI laisse l'utilisateur réviser/éditer l'arbre
avant la création effective des arcs/chapitres/scènes côté Core.
"""
arcs: list[ArcProposal]
page_count: int
ocr_page_count: int
def counts(self) -> tuple[int, int, int]:
"""(nb arcs, nb chapitres, nb scènes) — pour le diagnostic / la progression."""
chapters = sum(len(a.chapters) for a in self.arcs)
scenes = sum(len(c.scenes) for a in self.arcs for c in a.chapters)
return len(self.arcs), chapters, scenes

View File

@@ -7,7 +7,10 @@ En Python moderne on privilégie Protocol (PEP 544) sur ABC pour bénéficier
du duck typing structurel : toute classe qui possède les bonnes méthodes
satisfait le contrat, sans héritage explicite.
"""
from typing import AsyncIterator, Protocol
from typing import TYPE_CHECKING, AsyncIterator, Protocol
if TYPE_CHECKING:
from app.domain.models import ExtractedDocument
class LLMProvider(Protocol):
@@ -78,6 +81,32 @@ class LLMChatProvider(Protocol):
...
class PdfTextExtractor(Protocol):
"""Port sortant — extrait le texte d'un PDF (born-digital ou scan).
L'implémentation décide de sa stratégie (couche texte directe, repli OCR
page par page…). Le domaine ne connaît ni PyMuPDF ni Tesseract.
"""
def extract(self, pdf_bytes: bytes) -> "ExtractedDocument":
"""Extrait le texte du PDF fourni sous forme d'octets.
Args:
pdf_bytes: contenu binaire du fichier PDF.
Returns:
ExtractedDocument : une entrée par page (texte + flag OCR).
Raises:
PdfExtractionError: si le PDF est illisible/corrompu.
"""
...
class PdfExtractionError(Exception):
"""Erreur du domaine : un PDF n'a pas pu être lu/extrait."""
class LLMProviderError(Exception):
"""Erreur du domaine signalant qu'un LLMProvider n'a pas pu générer.

View File

@@ -14,6 +14,7 @@ avec des marqueurs de role lisibles pour le modele.
from __future__ import annotations
import json
import logging
from typing import AsyncIterator
import httpx
@@ -22,6 +23,8 @@ from app.core.config import Settings
from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError
logger = logging.getLogger(__name__)
_API_BASE = "https://api.1min.ai/api/chat-with-ai"
_PAYLOAD_TYPE = "UNIFY_CHAT_WITH_AI"
@@ -48,6 +51,18 @@ class OneMinAiLLMProvider:
"promptObject": {"prompt": prompt},
}
def _format_http_error(self, exc: httpx.HTTPError) -> str:
"""Message d'erreur lisible. Un timeout httpx a un str() vide → on le nomme."""
if isinstance(exc, httpx.TimeoutException):
return (
f"Erreur 1min.ai : délai dépassé (timeout {self._timeout}s). Le modèle a mis "
"trop de temps à répondre — typique d'un morceau d'import trop gros. "
"Réduisez « Taille des morceaux à l'import » (Paramètres → Import de PDF) "
"ou augmentez le timeout LLM."
)
detail = str(exc) or exc.__class__.__name__
return f"Erreur 1min.ai ({exc.__class__.__name__}) : {detail}"
async def generate(
self,
prompt: str,
@@ -55,18 +70,18 @@ class OneMinAiLLMProvider:
output_format: str | None = None, # 1min.ai ne supporte pas format=json
temperature: float | None = None, # idem, pas d'hyperparam expose ici
) -> str:
"""Appel one-shot : retourne la reponse complete sous forme de string."""
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
response = await client.post(
_API_BASE, headers=self._headers(), json=self._payload(prompt)
)
response.raise_for_status()
data = response.json()
except httpx.HTTPError as exc:
raise LLMProviderError(f"Erreur 1min.ai : {exc}") from exc
"""One-shot, mais via l'endpoint STREAMING (puis recollage).
return self._extract_result(data)
On NE passe PAS par l'endpoint non-streame `chat-with-ai` : sur les longues
generations (gros imports), la passerelle Cloudflare de 1min.ai coupe la
connexion au bout de ~100s et renvoie un HTTP 524. En streaming, des octets
circulent en continu => pas de 524, quelle que soit la duree. On accumule
tous les fragments pour reconstituer la reponse complete.
"""
chunks: list[str] = []
async for token in self._stream_prompt(prompt):
chunks.append(token)
return "".join(chunks)
async def stream_chat(
self,
@@ -75,17 +90,18 @@ class OneMinAiLLMProvider:
system_prompt: str | None = None,
temperature: float | None = None,
) -> AsyncIterator[str]:
"""Streame via SSE.
1min.ai expose deux evenements utiles :
- `event: content` → `data: {"content": "..."}`
- `event: done` → fin du stream
- `event: error` → erreur serveur
On yield le champ `content` au fil de l'arrivee.
"""
"""Streame une conversation : aplatit les messages puis delegue au coeur SSE."""
prompt = self._flatten_messages(messages, system_prompt)
url = f"{_API_BASE}?isStreaming=true"
async for token in self._stream_prompt(prompt):
yield token
async def _stream_prompt(self, prompt: str) -> AsyncIterator[str]:
"""Coeur du streaming SSE 1min.ai (`?isStreaming=true`) pour un prompt brut.
1min.ai expose : `event: content` → `data: {"content": "..."}`, `event: done`,
`event: error`. On yield le champ `content` au fil de l'arrivee.
"""
url = f"{_API_BASE}?isStreaming=true"
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
async with client.stream(
@@ -95,9 +111,7 @@ class OneMinAiLLMProvider:
async for token in self._parse_sse(response):
yield token
except httpx.HTTPError as exc:
raise LLMProviderError(
f"Erreur lors du streaming 1min.ai : {exc}"
) from exc
raise LLMProviderError(self._format_http_error(exc)) from exc
# --- Helpers ------------------------------------------------------------
@@ -146,12 +160,21 @@ class OneMinAiLLMProvider:
"""
record = payload.get("aiRecord") or {}
detail = record.get("aiRecordDetail") or {}
result = detail.get("resultObject") or []
if isinstance(result, list):
result = detail.get("resultObject")
if isinstance(result, list) and result:
return "".join(str(x) for x in result)
if isinstance(result, str):
if isinstance(result, str) and result:
return result
raise LLMProviderError("Reponse 1min.ai inattendue : resultObject absent.")
# Schema inattendu : on remonte un EXTRAIT du vrai payload pour diagnostiquer.
# Causes frequentes : credits/quota 1min.ai epuises, moderation, modele
# indisponible, ou reponse asynchrone (record cree mais resultat pas encore
# pret). Sans ce detail, l'erreur "resultObject absent" est aveugle.
snippet = json.dumps(payload, ensure_ascii=False)
if len(snippet) > 800:
snippet = snippet[:800] + ""
logger.warning("Reponse 1min.ai inattendue (resultObject absent) : %s", snippet)
raise LLMProviderError(f"Reponse 1min.ai inattendue (resultObject absent) : {snippet}")
@staticmethod
def _flatten_messages(

View File

@@ -0,0 +1,130 @@
"""Adapter OpenRouter — implémente les ports LLMProvider / LLMChatProvider.
OpenRouter expose l'API OpenAI standard (POST {base}/chat/completions, SSE), donc
cet adapter est en réalité un client "OpenAI-compatible". Le `generate` one-shot
passe lui aussi par le streaming (puis recollage) pour éviter les coupures de
passerelle sur les longues générations (cf. 1min.ai / Cloudflare 524).
Modèles GRATUITS : utiliser un id finissant par `:free` (ex.
`meta-llama/llama-3.3-70b-instruct:free`) ou le routeur `openrouter/free` (défaut)
qui choisit automatiquement un modèle gratuit — aucun crédit consommé.
"""
from __future__ import annotations
import json
from typing import AsyncIterator
import httpx
from app.core.config import Settings
from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError
_API_URL = "https://openrouter.ai/api/v1/chat/completions"
class OpenRouterLLMProvider:
"""Adapter OpenRouter (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider."""
def __init__(self, settings: Settings) -> None:
if not settings.openrouter_api_key:
raise LLMProviderError(
"Clé API OpenRouter manquante. Configure-la depuis l'écran Paramètres."
)
self._api_key = settings.openrouter_api_key
self._model = settings.openrouter_model
self._timeout = settings.llm_timeout_seconds
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
# Attribution facultative (classement OpenRouter) — sans impact fonctionnel.
"HTTP-Referer": "https://loremind.app",
"X-Title": "LoreMind",
}
async def generate(
self,
prompt: str,
*,
output_format: str | None = None,
temperature: float | None = None,
) -> str:
"""One-shot via streaming (puis recollage) pour robustesse sur longues sorties."""
chunks: list[str] = []
async for token in self._stream([ChatMessage(role="user", content=prompt)], None, temperature):
chunks.append(token)
return "".join(chunks)
async def stream_chat(
self,
messages: list[ChatMessage],
*,
system_prompt: str | None = None,
temperature: float | None = None,
) -> AsyncIterator[str]:
async for token in self._stream(messages, system_prompt, temperature):
yield token
async def _stream(
self,
messages: list[ChatMessage],
system_prompt: str | None,
temperature: float | None,
) -> AsyncIterator[str]:
payload_messages: list[dict[str, str]] = []
if system_prompt:
payload_messages.append({"role": "system", "content": system_prompt})
for m in messages:
payload_messages.append({"role": m.role, "content": m.content})
body: dict[str, object] = {
"model": self._model,
"messages": payload_messages,
"stream": True,
}
if temperature is not None:
body["temperature"] = temperature
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
async with client.stream(
"POST", _API_URL, headers=self._headers(), json=body
) as response:
response.raise_for_status()
async for token in self._parse_sse(response):
yield token
except httpx.HTTPError as exc:
raise LLMProviderError(self._format_http_error(exc)) from exc
@staticmethod
async def _parse_sse(response: httpx.Response) -> AsyncIterator[str]:
"""SSE OpenAI : lignes `data: {json}`, fin sur `data: [DONE]`."""
async for line in response.aiter_lines():
if not line or not line.startswith("data:"):
continue # lignes vides ou commentaires keep-alive (`: ...`)
data = line[len("data:"):].strip()
if data == "[DONE]":
return
try:
obj = json.loads(data)
except json.JSONDecodeError:
continue
choices = obj.get("choices")
if not choices:
continue
delta = choices[0].get("delta") or {}
content = delta.get("content")
if content:
yield content
def _format_http_error(self, exc: httpx.HTTPError) -> str:
"""Message lisible (timeout, quota 429, crédits 402, modèle inconnu…)."""
if isinstance(exc, httpx.TimeoutException):
return (
f"Erreur OpenRouter : délai dépassé (timeout {self._timeout}s). Le modèle a "
"mis trop de temps — réduis la taille des morceaux d'import ou augmente le timeout."
)
detail = str(exc) or exc.__class__.__name__
return f"Erreur OpenRouter ({exc.__class__.__name__}) : {detail}"

View File

@@ -0,0 +1,102 @@
"""Adapter d'extraction de texte PDF — implémente le port PdfTextExtractor.
Stratégie HYBRIDE auto :
1. On tente d'abord l'extraction de la couche texte (PyMuPDF). Les PDF
"born-digital" (livres de règles officiels type Nimble, faits dans
InDesign/Affinity : très graphiques mais avec une vraie couche texte)
passent par là → rapide, fidèle, AUCUN OCR.
2. Si une page ne rend (quasi) aucun texte → c'est probablement une image
(scan ou page 100% illustrée). On rasterise la page et on la passe à
Tesseract (OCR). Gère donc aussi les scans purs et les PDF mixtes.
Tesseract est un binaire SYSTÈME (installé dans l'image Docker du Brain). S'il
est absent (ex: exécution locale Windows sans install), l'OCR est désactivé
proprement : les pages-images ressortent vides mais l'extraction ne plante pas,
et le diagnostic le signale (used_ocr reste False, texte vide).
"""
from __future__ import annotations
import logging
import pymupdf as fitz # PyMuPDF — on importe par le nom canonique `pymupdf`
# (et NON `import fitz`) pour éviter la collision avec le faux paquet PyPI "fitz"
# qui échoue sur `from frontend import *`.
from app.domain.models import ExtractedDocument, ExtractedPage
from app.domain.ports import PdfExtractionError
logger = logging.getLogger(__name__)
# En dessous de ce nombre de caractères "significatifs" sur une page, on
# considère qu'il n'y a pas de couche texte exploitable → repli OCR.
_MIN_TEXT_CHARS = 20
# DPI de rasterisation avant OCR. 300 = bon compromis qualité/vitesse pour du
# texte de livre. Plus haut = plus lent et plus gourmand en mémoire.
_OCR_DPI = 300
# Langues Tesseract : français + anglais (la plupart des règles de JDR FR ont
# des termes anglais résiduels). Doivent être installées dans l'image Docker
# (tesseract-ocr-fra, tesseract-ocr-eng).
_OCR_LANGS = "fra+eng"
class PyMuPdfTextExtractor:
"""Extracteur PDF basé sur PyMuPDF, avec repli OCR Tesseract optionnel."""
def __init__(self) -> None:
# On détecte la disponibilité de l'OCR une seule fois (le binaire
# Tesseract ne va pas apparaître/disparaître en cours d'exécution).
self._ocr_available = self._detect_ocr()
@staticmethod
def _detect_ocr() -> bool:
"""True si pytesseract + le binaire Tesseract sont disponibles."""
try:
import pytesseract
pytesseract.get_tesseract_version()
return True
except Exception as exc: # ImportError, TesseractNotFoundError, etc.
logger.warning(
"OCR indisponible (Tesseract non installé ?) : %s. "
"Les pages sans couche texte ressortiront vides.",
exc,
)
return False
def extract(self, pdf_bytes: bytes) -> ExtractedDocument:
try:
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
except Exception as exc:
raise PdfExtractionError(f"PDF illisible ou corrompu : {exc}") from exc
pages: list[ExtractedPage] = []
try:
for index, page in enumerate(doc):
text = (page.get_text() or "").strip()
used_ocr = False
if len(text) < _MIN_TEXT_CHARS and self._ocr_available:
ocr_text = self._ocr_page(page)
if ocr_text.strip():
text = ocr_text.strip()
used_ocr = True
pages.append(ExtractedPage(index=index, text=text, used_ocr=used_ocr))
finally:
doc.close()
return ExtractedDocument(pages=pages)
@staticmethod
def _ocr_page(page: "fitz.Page") -> str:
"""Rasterise une page et lui applique l'OCR Tesseract."""
import pytesseract
from PIL import Image
pix = page.get_pixmap(dpi=_OCR_DPI)
img = Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
try:
return pytesseract.image_to_string(img, lang=_OCR_LANGS)
except Exception as exc:
logger.warning("Échec OCR sur la page %s : %s", page.number, exc)
return ""

View File

@@ -10,12 +10,15 @@ from typing import Annotated, AsyncIterator, Literal
import hmac
import httpx
import tiktoken
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field
from app.application.adapt_campaign import AdaptCampaignUseCase
from app.application.chat import ChatUseCase
from app.application.generate_page import GeneratePageUseCase
from app.application.import_campaign import ImportCampaignUseCase
from app.application.import_rules import ImportRulesUseCase
from app.core.config import Settings, get_settings
from app.core.settings_store import save_overrides
from app.domain.models import (
@@ -39,14 +42,16 @@ from app.domain.models import (
SceneSummary,
SessionContext,
)
from app.domain.ports import LLMProvider, LLMProviderError
from app.domain.ports import LLMProvider, LLMProviderError, PdfExtractionError
from app.infrastructure.ollama_adapter import OllamaLLMProvider
from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
from app.infrastructure.openrouter_adapter import OpenRouterLLMProvider
from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor
app = FastAPI(
title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.",
version="0.9.1-beta",
version="0.10.2-beta",
)
@@ -350,6 +355,8 @@ def get_llm_provider(
try:
if settings.llm_provider == "onemin":
return OneMinAiLLMProvider(settings)
if settings.llm_provider == "openrouter":
return OpenRouterLLMProvider(settings)
return OllamaLLMProvider(settings)
except LLMProviderError as exc:
# Ex : cle 1min.ai manquante. On renvoie du 400 plutot que du 500
@@ -375,6 +382,40 @@ def get_chat_use_case(
return ChatUseCase(llm=llm) # type: ignore[arg-type]
# 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.
_PDF_EXTRACTOR = PyMuPdfTextExtractor()
def get_import_rules_use_case(
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
settings: Annotated[Settings, Depends(get_settings)],
) -> ImportRulesUseCase:
"""Factory du use case d'import de règles PDF (extraction + structuration)."""
return ImportRulesUseCase(
llm=llm, extractor=_PDF_EXTRACTOR, chunk_target_tokens=settings.import_chunk_tokens)
def get_import_campaign_use_case(
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
settings: Annotated[Settings, Depends(get_settings)],
) -> ImportCampaignUseCase:
"""Factory du use case d'import de campagne PDF (extraction + arborescence)."""
return ImportCampaignUseCase(
llm=llm, extractor=_PDF_EXTRACTOR, chunk_target_tokens=settings.import_chunk_tokens)
def get_adapt_campaign_use_case(
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
settings: Annotated[Settings, Depends(get_settings)],
) -> AdaptCampaignUseCase:
"""Factory du use case d'adaptation d'un PDF à une campagne (conseils streamés)."""
# L'adapter satisfait aussi LLMChatProvider (stream_chat) par duck typing.
# Budget d'entrée = taille de morceau configurée (qui passe déjà côté provider).
return AdaptCampaignUseCase( # type: ignore[arg-type]
llm=llm, extractor=_PDF_EXTRACTOR, max_input_tokens=settings.import_chunk_tokens)
# --- Endpoints ---
@@ -428,6 +469,177 @@ async def generate_page(
return GeneratePageResponseDTO(values=result.values)
class RulesImportResponseDTO(BaseModel):
"""Proposition de sections de règles extraites d'un PDF.
`sections` = {titre → contenu markdown}. C'est une PROPOSITION : le Core
et l'UI laissent l'utilisateur réviser/éditer avant toute persistance.
`ocr_page_count` permet d'indiquer si le PDF était un scan (OCR utilisé).
"""
sections: dict[str, str]
page_count: int
ocr_page_count: int
# Garde-fou taille : un livre de règles dépasse rarement quelques dizaines de Mo.
# Au-delà, on refuse (probable erreur d'upload) plutôt que d'OOM le conteneur.
_MAX_PDF_BYTES = 60 * 1024 * 1024 # 60 Mo
@app.post("/import/rules", response_model=RulesImportResponseDTO)
async def import_rules(
use_case: Annotated[ImportRulesUseCase, Depends(get_import_rules_use_case)],
file: UploadFile = File(...),
) -> RulesImportResponseDTO:
"""Import d'un PDF de règles → sections markdown structurées (proposition).
Extrait le texte (couche texte + repli OCR par page pour les scans), découpe,
et demande au LLM de répartir les règles en sections thématiques. Ne persiste
rien : renvoie la proposition au Core, qui la présente pour révision.
"""
content = await file.read()
if not content:
raise HTTPException(status_code=422, detail="Fichier PDF vide.")
if len(content) > _MAX_PDF_BYTES:
raise HTTPException(
status_code=413,
detail=f"PDF trop volumineux (> {_MAX_PDF_BYTES // (1024 * 1024)} Mo).",
)
try:
result = await use_case.execute(content)
except PdfExtractionError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except LLMProviderError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return RulesImportResponseDTO(
sections=result.sections,
page_count=result.page_count,
ocr_page_count=result.ocr_page_count,
)
@app.post("/import/rules/stream")
async def import_rules_stream(
use_case: Annotated[ImportRulesUseCase, Depends(get_import_rules_use_case)],
file: UploadFile = File(...),
) -> StreamingResponse:
"""Import streamé : émet l'avancement (SSE) puis le résultat final.
Évènements SSE :
- `event: extracting` → data: {} (extraction en cours)
- `event: start` → data: {page_count, ocr_page_count, total}
- `event: progress` → data: {current, total, new_sections:[...]}
- `event: done` → data: {sections, page_count, ocr_page_count}
- `event: error` → data: {message}
"""
content = await file.read()
def _sse(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
async def event_stream() -> AsyncIterator[str]:
if not content:
yield _sse("error", {"message": "Fichier PDF vide."})
return
if len(content) > _MAX_PDF_BYTES:
yield _sse("error", {"message": f"PDF trop volumineux (> {_MAX_PDF_BYTES // (1024 * 1024)} Mo)."})
return
try:
async for ev in use_case.stream(content):
event_type = ev.pop("type")
yield _sse(event_type, ev)
except PdfExtractionError as exc:
yield _sse("error", {"message": str(exc)})
except LLMProviderError as exc:
yield _sse("error", {"message": str(exc)})
return StreamingResponse(event_stream(), media_type="text/event-stream")
@app.post("/import/campaign/stream")
async def import_campaign_stream(
use_case: Annotated[ImportCampaignUseCase, Depends(get_import_campaign_use_case)],
file: UploadFile = File(...),
) -> StreamingResponse:
"""Import streamé d'un PDF de campagne → arbre arc→chapitre→scène (SSE).
Évènements : `extracting`, `start` {page_count, ocr_page_count, total},
`progress` {current, total, arc_count, chapter_count, scene_count},
`done` {arcs:[...], page_count, ocr_page_count}, `error` {message}.
"""
content = await file.read()
def _sse(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
async def event_stream() -> AsyncIterator[str]:
if not content:
yield _sse("error", {"message": "Fichier PDF vide."})
return
if len(content) > _MAX_PDF_BYTES:
yield _sse("error", {"message": f"PDF trop volumineux (> {_MAX_PDF_BYTES // (1024 * 1024)} Mo)."})
return
try:
async for ev in use_case.stream(content):
event_type = ev.pop("type")
yield _sse(event_type, ev)
except PdfExtractionError as exc:
yield _sse("error", {"message": str(exc)})
except LLMProviderError as exc:
yield _sse("error", {"message": str(exc)})
return StreamingResponse(event_stream(), media_type="text/event-stream")
@app.post("/adapt/campaign/stream")
async def adapt_campaign_stream(
use_case: Annotated[AdaptCampaignUseCase, Depends(get_adapt_campaign_use_case)],
file: UploadFile = File(...),
brief: str = Form(""),
messages: str = Form("[]"),
) -> StreamingResponse:
"""Adaptation CONVERSATIONNELLE d'un PDF à une campagne (SSE markdown).
`brief` = description de la campagne (Core). `messages` = JSON de l'échange
([{role, content}, …]) ; vide au 1er tour. Évènements : `token`, `done`, `error`.
"""
content = await file.read()
try:
raw_messages = json.loads(messages) if messages else []
except json.JSONDecodeError:
raw_messages = []
convo = [
ChatMessage(role=str(m.get("role", "user")), content=str(m.get("content", "")))
for m in raw_messages
if isinstance(m, dict) and str(m.get("content", "")).strip()
]
def _sse(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
async def event_stream() -> AsyncIterator[str]:
if not content:
yield _sse("error", {"message": "Fichier PDF vide."})
return
if len(content) > _MAX_PDF_BYTES:
yield _sse("error", {"message": f"PDF trop volumineux (> {_MAX_PDF_BYTES // (1024 * 1024)} Mo)."})
return
try:
async for token in use_case.stream(content, brief, convo):
yield _sse("token", {"token": token})
yield _sse("done", {})
except PdfExtractionError as exc:
yield _sse("error", {"message": str(exc)})
except LLMProviderError as exc:
yield _sse("error", {"message": str(exc)})
return StreamingResponse(event_stream(), media_type="text/event-stream")
@app.post("/chat/stream")
async def chat_stream(
body: ChatStreamRequestDTO,
@@ -479,7 +691,9 @@ async def chat_stream(
"system": _count_tokens(system_prompt_preview),
"history": sum(_count_tokens(m.content) for m in history_msgs),
"current": _count_tokens(current_msg.content) if current_msg else 0,
"max": settings.llm_num_ctx,
# Plafond connu seulement pour Ollama (num_ctx). Pour le cloud (1min/OpenRouter)
# on ne connaît pas la fenêtre réelle → 0 = "pas de max" (jauge sans dénominateur).
"max": settings.llm_num_ctx if settings.llm_provider == "ollama" else 0,
}
async def event_stream() -> AsyncIterator[str]:
@@ -676,27 +890,38 @@ class SettingsDTO(BaseModel):
Les secrets (onemin_api_key) sont masques en lecture.
"""
llm_provider: Literal["ollama", "onemin"]
llm_provider: Literal["ollama", "onemin", "openrouter"]
ollama_base_url: str
llm_model: str
onemin_model: str
# True si une cle 1min.ai est deja configuree — pas de leak de la cle elle-meme.
onemin_api_key_set: bool
openrouter_model: str
# True si une cle OpenRouter est deja configuree (cle elle-meme jamais renvoyee).
openrouter_api_key_set: bool
# Fenetre de contexte effective passee au modele (num_ctx Ollama) — sert
# aussi de plafond a la jauge de contexte UI.
llm_num_ctx: int
# Taille cible d'un morceau (tokens) pour l'import de PDF (regles/campagne).
import_chunk_tokens: int
# Timeout HTTP des appels LLM (s). A monter si les imports lourds expirent.
llm_timeout_seconds: int
class SettingsUpdateDTO(BaseModel):
"""Patch partiel des settings. Tous les champs sont optionnels."""
llm_provider: Literal["ollama", "onemin"] | None = None
llm_provider: Literal["ollama", "onemin", "openrouter"] | None = None
ollama_base_url: str | None = None
llm_model: str | None = None
onemin_model: str | None = None
# Chaine vide => on efface la cle. None => pas de changement.
onemin_api_key: str | None = None
openrouter_model: str | None = None
openrouter_api_key: str | None = None
llm_num_ctx: int | None = None
import_chunk_tokens: int | None = None
llm_timeout_seconds: int | None = None
def _to_settings_dto(s: Settings) -> SettingsDTO:
@@ -706,7 +931,11 @@ def _to_settings_dto(s: Settings) -> SettingsDTO:
llm_model=s.llm_model,
onemin_model=s.onemin_model,
onemin_api_key_set=bool(s.onemin_api_key),
openrouter_model=s.openrouter_model,
openrouter_api_key_set=bool(s.openrouter_api_key),
llm_num_ctx=s.llm_num_ctx,
import_chunk_tokens=s.import_chunk_tokens,
llm_timeout_seconds=s.llm_timeout_seconds,
)
@@ -864,6 +1093,51 @@ async def delete_ollama_model(
return {"status": "deleted", "name": name}
@app.get("/models/openrouter")
async def list_openrouter_models() -> dict[str, list[dict[str, object]]]:
"""Catalogue DYNAMIQUE des modeles OpenRouter (API publique, sans cle).
Renvoie {models: [{id, name, context_length, free}]}, trie gratuits d'abord
puis contexte decroissant. `free` = id finissant par ':free' OU prix nul.
"""
try:
async with httpx.AsyncClient(timeout=20) as client:
response = await client.get("https://openrouter.ai/api/v1/models")
response.raise_for_status()
data = response.json()
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"OpenRouter injoignable : {exc}")
def _is_zero(value: object) -> bool:
try:
return float(value) == 0.0 # type: ignore[arg-type]
except (TypeError, ValueError):
return False
models: list[dict[str, object]] = []
for m in data.get("data", []) or []:
mid = str(m.get("id") or "")
if not mid:
continue
pricing = m.get("pricing") or {}
is_free = mid.endswith(":free") or (
_is_zero(pricing.get("prompt")) and _is_zero(pricing.get("completion"))
)
try:
ctx = int(m.get("context_length") or 0)
except (TypeError, ValueError):
ctx = 0
models.append({
"id": mid,
"name": str(m.get("name") or mid),
"context_length": ctx,
"free": is_free,
})
models.sort(key=lambda x: (not x["free"], -int(x["context_length"]))) # type: ignore[index]
return {"models": models}
@app.get("/models/onemin")
def list_onemin_models() -> dict[str, list[dict[str, object]]]:
"""Catalogue statique des modeles 1min.ai, groupes par fournisseur.

View File

@@ -2,6 +2,8 @@ fastapi==0.115.*
uvicorn[standard]==0.32.*
httpx==0.27.*
pydantic-settings==2.6.*
# Requis par FastAPI pour les uploads multipart (UploadFile) — import de PDF.
python-multipart==0.0.*
pydantic
@@ -10,3 +12,12 @@ pydantic
# la plupart des modeles Llama/Gemma/Mistral (~5-10% d'ecart) — suffisant
# pour une jauge visuelle.
tiktoken==0.8.*
# Import de PDF de regles (-> GameSystem). Extraction de la couche texte
# (born-digital) avec repli OCR par page pour les scans.
# - pymupdf : extraction texte + rasterisation des pages (pas besoin de poppler)
# - pytesseract + Pillow : OCR Tesseract sur les pages sans couche texte
# (le binaire tesseract-ocr est installe dans le Dockerfile, langues fra+eng)
pymupdf==1.24.*
pytesseract==0.3.*
Pillow==11.*

View File

@@ -0,0 +1,107 @@
"""CLI de test pour l'import de règles PDF — boucle de feedback rapide.
But : tester l'extraction + la structuration en sections sur un VRAI PDF
(ex: livre Nimble), SANS passer par HTTP, le Core ou l'UI.
Usage (depuis le dossier brain/, venv activé) :
python scripts/test_import_rules.py "chemin/vers/regles.pdf"
Le provider LLM utilisé est celui configuré (.env + overrides de l'écran
Paramètres) — donc 1min.ai si tu l'as sélectionné. Le script :
1. extrait le texte (et dit, page par page, si l'OCR s'est déclenché),
2. découpe + structure via le LLM,
3. affiche un résumé des sections,
4. écrit le markdown complet dans "<pdf>.rules.md" à côté du PDF.
"""
from __future__ import annotations
import asyncio
import logging
import sys
from pathlib import Path
# Permet `import app...` quel que soit le cwd : on ajoute la racine brain/.
_BRAIN_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(_BRAIN_ROOT))
from app.application.import_rules import ImportRulesUseCase # noqa: E402
from app.core.config import get_settings # noqa: E402
from app.domain.ports import LLMProvider, LLMProviderError, PdfExtractionError # noqa: E402
from app.infrastructure.ollama_adapter import OllamaLLMProvider # noqa: E402
from app.infrastructure.onemin_adapter import OneMinAiLLMProvider # noqa: E402
from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor # noqa: E402
def _build_provider() -> LLMProvider:
"""Réplique la factory de main.py (choix provider selon les settings)."""
settings = get_settings()
print(f"→ Provider LLM : {settings.llm_provider} "
f"(modèle : {settings.onemin_model if settings.llm_provider == 'onemin' else settings.llm_model})")
if settings.llm_provider == "onemin":
return OneMinAiLLMProvider(settings)
return OllamaLLMProvider(settings)
async def _run(pdf_path: Path) -> None:
pdf_bytes = pdf_path.read_bytes()
print(f"→ PDF chargé : {pdf_path.name} ({len(pdf_bytes) // 1024} Ko)\n")
extractor = PyMuPdfTextExtractor()
# 1. Extraction seule d'abord, pour le diagnostic page/OCR avant tout LLM.
try:
doc = extractor.extract(pdf_bytes)
except PdfExtractionError as exc:
print(f"✗ Extraction impossible : {exc}")
return
print(f"=== Extraction : {doc.page_count} page(s), "
f"{doc.ocr_page_count} via OCR, "
f"{len(doc.full_text)} caractères ===")
if doc.ocr_page_count == 0:
print(" → PDF born-digital détecté (couche texte présente, OCR non nécessaire).")
else:
print(f"{doc.ocr_page_count} page(s) sans couche texte : OCR déclenché.")
if not doc.full_text.strip():
print("\n✗ Aucun texte extrait. Si le PDF est un scan, vérifie que Tesseract "
"est installé (sinon l'OCR est désactivé).")
return
# 2. Structuration via le LLM (réutilise l'extraction déjà faite indirectement :
# le use case ré-extrait, coût négligeable vs l'appel LLM).
print("\n=== Structuration via LLM (peut prendre un moment selon le nombre de morceaux)… ===")
use_case = ImportRulesUseCase(llm=_build_provider(), extractor=extractor)
try:
result = await use_case.execute(pdf_bytes)
except LLMProviderError as exc:
print(f"✗ Échec LLM : {exc}")
return
if not result.sections:
print("\n✗ Aucune section proposée (le modèle n'a rien renvoyé d'exploitable).")
return
print(f"\n=== {len(result.sections)} section(s) proposée(s) ===")
for title, content in result.sections.items():
preview = content.strip().replace("\n", " ")[:90]
print(f"{title} ({len(content)} car.) — {preview}")
out_path = pdf_path.with_suffix(".rules.md")
out_path.write_text(result.to_markdown(), encoding="utf-8")
print(f"\n✓ Markdown complet écrit dans : {out_path}")
def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s%(message)s")
if len(sys.argv) != 2:
print("Usage : python scripts/test_import_rules.py \"chemin/vers/regles.pdf\"")
sys.exit(1)
pdf_path = Path(sys.argv[1]).expanduser()
if not pdf_path.is_file():
print(f"✗ Fichier introuvable : {pdf_path}")
sys.exit(1)
asyncio.run(_run(pdf_path))
if __name__ == "__main__":
main()

View File

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

View File

@@ -0,0 +1,118 @@
package com.loremind.application.campaigncontext;
import com.loremind.application.generationcontext.CampaignStructuralContextBuilder;
import com.loremind.application.generationcontext.LoreStructuralContextBuilder;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.ports.CampaignPdfAdvisor;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.generationcontext.CampaignStructuralContext;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.NpcSummary;
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
import com.loremind.domain.generationcontext.LoreStructuralContext;
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
/**
* Service applicatif : conseils d'adaptation d'un PDF à une campagne existante.
*
* <p>Assemble un « brief » de la campagne (structure + PNJ + univers/lore) — la même
* matière que le chat de campagne — et délègue la génération streamée au Brain via
* {@link CampaignPdfAdvisor}. Ne persiste rien : la sortie est du conseil libre.</p>
*/
@Service
public class CampaignAdaptService {
private final CampaignRepository campaignRepository;
private final CampaignStructuralContextBuilder campaignContextBuilder;
private final LoreStructuralContextBuilder loreContextBuilder;
private final CampaignPdfAdvisor advisor;
public CampaignAdaptService(
CampaignRepository campaignRepository,
CampaignStructuralContextBuilder campaignContextBuilder,
LoreStructuralContextBuilder loreContextBuilder,
CampaignPdfAdvisor advisor) {
this.campaignRepository = campaignRepository;
this.campaignContextBuilder = campaignContextBuilder;
this.loreContextBuilder = loreContextBuilder;
this.advisor = advisor;
}
public void adviseStreaming(
String campaignId,
byte[] pdfBytes,
String filename,
String messagesJson,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
Campaign campaign = campaignRepository.findById(campaignId)
.orElseThrow(() -> new IllegalArgumentException("Campagne introuvable : " + campaignId));
advisor.adviseStreaming(
pdfBytes, filename, buildBrief(campaign), messagesJson, onToken, onComplete, onError);
}
/** Construit un résumé markdown de la campagne (structure + PNJ + lore). */
private String buildBrief(Campaign campaign) {
CampaignStructuralContext cc = campaignContextBuilder.build(campaign.getId());
StringBuilder sb = new StringBuilder();
sb.append("# Campagne : ").append(cc.campaignName()).append("\n");
if (notBlank(cc.campaignDescription())) sb.append(cc.campaignDescription()).append("\n");
sb.append("\n## Structure (arcs → chapitres → scènes)\n");
if (cc.arcs().isEmpty()) {
sb.append("_(aucun arc pour le moment)_\n");
}
for (ArcSummary arc : cc.arcs()) {
sb.append("### Arc : ").append(arc.name());
if (notBlank(arc.description())) sb.append("").append(arc.description());
sb.append("\n");
for (ChapterSummary ch : arc.chapters()) {
sb.append("- Chapitre : ").append(ch.name());
if (notBlank(ch.description())) sb.append("").append(ch.description());
sb.append("\n");
for (SceneSummary sc : ch.scenes()) {
sb.append(" - Scène : ").append(sc.name());
if (notBlank(sc.description())) sb.append("").append(sc.description());
sb.append("\n");
}
}
}
if (!cc.npcs().isEmpty()) {
sb.append("\n## PNJ existants\n");
for (NpcSummary n : cc.npcs()) {
sb.append("- ").append(n.name());
if (notBlank(n.snippet())) sb.append(" : ").append(n.snippet());
sb.append("\n");
}
}
if (campaign.isLinkedToLore()) {
loreContextBuilder.buildOptional(campaign.getLoreId()).ifPresent(lore -> appendLore(sb, lore));
}
return sb.toString();
}
private void appendLore(StringBuilder sb, LoreStructuralContext lore) {
sb.append("\n## Univers (Lore) : ").append(lore.loreName()).append("\n");
if (notBlank(lore.loreDescription())) sb.append(lore.loreDescription()).append("\n");
for (Map.Entry<String, List<PageSummary>> entry : lore.folders().entrySet()) {
sb.append("### ").append(entry.getKey()).append("\n");
for (PageSummary page : entry.getValue()) {
sb.append("- ").append(page.title()).append("\n");
}
}
}
private static boolean notBlank(String s) {
return s != null && !s.isBlank();
}
}

View File

@@ -0,0 +1,181 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.ArcType;
import com.loremind.domain.campaigncontext.CampaignImportProgress;
import com.loremind.domain.campaigncontext.CampaignImportProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.ArcProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.ChapterProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.RoomProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.SceneProposal;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Room;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.CampaignPdfImporter;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.function.Consumer;
/**
* Service applicatif pour l'import d'un PDF de campagne.
*
* <p>Deux temps, conformes au principe « revue avant écriture » :
* 1. {@link #importStructureStreaming} génère une PROPOSITION d'arbre (rien
* n'est persisté), streamée pour l'avancement.
* 2. {@link #applyStructure} crée réellement les arcs/chapitres/scènes une fois
* l'arbre révisé/édité par l'utilisateur.</p>
*/
@Service
public class CampaignImportService {
private final CampaignPdfImporter campaignPdfImporter;
private final CampaignService campaignService;
private final ArcService arcService;
private final ChapterService chapterService;
private final SceneService sceneService;
public CampaignImportService(
CampaignPdfImporter campaignPdfImporter,
CampaignService campaignService,
ArcService arcService,
ChapterService chapterService,
SceneService sceneService) {
this.campaignPdfImporter = campaignPdfImporter;
this.campaignService = campaignService;
this.arcService = arcService;
this.chapterService = chapterService;
this.sceneService = sceneService;
}
/** Résumé de ce qui a été créé par {@link #applyStructure}. */
public record ApplyResult(int arcsCreated, int chaptersCreated, int scenesCreated) {}
/** Génère la proposition d'arbre (streamée). Ne persiste rien. */
public void importStructureStreaming(
byte[] pdfBytes,
String filename,
Consumer<CampaignImportProgress> onProgress,
Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> onError) {
campaignPdfImporter.importCampaignStreaming(pdfBytes, filename, onProgress, onDone, onError);
}
/**
* Crée les arcs/chapitres/scènes de l'arbre révisé dans la campagne. Les
* arcs sont ajoutés APRÈS les arcs existants (ordre continué). Tout est créé
* dans une seule transaction (rollback si une étape échoue).
*/
@Transactional
public ApplyResult applyStructure(String campaignId, CampaignImportProposal proposal) {
if (!campaignService.campaignExists(campaignId)) {
throw new IllegalArgumentException("Campagne introuvable : " + campaignId);
}
int arcsCreated = 0, chaptersCreated = 0, scenesCreated = 0;
// Les nouveaux nœuds sont ordonnés APRÈS les frères existants (déjà comptés
// via leur existingId dans l'arbre fusionné venu de la revue).
int arcOrder = countExisting(proposal.arcs(), ArcProposal::existingId);
for (ArcProposal arcP : proposal.arcs()) {
if (isBlank(arcP.name())) continue;
String arcId;
if (!isBlank(arcP.existingId())) {
arcId = arcP.existingId(); // arc déjà présent → on s'y rattache
} else {
arcOrder++;
Arc arc = arcService.createArc(Arc.builder()
.name(arcP.name().trim())
.description(nullIfBlank(arcP.description()))
.campaignId(campaignId)
.order(arcOrder)
.type(parseArcType(arcP.type()))
.build());
arcId = arc.getId();
arcsCreated++;
}
int chapterOrder = countExisting(arcP.chapters(), ChapterProposal::existingId);
for (ChapterProposal chapP : safe(arcP.chapters())) {
if (isBlank(chapP.name())) continue;
String chapId;
if (!isBlank(chapP.existingId())) {
chapId = chapP.existingId();
} else {
chapterOrder++;
Chapter chapter = chapterService.createChapter(
chapP.name().trim(), nullIfBlank(chapP.description()), arcId, chapterOrder);
chapId = chapter.getId();
chaptersCreated++;
}
int sceneOrder = countExisting(chapP.scenes(), SceneProposal::existingId);
for (SceneProposal sceneP : safe(chapP.scenes())) {
if (isBlank(sceneP.name())) continue;
if (!isBlank(sceneP.existingId())) continue; // scène déjà présente
sceneOrder++;
sceneService.createScene(Scene.builder()
.name(sceneP.name().trim())
.description(nullIfBlank(sceneP.description()))
.playerNarration(nullIfBlank(sceneP.playerNarration()))
.gmSecretNotes(nullIfBlank(sceneP.gmNotes()))
.chapterId(chapId)
.order(sceneOrder)
.rooms(toRooms(sceneP.rooms()))
.build());
scenesCreated++;
}
}
}
return new ApplyResult(arcsCreated, chaptersCreated, scenesCreated);
}
/** Compte les nœuds déjà présents (existingId non vide) d'une liste. */
private static <T> int countExisting(List<T> list, java.util.function.Function<T, String> idOf) {
if (list == null) return 0;
int n = 0;
for (T t : list) {
if (!isBlank(idOf.apply(t))) n++;
}
return n;
}
/** "HUB" (insensible à la casse) → {@link ArcType#HUB} ; tout le reste → LINEAR. */
private static ArcType parseArcType(String type) {
return "HUB".equalsIgnoreCase(type == null ? "" : type.trim()) ? ArcType.HUB : ArcType.LINEAR;
}
/** Convertit les pièces proposées en {@link Room} (ID généré, ordre = index). */
private static List<Room> toRooms(List<RoomProposal> proposals) {
List<Room> rooms = new ArrayList<>();
if (proposals == null) return rooms;
int order = 0;
for (RoomProposal r : proposals) {
if (isBlank(r.name())) continue;
rooms.add(Room.builder()
.id(UUID.randomUUID().toString())
.name(r.name().trim())
.description(nullIfBlank(r.description()))
.enemies(nullIfBlank(r.enemies()))
.loot(nullIfBlank(r.loot()))
.order(order++)
.build());
}
return rooms;
}
private static <T> java.util.List<T> safe(java.util.List<T> list) {
return list == null ? java.util.List.of() : list;
}
private static boolean isBlank(String s) {
return s == null || s.isBlank();
}
private static String nullIfBlank(String s) {
return isBlank(s) ? null : s.trim();
}
}

View File

@@ -6,6 +6,7 @@ import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@@ -40,6 +41,19 @@ public class SceneService {
return sceneRepository.save(scene);
}
/**
* Crée une scène à partir d'un objet Scene complet (tous les champs : narration
* joueurs, notes MJ, pièces…). Utilisé par l'import de campagne. L'ID est forcé
* à null pour laisser le repo en générer un nouveau.
*/
public Scene createScene(Scene input) {
input.setId(null);
if (input.getRooms() == null) {
input.setRooms(new ArrayList<>());
}
return sceneRepository.save(input);
}
public Optional<Scene> getSceneById(String id) {
return sceneRepository.findById(id);
}

View File

@@ -1,7 +1,9 @@
package com.loremind.application.gamesystemcontext;
import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.gamesystemcontext.RulesImportResult;
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
import com.loremind.domain.gamesystemcontext.ports.RulesPdfImporter;
import com.loremind.domain.shared.template.TemplateField;
import org.springframework.stereotype.Service;
@@ -12,9 +14,34 @@ import java.util.Optional;
public class GameSystemService {
private final GameSystemRepository gameSystemRepository;
private final RulesPdfImporter rulesPdfImporter;
public GameSystemService(GameSystemRepository gameSystemRepository) {
public GameSystemService(GameSystemRepository gameSystemRepository,
RulesPdfImporter rulesPdfImporter) {
this.gameSystemRepository = gameSystemRepository;
this.rulesPdfImporter = rulesPdfImporter;
}
/**
* Importe un PDF de règles et renvoie une PROPOSITION de sections (titre →
* markdown). Ne persiste rien : l'UI laisse l'utilisateur réviser/éditer
* puis enregistrer le GameSystem via {@link #updateGameSystem}/{@link #createGameSystem}.
*/
public RulesImportResult importRulesFromPdf(byte[] pdfBytes, String filename) {
return rulesPdfImporter.importRules(pdfBytes, filename);
}
/**
* Variante streamée de {@link #importRulesFromPdf} : remonte l'avancement via
* callbacks (import long → l'UI affiche une progression). Ne persiste rien.
*/
public void importRulesFromPdfStreaming(
byte[] pdfBytes,
String filename,
java.util.function.Consumer<com.loremind.domain.gamesystemcontext.RulesImportProgress> onProgress,
java.util.function.Consumer<RulesImportResult> onDone,
java.util.function.Consumer<Throwable> onError) {
rulesPdfImporter.importRulesStreaming(pdfBytes, filename, onProgress, onDone, onError);
}
/**

View File

@@ -174,10 +174,9 @@ public class SessionStructuralContextBuilder {
.filter(a -> a.getId() != null)
.collect(Collectors.toMap(Arc::getId, a -> a));
boolean anyHub = arcs.stream().anyMatch(a -> a.getType() == ArcType.HUB);
if (!anyHub) {
return new HubStatus(List.of(), List.of(), List.of(), activeFlags);
}
// On suit comme "quêtes" les chapitres CONDITIONNELS : ceux d'un arc HUB, ET
// ceux d'un arc linéaire qui portent des prérequis. Un chapitre linéaire sans
// condition reste hors du tableau (sinon tous les chapitres deviendraient des quêtes).
// Map chapterId -> ProgressionStatus pour ce Playthrough
Map<String, ProgressionStatus> progressionByChapter = new HashMap<>();
@@ -197,8 +196,10 @@ public class SessionStructuralContextBuilder {
List<String> lockedTitles = new ArrayList<>();
for (Arc arc : arcs) {
if (arc.getType() != ArcType.HUB) continue;
boolean isHub = arc.getType() == ArcType.HUB;
for (Chapter c : chapterRepository.findByArcId(arc.getId())) {
boolean hasPrereqs = c.getPrerequisites() != null && !c.getPrerequisites().isEmpty();
if (!isHub && !hasPrereqs) continue; // chapitre linéaire sans condition : ignoré
ProgressionStatus prog = progressionByChapter.getOrDefault(c.getId(), ProgressionStatus.NOT_STARTED);
QuestStatus status = prerequisiteEvaluator.computeStatus(prog, c.getPrerequisites(), ctx);
Arc parent = arcsById.get(c.getArcId());

View File

@@ -0,0 +1,18 @@
package com.loremind.domain.campaigncontext;
/**
* Évènement d'avancement émis pendant l'import streamé d'un PDF de campagne.
* <p>
* {@code total} = nombre de morceaux à traiter (0 pendant l'extraction).
* {@code current} = morceaux traités. Les compteurs arc/chapitre/scène donnent
* un aperçu de l'arbre trouvé jusqu'ici (affichage « au fil de l'eau »).
*/
public record CampaignImportProgress(
int current,
int total,
int pageCount,
int ocrPageCount,
int arcCount,
int chapterCount,
int sceneCount) {
}

View File

@@ -0,0 +1,40 @@
package com.loremind.domain.campaigncontext;
import java.util.List;
/**
* Proposition d'arborescence narrative extraite d'un PDF de campagne.
* <p>
* PROPOSITION non persistée : l'UI laisse l'utilisateur réviser/éditer l'arbre
* avant la création effective des arcs/chapitres/scènes. Records purs (domaine).
*/
public record CampaignImportProposal(List<ArcProposal> arcs) {
/**
* {@code existingId} (nullable) : si présent, le nœud existe DÉJÀ dans la
* campagne (rempli côté UI lors de la revue pré-chargée) → l'apply ne le
* recrée pas, il l'utilise comme parent des nouveaux enfants. Null = à créer.
*/
/** {@code type} = "LINEAR" ou "HUB" (mappé sur {@link ArcType} à l'apply). */
public record ArcProposal(
String name, String description, String type,
List<ChapterProposal> chapters, String existingId) {
}
public record ChapterProposal(
String name, String description, List<SceneProposal> scenes, String existingId) {
}
/**
* {@code rooms} non vide => lieu explorable (donjon). {@code playerNarration}
* = encadré « à lire aux joueurs », {@code gmNotes} = secrets/développement MJ.
*/
public record SceneProposal(
String name, String description, String playerNarration, String gmNotes,
List<RoomProposal> rooms, String existingId) {
}
public record RoomProposal(String name, String description, String enemies, String loot) {
}
}

View File

@@ -0,0 +1,16 @@
package com.loremind.domain.campaigncontext.ports;
/**
* Erreur de domaine : l'import d'un PDF de campagne a échoué (PDF illisible,
* Brain injoignable, LLM en erreur...).
*/
public class CampaignImportException extends RuntimeException {
public CampaignImportException(String message) {
super(message);
}
public CampaignImportException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,32 @@
package com.loremind.domain.campaigncontext.ports;
import java.util.function.Consumer;
/**
* Port de sortie : produit des CONSEILS d'adaptation d'un PDF à une campagne
* existante, streamés token par token. Délègue au Brain (LLM + extraction PDF).
* <p>
* Contrairement à {@link CampaignPdfImporter} (qui structure pour créer), ici la
* sortie est du texte libre (markdown) : l'utilisateur applique à la main.
*/
public interface CampaignPdfAdvisor {
/**
* @param pdfBytes contenu du PDF à adapter.
* @param filename nom d'origine (diagnostic ; peut être null).
* @param brief description de la campagne existante (structure + PNJ + lore).
* @param messagesJson JSON de l'échange conversationnel ([{role, content}, …]) ;
* "[]" au 1er tour. Permet à l'utilisateur de répondre/corriger.
* @param onToken invoqué à chaque fragment de texte généré.
* @param onComplete invoqué à la fin normale du flux.
* @param onError invoqué en cas d'échec (PDF illisible, Brain/LLM en erreur).
*/
void adviseStreaming(
byte[] pdfBytes,
String filename,
String brief,
String messagesJson,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError);
}

View File

@@ -0,0 +1,28 @@
package com.loremind.domain.campaigncontext.ports;
import com.loremind.domain.campaigncontext.CampaignImportProgress;
import com.loremind.domain.campaigncontext.CampaignImportProposal;
import java.util.function.Consumer;
/**
* Port de sortie : extrait et structure un PDF de campagne en arbre
* arc → chapitre → scène. L'implémentation délègue au Brain Python.
*/
public interface CampaignPdfImporter {
/**
* Variante streamée : l'import peut durer plusieurs minutes, on remonte
* l'avancement au fil de l'eau, puis la proposition finale.
*
* @param onProgress invoqué à chaque étape (extraction, puis par morceau).
* @param onDone invoqué une fois avec l'arbre proposé (non persisté).
* @param onError invoqué si l'extraction/structuration échoue.
*/
void importCampaignStreaming(
byte[] pdfBytes,
String filename,
Consumer<CampaignImportProgress> onProgress,
Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> onError);
}

View File

@@ -0,0 +1,19 @@
package com.loremind.domain.gamesystemcontext;
import java.util.List;
/**
* Évènement d'avancement émis pendant l'import streamé d'un PDF de règles.
* <p>
* {@code total} = nombre de morceaux à traiter (0 tant que l'extraction n'est
* pas finie). {@code current} = morceaux déjà traités. {@code newSectionTitles}
* = titres de sections nouvellement trouvés/complétés par le dernier morceau
* (pour un affichage « au fil de l'eau »).
*/
public record RulesImportProgress(
int current,
int total,
int pageCount,
int ocrPageCount,
List<String> newSectionTitles) {
}

View File

@@ -0,0 +1,20 @@
package com.loremind.domain.gamesystemcontext;
import java.util.Map;
/**
* Proposition de règles extraites d'un PDF, prête à être révisée par l'utilisateur.
* <p>
* {@code sections} associe un titre de section à son contenu markdown — aligné
* sur le format {@link GameSystem#getRulesMarkdown()} (découpé par titres H2).
* C'est une PROPOSITION : rien n'est persisté ; l'UI laisse l'utilisateur
* réviser/éditer avant d'enregistrer le GameSystem.
* <p>
* {@code ocrPageCount} indique combien de pages ont nécessité l'OCR (scan) —
* 0 = PDF born-digital (couche texte présente).
*/
public record RulesImportResult(
Map<String, String> sections,
int pageCount,
int ocrPageCount) {
}

View File

@@ -0,0 +1,17 @@
package com.loremind.domain.gamesystemcontext.ports;
/**
* Erreur de domaine : l'import d'un PDF de règles a échoué (PDF illisible,
* Brain injoignable, LLM en erreur...). Les couches supérieures la traduisent
* en réponse HTTP sans connaître l'adapter concret.
*/
public class RulesImportException extends RuntimeException {
public RulesImportException(String message) {
super(message);
}
public RulesImportException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,39 @@
package com.loremind.domain.gamesystemcontext.ports;
import com.loremind.domain.gamesystemcontext.RulesImportProgress;
import com.loremind.domain.gamesystemcontext.RulesImportResult;
import java.util.function.Consumer;
/**
* Port de sortie : extrait et structure les règles d'un PDF en sections.
* <p>
* L'implémentation (adapter) délègue au Brain Python (extraction texte + OCR +
* structuration LLM). Le domaine ne connaît ni HTTP, ni le Brain, ni le LLM.
*/
public interface RulesPdfImporter {
/**
* @param pdfBytes contenu binaire du PDF de règles.
* @param filename nom d'origine (diagnostic/logs ; peut être null).
* @return la proposition de sections (non persistée).
* @throws RulesImportException si l'extraction ou la structuration échoue.
*/
RulesImportResult importRules(byte[] pdfBytes, String filename);
/**
* Variante streamée : l'import peut durer plusieurs minutes, on remonte
* l'avancement au fil de l'eau. Les callbacks sont invoqués depuis le thread
* d'exécution de l'adapter (synchrone jusqu'à {@code onDone}/{@code onError}).
*
* @param onProgress invoqué à chaque étape (extraction, puis par morceau).
* @param onDone invoqué une fois avec le résultat final.
* @param onError invoqué si l'extraction/structuration échoue.
*/
void importRulesStreaming(
byte[] pdfBytes,
String filename,
Consumer<RulesImportProgress> onProgress,
Consumer<RulesImportResult> onDone,
Consumer<Throwable> onError);
}

View File

@@ -0,0 +1,120 @@
package com.loremind.infrastructure.ai;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.ports.CampaignPdfAdvisor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.MediaType;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.function.Consumer;
/**
* Adapter de sortie : implémente {@link CampaignPdfAdvisor} via WebClient + SSE
* (POST /adapt/campaign/stream). Envoie le PDF (multipart) + le brief de campagne,
* relaie les tokens de conseil au fil de l'eau.
*/
@Component
public class BrainCampaignAdaptClient implements CampaignPdfAdvisor {
private static final String ADAPT_PATH = "/adapt/campaign/stream";
private static final ParameterizedTypeReference<ServerSentEvent<String>> SSE_STRING_TYPE =
new ParameterizedTypeReference<>() {};
private final WebClient webClient;
private final ObjectMapper objectMapper;
private final long timeoutSeconds;
public BrainCampaignAdaptClient(
WebClient.Builder webClientBuilder,
ObjectMapper objectMapper,
@Value("${brain.base-url}") String baseUrl,
@Value("${brain.import-timeout-seconds:600}") long timeoutSeconds) {
this.webClient = webClientBuilder.baseUrl(baseUrl).build();
this.objectMapper = objectMapper;
this.timeoutSeconds = timeoutSeconds;
}
@Override
public void adviseStreaming(
byte[] pdfBytes,
String filename,
String brief,
String messagesJson,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
MultipartBodyBuilder parts = new MultipartBodyBuilder();
parts.part("file", new ByteArrayResource(pdfBytes) {
@Override
public String getFilename() {
return (filename == null || filename.isBlank()) ? "campaign.pdf" : filename;
}
});
parts.part("brief", brief == null ? "" : brief);
parts.part("messages", (messagesJson == null || messagesJson.isBlank()) ? "[]" : messagesJson);
Flux<ServerSentEvent<String>> flux = webClient.post()
.uri(ADAPT_PATH)
.contentType(MediaType.MULTIPART_FORM_DATA)
.accept(MediaType.TEXT_EVENT_STREAM)
.body(BodyInserters.fromMultipartData(parts.build()))
.retrieve()
.bodyToFlux(SSE_STRING_TYPE);
boolean[] terminated = {false};
try {
flux
.timeout(Duration.ofSeconds(timeoutSeconds))
.doOnNext(sse -> handleEvent(sse, terminated, onToken, onComplete, onError))
.blockLast();
// Flux clos sans 'done' explicite (ex: coupure) → on complète quand même.
if (!terminated[0]) onComplete.run();
} catch (Exception e) {
if (!terminated[0]) {
onError.accept(new RuntimeException(
"Erreur lors du streaming d'adaptation depuis le Brain.", e));
}
}
}
private void handleEvent(
ServerSentEvent<String> sse,
boolean[] terminated,
Consumer<String> onToken,
Runnable onComplete,
Consumer<Throwable> onError) {
String event = sse.event();
String data = sse.data() == null ? "" : sse.data();
if ("error".equals(event)) {
terminated[0] = true;
onError.accept(new RuntimeException("Le Brain a signalé une erreur : " + readField(data, "message")));
} else if ("done".equals(event)) {
terminated[0] = true;
onComplete.run();
} else if ("token".equals(event)) {
String token = readField(data, "token");
if (token != null && !token.isEmpty()) onToken.accept(token);
}
}
private String readField(String data, String field) {
try {
JsonNode node = objectMapper.readTree(data);
return node.hasNonNull(field) ? node.get(field).asText() : data;
} catch (Exception e) {
return data;
}
}
}

View File

@@ -0,0 +1,232 @@
package com.loremind.infrastructure.ai;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.CampaignImportProgress;
import com.loremind.domain.campaigncontext.CampaignImportProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.ArcProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.ChapterProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.RoomProposal;
import com.loremind.domain.campaigncontext.CampaignImportProposal.SceneProposal;
import com.loremind.domain.campaigncontext.ports.CampaignImportException;
import com.loremind.domain.campaigncontext.ports.CampaignPdfImporter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.MediaType;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
/**
* Adapter de sortie : implémente {@link CampaignPdfImporter} en appelant le
* Brain Python via WebClient + SSE (POST /import/campaign/stream).
* <p>
* Le secret inter-service est ajouté par le WebClientCustomizer. Le timeout est
* long (import d'un livre entier = nombreux appels LLM en série).
*/
@Component
public class BrainCampaignImportClient implements CampaignPdfImporter {
private static final String IMPORT_CAMPAIGN_STREAM_PATH = "/import/campaign/stream";
private static final ParameterizedTypeReference<ServerSentEvent<String>> SSE_STRING_TYPE =
new ParameterizedTypeReference<>() {};
private final WebClient webClient;
private final ObjectMapper objectMapper;
private final long importTimeoutSeconds;
public BrainCampaignImportClient(
WebClient.Builder webClientBuilder,
ObjectMapper objectMapper,
@Value("${brain.base-url}") String baseUrl,
@Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds) {
this.webClient = webClientBuilder.baseUrl(baseUrl).build();
this.objectMapper = objectMapper;
this.importTimeoutSeconds = importTimeoutSeconds;
}
@Override
public void importCampaignStreaming(
byte[] pdfBytes,
String filename,
Consumer<CampaignImportProgress> onProgress,
Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> onError) {
MultipartBodyBuilder parts = new MultipartBodyBuilder();
parts.part("file", filePart(pdfBytes, filename))
.filename(filename == null || filename.isBlank() ? "campaign.pdf" : filename);
Flux<ServerSentEvent<String>> flux = webClient.post()
.uri(IMPORT_CAMPAIGN_STREAM_PATH)
.contentType(MediaType.MULTIPART_FORM_DATA)
.accept(MediaType.TEXT_EVENT_STREAM)
.body(BodyInserters.fromMultipartData(parts.build()))
.retrieve()
.bodyToFlux(SSE_STRING_TYPE);
int[] pageCount = {0};
int[] ocrPageCount = {0};
boolean[] terminated = {false};
try {
flux
.timeout(Duration.ofSeconds(importTimeoutSeconds))
.doOnNext(sse -> handleEvent(
sse, pageCount, ocrPageCount, terminated, onProgress, onDone, onError))
.blockLast();
if (!terminated[0]) {
onError.accept(new CampaignImportException(
"Le flux d'import s'est interrompu avant la fin."));
}
} catch (Exception e) {
if (!terminated[0]) {
onError.accept(new CampaignImportException(
"Erreur lors du streaming d'import depuis le Brain.", e));
}
}
}
private void handleEvent(
ServerSentEvent<String> sse,
int[] pageCount,
int[] ocrPageCount,
boolean[] terminated,
Consumer<CampaignImportProgress> onProgress,
Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> onError) {
String event = sse.event();
String data = sse.data() == null ? "" : sse.data();
if ("error".equals(event)) {
terminated[0] = true;
onError.accept(new CampaignImportException(
"Le Brain a signalé une erreur : " + readMessage(data)));
return;
}
if ("extracting".equals(event)) {
onProgress.accept(new CampaignImportProgress(0, 0, 0, 0, 0, 0, 0));
return;
}
JsonNode node = readJson(data);
if (node == null) return;
if ("start".equals(event)) {
pageCount[0] = node.path("page_count").asInt();
ocrPageCount[0] = node.path("ocr_page_count").asInt();
onProgress.accept(new CampaignImportProgress(
0, node.path("total").asInt(), pageCount[0], ocrPageCount[0], 0, 0, 0));
} else if ("progress".equals(event)) {
onProgress.accept(new CampaignImportProgress(
node.path("current").asInt(),
node.path("total").asInt(),
pageCount[0],
ocrPageCount[0],
node.path("arc_count").asInt(),
node.path("chapter_count").asInt(),
node.path("scene_count").asInt()));
} else if ("done".equals(event)) {
terminated[0] = true;
onDone.accept(new CampaignImportProposal(toArcs(node.path("arcs"))));
}
}
// --- Parsing de l'arbre --------------------------------------------------
private List<ArcProposal> toArcs(JsonNode arcsNode) {
List<ArcProposal> arcs = new ArrayList<>();
if (arcsNode != null && arcsNode.isArray()) {
for (JsonNode arc : arcsNode) {
arcs.add(new ArcProposal(
text(arc, "name"),
text(arc, "description"),
text(arc, "type"),
toChapters(arc.path("chapters")),
null));
}
}
return arcs;
}
private List<ChapterProposal> toChapters(JsonNode chaptersNode) {
List<ChapterProposal> chapters = new ArrayList<>();
if (chaptersNode != null && chaptersNode.isArray()) {
for (JsonNode ch : chaptersNode) {
chapters.add(new ChapterProposal(
text(ch, "name"),
text(ch, "description"),
toScenes(ch.path("scenes")),
null));
}
}
return chapters;
}
private List<SceneProposal> toScenes(JsonNode scenesNode) {
List<SceneProposal> scenes = new ArrayList<>();
if (scenesNode != null && scenesNode.isArray()) {
for (JsonNode sc : scenesNode) {
scenes.add(new SceneProposal(
text(sc, "name"), text(sc, "description"),
text(sc, "player_narration"), text(sc, "gm_notes"),
toRooms(sc.path("rooms")), null));
}
}
return scenes;
}
private List<RoomProposal> toRooms(JsonNode roomsNode) {
List<RoomProposal> rooms = new ArrayList<>();
if (roomsNode != null && roomsNode.isArray()) {
for (JsonNode rm : roomsNode) {
rooms.add(new RoomProposal(
text(rm, "name"), text(rm, "description"),
text(rm, "enemies"), text(rm, "loot")));
}
}
return rooms;
}
// --- Helpers -------------------------------------------------------------
private ByteArrayResource filePart(byte[] pdfBytes, String filename) {
return new ByteArrayResource(pdfBytes) {
@Override
public String getFilename() {
return (filename == null || filename.isBlank()) ? "campaign.pdf" : filename;
}
};
}
private static String text(JsonNode node, String field) {
JsonNode v = node.path(field);
return v.isMissingNode() || v.isNull() ? "" : v.asText();
}
private JsonNode readJson(String data) {
try {
return objectMapper.readTree(data);
} catch (Exception e) {
return null;
}
}
private String readMessage(String data) {
JsonNode node = readJson(data);
if (node != null && node.hasNonNull("message")) {
return node.get("message").asText();
}
return data;
}
}

View File

@@ -0,0 +1,248 @@
package com.loremind.infrastructure.ai;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.gamesystemcontext.RulesImportProgress;
import com.loremind.domain.gamesystemcontext.RulesImportResult;
import com.loremind.domain.gamesystemcontext.ports.RulesImportException;
import com.loremind.domain.gamesystemcontext.ports.RulesPdfImporter;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClientResponseException;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
/**
* Adapter de sortie : implémente {@link RulesPdfImporter} en appelant le Brain
* Python via HTTP multipart.
* <p>
* Deux variantes :
* - {@link #importRules} : one-shot bloquant (RestTemplate, POST /import/rules).
* - {@link #importRulesStreaming} : streamé (WebClient + SSE, POST /import/rules/stream)
* pour remonter l'avancement d'un import long.
* <p>
* Le RestTemplate ({@code brainImportRestTemplate}) a un timeout long ; le secret
* inter-service est ajouté par l'intercepteur du bean (RestTemplate) et par le
* WebClientCustomizer (WebClient).
*/
@Component
public class BrainRulesImportClient implements RulesPdfImporter {
private static final String IMPORT_RULES_PATH = "/import/rules";
private static final String IMPORT_RULES_STREAM_PATH = "/import/rules/stream";
private static final ParameterizedTypeReference<ServerSentEvent<String>> SSE_STRING_TYPE =
new ParameterizedTypeReference<>() {};
private final RestTemplate restTemplate;
private final WebClient webClient;
private final ObjectMapper objectMapper;
private final String baseUrl;
private final long importTimeoutSeconds;
public BrainRulesImportClient(
@Qualifier("brainImportRestTemplate") RestTemplate restTemplate,
WebClient.Builder webClientBuilder,
ObjectMapper objectMapper,
@Value("${brain.base-url}") String baseUrl,
@Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds) {
this.restTemplate = restTemplate;
this.webClient = webClientBuilder.baseUrl(baseUrl).build();
this.objectMapper = objectMapper;
this.baseUrl = baseUrl;
this.importTimeoutSeconds = importTimeoutSeconds;
}
// --- One-shot (bloquant) -------------------------------------------------
@Override
public RulesImportResult importRules(byte[] pdfBytes, String filename) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("file", filePart(pdfBytes, filename));
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
try {
BrainRulesImportResponse response = restTemplate.postForObject(
baseUrl + IMPORT_RULES_PATH, entity, BrainRulesImportResponse.class);
if (response == null || response.getSections() == null) {
throw new RulesImportException("Le Brain a renvoyé une réponse vide.");
}
return new RulesImportResult(
Map.copyOf(response.getSections()),
response.getPageCount(),
response.getOcrPageCount());
} catch (ResourceAccessException e) {
throw new RulesImportException(
"Le Brain est injoignable (timeout ou service arrêté).", e);
} catch (RestClientResponseException e) {
throw new RulesImportException(
"Le Brain a répondu HTTP " + e.getStatusCode().value()
+ " : " + e.getResponseBodyAsString(), e);
} catch (RulesImportException e) {
throw e;
} catch (Exception e) {
throw new RulesImportException("Erreur inattendue lors de l'import via le Brain.", e);
}
}
// --- Streamé (SSE) -------------------------------------------------------
@Override
public void importRulesStreaming(
byte[] pdfBytes,
String filename,
Consumer<RulesImportProgress> onProgress,
Consumer<RulesImportResult> onDone,
Consumer<Throwable> onError) {
MultipartBodyBuilder parts = new MultipartBodyBuilder();
parts.part("file", filePart(pdfBytes, filename))
.filename(filename == null || filename.isBlank() ? "rules.pdf" : filename);
Flux<ServerSentEvent<String>> flux = webClient.post()
.uri(IMPORT_RULES_STREAM_PATH)
.contentType(MediaType.MULTIPART_FORM_DATA)
.accept(MediaType.TEXT_EVENT_STREAM)
.body(BodyInserters.fromMultipartData(parts.build()))
.retrieve()
.bodyToFlux(SSE_STRING_TYPE);
// Holders mutables : le flux est consommé séquentiellement (blockLast),
// donc pas de souci de concurrence sur ces compteurs.
int[] pageCount = {0};
int[] ocrPageCount = {0};
boolean[] terminated = {false};
try {
flux
.timeout(Duration.ofSeconds(importTimeoutSeconds))
.doOnNext(sse -> handleEvent(
sse, pageCount, ocrPageCount, terminated, onProgress, onDone, onError))
.blockLast();
// Flux terminé sans event done/error (ex: connexion coupée) → on signale.
if (!terminated[0]) {
onError.accept(new RulesImportException(
"Le flux d'import s'est interrompu avant la fin."));
}
} catch (Exception e) {
if (!terminated[0]) {
onError.accept(new RulesImportException(
"Erreur lors du streaming d'import depuis le Brain.", e));
}
}
}
private void handleEvent(
ServerSentEvent<String> sse,
int[] pageCount,
int[] ocrPageCount,
boolean[] terminated,
Consumer<RulesImportProgress> onProgress,
Consumer<RulesImportResult> onDone,
Consumer<Throwable> onError) {
String event = sse.event();
String data = sse.data() == null ? "" : sse.data();
if ("error".equals(event)) {
terminated[0] = true;
onError.accept(new RulesImportException(
"Le Brain a signalé une erreur : " + readMessage(data)));
return;
}
if ("extracting".equals(event)) {
// Phase d'extraction : total inconnu (0) → l'UI affiche "Extraction…".
onProgress.accept(new RulesImportProgress(0, 0, 0, 0, List.of()));
return;
}
JsonNode node = readJson(data);
if (node == null) return;
if ("start".equals(event)) {
pageCount[0] = node.path("page_count").asInt();
ocrPageCount[0] = node.path("ocr_page_count").asInt();
onProgress.accept(new RulesImportProgress(
0, node.path("total").asInt(), pageCount[0], ocrPageCount[0], List.of()));
} else if ("progress".equals(event)) {
onProgress.accept(new RulesImportProgress(
node.path("current").asInt(),
node.path("total").asInt(),
pageCount[0],
ocrPageCount[0],
toStringList(node.path("new_sections"))));
} else if ("done".equals(event)) {
terminated[0] = true;
onDone.accept(new RulesImportResult(
toStringMap(node.path("sections")),
node.path("page_count").asInt(),
node.path("ocr_page_count").asInt()));
}
}
// --- Helpers -------------------------------------------------------------
/** ByteArrayResource avec nom de fichier : sans nom, l'upload n'est pas vu comme un fichier. */
private ByteArrayResource filePart(byte[] pdfBytes, String filename) {
return new ByteArrayResource(pdfBytes) {
@Override
public String getFilename() {
return (filename == null || filename.isBlank()) ? "rules.pdf" : filename;
}
};
}
private JsonNode readJson(String data) {
try {
return objectMapper.readTree(data);
} catch (Exception e) {
return null; // morceau de flux non-JSON inattendu : on l'ignore.
}
}
private String readMessage(String data) {
JsonNode node = readJson(data);
if (node != null && node.hasNonNull("message")) {
return node.get("message").asText();
}
return data;
}
private List<String> toStringList(JsonNode array) {
List<String> out = new ArrayList<>();
if (array != null && array.isArray()) {
array.forEach(n -> out.add(n.asText()));
}
return out;
}
private Map<String, String> toStringMap(JsonNode object) {
Map<String, String> out = new LinkedHashMap<>();
if (object != null && object.isObject()) {
object.fields().forEachRemaining(e -> out.put(e.getKey(), e.getValue().asText()));
}
return out;
}
}

View File

@@ -0,0 +1,26 @@
package com.loremind.infrastructure.ai;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
/**
* DTO interne de l'Adapter : JSON reçu du Brain sur POST /import/rules.
*
* @Data + @NoArgsConstructor : requis par Jackson pour la désérialisation.
*/
@Data
@NoArgsConstructor
class BrainRulesImportResponse {
@JsonProperty("sections")
private Map<String, String> sections;
@JsonProperty("page_count")
private int pageCount;
@JsonProperty("ocr_page_count")
private int ocrPageCount;
}

View File

@@ -5,6 +5,7 @@ import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.boot.web.reactive.function.client.WebClientCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
@@ -23,6 +24,7 @@ public class RestTemplateConfig {
private static final String INTERNAL_SECRET_HEADER = "X-Internal-Secret";
@Bean
@Primary
public RestTemplate brainRestTemplate(
RestTemplateBuilder builder,
@Value("${brain.timeout-seconds}") long timeoutSeconds,
@@ -39,6 +41,29 @@ public class RestTemplateConfig {
.build();
}
/**
* RestTemplate dédié à l'import de PDF (règles/campagne) au timeout LONG :
* l'extraction + la structuration map-reduce d'un livre entier enchaîne de
* nombreux appels LLM et dépasse facilement le timeout des appels courts.
* Même entête d'auth inter-service que {@link #brainRestTemplate}.
*/
@Bean
public RestTemplate brainImportRestTemplate(
RestTemplateBuilder builder,
@Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds,
@Value("${brain.internal-secret}") String internalSecret) {
return builder
.setConnectTimeout(Duration.ofSeconds(10))
.setReadTimeout(Duration.ofSeconds(importTimeoutSeconds))
.additionalInterceptors((request, body, execution) -> {
if (internalSecret != null && !internalSecret.isBlank()) {
request.getHeaders().set(INTERNAL_SECRET_HEADER, internalSecret);
}
return execution.execute(request, body);
})
.build();
}
/**
* Ajoute X-Internal-Secret comme header par defaut a tous les WebClient
* construits via le builder auto-configure par Spring Boot. Evite de

View File

@@ -0,0 +1,97 @@
package com.loremind.infrastructure.web.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.application.campaigncontext.CampaignAdaptService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.task.TaskExecutor;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.Map;
/**
* REST Controller : conseils d'adaptation d'un PDF à une campagne existante (SSE).
* - POST /api/campaigns/{id}/adapt-pdf/stream (multipart) → flux de tokens markdown.
* Ne persiste rien : la sortie est du conseil libre à appliquer manuellement.
*/
@RestController
@RequestMapping("/api/campaigns/{campaignId}/adapt-pdf")
public class CampaignAdaptController {
private static final Logger log = LoggerFactory.getLogger(CampaignAdaptController.class);
private static final long SSE_TIMEOUT_MS = 15 * 60 * 1000L;
private final CampaignAdaptService campaignAdaptService;
private final TaskExecutor taskExecutor;
private final ObjectMapper objectMapper;
public CampaignAdaptController(
CampaignAdaptService campaignAdaptService,
@Qualifier("applicationTaskExecutor") TaskExecutor taskExecutor,
ObjectMapper objectMapper) {
this.campaignAdaptService = campaignAdaptService;
this.taskExecutor = taskExecutor;
this.objectMapper = objectMapper;
}
@PostMapping(value = "/stream",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter adaptStream(
@PathVariable String campaignId,
@RequestParam("file") MultipartFile file,
@RequestParam(value = "messages", required = false) String messagesJson) throws IOException {
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
if (file == null || file.isEmpty()) {
sendError(emitter, "Fichier PDF vide.");
return emitter;
}
byte[] bytes = file.getBytes();
String filename = file.getOriginalFilename();
taskExecutor.execute(() -> {
try {
campaignAdaptService.adviseStreaming(
campaignId, bytes, filename, messagesJson,
token -> sendToken(emitter, token),
() -> {
sendEvent(emitter, "done", Map.of());
emitter.complete();
},
error -> {
log.warn("Adaptation PDF échouée : {}", error.getMessage());
sendError(emitter, error.getMessage());
});
} catch (IllegalArgumentException e) {
sendError(emitter, "Campagne introuvable.");
} catch (Exception e) {
log.warn("Adaptation PDF échouée : {}", e.getMessage());
sendError(emitter, e.getMessage());
}
});
return emitter;
}
private void sendToken(SseEmitter emitter, String token) {
sendEvent(emitter, "token", Map.of("token", token));
}
private void sendError(SseEmitter emitter, String message) {
sendEvent(emitter, "error", Map.of("message", message != null ? message : "Erreur inconnue."));
emitter.complete();
}
private void sendEvent(SseEmitter emitter, String eventName, Object payload) {
try {
emitter.send(SseEmitter.event().name(eventName).data(
objectMapper.writeValueAsString(payload), MediaType.APPLICATION_JSON));
} catch (IOException e) {
emitter.completeWithError(e);
}
}
}

View File

@@ -0,0 +1,119 @@
package com.loremind.infrastructure.web.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.application.campaigncontext.CampaignImportService;
import com.loremind.domain.campaigncontext.CampaignImportProposal;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.task.TaskExecutor;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.Map;
/**
* REST Controller pour l'import d'un PDF de campagne → arbre arc/chapitre/scène.
* <p>
* - POST /api/campaigns/{id}/import-structure/stream (multipart) → SSE de la
* proposition (progress + done). Ne persiste rien.
* - POST /api/campaigns/{id}/import-structure/apply (JSON arbre révisé) →
* crée les entités et renvoie le récapitulatif.
*/
@RestController
@RequestMapping("/api/campaigns/{campaignId}/import-structure")
public class CampaignImportController {
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;
private final CampaignImportService campaignImportService;
private final TaskExecutor taskExecutor;
private final ObjectMapper objectMapper;
public CampaignImportController(
CampaignImportService campaignImportService,
@Qualifier("applicationTaskExecutor") TaskExecutor taskExecutor,
ObjectMapper objectMapper) {
this.campaignImportService = campaignImportService;
this.taskExecutor = taskExecutor;
this.objectMapper = objectMapper;
}
@PostMapping(value = "/stream",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter importStream(
@PathVariable String campaignId,
@RequestParam("file") MultipartFile file) throws IOException {
SseEmitter emitter = new SseEmitter(IMPORT_SSE_TIMEOUT_MS);
if (file == null || file.isEmpty()) {
sendError(emitter, "Fichier PDF vide.");
return emitter;
}
byte[] bytes = file.getBytes();
String filename = file.getOriginalFilename();
taskExecutor.execute(() -> {
try {
campaignImportService.importStructureStreaming(
bytes, filename,
progress -> sendEvent(emitter, "progress", progress),
proposal -> {
sendEvent(emitter, "done", proposal);
emitter.complete();
},
error -> {
log.warn("Import campagne (stream) échoué : {}", error.getMessage());
sendError(emitter, error.getMessage());
});
} catch (Exception e) {
log.warn("Import campagne (stream) échoué : {}", e.getMessage());
sendError(emitter, e.getMessage());
}
});
return emitter;
}
@PostMapping(value = "/apply", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<CampaignImportService.ApplyResult> apply(
@PathVariable String campaignId,
@RequestBody CampaignImportProposal proposal) {
try {
CampaignImportService.ApplyResult result =
campaignImportService.applyStructure(campaignId, proposal);
return ResponseEntity.ok(result);
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
// --- Helpers SSE ---------------------------------------------------------
private void sendEvent(SseEmitter emitter, String eventName, Object payload) {
try {
emitter.send(SseEmitter.event().name(eventName).data(
objectMapper.writeValueAsString(payload), MediaType.APPLICATION_JSON));
} catch (IOException e) {
emitter.completeWithError(e);
}
}
private void sendError(SseEmitter emitter, String message) {
try {
emitter.send(SseEmitter.event().name("error").data(
objectMapper.writeValueAsString(Map.of(
"message", message != null ? message : "Erreur inconnue.")),
MediaType.APPLICATION_JSON));
emitter.complete();
} catch (IOException e) {
emitter.completeWithError(e);
}
}
}

View File

@@ -1,33 +1,59 @@
package com.loremind.infrastructure.web.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.application.gamesystemcontext.GameSystemService;
import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.gamesystemcontext.RulesImportProgress;
import com.loremind.domain.gamesystemcontext.RulesImportResult;
import com.loremind.domain.gamesystemcontext.ports.RulesImportException;
import com.loremind.domain.shared.template.TemplateField;
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
import com.loremind.infrastructure.web.dto.gamesystemcontext.RulesImportResponseDTO;
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
import com.loremind.infrastructure.web.mapper.GameSystemMapper;
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.task.TaskExecutor;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/game-systems")
public class GameSystemController {
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;
private final GameSystemService gameSystemService;
private final GameSystemMapper gameSystemMapper;
private final TemplateFieldMapper templateFieldMapper;
private final TaskExecutor taskExecutor;
private final ObjectMapper objectMapper;
public GameSystemController(GameSystemService gameSystemService,
GameSystemMapper gameSystemMapper,
TemplateFieldMapper templateFieldMapper) {
TemplateFieldMapper templateFieldMapper,
@Qualifier("applicationTaskExecutor") TaskExecutor taskExecutor,
ObjectMapper objectMapper) {
this.gameSystemService = gameSystemService;
this.gameSystemMapper = gameSystemMapper;
this.templateFieldMapper = templateFieldMapper;
this.taskExecutor = taskExecutor;
this.objectMapper = objectMapper;
}
@PostMapping
@@ -71,6 +97,96 @@ public class GameSystemController {
return ResponseEntity.noContent().build();
}
/**
* Import d'un PDF de règles → proposition de sections (titre → markdown).
* Ne persiste RIEN : l'UI présente la proposition pour révision/édition,
* puis l'utilisateur enregistre le GameSystem via les endpoints habituels.
*/
@PostMapping(value = "/import-rules", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<RulesImportResponseDTO> importRules(@RequestParam("file") MultipartFile file) {
if (file == null || file.isEmpty()) {
return ResponseEntity.badRequest().build();
}
try {
RulesImportResult result = gameSystemService.importRulesFromPdf(
file.getBytes(), file.getOriginalFilename());
return ResponseEntity.ok(new RulesImportResponseDTO(
result.sections(), result.pageCount(), result.ocrPageCount()));
} catch (IOException e) {
return ResponseEntity.badRequest().build();
} catch (RulesImportException e) {
// Brain injoignable / LLM en erreur / PDF illisible : 502 (dépendance amont).
// On loggue la vraie cause (message du Brain propagé) pour le diagnostic.
log.warn("Import de règles échoué : {}", e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_GATEWAY).build();
}
}
/**
* Variante streamée de l'import : émet l'avancement (SSE) puis le résultat,
* pour que l'UI affiche une progression pendant un import long.
* <p>
* Événements : {@code progress} (current/total/pageCount/ocrPageCount/newSectionTitles),
* {@code done} (sections + compteurs), {@code error} (message).
*/
@PostMapping(value = "/import-rules/stream",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter importRulesStream(@RequestParam("file") MultipartFile file) throws IOException {
SseEmitter emitter = new SseEmitter(IMPORT_SSE_TIMEOUT_MS);
if (file == null || file.isEmpty()) {
sendImportError(emitter, "Fichier PDF vide.");
return emitter;
}
// Les octets sont lus sur le thread servlet (le MultipartFile n'est plus
// disponible une fois la requête asynchrone) avant de partir en tâche de fond.
byte[] bytes = file.getBytes();
String filename = file.getOriginalFilename();
taskExecutor.execute(() -> {
try {
gameSystemService.importRulesFromPdfStreaming(
bytes, filename,
progress -> sendImportEvent(emitter, "progress", progress),
result -> {
sendImportEvent(emitter, "done", result);
emitter.complete();
},
error -> {
log.warn("Import de règles (stream) échoué : {}", error.getMessage());
sendImportError(emitter, error.getMessage());
});
} catch (Exception e) {
log.warn("Import de règles (stream) échoué : {}", e.getMessage());
sendImportError(emitter, e.getMessage());
}
});
return emitter;
}
/** Sérialise `payload` en JSON et l'envoie comme évènement SSE nommé. */
private void sendImportEvent(SseEmitter emitter, String eventName, Object payload) {
try {
emitter.send(SseEmitter.event().name(eventName).data(
objectMapper.writeValueAsString(payload), MediaType.APPLICATION_JSON));
} catch (IOException e) {
emitter.completeWithError(e);
}
}
/** Envoie un évènement `error` {message} puis termine le flux. */
private void sendImportError(SseEmitter emitter, String message) {
try {
emitter.send(SseEmitter.event().name("error").data(
objectMapper.writeValueAsString(Map.of(
"message", message != null ? message : "Erreur inconnue.")),
MediaType.APPLICATION_JSON));
emitter.complete();
} catch (IOException e) {
emitter.completeWithError(e);
}
}
private GameSystemService.GameSystemData toData(GameSystemDTO dto) {
return new GameSystemService.GameSystemData(
dto.getName(),

View File

@@ -140,6 +140,11 @@ public class SettingsController {
return forward(HttpMethod.GET, "/models/onemin", null);
}
@GetMapping("/models/openrouter")
public ResponseEntity<Map<String, Object>> listOpenRouterModels() {
return forward(HttpMethod.GET, "/models/openrouter", null);
}
/**
* Serialiseur JSON minimal pour eviter d'instancier ObjectMapper a chaque
* appel. Suffisant pour notre cas d'usage : Map<String,Object> avec des

View File

@@ -0,0 +1,16 @@
package com.loremind.infrastructure.web.dto.gamesystemcontext;
import java.util.Map;
/**
* Réponse de l'import d'un PDF de règles : proposition de sections à réviser.
*
* @param sections titre de section → contenu markdown (non persisté).
* @param pageCount nombre de pages extraites du PDF.
* @param ocrPageCount nombre de pages ayant nécessité l'OCR (0 = born-digital).
*/
public record RulesImportResponseDTO(
Map<String, String> sections,
int pageCount,
int ocrPageCount) {
}

View File

@@ -35,6 +35,9 @@ spring.web.cors.allow-credentials=true
# Configuration du Brain (service IA Python)
brain.base-url=${BRAIN_BASE_URL:http://localhost:8000}
brain.timeout-seconds=120
# Timeout dedie a l'import de PDF (regles/campagne) : map-reduce LLM sur un
# livre entier => potentiellement plusieurs minutes. Surchargeable via env.
brain.import-timeout-seconds=${BRAIN_IMPORT_TIMEOUT_SECONDS:600}
# Secret partage Core <-> Brain (auth inter-service via entete X-Internal-Secret).
# OBLIGATOIRE en prod : si non defini, le Brain rejette toutes les requetes (fail-closed).
@@ -53,9 +56,10 @@ minio.access-key=${MINIO_ACCESS_KEY:minioadmin}
minio.secret-key=${MINIO_SECRET_KEY:minioadmin}
minio.bucket=${MINIO_BUCKET:loremind-images}
# Limites d'upload d'images (MB)
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB
# Limites d'upload multipart (MB) : images ET PDF de regles. Releve a 64 Mo
# pour accepter les livres de regles illustres (le Brain plafonne aussi a 60 Mo).
spring.servlet.multipart.max-file-size=64MB
spring.servlet.multipart.max-request-size=64MB
# Mode demo : masque Settings/Export cote front et bloque les PUT /api/settings
# cote serveur. Activer via DEMO_MODE=true sur les deploiements publics.

View File

@@ -1,15 +1,16 @@
package com.loremind.application.campaigncontext;
import com.loremind.application.playcontext.PlaythroughService;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.campaigncontext.Character;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import com.loremind.domain.playcontext.Playthrough;
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -42,7 +43,9 @@ public class CampaignServiceTest {
@Mock
private SceneRepository sceneRepository;
@Mock
private CharacterRepository characterRepository;
private PlaythroughRepository playthroughRepository;
@Mock
private PlaythroughService playthroughService;
@InjectMocks
private CampaignService campaignService;
@@ -222,7 +225,7 @@ public class CampaignServiceTest {
verify(arcRepository, never()).deleteById(anyString());
verify(chapterRepository, never()).deleteById(anyString());
verify(sceneRepository, never()).deleteById(anyString());
verify(characterRepository, never()).deleteById(anyString());
verify(playthroughService, never()).delete(anyString());
}
@Test
@@ -249,13 +252,15 @@ public class CampaignServiceTest {
}
@Test
void testDeleteCampaign_CascadesCharacters() {
Character pc = Character.builder().id("char-1").campaignId("campaign-1").name("Alric").build();
when(characterRepository.findByCampaignId("campaign-1")).thenReturn(List.of(pc));
void testDeleteCampaign_CascadesPlaythroughs() {
// Depuis Playthrough : les PJ/sessions ne sont plus rattachés à la campagne ;
// la suppression cascade sur les Parties, qui cascadent elles-mêmes.
Playthrough play = Playthrough.builder().id("play-1").campaignId("campaign-1").name("Partie principale").build();
when(playthroughRepository.findByCampaignId("campaign-1")).thenReturn(List.of(play));
campaignService.deleteCampaign("campaign-1");
verify(characterRepository).deleteById("char-1");
verify(playthroughService).delete("play-1");
verify(campaignRepository).deleteById("campaign-1");
}
@@ -267,20 +272,20 @@ public class CampaignServiceTest {
Scene s1 = Scene.builder().id("s-1").chapterId("chap-1").name("S1").build();
Scene s2 = Scene.builder().id("s-2").chapterId("chap-2").name("S2").build();
Scene s3 = Scene.builder().id("s-3").chapterId("chap-2").name("S3").build();
Character pc = Character.builder().id("char-1").campaignId("campaign-1").name("Alric").build();
Playthrough play = Playthrough.builder().id("play-1").campaignId("campaign-1").name("Partie principale").build();
when(arcRepository.findByCampaignId("campaign-1")).thenReturn(List.of(arc));
when(chapterRepository.findByArcId("arc-1")).thenReturn(List.of(c1, c2));
when(sceneRepository.findByChapterId("chap-1")).thenReturn(List.of(s1));
when(sceneRepository.findByChapterId("chap-2")).thenReturn(List.of(s2, s3));
when(characterRepository.findByCampaignId("campaign-1")).thenReturn(List.of(pc));
when(playthroughRepository.findByCampaignId("campaign-1")).thenReturn(List.of(play));
CampaignService.DeletionImpact impact = campaignService.getDeletionImpact("campaign-1");
assertEquals(1, impact.arcs());
assertEquals(2, impact.chapters());
assertEquals(3, impact.scenes());
assertEquals(1, impact.characters());
assertEquals(1, impact.playthroughs());
}
@Test

View File

@@ -151,11 +151,11 @@ public class CampaignStructuralContextBuilderTest {
@Test
void testBuild_ProjectsCharactersAndNpcsWithSnippets() {
Character pj1 = Character.builder().id("c-1").campaignId("camp-1").order(1)
Character pj1 = Character.builder().id("c-1").playthroughId("play-1").order(1)
.name("Aragorn")
.values(new java.util.HashMap<>(java.util.Map.of("Histoire", "# Aragorn\n\nRôdeur du Nord, héritier d'Isildur.")))
.build();
Character pj2 = Character.builder().id("c-2").campaignId("camp-1").order(2)
Character pj2 = Character.builder().id("c-2").playthroughId("play-1").order(2)
.name("Legolas")
.values(null) // pas de snippet → string vide
.build();
@@ -170,10 +170,10 @@ public class CampaignStructuralContextBuilderTest {
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
when(characterRepository.findByCampaignId("camp-1")).thenReturn(List.of(pj2, pj1));
when(characterRepository.findByPlaythroughId("play-1")).thenReturn(List.of(pj2, pj1));
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(npc1, npc2));
CampaignStructuralContext ctx = builder.build("camp-1");
CampaignStructuralContext ctx = builder.build("camp-1", "play-1");
// PJ triés par order croissant
assertEquals(2, ctx.characters().size());

View File

@@ -25,7 +25,8 @@ class CampaignStructuralContextTest {
"L'auberge",
"Rencontre tendue avec le tavernier",
2,
List.of(branch));
List.of(branch),
List.of());
ChapterSummary chapter = new ChapterSummary(
"L'arrivee",
@@ -77,7 +78,7 @@ class CampaignStructuralContextTest {
void illustrationCount_defaultsToZero_onAllSummaryTypes() {
ArcSummary arc = new ArcSummary("X", null, 0, List.of());
ChapterSummary chapter = new ChapterSummary("X", null, 0, List.of());
SceneSummary scene = new SceneSummary("X", null, 0, List.of());
SceneSummary scene = new SceneSummary("X", null, 0, List.of(), List.of());
assertEquals(0, arc.illustrationCount());
assertEquals(0, chapter.illustrationCount());

View File

@@ -163,7 +163,7 @@ class BrainChatPayloadBuilderTest {
@SuppressWarnings("unchecked")
void build_campaignContext_serializesFullNarrativeTree() {
BranchHint branch = new BranchHint("fuite", "La poursuite", "HP < 50%");
SceneSummary scene = new SceneSummary("L'auberge", "Rencontre tendue", 3, List.of(branch));
SceneSummary scene = new SceneSummary("L'auberge", "Rencontre tendue", 3, List.of(branch), List.of());
ChapterSummary chapter = new ChapterSummary("L'arrivee", "...", 0, List.of(scene));
ArcSummary arc = new ArcSummary("Acte I", "Mise en place", 1, List.of(chapter));
CampaignStructuralContext camp = new CampaignStructuralContext(
@@ -213,7 +213,7 @@ class BrainChatPayloadBuilderTest {
@Test
@SuppressWarnings("unchecked")
void build_sceneSummary_omitsBranches_whenEmpty() {
SceneSummary scene = new SceneSummary("S", "", 0, List.of());
SceneSummary scene = new SceneSummary("S", "", 0, List.of(), List.of());
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter));
CampaignStructuralContext camp = new CampaignStructuralContext(
@@ -232,7 +232,7 @@ class BrainChatPayloadBuilderTest {
@SuppressWarnings("unchecked")
void build_branchHint_omitsCondition_whenBlank() {
BranchHint branch = new BranchHint("X", "Y", " ");
SceneSummary scene = new SceneSummary("S", "", 0, List.of(branch));
SceneSummary scene = new SceneSummary("S", "", 0, List.of(branch), List.of());
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter));
CampaignStructuralContext camp = new CampaignStructuralContext(

View File

@@ -5,7 +5,9 @@ server {
root /usr/share/nginx/html;
index index.html;
client_max_body_size 10M;
# Upload max : aligné sur la limite multipart du Core (64 Mo). Couvre les
# imports/adaptations de PDF (livres de règles/campagne illustrés) et les images.
client_max_body_size 64M;
location /api/ {
proxy_pass http://core:8080/api/;
@@ -15,9 +17,11 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Streaming SSE (chat, import, adaptation) : pas de buffering, et timeouts
# longs car une génération sur gros PDF peut tarder avant le 1er octet.
proxy_buffering off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
# index.html : toujours revalide. Empeche un navigateur qui a precedemment

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "loremind-web",
"version": "0.9.1-beta",
"version": "0.10.2-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "loremind-web",
"version": "0.9.1-beta",
"version": "0.10.2-beta",
"dependencies": {
"@angular/animations": "^17.0.0",
"@angular/common": "^17.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "loremind-web",
"version": "0.9.1-beta",
"version": "0.10.2-beta",
"description": "LoreMind Frontend - Angular",
"scripts": {
"ng": "ng",

View File

@@ -6,6 +6,7 @@
<ng-container *ngIf="sidebarConfig$ | async as config">
<app-secondary-sidebar
[title]="config.title"
[titleRoute]="config.titleRoute || null"
[items]="config.items"
[createActions]="config.createActions"
[bottomPanel]="config.bottomPanel || null">

View File

@@ -16,11 +16,13 @@ export const routes: Routes = [
{ path: 'lore/:loreId/pages/:pageId/edit', loadComponent: () => import('./lore/page-edit/page-edit.component').then(m => m.PageEditComponent) },
{ path: 'campaigns', loadComponent: () => import('./campaigns/campaigns.component').then(m => m.CampaignsComponent) },
{ path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) },
{ path: 'campaigns/:campaignId/import', loadComponent: () => import('./campaigns/campaign/campaign-import/campaign-import.component').then(m => m.CampaignImportComponent) },
{ path: 'campaigns/:campaignId/adapt', loadComponent: () => import('./campaigns/campaign/campaign-adapt/campaign-adapt.component').then(m => m.CampaignAdaptComponent) },
{ path: 'campaigns/:campaignId/playthroughs/:playthroughId', loadComponent: () => import('./campaigns/playthrough/playthrough-detail/playthrough-detail.component').then(m => m.PlaythroughDetailComponent) },
{ path: 'campaigns/:campaignId/playthroughs/:playthroughId/flags', loadComponent: () => import('./campaigns/playthrough/playthrough-flags-page/playthrough-flags-page.component').then(m => m.PlaythroughFlagsPageComponent) },
{ path: 'campaigns/:campaignId/characters/create', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
{ path: 'campaigns/:campaignId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
{ path: 'campaigns/:campaignId/characters/:characterId', loadComponent: () => import('./campaigns/character/character-view/character-view.component').then(m => m.CharacterViewComponent) },
{ 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', loadComponent: () => import('./campaigns/character/character-view/character-view.component').then(m => m.CharacterViewComponent) },
{ 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', loadComponent: () => import('./campaigns/npc/npc-view/npc-view.component').then(m => m.NpcViewComponent) },

View File

@@ -39,7 +39,7 @@
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Quêtes du hub (scénario)</h2>
<p class="view-section-empty" *ngIf="hubQuests.length === 0">
Aucune quête pour ce hub. Créez un chapitre pour ajouter une première quête.
Aucune quête pour ce hub. Créez-en une pour démarrer.
</p>
<div class="hub-quest-grid" *ngIf="hubQuests.length > 0">

View File

@@ -82,30 +82,9 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
// IDs préfixés par type pour éviter les collisions dans LayoutService.expanded
// (chaque entité a sa propre séquence IDENTITY en base → arc.id=1 et chapter.id=1
// peuvent coexister et se marchaient sur les pieds dans le Set<string> global).
const sortedCharacters = [...data.characters].sort(byName);
const characterItems: TreeItem[] = sortedCharacters.map(ch => ({
id: `character-${ch.id}`,
label: ch.name,
route: `/campaigns/${campaignId}/characters/${ch.id}`
}));
const charactersNode: TreeItem = {
id: 'characters-root',
label: 'PJ',
iconKey: 'users',
children: characterItems,
meta: characterItems.length ? String(characterItems.length) : undefined,
sectionHeaderBefore: 'Personnages',
// Note : le section header "Personnages" est porté par le premier nœud (PJ).
// Le filet au-dessus est masqué par CSS si c'est le tout premier item de la sidebar.
createActions: [{
id: 'new-character',
label: 'Nouveau PJ',
route: `/campaigns/${campaignId}/characters/create`,
actionIcon: 'plus'
}]
};
// Note refonte Playthrough : les PJ ne sont plus rattachés à la campagne mais
// à une Partie (Playthrough). On ne les affiche donc plus dans la sidebar de
// campagne — seuls les PNJ (donnée de scénario) restent sous "Personnages".
const sortedNpcs = [...data.npcs].sort(byName);
const npcItems: TreeItem[] = sortedNpcs.map(n => ({
id: `npc-${n.id}`,
@@ -119,7 +98,9 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
iconKey: 'c-drama',
children: npcItems,
meta: npcItems.length ? String(npcItems.length) : undefined,
// Pas de sectionHeaderBefore : on reste sous le header "Personnages" du nœud PJ.
// 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.
sectionHeaderBefore: 'Personnages',
createActions: [{
id: 'new-npc',
label: 'Nouveau PNJ',
@@ -146,6 +127,8 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
id: `chapter-${ch.id}`,
label: ch.name,
iconKey: ch.icon ?? undefined,
// Cadenas si le chapitre porte des conditions de déblocage (hub ou linéaire).
meta: (ch.prerequisites?.length ?? 0) > 0 ? '🔒' : undefined,
children: sceneItems,
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}`,
createActions: [{
@@ -166,14 +149,15 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
createActions: [{
id: `new-chapter-${arc.id}`,
label: 'Nouveau chapitre',
// Dans un arc hub, un "chapitre" est présenté comme une "quête".
label: arc.type === 'HUB' ? 'Nouvelle quête' : 'Nouveau chapitre',
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/create`,
actionIcon: 'plus'
}]
};
});
return [...arcNodes, charactersNode, npcsNode];
return [...arcNodes, npcsNode];
}
/**
@@ -197,6 +181,8 @@ export function buildCampaignSidebarConfig(
}));
return {
title: campaign.name,
// Titre cliquable → accueil de la campagne (raccourci depuis n'importe quelle sous-page).
titleRoute: `/campaigns/${campaignId}`,
items: buildCampaignTree(campaignId, treeData),
footerLabel: 'Toutes les campagnes',
createActions: [

View File

@@ -0,0 +1,61 @@
<div class="adapt-page">
<div class="page-header">
<button type="button" class="btn-back" (click)="back()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour à la campagne
</button>
<h1>Adapter un PDF à cette campagne</h1>
<p class="subtitle">
L'IA connaît votre campagne (structure, PNJ, univers) et lit le PDF, puis discute avec vous
pour l'intégrer et l'adapter. Ce sont des <strong>conseils</strong> : rien n'est créé,
vous appliquez ce qui vous plaît — et vous pouvez lui répondre pour qu'elle ajuste.
</p>
</div>
<!-- Choix / changement du PDF -->
<section class="upload-bar">
<input #pdfInput type="file" accept="application/pdf,.pdf" hidden (change)="onPdfSelected($event)" />
<button type="button" class="btn-primary" [disabled]="streaming" (click)="pdfInput.click()">
<lucide-icon [img]="Upload" [size]="15"></lucide-icon>
{{ hasConversation ? 'Changer de PDF' : 'Choisir un PDF à adapter' }}
</button>
<span class="file-name" *ngIf="fileName">{{ fileName }}</span>
</section>
<p class="adapt-error" *ngIf="error">{{ error }}</p>
<!-- Conversation -->
<section class="chat" *ngIf="hasConversation">
<div class="msg" *ngFor="let m of messages; let i = index"
[class.msg--user]="m.role === 'user'" [class.msg--assistant]="m.role === 'assistant'">
<div class="msg-role">{{ m.role === 'user' ? 'Vous' : 'IA' }}</div>
<div class="msg-body" *ngIf="m.role === 'user'">{{ m.content }}</div>
<div class="msg-body markdown-body" *ngIf="m.role === 'assistant'" [innerHTML]="m.content | markdown"></div>
<div class="msg-actions" *ngIf="m.role === 'assistant' && m.content && !(streaming && i === messages.length - 1)">
<button type="button" class="btn-copy" (click)="copy(i)">
<lucide-icon [img]="copiedIndex === i ? Check : Copy" [size]="12"></lucide-icon>
{{ copiedIndex === i ? 'Copié' : 'Copier' }}
</button>
</div>
<span class="streaming-cursor" *ngIf="streaming && i === messages.length - 1 && m.role === 'assistant'"></span>
</div>
</section>
<!-- Saisie d'un feedback -->
<section class="composer" *ngIf="hasConversation">
<textarea
[(ngModel)]="input"
[disabled]="streaming"
rows="2"
placeholder="Répondez à l'IA : corrigez, demandez une alternative, un autre lieu à intégrer…"
(keydown.enter)="$event.preventDefault(); sendCurrent()"></textarea>
<button type="button" class="btn-send" [disabled]="streaming || !input.trim()" (click)="sendCurrent()">
<lucide-icon [img]="Send" [size]="16"></lucide-icon>
</button>
</section>
</div>

View File

@@ -0,0 +1,184 @@
.adapt-page {
padding: 2rem 2.5rem;
max-width: 900px;
}
.page-header {
margin-bottom: 1.25rem;
h1 { margin: 0.6rem 0 0.3rem; font-size: 1.5rem; color: #f3f4f6; }
}
.btn-back {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: transparent;
border: none;
color: #9ca3af;
cursor: pointer;
font-size: 0.85rem;
padding: 0;
&:hover { color: #c4b5fd; }
}
.subtitle { margin: 0; color: #9ca3af; font-size: 0.9rem; strong { color: #d1d5db; } }
// --- Barre PDF --------------------------------------------------------------
.upload-bar {
display: flex;
align-items: center;
gap: 0.8rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.6rem 1.1rem;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
border: none;
background: #6c63ff;
color: white;
&:hover:not(:disabled) { background: #5b52e0; }
&:disabled { opacity: 0.6; cursor: progress; }
}
.file-name { color: #9ca3af; font-size: 0.82rem; }
.adapt-error {
margin: 0 0 1rem;
padding: 0.55rem 0.8rem;
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.35);
border-radius: 8px;
color: #fca5a5;
font-size: 0.85rem;
}
// --- Conversation -----------------------------------------------------------
.chat {
display: flex;
flex-direction: column;
gap: 0.9rem;
margin-bottom: 1rem;
}
.msg {
border-radius: 12px;
padding: 0.7rem 0.9rem;
border: 1px solid #1f2937;
&--user {
background: rgba(108, 99, 255, 0.1);
border-color: rgba(108, 99, 255, 0.3);
align-self: flex-end;
max-width: 85%;
}
&--assistant {
background: #0b1220;
}
}
.msg-role {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #9ca3af;
margin-bottom: 0.35rem;
}
.msg-body {
color: #d1d5db;
font-size: 0.92rem;
line-height: 1.6;
white-space: pre-wrap;
&.markdown-body { white-space: normal; }
::ng-deep {
h1, h2, h3 { color: #f3f4f6; margin: 0.9rem 0 0.4rem; }
h2 { font-size: 1.08rem; }
h3 { font-size: 1rem; }
ul, ol { padding-left: 1.3rem; }
li { margin: 0.2rem 0; }
strong { color: #fff; }
code { background: #1f2937; padding: 0.1rem 0.3rem; border-radius: 4px; font-size: 0.85em; }
a { color: #a78bfa; }
p { margin: 0.4rem 0; }
}
}
.msg-actions { margin-top: 0.5rem; }
.btn-copy {
display: inline-flex;
align-items: center;
gap: 0.3rem;
background: transparent;
border: 1px solid #374151;
border-radius: 6px;
color: #9ca3af;
cursor: pointer;
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
&:hover { color: #c4b5fd; border-color: #6c63ff; }
}
.streaming-cursor {
display: inline-block;
color: #6c63ff;
animation: blink 1s step-start infinite;
}
@keyframes blink { 50% { opacity: 0; } }
// --- Composer ---------------------------------------------------------------
.composer {
display: flex;
gap: 0.5rem;
align-items: flex-end;
position: sticky;
bottom: 0;
padding-top: 0.5rem;
background: linear-gradient(to top, var(--color-bg, #0a0f1a) 70%, transparent);
textarea {
flex: 1;
background: #0b1220;
border: 1px solid #1f2937;
border-radius: 8px;
color: #f3f4f6;
padding: 0.55rem 0.7rem;
font-size: 0.9rem;
font-family: inherit;
resize: vertical;
&:focus { outline: none; border-color: #6c63ff; }
&:disabled { opacity: 0.6; }
}
}
.btn-send {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 8px;
border: none;
background: #6c63ff;
color: white;
cursor: pointer;
&:hover:not(:disabled) { background: #5b52e0; }
&:disabled { opacity: 0.5; cursor: default; }
}

View File

@@ -0,0 +1,130 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, ArrowLeft, Upload, Copy, Check, Send } from 'lucide-angular';
import { CampaignAdaptService, AdaptMessage } from '../../../services/campaign-adapt.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { PageTitleService } from '../../../services/page-title.service';
import { MarkdownPipe } from '../../../shared/markdown.pipe';
const FIRST_PROMPT = 'Propose-moi comment intégrer et adapter ce PDF à ma campagne.';
/**
* Page « Adapter un PDF » — CONVERSATIONNELLE. L'IA connaît la campagne (structure,
* PNJ, univers) + lit le PDF, propose une 1re adaptation, puis l'utilisateur peut
* répondre (corriger, demander des alternatives…) et l'IA rebondit.
* Route : /campaigns/:campaignId/adapt — rien n'est créé, conseils à appliquer à la main.
*/
@Component({
selector: 'app-campaign-adapt',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule, MarkdownPipe],
templateUrl: './campaign-adapt.component.html',
styleUrls: ['./campaign-adapt.component.scss']
})
export class CampaignAdaptComponent implements OnInit {
readonly ArrowLeft = ArrowLeft;
readonly Upload = Upload;
readonly Copy = Copy;
readonly Check = Check;
readonly Send = Send;
campaignId = '';
/** PDF choisi, conservé pour les tours de conversation suivants. */
private file: File | null = null;
fileName = '';
/** Conversation affichée (user + assistant). */
messages: AdaptMessage[] = [];
streaming = false;
error: string | null = null;
/** Saisie du message en cours. */
input = '';
copiedIndex: number | null = null;
constructor(
private route: ActivatedRoute,
private router: Router,
private service: CampaignAdaptService,
private campaignSidebar: CampaignSidebarService,
private pageTitle: PageTitleService
) {}
ngOnInit(): void {
this.campaignId = this.route.snapshot.paramMap.get('campaignId')!;
this.pageTitle.set('Adapter un PDF');
this.campaignSidebar.show(this.campaignId);
}
get hasConversation(): boolean { return this.messages.length > 0; }
// --- Choix du PDF (démarre / réinitialise la conversation) ---------------
onPdfSelected(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
this.file = file;
this.fileName = file.name;
this.messages = [];
this.error = null;
this.send(FIRST_PROMPT);
}
// --- Envoi d'un message (1er tour ou feedback) ---------------------------
sendCurrent(): void {
const text = this.input.trim();
if (!text || this.streaming) return;
this.input = '';
this.send(text);
}
private send(text: string): void {
if (!this.file || this.streaming) return;
this.error = null;
this.messages.push({ role: 'user', content: text });
// Historique envoyé = tout jusqu'au message user inclus (sans la bulle vide).
const payload: AdaptMessage[] = this.messages.map(m => ({ role: m.role, content: m.content }));
const assistant: AdaptMessage = { role: 'assistant', content: '' };
this.messages.push(assistant);
this.streaming = true;
this.service.adviseStream(this.campaignId, this.file, payload).subscribe({
next: (ev) => {
if (ev.type === 'token') {
assistant.content += ev.value;
} else if (ev.type === 'done') {
this.streaming = false;
}
},
error: (err: Error) => {
this.streaming = false;
// Bulle assistant restée vide → on la retire pour ne pas afficher de vide.
if (!assistant.content) {
this.messages = this.messages.filter(m => m !== assistant);
}
this.error = err?.message ? `Échec : ${err.message}` : "Échec de l'adaptation.";
}
});
}
copy(index: number): void {
const msg = this.messages[index];
if (!msg) return;
navigator.clipboard?.writeText(msg.content).then(() => {
this.copiedIndex = index;
setTimeout(() => (this.copiedIndex = null), 2000);
});
}
back(): void {
this.router.navigate(['/campaigns', this.campaignId]);
}
}

View File

@@ -102,38 +102,9 @@
<h2>Personnages</h2>
</div>
<!-- Sous-section : Personnages joueurs (PJ) -->
<div class="persona-subsection">
<div class="subsection-header">
<h3>
<lucide-icon [img]="User" [size]="16"></lucide-icon>
Personnages joueurs
<span class="count-badge" *ngIf="characters.length > 0">{{ characters.length }}</span>
</h3>
<button class="btn-add" (click)="createCharacter()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouveau PJ
</button>
</div>
<div class="characters-grid" *ngIf="characters.length > 0">
<div class="character-card" *ngFor="let character of characters" (click)="viewCharacter(character)">
<lucide-icon [img]="User" [size]="20" class="character-icon"></lucide-icon>
<div class="character-info">
<span class="character-name">{{ character.name }}</span>
<span class="character-snippet">{{ personaSnippet(character) }}</span>
</div>
</div>
</div>
<div class="empty-state empty-state--compact" *ngIf="characters.length === 0">
<p>Aucun personnage joueur pour le moment.</p>
<button class="btn-add-first" (click)="createCharacter()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Créer votre premier PJ
</button>
</div>
</div>
<!-- Les PJ ne sont plus rattachés à la campagne mais à une Partie (Playthrough) :
ils se gèrent depuis la page d'une Partie. Ici on ne liste que les PNJ
(donnée de scénario, partagée par toutes les Parties). -->
<!-- Sous-section : Personnages non-joueurs (PNJ) -->
<div class="persona-subsection">
@@ -172,10 +143,20 @@
<section class="detail-section arcs-section" *ngIf="!editing">
<div class="section-header">
<h2>Arcs narratifs</h2>
<button class="btn-add" (click)="createArc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouvel arc
</button>
<div class="section-header-actions">
<button class="btn-add btn-add--secondary" (click)="adaptCampaign()" title="Conseils IA pour adapter un PDF à cette campagne">
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
Adapter un PDF
</button>
<button class="btn-add btn-add--secondary" (click)="importCampaign()" title="Générer l'arborescence depuis un PDF de campagne">
<lucide-icon [img]="Upload" [size]="14"></lucide-icon>
Importer un PDF
</button>
<button class="btn-add" (click)="createArc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouvel arc
</button>
</div>
</div>
<div class="arcs-grid" *ngIf="arcs.length > 0">

View File

@@ -268,6 +268,12 @@
h2 { color: #d1d5db; font-size: 1rem; font-weight: 600; }
}
.section-header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.btn-add {
display: flex;
align-items: center;
@@ -283,6 +289,14 @@
transition: background 0.2s;
&:hover { background: #5b52e0; }
// Variante secondaire (ex: "Importer un PDF") : discrète à côté de l'action primaire.
&--secondary {
background: #1f2937;
color: #e5e7eb;
border: 1px solid #374151;
&:hover { background: #273244; border-color: #6c63ff; }
}
}
.arcs-grid {

View File

@@ -2,7 +2,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, User, Dices, Drama, Check, Play } from 'lucide-angular';
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, Dices, Drama, Check, Play, Upload, Sparkles } from 'lucide-angular';
import { Router, RouterLink } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { catchError, switchMap, filter, map } from 'rxjs/operators';
@@ -16,7 +16,6 @@ import { SessionService } from '../../../services/session.service';
import { PlaythroughService } from '../../../services/playthrough.service';
import { Playthrough } from '../../../services/campaign.model';
import { Session } from '../../../services/session.model';
import { Character } from '../../../services/character.model';
import { Npc } from '../../../services/npc.model';
import { LayoutService } from '../../../services/layout.service';
import { PageTitleService } from '../../../services/page-title.service';
@@ -38,11 +37,12 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
readonly Globe = Globe;
readonly Pencil = Pencil;
readonly Trash2 = Trash2;
readonly User = User;
readonly Dices = Dices;
readonly Drama = Drama;
readonly Check = Check;
readonly Play = Play;
readonly Upload = Upload;
readonly Sparkles = Sparkles;
campaign: Campaign | null = null;
arcs: Arc[] = [];
@@ -56,8 +56,6 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
availableGameSystems: GameSystem[] = [];
/** GameSystem associé si `campaign.gameSystemId` est renseigné ; sinon null. */
linkedGameSystem: GameSystem | null = null;
/** Fiches de personnages (PJ) de la campagne. */
characters: Character[] = [];
/** Fiches de personnages non-joueurs (PNJ) de la campagne. */
npcs: Npc[] = [];
/** Sessions de jeu (passées et en cours) liées à cette campagne. */
@@ -73,8 +71,6 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
/** Parties (Playthroughs) de cette campagne. */
playthroughs: Playthrough[] = [];
/** Partie par défaut (1re) — fallback pour les actions session/PJ tant que l'UI Playthrough n'est pas finie. */
defaultPlaythroughId: string | null = null;
/** Mode édition inline. */
editing = false;
@@ -124,10 +120,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
this.campaign = campaign;
this.editing = false;
this.playthroughs = playthroughs;
this.defaultPlaythroughId = playthroughs.length > 0 ? playthroughs[0].id! : null;
this.loadLinkedLore(campaign);
this.loadLinkedGameSystem(campaign);
this.loadCharacters(campaign.id!);
this.loadNpcs(campaign.id!);
this.loadSessions(campaign.id!);
this.arcs = treeData.arcs;
@@ -162,10 +156,8 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
this.campaign = campaign;
this.editing = false;
this.playthroughs = playthroughs;
this.defaultPlaythroughId = playthroughs.length > 0 ? playthroughs[0].id! : null;
this.loadLinkedLore(campaign);
this.loadLinkedGameSystem(campaign);
this.loadCharacters(campaign.id!);
this.loadNpcs(campaign.id!);
this.loadSessions(campaign.id!);
this.arcs = treeData.arcs;
@@ -200,18 +192,7 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
).subscribe(gs => this.linkedGameSystem = gs);
}
/** Charge les PJ de la Partie par défaut (refonte Playthrough). */
private loadCharacters(_campaignId: string): void {
if (!this.defaultPlaythroughId) {
this.characters = [];
return;
}
this.characterService.getByPlaythrough(this.defaultPlaythroughId).pipe(
catchError(() => of([] as Character[]))
).subscribe(list => this.characters = list);
}
/** Symétrique pour les PNJ. */
/** Charge les PNJ de la campagne (les PJ vivent désormais dans une Partie). */
private loadNpcs(campaignId: string): void {
this.npcService.getByCampaign(campaignId).pipe(
catchError(() => of([] as Npc[]))
@@ -245,11 +226,6 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
});
}
createCharacter(): void {
if (!this.campaign) return;
this.router.navigate(['/campaigns', this.campaign.id, 'characters', 'create']);
}
createNpc(): void {
if (!this.campaign) return;
this.router.navigate(['/campaigns', this.campaign.id, 'npcs', 'create']);
@@ -260,17 +236,6 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
this.router.navigate(['/campaigns', this.campaign.id, 'npcs', npc.id, 'edit']);
}
editCharacter(character: Character): void {
if (!this.campaign || !character.id) return;
this.router.navigate(['/campaigns', this.campaign.id, 'characters', character.id, 'edit']);
}
/** Ouvre la vue lecture seule (style WorldAnvil) — clic sur la carte. */
viewCharacter(character: Character): void {
if (!this.campaign || !character.id) return;
this.router.navigate(['/campaigns', this.campaign.id, 'characters', character.id]);
}
viewNpc(npc: Npc): void {
if (!this.campaign || !npc.id) return;
this.router.navigate(['/campaigns', this.campaign.id, 'npcs', npc.id]);
@@ -281,6 +246,18 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
this.router.navigate(['/campaigns', this.campaign.id, 'arcs', 'create']);
}
/** Ouvre la page d'import d'un PDF de campagne (proposition d'arbre à réviser). */
importCampaign(): void {
if (!this.campaign) return;
this.router.navigate(['/campaigns', this.campaign.id, 'import']);
}
/** Ouvre la page de conseils d'adaptation d'un PDF à cette campagne. */
adaptCampaign(): void {
if (!this.campaign) return;
this.router.navigate(['/campaigns', this.campaign.id, 'adapt']);
}
openArc(arc: Arc): void {
if (!this.campaign || !arc.id) return;
this.router.navigate(['/campaigns', this.campaign.id, 'arcs', arc.id]);
@@ -306,11 +283,6 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
return '(Fiche vide)';
}
/** Alias gardé pour compatibilité avec les anciens templates. */
characterSnippet(c: Character): string {
return this.personaSnippet(c);
}
private showLayout(allCampaigns: Campaign[], data: CampaignTreeData): void {
this.layoutService.show(buildCampaignSidebarConfig(this.campaign!, allCampaigns, data, this.campaign!.id!));
}
@@ -385,9 +357,9 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
const newGameSystemId = this.editGameSystemId ? this.editGameSystemId : null;
const currentGameSystemId = this.campaign.gameSystemId ?? null;
const gameSystemChanged = newGameSystemId !== currentGameSystemId;
const hasSheets = this.characters.length > 0 || this.npcs.length > 0;
const hasSheets = this.npcs.length > 0;
if (gameSystemChanged && hasSheets) {
const count = this.characters.length + this.npcs.length;
const count = this.npcs.length;
this.confirmDialog.confirm({
title: 'Changer le systeme de jeu ?',
message:

View File

@@ -0,0 +1,183 @@
<div class="import-page">
<div class="page-header">
<button type="button" class="btn-back" (click)="cancel()">
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
Retour à la campagne
</button>
<h1>Importer un PDF de campagne</h1>
<p class="subtitle">
L'IA propose une arborescence arc → chapitre → scène. Vous la révisez et l'ajustez
avant de créer quoi que ce soit.
</p>
</div>
<!-- Étape 1 : upload (masqué une fois en revue) -->
<section class="upload-area" *ngIf="!reviewing">
<input #pdfInput type="file" accept="application/pdf,.pdf" hidden (change)="onPdfSelected($event)" />
<button type="button" class="btn-primary big" [disabled]="importing" (click)="pdfInput.click()">
<lucide-icon [img]="Upload" [size]="16"></lucide-icon>
{{ importing ? 'Import en cours…' : 'Choisir un PDF de campagne' }}
</button>
<!-- Progression live -->
<div class="import-progress" *ngIf="importing">
<p class="import-phase">{{ importPhase }}</p>
<div class="progress-bar" *ngIf="importProgress">
<div class="progress-fill"
[style.width.%]="importProgress.total ? (importProgress.current / importProgress.total) * 100 : 0">
</div>
</div>
<p class="import-counts" *ngIf="importCounts">
Trouvé jusqu'ici : {{ importCounts.arcs }} arc(s) · {{ importCounts.chapters }} chapitre(s) · {{ importCounts.scenes }} scène(s)
</p>
</div>
<p class="import-error" *ngIf="importError">{{ importError }}</p>
</section>
<!-- Étape 2 : revue de l'arbre éditable -->
<section class="review-area" *ngIf="reviewing">
<div class="review-header">
<p class="review-summary">
À créer : <strong>{{ arcCount }}</strong> nouvel(s) arc(s),
<strong>{{ chapterCount }}</strong> chapitre(s),
<strong>{{ sceneCount }}</strong> scène(s).
Les éléments <em>« déjà présent »</em> (grisés) sont affichés pour situer les ajouts ;
ils ne seront pas recréés. Modifiez les nouveaux, puis créez.
</p>
</div>
<div class="tree">
<!-- Arc -->
<div class="arc-card" *ngFor="let arc of tree; let ai = index">
<div class="node-head arc-head" [class.existing-node]="arc.existing">
<button type="button" class="btn-collapse" (click)="toggleArc(arc)">
<lucide-icon [img]="arc.collapsed ? ChevronRight : ChevronDown" [size]="16"></lucide-icon>
</button>
<lucide-icon [img]="Swords" [size]="16" class="node-icon"></lucide-icon>
<input type="text" class="node-name" [(ngModel)]="arc.name" [name]="'arc-' + ai"
[readonly]="arc.existing" placeholder="Nom de l'arc" />
<span class="badge-existing" *ngIf="arc.existing">déjà présent</span>
<button type="button" class="btn-remove" *ngIf="!arc.existing" (click)="removeArc(ai)" title="Supprimer l'arc">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</button>
</div>
<div class="node-body" *ngIf="!arc.collapsed">
<div class="arc-type-toggle" *ngIf="!arc.existing">
<span class="toggle-label">Type :</span>
<button type="button" class="type-btn" [class.active]="arc.type === 'LINEAR'"
(click)="setArcType(arc, 'LINEAR')">Linéaire</button>
<button type="button" class="type-btn" [class.active]="arc.type === 'HUB'"
(click)="setArcType(arc, 'HUB')">Hub (quêtes)</button>
</div>
<textarea class="node-desc" *ngIf="!arc.existing" [(ngModel)]="arc.description" [name]="'arc-desc-' + ai"
rows="2" placeholder="Synopsis de l'arc (optionnel)"></textarea>
<!-- Chapitres -->
<div class="chapter-card" *ngFor="let chapter of arc.chapters; let ci = index">
<div class="node-head chapter-head" [class.existing-node]="chapter.existing">
<button type="button" class="btn-collapse" (click)="toggleChapter(chapter)">
<lucide-icon [img]="chapter.collapsed ? ChevronRight : ChevronDown" [size]="14"></lucide-icon>
</button>
<lucide-icon [img]="BookOpen" [size]="14" class="node-icon"></lucide-icon>
<input type="text" class="node-name" [(ngModel)]="chapter.name" [name]="'chap-' + ai + '-' + ci"
[readonly]="chapter.existing" placeholder="Nom du chapitre" />
<span class="badge-existing" *ngIf="chapter.existing">déjà présent</span>
<button type="button" class="btn-remove" *ngIf="!chapter.existing" (click)="removeChapter(arc, ci)" title="Supprimer le chapitre">
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
</button>
</div>
<div class="node-body" *ngIf="!chapter.collapsed">
<textarea class="node-desc" *ngIf="!chapter.existing" [(ngModel)]="chapter.description" [name]="'chap-desc-' + ai + '-' + ci"
rows="2" placeholder="Synopsis du chapitre (optionnel)"></textarea>
<!-- Scènes -->
<div class="scene-card" *ngFor="let scene of chapter.scenes; let si = index"
[class.existing-node]="scene.existing">
<div class="scene-row">
<lucide-icon [img]="MapPin" [size]="13" class="node-icon scene-icon"></lucide-icon>
<div class="scene-fields">
<input type="text" class="node-name" [(ngModel)]="scene.name"
[name]="'scene-' + ai + '-' + ci + '-' + si" [readonly]="scene.existing"
placeholder="Nom de la scène" />
<input type="text" class="node-desc-inline" *ngIf="!scene.existing" [(ngModel)]="scene.description"
[name]="'scene-desc-' + ai + '-' + ci + '-' + si" placeholder="Synopsis (optionnel)" />
</div>
<span class="badge-existing" *ngIf="scene.existing">déjà présent</span>
<button type="button" class="btn-details" *ngIf="!scene.existing" (click)="toggleDetails(scene)"
title="Narration joueurs, notes MJ, pièces">
<lucide-icon [img]="scene.detailsOpen ? ChevronDown : ChevronRight" [size]="12"></lucide-icon>
Détails<span *ngIf="scene.rooms.length"> · {{ scene.rooms.length }} pièce(s)</span>
</button>
<button type="button" class="btn-remove" *ngIf="!scene.existing" (click)="removeScene(chapter, si)" title="Supprimer la scène">
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
</button>
</div>
<div class="scene-details" *ngIf="scene.detailsOpen && !scene.existing">
<label class="field-label">À lire aux joueurs</label>
<textarea class="node-desc" [(ngModel)]="scene.playerNarration"
[name]="'scene-pn-' + ai + '-' + ci + '-' + si" rows="3"
placeholder="Texte d'encadré lu aux joueurs (optionnel)"></textarea>
<label class="field-label">Notes MJ</label>
<textarea class="node-desc" [(ngModel)]="scene.gmNotes"
[name]="'scene-gm-' + ai + '-' + ci + '-' + si" rows="3"
placeholder="Secrets, développement, conséquences (optionnel)"></textarea>
<!-- Pièces (donjon) : vide = scène narrative classique -->
<span class="field-label">Pièces (lieu explorable)</span>
<div class="rooms">
<div class="room-row" *ngFor="let room of scene.rooms; let ri = index">
<input type="text" class="room-name" [(ngModel)]="room.name"
[name]="'room-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Pièce" />
<input type="text" class="room-field" [(ngModel)]="room.description"
[name]="'room-desc-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Description" />
<input type="text" class="room-field" [(ngModel)]="room.enemies"
[name]="'room-en-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Ennemis" />
<input type="text" class="room-field" [(ngModel)]="room.loot"
[name]="'room-loot-' + ai + '-' + ci + '-' + si + '-' + ri" placeholder="Trésor" />
<button type="button" class="btn-remove" (click)="removeRoom(scene, ri)" title="Supprimer la pièce">
<lucide-icon [img]="Trash2" [size]="12"></lucide-icon>
</button>
</div>
<button type="button" class="btn-add-inline" (click)="addRoom(scene)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> Pièce
</button>
</div>
</div>
</div>
<button type="button" class="btn-add-inline" (click)="addScene(chapter)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> Scène
</button>
</div>
</div>
<button type="button" class="btn-add-inline" (click)="addChapter(arc)">
<lucide-icon [img]="Plus" [size]="12"></lucide-icon> Chapitre
</button>
</div>
</div>
<button type="button" class="btn-add" (click)="addArc()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> Ajouter un arc
</button>
</div>
<p class="apply-error" *ngIf="applyError">{{ applyError }}</p>
<div class="review-actions">
<button type="button" class="btn-primary" [disabled]="applying || !hasNewContent" (click)="apply()">
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
{{ applying ? 'Création…' : 'Créer dans la campagne' }}
</button>
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
</div>
</section>
</div>

View File

@@ -0,0 +1,323 @@
.import-page {
padding: 2rem 2.5rem;
max-width: 900px;
}
.page-header {
margin-bottom: 1.5rem;
h1 { margin: 0.6rem 0 0.3rem; font-size: 1.5rem; color: #f3f4f6; }
}
.btn-back {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: transparent;
border: none;
color: #9ca3af;
cursor: pointer;
font-size: 0.85rem;
padding: 0;
&:hover { color: #c4b5fd; }
}
.subtitle { margin: 0; color: #9ca3af; font-size: 0.9rem; }
// --- Upload -----------------------------------------------------------------
.upload-area {
padding: 2rem;
border: 1px dashed #374151;
border-radius: 12px;
text-align: center;
background: #0b1220;
}
.btn-primary,
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.6rem 1.1rem;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
border: none;
}
.btn-primary {
background: #6c63ff;
color: white;
&:hover:not(:disabled) { background: #5b52e0; }
&:disabled { opacity: 0.6; cursor: progress; }
&.big { padding: 0.8rem 1.4rem; font-size: 0.95rem; }
}
.btn-secondary {
background: #1f2937;
color: #e5e7eb;
border: 1px solid #374151;
&:hover { background: #273244; }
}
.import-progress {
margin: 1.25rem auto 0;
max-width: 460px;
text-align: left;
}
.import-phase { margin: 0 0 0.5rem; color: #c4b5fd; font-size: 0.85rem; font-weight: 500; }
.progress-bar {
height: 6px;
background: #1f2937;
border-radius: 999px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #6c63ff;
border-radius: 999px;
transition: width 0.3s ease;
}
.import-counts { margin: 0.55rem 0 0; color: #9ca3af; font-size: 0.8rem; }
.import-error,
.apply-error {
margin: 1rem 0 0;
padding: 0.55rem 0.8rem;
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.35);
border-radius: 8px;
color: #fca5a5;
font-size: 0.85rem;
}
// --- Revue (arbre) ----------------------------------------------------------
.review-summary { margin: 0 0 1rem; color: #d1d5db; font-size: 0.9rem; strong { color: #fff; } em { color: #9ca3af; font-style: italic; } }
// Nœuds déjà présents dans la campagne : grisés, lecture seule.
.existing-node {
opacity: 0.6;
.node-name { background: transparent; border-color: transparent; cursor: default; }
}
.badge-existing {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #9ca3af;
background: rgba(255, 255, 255, 0.06);
border: 1px solid #1f2937;
border-radius: 999px;
padding: 0.1rem 0.45rem;
white-space: nowrap;
}
.tree { display: flex; flex-direction: column; gap: 0.75rem; }
.arc-card {
border: 1px solid #1f2937;
border-radius: 10px;
background: #0b1220;
overflow: hidden;
}
.node-head {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 0.7rem;
}
.arc-head { background: #111827; }
.chapter-head { background: rgba(255, 255, 255, 0.02); }
.btn-collapse {
background: transparent;
border: none;
color: #9ca3af;
cursor: pointer;
padding: 0;
display: flex;
&:hover { color: #c4b5fd; }
}
.node-icon { color: #a78bfa; flex-shrink: 0; }
.scene-icon { color: #6b7280; }
.node-name {
flex: 1;
background: #0b1220;
border: 1px solid #1f2937;
border-radius: 6px;
color: #f3f4f6;
padding: 0.35rem 0.5rem;
font-size: 0.88rem;
&:focus { outline: none; border-color: #6c63ff; }
}
.btn-remove {
background: transparent;
border: none;
color: #6b7280;
cursor: pointer;
padding: 0.2rem;
display: flex;
&:hover { color: #f87171; }
}
.node-body { padding: 0.5rem 0.7rem 0.7rem 1.7rem; display: flex; flex-direction: column; gap: 0.5rem; }
.node-desc,
.node-desc-inline {
background: #0b1220;
border: 1px solid #1f2937;
border-radius: 6px;
color: #d1d5db;
padding: 0.35rem 0.5rem;
font-size: 0.82rem;
font-family: inherit;
resize: vertical;
&:focus { outline: none; border-color: #6c63ff; }
}
.chapter-card {
border: 1px solid #1f2937;
border-radius: 8px;
overflow: hidden;
}
// Sélecteur Linéaire / Hub
.arc-type-toggle {
display: flex;
align-items: center;
gap: 0.4rem;
}
.toggle-label { color: #9ca3af; font-size: 0.8rem; }
.type-btn {
background: #0b1220;
border: 1px solid #1f2937;
border-radius: 6px;
color: #9ca3af;
cursor: pointer;
font-size: 0.78rem;
padding: 0.3rem 0.6rem;
&:hover { border-color: #6c63ff; }
&.active { background: rgba(108, 99, 255, 0.18); border-color: #6c63ff; color: #c4b5fd; font-weight: 600; }
}
.scene-card {
border: 1px solid #161e2e;
border-radius: 6px;
padding: 0.3rem 0.4rem;
}
.scene-row {
display: flex;
align-items: center;
gap: 0.4rem;
}
.btn-details {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: transparent;
border: 1px solid #1f2937;
border-radius: 6px;
color: #9ca3af;
cursor: pointer;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
white-space: nowrap;
&:hover { color: #c4b5fd; border-color: #6c63ff; }
}
.scene-details {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin: 0.4rem 0 0.2rem 1.5rem;
padding-left: 0.6rem;
border-left: 2px solid #1f2937;
}
.field-label {
color: #9ca3af;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.03em;
margin-top: 0.3rem;
}
.rooms {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.room-row {
display: flex;
align-items: center;
gap: 0.35rem;
}
.room-name,
.room-field {
background: #0b1220;
border: 1px solid #1f2937;
border-radius: 5px;
color: #d1d5db;
padding: 0.25rem 0.4rem;
font-size: 0.78rem;
&:focus { outline: none; border-color: #6c63ff; }
}
.room-name { flex: 0 0 22%; }
.room-field { flex: 1; min-width: 0; }
.scene-fields {
flex: 1;
display: flex;
gap: 0.4rem;
.node-name { flex: 0 0 40%; }
.node-desc-inline { flex: 1; }
}
.btn-add,
.btn-add-inline {
display: inline-flex;
align-items: center;
gap: 0.3rem;
background: transparent;
border: 1px dashed #374151;
border-radius: 6px;
color: #9ca3af;
cursor: pointer;
font-size: 0.8rem;
padding: 0.35rem 0.6rem;
align-self: flex-start;
&:hover { color: #c4b5fd; border-color: #6c63ff; }
}
.btn-add { margin-top: 0.25rem; }
.review-actions {
display: flex;
gap: 0.6rem;
margin-top: 1.5rem;
}

View File

@@ -0,0 +1,374 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import {
LucideAngularModule, ArrowLeft, Upload, Plus, Trash2,
ChevronDown, ChevronRight, Swords, BookOpen, MapPin, Check
} from 'lucide-angular';
import { CampaignImportService } from '../../../services/campaign-import.service';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { PageTitleService } from '../../../services/page-title.service';
import { ArcKind, ArcProposal, ChapterProposal, SceneProposal } from '../../../services/campaign-import.model';
import { CampaignImportProposal } from '../../../services/campaign-import.model';
import { loadCampaignTreeData, CampaignTreeData } from '../../campaign-tree.helper';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
/**
* Nœuds éditables (= proposition + état d'UI). `existing` = déjà présent dans la
* campagne (chargé pour la revue) : lecture seule, sert de parent aux ajouts.
* `existingId` = l'ID de l'entité existante (envoyé à l'apply pour s'y rattacher).
*/
interface RoomNode { name: string; description: string; enemies: string; loot: string; }
interface SceneNode {
name: string; description: string; playerNarration: string; gmNotes: string;
rooms: RoomNode[]; detailsOpen: boolean; existing: boolean; existingId?: string;
}
interface ChapterNode {
name: string; description: string; scenes: SceneNode[]; collapsed: boolean;
existing: boolean; existingId?: string;
}
interface ArcNode {
name: string; description: string; type: ArcKind; chapters: ChapterNode[]; collapsed: boolean;
existing: boolean; existingId?: string;
}
/**
* Page d'import d'un PDF de campagne → arbre arc/chapitre/scène.
* Route : /campaigns/:campaignId/import
*
* Flux : upload → progression streamée → arbre éditable (revue) → création.
* Rien n'est créé tant que l'utilisateur n'a pas validé « Créer dans la campagne ».
*/
@Component({
selector: 'app-campaign-import',
standalone: true,
imports: [CommonModule, FormsModule, LucideAngularModule],
templateUrl: './campaign-import.component.html',
styleUrls: ['./campaign-import.component.scss']
})
export class CampaignImportComponent implements OnInit {
readonly ArrowLeft = ArrowLeft;
readonly Upload = Upload;
readonly Plus = Plus;
readonly Trash2 = Trash2;
readonly ChevronDown = ChevronDown;
readonly ChevronRight = ChevronRight;
readonly Swords = Swords;
readonly BookOpen = BookOpen;
readonly MapPin = MapPin;
readonly Check = Check;
campaignId = '';
// --- État import (streaming) ---
importing = false;
importPhase = '';
importProgress: { current: number; total: number } | null = null;
importCounts: { arcs: number; chapters: number; scenes: number } | null = null;
importError: string | null = null;
/** Vrai une fois la proposition reçue (on affiche l'arbre éditable). */
reviewing = false;
// --- Arbre éditable ---
tree: ArcNode[] = [];
/** Structure actuelle de la campagne (chargée pour la fusion à la revue). */
private existingData: CampaignTreeData | null = null;
// --- État application (création) ---
applying = false;
applyError: string | null = null;
constructor(
private route: ActivatedRoute,
private router: Router,
private service: CampaignImportService,
private campaignService: CampaignService,
private characterService: CharacterService,
private npcService: NpcService,
private campaignSidebar: CampaignSidebarService,
private pageTitle: PageTitleService
) {}
ngOnInit(): void {
this.campaignId = this.route.snapshot.paramMap.get('campaignId')!;
this.pageTitle.set('Importer une campagne');
this.campaignSidebar.show(this.campaignId);
// Pré-chargement de l'arborescence existante (pour fusionner à la revue).
// En cas d'échec on dégrade : tout sera considéré comme nouveau.
loadCampaignTreeData(this.campaignService, this.campaignId, this.characterService, this.npcService)
.pipe(catchError(() => of(null)))
.subscribe(data => this.existingData = data);
}
// --- Upload + streaming --------------------------------------------------
onPdfSelected(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (file) this.importPdf(file);
}
private importPdf(file: File): void {
this.importing = true;
this.reviewing = false;
this.importError = null;
this.applyError = null;
this.importPhase = 'Extraction du texte…';
this.importProgress = null;
this.importCounts = null;
this.tree = [];
this.service.importStructureStream(this.campaignId, file).subscribe({
next: (ev) => {
if (ev.type === 'progress') {
if (ev.total === 0) {
this.importPhase = 'Extraction du texte…';
this.importProgress = null;
} else {
this.importPhase = `Analyse de la campagne… (${ev.current}/${ev.total})`;
this.importProgress = { current: ev.current, total: ev.total };
this.importCounts = { arcs: ev.arcCount, chapters: ev.chapterCount, scenes: ev.sceneCount };
}
} else if (ev.type === 'done') {
this.importing = false;
this.importPhase = '';
this.importProgress = null;
if ((ev.arcs ?? []).length === 0) {
this.importError = "Aucune structure narrative détectée dans ce PDF.";
this.reviewing = false;
} else {
this.tree = this.buildMergedTree(ev.arcs);
this.reviewing = true;
}
}
},
error: (err: Error) => {
this.importing = false;
this.importPhase = '';
this.importProgress = null;
this.importError = err?.message ? `Échec de l'import : ${err.message}` : "Échec de l'import du PDF.";
}
});
}
// --- Construction de l'arbre fusionné (existant + proposition) -----------
/**
* Construit l'arbre de revue : d'abord l'arborescence ACTUELLE de la campagne
* (nœuds `existing`, lecture seule), puis on y fusionne la proposition par NOM
* (insensible à la casse). Ce qui matche un nœud existant est rattaché ; ce qui
* ne matche pas devient un nouveau nœud éditable.
*/
private buildMergedTree(proposalArcs: ArcProposal[]): ArcNode[] {
const byOrder = (a: { order?: number }, b: { order?: number }) => (a.order ?? 0) - (b.order ?? 0);
const arcs: ArcNode[] = [];
// 1. Arbre existant.
const data = this.existingData;
if (data) {
for (const arc of [...data.arcs].sort(byOrder)) {
const chapters: ChapterNode[] = [];
for (const ch of [...(data.chaptersByArc[arc.id!] ?? [])].sort(byOrder)) {
const scenes: SceneNode[] = [];
for (const sc of [...(data.scenesByChapter[ch.id!] ?? [])].sort(byOrder)) {
scenes.push(this.existingSceneNode(sc.id!, sc.name, sc.description));
}
chapters.push({
name: ch.name, description: ch.description ?? '', scenes,
collapsed: true, existing: true, existingId: ch.id
});
}
arcs.push({
name: arc.name, description: arc.description ?? '',
type: (arc.type === 'HUB' ? 'HUB' : 'LINEAR'), chapters,
collapsed: true, existing: true, existingId: arc.id
});
}
}
// 2. Fusion de la proposition.
for (const pa of proposalArcs ?? []) {
const match = arcs.find(a => a.existing && this.sameName(a.name, pa.name));
if (match) {
this.mergeChaptersInto(match, pa.chapters ?? []);
match.collapsed = false;
} else {
arcs.push(this.newArcNode(pa));
}
}
return arcs;
}
private mergeChaptersInto(arc: ArcNode, propChapters: ChapterProposal[]): void {
for (const pc of propChapters) {
const match = arc.chapters.find(c => c.existing && this.sameName(c.name, pc.name));
if (match) {
this.mergeScenesInto(match, pc.scenes ?? []);
match.collapsed = false;
} else {
arc.chapters.push(this.newChapterNode(pc));
}
}
}
private mergeScenesInto(chapter: ChapterNode, propScenes: SceneProposal[]): void {
for (const ps of propScenes) {
// Scène de même nom déjà présente → on ne duplique pas (dédup).
if (chapter.scenes.some(s => this.sameName(s.name, ps.name))) continue;
chapter.scenes.push(this.newSceneNode(ps));
}
}
private sameName(a: string, b: string): boolean {
return (a ?? '').trim().toLowerCase() === (b ?? '').trim().toLowerCase();
}
// --- Mappers proposition → nœud NEUF -------------------------------------
private newArcNode(a: ArcProposal): ArcNode {
return {
name: a.name ?? '', description: a.description ?? '',
type: (a.type === 'HUB' ? 'HUB' : 'LINEAR'),
collapsed: false, existing: false,
chapters: (a.chapters ?? []).map(c => this.newChapterNode(c))
};
}
private newChapterNode(c: ChapterProposal): ChapterNode {
return {
name: c.name ?? '', description: c.description ?? '',
collapsed: false, existing: false,
scenes: (c.scenes ?? []).map(s => this.newSceneNode(s))
};
}
private newSceneNode(s: SceneProposal): SceneNode {
return {
name: s.name ?? '', description: s.description ?? '',
playerNarration: s.playerNarration ?? '', gmNotes: s.gmNotes ?? '',
detailsOpen: false, existing: false,
rooms: (s.rooms ?? []).map(r => ({
name: r.name ?? '', description: r.description ?? '',
enemies: r.enemies ?? '', loot: r.loot ?? ''
}))
};
}
private existingSceneNode(id: string, name: string, description?: string): SceneNode {
return {
name, description: description ?? '', playerNarration: '', gmNotes: '',
detailsOpen: false, existing: true, existingId: id, rooms: []
};
}
setArcType(arc: ArcNode, type: ArcKind): void { arc.type = type; }
toggleDetails(scene: SceneNode): void { scene.detailsOpen = !scene.detailsOpen; }
addRoom(scene: SceneNode): void {
scene.rooms.push({ name: '', description: '', enemies: '', loot: '' });
scene.detailsOpen = true;
}
removeRoom(scene: SceneNode, index: number): void { scene.rooms.splice(index, 1); }
// --- Édition de l'arbre --------------------------------------------------
toggleArc(arc: ArcNode): void { arc.collapsed = !arc.collapsed; }
toggleChapter(chapter: ChapterNode): void { chapter.collapsed = !chapter.collapsed; }
addArc(): void {
this.tree.push({ name: '', description: '', type: 'LINEAR', chapters: [], collapsed: false, existing: false });
}
removeArc(index: number): void { this.tree.splice(index, 1); }
addChapter(arc: ArcNode): void {
arc.chapters.push({ name: '', description: '', scenes: [], collapsed: false, existing: false });
}
removeChapter(arc: ArcNode, index: number): void { arc.chapters.splice(index, 1); }
addScene(chapter: ChapterNode): void {
chapter.scenes.push({
name: '', description: '', playerNarration: '', gmNotes: '', rooms: [], detailsOpen: true, existing: false
});
}
removeScene(chapter: ChapterNode, index: number): void { chapter.scenes.splice(index, 1); }
/** Compteurs des nœuds NOUVEAUX (= ce qui sera réellement créé). */
get arcCount(): number { return this.tree.filter(a => !a.existing && a.name.trim()).length; }
get chapterCount(): number {
return this.tree.reduce((n, a) => n + a.chapters.filter(c => !c.existing && c.name.trim()).length, 0);
}
get sceneCount(): number {
return this.tree.reduce((n, a) =>
n + a.chapters.reduce((m, c) => m + c.scenes.filter(s => !s.existing && s.name.trim()).length, 0), 0);
}
/** Vrai s'il y a au moins un nœud nouveau à créer (sinon « Créer » désactivé). */
get hasNewContent(): boolean {
return this.arcCount > 0 || this.chapterCount > 0 || this.sceneCount > 0;
}
// --- Application (création) ----------------------------------------------
apply(): void {
if (this.applying || !this.hasNewContent) return;
this.applying = true;
this.applyError = null;
// On envoie l'arbre fusionné COMPLET (existants + nouveaux) : les nœuds
// `existing` portent leur existingId et servent de parents — l'apply ne
// recrée que les nœuds sans existingId.
const proposal: CampaignImportProposal = {
arcs: this.tree
.filter(a => a.name.trim())
.map(a => ({
name: a.name.trim(),
description: a.description.trim(),
type: a.type,
existingId: a.existingId ?? null,
chapters: a.chapters
.filter(c => c.name.trim())
.map(c => ({
name: c.name.trim(),
description: c.description.trim(),
existingId: c.existingId ?? null,
scenes: c.scenes
.filter(s => s.name.trim())
.map(s => ({
name: s.name.trim(),
description: s.description.trim(),
playerNarration: s.playerNarration.trim(),
gmNotes: s.gmNotes.trim(),
existingId: s.existingId ?? null,
rooms: s.rooms
.filter(r => r.name.trim())
.map(r => ({
name: r.name.trim(),
description: r.description.trim(),
enemies: r.enemies.trim(),
loot: r.loot.trim()
}))
}))
}))
}))
};
this.service.applyStructure(this.campaignId, proposal).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId]),
error: () => {
this.applying = false;
this.applyError = "Échec de la création. La campagne existe-t-elle toujours ?";
}
});
}
cancel(): void {
this.router.navigate(['/campaigns', this.campaignId]);
}
}

View File

@@ -1,19 +1,19 @@
<div class="chapter-create-page">
<div class="page-header">
<h1>Créer un nouveau chapitre</h1>
<p class="arc-ref" *ngIf="arcName">Arc : {{ arcName }}</p>
<h1>{{ isHub ? 'Créer une nouvelle quête' : 'Créer un nouveau chapitre' }}</h1>
<p class="arc-ref" *ngIf="arcName">{{ isHub ? 'Hub' : 'Arc' }} : {{ arcName }}</p>
</div>
<form [formGroup]="form" (ngSubmit)="submit()" class="chapter-form">
<div class="field">
<label for="chapter-create-name">Nom du chapitre *</label>
<label for="chapter-create-name">{{ isHub ? 'Nom de la quête *' : 'Nom du chapitre *' }}</label>
<input
id="chapter-create-name"
type="text"
formControlName="name"
placeholder="Ex: Chapitre 1: Les Disparitions"
[placeholder]="isHub ? 'Ex: Sauver le marchand disparu' : 'Ex: Chapitre 1: Les Disparitions'"
[class.invalid]="form.get('name')?.invalid && form.get('name')?.touched"
/>
</div>
@@ -23,7 +23,7 @@
<textarea
id="chapter-create-description"
formControlName="description"
placeholder="Décrivez ce chapitre..."
[placeholder]="isHub ? 'Décrivez cette quête...' : 'Décrivez ce chapitre...'"
rows="5">
</textarea>
</div>
@@ -35,7 +35,7 @@
<div class="form-actions">
<button type="submit" class="btn-primary" [disabled]="form.invalid">
Créer le chapitre
{{ isHub ? 'Créer la quête' : 'Créer le chapitre' }}
</button>
<button type="button" class="btn-secondary" (click)="cancel()">Annuler</button>
</div>

View File

@@ -31,6 +31,8 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
campaignId = '';
arcId = '';
arcName = '';
/** Arc parent de type hub : un "chapitre" y est présenté comme une "quête". */
isHub = false;
private existingChapterCount = 0;
constructor(
@@ -62,6 +64,7 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
}).subscribe(({ campaign, allCampaigns, treeData }) => {
const currentArc = treeData.arcs.find(a => a.id === this.arcId);
this.arcName = currentArc?.name ?? '';
this.isHub = currentArc?.type === 'HUB';
this.existingChapterCount = treeData.chaptersByArc[this.arcId]?.length ?? 0;
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, this.campaignId));

View File

@@ -26,13 +26,17 @@
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
<!-- Section Hub : conditions de déblocage (scénario). Visible si l'arc parent est HUB. -->
<div class="hub-section" *ngIf="parentArc?.type === 'HUB'">
<h2 class="hub-section-title">🗺️ Conditions de déblocage de la quête</h2>
<!-- Conditions de déblocage (scénario) — pour TOUT chapitre : arc HUB (= quête)
comme arc linéaire (= chapitre conditionnel). Vide = disponible d'emblée. -->
<div class="hub-section">
<h2 class="hub-section-title">
{{ parentArc?.type === 'HUB' ? '🗺️ Conditions de déblocage de la quête' : '🔒 Conditions de déblocage du chapitre' }}
</h2>
<small class="field-hint">
Définition du scénario : toutes les conditions doivent être remplies (ET) pour
que la quête soit débloquée dans une Partie. La progression en cours et l'état
des faits se gèrent dans l'écran de la Partie, pas ici.
Définition du scénario : toutes les conditions doivent être remplies (ET) pour que
{{ parentArc?.type === 'HUB' ? 'la quête' : 'ce chapitre' }} soit débloqué(e) dans une Partie.
Laissez vide pour qu'il soit disponible dès le départ. La progression et l'état des
faits se gèrent dans l'écran de la Partie, pas ici.
</small>
<app-prerequisite-editor
[prerequisites]="prerequisites"

View File

@@ -6,7 +6,14 @@
<lucide-icon *ngIf="chapter.icon" [img]="resolveCampaignIcon(chapter.icon)" [size]="22" class="title-icon"></lucide-icon>
{{ chapter.name }}
</h1>
<p class="view-subtitle">{{ parentArc?.type === 'HUB' ? 'Quête (Hub)' : 'Chapitre' }}</p>
<p class="view-subtitle">
{{ parentArc?.type === 'HUB' ? 'Quête (Hub)' : 'Chapitre' }}
<span class="cond-badge" *ngIf="(chapter.prerequisites?.length ?? 0) > 0"
title="Ce chapitre a des conditions de déblocage : il se débloque en Partie quand elles sont remplies.">
<lucide-icon [img]="Lock" [size]="12"></lucide-icon>
Conditionnel
</span>
</p>
</div>
<div class="view-actions">
<button type="button" class="btn-secondary" (click)="openGraph()"
@@ -25,8 +32,9 @@
</div>
</header>
<!-- Conditions de déblocage (donnée de scénario, read-only). Toujours visible si Arc HUB. -->
<section class="view-section" *ngIf="parentArc?.type === 'HUB'">
<!-- Conditions de déblocage (scénario, read-only). Visible pour un arc HUB (quête)
OU dès qu'un chapitre linéaire porte des conditions. -->
<section class="view-section" *ngIf="parentArc?.type === 'HUB' || (chapter.prerequisites?.length ?? 0) > 0">
<h2 class="view-section-title"><span class="view-section-icon">🔒</span> Conditions de déblocage</h2>
<ng-container *ngIf="(chapter.prerequisites?.length ?? 0) > 0; else noPrereqs">
@@ -34,8 +42,9 @@
<li *ngFor="let p of chapter.prerequisites">{{ describePrerequisite(p) }}</li>
</ul>
<small class="view-section-empty">
Pendant une partie, la quête se débloque dès que toutes ces conditions sont remplies.
Le toggle des faits se fait dans l'écran « Faits » de la Partie.
Pendant une partie, {{ parentArc?.type === 'HUB' ? 'la quête' : 'ce chapitre' }} se débloque
dès que toutes ces conditions sont remplies. Le toggle des faits se fait dans l'écran
« Faits » de la Partie.
</small>
</ng-container>

View File

@@ -1,5 +1,21 @@
// Styles partagés via styles/_view.scss
// Badge "Conditionnel" : chapitre porteur de conditions de déblocage.
.cond-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-left: 0.5rem;
padding: 0.1rem 0.5rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
vertical-align: middle;
color: #f0c674;
background: rgba(240, 198, 116, 0.12);
border: 1px solid rgba(240, 198, 116, 0.35);
}
.quest-status-bar {
border: 1px solid rgba(66, 133, 244, 0.25);
background: rgba(66, 133, 244, 0.04);

View File

@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Pencil, Network, Trash2 } from 'lucide-angular';
import { LucideAngularModule, Pencil, Network, Trash2, Lock } from 'lucide-angular';
import { resolveCampaignIcon } from '../../campaign-icons';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
@@ -32,6 +32,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
readonly Pencil = Pencil;
readonly Network = Network;
readonly Trash2 = Trash2;
readonly Lock = Lock;
readonly resolveCampaignIcon = resolveCampaignIcon;
campaignId = '';

View File

@@ -5,7 +5,6 @@ import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Save, ArrowLeft, User, Trash2, Sparkles } from 'lucide-angular';
import { CharacterService } from '../../../services/character.service';
import { CampaignService } from '../../../services/campaign.service';
import { PlaythroughService } from '../../../services/playthrough.service';
import { GameSystemService } from '../../../services/game-system.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
import { TemplateField } from '../../../services/template.model';
@@ -49,7 +48,7 @@ export class CharacterEditComponent implements OnInit {
toggleChat(): void { this.chatOpen = !this.chatOpen; }
campaignId: string | null = null;
/** Partie cible — déduite du 1er Playthrough de la campagne. */
/** Partie propriétaire du PJ — lue depuis la route (les PJ sont scoping Playthrough). */
playthroughId: string | null = null;
characterId: string | null = null;
@@ -67,7 +66,6 @@ export class CharacterEditComponent implements OnInit {
private router: Router,
private service: CharacterService,
private campaignService: CampaignService,
private playthroughService: PlaythroughService,
private gameSystemService: GameSystemService,
private campaignSidebar: CampaignSidebarService,
private confirmDialog: ConfirmDialogService
@@ -76,15 +74,12 @@ export class CharacterEditComponent implements OnInit {
ngOnInit(): void {
const params = this.route.snapshot.paramMap;
this.campaignId = params.get('campaignId');
this.playthroughId = params.get('playthroughId');
this.characterId = params.get('characterId');
if (this.campaignId) {
this.loadTemplateForCampaign(this.campaignId);
this.campaignSidebar.show(this.campaignId);
// Résolution Partie par défaut (1er Playthrough) — nécessaire pour create/update.
this.playthroughService.listByCampaign(this.campaignId).subscribe({
next: list => { this.playthroughId = list.length > 0 ? list[0].id! : null; }
});
}
if (this.characterId) {
@@ -138,7 +133,7 @@ export class CharacterEditComponent implements OnInit {
req.subscribe({
next: (saved) => {
if (isCreation && saved.id) {
this.router.navigate(['/campaigns', this.campaignId, 'characters', saved.id]);
this.router.navigate(['/campaigns', this.campaignId, 'playthroughs', this.playthroughId, 'characters', saved.id]);
} else {
this.back();
}
@@ -165,7 +160,9 @@ export class CharacterEditComponent implements OnInit {
}
back(): void {
if (this.campaignId) {
if (this.campaignId && this.playthroughId) {
this.router.navigate(['/campaigns', this.campaignId, 'playthroughs', this.playthroughId]);
} else if (this.campaignId) {
this.router.navigate(['/campaigns', this.campaignId]);
} else {
this.router.navigate(['/campaigns']);

View File

@@ -13,7 +13,7 @@ import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-dr
/**
* Vue lecture seule "WorldAnvil" d'une fiche PJ.
* Route : /campaigns/:campaignId/characters/:characterId
* Route : /campaigns/:campaignId/playthroughs/:playthroughId/characters/:characterId
*/
@Component({
selector: 'app-character-view',
@@ -28,6 +28,7 @@ export class CharacterViewComponent implements OnInit {
readonly Sparkles = Sparkles;
campaignId: string | null = null;
playthroughId: string | null = null;
characterId: string | null = null;
character: Character | null = null;
@@ -48,6 +49,7 @@ export class CharacterViewComponent implements OnInit {
ngOnInit(): void {
const params = this.route.snapshot.paramMap;
this.campaignId = params.get('campaignId');
this.playthroughId = params.get('playthroughId');
this.characterId = params.get('characterId');
if (this.characterId) {
this.service.getById(this.characterId).subscribe({
@@ -68,13 +70,15 @@ export class CharacterViewComponent implements OnInit {
}
edit(): void {
if (this.campaignId && this.characterId) {
this.router.navigate(['/campaigns', this.campaignId, 'characters', this.characterId, 'edit']);
if (this.campaignId && this.playthroughId && this.characterId) {
this.router.navigate(['/campaigns', this.campaignId, 'playthroughs', this.playthroughId, 'characters', this.characterId, 'edit']);
}
}
back(): void {
if (this.campaignId) {
if (this.campaignId && this.playthroughId) {
this.router.navigate(['/campaigns', this.campaignId, 'playthroughs', this.playthroughId]);
} else if (this.campaignId) {
this.router.navigate(['/campaigns', this.campaignId]);
} else {
this.router.navigate(['/campaigns']);

View File

@@ -42,11 +42,17 @@
<!-- PJ -->
<section class="block">
<h2><lucide-icon [img]="Users" [size]="18"></lucide-icon> Personnages joueurs</h2>
<div class="block-header">
<h2><lucide-icon [img]="Users" [size]="18"></lucide-icon> Personnages joueurs</h2>
<button type="button" class="btn-add" (click)="createCharacter()">
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
Nouveau PJ
</button>
</div>
<p class="empty" *ngIf="characters.length === 0">Aucun PJ pour cette partie.</p>
<ul class="character-list" *ngIf="characters.length > 0">
<li *ngFor="let c of characters">
<a [routerLink]="['/campaigns', campaignId, 'characters', c.id]">{{ c.name }}</a>
<a [routerLink]="['/campaigns', campaignId, 'playthroughs', playthroughId, 'characters', c.id]">{{ c.name }}</a>
</li>
</ul>
</section>

View File

@@ -46,6 +46,32 @@
}
}
.block-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.7rem;
h2 { margin: 0; }
}
.btn-add {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
background: #6c63ff;
color: white;
border: none;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
&:hover { background: #5b52e0; }
}
.empty {
font-size: 0.88rem;
color: var(--color-text-muted, #777);

View File

@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { LucideAngularModule, ArrowLeft, Play, Flag, Users, Trash2, Pencil } from 'lucide-angular';
import { LucideAngularModule, ArrowLeft, Play, Flag, Users, Trash2, Pencil, Plus } from 'lucide-angular';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service';
@@ -36,6 +36,7 @@ export class PlaythroughDetailComponent implements OnInit, OnDestroy {
readonly Users = Users;
readonly Trash2 = Trash2;
readonly Pencil = Pencil;
readonly Plus = Plus;
campaignId = '';
playthroughId = '';
@@ -105,6 +106,11 @@ export class PlaythroughDetailComponent implements OnInit, OnDestroy {
this.router.navigate(['/sessions', s.id]);
}
/** Crée un PJ rattaché à CETTE Partie (route scoping Playthrough). */
createCharacter(): void {
this.router.navigate(['/campaigns', this.campaignId, 'playthroughs', this.playthroughId, 'characters', 'create']);
}
openFlags(): void {
this.router.navigate(['/campaigns', this.campaignId, 'playthroughs', this.playthroughId, 'flags']);
}

View File

@@ -36,6 +36,33 @@
selon ce qu'elle génère (combat → Combat/Monstres, PNJ → Classes, arc → Lore/Factions).
</p>
<!-- Import d'un PDF de règles : l'IA propose un découpage en sections,
que l'utilisateur révise ci-dessous avant d'enregistrer. -->
<div class="import-row">
<input #pdfInput type="file" accept="application/pdf,.pdf" hidden (change)="onPdfSelected($event)" />
<button type="button" class="btn-import" [disabled]="importing" (click)="pdfInput.click()">
<lucide-icon [img]="Upload" [size]="14"></lucide-icon>
{{ importing ? 'Import en cours…' : 'Importer un PDF de règles' }}
</button>
<span class="import-hint" *ngIf="!importing">L'IA propose un découpage en sections — vous révisez avant d'enregistrer.</span>
</div>
<!-- Progression live de l'import (étape + barre + sections trouvées). -->
<div class="import-progress" *ngIf="importing">
<p class="import-phase">{{ importPhase }}</p>
<div class="progress-bar" *ngIf="importProgress">
<div class="progress-fill"
[style.width.%]="importProgress.total ? (importProgress.current / importProgress.total) * 100 : 0">
</div>
</div>
<p class="import-found" *ngIf="importFound.length">
Sections trouvées : {{ importFound.join(' · ') }}
</p>
</div>
<p class="import-note" *ngIf="importNote">{{ importNote }}</p>
<p class="import-error" *ngIf="importError">{{ importError }}</p>
<div class="section-list">
<div class="section-card" *ngFor="let section of sections; let i = index" [class.collapsed]="section.collapsed">

View File

@@ -95,6 +95,94 @@
margin: 0 0 1rem;
}
// --- Import PDF de règles ---------------------------------------------------
.import-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
margin: 0 0 1rem;
}
.btn-import {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.9rem;
background: #1f2937;
color: #e5e7eb;
border: 1px solid #374151;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
&:hover:not(:disabled) { background: #273244; border-color: #6c63ff; }
&:disabled { opacity: 0.6; cursor: progress; }
}
.import-hint {
color: #6b7280;
font-size: 0.8rem;
}
.import-progress {
margin: -0.4rem 0 1rem;
padding: 0.7rem 0.85rem;
background: #0b1220;
border: 1px solid #1f2937;
border-radius: 8px;
}
.import-phase {
margin: 0 0 0.5rem;
color: #c4b5fd;
font-size: 0.85rem;
font-weight: 500;
}
.progress-bar {
height: 6px;
background: #1f2937;
border-radius: 999px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #6c63ff;
border-radius: 999px;
transition: width 0.3s ease;
}
.import-found {
margin: 0.55rem 0 0;
color: #9ca3af;
font-size: 0.78rem;
line-height: 1.4;
}
.import-note {
margin: -0.4rem 0 1rem;
padding: 0.55rem 0.8rem;
background: rgba(108, 99, 255, 0.12);
border: 1px solid rgba(108, 99, 255, 0.35);
border-radius: 8px;
color: #c4b5fd;
font-size: 0.85rem;
}
.import-error {
margin: -0.4rem 0 1rem;
padding: 0.55rem 0.8rem;
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.35);
border-radius: 8px;
color: #fca5a5;
font-size: 0.85rem;
}
.section-list {
display: flex;
flex-direction: column;

View File

@@ -2,7 +2,7 @@ import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Save, ArrowLeft, Dices, Plus, Trash2, ChevronDown, ChevronRight } from 'lucide-angular';
import { LucideAngularModule, Save, ArrowLeft, Dices, Plus, Trash2, ChevronDown, ChevronRight, Upload } from 'lucide-angular';
import { GameSystemService } from '../../services/game-system.service';
import { TemplateField } from '../../services/template.model';
import { TemplateFieldsEditorComponent } from '../../shared/template-fields-editor/template-fields-editor.component';
@@ -54,9 +54,23 @@ export class GameSystemEditComponent implements OnInit {
readonly Trash2 = Trash2;
readonly ChevronDown = ChevronDown;
readonly ChevronRight = ChevronRight;
readonly Upload = Upload;
id: string | null = null;
/** Import PDF en cours (appel LLM long) → désactive le bouton + spinner. */
importing = false;
/** Message de succès post-import (sections ajoutées, pages, OCR). */
importNote: string | null = null;
/** Message d'erreur d'import (Brain injoignable, PDF illisible…). */
importError: string | null = null;
/** Libellé de l'étape courante (« Extraction… », « Analyse… (3/12) »). */
importPhase = '';
/** Avancement de la structuration ; null pendant l'extraction (total inconnu). */
importProgress: { current: number; total: number } | null = null;
/** Titres de sections trouvés au fil de l'eau (affichage live). */
importFound: string[] = [];
name = '';
description = '';
author = '';
@@ -108,6 +122,96 @@ export class GameSystemEditComponent implements OnInit {
this.sections.push({ title: '', content: '', collapsed: false });
}
// --- Import d'un PDF de règles -------------------------------------------
/** Déclenché par le <input file> caché : lance l'import du PDF choisi. */
onPdfSelected(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
// Reset de la valeur : re-sélectionner le même fichier doit re-déclencher.
input.value = '';
if (file) this.importPdf(file);
}
private importPdf(file: File): void {
this.importing = true;
this.importNote = null;
this.importError = null;
this.importPhase = 'Extraction du texte…';
this.importProgress = null;
this.importFound = [];
this.service.importRulesStream(file).subscribe({
next: (ev) => {
if (ev.type === 'progress') {
if (ev.total === 0) {
// Phase d'extraction (total encore inconnu).
this.importPhase = 'Extraction du texte…';
this.importProgress = null;
} else {
this.importPhase = `Analyse des règles… (${ev.current}/${ev.total})`;
this.importProgress = { current: ev.current, total: ev.total };
for (const t of ev.newSectionTitles) {
if (!this.importFound.includes(t)) this.importFound.push(t);
}
}
} else if (ev.type === 'done') {
this.finishImport(ev.sections, ev.pageCount, ev.ocrPageCount);
}
},
error: (err: Error) => {
this.resetImportProgress();
this.importError = err?.message
? `Échec de l'import : ${err.message}`
: "Échec de l'import du PDF.";
}
});
}
private finishImport(sections: Record<string, string>, pageCount: number, ocrPageCount: number): void {
this.resetImportProgress();
const added = this.mergeImportedSections(sections);
if (added === 0) {
this.importError = "Aucune règle exploitable n'a été extraite de ce PDF "
+ "(scan sans OCR, ou contenu non reconnu).";
return;
}
const ocr = ocrPageCount > 0 ? ` (dont ${ocrPageCount} page(s) via OCR)` : '';
this.importNote = `${added} section(s) proposée(s) depuis ${pageCount} page(s)${ocr}. `
+ `Relisez et ajustez ci-dessous avant d'enregistrer.`;
}
private resetImportProgress(): void {
this.importing = false;
this.importPhase = '';
this.importProgress = null;
}
/**
* Fusionne les sections proposées dans l'éditeur. Section de même titre
* (insensible à la casse) → contenu ajouté à la suite ; sinon nouvelle carte.
* Retourne le nombre de sections effectivement intégrées.
*/
private mergeImportedSections(sections: Record<string, string>): number {
let count = 0;
for (const [rawTitle, rawContent] of Object.entries(sections ?? {})) {
const title = (rawTitle ?? '').trim();
const content = (rawContent ?? '').trim();
if (!title || !content) continue;
const existing = this.sections.find(
s => s.title.trim().toLowerCase() === title.toLowerCase()
);
if (existing) {
existing.content = `${existing.content.trim()}\n\n${content}`.trim();
existing.collapsed = false;
} else {
this.sections.push({ title, content, collapsed: false });
}
count++;
}
return count;
}
removeSection(index: number): void {
this.sections.splice(index, 1);
}

View File

@@ -0,0 +1,108 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
/** Évènements du flux SSE de conseils d'adaptation. */
export type AdaptStreamEvent =
| { type: 'token'; value: string }
| { type: 'done' }
| { type: 'error'; message: string };
/** Message de la conversation d'adaptation. */
export interface AdaptMessage {
role: 'user' | 'assistant';
content: string;
}
/**
* Service : adaptation conversationnelle d'un PDF à une campagne (streamée).
* fetch() + SSE (POST multipart impossible avec EventSource), décodage ligne à ligne.
*/
@Injectable({ providedIn: 'root' })
export class CampaignAdaptService {
adviseStream(campaignId: string, file: File, messages: AdaptMessage[]): Observable<AdaptStreamEvent> {
return new Observable<AdaptStreamEvent>((subscriber) => {
const controller = new AbortController();
const form = new FormData();
form.append('file', file);
form.append('messages', JSON.stringify(messages ?? []));
fetch(`/api/campaigns/${campaignId}/adapt-pdf/stream`, {
method: 'POST',
headers: { 'Accept': 'text/event-stream' },
body: form,
signal: controller.signal
})
.then(async (response) => {
if (!response.ok || !response.body) {
subscriber.error(new Error(`HTTP ${response.status}`));
return;
}
await this.consume(response.body, subscriber);
})
.catch((err) => {
if (controller.signal.aborted) return;
subscriber.error(err);
});
return () => controller.abort();
});
}
private async consume(
body: ReadableStream<Uint8Array>,
subscriber: { next: (e: AdaptStreamEvent) => void; error: (e: unknown) => void; complete: () => void }
): Promise<void> {
const reader = body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let currentEvent: string | null = null;
let currentData = '';
const dispatch = () => {
const name = currentEvent ?? 'message';
if (name === 'error') {
let message = "Échec de l'adaptation.";
try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* défaut */ }
subscriber.error(new Error(message));
} else if (name === 'done') {
subscriber.next({ type: 'done' });
subscriber.complete();
} else if (name === 'token') {
try {
const tok = (JSON.parse(currentData) as { token?: string }).token;
if (tok) subscriber.next({ type: 'token', value: tok });
} catch { /* fragment ignoré */ }
}
currentEvent = null;
currentData = '';
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let idx: number;
while ((idx = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, idx).replace(/\r$/, '');
buffer = buffer.slice(idx + 1);
if (line === '') {
if (currentEvent !== null || currentData !== '') dispatch();
continue;
}
if (line.startsWith('event:')) {
currentEvent = line.slice(6).trim();
} else if (line.startsWith('data:')) {
const chunk = line.slice(5).replace(/^ /, '');
currentData = currentData ? `${currentData}\n${chunk}` : chunk;
}
}
}
if (currentEvent !== null || currentData !== '') dispatch();
subscriber.complete();
} catch (err) {
subscriber.error(err);
}
}
}

View File

@@ -0,0 +1,72 @@
/**
* Types de l'import de PDF de campagne (arbre arc → chapitre → scène).
* Jumeaux des records Java (CampaignImportProposal / CampaignImportProgress).
*/
export interface RoomProposal {
name: string;
description: string;
enemies: string;
loot: string;
}
export interface SceneProposal {
name: string;
description: string;
/** Texte d'encadré « à lire aux joueurs ». */
playerNarration: string;
/** Secrets / développement MJ. */
gmNotes: string;
/** Non vide ⇒ la scène est un lieu explorable (donjon). */
rooms: RoomProposal[];
/** ID si la scène existe déjà (revue pré-chargée) ; absent = à créer. */
existingId?: string | null;
}
export interface ChapterProposal {
name: string;
description: string;
scenes: SceneProposal[];
existingId?: string | null;
}
export type ArcKind = 'LINEAR' | 'HUB';
export interface ArcProposal {
name: string;
description: string;
type: ArcKind;
chapters: ChapterProposal[];
existingId?: string | null;
}
export interface CampaignImportProposal {
arcs: ArcProposal[];
}
/** Récapitulatif renvoyé après création effective des entités. */
export interface CampaignImportApplyResult {
arcsCreated: number;
chaptersCreated: number;
scenesCreated: number;
}
/**
* Évènements du flux SSE d'import streamé.
* - progress : avancement (total=0 ⇒ extraction en cours).
* - done : arbre proposé (à réviser).
* - error : message d'erreur côté serveur.
*/
export type CampaignImportStreamEvent =
| {
type: 'progress';
current: number;
total: number;
pageCount: number;
ocrPageCount: number;
arcCount: number;
chapterCount: number;
sceneCount: number;
}
| { type: 'done'; arcs: ArcProposal[] }
| { type: 'error'; message: string };

View File

@@ -0,0 +1,113 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import {
CampaignImportApplyResult,
CampaignImportProposal,
CampaignImportStreamEvent
} from './campaign-import.model';
/**
* Service HTTP pour l'import d'un PDF de campagne.
*
* `importStructureStream` utilise fetch() (POST multipart + SSE, impossible
* avec EventSource) et décode le flux ligne par ligne, comme le chat.
* `applyStructure` poste l'arbre révisé pour créer les entités.
*/
@Injectable({ providedIn: 'root' })
export class CampaignImportService {
constructor(private http: HttpClient) {}
importStructureStream(campaignId: string, file: File): Observable<CampaignImportStreamEvent> {
return new Observable<CampaignImportStreamEvent>((subscriber) => {
const controller = new AbortController();
const form = new FormData();
form.append('file', file);
fetch(`/api/campaigns/${campaignId}/import-structure/stream`, {
method: 'POST',
headers: { 'Accept': 'text/event-stream' },
body: form,
signal: controller.signal
})
.then(async (response) => {
if (!response.ok || !response.body) {
subscriber.error(new Error(`HTTP ${response.status}`));
return;
}
await this.consumeSse(response.body, subscriber);
})
.catch((err) => {
if (controller.signal.aborted) return;
subscriber.error(err);
});
return () => controller.abort();
});
}
applyStructure(campaignId: string, proposal: CampaignImportProposal): Observable<CampaignImportApplyResult> {
return this.http.post<CampaignImportApplyResult>(
`/api/campaigns/${campaignId}/import-structure/apply`, proposal);
}
/** Décode un ReadableStream SSE et émet les évènements d'import typés. */
private async consumeSse(
body: ReadableStream<Uint8Array>,
subscriber: { next: (e: CampaignImportStreamEvent) => void; error: (e: unknown) => void; complete: () => void }
): Promise<void> {
const reader = body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let currentEvent: string | null = null;
let currentData = '';
const dispatch = () => {
const name = currentEvent ?? 'message';
if (name === 'error') {
let message = 'Échec de l\'import.';
try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* défaut */ }
subscriber.error(new Error(message));
} else if (name === 'progress' || name === 'done') {
try {
const obj = JSON.parse(currentData);
if (name === 'done') {
subscriber.next({ type: 'done', arcs: obj.arcs ?? [] });
subscriber.complete();
} else {
subscriber.next({ type: 'progress', ...obj });
}
} catch { /* bloc malformé ignoré */ }
}
currentEvent = null;
currentData = '';
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let idx: number;
while ((idx = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, idx).replace(/\r$/, '');
buffer = buffer.slice(idx + 1);
if (line === '') {
if (currentEvent !== null || currentData !== '') dispatch();
continue;
}
if (line.startsWith('event:')) {
currentEvent = line.slice(6).trim();
} else if (line.startsWith('data:')) {
const chunk = line.slice(5).replace(/^ /, '');
currentData = currentData ? `${currentData}\n${chunk}` : chunk;
}
}
}
if (currentEvent !== null || currentData !== '') dispatch();
subscriber.complete();
} catch (err) {
subscriber.error(err);
}
}
}

View File

@@ -18,6 +18,28 @@ export interface GameSystem {
isPublic?: boolean;
}
/**
* Réponse de l'import d'un PDF de règles : proposition de sections à réviser.
* `sections` = {titre → contenu markdown}. `ocrPageCount` > 0 ⇒ le PDF était
* (au moins partiellement) un scan passé à l'OCR.
*/
export interface RulesImportResponse {
sections: Record<string, string>;
pageCount: number;
ocrPageCount: number;
}
/**
* Évènements du flux SSE d'import streamé.
* - progress : avancement (total=0 ⇒ phase d'extraction en cours).
* - done : résultat final (sections proposées).
* - error : message d'erreur côté serveur.
*/
export type RulesImportStreamEvent =
| { type: 'progress'; current: number; total: number; pageCount: number; ocrPageCount: number; newSectionTitles: string[] }
| { type: 'done'; sections: Record<string, string>; pageCount: number; ocrPageCount: number }
| { type: 'error'; message: string };
/** Payload de creation/mise a jour (sans id). */
export interface GameSystemCreate {
name: string;

View File

@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { GameSystem, GameSystemCreate } from './game-system.model';
import { GameSystem, GameSystemCreate, RulesImportResponse, RulesImportStreamEvent } from './game-system.model';
/**
* Service HTTP pour les GameSystems (systèmes de JDR).
@@ -36,4 +36,109 @@ export class GameSystemService {
const params = new HttpParams().set('q', q);
return this.http.get<GameSystem[]>(`${this.apiUrl}/search`, { params });
}
/**
* Importe un PDF de règles : renvoie une PROPOSITION de sections (titre →
* markdown). Rien n'est persisté côté serveur — l'appelant injecte les
* sections dans l'éditeur pour révision avant enregistrement.
*/
importRules(file: File): Observable<RulesImportResponse> {
const form = new FormData();
form.append('file', file);
return this.http.post<RulesImportResponse>(`${this.apiUrl}/import-rules`, form);
}
/**
* Variante streamée : émet l'avancement au fil de l'eau puis `done`.
* On utilise fetch() (pas EventSource : POST + multipart impossibles avec
* EventSource) et on décode le flux SSE ligne par ligne. Annuler la
* subscription annule le fetch (AbortController).
*/
importRulesStream(file: File): Observable<RulesImportStreamEvent> {
return new Observable<RulesImportStreamEvent>((subscriber) => {
const controller = new AbortController();
const form = new FormData();
form.append('file', file);
fetch(`${this.apiUrl}/import-rules/stream`, {
method: 'POST',
headers: { 'Accept': 'text/event-stream' },
body: form,
signal: controller.signal
})
.then(async (response) => {
if (!response.ok || !response.body) {
subscriber.error(new Error(`HTTP ${response.status}`));
return;
}
await this.consumeImportSse(response.body, subscriber);
})
.catch((err) => {
if (controller.signal.aborted) return;
subscriber.error(err);
});
return () => controller.abort();
});
}
/** Décode un ReadableStream SSE et émet les évènements d'import typés. */
private async consumeImportSse(
body: ReadableStream<Uint8Array>,
subscriber: { next: (e: RulesImportStreamEvent) => void; error: (e: unknown) => void; complete: () => void }
): Promise<void> {
const reader = body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let currentEvent: string | null = null;
let currentData = '';
const dispatch = () => {
const name = currentEvent ?? 'message';
if (name === 'error') {
let message = 'Échec de l\'import.';
try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* garde le défaut */ }
subscriber.error(new Error(message));
} else if (name === 'progress' || name === 'done') {
try {
const obj = JSON.parse(currentData);
if (name === 'done') {
subscriber.next({ type: 'done', ...obj });
subscriber.complete();
} else {
subscriber.next({ type: 'progress', ...obj });
}
} catch { /* bloc malformé ignoré */ }
}
currentEvent = null;
currentData = '';
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let idx: number;
while ((idx = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, idx).replace(/\r$/, '');
buffer = buffer.slice(idx + 1);
if (line === '') {
if (currentEvent !== null || currentData !== '') dispatch();
continue;
}
if (line.startsWith('event:')) {
currentEvent = line.slice(6).trim();
} else if (line.startsWith('data:')) {
const chunk = line.slice(5).replace(/^ /, '');
currentData = currentData ? `${currentData}\n${chunk}` : chunk;
}
}
}
if (currentEvent !== null || currentData !== '') dispatch();
subscriber.complete();
} catch (err) {
subscriber.error(err);
}
}
}

View File

@@ -73,6 +73,12 @@ export interface BottomPanel {
export interface SecondarySidebarConfig {
title: string;
/**
* Si défini, le titre devient cliquable et navigue vers cette route
* (ex: accueil de la campagne depuis n'importe quelle sous-page). Absent =
* titre statique (cas du Lore).
*/
titleRoute?: string;
items: TreeItem[];
createActions: SidebarAction[];
globalItems: GlobalItem[];

View File

@@ -6,13 +6,21 @@ import { Observable } from 'rxjs';
* Reflet de SettingsDTO cote Brain / SettingsController cote Core.
* `onemin_api_key_set` indique si une cle est configuree, sans la reveler.
*/
export type LlmProvider = 'ollama' | 'onemin' | 'openrouter';
export interface AppSettings {
llm_provider: 'ollama' | 'onemin';
llm_provider: LlmProvider;
ollama_base_url: string;
llm_model: string;
onemin_model: string;
onemin_api_key_set: boolean;
openrouter_model: string;
openrouter_api_key_set: boolean;
llm_num_ctx: number;
/** Taille cible d'un morceau (tokens) pour l'import de PDF (règles/campagne). */
import_chunk_tokens: number;
/** Timeout HTTP des appels LLM (secondes). */
llm_timeout_seconds: number;
}
/**
@@ -20,12 +28,16 @@ export interface AppSettings {
* `onemin_api_key: ''` efface la cle, `null`/absent ne touche a rien.
*/
export interface AppSettingsUpdate {
llm_provider?: 'ollama' | 'onemin';
llm_provider?: LlmProvider;
ollama_base_url?: string;
llm_model?: string;
onemin_model?: string;
onemin_api_key?: string;
openrouter_model?: string;
openrouter_api_key?: string;
llm_num_ctx?: number;
import_chunk_tokens?: number;
llm_timeout_seconds?: number;
}
/** Metadonnees d'un modele Ollama (issues de /api/show). */
@@ -66,6 +78,11 @@ export class SettingsService {
return this.http.get<{ groups: OneMinModelGroup[] }>(`${this.apiUrl}/models/onemin`, this.authOptions);
}
/** Catalogue dynamique des modeles OpenRouter (depuis leur API publique). */
listOpenRouterModels(): Observable<{ models: OpenRouterModel[] }> {
return this.http.get<{ models: OpenRouterModel[] }>(`${this.apiUrl}/models/openrouter`, this.authOptions);
}
/**
* Telecharge un modele Ollama et streame la progression au client.
*
@@ -178,3 +195,13 @@ export interface OneMinModelGroup {
provider: string;
models: string[];
}
/** Un modele OpenRouter (catalogue dynamique). */
export interface OpenRouterModel {
id: string;
name: string;
/** Fenetre de contexte du modele (tokens). 0 si inconnue. */
context_length: number;
/** True si le modele est gratuit (id `:free` ou prix nul). */
free: boolean;
}

View File

@@ -61,7 +61,7 @@
<button *ngFor="let c of characters"
type="button"
class="ref-item"
(click)="openInNewTab(['campaigns', campaignId, 'characters', c.id!])">
(click)="openInNewTab(['campaigns', campaignId, 'playthroughs', playthroughId!, 'characters', c.id!])">
<span class="ref-item-name">{{ c.name }}</span>
<lucide-icon [img]="ExternalLink" [size]="12" class="ref-item-icon"></lucide-icon>
</button>

View File

@@ -32,6 +32,10 @@
<input type="radio" name="provider" value="onemin" [(ngModel)]="settings.llm_provider">
<span>1min.ai (cloud)</span>
</label>
<label class="radio">
<input type="radio" name="provider" value="openrouter" [(ngModel)]="settings.llm_provider">
<span>OpenRouter (cloud)</span>
</label>
</div>
</div>
</section>
@@ -173,8 +177,45 @@
</div>
</section>
<!-- Bloc Fenetre de contexte -->
<section class="card" *ngIf="settings">
<!-- Bloc OpenRouter -->
<section class="card" *ngIf="settings && settings.llm_provider === 'openrouter'">
<h2>Configuration OpenRouter</h2>
<div class="form-row">
<label for="openrouter-key">Cle API</label>
<input
id="openrouter-key"
type="password"
[(ngModel)]="openrouterApiKeyInput"
[placeholder]="settings.openrouter_api_key_set ? 'Cle configuree (laisser vide pour ne pas changer)' : 'Saisir votre cle API (sk-or-...)'">
<label class="checkbox" *ngIf="settings.openrouter_api_key_set">
<input type="checkbox" [(ngModel)]="clearOpenrouterKey">
<span>Effacer la cle enregistree</span>
</label>
</div>
<div class="form-row">
<label for="openrouter-model">Modele</label>
<label class="checkbox">
<input type="checkbox" [(ngModel)]="openrouterFreeOnly">
<span>Gratuits seulement</span>
</label>
<select id="openrouter-model" [(ngModel)]="settings.openrouter_model">
<option *ngFor="let o of openrouterSelectOptions" [value]="o.id">{{ o.label }}</option>
</select>
<p class="hint">
Liste chargee automatiquement depuis OpenRouter. Pour rester <strong>gratuit</strong> :
un modele marque <code>· gratuit</code> ou le routeur <code>openrouter/free</code>
(choisit un modele gratuit tout seul). Pour les imports, prefere un gratuit a
<strong>grand contexte</strong> (valeur « ctx »).
</p>
</div>
</section>
<!-- Bloc Fenetre de contexte : seulement pour Ollama (num_ctx est un vrai
parametre du modele local). Pour le cloud (1min/OpenRouter), on ne fixe rien :
on utilise ce que le modele accepte, et la jauge du chat s'affiche sans max. -->
<section class="card" *ngIf="settings && settings.llm_provider === 'ollama'">
<h2>Fenetre de contexte</h2>
<!-- Ollama : slider borne par le max du modele -->
@@ -205,18 +246,43 @@
</p>
</div>
<!-- 1min.ai : saisie libre (pas d'introspection possible) -->
<div class="form-row" *ngIf="settings.llm_provider === 'onemin'">
<label for="llm-num-ctx-onemin">Fenetre de contexte (tokens)</label>
</section>
<!-- Bloc Import de PDF -->
<section class="card" *ngIf="settings">
<h2>Import de PDF</h2>
<div class="form-row">
<label for="import-chunk-tokens">Taille des morceaux a l'import (tokens)</label>
<input
id="llm-num-ctx-onemin"
id="import-chunk-tokens"
type="number"
min="2048"
step="1024"
[(ngModel)]="settings.llm_num_ctx">
min="2000"
step="1000"
[(ngModel)]="settings.import_chunk_tokens">
<p class="hint">
A regler selon la capacite du modele 1min.ai choisi (ex: 128 000 pour gpt-4o,
200 000 pour claude-sonnet). Sert de plafond a la jauge de contexte du chat.
Lors de l'import d'un PDF (regles ou campagne), le texte est decoupe en morceaux
de cette taille avant d'etre analyse par l'IA. <strong>Plus gros = moins de morceaux</strong>,
donc plus rapide et moins de fragmentation/doublons — mais le morceau doit tenir dans
la fenetre du modele.
<br />
Repere : ~10 000 pour Ollama (16k de contexte) ; jusqu'a ~100 000 pour un modele a
grand contexte (ex : GPT-5 mini, 400k) afin de traiter un livre entier en une seule passe.
Attention : un gros morceau = generation plus longue (voir le timeout ci-dessous).
</p>
</div>
<div class="form-row">
<label for="llm-timeout">Timeout des appels IA (secondes)</label>
<input
id="llm-timeout"
type="number"
min="30"
step="30"
[(ngModel)]="settings.llm_timeout_seconds">
<p class="hint">
Delai max d'attente d'une reponse du modele. Si un import lourd echoue avec
« delai depasse » (timeout), <strong>augmentez cette valeur</strong> (ex : 600)
ou reduisez la taille des morceaux ci-dessus. Defaut : 300 s.
</p>
</div>
</section>

View File

@@ -4,7 +4,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Download, Trash2, Plus, X, Heart, Link2, Unlink } from 'lucide-angular';
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OllamaPullEvent } from '../services/settings.service';
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OpenRouterModel, OllamaPullEvent } from '../services/settings.service';
import { UpdatesService, UpdateStatus } from '../services/updates.service';
import { ConfigService } from '../services/config.service';
import { LayoutService } from '../services/layout.service';
@@ -103,6 +103,10 @@ export class SettingsComponent implements OnInit, OnDestroy {
settings: AppSettings | null = null;
ollamaModels: string[] = [];
oneminGroups: OneMinModelGroup[] = [];
/** Catalogue OpenRouter (chargé dynamiquement) + filtre "gratuits seulement". */
openrouterModels: OpenRouterModel[] = [];
openrouterFreeOnly = true;
/** Fournisseur 1min.ai actuellement selectionne (filtre la liste des modeles). */
oneminProvider: string = '';
@@ -128,6 +132,11 @@ export class SettingsComponent implements OnInit, OnDestroy {
/** True si l'utilisateur a coche "effacer la cle". */
clearApiKey = false;
/** Cle OpenRouter saisie — vide = on ne touche pas a la cle persistee. */
openrouterApiKeyInput = '';
/** True si l'utilisateur a coche "effacer la cle OpenRouter". */
clearOpenrouterKey = false;
constructor(
private settingsService: SettingsService,
private router: Router,
@@ -457,6 +466,35 @@ export class SettingsComponent implements OnInit, OnDestroy {
},
error: () => this.oneminGroups = []
});
this.settingsService.listOpenRouterModels().subscribe({
next: (r) => this.openrouterModels = r.models,
error: () => this.openrouterModels = []
});
}
/** Modeles OpenRouter, filtres (gratuits seulement par defaut). */
get filteredOpenrouterModels(): OpenRouterModel[] {
return this.openrouterFreeOnly
? this.openrouterModels.filter(m => m.free)
: this.openrouterModels;
}
/** Options du select OpenRouter : routeur auto + liste filtree + valeur courante. */
get openrouterSelectOptions(): { id: string; label: string }[] {
const options: { id: string; label: string }[] = [
{ id: 'openrouter/free', label: 'openrouter/free — routeur auto (gratuit)' }
];
for (const m of this.filteredOpenrouterModels) {
const ctx = m.context_length ? `${m.context_length.toLocaleString()} ctx` : '';
options.push({ id: m.id, label: `${m.id}${ctx}${m.free ? ' · gratuit' : ''}` });
}
// Garantit que le modele actuellement configure reste selectionnable.
const cur = this.settings?.openrouter_model;
if (cur && !options.some(o => o.id === cur)) {
options.splice(1, 0, { id: cur, label: `${cur} (actuel)` });
}
return options;
}
/** Deduit le fournisseur a partir du modele actuellement configure. */
@@ -518,19 +556,29 @@ export class SettingsComponent implements OnInit, OnDestroy {
ollama_base_url: this.settings.ollama_base_url,
llm_model: this.settings.llm_model,
onemin_model: this.settings.onemin_model,
llm_num_ctx: this.settings.llm_num_ctx
openrouter_model: this.settings.openrouter_model,
llm_num_ctx: this.settings.llm_num_ctx,
import_chunk_tokens: this.settings.import_chunk_tokens,
llm_timeout_seconds: this.settings.llm_timeout_seconds
};
if (this.clearApiKey) {
patch.onemin_api_key = '';
} else if (this.oneminApiKeyInput.trim()) {
patch.onemin_api_key = this.oneminApiKeyInput.trim();
}
if (this.clearOpenrouterKey) {
patch.openrouter_api_key = '';
} else if (this.openrouterApiKeyInput.trim()) {
patch.openrouter_api_key = this.openrouterApiKeyInput.trim();
}
this.settingsService.updateSettings(patch).subscribe({
next: (s) => {
this.settings = { ...s };
this.oneminApiKeyInput = '';
this.clearApiKey = false;
this.openrouterApiKeyInput = '';
this.clearOpenrouterKey = false;
this.successMessage = 'Parametres sauvegardes.';
this.saving = false;
},

View File

@@ -97,13 +97,14 @@
</header>
<div class="context-gauge" *ngIf="usage" [attr.data-level]="usageLevel"
[attr.title]="'System: ' + usage.system + ' · Historique: ' + usage.history + ' · Courant: ' + usage.current + ' / ' + usage.max + ' tokens'">
<div class="gauge-bar">
[attr.title]="'System: ' + usage.system + ' · Historique: ' + usage.history + ' · Courant: ' + usage.current + (usageHasMax ? ' / ' + usage.max : '') + ' tokens'">
<!-- Barre seulement si on connaît le plafond (Ollama). Cloud : compteur seul. -->
<div class="gauge-bar" *ngIf="usageHasMax">
<div class="gauge-fill" [style.width.%]="usagePercent"></div>
</div>
<div class="gauge-label">
<span class="gauge-text">Contexte : {{ usageTotal }} / {{ usage.max }} tokens</span>
<span class="gauge-percent">{{ usagePercent }}%</span>
<span class="gauge-text">Contexte : {{ usageTotal }}<ng-container *ngIf="usageHasMax"> / {{ usage.max }}</ng-container> tokens</span>
<span class="gauge-percent" *ngIf="usageHasMax">{{ usagePercent }}%</span>
</div>
</div>

View File

@@ -129,6 +129,10 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
if (!this.usage) return 0;
return this.usage.system + this.usage.history + this.usage.current;
}
/** Vrai si on connaît la fenêtre max (Ollama) → affiche dénominateur + barre. */
get usageHasMax(): boolean {
return !!this.usage && this.usage.max > 0;
}
get usageRatio(): number {
if (!this.usage || this.usage.max <= 0) return 0;
return Math.min(1, this.usageTotal / this.usage.max);

View File

@@ -8,7 +8,19 @@
<ng-container *ngIf="!isCollapsed">
<h2 class="sidebar-title">{{ title }}</h2>
<button
*ngIf="titleRoute; else staticTitle"
type="button"
class="sidebar-title sidebar-title--link"
[class.active]="isTitleActive()"
title="Retour à l'accueil de la campagne"
(click)="clickTitle()">
<lucide-icon [img]="Home" [size]="14" class="sidebar-title-icon"></lucide-icon>
<span class="sidebar-title-text">{{ title }}</span>
</button>
<ng-template #staticTitle>
<h2 class="sidebar-title">{{ title }}</h2>
</ng-template>
<div class="actions-row" *ngIf="createActions.length">
<button

View File

@@ -77,6 +77,35 @@
text-overflow: ellipsis;
}
// Variante cliquable : le titre devient un bouton "retour à l'accueil".
.sidebar-title--link {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
background: none;
border: none;
text-align: left;
cursor: pointer;
border-radius: 6px;
padding: 0.3rem 0.5rem;
transition: background 0.15s;
.sidebar-title-icon {
flex-shrink: 0;
color: #a78bfa;
}
.sidebar-title-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&:hover { background: rgba(255, 255, 255, 0.07); }
&.active { background: rgba(108, 99, 255, 0.18); }
}
.actions-row {
display: flex;
gap: 0.4rem;

View File

@@ -1,7 +1,7 @@
import { Component, Input, Output, EventEmitter, HostListener, OnDestroy, ElementRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { LucideAngularModule, ChevronRight, ChevronDown, PanelLeftClose, PanelLeftOpen, Plus, FolderPlus, FilePlus, LucideIconData } from 'lucide-angular';
import { LucideAngularModule, ChevronRight, ChevronDown, PanelLeftClose, PanelLeftOpen, Plus, FolderPlus, FilePlus, Home, LucideIconData } from 'lucide-angular';
import { TreeItem, TreeCreateAction, SidebarAction, BottomPanel, BottomPanelItem, LayoutService } from '../../services/layout.service';
import { resolveIcon } from '../../lore/lore-icons';
@@ -14,6 +14,8 @@ import { resolveIcon } from '../../lore/lore-icons';
})
export class SecondarySidebarComponent implements OnDestroy {
@Input() title = '';
/** Si défini, le titre est cliquable et navigue vers cette route (accueil de section). */
@Input() titleRoute: string | null = null;
@Input() createActions: SidebarAction[] = [];
@Input() bottomPanel: BottomPanel | null = null;
@Output() collapsedChange = new EventEmitter<boolean>();
@@ -28,6 +30,7 @@ export class SecondarySidebarComponent implements OnDestroy {
readonly Plus = Plus;
readonly FolderPlus = FolderPlus;
readonly FilePlus = FilePlus;
readonly Home = Home;
isCollapsed = false;
@@ -114,6 +117,19 @@ export class SecondarySidebarComponent implements OnDestroy {
if (action.route) { this.router.navigate([action.route]); }
}
/** Clic sur le titre cliquable : retour à l'accueil de la section (ex: campagne). */
clickTitle(): void {
if (this.titleRoute) { this.router.navigate([this.titleRoute]); }
}
/** True si on est déjà sur la route du titre (surligne le titre comme actif). */
isTitleActive(): boolean {
if (!this.titleRoute) return false;
return this.router.isActive(this.titleRoute, {
paths: 'exact', queryParams: 'ignored', fragment: 'ignored', matrixParams: 'ignored'
});
}
clickItem(item: TreeItem): void {
if (item.route) { this.router.navigate([item.route]); return; }
this.toggleItem(item.id);