From 809e00ce4983c0440bc62c510cae732770a6dbd7 Mon Sep 17 00:00:00 2001 From: "IETM_FIXE\\ietm6" Date: Fri, 12 Jun 2026 16:57:57 +0200 Subject: [PATCH] =?UTF-8?q?Ajout=20de=20la=20possibilit=C3=A9=20d'archiver?= =?UTF-8?q?=20le=20chat=20dans=20l'atelier=20PDF=20+=20IA,=20ainsi=20que?= =?UTF-8?q?=20de=20r=C3=A9f=C3=A9rencer=20l'archive=20dans=20la=20conversa?= =?UTF-8?q?tion=20actuelle.=20Le=20chat=20est=20limit=C3=A9=20=C3=A0=2016?= =?UTF-8?q?=20000=20caract=C3=A8res=20pour=20l'archive=20et=20le=20d=C3=A9?= =?UTF-8?q?but=20est=20tronqu=C3=A9=20pour=20laisser=20plut=C3=B4t=20la=20?= =?UTF-8?q?conclusion=20en=20visibilit=C3=A9.=20Passage=20b=C3=AAta=200.12?= =?UTF-8?q?.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- brain/app/main.py | 2 +- core/pom.xml | 2 +- .../campaigncontext/NotebookService.java | 52 +++++ .../campaigncontext/NotebookMessage.java | 2 + .../ports/NotebookRepository.java | 5 + .../entity/NotebookMessageJpaEntity.java | 8 + .../jpa/NotebookMessageJpaRepository.java | 17 +- .../postgres/PostgresNotebookRepository.java | 15 +- .../web/controller/NotebookController.java | 58 +++++- web/package-lock.json | 4 +- web/package.json | 2 +- .../notebook-detail.component.html | 180 +++++++++++++----- .../notebook-detail.component.scss | 161 +++++++++++++++- .../notebook-detail.component.ts | 94 ++++++++- web/src/app/services/notebook.model.ts | 9 + web/src/app/services/notebook.service.ts | 26 ++- 16 files changed, 567 insertions(+), 70 deletions(-) diff --git a/brain/app/main.py b/brain/app/main.py index 8ff1e15..86bc751 100644 --- a/brain/app/main.py +++ b/brain/app/main.py @@ -26,7 +26,7 @@ from app.infrastructure.ollama_model_installer import ensure_ollama_embedding_mo app = FastAPI( title="LoreMind Brain", description="Backend IA pour la génération de contenu narratif.", - version="0.12.5-beta", + version="0.12.6-beta", ) logger = logging.getLogger(__name__) diff --git a/core/pom.xml b/core/pom.xml index 2e4962d..40e2dae 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -14,7 +14,7 @@ com.loremind loremind-core - 0.12.5-beta + 0.12.6-beta LoreMind Core Backend Core - Architecture Hexagonale diff --git a/core/src/main/java/com/loremind/application/campaigncontext/NotebookService.java b/core/src/main/java/com/loremind/application/campaigncontext/NotebookService.java index 44ce6b6..7b20e11 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/NotebookService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/NotebookService.java @@ -119,6 +119,58 @@ public class NotebookService { .notebookId(notebookId).role(role).content(content).build()); } + /** « Vider la conversation » : archive le fil actif (rien n'est supprimé). */ + public void clearChat(String notebookId) { + repository.archiveMessagesByNotebookId(notebookId); + } + + /** Messages archivés, chronologiques — l'appelant regroupe par {@code archivedAt}. */ + public List getArchivedMessages(String notebookId) { + return repository.findArchivedMessagesByNotebookId(notebookId); + } + + // Budget total (caractères ≈ tokens/4) des archives injectées en référence : + // borne le prompt même si l'utilisateur coche plusieurs longues conversations. + private static final int ARCHIVE_CONTEXT_MAX_CHARS = 16000; + + /** + * Bloc de contexte construit à partir des archives COCHÉES par l'utilisateur + * (clés = {@code archivedAt.toString()}). Injecté dans le prompt du chat pour + * que l'IA puisse s'appuyer sur d'anciennes conversations. Chaîne vide si + * aucune clé valide. Chaque archive est tronquée PAR LE DÉBUT au-delà de son + * budget : la fin d'une conversation (conclusions) est la partie utile. + */ + public String buildArchiveContext(String notebookId, List archivedAtKeys) { + if (archivedAtKeys == null || archivedAtKeys.isEmpty()) return ""; + var wanted = new java.util.HashSet<>(archivedAtKeys); + var groups = new java.util.LinkedHashMap>(); + for (NotebookMessage m : repository.findArchivedMessagesByNotebookId(notebookId)) { + if (m.getArchivedAt() != null && wanted.contains(m.getArchivedAt().toString())) { + groups.computeIfAbsent(m.getArchivedAt(), k -> new java.util.ArrayList<>()).add(m); + } + } + if (groups.isEmpty()) return ""; + + int budgetPerArchive = Math.max(2000, ARCHIVE_CONTEXT_MAX_CHARS / groups.size()); + StringBuilder out = new StringBuilder( + "--- ANCIENNES CONVERSATIONS DE CET ATELIER (références choisies par le MJ : " + + "tu peux t'appuyer sur leurs conclusions) ---\n"); + groups.forEach((archivedAt, messages) -> { + StringBuilder convo = new StringBuilder(); + for (NotebookMessage m : messages) { + convo.append("user".equals(m.getRole()) ? "MJ : " : "IA : ") + .append(m.getContent()).append('\n'); + } + String text = convo.toString(); + if (text.length() > budgetPerArchive) { + text = "[…début tronqué…]\n" + text.substring(text.length() - budgetPerArchive); + } + out.append("[Archive du ").append(archivedAt).append("]\n").append(text).append('\n'); + }); + out.append("--- FIN DES ANCIENNES CONVERSATIONS ---"); + return out.toString(); + } + // --- Contexte campagne (oriente l'IA) --- /** Brief COMPLET de la campagne (structure arcs/chapitres/scènes + PNJ + lore) : diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/NotebookMessage.java b/core/src/main/java/com/loremind/domain/campaigncontext/NotebookMessage.java index b87a67d..a56636a 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/NotebookMessage.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/NotebookMessage.java @@ -17,4 +17,6 @@ public class NotebookMessage { private String role; private String content; private LocalDateTime createdAt; + /** Null = conversation active ; sinon horodatage du « vider » (lot d'archive). */ + private LocalDateTime archivedAt; } diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ports/NotebookRepository.java b/core/src/main/java/com/loremind/domain/campaigncontext/ports/NotebookRepository.java index ef0ecba..3151be5 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/ports/NotebookRepository.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/ports/NotebookRepository.java @@ -28,5 +28,10 @@ public interface NotebookRepository { // --- Messages (conversation) --- NotebookMessage saveMessage(NotebookMessage message); + /** Messages de la conversation ACTIVE (les archives sont exclues). */ List findMessagesByNotebookId(String notebookId); + /** « Vider » : archive le fil actif en un lot horodaté (rien n'est supprimé). */ + void archiveMessagesByNotebookId(String notebookId); + /** Messages archivés, chronologiques (regroupables par {@code archivedAt}). */ + List findArchivedMessagesByNotebookId(String notebookId); } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/NotebookMessageJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/NotebookMessageJpaEntity.java index 34dd262..7c25d0c 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/NotebookMessageJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/NotebookMessageJpaEntity.java @@ -34,6 +34,14 @@ public class NotebookMessageJpaEntity { @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; + /** + * Null = message de la conversation ACTIVE. Non-null = message archivé lors + * d'un « vider la conversation » ; tous les messages d'un même clear portent + * le même horodatage, qui sert d'identifiant de lot d'archive. + */ + @Column(name = "archived_at") + private LocalDateTime archivedAt; + @PrePersist protected void onCreate() { if (createdAt == null) createdAt = LocalDateTime.now(); diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/NotebookMessageJpaRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/NotebookMessageJpaRepository.java index 95d484f..131d839 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/NotebookMessageJpaRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/NotebookMessageJpaRepository.java @@ -2,12 +2,27 @@ package com.loremind.infrastructure.persistence.jpa; import com.loremind.infrastructure.persistence.entity.NotebookMessageJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; import java.util.List; @Repository public interface NotebookMessageJpaRepository extends JpaRepository { - List findByNotebookIdOrderByCreatedAtAsc(Long notebookId); + /** Messages de la conversation ACTIVE (les archives sont exclues). */ + List findByNotebookIdAndArchivedAtIsNullOrderByCreatedAtAsc(Long notebookId); + + /** Messages archivés (tous lots confondus, l'appelant regroupe par archivedAt). */ + List findByNotebookIdAndArchivedAtIsNotNullOrderByCreatedAtAsc(Long notebookId); + void deleteByNotebookId(Long notebookId); + + /** « Vider la conversation » : archive le fil actif en un lot horodaté. */ + @Modifying + @Query("update NotebookMessageJpaEntity m set m.archivedAt = :now " + + "where m.notebookId = :notebookId and m.archivedAt is null") + int archiveActiveMessages(@Param("notebookId") Long notebookId, @Param("now") LocalDateTime now); } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresNotebookRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresNotebookRepository.java index 83b47f6..f16d1d8 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresNotebookRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresNotebookRepository.java @@ -115,7 +115,19 @@ public class PostgresNotebookRepository implements NotebookRepository { @Override public List findMessagesByNotebookId(String notebookId) { - return messageJpa.findByNotebookIdOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream() + return messageJpa.findByNotebookIdAndArchivedAtIsNullOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream() + .map(this::toMessage).collect(Collectors.toList()); + } + + @Override + @Transactional + public void archiveMessagesByNotebookId(String notebookId) { + messageJpa.archiveActiveMessages(Long.parseLong(notebookId), java.time.LocalDateTime.now()); + } + + @Override + public List findArchivedMessagesByNotebookId(String notebookId) { + return messageJpa.findByNotebookIdAndArchivedAtIsNotNullOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream() .map(this::toMessage).collect(Collectors.toList()); } @@ -150,6 +162,7 @@ public class PostgresNotebookRepository implements NotebookRepository { .role(e.getRole()) .content(e.getContent()) .createdAt(e.getCreatedAt()) + .archivedAt(e.getArchivedAt()) .build(); } } diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/NotebookController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/NotebookController.java index a66edc7..ea56a63 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/NotebookController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/NotebookController.java @@ -105,6 +105,43 @@ public class NotebookController { return ResponseEntity.noContent().build(); } + // --- Conversation : vider (= archiver) et consulter les archives --- + + /** + * « Vider la conversation » : le fil actif est ARCHIVÉ en un lot horodaté, + * jamais supprimé — consultable ensuite via {@link #listArchives}. + */ + @PostMapping("/{id}/chat/clear") + public ResponseEntity clearChat(@PathVariable String id) { + if (service.getNotebook(id).isEmpty()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Notebook introuvable"); + } + service.clearChat(id); + return ResponseEntity.noContent().build(); + } + + /** Archives de conversation, plus récentes d'abord : [{archivedAt, messages:[…]}]. */ + @GetMapping("/{id}/chat/archives") + public ResponseEntity>> listArchives(@PathVariable String id) { + var grouped = new java.util.TreeMap>>( + java.util.Comparator.reverseOrder()); + for (var m : service.getArchivedMessages(id)) { + grouped.computeIfAbsent(m.getArchivedAt(), k -> new java.util.ArrayList<>()) + .add(Map.of( + "role", m.getRole(), + "content", m.getContent(), + "createdAt", m.getCreatedAt().toString())); + } + List> out = new java.util.ArrayList<>(); + grouped.forEach((archivedAt, messages) -> { + Map archive = new LinkedHashMap<>(); + archive.put("archivedAt", archivedAt.toString()); + archive.put("messages", messages); + out.add(archive); + }); + return ResponseEntity.ok(out); + } + // --- Chat ancré streamé --- @PostMapping(value = "/{id}/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) @@ -136,7 +173,14 @@ public class NotebookController { } else { sourceIds = readyIds; } - String context = service.buildContext(nb.getCampaignId()); + // Contexte = brief de campagne + archives cochées en référence (le tout + // dans une variable finale : capturée par la lambda du taskExecutor). + String campaignContext = service.buildContext(nb.getCampaignId()); + String archiveContext = service.buildArchiveContext(id, req.archiveIds()); + final String context = archiveContext.isEmpty() + ? campaignContext + : (campaignContext.isEmpty() ? archiveContext + : campaignContext + "\n\n" + archiveContext); boolean deep = req.deep() != null && req.deep(); taskExecutor.execute(() -> { @@ -232,9 +276,13 @@ public class NotebookController { public record CreateRequest(String campaignId, String name) {} public record RenameRequest(String name) {} /** - * @param sourceIds Optionnel : sous-ensemble de sources à utiliser pour ce tour - * (cases cochées dans l'UI). Null = toutes les sources prêtes. - * Toujours intersecté avec les sources du notebook (sécurité). + * @param sourceIds Optionnel : sous-ensemble de sources à utiliser pour ce tour + * (cases cochées dans l'UI). Null = toutes les sources prêtes. + * Toujours intersecté avec les sources du notebook (sécurité). + * @param archiveIds Optionnel : archives de conversation cochées comme RÉFÉRENCE + * (clés = archivedAt). Leur contenu est injecté dans le contexte + * du prompt — toujours résolu dans CE notebook (sécurité). */ - public record ChatRequest(String message, Boolean deep, List sourceIds) {} + public record ChatRequest(String message, Boolean deep, List sourceIds, + List archiveIds) {} } diff --git a/web/package-lock.json b/web/package-lock.json index 1b61336..a6128f2 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "loremind-web", - "version": "0.12.5-beta", + "version": "0.12.6-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loremind-web", - "version": "0.12.5-beta", + "version": "0.12.6-beta", "dependencies": { "@angular/animations": "^21.2.16", "@angular/common": "^21.2.16", diff --git a/web/package.json b/web/package.json index 0d29401..229f966 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "loremind-web", - "version": "0.12.5-beta", + "version": "0.12.6-beta", "description": "LoreMind Frontend - Angular", "scripts": { "ng": "ng", diff --git a/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.html b/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.html index 94df349..c25b5d6 100644 --- a/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.html +++ b/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.html @@ -71,55 +71,140 @@
-
- @if (messages.length === 0) { -

- Pose une question sur ta source, ou demande une adaptation pour ta campagne. - @if (!hasReadySource()) { -
(Ajoute d'abord une source indexée pour des réponses ancrées.)
- } -

- } - @for (m of messages; track m) { -
-
- @if (m.role === 'assistant') { - - } - {{ m.role === 'user' ? 'Vous' : 'IA' }} -
- @if (m.role === 'assistant') { - @if (parsedOf(m); as p) { - @if (sending && deepProgress && !p.text) { -
- - Analyse approfondie du document… {{ deepProgress.current }}/{{ deepProgress.total }} -
- } -
{{ p.text }}@if (sending && !p.text && !p.actions.length && !deepProgress) { - - }
- @for (a of p.actions; track $index) { - - - } - @if (sourcesLabel(m); as label) { -
- 📖 {{ label }} -
- } - } - } @else { -
{{ m.content }}
- } -
+ +
+ @if (viewingArchive) { + + + Archive du {{ archiveLabel(viewingArchive) }} — lecture seule + + + } @else { + @if (referencedArchiveIds.size > 0) { + + + {{ referencedArchiveIds.size }} archive(s) en référence + + } + + + }
+ + @if (archivesOpen && !viewingArchive) { +
+ @if (archives.length === 0) { +

Aucune conversation archivée pour l'instant — « Vider » archive le fil courant ici.

+ } @else { +

+ Cochez une archive pour l'utiliser comme référence dans le chat ; + cliquez sur son nom pour la relire. +

+ } + @for (a of archives; track a.archivedAt) { +
+ + +
+ } +
+ } +
+ @if (viewingArchive) { + + @for (m of viewingArchive.messages; track $index) { +
+
+ @if (m.role === 'assistant') { + + } + {{ m.role === 'user' ? 'Vous' : 'IA' }} +
+ @if (m.role === 'assistant') { +
+ } @else { +
{{ m.content }}
+ } +
+ } + } @else { + @if (messages.length === 0) { +

+ Pose une question sur ta source, ou demande une adaptation pour ta campagne. + @if (!hasReadySource()) { +
(Ajoute d'abord une source indexée pour des réponses ancrées.)
+ } +

+ } + @for (m of messages; track m) { +
+
+ @if (m.role === 'assistant') { + + } + {{ m.role === 'user' ? 'Vous' : 'IA' }} +
+ @if (m.role === 'assistant') { + @if (parsedOf(m); as p) { + @if (sending && deepProgress && !p.text) { +
+ + Analyse approfondie du document… {{ deepProgress.current }}/{{ deepProgress.total }} +
+ } + @if (p.text) { +
+ } @else if (sending && !p.actions.length && !deepProgress) { +
+ } + @for (a of p.actions; track $index) { + + + } + @if (sourcesLabel(m); as label) { +
+ 📖 {{ label }} +
+ } + } + } @else { +
{{ m.content }}
+ } +
+ } + } +
+ @if (!viewingArchive) {