@@ -29,6 +41,7 @@
diff --git a/frontend/src/entities/PowerGrid/api.ts b/frontend/src/entities/PowerGrid/api.ts
index 81503cf3..de4dd9e6 100644
--- a/frontend/src/entities/PowerGrid/api.ts
+++ b/frontend/src/entities/PowerGrid/api.ts
@@ -1,9 +1,36 @@
import http from '@/plugins/http'
import type { Action } from '@/types/entities'
-export function applyRecommendation(data: Action<'PowerGrid'>) {
- return http.post<{ message: string }>(
- import.meta.env.VITE_POWERGRID_SIMU + '/api/v1/recommendations',
- data
+export async function applyRecommendation(data: Action<'PowerGrid'>) {
+ const base = import.meta.env.VITE_POWERGRID_SIMU
+ const url = base + '/api/v1/recommendations'
+ // Debug logging: confirm the apply action is actually sent to the simulator, and what is sent.
+ // NB: the axios instance prepends baseURL (VITE_API), which is empty in standalone => same-origin.
+ console.info(
+ `[PowerGrid][apply] POST ${url} (axios baseURL="${import.meta.env.VITE_API ?? ''}", VITE_POWERGRID_SIMU="${base ?? ''}")`
)
+ console.info('[PowerGrid][apply] payload sent to simulator:', data)
+ try {
+ const res = await http.post<{ message: string }>(url, data)
+ const raw: unknown = res.data
+ const looksLikeHtml = typeof raw === 'string' && / 300 ? raw.slice(0, 300) + '… (truncated)' : res.data
+ console.info(`[PowerGrid][apply] simulator responded HTTP ${res.status}:`, body)
+ if (looksLikeHtml)
+ console.warn(
+ '[PowerGrid][apply] ⚠ Response is the SPA index.html, NOT a simulator reply. The request ' +
+ 'fell through to the nginx SPA fallback — there is no /powergrid-simu/ proxy in the running ' +
+ 'nginx config, so the action never reached the simulator.'
+ )
+ return res
+ } catch (err) {
+ const e = err as { response?: { status?: number; data?: unknown }; message?: string }
+ console.error(
+ '[PowerGrid][apply] request FAILED — HTTP',
+ e.response?.status ?? '(no response / network error)',
+ e.response?.data ?? e.message ?? err
+ )
+ throw err
+ }
}
diff --git a/frontend/src/entities/Railway/CAB/Assistant.vue b/frontend/src/entities/Railway/CAB/Assistant.vue
index 82d4f777..c73158ad 100644
--- a/frontend/src/entities/Railway/CAB/Assistant.vue
+++ b/frontend/src/entities/Railway/CAB/Assistant.vue
@@ -5,19 +5,12 @@
{{ $t('cab.assistant.recommendations') }}
-
{{ $t('cab.assistant.procedure') }}
+
{{ $t('cab.assistant.recommendations') }}
+ :primary-action="primaryAction">
-
+
- {{ recommendation.title }}
+ R{{ index }}: {{ recommendation.title }}
@@ -82,6 +75,38 @@
{{ $t('recommendations.button.secondary') }}
+
+
+
+
+
+ KPI
+
+ R{{ index }}
+
+
+
+
+
+ {{ $t(`Railway.kpis.${key}`) }}
+
+ {{
+ isFinite(Number(recommendation.kpis?.[key]))
+ ? Number(recommendation.kpis?.[key])
+ : recommendation.kpis?.[key]
+ }}
+
+
+
+
+
+
diff --git a/frontend/src/entities/Railway/api.ts b/frontend/src/entities/Railway/api.ts
index d97788d8..c1157ea1 100644
--- a/frontend/src/entities/Railway/api.ts
+++ b/frontend/src/entities/Railway/api.ts
@@ -1,6 +1,12 @@
import http from '@/plugins/http'
import type { Action } from '@/types/entities'
+// TODO: TEMP HACK (eval-demo) — MUST BE REMOVED before next release
+// The real Railway simulator API call is disabled and replaced with a fake success response for demo purposes.
+// To restore: uncomment the http.post line and delete the Promise.resolve line.
export function applyRecommendation(data: Action<'Railway'>) {
- return http.post<{ message: string }>(import.meta.env.VITE_RAILWAY_SIMU + '/transport_plan', data)
+ // [DISABLED] Simulator API is inactive — returning fake success for demo
+ // To restore: uncomment the http.post and remove the Promise.resolve
+ // return http.post<{ message: string }>(import.meta.env.VITE_RAILWAY_SIMU + '/transport_plan', data)
+ return Promise.resolve({ data: { message: 'ok (simulated)' } }) // TEMP HACK: remove this line
}
diff --git a/frontend/src/entities/Railway/locales/en.json b/frontend/src/entities/Railway/locales/en.json
index c7dd336c..d863b08d 100644
--- a/frontend/src/entities/Railway/locales/en.json
+++ b/frontend/src/entities/Railway/locales/en.json
@@ -3,10 +3,10 @@
"card.event_type.HARDWARE": "Hardware",
"card.event_type.IMPACT": "Impact",
"card.event_type.INFRASTRUCTURE": "Infrastructure",
- "card.event_type.PASSENGER": "Passenger",
+ "card.event_type.PASSENGER": "Passengers",
"card.event_type.RAIL": "Rail",
"event.button.primary": "Get recommendation from maze",
- "event.button.primary.passenger": "Show procedure",
+ "event.button.primary.passenger": "Get recommendation",
"event.button.secondary": "Continue the procedure follow-up",
"event.button.secondary.passenger": "Put on hold",
"event.text": "{event} detected. Would you like help recalculating transportation plans?",
@@ -19,5 +19,11 @@
"recommendations.button3": "Cost",
"recommendations.button4": "Client impact",
"recommendations.description": "Solution description",
- "recommendations.modal": "You are about to apply {recommendation}. Do you want to continue?"
+ "recommendations.modal": "You are about to apply {recommendation}. Do you want to continue?",
+ "Railway.kpis.cost": "Cost",
+ "Railway.kpis.nb_impacted_passengers": "Number of impacted passengers",
+ "Railway.kpis.total_cost": "Total cost",
+ "Railway.kpis.delay": "Delay",
+ "Railway.kpis.best": "Best"
+
}
diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json
index e8e93dfc..a05c4beb 100644
--- a/frontend/src/locales/en.json
+++ b/frontend/src/locales/en.json
@@ -12,7 +12,7 @@
"cab": "InteractiveAI",
"cab.assistant": "Assistant",
"cab.assistant.correlations": "Correlations",
- "cab.assistant.procedure": "Procedure monitoring",
+ "cab.assistant.procedure": "Recommendations",
"cab.assistant.procedure.collaborate": "Collaborate with {cab}",
"cab.assistant.recommendations": "Recommendations",
"cab.context.notification.expanded.false": "Open notification",
@@ -54,6 +54,7 @@
"map.lockview": "Lock view to objects in context",
"map.no_lockview": "Free view",
"minute": "{n} minute | {n} minutes",
+ "modal.consent.cognitive": "Do you consent to the platform collecting and using your cognitive and stress data? ",
"modal.default": "Default modal message",
"modal.error.CONTEXT_FAILED": "Unable to retrieve context, try again?",
"modal.error.DISCONNECTED": "Your session has expired",
diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json
index b3080899..f6bf079b 100644
--- a/frontend/src/locales/fr.json
+++ b/frontend/src/locales/fr.json
@@ -54,6 +54,7 @@
"map.lockview": "Verrouiller la vue aux objets dans le contexte",
"map.no_lockview": "Vue libre",
"minute": "{n} minute | {n} minutes",
+ "modal.consent.cognitive": "Consentez-vous à ce que la plateforme collecte et utilise vos données cognitives et de stress ? ",
"modal.default": "Message de modale par défaut",
"modal.error.CONTEXT_FAILED": "Impossible de récupérer le contexte, réessayer ?",
"modal.error.DISCONNECTED": "Votre session a expiré",
diff --git a/frontend/src/plugins/http.ts b/frontend/src/plugins/http.ts
index 70ac6cad..73da579b 100644
--- a/frontend/src/plugins/http.ts
+++ b/frontend/src/plugins/http.ts
@@ -43,28 +43,32 @@ http.interceptors.response.use(
async function (error: AxiosError) {
const authStore = useAuthStore()
const appStore = useAppStore()
+ // TODO: TEMP HACK (eval-demo) — MUST BE REMOVED before next release
+ // Auto-logout on token expiry is fully disabled. Users stay logged in even when the token expires.
+ // This was done to avoid interruptions during the demo. Re-enable the block below when done.
+ // [DISABLED] Auto-logout on expired token — commented out to stay logged in despite failed requests
// If request failed, check if token is expired
- if (error.config?.url !== '/auth/check_token' && authStore.token?.access_token) {
- const res = await authStore.checkToken()
- if (!res) {
- appStore._modals = []
- appStore.addModal({
- data: t('modal.error.DISCONNECTED'),
- type: 'info',
- callback: () => {
- appStore.status.requests = []
- }
- })
- authStore.logout()
- router.push({ name: 'login' })
- return
- }
- } else {
- // If the request that failed was the token check,
- // then it is probably a network error and simply log out the user
- authStore.logout()
- router.push({ name: 'login' })
- }
+ // if (error.config?.url !== '/auth/check_token' && authStore.token?.access_token) {
+ // const res = await authStore.checkToken()
+ // if (!res) {
+ // appStore._modals = []
+ // appStore.addModal({
+ // data: t('modal.error.DISCONNECTED'),
+ // type: 'info',
+ // callback: () => {
+ // appStore.status.requests = []
+ // }
+ // })
+ // authStore.logout()
+ // router.push({ name: 'login' })
+ // return
+ // }
+ // } else {
+ // // If the request that failed was the token check,
+ // // then it is probably a network error and simply log out the user
+ // authStore.logout()
+ // router.push({ name: 'login' })
+ // }
appStore.status.requests[appStore.status.requests.findIndex((el) => el.data.url)] = {
state: 'ERROR',
data: error
diff --git a/frontend/src/plugins/i18n.ts b/frontend/src/plugins/i18n.ts
index bbc6c207..ea0bb348 100644
--- a/frontend/src/plugins/i18n.ts
+++ b/frontend/src/plugins/i18n.ts
@@ -8,7 +8,7 @@ import { ENTITIES } from '@/types/entities'
export const SUPPORT_LOCALES = ['en', 'fr'] as const
Object.freeze(SUPPORT_LOCALES)
-export default createI18n({
+const i18n = createI18n({
locale: window.navigator.language.split('-')[0] || import.meta.env.VITE_DEFAULT_LOCALE || 'en',
fallbackLocale: import.meta.env.VITE_FALLBACK_LOCALE || 'en',
legacy: false,
@@ -20,10 +20,12 @@ export default createI18n({
}
})
-export async function setupEntitiesLocales(i18n: ReturnType) {
+export default i18n
+
+export async function setupEntitiesLocales(instance: typeof i18n = i18n) {
for (const entity of ENTITIES)
for (const locale of SUPPORT_LOCALES) {
- i18n.global.setLocaleMessage(
+ instance.global.setLocaleMessage(
`${locale}-${entity}`,
(await import(`../entities/${entity}/locales/${locale}.json`)).default
)
diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts
index 4a8dc939..d3a4e876 100644
--- a/frontend/src/stores/auth.ts
+++ b/frontend/src/stores/auth.ts
@@ -4,6 +4,8 @@ import { computed, ref } from 'vue'
import * as auth from '@/api/auth'
import type { LoginResponse, UserResponse } from '@/types/auth'
import { ENTITIES, type Entity } from '@/types/entities'
+import { clearCognitiveConsent } from '@/utils/consent'
+import { clearTraceSession, exportTraceSession, startTraceSession } from '@/utils/traceSessionExport'
export const useAuthStore = defineStore(
'auth',
@@ -25,6 +27,11 @@ export const useAuthStore = defineStore(
expireDate.value = Date.now() + tokenRes.data.expires_in * 1000
const userRes = await auth.getCurrentUser()
user.value = userRes.data
+ try {
+ startTraceSession(userRes.data.userData.login)
+ } catch (error) {
+ console.warn('Unable to start trace session export:', error)
+ }
}
async function checkToken() {
@@ -32,10 +39,21 @@ export const useAuthStore = defineStore(
return data.active
}
- function logout() {
- token.value = undefined
- user.value = undefined
- localStorage.removeItem('context')
+ function logout(format: 'json' | 'csv' = 'json') {
+ try {
+ exportTraceSession(format, {
+ force: true,
+ userLogin: user.value?.userData.login
+ })
+ } catch (error) {
+ console.warn('Unable to export trace session:', error)
+ } finally {
+ clearTraceSession()
+ clearCognitiveConsent()
+ token.value = undefined
+ user.value = undefined
+ localStorage.removeItem('context')
+ }
}
return { token, user, entities, login, logout, checkToken }
diff --git a/frontend/src/stores/cards.ts b/frontend/src/stores/cards.ts
index 4205cbae..b857d31d 100644
--- a/frontend/src/stores/cards.ts
+++ b/frontend/src/stores/cards.ts
@@ -7,6 +7,7 @@ import eventBus from '@/plugins/eventBus'
import i18n from '@/plugins/i18n'
import { type Card, type CardEvent, CardOperationType } from '@/types/cards'
import { type Entity } from '@/types/entities'
+import { recordTraceForSession } from '@/utils/traceSessionExport'
import { uuid } from '@/utils/utils'
import { useAppStore } from './app'
@@ -76,6 +77,28 @@ export const useCardsStore = defineStore('cards', () => {
const { data } = await cardsApi.get(cardEvent.card.id)
hydratedCard = data.card
}
+ if (cardEvent.type === CardOperationType.ADD || existingCard === -1) {
+ try {
+ // Use hydrated card for title/summary if available (SSE notification may not include titleTranslated)
+ const fullCard = hydratedCard ?? cardEvent.card
+ recordTraceForSession({
+ use_case: entity,
+ step: 'EVENT',
+ data: {
+ card_id: cardEvent.card.id,
+ process_instance_id: cardEvent.card.processInstanceId,
+ start_date: new Date(cardEvent.card.startDate).toISOString(),
+ publish_date: new Date(cardEvent.card.publishDate).toISOString(),
+ title: fullCard.titleTranslated || fullCard.title?.parameters?.title || '',
+ summary: fullCard.summaryTranslated || fullCard.summary?.parameters?.summary || '',
+ metadata: (hydratedCard ?? cardEvent.card).data.metadata
+ },
+ date: new Date().toISOString()
+ })
+ } catch (error) {
+ console.warn('Unable to record EVENT trace for session export:', error)
+ }
+ }
if (existingCard !== -1) {
if (
_cards.value[existingCard].data.criticality !== 'ND' &&
@@ -137,5 +160,13 @@ export const useCardsStore = defineStore('cards', () => {
cardsApi.remove(card.id)
}
- return { _cards, cards, subscribe, unsubscribe, acknowledge, remove }
+ /** Set the card's criticality to 'ND' (resolved) after the user confirms a recommendation. */
+ function resolveCriticality(card: Card) {
+ if (card.data.criticality !== 'ND') {
+ card.data.criticality = 'ND'
+ eventBus.emit('notifications:ended', card)
+ }
+ }
+
+ return { _cards, cards, subscribe, unsubscribe, acknowledge, remove, resolveCriticality }
})
diff --git a/frontend/src/stores/services.ts b/frontend/src/stores/services.ts
index 678546d7..92f3c7f7 100644
--- a/frontend/src/stores/services.ts
+++ b/frontend/src/stores/services.ts
@@ -1,11 +1,14 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
+import { fetchCognitiveSnapshot } from '@/api/cognitive'
+import type { CognitiveSnapshot } from '@/api/cognitive'
import * as servicesApi from '@/api/services'
import i18n from '@/plugins/i18n'
import type { Card } from '@/types/cards'
-import type { Entity } from '@/types/entities'
+import type { Context, Entity } from '@/types/entities'
import type { FullContext, Recommendation } from '@/types/services'
+import { hasCognitiveConsent } from '@/utils/consent'
import { getRootCard } from '@/utils/utils'
import { useAppStore } from './app'
@@ -94,10 +97,33 @@ export const useServicesStore = defineStore('services', () => {
appStore.tab.assistant = 0
return
}
- const { data } = await servicesApi.getRecommendation({
+ // Send the structured grid data to the RL agent, not the rendered image.
+ // `context.observation` already holds the full state (topo_vect, line_status,
+ // rho, load/gen, …); `context.topology` is only a ~288 KB base64 PNG used for
+ // UI display. Strip it from the agent payload (a shallow copy keeps the store
+ // — and therefore the UI, which reads `context.topology` — untouched).
+ const contextForAgent = { ...context.data }
+ if ('topology' in contextForAgent) {
+ delete (contextForAgent as Record).topology
+ }
+
+ // Send the latest cognitive/stress snapshot alongside event/context so the
+ // RL agent can factor operator state into its recommendation — but only
+ // when the operator has consented. Without consent, nothing is fetched or
+ // sent to the agent. Sent as-is even on failure (snapshot may contain nulls
+ // or an `error` field) so a cognitive-API outage never blocks the request.
+ const payload: {
+ event: Card['data']['metadata']
+ context: Context
+ cognitive_snapshot?: CognitiveSnapshot
+ } = {
event: getRootCard(event).data.metadata,
- context: context.data
- })
+ context: contextForAgent
+ }
+ if (hasCognitiveConsent()) {
+ payload.cognitive_snapshot = await fetchCognitiveSnapshot()
+ }
+ const { data } = await servicesApi.getRecommendation(payload)
_recommendations.value = data
}
diff --git a/frontend/src/utils/consent.ts b/frontend/src/utils/consent.ts
new file mode 100644
index 00000000..7f1ec841
--- /dev/null
+++ b/frontend/src/utils/consent.ts
@@ -0,0 +1,28 @@
+/**
+ * User consent for collecting and using cognitive/stress data.
+ *
+ * The operator is asked at login whether the platform may fetch their
+ * cognitive and stress state. When consent is NOT given, cognitive data must
+ * not be fetched, recorded in session traces, or sent to the AI agent.
+ *
+ * Backed by localStorage (single source of truth) so it can be read from both
+ * Pinia stores and plain utility modules without creating circular imports.
+ * The default — no stored value — is treated as "no consent", the safe default.
+ */
+
+const CONSENT_KEY = 'cognitiveConsent'
+
+/** Record the operator's consent decision. */
+export function setCognitiveConsent(value: boolean): void {
+ localStorage.setItem(CONSENT_KEY, value ? 'true' : 'false')
+}
+
+/** True only when the operator has explicitly consented. */
+export function hasCognitiveConsent(): boolean {
+ return localStorage.getItem(CONSENT_KEY) === 'true'
+}
+
+/** Forget any previous decision (e.g. on logout) so the next login re-asks. */
+export function clearCognitiveConsent(): void {
+ localStorage.removeItem(CONSENT_KEY)
+}
diff --git a/frontend/src/utils/traceSessionExport.ts b/frontend/src/utils/traceSessionExport.ts
new file mode 100644
index 00000000..5e2914e2
--- /dev/null
+++ b/frontend/src/utils/traceSessionExport.ts
@@ -0,0 +1,640 @@
+import { fetchCognitiveSnapshot } from '@/api/cognitive'
+import type { CognitiveSnapshot } from '@/api/cognitive'
+import type { Trace } from '@/types/services'
+import { hasCognitiveConsent } from '@/utils/consent'
+
+type ExportFormat = 'json' | 'csv'
+type SessionStep = Trace['step'] | 'FEEDBACK'
+
+type StoredTrace = {
+ date: string
+ use_case: Trace['use_case']
+ step: SessionStep
+ data: unknown
+}
+
+type TraceSession = {
+ sessionId: string
+ startedAt: string
+ userLogin?: string
+ traces: StoredTrace[]
+}
+
+type ExportOptions = {
+ force?: boolean
+ userLogin?: string
+}
+
+const STORAGE_KEY = 'interactiveai.trace-session.v1'
+
+function generateSessionId() {
+ if (window.isSecureContext && typeof crypto.randomUUID === 'function') {
+ return crypto.randomUUID()
+ }
+ return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (char) =>
+ (+char ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+char / 4)))).toString(16)
+ )
+}
+
+function createSession(userLogin?: string): TraceSession {
+ return {
+ sessionId: generateSessionId(),
+ startedAt: new Date().toISOString(),
+ userLogin,
+ traces: []
+ }
+}
+
+function loadSession(): TraceSession | undefined {
+ const raw = localStorage.getItem(STORAGE_KEY)
+ if (!raw) return undefined
+ try {
+ return JSON.parse(raw) as TraceSession
+ } catch {
+ localStorage.removeItem(STORAGE_KEY)
+ return undefined
+ }
+}
+
+function saveSession(session: TraceSession) {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(session))
+}
+
+function eventKey(data: unknown): string | undefined {
+ if (!data || typeof data !== 'object') return undefined
+ const candidate = data as { card_id?: unknown; process_instance_id?: unknown }
+ if (typeof candidate.card_id === 'string') return `card:${candidate.card_id}`
+ if (typeof candidate.process_instance_id === 'string') return `process:${candidate.process_instance_id}`
+ return undefined
+}
+
+/**
+ * Build a structured view: nest ASKFORHELP/FEEDBACK/AWARD under the EVENT they belong to.
+ *
+ * Algorithm:
+ * 1. Walk traces in chronological order.
+ * 2. Every EVENT becomes a top-level structured entry (with an `interactions` array).
+ * 3. An ASKFORHELP opens a "current interaction group" linked to an EVENT via card_id.
+ * 4. Subsequent FEEDBACK / AWARD traces are appended to that group until a new ASKFORHELP or EVENT appears.
+ * 5. Traces that cannot be linked to any EVENT remain at the top level as-is.
+ */
+type StructuredEvent = StoredTrace & {
+ interactions: StoredTrace[]
+ /** Time in ms between ASKFORHELP and AWARD. null when the user didn't choose a solution. */
+ decision_time_ms: number | null
+}
+
+type StructuredTrace = StoredTrace | StructuredEvent
+
+type SessionKpis = {
+ /** Total session duration in ms (endedAt − startedAt) */
+ total_session_time_ms: number
+ /** Average decision time across ALL events (sum of decision times / total events). null if no events. */
+ avg_decision_time_ms: number | null
+}
+
+function isStructuredEvent(t: StructuredTrace): t is StructuredEvent {
+ return 'interactions' in t
+}
+
+function computeDecisionTime(interactions: StoredTrace[]): number | null {
+ let askDate: string | undefined
+ let awardDate: string | undefined
+ for (let i = 0; i < interactions.length; i++) {
+ if (interactions[i].step === 'ASKFORHELP' && !askDate) askDate = interactions[i].date
+ if (interactions[i].step === 'AWARD' && !awardDate) awardDate = interactions[i].date
+ }
+ if (!askDate || !awardDate) return null
+ return new Date(awardDate).getTime() - new Date(askDate).getTime()
+}
+
+/** Map legacy event_type values to human-readable labels for export. */
+function normalizeEventType(eventType: string): string {
+ if (eventType === 'KPI') return 'Overload'
+ return eventType
+}
+
+function buildStructuredTraces(flat: StoredTrace[]): StructuredTrace[] {
+ // Index: card_id → structured event entry
+ const eventByCardId: Record = {}
+ const result: StructuredTrace[] = []
+
+ // Pointer to the currently "active" interaction group
+ let currentEvent: StructuredEvent | undefined
+
+ for (const trace of flat) {
+ if (trace.step === 'EVENT') {
+ const structured: StructuredEvent = { ...trace, interactions: [], decision_time_ms: null }
+ const data = trace.data as Record | undefined
+ const cardId = data?.card_id as string | undefined
+ if (cardId) eventByCardId[cardId] = structured
+ result.push(structured)
+ // Don't change currentEvent here – EVENTs are async / independent
+ continue
+ }
+
+ if (trace.step === 'ASKFORHELP') {
+ // Resolve the parent EVENT via the card id stored in data.id
+ const data = trace.data as Record | undefined
+ const cardId = data?.id as string | undefined
+ const parent = cardId ? eventByCardId[cardId] : undefined
+ if (parent) {
+ currentEvent = parent
+ currentEvent.interactions.push(trace)
+ } else {
+ // Orphan ASKFORHELP – keep at top level
+ currentEvent = undefined
+ result.push(trace)
+ }
+ continue
+ }
+
+ // FEEDBACK / AWARD / anything else → attach to current group if open
+ if (currentEvent) {
+ currentEvent.interactions.push(trace)
+ } else {
+ result.push(trace)
+ }
+ }
+
+ // Compute per-event decision time KPIs
+ for (const entry of result) {
+ if (isStructuredEvent(entry)) {
+ entry.decision_time_ms = computeDecisionTime(entry.interactions)
+ }
+ }
+
+ return result
+}
+
+function escapeCsv(value: unknown): string {
+ const normalized = typeof value === 'string' ? value : JSON.stringify(value)
+ return `"${(normalized ?? '').replace(/"/g, '""')}"`
+}
+
+function buildCsv(session: TraceSession, endedAt: string) {
+ const header = [
+ 'session_id',
+ 'user_login',
+ 'session_started_at',
+ 'session_ended_at',
+ 'trace_date',
+ 'use_case',
+ 'step',
+ 'data'
+ ]
+
+ const rows = session.traces.map((trace) =>
+ [
+ session.sessionId,
+ session.userLogin ?? '',
+ session.startedAt,
+ endedAt,
+ trace.date,
+ trace.use_case,
+ trace.step,
+ trace.data
+ ]
+ .map((item) => escapeCsv(item))
+ .join(',')
+ )
+
+ return [header.join(','), ...rows].join('\n')
+}
+
+function formatMs(ms: number | null): string {
+ if (ms === null) return '—'
+ if (ms < 1000) return ms + ' ms'
+ const totalSec = Math.round(ms / 1000)
+ const min = Math.floor(totalSec / 60)
+ const sec = totalSec % 60
+ return min > 0 ? min + 'm ' + sec + 's' : sec + 's'
+}
+
+function escapeHtml(str: string): string {
+ return str
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+}
+
+function formatTime(iso: string): string {
+ try {
+ return new Date(iso).toLocaleString()
+ } catch {
+ return iso
+ }
+}
+
+function stepBadge(step: string): string {
+ const colors: Record = {
+ EVENT: '#2563eb',
+ ASKFORHELP: '#d97706',
+ FEEDBACK: '#7c3aed',
+ AWARD: '#059669',
+ SOLUTION: '#0891b2'
+ }
+ const bg = colors[step] || '#6b7280'
+ return '' + escapeHtml(step) + ' '
+}
+
+function feedbackLabel(data: unknown): string {
+ if (!data || typeof data !== 'object') return ''
+ const d = data as Record
+ const action = typeof d.action === 'string' ? d.action : ''
+ const rec = typeof d.recommendation === 'string' ? d.recommendation : ''
+ if (action === 'confirm_recommendation') return '✓ Confirmed — ' + escapeHtml(rec)
+ if (action === 'downvote_recommendation') return '✗ Downvoted — ' + escapeHtml(rec)
+ if (action === 'dismiss_kpi') return 'Dismissed KPI '
+ return escapeHtml(action) + (rec ? ' — ' + escapeHtml(rec) : '')
+}
+
+/** Max character length for a single value rendered in the HTML summary. */
+const MAX_VALUE_LENGTH = 200
+
+/** Return a safe, human-readable representation of a value, truncating if too large. */
+function safeValue(val: unknown): string {
+ if (val === null || val === undefined) return ''
+ const str = typeof val === 'string' ? val : JSON.stringify(val)
+ if (str.length <= MAX_VALUE_LENGTH) return str
+ return str.slice(0, MAX_VALUE_LENGTH) + '\u2026 [truncated]'
+}
+
+/** Returns true when a value looks like encrypted / binary blob data. */
+function isLargeBlob(val: unknown): boolean {
+ if (val === null || val === undefined) return false
+ const str = typeof val === 'string' ? val : JSON.stringify(val)
+ return str.length > MAX_VALUE_LENGTH
+}
+
+function eventMetadataHtml(data: unknown): string {
+ if (!data || typeof data !== 'object') return ''
+ const d = data as Record
+ const meta = d.metadata as Record | undefined
+ if (!meta) return ''
+ const rows: string[] = []
+ const keys = Object.keys(meta)
+ for (let i = 0; i < keys.length; i++) {
+ const key = keys[i]
+ const val = meta[key]
+ if (key === 'event_context' && isLargeBlob(val) && typeof val === 'string') {
+ const src = val.startsWith('data:') ? val : 'data:image/png;base64,' + val
+ rows.push('' + escapeHtml(key) + ' ')
+ } else if (isLargeBlob(val)) {
+ rows.push('' + escapeHtml(key) + ' [large data omitted] ')
+ } else {
+ rows.push('' + escapeHtml(key) + ' ' + escapeHtml(safeValue(val)) + ' ')
+ }
+ }
+ return ''
+}
+
+function cognitiveSnapshotHtml(data: unknown): string {
+ if (!data || typeof data !== 'object') return ''
+ const d = data as Record
+ const snapshot = d.cognitive_snapshot as CognitiveSnapshot | null | undefined
+ if (!snapshot) return ''
+
+ const { cognitive_performance: cp, stress_state: ss, cognitive_performance_explainability: cpExp, stress_explainability: ssExp, error } = snapshot
+
+ let html = ''
+ html += '
🧠 User\'s cognitive informations
'
+
+ if (error) {
+ html += '
⚠ ' + escapeHtml(error) + '
'
+ html += '
'
+ return html
+ }
+
+ if (cp) {
+ html += 'Cognitive Performance: ' + escapeHtml(cp.value) + ' ' + escapeHtml(cp.timestamp) + '
'
+ } else {
+ html += 'Cognitive Performance: —
'
+ }
+ if (ss) {
+ const stressed = ss.value === '1'
+ const stressLabel = stressed
+ ? '⚠ Stressed '
+ : '✓ Not stressed '
+ html += 'Stress State: ' + stressLabel + ' ' + escapeHtml(ss.timestamp) + '
'
+ } else {
+ html += 'Stress State: —
'
+ }
+ if (cpExp) {
+ html += 'Cognitive Performance Explainability: ' + escapeHtml(cpExp.value) + '
'
+ } else {
+ html += 'Cognitive Performance Explainability: —
'
+ }
+ if (ssExp) {
+ html += 'Stress Explainability: ' + escapeHtml(ssExp.value) + '
'
+ } else {
+ html += 'Stress Explainability: —
'
+ }
+ html += ' '
+ return html
+}
+
+function awardHtml(trace: StoredTrace): string {
+ const d = trace.data as Record