Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/recommendation-service/api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/api/services.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -11,6 +12,7 @@
export function getRecommendation<E extends Entity = Entity>(payload: {
event: Card<E>['data']['metadata']
context: Context<E>
cognitive_snapshot?: CognitiveSnapshot
}) {
return http.post<Recommendation<E>[]>('/cab_recommendation/api/v1/recommendation', payload)
}
Expand Down Expand Up @@ -51,7 +53,7 @@

// TODO: TEMP HACK (eval-demo) — MUST BE REMOVED before next release
// The real API call is disabled and replaced with a fake success response for demo purposes.
export function applyRecommendation<E extends Entity = Entity>(data: Action<E>) {

Check warning on line 56 in frontend/src/api/services.ts

View workflow job for this annotation

GitHub Actions / build-and-test

'data' is defined but never used
// [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 }>('/api/v1/recommendations', data)
Expand Down
1 change: 1 addition & 0 deletions frontend/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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é",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/stores/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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')
Expand Down
34 changes: 30 additions & 4 deletions frontend/src/stores/services.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -94,10 +97,33 @@ export const useServicesStore = defineStore('services', () => {
appStore.tab.assistant = 0
return
}
const { data } = await servicesApi.getRecommendation<E>({
// 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<string, unknown>).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<E>['data']['metadata']
context: Context<E>
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<E>(payload)
_recommendations.value = data
}

Expand Down
28 changes: 28 additions & 0 deletions frontend/src/utils/consent.ts
Original file line number Diff line number Diff line change
@@ -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)
}
48 changes: 26 additions & 22 deletions frontend/src/utils/traceSessionExport.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<string, unknown>)
: {}
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<string, unknown>)
: {}
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<string, unknown>)
: {}
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<string, unknown>)
: {}
enrichedData = {
...base,
cognitive_snapshot: {
cognitive_performance: null,
stress_state: null,
cognitive_performance_explainability: null,
stress_explainability: null,
error: `Failed to get cognitive factors: ${message}`
}
}
}
}
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/views/Login.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,33 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'

import Avatar from '@/components/atoms/Avatar.vue'
import Button from '@/components/atoms/Button.vue'
import router from '@/router'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
import { setCognitiveConsent } from '@/utils/consent'

const authStore = useAuthStore()
const appStore = useAppStore()
const { t } = useI18n()

const username = ref('')
const password = ref('')

async function login() {
await authStore.login(username.value, password.value)
// Ask the operator whether cognitive/stress data may be collected. Default to
// "no consent" until they explicitly accept, so nothing is collected in the
// gap before they answer.
setCognitiveConsent(false)
appStore.addModal({
data: t('modal.consent.cognitive'),
type: 'choice',
callback: (success: boolean) => setCognitiveConsent(success)
})
router.push('/')
}
</script>
Expand Down
Loading