From 3d1cf6e495fa6a2b0d22a1983997a477734353e7 Mon Sep 17 00:00:00 2001 From: "IETM_FIXE\\ietm6" Date: Wed, 10 Jun 2026 15:45:56 +0200 Subject: [PATCH] Chat atelier : evenement SSE sources + affichage des pages utilisees Le Brain emet un evenement sources (source_id, page, score des passages retenus) AVANT le premier token ; le Core le relaie tel quel (JSON brut) ; l UI affiche une ligne discrete sous la reponse (ex: 12, 47, 103), prefixee du nom de fichier si plusieurs sources. Transparence pour le MJ et diagnostic immediat quand le RAG repond a cote. Co-Authored-By: Claude Opus 4.8 --- brain/app/api/routers/notebooks.py | 11 +++++--- brain/app/application/notebook_chat.py | 16 ++++++++++-- .../ports/NotebookChatStreamer.java | 8 +++--- .../ai/BrainNotebookChatClient.java | 9 ++++++- .../web/controller/NotebookController.java | 10 ++++++++ .../notebook-detail.component.html | 5 ++++ .../notebook-detail.component.scss | 7 ++++++ .../notebook-detail.component.ts | 25 ++++++++++++++++++- web/src/app/services/notebook.model.ts | 11 ++++++++ web/src/app/services/notebook.service.ts | 10 ++++++++ 10 files changed, 102 insertions(+), 10 deletions(-) diff --git a/brain/app/api/routers/notebooks.py b/brain/app/api/routers/notebooks.py index 4199e4c..a1644bf 100644 --- a/brain/app/api/routers/notebooks.py +++ b/brain/app/api/routers/notebooks.py @@ -85,9 +85,14 @@ async def chat_notebook_stream( async def event_stream() -> AsyncIterator[str]: try: - async for token in use_case.stream(body.source_ids, messages, context=body.context, top_k=top_k): - if token: - yield sse_event("token", {"token": token}) + async for ev in use_case.stream(body.source_ids, messages, context=body.context, top_k=top_k): + if ev["type"] == "token": + if ev.get("token"): + yield sse_event("token", {"token": ev["token"]}) + else: + # 'sources' (et tout futur évènement typé) : relayé tel quel. + ev_type = ev.pop("type") + yield sse_event(ev_type, ev) yield sse_event("done", {}) except (LLMProviderError, EmbeddingError) as exc: yield sse_event("error", {"message": str(exc)}) diff --git a/brain/app/application/notebook_chat.py b/brain/app/application/notebook_chat.py index a9b33eb..32701ab 100644 --- a/brain/app/application/notebook_chat.py +++ b/brain/app/application/notebook_chat.py @@ -68,13 +68,25 @@ class NotebookChatUseCase: messages: list[ChatMessage], context: str = "", top_k: int = 6, - ) -> AsyncIterator[str]: + ) -> AsyncIterator[dict]: + """Yield des évènements : {type:'sources', sources:[…]} (une fois, avant la + réponse — transparence sur les passages utilisés), puis {type:'token', token}.""" # Question AUTONOME pour la recherche : sur une relance (« et ses # faiblesses ? »), l'embedding du dernier message seul ne contient pas # le sujet → on le résout depuis l'historique (best-effort, 1 appel léger, # uniquement à partir du 2e tour). La réponse, elle, voit tout l'historique. search_query = await standalone_question(self._llm, messages) passages = await self._rag.retrieve(source_ids, search_query, top_k=top_k) + # Évènement 'sources' AVANT le premier token : l'UI peut afficher les + # pages utilisées (« 📖 p. 12, 47 ») dès le début de la réponse. + yield {"type": "sources", "sources": [ + { + "source_id": p.get("source_id"), + "page": p.get("page"), + "score": round(float(p.get("score") or 0.0), 3), + } + for p in passages + ]} sources_block = ( "\n\n".join(self._format_passage(p) for p in passages) if passages else "(aucun passage pertinent trouvé dans les sources)" @@ -86,7 +98,7 @@ class NotebookChatUseCase: system_prompt = _SYSTEM_PROMPT.format( context_block=context_block, sources_block=sources_block) async for token in self._llm.stream_chat(messages, system_prompt=system_prompt): - yield token + yield {"type": "token", "token": token} @staticmethod def _format_passage(p: dict) -> str: diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/ports/NotebookChatStreamer.java b/core/src/main/java/com/loremind/domain/campaigncontext/ports/NotebookChatStreamer.java index 7922c70..4d74bcf 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/ports/NotebookChatStreamer.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/ports/NotebookChatStreamer.java @@ -17,9 +17,10 @@ public interface NotebookChatStreamer { /** * Streame la réponse ancrée sur les sources. Les callbacks sont invoqués au fil - * de l'eau : {@code onToken} par fragment, {@code onProgress} (mode approfondi - * uniquement) pendant la lecture du document, {@code onDone} à la fin, - * {@code onError} en cas d'échec. + * de l'eau : {@code onSourcesJson} UNE fois avant le premier token (JSON brut des + * passages utilisés — transparence UI), {@code onToken} par fragment, + * {@code onProgress} (mode approfondi uniquement) pendant la lecture du document, + * {@code onDone} à la fin, {@code onError} en cas d'échec. * * @param deep true = analyse approfondie (map-reduce sur tout le document) ; * false = chat RAG (top-k). @@ -29,6 +30,7 @@ public interface NotebookChatStreamer { List messages, String context, boolean deep, + Consumer onSourcesJson, Consumer onToken, Consumer onProgress, Runnable onDone, diff --git a/core/src/main/java/com/loremind/infrastructure/ai/BrainNotebookChatClient.java b/core/src/main/java/com/loremind/infrastructure/ai/BrainNotebookChatClient.java index f6de715..7b1c140 100644 --- a/core/src/main/java/com/loremind/infrastructure/ai/BrainNotebookChatClient.java +++ b/core/src/main/java/com/loremind/infrastructure/ai/BrainNotebookChatClient.java @@ -51,6 +51,7 @@ public class BrainNotebookChatClient implements NotebookChatStreamer { List messages, String context, boolean deep, + Consumer onSourcesJson, Consumer onToken, Consumer onProgress, Runnable onDone, @@ -75,7 +76,8 @@ public class BrainNotebookChatClient implements NotebookChatStreamer { try { flux .timeout(Duration.ofSeconds(timeoutSeconds)) - .doOnNext(sse -> handleEvent(sse, terminated, onToken, onProgress, onDone, onError)) + .doOnNext(sse -> handleEvent( + sse, terminated, onSourcesJson, onToken, onProgress, onDone, onError)) .blockLast(); if (!terminated[0]) { onDone.run(); // flux terminé sans event done explicite @@ -93,6 +95,7 @@ public class BrainNotebookChatClient implements NotebookChatStreamer { private void handleEvent( ServerSentEvent sse, boolean[] terminated, + Consumer onSourcesJson, Consumer onToken, Consumer onProgress, Runnable onDone, @@ -103,6 +106,10 @@ public class BrainNotebookChatClient implements NotebookChatStreamer { if ("token".equals(event)) { String token = readField(data, "token"); if (token != null && !token.isEmpty()) onToken.accept(token); + } else if ("sources".equals(event)) { + // Passages utilisés par le RAG : relayés tels quels (JSON brut) — le + // Core n'a pas besoin de les comprendre, seulement de les transmettre. + onSourcesJson.accept(data); } else if ("progress".equals(event)) { onProgress.accept(new Progress(readInt(data, "current"), readInt(data, "total"))); } else if ("done".equals(event)) { 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 51b8c68..b72fd46 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 @@ -132,6 +132,7 @@ public class NotebookController { StringBuilder assistant = new StringBuilder(); chatStreamer.stream( sourceIds, history, context, deep, + sourcesJson -> sendSources(emitter, sourcesJson), token -> { assistant.append(token); sendToken(emitter, token); }, progress -> sendProgress(emitter, progress), () -> { @@ -161,6 +162,15 @@ public class NotebookController { } } + private void sendSources(SseEmitter emitter, String sourcesJson) { + try { + // JSON brut du Brain ({"sources":[{source_id,page,score},…]}), relayé tel quel. + emitter.send(SseEmitter.event().name("sources").data(sourcesJson)); + } catch (IOException | IllegalStateException e) { + // flux fermé/expiré : on cesse d'écrire + } + } + private void sendProgress(SseEmitter emitter, NotebookChatStreamer.Progress p) { try { emitter.send(SseEmitter.event().name("progress") 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 768484e..a2a6f45 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 @@ -91,6 +91,11 @@ (created)="onActionCreated()"> } + @if (sourcesLabel(m); as label) { +
+ 📖 {{ label }} +
+ } } } @else {
{{ m.content }}
diff --git a/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.scss b/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.scss index 69dcb09..c02f795 100644 --- a/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.scss +++ b/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.scss @@ -104,3 +104,10 @@ display: inline-flex; align-items: center; gap: 0.35rem; font-size: 0.82rem; color: #c4a8ff; font-style: italic; } + +// Pages de la source utilisées par le RAG (transparence de la réponse) +.nbd-msg-sources { + margin-top: 0.35rem; + font-size: 0.75rem; + opacity: 0.65; +} diff --git a/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.ts b/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.ts index 40c79f9..eff1f12 100644 --- a/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.ts +++ b/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.ts @@ -107,7 +107,29 @@ export class NotebookDetailComponent implements OnInit { return cache._parsed; } - /** trackBy stable pour les cartes d'action (évite toute recréation parasite). */ + /** + * Libellé compact des pages utilisées par le RAG pour une réponse + * (« p. 12, 47, 103 » — préfixé du nom du fichier si plusieurs sources). + */ + sourcesLabel(m: NotebookMessage): string { + const src = m.sources ?? []; + if (!src.length) return ''; + const bySource = new Map(); + for (const s of src) { + if (s.page == null) continue; + const pages = bySource.get(s.sourceId) ?? []; + if (!pages.includes(s.page)) pages.push(s.page); + bySource.set(s.sourceId, pages); + } + if (bySource.size === 0) return ''; + const nameOf = (id: string) => this.sources.find(x => x.id === id)?.filename ?? ''; + return [...bySource.entries()] + .map(([id, pages]) => { + const label = `p. ${pages.sort((a, b) => a - b).join(', ')}`; + return bySource.size > 1 && nameOf(id) ? `${nameOf(id)} — ${label}` : label; + }) + .join(' · '); + } load(): void { this.service.get(this.notebookId).subscribe({ @@ -162,6 +184,7 @@ export class NotebookDetailComponent implements OnInit { this.service.streamChat(this.notebookId, text, deep).subscribe({ next: (ev) => { if (ev.type === 'token') { this.deepProgress = null; assistant.content += ev.value; } + else if (ev.type === 'sources') assistant.sources = ev.sources; else if (ev.type === 'progress') this.deepProgress = { current: ev.current, total: ev.total }; else if (ev.type === 'error') assistant.content += (assistant.content ? '\n\n' : '') + `⚠️ ${ev.message}`; }, diff --git a/web/src/app/services/notebook.model.ts b/web/src/app/services/notebook.model.ts index e909600..4d66368 100644 --- a/web/src/app/services/notebook.model.ts +++ b/web/src/app/services/notebook.model.ts @@ -16,12 +16,22 @@ export interface NotebookSource { pageCount: number; } +/** Passage source utilisé par le RAG pour ancrer une réponse (transparence). */ +export interface NotebookChatSource { + sourceId: string; + /** Numéro de page 1-based, null si inconnu. */ + page: number | null; + score: number; +} + export interface NotebookMessage { id?: string; notebookId?: string; /** "user" | "assistant" */ role: string; content: string; + /** Passages utilisés (réponses streamées uniquement — non persisté). */ + sources?: NotebookChatSource[]; } export interface NotebookDetail { @@ -35,6 +45,7 @@ export interface NotebookDetail { /** Évènements du chat ancré streamé. */ export type NotebookChatEvent = | { type: 'token'; value: string } + | { type: 'sources'; sources: NotebookChatSource[] } | { type: 'progress'; current: number; total: number } | { type: 'done' } | { type: 'error'; message: string }; diff --git a/web/src/app/services/notebook.service.ts b/web/src/app/services/notebook.service.ts index fbd0aac..951a739 100644 --- a/web/src/app/services/notebook.service.ts +++ b/web/src/app/services/notebook.service.ts @@ -78,6 +78,16 @@ export class NotebookService { if (name === 'token') { try { emit({ type: 'token', value: (JSON.parse(currentData) as { token?: string }).token ?? '' }); } catch { /* ignore */ } + } else if (name === 'sources') { + try { + const o = JSON.parse(currentData) as { sources?: { source_id?: string; page?: number | null; score?: number }[] }; + emit({ + type: 'sources', + sources: (o.sources ?? []).map(s => ({ + sourceId: s.source_id ?? '', page: s.page ?? null, score: s.score ?? 0 + })) + }); + } catch { /* ignore */ } } else if (name === 'progress') { try { const o = JSON.parse(currentData) as { current?: number; total?: number };