From 5b0ce5d06fb6a8b785db21b46f43632f4be243de Mon Sep 17 00:00:00 2001 From: Abderrahman AIT SAID Date: Fri, 24 Jul 2026 12:04:34 +0000 Subject: [PATCH] consent-gated cognitive data --- backend/recommendation-service/api/schemas.py | 4 ++ frontend/src/api/services.ts | 2 + frontend/src/locales/en.json | 1 + frontend/src/locales/fr.json | 1 + frontend/src/stores/auth.ts | 2 + frontend/src/stores/services.ts | 34 +++++++++++-- frontend/src/utils/consent.ts | 28 +++++++++++ frontend/src/utils/traceSessionExport.ts | 48 ++++++++++--------- frontend/src/views/Login.vue | 14 ++++++ 9 files changed, 108 insertions(+), 26 deletions(-) create mode 100644 frontend/src/utils/consent.ts diff --git a/backend/recommendation-service/api/schemas.py b/backend/recommendation-service/api/schemas.py index be3f7849..7c76633f 100644 --- a/backend/recommendation-service/api/schemas.py +++ b/backend/recommendation-service/api/schemas.py @@ -6,6 +6,10 @@ class RecommendationAsk(Schema): context = Dict() event = Dict() + # Optional operator cognitive/stress snapshot, sent alongside event/context + # only when the operator has consented. Declared so it survives schema + # validation and is forwarded verbatim to the RL agent. + cognitive_snapshot = Dict() class RecommendationOut(Schema): diff --git a/frontend/src/api/services.ts b/frontend/src/api/services.ts index 5846b058..cdfda14d 100644 --- a/frontend/src/api/services.ts +++ b/frontend/src/api/services.ts @@ -1,3 +1,4 @@ +import type { CognitiveSnapshot } from '@/api/cognitive' import http from '@/plugins/http' import { useAppStore } from '@/stores/app' import { useCardsStore } from '@/stores/cards' @@ -11,6 +12,7 @@ import { recordTraceForSession } from '@/utils/traceSessionExport' export function getRecommendation(payload: { event: Card['data']['metadata'] context: Context + cognitive_snapshot?: CognitiveSnapshot }) { return http.post[]>('/cab_recommendation/api/v1/recommendation', payload) } diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index f48a5436..a05c4beb 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -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/stores/auth.ts b/frontend/src/stores/auth.ts index 1288d3bd..d3a4e876 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -4,6 +4,7 @@ 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( @@ -48,6 +49,7 @@ export const useAuthStore = defineStore( console.warn('Unable to export trace session:', error) } finally { clearTraceSession() + clearCognitiveConsent() token.value = undefined user.value = undefined localStorage.removeItem('context') 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 index 53174807..5e2914e2 100644 --- a/frontend/src/utils/traceSessionExport.ts +++ b/frontend/src/utils/traceSessionExport.ts @@ -1,6 +1,7 @@ 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' @@ -511,29 +512,32 @@ export async function recordTraceForSession( } } - // Enrich trace data with the latest cognitive snapshot. - // Always attached — on API failure the snapshot contains an `error` field. + // Enrich trace data with the latest cognitive snapshot — but only when the + // operator has consented. Without consent, nothing is fetched or recorded. + // On API failure the snapshot contains an `error` field. let enrichedData: unknown = trace.data - try { - const cognitiveSnapshot = await fetchCognitiveSnapshot() - const base = (trace.data !== null && typeof trace.data === 'object') - ? (trace.data as Record) - : {} - enrichedData = { ...base, cognitive_snapshot: cognitiveSnapshot } - } catch (err: unknown) { - // Should not happen (fetchCognitiveSnapshot never throws), but guard anyway - const message = err instanceof Error ? err.message : String(err) - const base = (trace.data !== null && typeof trace.data === 'object') - ? (trace.data as Record) - : {} - enrichedData = { - ...base, - cognitive_snapshot: { - cognitive_performance: null, - stress_state: null, - cognitive_performance_explainability: null, - stress_explainability: null, - error: `Failed to get cognitive factors: ${message}` + if (hasCognitiveConsent()) { + try { + const cognitiveSnapshot = await fetchCognitiveSnapshot() + const base = (trace.data !== null && typeof trace.data === 'object') + ? (trace.data as Record) + : {} + enrichedData = { ...base, cognitive_snapshot: cognitiveSnapshot } + } catch (err: unknown) { + // Should not happen (fetchCognitiveSnapshot never throws), but guard anyway + const message = err instanceof Error ? err.message : String(err) + const base = (trace.data !== null && typeof trace.data === 'object') + ? (trace.data as Record) + : {} + enrichedData = { + ...base, + cognitive_snapshot: { + cognitive_performance: null, + stress_state: null, + cognitive_performance_explainability: null, + stress_explainability: null, + error: `Failed to get cognitive factors: ${message}` + } } } } diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index 8bd8c772..2621a719 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -19,19 +19,33 @@