Import campagne : detection des PNJ et creatures notables (bout en bout)
Brain : la phase MAP recense les PNJ NOMMES et creatures uniques (pas les monstres generiques) avec une courte fiche fidele au livre ; dedoublonnage inter-morceaux (description la plus complete gardee) ; payload done + npc_count. Core : CampaignImportProposal.npcs + NpcProposal ; a l apply, creation comme Npc de la campagne (description -> values[Description], meme convention que les cartes d action des ateliers) avec anti-doublon par nom. Web : section de revue PNJ a cases a cocher (coches par defaut, grises si deja presents), compteurs de progression et recapitulatif mis a jour. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,7 @@ from app.domain.models import (
|
|||||||
ArcProposal,
|
ArcProposal,
|
||||||
CampaignImportResult,
|
CampaignImportResult,
|
||||||
ChapterProposal,
|
ChapterProposal,
|
||||||
|
NpcImportProposal,
|
||||||
RoomProposal,
|
RoomProposal,
|
||||||
SceneProposal,
|
SceneProposal,
|
||||||
)
|
)
|
||||||
@@ -83,6 +84,14 @@ PIÈCES (rooms) — uniquement pour les scènes qui sont des lieux explorables :
|
|||||||
- `enemies` = créatures/boss de la salle (vide si aucune). `loot` = trésor/récompense (vide si aucun).
|
- `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 [].
|
- Pour une scène narrative classique (pas un donjon), "rooms" est un tableau vide [].
|
||||||
|
|
||||||
|
PNJ ET CRÉATURES NOTABLES ("npcs", tableau au niveau racine) :
|
||||||
|
- Recense les PNJ NOMMÉS (alliés, marchands, antagonistes) et les créatures UNIQUES
|
||||||
|
(boss, monstre récurrent) présents dans l'extrait.
|
||||||
|
- `description` = courte fiche utile au MJ : rôle dans l'histoire, apparence,
|
||||||
|
motivations, où on le rencontre. 2 à 4 phrases, fidèles au livre.
|
||||||
|
- N'inclus PAS les monstres génériques sans nom (« 3 gobelins », « un loup »).
|
||||||
|
- Aucun PNJ nommé dans l'extrait → "npcs": [].
|
||||||
|
|
||||||
Format de réponse :
|
Format de réponse :
|
||||||
- Tu réponds UNIQUEMENT par un objet JSON valide, sans markdown ni commentaire autour.
|
- Tu réponds UNIQUEMENT par un objet JSON valide, sans markdown ni commentaire autour.
|
||||||
- Schéma EXACT :
|
- Schéma EXACT :
|
||||||
@@ -91,7 +100,8 @@ Format de réponse :
|
|||||||
{{"name": "...", "description": "...", "player_narration": "...", "gm_notes": "...",
|
{{"name": "...", "description": "...", "player_narration": "...", "gm_notes": "...",
|
||||||
"rooms": [{{"name": "...", "description": "...", "enemies": "...", "loot": "..."}}]}}
|
"rooms": [{{"name": "...", "description": "...", "enemies": "...", "loot": "..."}}]}}
|
||||||
]}}]}}
|
]}}]}}
|
||||||
]}}
|
],
|
||||||
|
"npcs": [{{"name": "...", "description": "..."}}]}}
|
||||||
- Utilise les VRAIS titres du livre pour les noms (pas de paraphrase).
|
- 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}".
|
- 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.
|
- N'invente pas de contenu : tu réorganises et recopies ce qui est présent dans l'extrait.
|
||||||
@@ -157,6 +167,8 @@ class _TreeMerger:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
# arc_key -> {"name", "description", "chapters": {chap_key -> {...}}}
|
# arc_key -> {"name", "description", "chapters": {chap_key -> {...}}}
|
||||||
self._arcs: dict[str, dict] = {}
|
self._arcs: dict[str, dict] = {}
|
||||||
|
# npc_key (nom en minuscules) -> {"name", "description"}
|
||||||
|
self._npcs: dict[str, dict] = {}
|
||||||
|
|
||||||
def add(self, arcs_json: list[dict]) -> None:
|
def add(self, arcs_json: list[dict]) -> None:
|
||||||
for arc in arcs_json or []:
|
for arc in arcs_json or []:
|
||||||
@@ -202,6 +214,22 @@ class _TreeMerger:
|
|||||||
self._fill_field(r, rm, "enemies")
|
self._fill_field(r, rm, "enemies")
|
||||||
self._fill_field(r, rm, "loot")
|
self._fill_field(r, rm, "loot")
|
||||||
|
|
||||||
|
def add_npcs(self, npcs_json: list[dict]) -> None:
|
||||||
|
"""Accumule les PNJ détectés. Un PNJ revu dans un autre morceau garde la
|
||||||
|
description la plus COMPLÈTE (la plus longue) — un PNJ récurrent est
|
||||||
|
souvent décrit en détail une seule fois."""
|
||||||
|
for npc in npcs_json or []:
|
||||||
|
name = str(npc.get("name", "")).strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
desc = str(npc.get("description") or "").strip()
|
||||||
|
entry = self._npcs.setdefault(name.lower(), {"name": name, "description": ""})
|
||||||
|
if len(desc) > len(entry["description"]):
|
||||||
|
entry["description"] = desc
|
||||||
|
|
||||||
|
def npcs(self) -> list[NpcImportProposal]:
|
||||||
|
return [NpcImportProposal(n["name"], n["description"]) for n in self._npcs.values()]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _fill_desc(node: dict, src: dict) -> None:
|
def _fill_desc(node: dict, src: dict) -> None:
|
||||||
if not node["description"]:
|
if not node["description"]:
|
||||||
@@ -358,13 +386,15 @@ class ImportCampaignUseCase:
|
|||||||
for i, c in wave
|
for i, c in wave
|
||||||
))
|
))
|
||||||
for res in results:
|
for res in results:
|
||||||
merger.add(res)
|
merger.add(res["arcs"])
|
||||||
|
merger.add_npcs(res["npcs"])
|
||||||
if total > 1:
|
if total > 1:
|
||||||
await self._consolidate(merger)
|
await self._consolidate(merger)
|
||||||
return CampaignImportResult(
|
return CampaignImportResult(
|
||||||
arcs=merger.result(),
|
arcs=merger.result(),
|
||||||
page_count=doc.page_count,
|
page_count=doc.page_count,
|
||||||
ocr_page_count=doc.ocr_page_count,
|
ocr_page_count=doc.ocr_page_count,
|
||||||
|
npcs=merger.npcs(),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def stream(self, pdf_bytes: bytes):
|
async def stream(self, pdf_bytes: bytes):
|
||||||
@@ -429,7 +459,8 @@ class ImportCampaignUseCase:
|
|||||||
elif isinstance(res, BaseException):
|
elif isinstance(res, BaseException):
|
||||||
raise res # bug inattendu : ne pas l'avaler en silence
|
raise res # bug inattendu : ne pas l'avaler en silence
|
||||||
else:
|
else:
|
||||||
merger.add(res or [])
|
merger.add((res or {}).get("arcs") or [])
|
||||||
|
merger.add_npcs((res or {}).get("npcs") or [])
|
||||||
arcs, chapters, scenes = merger.counts()
|
arcs, chapters, scenes = merger.counts()
|
||||||
yield {
|
yield {
|
||||||
"type": "progress",
|
"type": "progress",
|
||||||
@@ -438,6 +469,7 @@ class ImportCampaignUseCase:
|
|||||||
"arc_count": arcs,
|
"arc_count": arcs,
|
||||||
"chapter_count": chapters,
|
"chapter_count": chapters,
|
||||||
"scene_count": scenes,
|
"scene_count": scenes,
|
||||||
|
"npc_count": len(merger.npcs()),
|
||||||
"skipped": skipped,
|
"skipped": skipped,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,6 +491,7 @@ class ImportCampaignUseCase:
|
|||||||
yield {
|
yield {
|
||||||
"type": "done",
|
"type": "done",
|
||||||
"arcs": _serialize_arcs(merger.result()),
|
"arcs": _serialize_arcs(merger.result()),
|
||||||
|
"npcs": [{"name": n.name, "description": n.description} for n in merger.npcs()],
|
||||||
"page_count": doc.page_count,
|
"page_count": doc.page_count,
|
||||||
"ocr_page_count": doc.ocr_page_count,
|
"ocr_page_count": doc.ocr_page_count,
|
||||||
"skipped": skipped,
|
"skipped": skipped,
|
||||||
@@ -506,16 +539,17 @@ class ImportCampaignUseCase:
|
|||||||
|
|
||||||
async def _map_chunk(
|
async def _map_chunk(
|
||||||
self, chunk: str, *, index: int, total: int, toc_block: str = ""
|
self, chunk: str, *, index: int, total: int, toc_block: str = ""
|
||||||
) -> list[dict]:
|
) -> dict:
|
||||||
return await self._extract_arcs(
|
"""Phase MAP d'un morceau → {"arcs": [...], "npcs": [...]}."""
|
||||||
|
return await self._extract_payload(
|
||||||
chunk, index=index, total=total, depth=0, toc_block=toc_block)
|
chunk, index=index, total=total, depth=0, toc_block=toc_block)
|
||||||
|
|
||||||
async def _extract_arcs(
|
async def _extract_payload(
|
||||||
self, text: str, *, index: int, total: int, depth: int, toc_block: str = ""
|
self, text: str, *, index: int, total: int, depth: int, toc_block: str = ""
|
||||||
) -> list[dict]:
|
) -> dict:
|
||||||
"""Extrait l'arborescence d'un texte. Si la SORTIE est tronquée, retraite le
|
"""Extrait l'arborescence + les PNJ d'un texte. Si la SORTIE est tronquée,
|
||||||
texte en DEUX moitiés et concatène — le `_TreeMerger` final dédoublonne par
|
retraite le texte en DEUX moitiés et concatène — le `_TreeMerger` final
|
||||||
nom (un arc/chapitre coupé entre les moitiés est recollé)."""
|
dédoublonne par nom (un arc/chapitre coupé entre les moitiés est recollé)."""
|
||||||
toc_section = _TOC_BLOCK.format(toc=toc_block) if toc_block else ""
|
toc_section = _TOC_BLOCK.format(toc=toc_block) if toc_block else ""
|
||||||
prompt = (
|
prompt = (
|
||||||
_MAP_SYSTEM.format(default_arc=_DEFAULT_ARC_NAME)
|
_MAP_SYSTEM.format(default_arc=_DEFAULT_ARC_NAME)
|
||||||
@@ -525,7 +559,7 @@ class ImportCampaignUseCase:
|
|||||||
)
|
)
|
||||||
raw = await generate_with_retry(
|
raw = await generate_with_retry(
|
||||||
self._llm, prompt, output_format="json", temperature=_TEMPERATURE)
|
self._llm, prompt, output_format="json", temperature=_TEMPERATURE)
|
||||||
arcs, truncated = self._parse_arcs(raw, index=index)
|
payload, truncated = self._parse_payload(raw, index=index)
|
||||||
|
|
||||||
if truncated and depth < _MAX_SPLIT_DEPTH:
|
if truncated and depth < _MAX_SPLIT_DEPTH:
|
||||||
left, right = split_in_half(text)
|
left, right = split_in_half(text)
|
||||||
@@ -533,19 +567,20 @@ class ImportCampaignUseCase:
|
|||||||
logger.info(
|
logger.info(
|
||||||
"Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).",
|
"Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).",
|
||||||
index, depth + 1)
|
index, depth + 1)
|
||||||
a = await self._extract_arcs(
|
a = await self._extract_payload(
|
||||||
left, index=index, total=total, depth=depth + 1, toc_block=toc_block)
|
left, index=index, total=total, depth=depth + 1, toc_block=toc_block)
|
||||||
b = await self._extract_arcs(
|
b = await self._extract_payload(
|
||||||
right, index=index, total=total, depth=depth + 1, toc_block=toc_block)
|
right, index=index, total=total, depth=depth + 1, toc_block=toc_block)
|
||||||
return a + b
|
return {"arcs": a["arcs"] + b["arcs"], "npcs": a["npcs"] + b["npcs"]}
|
||||||
if truncated:
|
if truncated:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Morceau %s : sortie tronquée, profondeur max atteinte — partiel conservé.", index)
|
"Morceau %s : sortie tronquée, profondeur max atteinte — partiel conservé.", index)
|
||||||
return arcs
|
return payload
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_arcs(raw: str, *, index: int) -> tuple[list[dict], bool]:
|
def _parse_payload(raw: str, *, index: int) -> tuple[dict, bool]:
|
||||||
"""Parse robuste → (arcs, tronqué). `tronqué`=True si récupération partielle."""
|
"""Parse robuste → ({"arcs", "npcs"}, tronqué). `tronqué`=True si partiel."""
|
||||||
|
empty = {"arcs": [], "npcs": []}
|
||||||
parsed, recovered = load_json_object(raw)
|
parsed, recovered = load_json_object(raw)
|
||||||
if parsed is None:
|
if parsed is None:
|
||||||
truncated = looks_like_truncated_json(raw)
|
truncated = looks_like_truncated_json(raw)
|
||||||
@@ -554,11 +589,15 @@ class ImportCampaignUseCase:
|
|||||||
"Morceau %s : aucun objet JSON exploitable, ignoré. "
|
"Morceau %s : aucun objet JSON exploitable, ignoré. "
|
||||||
"Début de la réponse du modèle : %r",
|
"Début de la réponse du modèle : %r",
|
||||||
index, (raw or "").strip()[:300] or "(réponse VIDE)")
|
index, (raw or "").strip()[:300] or "(réponse VIDE)")
|
||||||
return [], truncated
|
return empty, truncated
|
||||||
if isinstance(parsed, dict):
|
if isinstance(parsed, dict):
|
||||||
arcs = parsed.get("arcs", [])
|
arcs = parsed.get("arcs", [])
|
||||||
return (arcs if isinstance(arcs, list) else []), recovered
|
npcs = parsed.get("npcs", [])
|
||||||
return [], recovered
|
return {
|
||||||
|
"arcs": arcs if isinstance(arcs, list) else [],
|
||||||
|
"npcs": npcs if isinstance(npcs, list) else [],
|
||||||
|
}, recovered
|
||||||
|
return empty, recovered
|
||||||
|
|
||||||
|
|
||||||
def _serialize_arcs(arcs: list[ArcProposal]) -> list[dict]:
|
def _serialize_arcs(arcs: list[ArcProposal]) -> list[dict]:
|
||||||
|
|||||||
@@ -424,6 +424,18 @@ class ArcProposal:
|
|||||||
chapters: list[ChapterProposal] = field(default_factory=list)
|
chapters: list[ChapterProposal] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class NpcImportProposal:
|
||||||
|
"""PNJ/créature notable détecté à l'import d'un PDF de campagne.
|
||||||
|
|
||||||
|
PNJ NOMMÉS et créatures uniques (boss) — pas les monstres génériques.
|
||||||
|
`description` = courte fiche (rôle, apparence, motivations, où on le croise).
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
description: str = ""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class CampaignImportResult:
|
class CampaignImportResult:
|
||||||
"""Proposition d'arborescence narrative extraite d'un PDF de campagne.
|
"""Proposition d'arborescence narrative extraite d'un PDF de campagne.
|
||||||
@@ -435,6 +447,9 @@ class CampaignImportResult:
|
|||||||
arcs: list[ArcProposal]
|
arcs: list[ArcProposal]
|
||||||
page_count: int
|
page_count: int
|
||||||
ocr_page_count: int
|
ocr_page_count: int
|
||||||
|
# PNJ/créatures notables détectés au fil des morceaux (proposition, à cocher
|
||||||
|
# dans l'écran de revue avant création).
|
||||||
|
npcs: list[NpcImportProposal] = field(default_factory=list)
|
||||||
|
|
||||||
def counts(self) -> tuple[int, int, int]:
|
def counts(self) -> tuple[int, int, int]:
|
||||||
"""(nb arcs, nb chapitres, nb scènes) — pour le diagnostic / la progression."""
|
"""(nb arcs, nb chapitres, nb scènes) — pour le diagnostic / la progression."""
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.loremind.domain.campaigncontext.CampaignImportProgress;
|
|||||||
import com.loremind.domain.campaigncontext.CampaignImportProposal;
|
import com.loremind.domain.campaigncontext.CampaignImportProposal;
|
||||||
import com.loremind.domain.campaigncontext.CampaignImportProposal.ArcProposal;
|
import com.loremind.domain.campaigncontext.CampaignImportProposal.ArcProposal;
|
||||||
import com.loremind.domain.campaigncontext.CampaignImportProposal.ChapterProposal;
|
import com.loremind.domain.campaigncontext.CampaignImportProposal.ChapterProposal;
|
||||||
|
import com.loremind.domain.campaigncontext.CampaignImportProposal.NpcProposal;
|
||||||
import com.loremind.domain.campaigncontext.CampaignImportProposal.RoomProposal;
|
import com.loremind.domain.campaigncontext.CampaignImportProposal.RoomProposal;
|
||||||
import com.loremind.domain.campaigncontext.CampaignImportProposal.SceneProposal;
|
import com.loremind.domain.campaigncontext.CampaignImportProposal.SceneProposal;
|
||||||
import com.loremind.domain.campaigncontext.Chapter;
|
import com.loremind.domain.campaigncontext.Chapter;
|
||||||
@@ -37,22 +38,26 @@ public class CampaignImportService {
|
|||||||
private final ArcService arcService;
|
private final ArcService arcService;
|
||||||
private final ChapterService chapterService;
|
private final ChapterService chapterService;
|
||||||
private final SceneService sceneService;
|
private final SceneService sceneService;
|
||||||
|
private final NpcService npcService;
|
||||||
|
|
||||||
public CampaignImportService(
|
public CampaignImportService(
|
||||||
CampaignPdfImporter campaignPdfImporter,
|
CampaignPdfImporter campaignPdfImporter,
|
||||||
CampaignService campaignService,
|
CampaignService campaignService,
|
||||||
ArcService arcService,
|
ArcService arcService,
|
||||||
ChapterService chapterService,
|
ChapterService chapterService,
|
||||||
SceneService sceneService) {
|
SceneService sceneService,
|
||||||
|
NpcService npcService) {
|
||||||
this.campaignPdfImporter = campaignPdfImporter;
|
this.campaignPdfImporter = campaignPdfImporter;
|
||||||
this.campaignService = campaignService;
|
this.campaignService = campaignService;
|
||||||
this.arcService = arcService;
|
this.arcService = arcService;
|
||||||
this.chapterService = chapterService;
|
this.chapterService = chapterService;
|
||||||
this.sceneService = sceneService;
|
this.sceneService = sceneService;
|
||||||
|
this.npcService = npcService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Résumé de ce qui a été créé par {@link #applyStructure}. */
|
/** Résumé de ce qui a été créé par {@link #applyStructure}. */
|
||||||
public record ApplyResult(int arcsCreated, int chaptersCreated, int scenesCreated) {}
|
public record ApplyResult(
|
||||||
|
int arcsCreated, int chaptersCreated, int scenesCreated, int npcsCreated) {}
|
||||||
|
|
||||||
/** Génère la proposition d'arbre (streamée). Ne persiste rien. */
|
/** Génère la proposition d'arbre (streamée). Ne persiste rien. */
|
||||||
public void importStructureStreaming(
|
public void importStructureStreaming(
|
||||||
@@ -130,7 +135,36 @@ public class CampaignImportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return new ApplyResult(arcsCreated, chaptersCreated, scenesCreated);
|
|
||||||
|
int npcsCreated = createNpcs(campaignId, proposal.npcs());
|
||||||
|
return new ApplyResult(arcsCreated, chaptersCreated, scenesCreated, npcsCreated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée les PNJ proposés (description → values["Description"], même convention
|
||||||
|
* que les cartes d'action des ateliers). Les PNJ portant un nom déjà présent
|
||||||
|
* dans la campagne sont ignorés (ré-import sans doublon).
|
||||||
|
*/
|
||||||
|
private int createNpcs(String campaignId, List<NpcProposal> proposals) {
|
||||||
|
if (proposals == null || proposals.isEmpty()) return 0;
|
||||||
|
java.util.Set<String> existingNames = new java.util.HashSet<>();
|
||||||
|
for (var npc : npcService.getNpcsByCampaignId(campaignId)) {
|
||||||
|
existingNames.add(npc.getName().trim().toLowerCase());
|
||||||
|
}
|
||||||
|
int created = 0;
|
||||||
|
for (NpcProposal p : proposals) {
|
||||||
|
if (isBlank(p.name()) || !existingNames.add(p.name().trim().toLowerCase())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
npcService.createNpc(new NpcService.NpcData(
|
||||||
|
p.name().trim(), null, null,
|
||||||
|
isBlank(p.description())
|
||||||
|
? java.util.Map.of()
|
||||||
|
: java.util.Map.of("Description", p.description().trim()),
|
||||||
|
null, null, campaignId, null, null));
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compte les nœuds déjà présents (existingId non vide) d'une liste. */
|
/** Compte les nœuds déjà présents (existingId non vide) d'une liste. */
|
||||||
|
|||||||
@@ -14,5 +14,6 @@ public record CampaignImportProgress(
|
|||||||
int ocrPageCount,
|
int ocrPageCount,
|
||||||
int arcCount,
|
int arcCount,
|
||||||
int chapterCount,
|
int chapterCount,
|
||||||
int sceneCount) {
|
int sceneCount,
|
||||||
|
int npcCount) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import java.util.List;
|
|||||||
* PROPOSITION non persistée : l'UI laisse l'utilisateur réviser/éditer l'arbre
|
* 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).
|
* avant la création effective des arcs/chapitres/scènes. Records purs (domaine).
|
||||||
*/
|
*/
|
||||||
public record CampaignImportProposal(List<ArcProposal> arcs) {
|
public record CampaignImportProposal(List<ArcProposal> arcs, List<NpcProposal> npcs) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@code existingId} (nullable) : si présent, le nœud existe DÉJÀ dans la
|
* {@code existingId} (nullable) : si présent, le nœud existe DÉJÀ dans la
|
||||||
@@ -37,4 +37,14 @@ public record CampaignImportProposal(List<ArcProposal> arcs) {
|
|||||||
|
|
||||||
public record RoomProposal(String name, String description, String enemies, String loot) {
|
public record RoomProposal(String name, String description, String enemies, String loot) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PNJ/creature notable detecte dans le PDF (PNJ nommes, boss). Propose a la
|
||||||
|
* revue (coche par defaut) ; cree comme Npc de la campagne a l''apply avec
|
||||||
|
* sa description dans values["Description"] (meme convention que les cartes
|
||||||
|
* d''action des ateliers).
|
||||||
|
*/
|
||||||
|
public record NpcProposal(String name, String description) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.loremind.domain.campaigncontext.CampaignImportProgress;
|
|||||||
import com.loremind.domain.campaigncontext.CampaignImportProposal;
|
import com.loremind.domain.campaigncontext.CampaignImportProposal;
|
||||||
import com.loremind.domain.campaigncontext.CampaignImportProposal.ArcProposal;
|
import com.loremind.domain.campaigncontext.CampaignImportProposal.ArcProposal;
|
||||||
import com.loremind.domain.campaigncontext.CampaignImportProposal.ChapterProposal;
|
import com.loremind.domain.campaigncontext.CampaignImportProposal.ChapterProposal;
|
||||||
|
import com.loremind.domain.campaigncontext.CampaignImportProposal.NpcProposal;
|
||||||
import com.loremind.domain.campaigncontext.CampaignImportProposal.RoomProposal;
|
import com.loremind.domain.campaigncontext.CampaignImportProposal.RoomProposal;
|
||||||
import com.loremind.domain.campaigncontext.CampaignImportProposal.SceneProposal;
|
import com.loremind.domain.campaigncontext.CampaignImportProposal.SceneProposal;
|
||||||
import com.loremind.domain.campaigncontext.ports.CampaignImportException;
|
import com.loremind.domain.campaigncontext.ports.CampaignImportException;
|
||||||
@@ -119,7 +120,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ("extracting".equals(event)) {
|
if ("extracting".equals(event)) {
|
||||||
onProgress.accept(new CampaignImportProgress(0, 0, 0, 0, 0, 0, 0));
|
onProgress.accept(new CampaignImportProgress(0, 0, 0, 0, 0, 0, 0, 0));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +131,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
|||||||
pageCount[0] = node.path("page_count").asInt();
|
pageCount[0] = node.path("page_count").asInt();
|
||||||
ocrPageCount[0] = node.path("ocr_page_count").asInt();
|
ocrPageCount[0] = node.path("ocr_page_count").asInt();
|
||||||
onProgress.accept(new CampaignImportProgress(
|
onProgress.accept(new CampaignImportProgress(
|
||||||
0, node.path("total").asInt(), pageCount[0], ocrPageCount[0], 0, 0, 0));
|
0, node.path("total").asInt(), pageCount[0], ocrPageCount[0], 0, 0, 0, 0));
|
||||||
} else if ("progress".equals(event)) {
|
} else if ("progress".equals(event)) {
|
||||||
onProgress.accept(new CampaignImportProgress(
|
onProgress.accept(new CampaignImportProgress(
|
||||||
node.path("current").asInt(),
|
node.path("current").asInt(),
|
||||||
@@ -139,10 +140,12 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
|||||||
ocrPageCount[0],
|
ocrPageCount[0],
|
||||||
node.path("arc_count").asInt(),
|
node.path("arc_count").asInt(),
|
||||||
node.path("chapter_count").asInt(),
|
node.path("chapter_count").asInt(),
|
||||||
node.path("scene_count").asInt()));
|
node.path("scene_count").asInt(),
|
||||||
|
node.path("npc_count").asInt()));
|
||||||
} else if ("done".equals(event)) {
|
} else if ("done".equals(event)) {
|
||||||
terminated[0] = true;
|
terminated[0] = true;
|
||||||
onDone.accept(new CampaignImportProposal(toArcs(node.path("arcs"))));
|
onDone.accept(new CampaignImportProposal(
|
||||||
|
toArcs(node.path("arcs")), toNpcs(node.path("npcs"))));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +205,16 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
|||||||
return rooms;
|
return rooms;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<NpcProposal> toNpcs(JsonNode npcsNode) {
|
||||||
|
List<NpcProposal> npcs = new ArrayList<>();
|
||||||
|
if (npcsNode != null && npcsNode.isArray()) {
|
||||||
|
for (JsonNode n : npcsNode) {
|
||||||
|
npcs.add(new NpcProposal(text(n, "name"), text(n, "description")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return npcs;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Helpers -------------------------------------------------------------
|
// --- Helpers -------------------------------------------------------------
|
||||||
|
|
||||||
private ByteArrayResource filePart(byte[] pdfBytes, String filename) {
|
private ByteArrayResource filePart(byte[] pdfBytes, String filename) {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
}
|
}
|
||||||
@if (importCounts) {
|
@if (importCounts) {
|
||||||
<p class="import-counts">
|
<p class="import-counts">
|
||||||
Trouvé jusqu'ici : {{ importCounts.arcs }} arc(s) · {{ importCounts.chapters }} chapitre(s) · {{ importCounts.scenes }} scène(s)
|
Trouvé jusqu'ici : {{ importCounts.arcs }} arc(s) · {{ importCounts.chapters }} chapitre(s) · {{ importCounts.scenes }} scène(s) · {{ importCounts.npcs }} PNJ
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -51,7 +51,8 @@
|
|||||||
<p class="review-summary">
|
<p class="review-summary">
|
||||||
À créer : <strong>{{ arcCount }}</strong> nouvel(s) arc(s),
|
À créer : <strong>{{ arcCount }}</strong> nouvel(s) arc(s),
|
||||||
<strong>{{ chapterCount }}</strong> chapitre(s),
|
<strong>{{ chapterCount }}</strong> chapitre(s),
|
||||||
<strong>{{ sceneCount }}</strong> scène(s).
|
<strong>{{ sceneCount }}</strong> scène(s)@if (npcs.length > 0) {<span>,
|
||||||
|
<strong>{{ npcCount }}</strong> PNJ</span>}.
|
||||||
Les éléments <em>« déjà présent »</em> (grisés) sont affichés pour situer les ajouts ;
|
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.
|
ils ne seront pas recréés. Modifiez les nouveaux, puis créez.
|
||||||
</p>
|
</p>
|
||||||
@@ -203,6 +204,34 @@
|
|||||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> Ajouter un arc
|
<lucide-icon [img]="Plus" [size]="14"></lucide-icon> Ajouter un arc
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- PNJ détectés dans le PDF : revue par cases à cocher -->
|
||||||
|
@if (npcs.length > 0) {
|
||||||
|
<div class="npc-review">
|
||||||
|
<h3>PNJ et créatures détectés ({{ npcs.length }})</h3>
|
||||||
|
<p class="hint">
|
||||||
|
Cochez ceux à créer dans la campagne (description issue du livre).
|
||||||
|
Ceux déjà présents dans la campagne sont grisés et ne seront pas recréés.
|
||||||
|
</p>
|
||||||
|
<ul class="npc-list">
|
||||||
|
@for (npc of npcs; track npc.name) {
|
||||||
|
<li class="npc-row" [class.npc-existing]="npc.existing">
|
||||||
|
<label class="npc-check">
|
||||||
|
<input type="checkbox" [(ngModel)]="npc.selected" [disabled]="npc.existing">
|
||||||
|
<strong>{{ npc.name }}</strong>
|
||||||
|
@if (npc.existing) {
|
||||||
|
<em class="npc-tag">déjà présent</em>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
@if (npc.description) {
|
||||||
|
<p class="npc-desc">{{ npc.description }}</p>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@if (applyError) {
|
@if (applyError) {
|
||||||
<p class="apply-error">{{ applyError }}</p>
|
<p class="apply-error">{{ applyError }}</p>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,3 +321,49 @@
|
|||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Revue des PNJ détectés dans le PDF ------------------------------------
|
||||||
|
.npc-review {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--border-color, #2e2e3a);
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
h3 { margin: 0 0 0.25rem; font-size: 1rem; }
|
||||||
|
.hint { margin: 0 0 0.75rem; font-size: 0.85rem; opacity: 0.75; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.npc-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.npc-row {
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
|
||||||
|
&.npc-existing { opacity: 0.55; }
|
||||||
|
|
||||||
|
.npc-check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.npc-tag {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.npc-desc {
|
||||||
|
margin: 0.3rem 0 0 1.6rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { NpcService } from '../../../services/npc.service';
|
|||||||
import { RandomTableService } from '../../../services/random-table.service';
|
import { RandomTableService } from '../../../services/random-table.service';
|
||||||
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||||
import { PageTitleService } from '../../../services/page-title.service';
|
import { PageTitleService } from '../../../services/page-title.service';
|
||||||
import { ArcKind, ArcProposal, ChapterProposal, SceneProposal } from '../../../services/campaign-import.model';
|
import { ArcKind, ArcProposal, ChapterProposal, NpcProposal, SceneProposal } from '../../../services/campaign-import.model';
|
||||||
import { CampaignImportProposal } from '../../../services/campaign-import.model';
|
import { CampaignImportProposal } from '../../../services/campaign-import.model';
|
||||||
import { loadCampaignTreeData, CampaignTreeData } from '../../campaign-tree.helper';
|
import { loadCampaignTreeData, CampaignTreeData } from '../../campaign-tree.helper';
|
||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
@@ -37,6 +37,11 @@ interface ArcNode {
|
|||||||
name: string; description: string; type: ArcKind; chapters: ChapterNode[]; collapsed: boolean;
|
name: string; description: string; type: ArcKind; chapters: ChapterNode[]; collapsed: boolean;
|
||||||
existing: boolean; existingId?: string;
|
existing: boolean; existingId?: string;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* PNJ détecté dans le PDF, à cocher à la revue. `existing` = un PNJ du même nom
|
||||||
|
* existe déjà dans la campagne (décoché et non créable, anti-doublon).
|
||||||
|
*/
|
||||||
|
interface NpcNode { name: string; description: string; selected: boolean; existing: boolean; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Page d'import d'un PDF de campagne → arbre arc/chapitre/scène.
|
* Page d'import d'un PDF de campagne → arbre arc/chapitre/scène.
|
||||||
@@ -69,7 +74,7 @@ export class CampaignImportComponent implements OnInit {
|
|||||||
importing = false;
|
importing = false;
|
||||||
importPhase = '';
|
importPhase = '';
|
||||||
importProgress: { current: number; total: number } | null = null;
|
importProgress: { current: number; total: number } | null = null;
|
||||||
importCounts: { arcs: number; chapters: number; scenes: number } | null = null;
|
importCounts: { arcs: number; chapters: number; scenes: number; npcs: number } | null = null;
|
||||||
importError: string | null = null;
|
importError: string | null = null;
|
||||||
/** Vrai une fois la proposition reçue (on affiche l'arbre éditable). */
|
/** Vrai une fois la proposition reçue (on affiche l'arbre éditable). */
|
||||||
reviewing = false;
|
reviewing = false;
|
||||||
@@ -77,6 +82,9 @@ export class CampaignImportComponent implements OnInit {
|
|||||||
// --- Arbre éditable ---
|
// --- Arbre éditable ---
|
||||||
tree: ArcNode[] = [];
|
tree: ArcNode[] = [];
|
||||||
|
|
||||||
|
/** PNJ détectés dans le PDF (revue par cases à cocher). */
|
||||||
|
npcs: NpcNode[] = [];
|
||||||
|
|
||||||
/** Structure actuelle de la campagne (chargée pour la fusion à la revue). */
|
/** Structure actuelle de la campagne (chargée pour la fusion à la revue). */
|
||||||
private existingData: CampaignTreeData | null = null;
|
private existingData: CampaignTreeData | null = null;
|
||||||
|
|
||||||
@@ -136,17 +144,21 @@ export class CampaignImportComponent implements OnInit {
|
|||||||
} else {
|
} else {
|
||||||
this.importPhase = `Analyse de la campagne… (${ev.current}/${ev.total})`;
|
this.importPhase = `Analyse de la campagne… (${ev.current}/${ev.total})`;
|
||||||
this.importProgress = { current: ev.current, total: ev.total };
|
this.importProgress = { current: ev.current, total: ev.total };
|
||||||
this.importCounts = { arcs: ev.arcCount, chapters: ev.chapterCount, scenes: ev.sceneCount };
|
this.importCounts = {
|
||||||
|
arcs: ev.arcCount, chapters: ev.chapterCount,
|
||||||
|
scenes: ev.sceneCount, npcs: ev.npcCount ?? 0
|
||||||
|
};
|
||||||
}
|
}
|
||||||
} else if (ev.type === 'done') {
|
} else if (ev.type === 'done') {
|
||||||
this.importing = false;
|
this.importing = false;
|
||||||
this.importPhase = '';
|
this.importPhase = '';
|
||||||
this.importProgress = null;
|
this.importProgress = null;
|
||||||
if ((ev.arcs ?? []).length === 0) {
|
if ((ev.arcs ?? []).length === 0 && (ev.npcs ?? []).length === 0) {
|
||||||
this.importError = "Aucune structure narrative détectée dans ce PDF.";
|
this.importError = "Aucune structure narrative détectée dans ce PDF.";
|
||||||
this.reviewing = false;
|
this.reviewing = false;
|
||||||
} else {
|
} else {
|
||||||
this.tree = this.buildMergedTree(ev.arcs);
|
this.tree = this.buildMergedTree(ev.arcs ?? []);
|
||||||
|
this.npcs = this.buildNpcNodes(ev.npcs ?? []);
|
||||||
this.reviewing = true;
|
this.reviewing = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -232,6 +244,21 @@ export class CampaignImportComponent implements OnInit {
|
|||||||
return (a ?? '').trim().toLowerCase() === (b ?? '').trim().toLowerCase();
|
return (a ?? '').trim().toLowerCase() === (b ?? '').trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit la liste de revue des PNJ : cochés par défaut, sauf ceux dont le
|
||||||
|
* nom existe déjà dans la campagne (anti-doublon, affichés grisés).
|
||||||
|
*/
|
||||||
|
private buildNpcNodes(proposals: NpcProposal[]): NpcNode[] {
|
||||||
|
const existingNames = new Set(
|
||||||
|
(this.existingData?.npcs ?? []).map(n => (n.name ?? '').trim().toLowerCase()));
|
||||||
|
return proposals
|
||||||
|
.filter(p => (p.name ?? '').trim())
|
||||||
|
.map(p => {
|
||||||
|
const existing = existingNames.has(p.name.trim().toLowerCase());
|
||||||
|
return { name: p.name.trim(), description: p.description ?? '', selected: !existing, existing };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- Mappers proposition → nœud NEUF -------------------------------------
|
// --- Mappers proposition → nœud NEUF -------------------------------------
|
||||||
|
|
||||||
private newArcNode(a: ArcProposal): ArcNode {
|
private newArcNode(a: ArcProposal): ArcNode {
|
||||||
@@ -310,9 +337,14 @@ export class CampaignImportComponent implements OnInit {
|
|||||||
n + a.chapters.reduce((m, c) => m + c.scenes.filter(s => !s.existing && s.name.trim()).length, 0), 0);
|
n + a.chapters.reduce((m, c) => m + c.scenes.filter(s => !s.existing && s.name.trim()).length, 0), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Nombre de PNJ cochés (= qui seront créés). */
|
||||||
|
get npcCount(): number {
|
||||||
|
return this.npcs.filter(n => n.selected && !n.existing).length;
|
||||||
|
}
|
||||||
|
|
||||||
/** Vrai s'il y a au moins un nœud nouveau à créer (sinon « Créer » désactivé). */
|
/** Vrai s'il y a au moins un nœud nouveau à créer (sinon « Créer » désactivé). */
|
||||||
get hasNewContent(): boolean {
|
get hasNewContent(): boolean {
|
||||||
return this.arcCount > 0 || this.chapterCount > 0 || this.sceneCount > 0;
|
return this.arcCount > 0 || this.chapterCount > 0 || this.sceneCount > 0 || this.npcCount > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Application (création) ----------------------------------------------
|
// --- Application (création) ----------------------------------------------
|
||||||
@@ -357,7 +389,10 @@ export class CampaignImportComponent implements OnInit {
|
|||||||
}))
|
}))
|
||||||
}))
|
}))
|
||||||
}))
|
}))
|
||||||
}))
|
})),
|
||||||
|
npcs: this.npcs
|
||||||
|
.filter(n => n.selected && !n.existing && n.name.trim())
|
||||||
|
.map(n => ({ name: n.name.trim(), description: n.description.trim() }))
|
||||||
};
|
};
|
||||||
|
|
||||||
this.service.applyStructure(this.campaignId, proposal).subscribe({
|
this.service.applyStructure(this.campaignId, proposal).subscribe({
|
||||||
|
|||||||
@@ -40,8 +40,16 @@ export interface ArcProposal {
|
|||||||
existingId?: string | null;
|
existingId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** PNJ/créature notable détecté dans le PDF (PNJ nommés, boss). */
|
||||||
|
export interface NpcProposal {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CampaignImportProposal {
|
export interface CampaignImportProposal {
|
||||||
arcs: ArcProposal[];
|
arcs: ArcProposal[];
|
||||||
|
/** PNJ cochés à la revue (créés comme Npc de la campagne à l'apply). */
|
||||||
|
npcs?: NpcProposal[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Récapitulatif renvoyé après création effective des entités. */
|
/** Récapitulatif renvoyé après création effective des entités. */
|
||||||
@@ -49,6 +57,7 @@ export interface CampaignImportApplyResult {
|
|||||||
arcsCreated: number;
|
arcsCreated: number;
|
||||||
chaptersCreated: number;
|
chaptersCreated: number;
|
||||||
scenesCreated: number;
|
scenesCreated: number;
|
||||||
|
npcsCreated: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,6 +76,7 @@ export type CampaignImportStreamEvent =
|
|||||||
arcCount: number;
|
arcCount: number;
|
||||||
chapterCount: number;
|
chapterCount: number;
|
||||||
sceneCount: number;
|
sceneCount: number;
|
||||||
|
npcCount: number;
|
||||||
}
|
}
|
||||||
| { type: 'done'; arcs: ArcProposal[] }
|
| { type: 'done'; arcs: ArcProposal[]; npcs: NpcProposal[] }
|
||||||
| { type: 'error'; message: string };
|
| { type: 'error'; message: string };
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export class CampaignImportService {
|
|||||||
try {
|
try {
|
||||||
const obj = JSON.parse(currentData);
|
const obj = JSON.parse(currentData);
|
||||||
if (name === 'done') {
|
if (name === 'done') {
|
||||||
subscriber.next({ type: 'done', arcs: obj.arcs ?? [] });
|
subscriber.next({ type: 'done', arcs: obj.arcs ?? [], npcs: obj.npcs ?? [] });
|
||||||
subscriber.complete();
|
subscriber.complete();
|
||||||
} else {
|
} else {
|
||||||
subscriber.next({ type: 'progress', ...obj });
|
subscriber.next({ type: 'progress', ...obj });
|
||||||
|
|||||||
Reference in New Issue
Block a user