Ajout de la possibilité d'archiver le chat dans l'atelier PDF + IA, ainsi que de référencer l'archive dans la conversation actuelle.
All checks were successful
All checks were successful
Le chat est limité à 16 000 caractères pour l'archive et le début est tronqué pour laisser plutôt la conclusion en visibilité. Passage bêta 0.12.6
This commit is contained in:
@@ -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<NotebookMessage> getArchivedMessages(String notebookId) {
|
||||
return repository.findArchivedMessagesByNotebookId(notebookId);
|
||||
}
|
||||
|
||||
// Budget total (caractères ≈ tokens/4) des archives injectées en référence :
|
||||
// borne le prompt même si l'utilisateur coche plusieurs longues conversations.
|
||||
private static final int ARCHIVE_CONTEXT_MAX_CHARS = 16000;
|
||||
|
||||
/**
|
||||
* Bloc de contexte construit à partir des archives COCHÉES par l'utilisateur
|
||||
* (clés = {@code archivedAt.toString()}). Injecté dans le prompt du chat pour
|
||||
* que l'IA puisse s'appuyer sur d'anciennes conversations. Chaîne vide si
|
||||
* aucune clé valide. Chaque archive est tronquée PAR LE DÉBUT au-delà de son
|
||||
* budget : la fin d'une conversation (conclusions) est la partie utile.
|
||||
*/
|
||||
public String buildArchiveContext(String notebookId, List<String> archivedAtKeys) {
|
||||
if (archivedAtKeys == null || archivedAtKeys.isEmpty()) return "";
|
||||
var wanted = new java.util.HashSet<>(archivedAtKeys);
|
||||
var groups = new java.util.LinkedHashMap<java.time.LocalDateTime, List<NotebookMessage>>();
|
||||
for (NotebookMessage m : repository.findArchivedMessagesByNotebookId(notebookId)) {
|
||||
if (m.getArchivedAt() != null && wanted.contains(m.getArchivedAt().toString())) {
|
||||
groups.computeIfAbsent(m.getArchivedAt(), k -> new java.util.ArrayList<>()).add(m);
|
||||
}
|
||||
}
|
||||
if (groups.isEmpty()) return "";
|
||||
|
||||
int budgetPerArchive = Math.max(2000, ARCHIVE_CONTEXT_MAX_CHARS / groups.size());
|
||||
StringBuilder out = new StringBuilder(
|
||||
"--- ANCIENNES CONVERSATIONS DE CET ATELIER (références choisies par le MJ : "
|
||||
+ "tu peux t'appuyer sur leurs conclusions) ---\n");
|
||||
groups.forEach((archivedAt, messages) -> {
|
||||
StringBuilder convo = new StringBuilder();
|
||||
for (NotebookMessage m : messages) {
|
||||
convo.append("user".equals(m.getRole()) ? "MJ : " : "IA : ")
|
||||
.append(m.getContent()).append('\n');
|
||||
}
|
||||
String text = convo.toString();
|
||||
if (text.length() > budgetPerArchive) {
|
||||
text = "[…début tronqué…]\n" + text.substring(text.length() - budgetPerArchive);
|
||||
}
|
||||
out.append("[Archive du ").append(archivedAt).append("]\n").append(text).append('\n');
|
||||
});
|
||||
out.append("--- FIN DES ANCIENNES CONVERSATIONS ---");
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
// --- Contexte campagne (oriente l'IA) ---
|
||||
|
||||
/** Brief COMPLET de la campagne (structure arcs/chapitres/scènes + PNJ + lore) :
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -28,5 +28,10 @@ public interface NotebookRepository {
|
||||
|
||||
// --- Messages (conversation) ---
|
||||
NotebookMessage saveMessage(NotebookMessage message);
|
||||
/** Messages de la conversation ACTIVE (les archives sont exclues). */
|
||||
List<NotebookMessage> findMessagesByNotebookId(String notebookId);
|
||||
/** « Vider » : archive le fil actif en un lot horodaté (rien n'est supprimé). */
|
||||
void archiveMessagesByNotebookId(String notebookId);
|
||||
/** Messages archivés, chronologiques (regroupables par {@code archivedAt}). */
|
||||
List<NotebookMessage> findArchivedMessagesByNotebookId(String notebookId);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<NotebookMessageJpaEntity, Long> {
|
||||
List<NotebookMessageJpaEntity> findByNotebookIdOrderByCreatedAtAsc(Long notebookId);
|
||||
/** Messages de la conversation ACTIVE (les archives sont exclues). */
|
||||
List<NotebookMessageJpaEntity> findByNotebookIdAndArchivedAtIsNullOrderByCreatedAtAsc(Long notebookId);
|
||||
|
||||
/** Messages archivés (tous lots confondus, l'appelant regroupe par archivedAt). */
|
||||
List<NotebookMessageJpaEntity> findByNotebookIdAndArchivedAtIsNotNullOrderByCreatedAtAsc(Long notebookId);
|
||||
|
||||
void deleteByNotebookId(Long notebookId);
|
||||
|
||||
/** « 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);
|
||||
}
|
||||
|
||||
@@ -115,7 +115,19 @@ public class PostgresNotebookRepository implements NotebookRepository {
|
||||
|
||||
@Override
|
||||
public List<NotebookMessage> findMessagesByNotebookId(String notebookId) {
|
||||
return messageJpa.findByNotebookIdOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream()
|
||||
return messageJpa.findByNotebookIdAndArchivedAtIsNullOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream()
|
||||
.map(this::toMessage).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void archiveMessagesByNotebookId(String notebookId) {
|
||||
messageJpa.archiveActiveMessages(Long.parseLong(notebookId), java.time.LocalDateTime.now());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<NotebookMessage> findArchivedMessagesByNotebookId(String notebookId) {
|
||||
return messageJpa.findByNotebookIdAndArchivedAtIsNotNullOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream()
|
||||
.map(this::toMessage).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@@ -150,6 +162,7 @@ public class PostgresNotebookRepository implements NotebookRepository {
|
||||
.role(e.getRole())
|
||||
.content(e.getContent())
|
||||
.createdAt(e.getCreatedAt())
|
||||
.archivedAt(e.getArchivedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Void> clearChat(@PathVariable String id) {
|
||||
if (service.getNotebook(id).isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Notebook introuvable");
|
||||
}
|
||||
service.clearChat(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/** Archives de conversation, plus récentes d'abord : [{archivedAt, messages:[…]}]. */
|
||||
@GetMapping("/{id}/chat/archives")
|
||||
public ResponseEntity<List<Map<String, Object>>> listArchives(@PathVariable String id) {
|
||||
var grouped = new java.util.TreeMap<java.time.LocalDateTime, List<Map<String, Object>>>(
|
||||
java.util.Comparator.reverseOrder());
|
||||
for (var m : service.getArchivedMessages(id)) {
|
||||
grouped.computeIfAbsent(m.getArchivedAt(), k -> new java.util.ArrayList<>())
|
||||
.add(Map.of(
|
||||
"role", m.getRole(),
|
||||
"content", m.getContent(),
|
||||
"createdAt", m.getCreatedAt().toString()));
|
||||
}
|
||||
List<Map<String, Object>> out = new java.util.ArrayList<>();
|
||||
grouped.forEach((archivedAt, messages) -> {
|
||||
Map<String, Object> archive = new LinkedHashMap<>();
|
||||
archive.put("archivedAt", archivedAt.toString());
|
||||
archive.put("messages", messages);
|
||||
out.add(archive);
|
||||
});
|
||||
return ResponseEntity.ok(out);
|
||||
}
|
||||
|
||||
// --- Chat ancré streamé ---
|
||||
|
||||
@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<String> sourceIds) {}
|
||||
public record ChatRequest(String message, Boolean deep, List<String> sourceIds,
|
||||
List<String> archiveIds) {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user