From ed22d9f29c98a080825a495beca55bd33f73e82c Mon Sep 17 00:00:00 2001 From: "IETM_FIXE\\ietm6" Date: Mon, 8 Jun 2026 11:05:07 +0200 Subject: [PATCH] =?UTF-8?q?R=C3=A9solution=20d'un=20bug=20de=20switch=20en?= =?UTF-8?q?tre=20les=20PNJ=20lorsqu'on=20essai=20de=20passer=20directement?= =?UTF-8?q?=20de=20l'un=20=C3=A0=20l'autre=20Ajout=20de=20dossiers=20pour?= =?UTF-8?q?=20la=20partie=20PNJ=20pour=20qu'on=20puisse=20les=20regrouper?= =?UTF-8?q?=20par=20cit=C3=A9=20par=20exemple=20et=20que=20ce=20soit=20plu?= =?UTF-8?q?s=20lisible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- brain/app/application/notebook_deep.py | 18 ++++-- brain/app/main.py | 5 +- .../campaigncontext/NpcService.java | 10 +++ .../loremind/domain/campaigncontext/Npc.java | 3 + .../persistence/entity/NpcJpaEntity.java | 3 + .../postgres/PostgresNpcRepository.java | 2 + .../web/controller/NpcController.java | 1 + .../web/dto/campaigncontext/NpcDTO.java | 1 + .../infrastructure/web/mapper/NpcMapper.java | 2 + web/src/app/campaigns/campaign-tree.helper.ts | 35 +++++++++-- .../npc/npc-edit/npc-edit.component.html | 15 +++++ .../npc/npc-edit/npc-edit.component.ts | 17 +++++ .../npc/npc-view/npc-view.component.ts | 62 ++++++++++++------- web/src/app/services/npc.model.ts | 3 + 14 files changed, 145 insertions(+), 32 deletions(-) diff --git a/brain/app/application/notebook_deep.py b/brain/app/application/notebook_deep.py index 0b78cb8..b89a68f 100644 --- a/brain/app/application/notebook_deep.py +++ b/brain/app/application/notebook_deep.py @@ -63,11 +63,18 @@ class NotebookDeepUseCase: async def stream( self, source_ids: list[str], - question: str, + messages: list[ChatMessage], context: str = "", + history_limit: int = 8, ) -> AsyncIterator[dict]: """Yield des évènements : {type:'progress',current,total}, {type:'token',token}, - {type:'done'}. (Les erreurs LLM des lots sont tolérées : lot ignoré.)""" + {type:'done'}. (Les erreurs LLM des lots sont tolérées : lot ignoré.) + + La dernière question utilisateur sert à la LECTURE du document (map) ; la + SYNTHÈSE (reduce) reçoit les `history_limit` derniers messages → les relances + conversationnelles (« et pour les autres ? ») fonctionnent aussi en approfondi. + """ + question = next((m.content for m in reversed(messages) if m.role == "user"), "") chunks: list[dict] = [] for sid in source_ids: chunks.extend(vector_store.all_chunks(sid)) @@ -102,10 +109,11 @@ class NotebookDeepUseCase: if context.strip() else "" ) system_prompt = _REDUCE_SYSTEM.format(context_block=context_block, notes_block=notes_block) + # Historique récent pour la cohérence des relances ; on garantit que le + # dernier message est bien la question courante. + reduce_messages = messages[-history_limit:] if messages else [ChatMessage(role="user", content=question)] llm_chat: LLMChatProvider = self._llm # type: ignore[assignment] - async for token in llm_chat.stream_chat( - [ChatMessage(role="user", content=question)], system_prompt=system_prompt - ): + async for token in llm_chat.stream_chat(reduce_messages, system_prompt=system_prompt): yield {"type": "token", "token": token} yield {"type": "done"} diff --git a/brain/app/main.py b/brain/app/main.py index b7eda84..a9447f5 100644 --- a/brain/app/main.py +++ b/brain/app/main.py @@ -1100,7 +1100,8 @@ async def chat_notebook_deep_stream( ) -> StreamingResponse: """Analyse APPROFONDIE (map-reduce sur tout le document). Évènements SSE : `progress` {current,total} pendant la lecture, puis `token` {token}, puis `done`.""" - question = next((m.content for m in reversed(body.messages) if m.role == "user"), "") + messages = [ChatMessage(role=m.role, content=m.content) for m in body.messages] + question = next((m.content for m in reversed(messages) if m.role == "user"), "") def _sse(event: str, data: dict) -> str: return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" @@ -1110,7 +1111,7 @@ async def chat_notebook_deep_stream( yield _sse("error", {"message": "Question vide."}) return try: - async for ev in use_case.stream(body.source_ids, question, context=body.context): + async for ev in use_case.stream(body.source_ids, messages, context=body.context): ev_type = ev.pop("type") yield _sse(ev_type, ev) except (LLMProviderError, EmbeddingError) as exc: diff --git a/core/src/main/java/com/loremind/application/campaigncontext/NpcService.java b/core/src/main/java/com/loremind/application/campaigncontext/NpcService.java index 0d23b29..5beb2d9 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/NpcService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/NpcService.java @@ -29,6 +29,7 @@ public class NpcService { Map> imageValues, Map> keyValueValues, String campaignId, + String folder, Integer order ) {} @@ -44,6 +45,7 @@ public class NpcService { .imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>()) .keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>()) .campaignId(data.campaignId()) + .folder(normalizeFolder(data.folder())) .order(order) .build(); return npcRepository.save(npc); @@ -66,6 +68,7 @@ public class NpcService { existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>()); existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>()); existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>()); + existing.setFolder(normalizeFolder(data.folder())); if (data.order() != null) { existing.setOrder(data.order()); } @@ -76,6 +79,13 @@ public class NpcService { npcRepository.deleteById(id); } + /** Trim le dossier ; chaîne vide → null (= non classé). */ + private static String normalizeFolder(String folder) { + if (folder == null) return null; + String trimmed = folder.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + private int nextOrderFor(String campaignId) { return npcRepository.findByCampaignId(campaignId).stream() .mapToInt(Npc::getOrder) diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Npc.java b/core/src/main/java/com/loremind/domain/campaigncontext/Npc.java index b57ba54..82c6b81 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/Npc.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/Npc.java @@ -46,6 +46,9 @@ public class Npc { /** Référence vers la Campaign parente (cross-aggregate via ID). */ private String campaignId; + /** Dossier de classement (texte libre, ex. « Bard's Gate »). Nullable = non classé. */ + private String folder; + /** Ordre d'affichage dans la liste des PNJ de la campagne. */ private int order; diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/NpcJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/NpcJpaEntity.java index ebb59df..01ac4c4 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/NpcJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/NpcJpaEntity.java @@ -54,6 +54,9 @@ public class NpcJpaEntity { @Column(name = "campaign_id", nullable = false) private Long campaignId; + @Column(name = "folder") + private String folder; + @Column(name = "\"order\"", nullable = false) private int order; diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresNpcRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresNpcRepository.java index a19478c..8ae7cbd 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresNpcRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresNpcRepository.java @@ -59,6 +59,7 @@ public class PostgresNpcRepository implements NpcRepository { .imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>()) .keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>()) .campaignId(e.getCampaignId().toString()) + .folder(e.getFolder()) .order(e.getOrder()) .createdAt(e.getCreatedAt()) .updatedAt(e.getUpdatedAt()) @@ -76,6 +77,7 @@ public class PostgresNpcRepository implements NpcRepository { .imageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>()) .keyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>()) .campaignId(Long.parseLong(n.getCampaignId())) + .folder(n.getFolder()) .order(n.getOrder()) .createdAt(n.getCreatedAt()) .updatedAt(n.getUpdatedAt()) diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/NpcController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/NpcController.java index 2578638..41b072c 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/NpcController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/NpcController.java @@ -64,6 +64,7 @@ public class NpcController { dto.getImageValues(), dto.getKeyValueValues(), dto.getCampaignId(), + dto.getFolder(), order ); } diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/NpcDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/NpcDTO.java index e8207f4..7e9c7af 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/NpcDTO.java +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/NpcDTO.java @@ -20,5 +20,6 @@ public class NpcDTO { private Map> imageValues = new HashMap<>(); private Map> keyValueValues = new HashMap<>(); private String campaignId; + private String folder; private int order; } diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/NpcMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/NpcMapper.java index 19786e0..c2be917 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/mapper/NpcMapper.java +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/NpcMapper.java @@ -20,6 +20,7 @@ public class NpcMapper { dto.setImageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>()); dto.setKeyValueValues(n.getKeyValueValues() != null ? new HashMap<>(n.getKeyValueValues()) : new HashMap<>()); dto.setCampaignId(n.getCampaignId()); + dto.setFolder(n.getFolder()); dto.setOrder(n.getOrder()); return dto; } @@ -35,6 +36,7 @@ public class NpcMapper { .imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>()) .keyValueValues(dto.getKeyValueValues() != null ? new HashMap<>(dto.getKeyValueValues()) : new HashMap<>()) .campaignId(dto.getCampaignId()) + .folder(dto.getFolder()) .order(dto.getOrder()) .build(); } diff --git a/web/src/app/campaigns/campaign-tree.helper.ts b/web/src/app/campaigns/campaign-tree.helper.ts index 305eecd..b2b2f24 100644 --- a/web/src/app/campaigns/campaign-tree.helper.ts +++ b/web/src/app/campaigns/campaign-tree.helper.ts @@ -96,18 +96,45 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T // à 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 => ({ + const npcItem = (n: Npc): TreeItem => ({ id: `npc-${n.id}`, label: n.name, route: `/campaigns/${campaignId}/npcs/${n.id}` - })); + }); + + // Regroupement par DOSSIER : un sous-nœud (dépliable) par dossier, puis les PNJ + // non classés directement sous « PNJ ». + const npcsByFolder = new Map(); + const ungroupedNpcs: Npc[] = []; + for (const n of sortedNpcs) { + const f = (n.folder ?? '').trim(); + if (f) { + if (!npcsByFolder.has(f)) npcsByFolder.set(f, []); + npcsByFolder.get(f)!.push(n); + } else { + ungroupedNpcs.push(n); + } + } + const npcFolderNodes: TreeItem[] = [...npcsByFolder.keys()] + .sort((a, b) => a.localeCompare(b, 'fr', { sensitivity: 'base' })) + .map(folder => { + const items = npcsByFolder.get(folder)!.map(npcItem); + return { + id: `npc-folder-${folder}`, + label: folder, + iconKey: 'folder', + children: items, + meta: String(items.length) + }; + }); + const npcChildren: TreeItem[] = [...npcFolderNodes, ...ungroupedNpcs.map(npcItem)]; const npcsNode: TreeItem = { id: 'npcs-root', label: 'PNJ', iconKey: 'c-drama', - children: npcItems, - meta: npcItems.length ? String(npcItems.length) : undefined, + children: npcChildren, + meta: sortedNpcs.length ? String(sortedNpcs.length) : undefined, // 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', diff --git a/web/src/app/campaigns/npc/npc-edit/npc-edit.component.html b/web/src/app/campaigns/npc/npc-edit/npc-edit.component.html index 2e3df12..b891f12 100644 --- a/web/src/app/campaigns/npc/npc-edit/npc-edit.component.html +++ b/web/src/app/campaigns/npc/npc-edit/npc-edit.component.html @@ -36,6 +36,21 @@ /> +
+ + + + + +
+
diff --git a/web/src/app/campaigns/npc/npc-edit/npc-edit.component.ts b/web/src/app/campaigns/npc/npc-edit/npc-edit.component.ts index 4e16dc9..21ef636 100644 --- a/web/src/app/campaigns/npc/npc-edit/npc-edit.component.ts +++ b/web/src/app/campaigns/npc/npc-edit/npc-edit.component.ts @@ -46,6 +46,9 @@ export class NpcEditComponent implements OnInit { npcId: string | null = null; name = ''; + folder = ''; + /** Dossiers déjà utilisés dans la campagne (datalist d'auto-complétion). */ + existingFolders: string[] = []; portraitImageId: string | null = null; headerImageId: string | null = null; values: Record = {}; @@ -72,12 +75,14 @@ export class NpcEditComponent implements OnInit { if (this.campaignId) { this.loadTemplateForCampaign(this.campaignId); this.campaignSidebar.show(this.campaignId); + this.loadExistingFolders(this.campaignId); } if (this.npcId) { this.service.getById(this.npcId).subscribe({ next: (n) => { this.name = n.name; + this.folder = n.folder ?? ''; this.portraitImageId = n.portraitImageId ?? null; this.headerImageId = n.headerImageId ?? null; this.values = n.values ?? {}; @@ -90,6 +95,17 @@ export class NpcEditComponent implements OnInit { } } + private loadExistingFolders(campaignId: string): void { + this.service.getByCampaign(campaignId).subscribe({ + next: (list) => { + this.existingFolders = [...new Set( + list.map(n => (n.folder ?? '').trim()).filter(f => f.length > 0) + )].sort((a, b) => a.localeCompare(b, 'fr')); + }, + error: () => { this.existingFolders = []; } + }); + } + private loadTemplateForCampaign(campaignId: string): void { this.campaignService.getCampaignById(campaignId).subscribe({ next: (campaign) => { @@ -111,6 +127,7 @@ export class NpcEditComponent implements OnInit { if (!this.name.trim() || !this.campaignId) return; const payload = { name: this.name.trim(), + folder: this.folder.trim() || null, portraitImageId: this.portraitImageId, headerImageId: this.headerImageId, values: this.values, diff --git a/web/src/app/campaigns/npc/npc-view/npc-view.component.ts b/web/src/app/campaigns/npc/npc-view/npc-view.component.ts index 650b37b..e7b2d0d 100644 --- a/web/src/app/campaigns/npc/npc-view/npc-view.component.ts +++ b/web/src/app/campaigns/npc/npc-view/npc-view.component.ts @@ -1,6 +1,7 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router } from '@angular/router'; +import { Subscription } from 'rxjs'; import { LucideAngularModule, ArrowLeft, Edit3, Sparkles } from 'lucide-angular'; import { NpcService } from '../../../services/npc.service'; import { CampaignService } from '../../../services/campaign.service'; @@ -22,7 +23,7 @@ import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-dr templateUrl: './npc-view.component.html', styleUrls: ['./npc-view.component.scss'] }) -export class NpcViewComponent implements OnInit { +export class NpcViewComponent implements OnInit, OnDestroy { readonly ArrowLeft = ArrowLeft; readonly Edit3 = Edit3; readonly Sparkles = Sparkles; @@ -36,6 +37,8 @@ export class NpcViewComponent implements OnInit { chatOpen = false; toggleChat(): void { this.chatOpen = !this.chatOpen; } + private paramsSub?: Subscription; + constructor( private route: ActivatedRoute, private router: Router, @@ -46,25 +49,42 @@ export class NpcViewComponent implements OnInit { ) {} ngOnInit(): void { - const params = this.route.snapshot.paramMap; - this.campaignId = params.get('campaignId'); - this.npcId = params.get('npcId'); - if (this.npcId) { - this.service.getById(this.npcId).subscribe({ - next: n => { this.npc = n; }, - error: () => this.back() - }); - } - if (this.campaignId) { - this.campaignSidebar.show(this.campaignId); - this.campaignService.getCampaignById(this.campaignId).subscribe(camp => { - if (camp.gameSystemId) { - this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => { - this.templateFields = gs.npcTemplate ?? []; - }); - } - }); - } + // S'abonner aux paramMap (pas le snapshot) : quand on passe d'un PNJ à un autre, + // Angular RÉUTILISE le composant (même route) → ngOnInit ne re-tourne pas. Sans ce + // subscribe, la fiche du centre resterait figée sur l'ancien PNJ. + this.paramsSub = this.route.paramMap.subscribe(params => { + const newCampaignId = params.get('campaignId'); + this.npcId = params.get('npcId'); + + // Recharge la fiche à CHAQUE changement de PNJ. + this.chatOpen = false; + if (this.npcId) { + this.service.getById(this.npcId).subscribe({ + next: n => { this.npc = n; }, + error: () => this.back() + }); + } + + // Sidebar + template du système : seulement quand la campagne change (inutile + // de les recharger à chaque switch de PNJ d'une même campagne). + if (newCampaignId && newCampaignId !== this.campaignId) { + this.campaignId = newCampaignId; + this.campaignSidebar.show(this.campaignId); + this.campaignService.getCampaignById(this.campaignId).subscribe(camp => { + if (camp.gameSystemId) { + this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => { + this.templateFields = gs.npcTemplate ?? []; + }); + } + }); + } else if (newCampaignId) { + this.campaignId = newCampaignId; + } + }); + } + + ngOnDestroy(): void { + this.paramsSub?.unsubscribe(); } edit(): void { diff --git a/web/src/app/services/npc.model.ts b/web/src/app/services/npc.model.ts index 56dd9e1..e629b27 100644 --- a/web/src/app/services/npc.model.ts +++ b/web/src/app/services/npc.model.ts @@ -11,6 +11,8 @@ export interface Npc { imageValues?: Record; keyValueValues?: Record>; campaignId: string; + /** Dossier de classement (ex. « Bard's Gate »). Vide/absent = non classé. */ + folder?: string | null; order?: number; } @@ -22,4 +24,5 @@ export interface NpcCreate { imageValues?: Record; keyValueValues?: Record>; campaignId: string; + folder?: string | null; }