diff --git a/core/lombok.config b/core/lombok.config
new file mode 100644
index 0000000..07b0941
--- /dev/null
+++ b/core/lombok.config
@@ -0,0 +1,11 @@
+## LoreMind Core - Configuration Lombok
+#
+# addLombokGeneratedAnnotation : ajoute @lombok.Generated sur toutes les
+# methodes generees par Lombok (equals, hashCode, toString, builders,
+# getters/setters, etc.). JaCoCo 0.8.2+ reconnait cette annotation et
+# exclut automatiquement ces methodes du rapport de couverture.
+#
+# Objectif : mesurer la couverture UNIQUEMENT sur le code que nous ecrivons,
+# pas sur le bytecode auto-genere (qui fausse les metriques : branches et
+# instructions gonflees par les equals/hashCode).
+lombok.addLombokGeneratedAnnotation = true
diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainAiChatClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainAiChatClient.java
index 5ea4174..628fa7a 100644
--- a/core/src/main/java/com/loremind/infrastructure/ai/BrainAiChatClient.java
+++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainAiChatClient.java
@@ -1,17 +1,7 @@
package com.loremind.infrastructure.ai;
-import com.loremind.domain.generationcontext.CampaignStructuralContext;
-import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
-import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
-import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
-import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
-import com.loremind.domain.generationcontext.ChatMessage;
import com.loremind.domain.generationcontext.ChatRequest;
import com.loremind.domain.generationcontext.ChatUsage;
-import com.loremind.domain.generationcontext.LoreStructuralContext;
-import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
-import com.loremind.domain.generationcontext.NarrativeEntityContext;
-import com.loremind.domain.generationcontext.PageContext;
import com.loremind.domain.generationcontext.ports.AiChatProvider;
import com.loremind.domain.generationcontext.ports.AiProviderException;
import org.springframework.beans.factory.annotation.Value;
@@ -23,25 +13,21 @@ import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import java.time.Duration;
-import java.util.LinkedHashMap;
-import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
-import java.util.stream.Collectors;
/**
* Adapter de sortie (Architecture Hexagonale) : implémente AiChatProvider
* en appelant le Brain Python via WebClient + SSE (Server-Sent Events).
*
- * Responsabilités :
- * 1. Traduire ChatRequest (domaine) -> JSON attendu par /chat/stream.
- * Sérialise lore_context, page_context, campaign_context et
- * narrative_entity de façon conditionnelle selon le scénario d'appel
- * (chat Lore / chat Lore focalisé page / chat Campagne / chat Campagne
- * focalisé arc-chapter-scene).
- * 2. Consommer le flux SSE token par token.
- * 3. Invoquer onToken / onComplete / onError au bon moment.
- * 4. Traduire toute erreur technique en AiProviderException.
+ * Responsabilités (après extraction) :
+ * 1. Transport HTTP + consommation du flux SSE.
+ * 2. Dispatch des évènements SSE (data / done / error / usage).
+ * 3. Traduction des erreurs techniques en AiProviderException.
+ *
+ * Les responsabilités auxiliaires sont déléguées :
+ * - Construction du payload JSON : {@link BrainChatPayloadBuilder}.
+ * - Parsing des payloads SSE : {@link BrainSseParser}.
*
* Le domaine ne voit JAMAIS WebClient, Flux, ni la moindre URL.
*/
@@ -53,11 +39,17 @@ public class BrainAiChatClient implements AiChatProvider {
new ParameterizedTypeReference<>() {};
private final WebClient webClient;
+ private final BrainChatPayloadBuilder payloadBuilder;
+ private final BrainSseParser sseParser;
public BrainAiChatClient(
WebClient.Builder builder,
- @Value("${brain.base-url}") String baseUrl) {
+ @Value("${brain.base-url}") String baseUrl,
+ BrainChatPayloadBuilder payloadBuilder,
+ BrainSseParser sseParser) {
this.webClient = builder.baseUrl(baseUrl).build();
+ this.payloadBuilder = payloadBuilder;
+ this.sseParser = sseParser;
}
@Override
@@ -68,7 +60,7 @@ public class BrainAiChatClient implements AiChatProvider {
Runnable onComplete,
Consumer onError) {
- Map payload = toPayload(request);
+ Map payload = payloadBuilder.build(request);
Flux> flux = webClient.post()
.uri(CHAT_STREAM_PATH)
@@ -92,13 +84,13 @@ public class BrainAiChatClient implements AiChatProvider {
}
}
- /** Dispatch selon le type d'événement SSE (data par défaut, done, error, usage). */
+ /** Dispatch selon le type d'évènement SSE (data par défaut, done, error, usage). */
private void handleEvent(
ServerSentEvent sse,
Consumer onUsage,
Consumer onToken,
Consumer onError) {
- String event = sse.event(); // null si pas d'event: xxx -> c'est un data par défaut
+ String event = sse.event(); // null si pas d'event: xxx -> data par défaut
String data = sse.data();
if ("error".equals(event)) {
@@ -107,235 +99,17 @@ public class BrainAiChatClient implements AiChatProvider {
return;
}
if ("done".equals(event)) {
- return; // la fin est gérée par blockLast + onComplete
+ return; // fin gérée par blockLast + onComplete
}
if ("usage".equals(event)) {
- ChatUsage usage = extractUsage(data);
+ ChatUsage usage = sseParser.parseUsage(data);
if (usage != null) onUsage.accept(usage);
return;
}
- // Défaut : événement data avec JSON {"token":"..."}.
- String token = extractToken(data);
+ // Défaut : évènement data avec JSON {"token":"..."}.
+ String token = sseParser.parseToken(data);
if (token != null && !token.isEmpty()) {
onToken.accept(token);
}
}
-
- /**
- * Parse un JSON {"system":N,"history":N,"current":N,"max":N} en ChatUsage.
- * Renvoie null si le payload est illisible — dans ce cas on ne propage
- * simplement pas d'usage, le stream token continue normalement.
- */
- private ChatUsage extractUsage(String json) {
- if (json == null) return null;
- try {
- int system = extractIntField(json, "system");
- int history = extractIntField(json, "history");
- int current = extractIntField(json, "current");
- int max = extractIntField(json, "max");
- return new ChatUsage(system, history, current, max);
- } catch (Exception e) {
- return null;
- }
- }
-
- /** Parse minimaliste d'un champ entier JSON sans dépendre de Jackson. */
- private int extractIntField(String json, String field) {
- String needle = "\"" + field + "\"";
- int idx = json.indexOf(needle);
- if (idx < 0) return 0;
- int colon = json.indexOf(':', idx);
- if (colon < 0) return 0;
- int start = colon + 1;
- while (start < json.length() && Character.isWhitespace(json.charAt(start))) start++;
- int end = start;
- while (end < json.length() && (Character.isDigit(json.charAt(end)) || json.charAt(end) == '-')) end++;
- if (end == start) return 0;
- return Integer.parseInt(json.substring(start, end));
- }
-
- /**
- * Parse minimaliste du JSON {"token":"..."} sans pull Jackson ici.
- * Si le format se complexifie, on remplacera par un DTO Jackson.
- */
- private String extractToken(String json) {
- if (json == null) return null;
- int idx = json.indexOf("\"token\"");
- if (idx < 0) return null;
- int colon = json.indexOf(':', idx);
- int firstQuote = json.indexOf('"', colon + 1);
- int lastQuote = json.lastIndexOf('"');
- if (firstQuote < 0 || lastQuote <= firstQuote) return null;
- return json.substring(firstQuote + 1, lastQuote)
- .replace("\\n", "\n")
- .replace("\\\"", "\"")
- .replace("\\\\", "\\");
- }
-
- // --- Construction du payload JSON vers le Brain -------------------------
-
- /**
- * Construit le payload JSON. Chaque contexte optionnel est omis s'il est
- * null, pour s'aligner sur le schéma Pydantic côté Brain (champs
- * Optional qui restent absents du dict transmis au LLM).
- */
- private Map toPayload(ChatRequest request) {
- Map root = new LinkedHashMap<>();
- root.put("messages", request.getMessages().stream()
- .map(this::messageToMap)
- .collect(Collectors.toList()));
-
- if (request.getLoreContext() != null) {
- root.put("lore_context", loreContextToMap(request.getLoreContext()));
- }
- if (request.getPageContext() != null) {
- root.put("page_context", pageContextToMap(request.getPageContext()));
- }
- if (request.getCampaignContext() != null) {
- root.put("campaign_context", campaignContextToMap(request.getCampaignContext()));
- }
- if (request.getNarrativeEntity() != null) {
- root.put("narrative_entity", narrativeEntityToMap(request.getNarrativeEntity()));
- }
- return root;
- }
-
- private Map messageToMap(ChatMessage m) {
- Map map = new LinkedHashMap<>();
- map.put("role", m.role());
- map.put("content", m.content());
- return map;
- }
-
- private Map loreContextToMap(LoreStructuralContext ctx) {
- Map map = new LinkedHashMap<>();
- map.put("lore_name", ctx.getLoreName());
- map.put("lore_description", ctx.getLoreDescription());
-
- Map foldersMap = new LinkedHashMap<>();
- for (Map.Entry> e : ctx.getFolders().entrySet()) {
- foldersMap.put(e.getKey(), e.getValue().stream()
- .map(this::pageSummaryToMap)
- .collect(Collectors.toList()));
- }
- map.put("folders", foldersMap);
- map.put("tags", ctx.getTags());
- return map;
- }
-
- private Map pageSummaryToMap(PageSummary ps) {
- Map map = new LinkedHashMap<>();
- map.put("title", ps.getTitle());
- map.put("template_name", ps.getTemplateName());
- // values/tags/related_page_titles ne sont sérialisés que s'ils contiennent
- // de l'info — payload réseau plus léger quand la page est vierge.
- if (ps.getValues() != null && !ps.getValues().isEmpty()) {
- map.put("values", ps.getValues());
- }
- if (ps.getTags() != null && !ps.getTags().isEmpty()) {
- map.put("tags", ps.getTags());
- }
- if (ps.getRelatedPageTitles() != null && !ps.getRelatedPageTitles().isEmpty()) {
- map.put("related_page_titles", ps.getRelatedPageTitles());
- }
- return map;
- }
-
- private Map pageContextToMap(PageContext pc) {
- Map map = new LinkedHashMap<>();
- map.put("title", pc.getTitle());
- map.put("template_name", pc.getTemplateName());
- map.put("template_fields", pc.getTemplateFields());
- map.put("values", pc.getValues());
- return map;
- }
-
- private Map campaignContextToMap(CampaignStructuralContext ctx) {
- Map map = new LinkedHashMap<>();
- map.put("campaign_name", ctx.getCampaignName());
- map.put("campaign_description", ctx.getCampaignDescription());
- map.put("arcs", ctx.getArcs().stream()
- .map(this::arcSummaryToMap)
- .collect(Collectors.toList()));
- return map;
- }
-
- /**
- * Helper generic pour serialiser les entites structurelles (Arc/Chapter/Scene)
- * avec name, description et illustration_count conditionnel.
- */
- private Map structuralSummaryToMap(
- T entity,
- java.util.function.Function nameExtractor,
- java.util.function.Function descriptionExtractor,
- java.util.function.Function illustrationCountExtractor,
- java.util.function.BiConsumer