Ecriture de tests unitaires coté java pour améliorer la stabilité de l'application
This commit is contained in:
@@ -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).
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* Les responsabilités auxiliaires sont déléguées :
|
||||
* - Construction du payload JSON : {@link BrainChatPayloadBuilder}.
|
||||
* - Parsing des payloads SSE : {@link BrainSseParser}.
|
||||
* <p>
|
||||
* 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<Throwable> onError) {
|
||||
|
||||
Map<String, Object> payload = toPayload(request);
|
||||
Map<String, Object> payload = payloadBuilder.build(request);
|
||||
|
||||
Flux<ServerSentEvent<String>> 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<String> sse,
|
||||
Consumer<ChatUsage> onUsage,
|
||||
Consumer<String> onToken,
|
||||
Consumer<Throwable> 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<String, Object> toPayload(ChatRequest request) {
|
||||
Map<String, Object> 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<String, Object> messageToMap(ChatMessage m) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("role", m.role());
|
||||
map.put("content", m.content());
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> loreContextToMap(LoreStructuralContext ctx) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("lore_name", ctx.getLoreName());
|
||||
map.put("lore_description", ctx.getLoreDescription());
|
||||
|
||||
Map<String, Object> foldersMap = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, List<PageSummary>> 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<String, Object> pageSummaryToMap(PageSummary ps) {
|
||||
Map<String, Object> 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<String, Object> pageContextToMap(PageContext pc) {
|
||||
Map<String, Object> 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<String, Object> campaignContextToMap(CampaignStructuralContext ctx) {
|
||||
Map<String, Object> 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 <T> Map<String, Object> structuralSummaryToMap(
|
||||
T entity,
|
||||
java.util.function.Function<T, String> nameExtractor,
|
||||
java.util.function.Function<T, String> descriptionExtractor,
|
||||
java.util.function.Function<T, Integer> illustrationCountExtractor,
|
||||
java.util.function.BiConsumer<Map<String, Object>, T> childSerializer) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("name", nameExtractor.apply(entity));
|
||||
map.put("description", descriptionExtractor.apply(entity));
|
||||
// Envoye au Python pour enrichir le prompt ("N illustrations attachees").
|
||||
// Serialise uniquement si > 0 pour economiser le payload sur les entites sans images.
|
||||
if (illustrationCountExtractor.apply(entity) > 0) {
|
||||
map.put("illustration_count", illustrationCountExtractor.apply(entity));
|
||||
}
|
||||
childSerializer.accept(map, entity);
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> arcSummaryToMap(ArcSummary a) {
|
||||
return structuralSummaryToMap(
|
||||
a,
|
||||
ArcSummary::getName,
|
||||
ArcSummary::getDescription,
|
||||
ArcSummary::getIllustrationCount,
|
||||
(map, arc) -> map.put("chapters", arc.getChapters().stream()
|
||||
.map(this::chapterSummaryToMap)
|
||||
.collect(Collectors.toList())));
|
||||
}
|
||||
|
||||
private Map<String, Object> chapterSummaryToMap(ChapterSummary c) {
|
||||
return structuralSummaryToMap(
|
||||
c,
|
||||
ChapterSummary::getName,
|
||||
ChapterSummary::getDescription,
|
||||
ChapterSummary::getIllustrationCount,
|
||||
(map, chapter) -> map.put("scenes", chapter.getScenes().stream()
|
||||
.map(this::sceneSummaryToMap)
|
||||
.collect(Collectors.toList())));
|
||||
}
|
||||
|
||||
private Map<String, Object> sceneSummaryToMap(SceneSummary s) {
|
||||
return structuralSummaryToMap(
|
||||
s,
|
||||
SceneSummary::getName,
|
||||
SceneSummary::getDescription,
|
||||
SceneSummary::getIllustrationCount,
|
||||
(map, scene) -> {
|
||||
// Branches narratives : serialise uniquement si presentes, pour garder
|
||||
// un payload leger sur les scenes lineaires classiques.
|
||||
if (s.getBranches() != null && !s.getBranches().isEmpty()) {
|
||||
map.put("branches", s.getBranches().stream()
|
||||
.map(this::branchHintToMap)
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private Map<String, Object> branchHintToMap(BranchHint b) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("label", b.getLabel());
|
||||
map.put("target_scene_name", b.getTargetSceneName());
|
||||
if (b.getCondition() != null && !b.getCondition().isBlank()) {
|
||||
map.put("condition", b.getCondition());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> narrativeEntityToMap(NarrativeEntityContext ne) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("entity_type", ne.getEntityType());
|
||||
map.put("title", ne.getTitle());
|
||||
map.put("fields", ne.getFields());
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
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.LoreStructuralContext;
|
||||
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
|
||||
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
||||
import com.loremind.domain.generationcontext.PageContext;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Helper d'infrastructure : traduit un ChatRequest (domaine) vers le dict JSON
|
||||
* attendu par le Brain Python (/chat/stream).
|
||||
* <p>
|
||||
* Extrait de BrainAiChatClient pour isoler la responsabilité "sérialisation
|
||||
* de payload" (SRP) — le client HTTP se concentre désormais uniquement sur le
|
||||
* transport et le streaming SSE.
|
||||
* <p>
|
||||
* Chaque contexte optionnel (lore, page, campaign, entité narrative) est omis
|
||||
* si null, pour s'aligner sur le schéma Pydantic (champs Optional absents).
|
||||
*/
|
||||
@Component
|
||||
public class BrainChatPayloadBuilder {
|
||||
|
||||
public Map<String, Object> build(ChatRequest request) {
|
||||
Map<String, Object> 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<String, Object> messageToMap(ChatMessage m) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("role", m.role());
|
||||
map.put("content", m.content());
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> loreContextToMap(LoreStructuralContext ctx) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("lore_name", ctx.getLoreName());
|
||||
map.put("lore_description", ctx.getLoreDescription());
|
||||
|
||||
Map<String, Object> foldersMap = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, List<PageSummary>> 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<String, Object> pageSummaryToMap(PageSummary ps) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("title", ps.getTitle());
|
||||
map.put("template_name", ps.getTemplateName());
|
||||
// values/tags/related_page_titles : omis si vides pour alléger le payload.
|
||||
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<String, Object> pageContextToMap(PageContext pc) {
|
||||
Map<String, Object> 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<String, Object> campaignContextToMap(CampaignStructuralContext ctx) {
|
||||
Map<String, Object> 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 générique pour sérialiser les entités structurelles (Arc/Chapter/Scene)
|
||||
* avec name, description et illustration_count conditionnel.
|
||||
*/
|
||||
private <T> Map<String, Object> structuralSummaryToMap(
|
||||
T entity,
|
||||
Function<T, String> nameExtractor,
|
||||
Function<T, String> descriptionExtractor,
|
||||
Function<T, Integer> illustrationCountExtractor,
|
||||
BiConsumer<Map<String, Object>, T> childSerializer) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("name", nameExtractor.apply(entity));
|
||||
map.put("description", descriptionExtractor.apply(entity));
|
||||
if (illustrationCountExtractor.apply(entity) > 0) {
|
||||
map.put("illustration_count", illustrationCountExtractor.apply(entity));
|
||||
}
|
||||
childSerializer.accept(map, entity);
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> arcSummaryToMap(ArcSummary a) {
|
||||
return structuralSummaryToMap(
|
||||
a,
|
||||
ArcSummary::getName,
|
||||
ArcSummary::getDescription,
|
||||
ArcSummary::getIllustrationCount,
|
||||
(map, arc) -> map.put("chapters", arc.getChapters().stream()
|
||||
.map(this::chapterSummaryToMap)
|
||||
.collect(Collectors.toList())));
|
||||
}
|
||||
|
||||
private Map<String, Object> chapterSummaryToMap(ChapterSummary c) {
|
||||
return structuralSummaryToMap(
|
||||
c,
|
||||
ChapterSummary::getName,
|
||||
ChapterSummary::getDescription,
|
||||
ChapterSummary::getIllustrationCount,
|
||||
(map, chapter) -> map.put("scenes", chapter.getScenes().stream()
|
||||
.map(this::sceneSummaryToMap)
|
||||
.collect(Collectors.toList())));
|
||||
}
|
||||
|
||||
private Map<String, Object> sceneSummaryToMap(SceneSummary s) {
|
||||
return structuralSummaryToMap(
|
||||
s,
|
||||
SceneSummary::getName,
|
||||
SceneSummary::getDescription,
|
||||
SceneSummary::getIllustrationCount,
|
||||
(map, scene) -> {
|
||||
// Branches narratives : omises si absentes (scènes linéaires classiques).
|
||||
if (s.getBranches() != null && !s.getBranches().isEmpty()) {
|
||||
map.put("branches", s.getBranches().stream()
|
||||
.map(this::branchHintToMap)
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private Map<String, Object> branchHintToMap(BranchHint b) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("label", b.getLabel());
|
||||
map.put("target_scene_name", b.getTargetSceneName());
|
||||
if (b.getCondition() != null && !b.getCondition().isBlank()) {
|
||||
map.put("condition", b.getCondition());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<String, Object> narrativeEntityToMap(NarrativeEntityContext ne) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("entity_type", ne.getEntityType());
|
||||
map.put("title", ne.getTitle());
|
||||
map.put("fields", ne.getFields());
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.loremind.infrastructure.ai;
|
||||
|
||||
import com.loremind.domain.generationcontext.ChatUsage;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Helper d'infrastructure : parse les payloads JSON véhiculés dans les
|
||||
* évènements SSE reçus du Brain Python.
|
||||
* <p>
|
||||
* Implémentation volontairement minimaliste (pas de Jackson ici) car les
|
||||
* schémas attendus sont figés et simples : {"token":"..."} et
|
||||
* {"system":N,"history":N,"current":N,"max":N}. Si la complexité augmente,
|
||||
* remplacer par un ObjectMapper + DTOs.
|
||||
*/
|
||||
@Component
|
||||
public class BrainSseParser {
|
||||
|
||||
/**
|
||||
* Parse un JSON {"system":N,"history":N,"current":N,"max":N} en ChatUsage.
|
||||
* Renvoie null si le payload est illisible — l'appelant décidera de ne
|
||||
* simplement pas propager l'usage (le stream token continue).
|
||||
*/
|
||||
public ChatUsage parseUsage(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 {"token":"..."} et renvoie la valeur du champ token (chaîne vide
|
||||
* ou null si introuvable).
|
||||
*/
|
||||
public String parseToken(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("\\\\", "\\");
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user