From 66c230c939d6df72b02507a91018418598f5ab33 Mon Sep 17 00:00:00 2001 From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:57:45 +0200 Subject: [PATCH 1/8] refactor: add support for A3S --- .gitignore | 3 +- README.md | 16 + .../cab_common/cab_common_auth/settings.py | 2 + .../resources/PowerGrid/schemas.py | 2 + .../recommendation-service/api/exceptions.py | 13 + backend/recommendation-service/api/schemas.py | 5 + .../resources/PowerGrid/manager.py | 67 ++- backend/recommendation-service/settings.py | 4 +- .../tests/test_smoke_pipeline.py | 39 ++ config/dev/cab-standalone/.secrets.example | 2 + config/dev/cab-standalone/docker-compose.sh | 10 +- config/dev/cab-standalone/docker-compose.yml | 22 + .../cab-standalone/nginx-cors-permissive.conf | 3 + .../dev/cab-standalone/nginx-kubernetes.conf | 3 + config/dev/cab-standalone/nginx.conf | 3 + config/dev/recommendation-service/nginx.conf | 3 + frontend/default.conf | 3 + frontend/src/api/services.ts | 1 + .../src/entities/PowerGrid/CAB/Assistant.vue | 25 +- .../PowerGrid/CAB/KpiProjectionChart.vue | 311 +++++++++++++ .../entities/PowerGrid/CAB/kpiProjection.ts | 39 ++ .../src/entities/PowerGrid/locales/en.json | 10 +- .../src/entities/PowerGrid/locales/fr.json | 10 +- frontend/src/entities/PowerGrid/types.ts | 6 + frontend/src/stores/services.ts | 7 +- frontend/src/types/services.ts | 7 + local_setup.sh | 413 ++++++++++++++++++ local_stop.sh | 49 +++ usecases_examples/PowerGrid/Dockerfile.app | 1 - .../PowerGrid/app/models/Communicate.py | 33 +- .../PowerGrid/app/models/Simulator.py | 34 +- .../PowerGrid/app/models/env_serialization.py | 99 +++++ .../PowerGrid/config/API_POWERGRID_CAB.toml | 4 + .../PowerGrid/tests/test_env_serialization.py | 125 ++++++ 34 files changed, 1340 insertions(+), 34 deletions(-) create mode 100644 backend/recommendation-service/tests/test_smoke_pipeline.py create mode 100644 frontend/src/entities/PowerGrid/CAB/KpiProjectionChart.vue create mode 100644 frontend/src/entities/PowerGrid/CAB/kpiProjection.ts create mode 100755 local_setup.sh create mode 100755 local_stop.sh create mode 100644 usecases_examples/PowerGrid/app/models/env_serialization.py create mode 100644 usecases_examples/PowerGrid/tests/test_env_serialization.py diff --git a/.gitignore b/.gitignore index ae6f754d..c473bc79 100644 --- a/.gitignore +++ b/.gitignore @@ -197,4 +197,5 @@ go.sh # Frontend !frontend/.vscode !frontend/env/ -!frontend/env/.env \ No newline at end of file +!frontend/env/.env +/config/dev/cab-standalone/.secrets.pre-simplify diff --git a/README.md b/README.md index 3b4fb7a1..349f0c69 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,22 @@ Some examples of credentials: By default, the system allows the user to be connected only from a single machine. Which means if you try to connect using the same credentials from another machine, you will be disconnected on the first machine. +### OPTIONAL: connecting a local A3S service + +[A3S](a3s-service/README.md) is a local drop-in replacement for the external RL agent API, +also able to project KPIs several timesteps ahead. + +```bash +USE_A3S=1 ./local_setup.sh # full local stack, A3S instead of the remote RL agent +curl localhost:5010/api/v1/health # {"message": "Ok"} +``` + +To iterate on A3S alone, run `./docker/local_setup.sh` from `a3s-service/`, then set +`RL_AGENT_API_URL=http://host.docker.internal:5010/api/v1/recommendation` in +`config/dev/cab-standalone/.secrets` and re-run `./docker-compose.sh` there. The same override +connects any other agent exposing that contract, e.g. a local +[T2.1_deep_expert](https://github.com/ainetus/T2.1_deep_expert) build. Stop A3S with `./local_stop.sh`. + # Development Contributions to the InteractiveAI Assistant Platform are welcome! To contribute, please make sure to use [developer guide](docs/developer-guide.md) diff --git a/backend/cab_common/cab_common_auth/settings.py b/backend/cab_common/cab_common_auth/settings.py index 5595cd35..be9997aa 100644 --- a/backend/cab_common/cab_common_auth/settings.py +++ b/backend/cab_common/cab_common_auth/settings.py @@ -30,6 +30,8 @@ } }, "root": {"level": "DEBUG", "handlers": ["wsgi"]}, + # Avoid silently disabling loggers already set up by the host service. + "disable_existing_loggers": False, } logging.config.dictConfig(DEFAULT_LOGGING) diff --git a/backend/context-service/resources/PowerGrid/schemas.py b/backend/context-service/resources/PowerGrid/schemas.py index 54645552..2d29d1bc 100644 --- a/backend/context-service/resources/PowerGrid/schemas.py +++ b/backend/context-service/resources/PowerGrid/schemas.py @@ -5,3 +5,5 @@ class MetadataSchemaPowerGrid(MetadataSchema): topology = String(allow_none=False) observation = Dict(allow_none=False) + # Serialized environment state, so agents can rebuild it by replay. Optional. + environment_state = Dict(allow_none=True, required=False) diff --git a/backend/recommendation-service/api/exceptions.py b/backend/recommendation-service/api/exceptions.py index 4e675ef5..5e3fe080 100644 --- a/backend/recommendation-service/api/exceptions.py +++ b/backend/recommendation-service/api/exceptions.py @@ -4,3 +4,16 @@ class InvalidUseCase(HTTPError): status_code = 400 message = 'Use case invalid' + + +class UpstreamAgentError(HTTPError): + """The RL agent API could not be reached, or answered with an error. + + Distinct from "the agent ran and had nothing to recommend", which is an + empty list and a success. Collapsing the two into an empty 200 makes an + outage indistinguishable from a quiet grid: the caller waits on a spinner + with nothing to retry and nothing in the UI to say why. + """ + + status_code = 502 + message = 'The recommendation agent is unavailable' diff --git a/backend/recommendation-service/api/schemas.py b/backend/recommendation-service/api/schemas.py index 7c76633f..7a403a2e 100644 --- a/backend/recommendation-service/api/schemas.py +++ b/backend/recommendation-service/api/schemas.py @@ -10,6 +10,8 @@ class RecommendationAsk(Schema): # only when the operator has consented. Declared so it survives schema # validation and is forwarded verbatim to the RL agent. cognitive_snapshot = Dict() + # Optional per-request tuning, e.g. kpi_prediction_steps for A3S. + options = Dict() class RecommendationOut(Schema): @@ -20,6 +22,9 @@ class RecommendationOut(Schema): agent_type = String() actions = List(Dict()) kpis = Dict(allow_none=True) + # A3S multi-step rollouts: candidate index and rollout timestep. + branch_index = Integer(allow_none=True) + step = Integer(allow_none=True) class ProcedureOut(Schema): diff --git a/backend/recommendation-service/resources/PowerGrid/manager.py b/backend/recommendation-service/resources/PowerGrid/manager.py index aefea2cb..ca52e053 100644 --- a/backend/recommendation-service/resources/PowerGrid/manager.py +++ b/backend/recommendation-service/resources/PowerGrid/manager.py @@ -1,7 +1,9 @@ import os +import time import requests import urllib3 +from api.exceptions import UpstreamAgentError from api.manager.base_manager import BaseRecommendationManager from settings import logger @@ -24,6 +26,10 @@ def __init__(self): "http://frontend:80/rl-api/recommendation", ) self.rl_agent_api_token = os.environ.get("RL_AGENT_API_TOKEN", "") + # A3S rollouts can take tens of seconds; 30s was too tight. + self.rl_agent_api_timeout = float( + os.environ.get("RL_AGENT_API_TIMEOUT", "120") + ) super().__init__() def get_recommendation(self, request_data): @@ -45,35 +51,76 @@ def _get_rl_parades(self, request_data): request_data (dict): Full request payload with keys "event" and "context" Returns: - list[dict]: List of parade recommendations, empty on failure + list[dict]: List of parade recommendations. Empty only when the + agent ran and had nothing to propose. + + Raises: + UpstreamAgentError: If the agent API could not be reached or + answered with an error. Deliberately not swallowed into an + empty list — see the exception's docstring. """ try: headers = {} if self.rl_agent_api_token: headers["Authorization"] = f"Bearer {self.rl_agent_api_token}" + started = time.monotonic() response = requests.post( self.rl_agent_api_url, json=request_data, headers=headers, - timeout=30, + timeout=self.rl_agent_api_timeout, verify=False, # SSL cert may not be trusted inside the container ) response.raise_for_status() data = response.json() + elapsed = time.monotonic() - started + if elapsed > self.rl_agent_api_timeout / 2: + logger.warning( + "RL agent call took %.1fs of a %.0fs budget", + elapsed, + self.rl_agent_api_timeout, + ) + else: + logger.info("RL agent call took %.1fs", elapsed) logger.info(f"RL agent returned {len(data)} recommendation(s)") return data except requests.exceptions.SSLError as e: logger.error(f"SSL error calling RL agent API: {e}") - return [] + raise UpstreamAgentError( + message="Could not establish a secure connection to the " + "recommendation agent" + ) from e except requests.exceptions.HTTPError as e: - logger.error(f"HTTP error calling RL agent API: {e} — response body: {e.response.text[:500] if e.response is not None else 'N/A'}") - return [] + body = e.response.text[:500] if e.response is not None else 'N/A' + logger.error(f"HTTP error calling RL agent API: {e} — response body: {body}") + status = e.response.status_code if e.response is not None else None + raise UpstreamAgentError( + message=f"The recommendation agent returned an error " + f"({status})" if status else + "The recommendation agent returned an error", + # The agent's own message goes in the detail rather than the + # user-facing message: it is a Python traceback summary, not + # something an operator can act on. + detail={"upstream_status": status, "upstream_body": body}, + ) from e + except requests.exceptions.Timeout as e: + # Before ConnectionError: requests' Timeout subclasses it for the + # connect-timeout case, so the narrower except has to come first. + logger.error( + "Timeout calling RL agent API (%s) after %.0fs", + self.rl_agent_api_url, + self.rl_agent_api_timeout, + ) + raise UpstreamAgentError( + message="The recommendation agent did not answer in time" + ) from e except requests.exceptions.ConnectionError as e: logger.error(f"Connection error calling RL agent API ({self.rl_agent_api_url}): {e}") - return [] - except requests.exceptions.Timeout: - logger.error(f"Timeout calling RL agent API ({self.rl_agent_api_url}) after 30s") - return [] + raise UpstreamAgentError( + message="Could not reach the recommendation agent" + ) from e except Exception as e: logger.error(f"Unexpected error calling RL agent API: {type(e).__name__}: {e}") - return [] + raise UpstreamAgentError( + detail={"error": f"{type(e).__name__}: {e}"} + ) from e diff --git a/backend/recommendation-service/settings.py b/backend/recommendation-service/settings.py index b69f904d..399a68a1 100644 --- a/backend/recommendation-service/settings.py +++ b/backend/recommendation-service/settings.py @@ -14,7 +14,9 @@ 'root': { 'level': 'INFO', 'handlers': ['wsgi'] - } + }, + # Avoid silently disabling loggers already set up elsewhere. + 'disable_existing_loggers': False, } logging.config.dictConfig(DEFAULT_LOGGING) diff --git a/backend/recommendation-service/tests/test_smoke_pipeline.py b/backend/recommendation-service/tests/test_smoke_pipeline.py new file mode 100644 index 00000000..f38d54e0 --- /dev/null +++ b/backend/recommendation-service/tests/test_smoke_pipeline.py @@ -0,0 +1,39 @@ +"""Smoke test: post a context, stub out the RL agent, expect an ontology recommendation back.""" +import json + +POWERGRID_BEARER_TOKEN = "dummy-token-see-PowerGrid_auth_mocker-fixture" + + +def test_pipeline_smoke_context_to_recommendation( + client, create_usecases, PowerGrid_auth_mocker, mocker +): + mocker.patch( + "resources.PowerGrid.manager.PowerGridManager._get_rl_parades", + return_value=[], + ) + + with open("tests/tests_resources/rte_recommendation.json") as json_file: + payload = json.load(json_file) + + headers = {"Authorization": f"Bearer {POWERGRID_BEARER_TOKEN}"} + response = client.post( + "/api/v1/recommendation?use_case=PowerGrid", + headers=headers, + json=payload, + ) + + assert response.status_code == 200 + recommendations = response.get_json() + assert isinstance(recommendations, list) and len(recommendations) >= 1 + + for reco in recommendations: + assert reco["use_case"] == "PowerGrid" + assert reco["agent_type"] in {"IA", "onto"} + assert reco["title"] + assert "kpis" in reco + + onto_recos = [r for r in recommendations if r["agent_type"] == "onto"] + assert onto_recos + assert any( + "efficiency_of_the_reco" in (r["kpis"] or {}) for r in onto_recos + ) diff --git a/config/dev/cab-standalone/.secrets.example b/config/dev/cab-standalone/.secrets.example index 3ca7c1dc..c21a7d7d 100644 --- a/config/dev/cab-standalone/.secrets.example +++ b/config/dev/cab-standalone/.secrets.example @@ -1,5 +1,7 @@ # Copy to .secrets and fill in your values — this file is gitignored # +# export USE_A3S=1 # use the local A3S service instead of the external RL agent +# # RL agent (T2.1_deep_expert) — pick ONE RL_AGENT_API_URL for your environment: # Local dev : RL agent running on THIS host on port 5123, reached via host.docker.internal # (cab_recommendation has the host-gateway mapping) diff --git a/config/dev/cab-standalone/docker-compose.sh b/config/dev/cab-standalone/docker-compose.sh index 2826d66a..e2e0f895 100755 --- a/config/dev/cab-standalone/docker-compose.sh +++ b/config/dev/cab-standalone/docker-compose.sh @@ -48,6 +48,10 @@ echo "HOST_IP=${HOST_IP}" >> .env if [[ -f .secrets ]]; then source .secrets fi +# USE_A3S=1 points RL_AGENT_API_URL at the local A3S service, unless already set. +if [[ "${USE_A3S:-0}" == "1" && -z "${RL_AGENT_API_URL:-}" ]]; then + RL_AGENT_API_URL="http://caba3s:5010/api/v1/recommendation" +fi echo "RL_AGENT_API_URL=${RL_AGENT_API_URL:-https://interactiveagent.passerelle.irt-systemx.fr/api/v1/recommendation}" >> .env echo "RL_AGENT_API_TOKEN=${RL_AGENT_API_TOKEN:-}" >> .env echo "VITE_POWERGRID_SIMU=${VITE_POWERGRID_SIMU:-/powergrid-simu}" >> .env @@ -59,4 +63,8 @@ echo "COGNITIVE_TOKEN=${COGNITIVE_TOKEN:-}" >> .env # terminal (and in any CI log that runs this script). sed -E 's/^([A-Z_]*(TOKEN|SECRET|PASSWORD)[A-Z_]*)=(.+)$/\1=/; s/^([A-Z_]*(TOKEN|SECRET|PASSWORD)[A-Z_]*)=$/\1=/' .env -docker compose up -d +if [[ "${USE_A3S:-0}" == "1" ]]; then + docker compose --profile a3s up -d +else + docker compose up -d +fi diff --git a/config/dev/cab-standalone/docker-compose.yml b/config/dev/cab-standalone/docker-compose.yml index 077f4b77..ca02c70b 100644 --- a/config/dev/cab-standalone/docker-compose.yml +++ b/config/dev/cab-standalone/docker-compose.yml @@ -189,6 +189,8 @@ services: - TZ=UTC # Set timezone to UTC - RL_AGENT_API_URL=${RL_AGENT_API_URL} - RL_AGENT_API_TOKEN=${RL_AGENT_API_TOKEN} + # A3S rollouts can take tens of seconds to answer. + - RL_AGENT_API_TIMEOUT=${RL_AGENT_API_TIMEOUT:-120} command: sh -c "./entrypoint.sh" ports: - 5400:5000 @@ -197,6 +199,26 @@ services: depends_on: - db_postgres_recommendation + # Local A3S (agent-as-a-service) instance. Opt-in via the "a3s" profile. + caba3s: + container_name: cab_a3s + image: cab/caba3s + profiles: ["a3s"] + build: + context: ../../../a3s-service + dockerfile: docker/Dockerfile + args: + # Baked-in default policy; overridable via `environment:` below without a rebuild. + A3S_POWERGRID_AGENT: ${A3S_POWERGRID_AGENT:-t2.1} + restart: unless-stopped + environment: + - FLASK_APP=app:create_app('dev') + - TZ=UTC + # Which policy serves PowerGrid: "t2.1" (default) or "xd". + - A3S_POWERGRID_AGENT=${A3S_POWERGRID_AGENT:-t2.1} + ports: + - 5010:5010 + db_postgres_recommendation: container_name: db_postgres_recommendation image: postgres:14.7 diff --git a/config/dev/cab-standalone/nginx-cors-permissive.conf b/config/dev/cab-standalone/nginx-cors-permissive.conf index f99158f4..de3e4fe3 100644 --- a/config/dev/cab-standalone/nginx-cors-permissive.conf +++ b/config/dev/cab-standalone/nginx-cors-permissive.conf @@ -3,6 +3,9 @@ resolver 127.0.0.11 ipv6=off; server { listen 80; server_name localhost; + + # PowerGrid environment_state payloads exceed the 1 MB nginx default. + client_max_body_size 64m; ### CUSTOMIZATION - BEGIN # Url of the Authentication provider set $KeycloakBaseUrl "http://keycloak:8080"; diff --git a/config/dev/cab-standalone/nginx-kubernetes.conf b/config/dev/cab-standalone/nginx-kubernetes.conf index 11f75cc3..a7e61651 100644 --- a/config/dev/cab-standalone/nginx-kubernetes.conf +++ b/config/dev/cab-standalone/nginx-kubernetes.conf @@ -13,6 +13,9 @@ log_format upstreamlog '[$time_local] $remote_addr - $remote_user - $server_name server { listen 80; server_name localhost cab-dev.irtsystemx.org; + + # PowerGrid environment_state payloads exceed the 1 MB nginx default. + client_max_body_size 64m; error_log /var/log/nginx/error.log debug; access_log /var/log/nginx/access.log opfab-log; diff --git a/config/dev/cab-standalone/nginx.conf b/config/dev/cab-standalone/nginx.conf index 75078c35..ee2ecec7 100644 --- a/config/dev/cab-standalone/nginx.conf +++ b/config/dev/cab-standalone/nginx.conf @@ -11,6 +11,9 @@ log_format opfab-log '$remote_addr - $time_local_ms' server { listen 80; server_name localhost; + + # PowerGrid environment_state payloads exceed the 1 MB nginx default. + client_max_body_size 64m; access_log /var/log/nginx/access.log opfab-log; ### CUSTOMIZATION - BEGIN diff --git a/config/dev/recommendation-service/nginx.conf b/config/dev/recommendation-service/nginx.conf index b9ad9014..eca4b28c 100644 --- a/config/dev/recommendation-service/nginx.conf +++ b/config/dev/recommendation-service/nginx.conf @@ -3,6 +3,9 @@ resolver 127.0.0.11 ipv6=off; server { listen 80; server_name localhost; + + # PowerGrid environment_state payloads exceed the 1 MB nginx default. + client_max_body_size 64m; ### CUSTOMIZATION - BEGIN # Url of the Authentication provider set $KeycloakBaseUrl "http://keycloak:8080"; diff --git a/frontend/default.conf b/frontend/default.conf index e76df39a..8c95ebe8 100644 --- a/frontend/default.conf +++ b/frontend/default.conf @@ -5,6 +5,9 @@ server { listen 80; server_name localhost; + # PowerGrid environment_state payloads exceed the 1 MB nginx default. + client_max_body_size 64m; + gzip on; gzip_types application/javascript text/css; diff --git a/frontend/src/api/services.ts b/frontend/src/api/services.ts index 5892be71..300fbac8 100644 --- a/frontend/src/api/services.ts +++ b/frontend/src/api/services.ts @@ -13,6 +13,7 @@ export function getRecommendation(payload: { event: Card['data']['metadata'] context: Context cognitive_snapshot?: CognitiveSnapshot + options?: { kpi_prediction_steps?: number } }) { return http.post[]>('/cab_recommendation/api/v1/recommendation', payload) } diff --git a/frontend/src/entities/PowerGrid/CAB/Assistant.vue b/frontend/src/entities/PowerGrid/CAB/Assistant.vue index b1fef2d3..e9073f86 100644 --- a/frontend/src/entities/PowerGrid/CAB/Assistant.vue +++ b/frontend/src/entities/PowerGrid/CAB/Assistant.vue @@ -30,6 +30,9 @@ + diff --git a/frontend/src/entities/PowerGrid/CAB/kpiProjection.ts b/frontend/src/entities/PowerGrid/CAB/kpiProjection.ts new file mode 100644 index 00000000..cc5d026d --- /dev/null +++ b/frontend/src/entities/PowerGrid/CAB/kpiProjection.ts @@ -0,0 +1,39 @@ +import type { Entity } from '@/types/entities' +import type { Recommendation } from '@/types/services' + +export type KpiProjectionGroup = { + branchIndex: number + title: string + values: (number | undefined)[] +} + +// Groups rollout steps of the same candidate action together. +export function branchKey(recommendation: Recommendation) { + return recommendation.branch_index ?? recommendation.title +} + +// Regroups the flat API list (one item per rollout step) into one KPI series per candidate. +export function groupKpiProjection( + recommendations: Recommendation[], + kpiKey: string +): { steps: number[]; groups: KpiProjectionGroup[] } { + const steps = [...new Set(recommendations.map((r) => r.step ?? 1))].sort((a, b) => a - b) + const groups = new Map() + + recommendations.forEach((recommendation, index) => { + const key = branchKey(recommendation) + let group = groups.get(key) + if (!group) { + group = { + branchIndex: recommendation.branch_index ?? index, + title: recommendation.title.replace(/_step_\d+$/, ''), + values: steps.map(() => undefined) + } + groups.set(key, group) + } + const value = recommendation.kpis?.[kpiKey] + if (isFinite(value)) group.values[steps.indexOf(recommendation.step ?? 1)] = Number(value) + }) + + return { steps, groups: [...groups.values()].sort((a, b) => a.branchIndex - b.branchIndex) } +} diff --git a/frontend/src/entities/PowerGrid/locales/en.json b/frontend/src/entities/PowerGrid/locales/en.json index e11cecd4..0abcf683 100644 --- a/frontend/src/entities/PowerGrid/locales/en.json +++ b/frontend/src/entities/PowerGrid/locales/en.json @@ -17,5 +17,13 @@ "PowerGrid.kpis.redispatching_volume": "Redispatch volume", "PowerGrid.kpis.renewable_energy_share": "Proportion of renewable", "PowerGrid.kpis.total_consumption": "Total consumption", - "PowerGrid.kpis.type_of_the_reco": "Type of recommendation" + "PowerGrid.kpis.type_of_the_reco": "Type of recommendation", + "kpiProjection.show": "Show KPI projections", + "kpiProjection.close": "Close", + "kpiProjection.title": "{kpi} — projection over the next timesteps", + "kpiProjection.description": "For each candidate recommendation, projected value of the selected KPI if the action is applied and the grid then evolves under the agent's own policy for the next timesteps.", + "kpiProjection.xAxis": "Timesteps ahead", + "kpiProjection.tableCaption": "Projected {kpi} value per candidate recommendation and timestep ahead", + "kpiProjection.tableRecommendation": "Recommendation", + "kpiProjection.step": "t+{step}" } diff --git a/frontend/src/entities/PowerGrid/locales/fr.json b/frontend/src/entities/PowerGrid/locales/fr.json index 4c1666cb..64593b72 100644 --- a/frontend/src/entities/PowerGrid/locales/fr.json +++ b/frontend/src/entities/PowerGrid/locales/fr.json @@ -17,5 +17,13 @@ "PowerGrid.kpis.distance_from_reference_topology": "Distance à la topologie de référence", "PowerGrid.kpis.curtailment_volume": "Volume de curtailment", "PowerGrid.kpis.redispatching_volume": "Volume de redispatching", - "PowerGrid.kpis.type_of_the_reco": "Type de la parade" + "PowerGrid.kpis.type_of_the_reco": "Type de la parade", + "kpiProjection.show": "Afficher les projections de KPI", + "kpiProjection.close": "Fermer", + "kpiProjection.title": "{kpi} — projection sur les prochains pas de temps", + "kpiProjection.description": "Pour chaque parade candidate, valeur projetée du KPI sélectionné si l'action est appliquée puis que le réseau évolue selon la politique de l'agent sur les prochains pas de temps.", + "kpiProjection.xAxis": "Pas de temps à venir", + "kpiProjection.tableCaption": "Valeur projetée du KPI {kpi} par parade candidate et pas de temps à venir", + "kpiProjection.tableRecommendation": "Parade", + "kpiProjection.step": "t+{step}" } diff --git a/frontend/src/entities/PowerGrid/types.ts b/frontend/src/entities/PowerGrid/types.ts index d963a438..4ad4ce40 100644 --- a/frontend/src/entities/PowerGrid/types.ts +++ b/frontend/src/entities/PowerGrid/types.ts @@ -61,6 +61,12 @@ export type PowerGrid = { year: [number] } topology: string + // Opaque state envelope, forwarded to the recommendation service so A3S can rebuild the environment. + environment_state?: { + serializer: string + state: Record + metadata?: Record + } } Metadata: { event_type: 'KPI' | 'anticipation' | 'agent' | 'consignation' diff --git a/frontend/src/stores/services.ts b/frontend/src/stores/services.ts index 582f5c0a..fad01b3e 100644 --- a/frontend/src/stores/services.ts +++ b/frontend/src/stores/services.ts @@ -1,8 +1,8 @@ import { defineStore } from 'pinia' import { ref } from 'vue' -import { fetchCognitiveSnapshot } from '@/api/cognitive' import type { CognitiveSnapshot } from '@/api/cognitive' +import { fetchCognitiveSnapshot } from '@/api/cognitive' import * as servicesApi from '@/api/services' import i18n from '@/plugins/i18n' import type { Card } from '@/types/cards' @@ -132,9 +132,12 @@ export const useServicesStore = defineStore('services', () => { event: Card['data']['metadata'] context: Context cognitive_snapshot?: CognitiveSnapshot + options?: { kpi_prediction_steps?: number } } = { event: getRootCard(event).data.metadata, - context: contextForAgent + context: contextForAgent, + // A3S rolls this out; other managers ignore it. + options: { kpi_prediction_steps: 6 } } if (hasCognitiveConsent()) { payload.cognitive_snapshot = await fetchCognitiveSnapshot() diff --git a/frontend/src/types/services.ts b/frontend/src/types/services.ts index 75756293..1fc30c0d 100644 --- a/frontend/src/types/services.ts +++ b/frontend/src/types/services.ts @@ -8,6 +8,13 @@ export type Recommendation = { title: string actions: Action[] kpis?: { [key: string]: any } + // A3S multi-step rollouts: candidate index and rollout timestep. + branch_index?: number + step?: number + // Whether this rollout step ended the episode (terminal-state KPIs). + done?: boolean + // Environment's absolute clock, vs `step`'s rollout-relative count. + env_timestep?: number } export type FullContext = { diff --git a/local_setup.sh b/local_setup.sh new file mode 100755 index 00000000..b5ef494f --- /dev/null +++ b/local_setup.sh @@ -0,0 +1,413 @@ +#!/usr/bin/env bash +# +# local_setup.sh — one-shot local setup for the InteractiveAI backend + PowerGrid simulator. +# +# Steps: start the backend, wait for Keycloak + frontend, configure Keycloak via +# the admin REST API (manual prompt as fallback), load OperatorFabric resources, +# build and start the PowerGrid simulator, reload the frontend nginx and verify +# the recommendation path. +# +# Usage: +# ./local_setup.sh # full setup (prompts if containers already run) +# ./local_setup.sh --clean # tear down existing containers first, no prompt +# ./local_setup.sh --wipe # tear down existing containers AND volumes, no prompt +# +# Overridable via environment: +# KC_ADMIN (admin) KC_PW (admin) FRONTEND_URL (http://localhost:3200) +# +# Secrets (RL_AGENT_API_URL / RL_AGENT_API_TOKEN / VITE_COGNITIVE_TOKEN / USE_A3S) +# are read from config/dev/cab-standalone/.secrets if present (see .secrets.example). + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BACKEND_DIR="$REPO_ROOT/config/dev/cab-standalone" +RESOURCES_DIR="$REPO_ROOT/resources" +SIM_DIR="$REPO_ROOT/usecases_examples/PowerGrid" +SIM_COMPOSE="docker-compose.local.yml" # server config lives in docker-compose.yml +SIM_PORT=5122 # must match POWERGRID_SIMU_UPSTREAM in .env +A3S_PORT=5010 # host port the compose `caba3s` service publishes +# Serializer name the simulator stamps into environment_state; must match what A3S accepts. +SIM_SERIALIZER_SRC="$REPO_ROOT/usecases_examples/PowerGrid/app/models/env_serialization.py" + +KC_BASE="http://localhost:89/auth" # Keycloak 16.x (legacy /auth base path) +KC_REALM="dev" +KC_CLIENT="opfab-client" +KC_ADMIN="${KC_ADMIN:-admin}" +KC_PW="${KC_PW:-admin}" + +FRONTEND_URL="${FRONTEND_URL:-http://localhost:3200}" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +log() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; } +ok() { printf '\033[1;32m ✓ %s\033[0m\n' "$*"; } +warn() { printf '\033[1;33m ! %s\033[0m\n' "$*"; } +die() { printf '\033[1;31m ✗ %s\033[0m\n' "$*" >&2; exit 1; } + +# Emit an OSC 8 terminal hyperlink; degrades to plain text if unsupported. +link() { printf '\033]8;;%s\033\\%s\033]8;;\033\\' "$1" "$1"; } + +# Block until an HTTP endpoint answers with the wanted status, or time out. +wait_for_http() { + local url="$1" want="${2:-200}" tries="${3:-90}" i=1 code + while (( i <= tries )); do + code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 "$url" || true)" + [[ "$code" == "$want" ]] && return 0 + printf ' waiting for %s (%s/%s, last=%s)\r' "$url" "$i" "$tries" "$code" + sleep 2; (( i++ )) + done + printf '\n'; return 1 +} + +require() { command -v "$1" >/dev/null 2>&1 || die "'$1' is required but not installed."; } + +# True if $1 is the name of a currently running container. +container_running() { docker ps --format '{{.Names}}' | grep -qx "$1"; } + +# --------------------------------------------------------------------------- +# Gateway / A3S robustness +# --------------------------------------------------------------------------- +# nginx caches upstream IPs at config load, so a container recreated afterwards +# 502s until reloaded. Reload against the substituted config (-c), not the raw +# templated one nginx -s reload would otherwise validate. +frontend_nginx_reload() { + container_running frontend || { warn "frontend not running — skipping nginx reload"; return 0; } + if docker exec frontend nginx -c /personal-conf/nginx.conf -s reload >/dev/null 2>&1; then + ok "frontend nginx reloaded (upstream IPs re-resolved)" + elif docker exec frontend nginx -s reload >/dev/null 2>&1; then + ok "frontend nginx reloaded" + else + warn "nginx reload failed — restarting the frontend container instead" + docker restart frontend >/dev/null 2>&1 || { warn "frontend restart failed"; return 0; } + wait_for_http "$FRONTEND_URL/" 200 >/dev/null || warn "frontend not answering 200 yet" + ok "frontend restarted" + fi +} + +# A standalone a3s-service/docker/local_setup.sh can grab port 5010 before the +# compose `caba3s` service starts, leaving it unable to bind. Free the port, +# unless RL_AGENT_API_URL is set explicitly (the operator is pointing elsewhere). +free_a3s_port() { + [[ "${USE_A3S:-0}" == "1" ]] || return 0 + if [[ -n "${RL_AGENT_API_URL:-}" ]]; then + warn "both USE_A3S=1 and RL_AGENT_API_URL are set in .secrets — the explicit URL wins" + warn " using: $RL_AGENT_API_URL" + warn " cab_a3s will still be built and started, but nothing will call it" + warn " for a self-contained stack, comment RL_AGENT_API_URL out and keep USE_A3S=1" + ok "leaving port $A3S_PORT and any standalone A3S as-is" + return 0 + fi + local holder + holder="$(docker ps --format '{{.Names}}\t{{.Ports}}' \ + | awk -F'\t' -v p=":$A3S_PORT->" 'index($2, p) {print $1}' \ + | grep -vx cab_a3s || true)" + [[ -z "$holder" ]] && return 0 + warn "port $A3S_PORT is needed by cab_a3s but is held by container '$holder'" + warn "(that is the standalone A3S from a3s-service/docker/local_setup.sh — this stack builds its own)" + local c + for c in $holder; do + docker stop "$c" >/dev/null 2>&1 && ok "stopped '$c' to free port $A3S_PORT (start it again later if you need it)" + done +} + +# Checks the gateway route, the RL agent, and the simulator/A3S serializer contract. +verify_recommendation_path() { + local failures=0 + + if wait_for_http "$FRONTEND_URL/cab_recommendation/api/v1/health" 200 15 >/dev/null; then + ok "gateway route $FRONTEND_URL/cab_recommendation/ reaches the recommendation service" + else + warn "gateway route to the recommendation service is not answering 200" + warn " the browser calls it at /cab_recommendation/ — a 502 here means a stale nginx upstream" + failures=$(( failures + 1 )) + fi + + if [[ "${USE_A3S:-0}" == "1" && -z "${RL_AGENT_API_URL:-}" ]]; then + if ! container_running cab_a3s; then + warn "USE_A3S=1 but the cab_a3s container is not running" + warn " check: cd $BACKEND_DIR && docker compose --profile a3s logs caba3s" + failures=$(( failures + 1 )) + elif wait_for_http "http://localhost:$A3S_PORT/api/v1/health" 200 15 >/dev/null; then + ok "A3S is healthy on port $A3S_PORT" + verify_serializer_contract cab_a3s || failures=$(( failures + 1 )) + else + warn "cab_a3s is running but not answering on port $A3S_PORT" + failures=$(( failures + 1 )) + fi + elif [[ -n "${RL_AGENT_API_URL:-}" ]]; then + # Resolve from inside the recommendation container, whose network namespace RL_AGENT_API_URL targets. + local health_url="${RL_AGENT_API_URL%/recommendation}/health" + if container_running cab_recommendation && docker exec cab_recommendation python -c " +import sys, urllib.request, ssl +ctx = ssl._create_unverified_context() +try: + urllib.request.urlopen('$health_url', timeout=8, context=ctx) +except Exception as e: + sys.exit(str(e) or 'unreachable') +" >/dev/null 2>&1; then + ok "RL agent reachable from cab_recommendation at $health_url" + # May point at a standalone A3S on the host; find it by its published port. + local port a3s_container + port="$(sed -E 's#.*://[^/:]+:([0-9]+).*#\1#' <<<"$RL_AGENT_API_URL")" + if [[ "$port" =~ ^[0-9]+$ ]]; then + a3s_container="$(docker ps --format '{{.Names}}\t{{.Ports}}' \ + | awk -F'\t' -v p=":$port->" 'index($2, p) {print $1; exit}')" + if [[ -n "$a3s_container" ]]; then + verify_serializer_contract "$a3s_container" || failures=$(( failures + 1 )) + fi + fi + else + warn "RL agent at \$RL_AGENT_API_URL is NOT reachable from inside cab_recommendation" + warn " URL: $RL_AGENT_API_URL" + warn " a container on another compose network is not reachable by name —" + warn " use http://host.docker.internal:/api/v1/recommendation" + failures=$(( failures + 1 )) + fi + else + warn "neither USE_A3S=1 nor RL_AGENT_API_URL is set — only the ontology recommender will run" + fi + + return "$failures" +} + +# Compares the simulator's serializer name against what A3S accepts. +# $1 = name of the container running A3S. +verify_serializer_contract() { + local a3s="$1" emitted accepted + emitted="$(grep -oE '"serializer": [A-Z0-9_]+' "$SIM_SERIALIZER_SRC" 2>/dev/null | awk '{print $2}' | head -1)" + [[ -n "$emitted" ]] || { warn "could not determine the simulator's serializer from $SIM_SERIALIZER_SRC"; return 0; } + emitted="$(grep -oE "^${emitted} = \"[a-z0-9_]+\"" "$SIM_SERIALIZER_SRC" | sed 's/.*"\(.*\)"/\1/' | head -1)" + [[ -n "$emitted" ]] || { warn "could not resolve the simulator's serializer constant"; return 0; } + + accepted="$(docker exec "$a3s" sh -c 'grep -hoE "grid2op_observation_v[0-9]+" /my_app/agent_as_a_service/powergrid/serialization.py 2>/dev/null | sort -u' 2>/dev/null || true)" + if [[ -z "$accepted" ]]; then + warn "could not read the serializers '$a3s' accepts — skipping the contract check" + return 0 + fi + if grep -qx "$emitted" <<<"$accepted"; then + ok "serializer contract OK — simulator emits '$emitted', '$a3s' accepts it" + return 0 + fi + warn "SERIALIZER MISMATCH — recommendations will silently come back empty" + warn " simulator emits: $emitted" + warn " '$a3s' accepts: $(tr '\n' ' ' <<<"$accepted")" + warn " that A3S image predates the simulator's payload format — rebuild it:" + if [[ "$a3s" == "cab_a3s" ]]; then + warn " cd $BACKEND_DIR && docker compose --profile a3s up -d --build --force-recreate caba3s" + else + warn " cd $REPO_ROOT/a3s-service && ./docker/local_setup.sh --rebuild" + fi + return 1 +} + +# --------------------------------------------------------------------------- +# Keycloak configuration via the admin REST API +# --------------------------------------------------------------------------- +kc_admin_token() { + curl -s --max-time 10 -X POST \ + "$KC_BASE/realms/master/protocol/openid-connect/token" \ + -d client_id=admin-cli -d "username=$KC_ADMIN" -d "password=$KC_PW" \ + -d grant_type=password \ + | python3 -c 'import sys,json; print(json.load(sys.stdin).get("access_token",""))' 2>/dev/null +} + +# Returns 0 on success, non-zero if anything went wrong (caller then prompts). +kc_configure() { + local token realm updated code + token="$(kc_admin_token)" + [[ -n "$token" ]] || { warn "could not obtain a Keycloak admin token"; return 1; } + + # Without a matching Frontend URL, token issuer URLs mismatch and auth returns 401. + realm="$(curl -s --max-time 10 -H "Authorization: Bearer $token" "$KC_BASE/admin/realms/$KC_REALM")" + updated="$(printf '%s' "$realm" | FRONTEND_URL="$FRONTEND_URL" python3 -c ' +import sys, json, os +d = json.load(sys.stdin) +attrs = d.get("attributes") or {} +attrs["frontendUrl"] = os.environ["FRONTEND_URL"] +d["attributes"] = attrs +print(json.dumps(d))' 2>/dev/null)" + [[ -n "$updated" ]] || { warn "could not read/patch the '$KC_REALM' realm"; return 1; } + code="$(curl -s -o /dev/null -w '%{http_code}' -X PUT \ + "$KC_BASE/admin/realms/$KC_REALM" \ + -H "Authorization: Bearer $token" -H "Content-Type: application/json" \ + -d "$updated")" + [[ "$code" == 2* ]] || { warn "realm update returned HTTP $code"; return 1; } + ok "realm '$KC_REALM' Frontend URL set to $FRONTEND_URL" + return 0 +} + +# Manual fallback: pause and let the operator configure Keycloak by hand. +kc_manual_prompt() { + cat < General -> Frontend URL = $FRONTEND_URL -> Save + 4. Clients -> '$KC_CLIENT' -> Valid Redirect URIs must include + ${FRONTEND_URL%/}/* (and Web Origins ${FRONTEND_URL}) -> Save + ------------------------------------------------------------------ +EOF + read -r -p " Press ENTER once Keycloak is configured to continue... " _ +} + +# --------------------------------------------------------------------------- +# Existing-container detection / teardown +# --------------------------------------------------------------------------- +# Lists running containers of the compose project rooted at $1. $2 = optional compose file. +compose_running() { + ( cd "$1" && docker compose ${2:+-f "$2"} ps --format ' {{.Name}} ({{.Status}})' 2>/dev/null ) || true +} + +# Remove both compose stacks. $1 = extra `down` args (e.g. "-v" to drop volumes). +teardown_stacks() { + local extra="${1:-}" + log "Tearing down existing containers${extra:+ and volumes} for a clean rebuild" + ( cd "$SIM_DIR" && docker compose -f "$SIM_COMPOSE" down $extra 2>/dev/null ) || true + ( cd "$BACKEND_DIR" && docker compose down $extra 2>/dev/null ) || true + ok "existing containers removed" +} + +# Ask what to do if our containers are already running. $1: "" ask, "clean" down, "wipe" down -v. +handle_existing_containers() { + local mode="${1:-}" running + running="$(compose_running "$BACKEND_DIR"; compose_running "$SIM_DIR" "$SIM_COMPOSE")" + + if [[ -z "$running" ]]; then + ok "no existing project containers running" + return 0 + fi + + warn "Found running containers from this setup:" + printf '%s\n' "$running" + + case "$mode" in + clean) teardown_stacks "" ; return 0 ;; + wipe) teardown_stacks "-v" ; return 0 ;; + esac + + if [[ ! -t 0 ]]; then + warn "non-interactive shell and no --clean/--wipe flag: leaving containers as-is" + return 0 + fi + + local reply + read -r -p " Kill them and rebuild clean? [y]es / [w]ipe data too / [N]o, keep running: " reply + case "${reply,,}" in + y|yes) teardown_stacks "" ;; + w|wipe) teardown_stacks "-v" ;; + *) warn "leaving existing containers in place (continuing)" ;; + esac +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +main() { + local CLEAN_MODE="" + for arg in "$@"; do + case "$arg" in + --clean) CLEAN_MODE="clean" ;; + --wipe) CLEAN_MODE="wipe" ;; + -h|--help) sed -n '3,26p' "${BASH_SOURCE[0]}"; exit 0 ;; + *) die "unknown argument: $arg (see --help)" ;; + esac + done + + log "Checking prerequisites" + require docker; require curl; require python3 + docker compose version >/dev/null 2>&1 || die "'docker compose' (v2) is required." + ok "docker, docker compose, curl, python3 present" + + # Source .secrets in THIS shell (docker-compose.sh sources it in a subshell), + # so USE_A3S is visible to the rebuild check further down. + if [[ -f "$BACKEND_DIR/.secrets" ]]; then + # shellcheck disable=SC1091 # path is runtime-resolved, gitignored + source "$BACKEND_DIR/.secrets" + else + warn "no config/dev/cab-standalone/.secrets file — using default RL agent API and no cognitive token" + warn "copy .secrets.example to .secrets to override (see docker-compose.sh)" + fi + + log "Checking for existing containers" + handle_existing_containers "$CLEAN_MODE" + + log "Checking the A3S port is available" + free_a3s_port + ok "port $A3S_PORT ready for cab_a3s" + + log "Step 1/6 — Starting the InteractiveAI backend" + ( cd "$BACKEND_DIR" && ./docker-compose.sh ) + ok "backend compose brought up" + + # Force a rebuild from this repo's source (docker-compose.sh's `up -d` reuses + # existing images as-is); caba3s only when USE_A3S=1 brought it up. + local rebuild=(frontend cabrecommendation) profile=() + if [[ "${USE_A3S:-0}" == "1" ]]; then + rebuild+=(caba3s) + profile=(--profile a3s) + fi + log "Rebuilding ${rebuild[*]} from this repo's source" + ( cd "$BACKEND_DIR" && docker compose "${profile[@]}" up -d --build --force-recreate "${rebuild[@]}" ) + ok "${rebuild[*]} rebuilt from source" + + log "Step 2/6 — Waiting for Keycloak" + wait_for_http "$KC_BASE/realms/master" 200 || die "Keycloak did not come up on :89" + ok "Keycloak is up" + + log "Step 3/6 — Configuring Keycloak" + if kc_configure; then + ok "Keycloak configured automatically" + else + kc_manual_prompt + fi + log "Restarting the frontend to pick up the Keycloak change" + docker restart frontend >/dev/null && ok "frontend restarted" + wait_for_http "$FRONTEND_URL/" 200 || warn "frontend not answering 200 yet (continuing)" + + log "Step 4/6 — Loading resources and registering use cases" + # Wait until auth actually works end-to-end before loading (avoids 401s). + local i=1 + while true; do + unset token + source "$RESOURCES_DIR/getToken.sh" admin "${FRONTEND_URL%:*}" >/dev/null 2>&1 || true + [[ -n "${token:-}" ]] && break + (( i > 24 )) && die "auth never became ready ($FRONTEND_URL/auth/token)" + printf ' waiting for auth to be ready (%s/24)\r' "$i"; sleep 5; (( i++ )) + done + printf '\n'; ok "auth is ready" + ( cd "$RESOURCES_DIR" && ./loadTestConf.sh ) + ok "resources loaded, use cases registered" + + log "Step 5/6 — Building and starting the PowerGrid simulator" + # The default docker-compose.yml is the server config (wrong port); use the local one. + ( cd "$SIM_DIR" && docker compose -f "$SIM_COMPOSE" up -d --build --force-recreate app ) + ok "PowerGrid simulator started" + + # Last, once nothing else will be recreated, so the re-resolved upstream IPs stick. + log "Step 6/6 — Re-pointing the gateway and verifying the recommendation path" + frontend_nginx_reload + if verify_recommendation_path; then + ok "recommendation path verified end to end" + else + warn "the recommendation path is NOT fully working — see the warnings above" + warn "the UI will come up, but the PowerGrid recommendation panel may stay empty" + fi + + printf '\n\033[1;32mSetup complete.\033[0m\n\n' + # powergrid_user is provisioned with the PowerGrid entity; publisher_test isn't. + printf ' InteractiveAI UI %s (powergrid_user / test)\n' "$(link "$FRONTEND_URL")" + printf ' PowerGrid simulator %s (also proxied same-origin at %s/powergrid-simu/)\n' "$(link "http://localhost:$SIM_PORT")" "$FRONTEND_URL" + printf ' Keycloak admin %s (admin / admin)\n' "$(link "$KC_BASE/admin")" + printf '\n In the simulator, pick server %s and log in.\n' "$(link "http://host.docker.internal:3200/")" +} + +main "$@" diff --git a/local_stop.sh b/local_stop.sh new file mode 100755 index 00000000..f576d8fe --- /dev/null +++ b/local_stop.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# +# local_stop.sh — tear down the local InteractiveAI backend + PowerGrid simulator. +# +# Usage: +# ./local_stop.sh # stop & remove containers, KEEP data volumes (default) +# ./local_stop.sh --wipe # also delete data volumes (Postgres/Mongo/Keycloak) — fresh start next time +# ./local_stop.sh --pause # just stop containers, keep them (fastest; `./local_setup.sh` or `docker compose start` to resume) +# ./local_stop.sh --help +# +# Both Docker Compose projects are handled: the backend (config/dev/cab-standalone) +# and the PowerGrid simulator (usecases_examples/PowerGrid). + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BACKEND_DIR="$REPO_ROOT/config/dev/cab-standalone" +SIM_DIR="$REPO_ROOT/usecases_examples/PowerGrid" +# Local dev uses docker-compose.local.yml, not the default (server) compose file. +SIM_COMPOSE="docker-compose.local.yml" + +log() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; } +ok() { printf '\033[1;32m ✓ %s\033[0m\n' "$*"; } +die() { printf '\033[1;31m ✗ %s\033[0m\n' "$*" >&2; exit 1; } + +case "${1:-}" in + "") MSG="Stopping everything (containers removed, data volumes kept)" + COMPOSE_CMD="down" ;; + --wipe|--volumes) MSG="Stopping everything and DELETING data volumes (fresh start next time)" + COMPOSE_CMD="down -v" ;; + --pause|--stop) MSG="Pausing everything (containers kept, resume later)" + COMPOSE_CMD="stop" ;; + -h|--help) sed -n '3,13p' "${BASH_SOURCE[0]}"; exit 0 ;; + *) die "unknown argument: ${1} (see --help)" ;; +esac + +# Run the chosen teardown command in a compose project directory. +# $2 = optional compose file. +run() { + ( cd "$1" && docker compose ${2:+-f "$2"} $COMPOSE_CMD 2>/dev/null ) || true +} + +log "$MSG" + +# Simulator first, then backend it depends on. +run "$SIM_DIR" "$SIM_COMPOSE" +run "$BACKEND_DIR" + +ok "done" diff --git a/usecases_examples/PowerGrid/Dockerfile.app b/usecases_examples/PowerGrid/Dockerfile.app index 782ea872..8f9d46ba 100644 --- a/usecases_examples/PowerGrid/Dockerfile.app +++ b/usecases_examples/PowerGrid/Dockerfile.app @@ -7,7 +7,6 @@ RUN mkdir /code COPY . /code/ WORKDIR /code -RUN apt-get update && apt-get install ffmpeg libsm6 libxext6 -y RUN pip3 install -r requirements-app.txt CMD ["python3", "PowerGrid_poc_simulator_app.py"] \ No newline at end of file diff --git a/usecases_examples/PowerGrid/app/models/Communicate.py b/usecases_examples/PowerGrid/app/models/Communicate.py index 9e779eb8..28793a58 100644 --- a/usecases_examples/PowerGrid/app/models/Communicate.py +++ b/usecases_examples/PowerGrid/app/models/Communicate.py @@ -7,6 +7,7 @@ import numpy as np from config.config import logging, set_pause, get_pause_status from app.models.recommendation_store import store as recommendation_store +from app.models.env_serialization import build_environment_state class Communicate: """ @@ -227,7 +228,22 @@ def login(self, username, password): authorization = True return url, authorization - def send_context_online(self, obs, scn_first_step, context_date, img_b64): + def environment_state_payload(self, obs, env_identity, recorder): + """Builds the `environment_state` envelope, or None if disabled/unavailable.""" + if not env_identity or recorder is None or not len(recorder): + return None + + context_config = self.outputs_config['Outputs']['Context'] + if str(context_config.get('serialize_env_state', 'yes')).lower() != 'yes': + return None + + return build_environment_state( + obs, env_identity, recorder, + float(context_config.get('max_state_mb', 32)) + ) + + def send_context_online(self, obs, scn_first_step, context_date, img_b64, + env_identity=None, recorder=None): """ Sends the context online with the observation image. @@ -236,6 +252,8 @@ def send_context_online(self, obs, scn_first_step, context_date, img_b64): scn_first_step: First step of the scenario. context_date: Date of the context. img_b64: Base64 encoded image. + env_identity: Environment identity from `build_env_identity`. + recorder: ReplayRecorder of actions since reset; published as `environment_state`. """ if obs.current_step < scn_first_step or not self.cab_api_on: return @@ -243,12 +261,17 @@ def send_context_online(self, obs, scn_first_step, context_date, img_b64): try: url = self.cab_url + \ self.outputs_config['Outputs']['Context']['context_port'] + data = { + "observation": obs.to_json(), + "topology": img_b64 + } + environment_state = self.environment_state_payload( + obs, env_identity or {}, recorder) + if environment_state is not None: + data["environment_state"] = environment_state payload = json.dumps({ "date": f"{context_date}", - "data": { - "observation": obs.to_json(), - "topology": img_b64 - }, + "data": data, "use_case": "PowerGrid" }) headers = { diff --git a/usecases_examples/PowerGrid/app/models/Simulator.py b/usecases_examples/PowerGrid/app/models/Simulator.py index fd1bb734..17fa4075 100644 --- a/usecases_examples/PowerGrid/app/models/Simulator.py +++ b/usecases_examples/PowerGrid/app/models/Simulator.py @@ -11,6 +11,7 @@ import matplotlib matplotlib.use('agg') from app.models.Listener import Listener +from app.models.env_serialization import ReplayRecorder, build_env_identity from config.config import logging, set_pause, get_pause_status from app.models.utils import (create_observation_image, get_alert_lines, search_chronic_num_from_name, get_curent_lines_in_bad_kpi, get_curent_lines_lost, @@ -35,6 +36,9 @@ def __init__(self, socketio): self.env = None self.obs = None self.act = None + # Replay history published with the context, and the env it replays against. + self.replay_recorder = ReplayRecorder() + self.env_identity = {} self.listen = None self.local_assistant = None self.com = None @@ -91,6 +95,9 @@ def initialize_simulation(self, com, session): self.config['scenario_name'], self.env) self.env.set_id(id_scenario) # Scenario choice self.obs = self.env.reset() + # Reset the replay history for this new episode. + self.replay_recorder.reset() + self.env_identity = build_env_identity(self.config) logging.info("Loaded scenario: %s \n", self.env.chronics_handler.get_name()) session['message'].append(f"Loaded scenario: {self.env.chronics_handler.get_name()}") @@ -173,6 +180,9 @@ def run_simulator(self, com): topo_before = self.obs.topo_vect.copy() line_status_before = self.obs.line_status.copy() + # Record before applying, so the replay history stays gap-free. + self.replay_recorder.record(act) + # Beginning of step: observation update self.obs, _, done, info = self.env.step(act) @@ -284,7 +294,9 @@ def run_simulator(self, com): com.send_context_online(self.obs, self.config['scenario_first_step'], context_date, - img_b64_current) + img_b64_current, + self.env_identity, + self.replay_recorder) event_resolved_trigger = False context_just_sent = True @@ -307,7 +319,9 @@ def run_simulator(self, com): com.send_context_online(self.obs, self.config['scenario_first_step'], context_date, - img_b64_current) + img_b64_current, + self.env_identity, + self.replay_recorder) context_just_sent = True logging.info("Status: Overload detected on the network") @@ -384,7 +398,9 @@ def run_simulator(self, com): com.send_context_online(self.obs, self.config['scenario_first_step'], context_date, - img_b64_current) + img_b64_current, + self.env_identity, + self.replay_recorder) context_just_sent = True logging.info("Status: AI agent raised an alarm") @@ -420,7 +436,9 @@ def run_simulator(self, com): com.send_context_online(self.obs, self.config['scenario_first_step'], context_date, - img_b64_current) + img_b64_current, + self.env_identity, + self.replay_recorder) context_just_sent = True logging.info("Status: AI agent raised an alert") @@ -454,7 +472,9 @@ def run_simulator(self, com): com.send_context_online(self.obs, self.config['scenario_first_step'], context_date, - img_b64_current) + img_b64_current, + self.env_identity, + self.replay_recorder) context_just_sent = True logging.info( @@ -505,7 +525,9 @@ def run_simulator(self, com): com.send_context_online(self.obs, self.config['scenario_first_step'], context_date, - img_b64_current) + img_b64_current, + self.env_identity, + self.replay_recorder) context_just_sent = True logging.info("Status: Line loss detected: %s", diff --git a/usecases_examples/PowerGrid/app/models/env_serialization.py b/usecases_examples/PowerGrid/app/models/env_serialization.py new file mode 100644 index 00000000..f754e094 --- /dev/null +++ b/usecases_examples/PowerGrid/app/models/env_serialization.py @@ -0,0 +1,99 @@ +# Serializes the simulator's environment state (identity + action history) so +# consumers like A3S can rebuild it exactly by replay instead of approximating it. +import base64 +import gzip +import json +import logging + +# Must stay in sync with GRID2OP_OBSERVATION_V2 in the A3S service +# (a3s-service/agent_as_a_service/powergrid/serialization.py). +GRID2OP_OBSERVATION_V2 = "grid2op_observation_v2" + +# Framing of replay_actions, stamped in metadata. +REPLAY_COMPRESSION = "gzip+base64" + + +class ReplayRecorder: + """Records actions applied since the last reset; the history must be gap-free to replay.""" + + def __init__(self): + self._actions = [] + self._broken = False + + def reset(self) -> None: + self._actions = [] + self._broken = False + + def record(self, action) -> None: + """Appends an action; marks the recorder broken (for the rest of the episode) on failure.""" + if self._broken: + return + try: + self._actions.append({"vect": action.to_vect().tolist()}) + except Exception as e: + logging.error( + "Failed to record an action for replay; no environment state " + "will be published for the rest of this episode: %s", e) + self._actions = [] + self._broken = True + + @property + def actions(self) -> list: + return self._actions + + @property + def broken(self) -> bool: + return self._broken + + def __len__(self) -> int: + return len(self._actions) + + +def build_env_identity(config: dict) -> dict: + """Captures which environment (seed, scenario, library versions) to replay against.""" + identity = { + "seed": int(config["env_seed"]), + "scenario_name": str(config["scenario_name"]), + } + for name, package in (("grid2op_version", "grid2op"), + ("lightsim2grid_version", "lightsim2grid")): + try: + identity[name] = __import__(package).__version__ + except Exception as e: + logging.warning("Could not read the %s version: %s", package, e) + return identity + + +def encode_replay_actions(replay_actions: list) -> str: + """Compresses the action history to gzipped, base64-encoded JSON.""" + raw = json.dumps(replay_actions, separators=(",", ":")).encode("utf-8") + return base64.b64encode(gzip.compress(raw, 6)).decode("ascii") + + +def build_environment_state(obs, env_identity: dict, recorder: ReplayRecorder, + max_state_mb: float) -> dict: + """Builds the environment_state envelope, or None if there's nothing replayable or it's too large.""" + if recorder.broken or not len(recorder): + return None + + encoded = encode_replay_actions(recorder.actions) + size_mb = len(encoded) / (1024 * 1024) + if size_mb > max_state_mb: + logging.warning( + "Replay history dropped: %.1f MB over the %.1f MB cap (%d actions).", + size_mb, max_state_mb, len(recorder)) + return None + + return { + "serializer": GRID2OP_OBSERVATION_V2, + "state": { + "observation": obs.to_json(), + "replay_actions": encoded, + **env_identity, + }, + "metadata": { + "current_step": int(obs.current_step), + "replayed_actions": len(recorder), + "compression": REPLAY_COMPRESSION, + }, + } diff --git a/usecases_examples/PowerGrid/config/API_POWERGRID_CAB.toml b/usecases_examples/PowerGrid/config/API_POWERGRID_CAB.toml index 6fe1a593..ee008a94 100644 --- a/usecases_examples/PowerGrid/config/API_POWERGRID_CAB.toml +++ b/usecases_examples/PowerGrid/config/API_POWERGRID_CAB.toml @@ -19,6 +19,10 @@ event_port = "cab_event/api/v1/events" [Outputs.Context] context_port = "cabcontext/api/v1/contexts" tempo = 30 +# Publish environment_state (identity + replay history) alongside the observation. +serialize_env_state = "yes" +# Cap on the published (compressed) history size, in MB. +max_state_mb = 32 [Inputs.Act] url = "http://127.0.0.1:5000/api/v1/recommendations" diff --git a/usecases_examples/PowerGrid/tests/test_env_serialization.py b/usecases_examples/PowerGrid/tests/test_env_serialization.py new file mode 100644 index 00000000..75047c95 --- /dev/null +++ b/usecases_examples/PowerGrid/tests/test_env_serialization.py @@ -0,0 +1,125 @@ +# Tests the producer side of environment_state, without the Grid2Op stack. +# Run from usecases_examples/PowerGrid: python -m pytest tests -q +import base64 +import gzip +import json + +from app.models.env_serialization import ( + GRID2OP_OBSERVATION_V2, + REPLAY_COMPRESSION, + ReplayRecorder, + build_env_identity, + build_environment_state, +) + +CONFIG = {"env_seed": "2118338672", "scenario_name": "jan_28_1"} + + +class StubAction: + """Stands in for a Grid2Op action, vectorizable or not.""" + + def __init__(self, vector: list, vectorizable: bool): + self._vector = vector + self._vectorizable = vectorizable + + def to_vect(self): + if not self._vectorizable: + raise RuntimeError("cannot vectorize this action") + return _Vector(self._vector) + + +class _Vector: + """Minimal stand-in for the numpy array to_vect() returns.""" + + def __init__(self, values: list): + self._values = values + + def tolist(self) -> list: + return list(self._values) + + +class StubObservation: + """Stands in for the Grid2Op observation being published.""" + + current_step = 12 + + def to_json(self) -> dict: + return {"current_step": [self.current_step]} + + +def _decode(encoded: str) -> list: + """Decodes a published history back into a list of actions.""" + return json.loads(gzip.decompress(base64.b64decode(encoded)).decode("utf-8")) + + +def test_the_published_state_carries_the_history_and_the_identity(): + recorder = ReplayRecorder() + for step in range(3): + recorder.record(StubAction([float(step), 0.0], vectorizable=True)) + + state = build_environment_state( + StubObservation(), build_env_identity(CONFIG), recorder, 32 + ) + + assert state["serializer"] == GRID2OP_OBSERVATION_V2 + assert state["metadata"]["compression"] == REPLAY_COMPRESSION + assert state["metadata"]["replayed_actions"] == 3 + assert state["state"]["seed"] == 2118338672 + assert state["state"]["scenario_name"] == "jan_28_1" + assert _decode(state["state"]["replay_actions"]) == [ + {"vect": [0.0, 0.0]}, + {"vect": [1.0, 0.0]}, + {"vect": [2.0, 0.0]}, + ] + + +def test_a_failed_recording_publishes_nothing_for_the_rest_of_the_episode(): + recorder = ReplayRecorder() + recorder.record(StubAction([1.0], vectorizable=True)) + recorder.record(StubAction([2.0], vectorizable=False)) + recorder.record(StubAction([3.0], vectorizable=True)) + + assert recorder.broken + assert len(recorder) == 0 + assert ( + build_environment_state( + StubObservation(), build_env_identity(CONFIG), recorder, 32 + ) + is None + ) + + # A new episode records again. + recorder.reset() + recorder.record(StubAction([4.0], vectorizable=True)) + + assert not recorder.broken + assert len(recorder) == 1 + + +def test_an_empty_history_publishes_nothing(): + assert ( + build_environment_state( + StubObservation(), build_env_identity(CONFIG), ReplayRecorder(), 32 + ) + is None + ) + + +def test_an_oversized_history_is_dropped(): + recorder = ReplayRecorder() + for step in range(50): + recorder.record(StubAction([float(step)], vectorizable=True)) + + assert ( + build_environment_state( + StubObservation(), build_env_identity(CONFIG), recorder, 0.0 + ) + is None + ) + + +def test_the_identity_reports_the_libraries_it_can_read(): + identity = build_env_identity(CONFIG) + + assert identity["seed"] == 2118338672 + assert identity["scenario_name"] == "jan_28_1" From 7ce6315e422de5911f17b3e0d9133a93e6bffe4d Mon Sep 17 00:00:00 2001 From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com> Date: Tue, 15 Sep 2026 08:46:37 +0200 Subject: [PATCH 2/8] refactor: update installation instructions --- .gitignore | 3 + README.md | 279 +++++++------------ config/dev/cab-standalone/.secrets.example | 3 +- config/dev/cab-standalone/docker-compose.sh | 15 +- config/dev/cab-standalone/docker-compose.yml | 20 -- local_setup.sh | 122 ++++---- 6 files changed, 171 insertions(+), 271 deletions(-) diff --git a/.gitignore b/.gitignore index c473bc79..c9481f62 100644 --- a/.gitignore +++ b/.gitignore @@ -199,3 +199,6 @@ go.sh !frontend/env/ !frontend/env/.env /config/dev/cab-standalone/.secrets.pre-simplify + +# The deep expert agent, cloned here per the README — a separate repository. +/T2.1_deep_expert/ diff --git a/README.md b/README.md index 349f0c69..4f82d389 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,12 @@ _Backend_ Getting Started -
  • Usage
  • Development
  • Docs
  • @@ -48,164 +50,114 @@ The platform uses the project **OperatorFabric** for notification management. ### Prerequisites -- [Git (version 2.40.1)](https://git-scm.com/) -- [Docker Engine (version 27)](https://www.docker.com/) -- [Docker Compose V2](https://www.docker.com/) +- Git, Docker Engine 27+, Docker Compose V2, `curl`, `python3` - -### Setting Up the Environment - -Clone the repo of the assistant +### Install ```sh -git clone [repo-url] +git clone [repo-url] && cd InteractiveAI +cp config/dev/cab-standalone/.secrets.example config/dev/cab-standalone/.secrets +# edit .secrets — at minimum RL_AGENT_API_URL / RL_AGENT_API_TOKEN, see "Configuration" +./local_setup.sh ``` -## Usage - -InteractiveAI offers versatile deployment options, leveraging either Docker or Kubernetes. The primary method entails initiating InteractiveAI via Docker to launch all services concurrently. However, recognizing potential resource strain in this mode, we've introduced alternative configurations. These configurations enable selective startup of essential services with minimal dependencies, catering to streamlined versions of certain APIs. -Below are the steps to start all services. For other methods, please consult the developer guide. - -### Running All Services (Dev Mode) - -1. **Set-up environment variables** - -Configuration is read from a gitignored `.secrets` file that `docker-compose.sh` sources. -Copy the template and fill in your values: +To use the local [A3S](a3s-service/README.md) service instead of a remote RL agent, +start it first and add `--a3s` — no `.secrets` change needed: ```sh -cd config/dev/cab-standalone -cp .secrets.example .secrets -# then edit .secrets +cd a3s-service && ./docker/local_setup.sh && cd .. # see a3s-service/README.md +./local_setup.sh --a3s ``` -Key variables (see `.secrets.example` for all options and per-environment values): - -- `VITE_POWERGRID_SIMU` — the frontend's simulator endpoint. Use the same-origin proxy - value `/powergrid-simu` (avoids CORS); set it to `false` to disable the PowerGrid UI. +`local_setup.sh` starts the backend, configures Keycloak, loads the OperatorFabric +resources, rebuilds the frontend and recommendation service from this source tree, +builds and starts the PowerGrid simulator, and verifies the recommendation path end +to end. It prints the URLs and credentials when it is done. + +| Flag | Effect | +| --- | --- | +| *(none)* | full setup; asks what to do if containers from a previous run are up | +| `--clean` | tear those containers down first, no prompt | +| `--wipe` | tear down containers **and** volumes, no prompt | +| `--a3s [URL]` | take recommendations from an already-running [A3S](a3s-service/README.md); default URL `http://host.docker.internal:5010/api/v1/recommendation` | + +It never starts A3S — start that yourself first, or `--a3s` aborts before touching +any container. + +Then log in at http://localhost:3200 as `powergrid_user` / `test`, and in the +simulator (http://localhost:5122) pick server `http://host.docker.internal:3200/`. +Stop everything with `./local_stop.sh` (`--wipe` to drop the data volumes too). + +The last step prints a warning for anything it could not verify — a stale nginx +upstream, an unreachable agent, a simulator/A3S payload mismatch. The UI still comes +up; the PowerGrid recommendation panel is what stays empty. + +### Configuration + +Everything lives in `config/dev/cab-standalone/.secrets` (gitignored, +`docker-compose.sh` sources it). The values that matter: + +- `RL_AGENT_API_URL` / `RL_AGENT_API_TOKEN` — the agent producing PowerGrid + recommendations. Options: + - the **deep expert agent** on this host: `http://host.docker.internal:5123/api/v1/recommendation` + (see [below](#the-powergrid-expert-agent-api)); it requires a token + - the hosted one: `https://interactiveagent.passerelle.irt-systemx.fr/api/v1/recommendation`, + also with a token + - a local **[A3S](a3s-service/README.md)** — no token, and no need to set the URL: + `./local_setup.sh --a3s` overrides it for that run +- `POWERGRID_SIMU_UPSTREAM` — where nginx forwards `/powergrid-simu/`. Local dev: + `http://host.docker.internal:5122/`. +- `VITE_POWERGRID_SIMU` — the frontend's simulator endpoint; keep the same-origin + proxy value `/powergrid-simu` (avoids CORS), or `false` to hide the PowerGrid UI. `VITE_RAILWAY_SIMU` / `VITE_ATM_SIMU` are the equivalents for the other use cases. -- `POWERGRID_SIMU_UPSTREAM` — where nginx actually forwards `/powergrid-simu/`: - - Local dev : `http://host.docker.internal:5122/` (simulator container on the host) - - LAN : `http://192.168.208.61:5100/` - - Public/k8s: same variable, set as an env var on the **frontend pod** (see - `deploy-chart/values.ovh.yaml`). -- `COGNITIVE_TOKEN` — bearer token for the INESCTEC cognitive API. nginx attaches it to - every `/cognitive-api/` request, so the frontend never sees it. It used to be - `VITE_COGNITIVE_TOKEN`, a build-time value inlined into the public JS bundle; that meant - any visitor could read it and rotating it required a full image rebuild. -- `RL_AGENT_API_URL` / `RL_AGENT_API_TOKEN` — the deep expert agent that powers PowerGrid - recommendations (see [The PowerGrid expert agent API](#the-powergrid-expert-agent-api) below to - install it). A token is required in every mode: - - Local dev : `http://host.docker.internal:5123/api/v1/recommendation` (agent on the host) - - Server : `http://192.168.208.61:5000/api/v1/recommendation` - - Public : `https://interactiveagent.passerelle.irt-systemx.fr/api/v1/recommendation` - -> **_NOTE:_** `host.docker.internal` lets the containers reach services (simulator, expert agent) -> running on the host — this is how local dev connects to them. Make sure those host services -> listen on `0.0.0.0` (not only `127.0.0.1`) so the containers can reach them. -> -> **_NOTE:_** For the simulator itself, you can use the example we provide — follow the tutorial -> in [InteractiveAI/usecases_examples/PowerGrid/](/usecases_examples/PowerGrid/README.md). -> -> -### How runtime nginx configuration works - -`POWERGRID_SIMU_UPSTREAM` and `COGNITIVE_TOKEN` are **runtime** values, not -build-time ones. They appear in the nginx config as `__NAME__` placeholders, and -`frontend/start-webui.sh` substitutes them from the matching env var when the container -starts. Changing one is: update the env var (or the k8s secret) and restart the -frontend — no image rebuild. - -To add another: give it a default in `start-webui.sh`, append its name to `SUBST_VARS`, and -use `__NAME__` in the config. If a placeholder survives substitution the container exits -with the name of the missing variable, and the generated config is checked with `nginx -t` -before the daemon starts — so a misconfiguration fails loudly at startup instead of -producing a silently broken proxy. - -`REQUIRED_VARS` (space- or comma-separated) lists the variables that must be **non-empty**; -an empty one aborts startup. It is opt-in because an absent value is not always wrong — -local dev runs the whole stack with no cognitive token and just loses that panel — whereas -on a public deploy an empty token means nginx sends `Bearer ` with nothing after it and -every `/cognitive-api/` call 401s while the pod still reports itself healthy. -`deploy-chart/values.ovh.yaml` therefore sets `REQUIRED_VARS=COGNITIVE_TOKEN`, so the pod -crashloops with the reason in its log and k8s keeps the previous pod serving. - -Two ordering rules follow from all of this, and breaking the first is what silently broke -`/cognitive-api/` once already: - -- **Never push a conf ahead of the pod that has to substitute it.** A placeholder the - running image does not know is left in the config *literally* and goes out in the proxied - request. `deploy-chart/apply-nginx-conf.sh` now refuses to push in that case: it checks - every `__NAME__` in the conf against the deployment's env, and resolves `secretKeyRef`s - to confirm the secret and key actually exist. -- **Verify the config nginx loaded, not the ConfigMap.** nginx runs with an explicit - `-c /personal-conf/nginx.conf`; a bare `nginx -T` re-reads `/etc/nginx/nginx.conf` and the - raw ConfigMap mount, where `proxy_pass __POWERGRID_SIMU_UPSTREAM__;` is not a valid URL — - so it exits non-zero and prints nothing, which reads as a missing location. - -Two things to keep in mind: - -- **In k8s the config does not come from the image.** The `cab-assistant-platform-config` - ConfigMap is mounted over `/etc/nginx/conf.d` and **overrides** the `default.conf` baked - into the image, so every placeholder and every `location` must be present in the ConfigMap - too (`deploy-chart/apply-nginx-conf.sh` pushes just that key). A missing - `/powergrid-simu/` location, for instance, lets the apply POST fall through to the static - `location /`, and nginx answers 405. -- **nginx reads `conf.d` only at startup**, so restart the frontend after any change: - `kubectl -n cab rollout restart deploy/cab-frontend`. - -2. **Run InteractiveAI assistant** -```sh -cd config/dev/cab-standalone -./docker-compose.sh -``` -> **_NOTE:_** You will see the word cab on most files in the project. Note that it was the initial project name of InteractiveAI. Might be updated later. - -3. **Setting up Keycloak `Frontend URL`** - * Access Keycloak Interface: - - Ensure that your Keycloak instance is running and accessible. - - Open a web browser and navigate to the Keycloak admin console, typically available at `http://localhost:89/auth/admin`. - * Login to Keycloak Admin Console: - - Log in to the Keycloak admin console using your administrator credentials (`admin:admin` by default) - * Configure frontendUrl: - - On the Keycloak admin console, locate and click on the "Realm Settings" section. - - In the Frontend URL field, add the URL of InteractiveAI frontend. If your frontend is hosted locally for development purposes, you might add `http://localhost:3200/`. - - After adding the frontend URL, save the changes. - * Configure Valid Redirect URIs: - - On the Keycloak admin console, locate and click on the "Clients" section. - - Select the client (opfab-client). - - Within the client settings, look for the "Valid Redirect URIs" field. - - Add the URL of the frontend with /*, if it's local deployment: `http://localhost:3200/*`. - - After adding the Valid Redirect URIs, save the changes to update the client settings. - - -4. **Load resources** - -**WARNING:** You need to restart the frontend after updating the URL on keycloak do it before loading the resources. -```sh -docker restart frontend -``` - -```sh -cd resources -./loadTestConf.sh -``` - -5. If you encounter CORS errors (which can happen if you start the platform in a non-HTTPS environment), you can start your browser with security mode disabled. - -```sh -your-chromium-browser --disable-web-security --user-data-dir="[some directory here]" # replace your-chromium-browser with your browser -``` - -> **_NOTE:_** If you encounter any issues, please refer to our [troubleshooting guide](docs/troubleshooting.md). +- `COGNITIVE_TOKEN` — bearer token for the INESCTEC cognitive API. nginx attaches it + to every `/cognitive-api/` request, so it never reaches the browser. Empty is fine + locally; you just lose that panel. + +`host.docker.internal` is how the containers reach services on the host (simulator, +agent). Those services must listen on `0.0.0.0`, not only `127.0.0.1`. + +If you hit CORS errors (the platform running without HTTPS), start a Chromium +browser with `--disable-web-security --user-data-dir="[some directory]"`. + +Anything else: [troubleshooting guide](docs/troubleshooting.md). + +### Manual setup + +The same steps by hand, in order. `local_setup.sh` does all of them for you — use +this only when you need to run one in isolation. + +1. **Backend** — `cd config/dev/cab-standalone && ./docker-compose.sh` + (it writes `.env` from `.secrets` and brings the compose project up). +2. **Rebuild from source** — `docker compose up -d --build --force-recreate frontend + cabrecommendation`, in the same directory. Step 1 reuses existing images, so + without this your local changes are not in the running containers. +3. **Keycloak** — in the admin console (http://localhost:89/auth/admin, + `admin`/`admin`), realm `dev`: set **Realm Settings → Frontend URL** to + `http://localhost:3200/`, and add `http://localhost:3200/*` to + **Clients → opfab-client → Valid Redirect URIs**. +4. **Restart the frontend** — `docker restart frontend`, so it picks up that change. + Required before the next step. +5. **Resources** — `cd resources && ./loadTestConf.sh` (registers the use cases). +6. **Simulator** — `cd usecases_examples/PowerGrid && docker compose -f + docker-compose.local.yml up -d --build app`. Use the `.local` compose file: the + default one is the server config and binds the wrong port. See its + [README](/usecases_examples/PowerGrid/README.md). +7. **Reload the gateway** — `docker exec frontend nginx -c /personal-conf/nginx.conf + -s reload`. nginx caches upstream IPs at load, so containers recreated after it + started answer 502 until this is done. + +Check it: `curl localhost:3200/cab_recommendation/api/v1/health` should answer 200, +and the recommendation service must be able to reach `RL_AGENT_API_URL` from inside +its own container. + +> **_NOTE:_** `cab` appears all over the project — it was the original name of +> InteractiveAI. ### The PowerGrid expert agent API -PowerGrid recommendations are produced by a separate service — the **deep expert agent**. The -`cab_recommendation` service calls it at `RL_AGENT_API_URL`, so it must be running (and reachable) -for recommendations to appear in InteractiveAI. - -1. Clone the agent repository and check out the API branch: +`cab_recommendation` calls the deep expert agent at `RL_AGENT_API_URL`, so that agent +must be running for recommendations to appear. ```sh git clone https://github.com/ainetus/T2.1_deep_expert.git @@ -213,23 +165,17 @@ cd T2.1_deep_expert git checkout feat/api-auth-compose ``` -2. Start it by following that repository's README (the `feat/api-auth-compose` branch ships a - Docker Compose and adds token authentication). For local development: - - expose it on port **5123**, and - - make it listen on `0.0.0.0` (not only `127.0.0.1`) so the InteractiveAI containers can reach - it through `host.docker.internal`. - -3. Point InteractiveAI at it in `config/dev/cab-standalone/.secrets`, with a token that matches - the one the agent expects: +Start it per that repo's README (that branch ships a Docker Compose and token auth), +on port **5123** and bound to `0.0.0.0`. Then set in `.secrets`: ```sh export RL_AGENT_API_URL=http://host.docker.internal:5123/api/v1/recommendation export RL_AGENT_API_TOKEN= ``` -Then (re)run `./docker-compose.sh` so `cab_recommendation` picks up the values. For the LAN and -public deployments, use the corresponding `RL_AGENT_API_URL` from step 1 of -[Running All Services](#running-all-services-dev-mode) instead. +The local alternative is [A3S](a3s-service/README.md), which serves the same API and +can project KPIs several timesteps ahead: start it from `a3s-service/` with +`./docker/local_setup.sh`, then run `./local_setup.sh --a3s` here. ### Default ports @@ -244,6 +190,7 @@ Companion services for the PowerGrid use case run on the host (local dev) and ar containers via `host.docker.internal`: * PowerGrid simulator (provided example): 5122 * PowerGrid expert agent API: 5123 +* A3S (if used instead of the expert agent): 5010 ### Authentication data @@ -262,22 +209,6 @@ Some examples of credentials: By default, the system allows the user to be connected only from a single machine. Which means if you try to connect using the same credentials from another machine, you will be disconnected on the first machine. -### OPTIONAL: connecting a local A3S service - -[A3S](a3s-service/README.md) is a local drop-in replacement for the external RL agent API, -also able to project KPIs several timesteps ahead. - -```bash -USE_A3S=1 ./local_setup.sh # full local stack, A3S instead of the remote RL agent -curl localhost:5010/api/v1/health # {"message": "Ok"} -``` - -To iterate on A3S alone, run `./docker/local_setup.sh` from `a3s-service/`, then set -`RL_AGENT_API_URL=http://host.docker.internal:5010/api/v1/recommendation` in -`config/dev/cab-standalone/.secrets` and re-run `./docker-compose.sh` there. The same override -connects any other agent exposing that contract, e.g. a local -[T2.1_deep_expert](https://github.com/ainetus/T2.1_deep_expert) build. Stop A3S with `./local_stop.sh`. - # Development Contributions to the InteractiveAI Assistant Platform are welcome! To contribute, please make sure to use [developer guide](docs/developer-guide.md) diff --git a/config/dev/cab-standalone/.secrets.example b/config/dev/cab-standalone/.secrets.example index c21a7d7d..a8da8ece 100644 --- a/config/dev/cab-standalone/.secrets.example +++ b/config/dev/cab-standalone/.secrets.example @@ -1,8 +1,7 @@ # Copy to .secrets and fill in your values — this file is gitignored # -# export USE_A3S=1 # use the local A3S service instead of the external RL agent -# # RL agent (T2.1_deep_expert) — pick ONE RL_AGENT_API_URL for your environment: +# To use a local A3S instead, do not set this: run ../../../local_setup.sh --a3s. # Local dev : RL agent running on THIS host on port 5123, reached via host.docker.internal # (cab_recommendation has the host-gateway mapping) # Server : RL agent on the LAN host diff --git a/config/dev/cab-standalone/docker-compose.sh b/config/dev/cab-standalone/docker-compose.sh index e2e0f895..1157bf8e 100755 --- a/config/dev/cab-standalone/docker-compose.sh +++ b/config/dev/cab-standalone/docker-compose.sh @@ -45,13 +45,14 @@ echo "HOST_IP=${HOST_IP}" >> .env # Secrets — sourced from .secrets if present (gitignored), otherwise from shell env # In CI these are injected by GitHub Actions as environment variables. +# An RL_AGENT_API_URL already in the environment wins over the file: that is how +# `local_setup.sh --a3s` points this stack at a running A3S without editing +# .secrets, and it would otherwise be silently overwritten here. +_ENV_RL_AGENT_API_URL="${RL_AGENT_API_URL:-}" if [[ -f .secrets ]]; then source .secrets fi -# USE_A3S=1 points RL_AGENT_API_URL at the local A3S service, unless already set. -if [[ "${USE_A3S:-0}" == "1" && -z "${RL_AGENT_API_URL:-}" ]]; then - RL_AGENT_API_URL="http://caba3s:5010/api/v1/recommendation" -fi +RL_AGENT_API_URL="${_ENV_RL_AGENT_API_URL:-${RL_AGENT_API_URL:-}}" echo "RL_AGENT_API_URL=${RL_AGENT_API_URL:-https://interactiveagent.passerelle.irt-systemx.fr/api/v1/recommendation}" >> .env echo "RL_AGENT_API_TOKEN=${RL_AGENT_API_TOKEN:-}" >> .env echo "VITE_POWERGRID_SIMU=${VITE_POWERGRID_SIMU:-/powergrid-simu}" >> .env @@ -63,8 +64,4 @@ echo "COGNITIVE_TOKEN=${COGNITIVE_TOKEN:-}" >> .env # terminal (and in any CI log that runs this script). sed -E 's/^([A-Z_]*(TOKEN|SECRET|PASSWORD)[A-Z_]*)=(.+)$/\1=/; s/^([A-Z_]*(TOKEN|SECRET|PASSWORD)[A-Z_]*)=$/\1=/' .env -if [[ "${USE_A3S:-0}" == "1" ]]; then - docker compose --profile a3s up -d -else - docker compose up -d -fi +docker compose up -d diff --git a/config/dev/cab-standalone/docker-compose.yml b/config/dev/cab-standalone/docker-compose.yml index ca02c70b..cbfa1b6a 100644 --- a/config/dev/cab-standalone/docker-compose.yml +++ b/config/dev/cab-standalone/docker-compose.yml @@ -199,26 +199,6 @@ services: depends_on: - db_postgres_recommendation - # Local A3S (agent-as-a-service) instance. Opt-in via the "a3s" profile. - caba3s: - container_name: cab_a3s - image: cab/caba3s - profiles: ["a3s"] - build: - context: ../../../a3s-service - dockerfile: docker/Dockerfile - args: - # Baked-in default policy; overridable via `environment:` below without a rebuild. - A3S_POWERGRID_AGENT: ${A3S_POWERGRID_AGENT:-t2.1} - restart: unless-stopped - environment: - - FLASK_APP=app:create_app('dev') - - TZ=UTC - # Which policy serves PowerGrid: "t2.1" (default) or "xd". - - A3S_POWERGRID_AGENT=${A3S_POWERGRID_AGENT:-t2.1} - ports: - - 5010:5010 - db_postgres_recommendation: container_name: db_postgres_recommendation image: postgres:14.7 diff --git a/local_setup.sh b/local_setup.sh index b5ef494f..86768583 100755 --- a/local_setup.sh +++ b/local_setup.sh @@ -11,12 +11,17 @@ # ./local_setup.sh # full setup (prompts if containers already run) # ./local_setup.sh --clean # tear down existing containers first, no prompt # ./local_setup.sh --wipe # tear down existing containers AND volumes, no prompt +# ./local_setup.sh --a3s [URL] # take recommendations from an already-running A3S +# # (default URL http://host.docker.internal:5010/api/v1/recommendation) +# +# This script never starts A3S. Start it first from a3s-service/ with +# ./docker/local_setup.sh, then pass --a3s here. # # Overridable via environment: # KC_ADMIN (admin) KC_PW (admin) FRONTEND_URL (http://localhost:3200) # -# Secrets (RL_AGENT_API_URL / RL_AGENT_API_TOKEN / VITE_COGNITIVE_TOKEN / USE_A3S) -# are read from config/dev/cab-standalone/.secrets if present (see .secrets.example). +# Secrets (RL_AGENT_API_URL / RL_AGENT_API_TOKEN / COGNITIVE_TOKEN) are read from +# config/dev/cab-standalone/.secrets if present (see .secrets.example). set -euo pipefail @@ -29,7 +34,9 @@ RESOURCES_DIR="$REPO_ROOT/resources" SIM_DIR="$REPO_ROOT/usecases_examples/PowerGrid" SIM_COMPOSE="docker-compose.local.yml" # server config lives in docker-compose.yml SIM_PORT=5122 # must match POWERGRID_SIMU_UPSTREAM in .env -A3S_PORT=5010 # host port the compose `caba3s` service publishes +# Where --a3s points the recommendation service when no URL is given. host.docker.internal +# is how the containers reach a service published on the host. +A3S_DEFAULT_URL="http://host.docker.internal:5010/api/v1/recommendation" # Serializer name the simulator stamps into environment_state; must match what A3S accepts. SIM_SERIALIZER_SRC="$REPO_ROOT/usecases_examples/PowerGrid/app/models/env_serialization.py" @@ -89,30 +96,22 @@ frontend_nginx_reload() { fi } -# A standalone a3s-service/docker/local_setup.sh can grab port 5010 before the -# compose `caba3s` service starts, leaving it unable to bind. Free the port, -# unless RL_AGENT_API_URL is set explicitly (the operator is pointing elsewhere). -free_a3s_port() { - [[ "${USE_A3S:-0}" == "1" ]] || return 0 - if [[ -n "${RL_AGENT_API_URL:-}" ]]; then - warn "both USE_A3S=1 and RL_AGENT_API_URL are set in .secrets — the explicit URL wins" - warn " using: $RL_AGENT_API_URL" - warn " cab_a3s will still be built and started, but nothing will call it" - warn " for a self-contained stack, comment RL_AGENT_API_URL out and keep USE_A3S=1" - ok "leaving port $A3S_PORT and any standalone A3S as-is" +# --a3s: A3S runs on its own (a3s-service/docker/local_setup.sh), so all this +# does is point the recommendation service at it — after confirming it answers, +# because otherwise the only symptom is an empty recommendation panel at the end +# of a ten-minute setup. +require_a3s() { + local health="${RL_AGENT_API_URL%/recommendation}/health" + # The containers reach it via host.docker.internal; from here it is localhost. + local host_health="${health/host.docker.internal/localhost}" + if wait_for_http "$host_health" 200 3 >/dev/null; then + ok "A3S is answering at $host_health" return 0 fi - local holder - holder="$(docker ps --format '{{.Names}}\t{{.Ports}}' \ - | awk -F'\t' -v p=":$A3S_PORT->" 'index($2, p) {print $1}' \ - | grep -vx cab_a3s || true)" - [[ -z "$holder" ]] && return 0 - warn "port $A3S_PORT is needed by cab_a3s but is held by container '$holder'" - warn "(that is the standalone A3S from a3s-service/docker/local_setup.sh — this stack builds its own)" - local c - for c in $holder; do - docker stop "$c" >/dev/null 2>&1 && ok "stopped '$c' to free port $A3S_PORT (start it again later if you need it)" - done + warn "no A3S answering at $host_health" + warn " start it first: cd $REPO_ROOT/a3s-service && ./docker/local_setup.sh" + warn " or pass the URL: ./local_setup.sh --a3s http://host.docker.internal:/api/v1/recommendation" + die "A3S is not running" } # Checks the gateway route, the RL agent, and the simulator/A3S serializer contract. @@ -127,19 +126,7 @@ verify_recommendation_path() { failures=$(( failures + 1 )) fi - if [[ "${USE_A3S:-0}" == "1" && -z "${RL_AGENT_API_URL:-}" ]]; then - if ! container_running cab_a3s; then - warn "USE_A3S=1 but the cab_a3s container is not running" - warn " check: cd $BACKEND_DIR && docker compose --profile a3s logs caba3s" - failures=$(( failures + 1 )) - elif wait_for_http "http://localhost:$A3S_PORT/api/v1/health" 200 15 >/dev/null; then - ok "A3S is healthy on port $A3S_PORT" - verify_serializer_contract cab_a3s || failures=$(( failures + 1 )) - else - warn "cab_a3s is running but not answering on port $A3S_PORT" - failures=$(( failures + 1 )) - fi - elif [[ -n "${RL_AGENT_API_URL:-}" ]]; then + if [[ -n "${RL_AGENT_API_URL:-}" ]]; then # Resolve from inside the recommendation container, whose network namespace RL_AGENT_API_URL targets. local health_url="${RL_AGENT_API_URL%/recommendation}/health" if container_running cab_recommendation && docker exec cab_recommendation python -c " @@ -169,7 +156,7 @@ except Exception as e: failures=$(( failures + 1 )) fi else - warn "neither USE_A3S=1 nor RL_AGENT_API_URL is set — only the ontology recommender will run" + warn "RL_AGENT_API_URL is not set — only the ontology recommender will run" fi return "$failures" @@ -184,7 +171,7 @@ verify_serializer_contract() { emitted="$(grep -oE "^${emitted} = \"[a-z0-9_]+\"" "$SIM_SERIALIZER_SRC" | sed 's/.*"\(.*\)"/\1/' | head -1)" [[ -n "$emitted" ]] || { warn "could not resolve the simulator's serializer constant"; return 0; } - accepted="$(docker exec "$a3s" sh -c 'grep -hoE "grid2op_observation_v[0-9]+" /my_app/agent_as_a_service/powergrid/serialization.py 2>/dev/null | sort -u' 2>/dev/null || true)" + accepted="$(docker exec "$a3s" sh -c 'grep -hoE "grid2op_observation_v[0-9]+" /my_app/integrations/powergrid/serialization.py 2>/dev/null | sort -u' 2>/dev/null || true)" if [[ -z "$accepted" ]]; then warn "could not read the serializers '$a3s' accepts — skipping the contract check" return 0 @@ -197,11 +184,7 @@ verify_serializer_contract() { warn " simulator emits: $emitted" warn " '$a3s' accepts: $(tr '\n' ' ' <<<"$accepted")" warn " that A3S image predates the simulator's payload format — rebuild it:" - if [[ "$a3s" == "cab_a3s" ]]; then - warn " cd $BACKEND_DIR && docker compose --profile a3s up -d --build --force-recreate caba3s" - else - warn " cd $REPO_ROOT/a3s-service && ./docker/local_setup.sh --rebuild" - fi + warn " cd $REPO_ROOT/a3s-service && ./docker/local_setup.sh --rebuild" return 1 } @@ -312,14 +295,20 @@ handle_existing_containers() { # Main # --------------------------------------------------------------------------- main() { - local CLEAN_MODE="" - for arg in "$@"; do - case "$arg" in + # Deliberately not named USE_A3S: .secrets is sourced into this scope below, + # and an old `export USE_A3S=1` there would silently turn this flag on. + local CLEAN_MODE="" A3S_MODE=0 A3S_URL="" + while (( $# )); do + case "$1" in --clean) CLEAN_MODE="clean" ;; --wipe) CLEAN_MODE="wipe" ;; - -h|--help) sed -n '3,26p' "${BASH_SOURCE[0]}"; exit 0 ;; - *) die "unknown argument: $arg (see --help)" ;; + # --a3s takes an optional URL: anything that is not another flag. + --a3s) A3S_MODE=1 + [[ "${2:-}" == -* || -z "${2:-}" ]] || { A3S_URL="$2"; shift; } ;; + -h|--help) sed -n '3,24p' "${BASH_SOURCE[0]}"; exit 0 ;; + *) die "unknown argument: $1 (see --help)" ;; esac + shift done log "Checking prerequisites" @@ -327,8 +316,8 @@ main() { docker compose version >/dev/null 2>&1 || die "'docker compose' (v2) is required." ok "docker, docker compose, curl, python3 present" - # Source .secrets in THIS shell (docker-compose.sh sources it in a subshell), - # so USE_A3S is visible to the rebuild check further down. + # Source .secrets in THIS shell too (docker-compose.sh sources it in a + # subshell), so the verification step below knows which agent is configured. if [[ -f "$BACKEND_DIR/.secrets" ]]; then # shellcheck disable=SC1091 # path is runtime-resolved, gitignored source "$BACKEND_DIR/.secrets" @@ -337,27 +326,28 @@ main() { warn "copy .secrets.example to .secrets to override (see docker-compose.sh)" fi + # --a3s wins over whatever .secrets says: it is the more explicit statement of + # where recommendations come from for this run. docker-compose.sh sources + # .secrets in its own shell, so export it rather than just setting it. + if (( A3S_MODE )); then + log "Using A3S for recommendations" + export RL_AGENT_API_URL="${A3S_URL:-$A3S_DEFAULT_URL}" + require_a3s + ok "recommendation service will call $RL_AGENT_API_URL" + fi + log "Checking for existing containers" handle_existing_containers "$CLEAN_MODE" - log "Checking the A3S port is available" - free_a3s_port - ok "port $A3S_PORT ready for cab_a3s" - log "Step 1/6 — Starting the InteractiveAI backend" ( cd "$BACKEND_DIR" && ./docker-compose.sh ) ok "backend compose brought up" - # Force a rebuild from this repo's source (docker-compose.sh's `up -d` reuses - # existing images as-is); caba3s only when USE_A3S=1 brought it up. - local rebuild=(frontend cabrecommendation) profile=() - if [[ "${USE_A3S:-0}" == "1" ]]; then - rebuild+=(caba3s) - profile=(--profile a3s) - fi - log "Rebuilding ${rebuild[*]} from this repo's source" - ( cd "$BACKEND_DIR" && docker compose "${profile[@]}" up -d --build --force-recreate "${rebuild[@]}" ) - ok "${rebuild[*]} rebuilt from source" + # Force a rebuild from this repo's source: docker-compose.sh's `up -d` reuses + # existing images as-is, so edits here would otherwise not reach the containers. + log "Rebuilding frontend and cabrecommendation from this repo's source" + ( cd "$BACKEND_DIR" && docker compose up -d --build --force-recreate frontend cabrecommendation ) + ok "frontend, cabrecommendation rebuilt from source" log "Step 2/6 — Waiting for Keycloak" wait_for_http "$KC_BASE/realms/master" 200 || die "Keycloak did not come up on :89" From 3b16b6b36b73f312bab1336e2f8b1199fce91c62 Mon Sep 17 00:00:00 2001 From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:33:44 +0200 Subject: [PATCH 3/8] refactor: add local credentials in local_setup.sh --- local_setup.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/local_setup.sh b/local_setup.sh index 86768583..3f134797 100755 --- a/local_setup.sh +++ b/local_setup.sh @@ -395,7 +395,7 @@ main() { printf '\n\033[1;32mSetup complete.\033[0m\n\n' # powergrid_user is provisioned with the PowerGrid entity; publisher_test isn't. printf ' InteractiveAI UI %s (powergrid_user / test)\n' "$(link "$FRONTEND_URL")" - printf ' PowerGrid simulator %s (also proxied same-origin at %s/powergrid-simu/)\n' "$(link "http://localhost:$SIM_PORT")" "$FRONTEND_URL" + printf ' PowerGrid simulator %s (powergrid_user / test) (also proxied same-origin at %s/powergrid-simu/)\n' "$(link "http://localhost:$SIM_PORT")" "$FRONTEND_URL" printf ' Keycloak admin %s (admin / admin)\n' "$(link "$KC_BASE/admin")" printf '\n In the simulator, pick server %s and log in.\n' "$(link "http://host.docker.internal:3200/")" } From 579480825a85ffafe7e5a586dfa72b04a5b8b317 Mon Sep 17 00:00:00 2001 From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:57:45 +0200 Subject: [PATCH 4/8] refactor: add support for A3S --- .gitignore | 2 + README.md | 16 + .../cab_common/cab_common_auth/settings.py | 2 + .../resources/PowerGrid/schemas.py | 2 + .../recommendation-service/api/exceptions.py | 13 + backend/recommendation-service/api/schemas.py | 5 + .../resources/PowerGrid/manager.py | 67 ++- backend/recommendation-service/settings.py | 4 +- .../tests/test_smoke_pipeline.py | 39 ++ config/dev/cab-standalone/.secrets.example | 2 + config/dev/cab-standalone/docker-compose.sh | 10 +- config/dev/cab-standalone/docker-compose.yml | 22 + .../cab-standalone/nginx-cors-permissive.conf | 3 + .../dev/cab-standalone/nginx-kubernetes.conf | 3 + config/dev/cab-standalone/nginx.conf | 3 + config/dev/recommendation-service/nginx.conf | 3 + frontend/default.conf | 3 + frontend/src/api/services.ts | 1 + .../src/entities/PowerGrid/CAB/Assistant.vue | 25 +- .../PowerGrid/CAB/KpiProjectionChart.vue | 311 +++++++++++++ .../entities/PowerGrid/CAB/kpiProjection.ts | 39 ++ .../src/entities/PowerGrid/locales/en.json | 10 +- .../src/entities/PowerGrid/locales/fr.json | 10 +- frontend/src/entities/PowerGrid/types.ts | 6 + frontend/src/stores/services.ts | 7 +- frontend/src/types/services.ts | 7 + local_setup.sh | 413 ++++++++++++++++++ local_stop.sh | 49 +++ usecases_examples/PowerGrid/Dockerfile.app | 1 - .../PowerGrid/app/models/Communicate.py | 33 +- .../PowerGrid/app/models/Simulator.py | 34 +- .../PowerGrid/app/models/env_serialization.py | 99 +++++ .../PowerGrid/config/API_POWERGRID_CAB.toml | 4 + .../PowerGrid/tests/test_env_serialization.py | 125 ++++++ 34 files changed, 1340 insertions(+), 33 deletions(-) create mode 100644 backend/recommendation-service/tests/test_smoke_pipeline.py create mode 100644 frontend/src/entities/PowerGrid/CAB/KpiProjectionChart.vue create mode 100644 frontend/src/entities/PowerGrid/CAB/kpiProjection.ts create mode 100755 local_setup.sh create mode 100755 local_stop.sh create mode 100644 usecases_examples/PowerGrid/app/models/env_serialization.py create mode 100644 usecases_examples/PowerGrid/tests/test_env_serialization.py diff --git a/.gitignore b/.gitignore index 286604b7..c12aeb57 100644 --- a/.gitignore +++ b/.gitignore @@ -198,4 +198,6 @@ go.sh !frontend/.vscode !frontend/env/ !frontend/env/.env +/config/dev/cab-standalone/.secrets.pre-simplify + hmisurveys/ \ No newline at end of file diff --git a/README.md b/README.md index 3b4fb7a1..349f0c69 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,22 @@ Some examples of credentials: By default, the system allows the user to be connected only from a single machine. Which means if you try to connect using the same credentials from another machine, you will be disconnected on the first machine. +### OPTIONAL: connecting a local A3S service + +[A3S](a3s-service/README.md) is a local drop-in replacement for the external RL agent API, +also able to project KPIs several timesteps ahead. + +```bash +USE_A3S=1 ./local_setup.sh # full local stack, A3S instead of the remote RL agent +curl localhost:5010/api/v1/health # {"message": "Ok"} +``` + +To iterate on A3S alone, run `./docker/local_setup.sh` from `a3s-service/`, then set +`RL_AGENT_API_URL=http://host.docker.internal:5010/api/v1/recommendation` in +`config/dev/cab-standalone/.secrets` and re-run `./docker-compose.sh` there. The same override +connects any other agent exposing that contract, e.g. a local +[T2.1_deep_expert](https://github.com/ainetus/T2.1_deep_expert) build. Stop A3S with `./local_stop.sh`. + # Development Contributions to the InteractiveAI Assistant Platform are welcome! To contribute, please make sure to use [developer guide](docs/developer-guide.md) diff --git a/backend/cab_common/cab_common_auth/settings.py b/backend/cab_common/cab_common_auth/settings.py index 5595cd35..be9997aa 100644 --- a/backend/cab_common/cab_common_auth/settings.py +++ b/backend/cab_common/cab_common_auth/settings.py @@ -30,6 +30,8 @@ } }, "root": {"level": "DEBUG", "handlers": ["wsgi"]}, + # Avoid silently disabling loggers already set up by the host service. + "disable_existing_loggers": False, } logging.config.dictConfig(DEFAULT_LOGGING) diff --git a/backend/context-service/resources/PowerGrid/schemas.py b/backend/context-service/resources/PowerGrid/schemas.py index 54645552..2d29d1bc 100644 --- a/backend/context-service/resources/PowerGrid/schemas.py +++ b/backend/context-service/resources/PowerGrid/schemas.py @@ -5,3 +5,5 @@ class MetadataSchemaPowerGrid(MetadataSchema): topology = String(allow_none=False) observation = Dict(allow_none=False) + # Serialized environment state, so agents can rebuild it by replay. Optional. + environment_state = Dict(allow_none=True, required=False) diff --git a/backend/recommendation-service/api/exceptions.py b/backend/recommendation-service/api/exceptions.py index 4e675ef5..5e3fe080 100644 --- a/backend/recommendation-service/api/exceptions.py +++ b/backend/recommendation-service/api/exceptions.py @@ -4,3 +4,16 @@ class InvalidUseCase(HTTPError): status_code = 400 message = 'Use case invalid' + + +class UpstreamAgentError(HTTPError): + """The RL agent API could not be reached, or answered with an error. + + Distinct from "the agent ran and had nothing to recommend", which is an + empty list and a success. Collapsing the two into an empty 200 makes an + outage indistinguishable from a quiet grid: the caller waits on a spinner + with nothing to retry and nothing in the UI to say why. + """ + + status_code = 502 + message = 'The recommendation agent is unavailable' diff --git a/backend/recommendation-service/api/schemas.py b/backend/recommendation-service/api/schemas.py index 7c76633f..7a403a2e 100644 --- a/backend/recommendation-service/api/schemas.py +++ b/backend/recommendation-service/api/schemas.py @@ -10,6 +10,8 @@ class RecommendationAsk(Schema): # only when the operator has consented. Declared so it survives schema # validation and is forwarded verbatim to the RL agent. cognitive_snapshot = Dict() + # Optional per-request tuning, e.g. kpi_prediction_steps for A3S. + options = Dict() class RecommendationOut(Schema): @@ -20,6 +22,9 @@ class RecommendationOut(Schema): agent_type = String() actions = List(Dict()) kpis = Dict(allow_none=True) + # A3S multi-step rollouts: candidate index and rollout timestep. + branch_index = Integer(allow_none=True) + step = Integer(allow_none=True) class ProcedureOut(Schema): diff --git a/backend/recommendation-service/resources/PowerGrid/manager.py b/backend/recommendation-service/resources/PowerGrid/manager.py index aefea2cb..ca52e053 100644 --- a/backend/recommendation-service/resources/PowerGrid/manager.py +++ b/backend/recommendation-service/resources/PowerGrid/manager.py @@ -1,7 +1,9 @@ import os +import time import requests import urllib3 +from api.exceptions import UpstreamAgentError from api.manager.base_manager import BaseRecommendationManager from settings import logger @@ -24,6 +26,10 @@ def __init__(self): "http://frontend:80/rl-api/recommendation", ) self.rl_agent_api_token = os.environ.get("RL_AGENT_API_TOKEN", "") + # A3S rollouts can take tens of seconds; 30s was too tight. + self.rl_agent_api_timeout = float( + os.environ.get("RL_AGENT_API_TIMEOUT", "120") + ) super().__init__() def get_recommendation(self, request_data): @@ -45,35 +51,76 @@ def _get_rl_parades(self, request_data): request_data (dict): Full request payload with keys "event" and "context" Returns: - list[dict]: List of parade recommendations, empty on failure + list[dict]: List of parade recommendations. Empty only when the + agent ran and had nothing to propose. + + Raises: + UpstreamAgentError: If the agent API could not be reached or + answered with an error. Deliberately not swallowed into an + empty list — see the exception's docstring. """ try: headers = {} if self.rl_agent_api_token: headers["Authorization"] = f"Bearer {self.rl_agent_api_token}" + started = time.monotonic() response = requests.post( self.rl_agent_api_url, json=request_data, headers=headers, - timeout=30, + timeout=self.rl_agent_api_timeout, verify=False, # SSL cert may not be trusted inside the container ) response.raise_for_status() data = response.json() + elapsed = time.monotonic() - started + if elapsed > self.rl_agent_api_timeout / 2: + logger.warning( + "RL agent call took %.1fs of a %.0fs budget", + elapsed, + self.rl_agent_api_timeout, + ) + else: + logger.info("RL agent call took %.1fs", elapsed) logger.info(f"RL agent returned {len(data)} recommendation(s)") return data except requests.exceptions.SSLError as e: logger.error(f"SSL error calling RL agent API: {e}") - return [] + raise UpstreamAgentError( + message="Could not establish a secure connection to the " + "recommendation agent" + ) from e except requests.exceptions.HTTPError as e: - logger.error(f"HTTP error calling RL agent API: {e} — response body: {e.response.text[:500] if e.response is not None else 'N/A'}") - return [] + body = e.response.text[:500] if e.response is not None else 'N/A' + logger.error(f"HTTP error calling RL agent API: {e} — response body: {body}") + status = e.response.status_code if e.response is not None else None + raise UpstreamAgentError( + message=f"The recommendation agent returned an error " + f"({status})" if status else + "The recommendation agent returned an error", + # The agent's own message goes in the detail rather than the + # user-facing message: it is a Python traceback summary, not + # something an operator can act on. + detail={"upstream_status": status, "upstream_body": body}, + ) from e + except requests.exceptions.Timeout as e: + # Before ConnectionError: requests' Timeout subclasses it for the + # connect-timeout case, so the narrower except has to come first. + logger.error( + "Timeout calling RL agent API (%s) after %.0fs", + self.rl_agent_api_url, + self.rl_agent_api_timeout, + ) + raise UpstreamAgentError( + message="The recommendation agent did not answer in time" + ) from e except requests.exceptions.ConnectionError as e: logger.error(f"Connection error calling RL agent API ({self.rl_agent_api_url}): {e}") - return [] - except requests.exceptions.Timeout: - logger.error(f"Timeout calling RL agent API ({self.rl_agent_api_url}) after 30s") - return [] + raise UpstreamAgentError( + message="Could not reach the recommendation agent" + ) from e except Exception as e: logger.error(f"Unexpected error calling RL agent API: {type(e).__name__}: {e}") - return [] + raise UpstreamAgentError( + detail={"error": f"{type(e).__name__}: {e}"} + ) from e diff --git a/backend/recommendation-service/settings.py b/backend/recommendation-service/settings.py index b69f904d..399a68a1 100644 --- a/backend/recommendation-service/settings.py +++ b/backend/recommendation-service/settings.py @@ -14,7 +14,9 @@ 'root': { 'level': 'INFO', 'handlers': ['wsgi'] - } + }, + # Avoid silently disabling loggers already set up elsewhere. + 'disable_existing_loggers': False, } logging.config.dictConfig(DEFAULT_LOGGING) diff --git a/backend/recommendation-service/tests/test_smoke_pipeline.py b/backend/recommendation-service/tests/test_smoke_pipeline.py new file mode 100644 index 00000000..f38d54e0 --- /dev/null +++ b/backend/recommendation-service/tests/test_smoke_pipeline.py @@ -0,0 +1,39 @@ +"""Smoke test: post a context, stub out the RL agent, expect an ontology recommendation back.""" +import json + +POWERGRID_BEARER_TOKEN = "dummy-token-see-PowerGrid_auth_mocker-fixture" + + +def test_pipeline_smoke_context_to_recommendation( + client, create_usecases, PowerGrid_auth_mocker, mocker +): + mocker.patch( + "resources.PowerGrid.manager.PowerGridManager._get_rl_parades", + return_value=[], + ) + + with open("tests/tests_resources/rte_recommendation.json") as json_file: + payload = json.load(json_file) + + headers = {"Authorization": f"Bearer {POWERGRID_BEARER_TOKEN}"} + response = client.post( + "/api/v1/recommendation?use_case=PowerGrid", + headers=headers, + json=payload, + ) + + assert response.status_code == 200 + recommendations = response.get_json() + assert isinstance(recommendations, list) and len(recommendations) >= 1 + + for reco in recommendations: + assert reco["use_case"] == "PowerGrid" + assert reco["agent_type"] in {"IA", "onto"} + assert reco["title"] + assert "kpis" in reco + + onto_recos = [r for r in recommendations if r["agent_type"] == "onto"] + assert onto_recos + assert any( + "efficiency_of_the_reco" in (r["kpis"] or {}) for r in onto_recos + ) diff --git a/config/dev/cab-standalone/.secrets.example b/config/dev/cab-standalone/.secrets.example index 3ca7c1dc..c21a7d7d 100644 --- a/config/dev/cab-standalone/.secrets.example +++ b/config/dev/cab-standalone/.secrets.example @@ -1,5 +1,7 @@ # Copy to .secrets and fill in your values — this file is gitignored # +# export USE_A3S=1 # use the local A3S service instead of the external RL agent +# # RL agent (T2.1_deep_expert) — pick ONE RL_AGENT_API_URL for your environment: # Local dev : RL agent running on THIS host on port 5123, reached via host.docker.internal # (cab_recommendation has the host-gateway mapping) diff --git a/config/dev/cab-standalone/docker-compose.sh b/config/dev/cab-standalone/docker-compose.sh index 2826d66a..e2e0f895 100755 --- a/config/dev/cab-standalone/docker-compose.sh +++ b/config/dev/cab-standalone/docker-compose.sh @@ -48,6 +48,10 @@ echo "HOST_IP=${HOST_IP}" >> .env if [[ -f .secrets ]]; then source .secrets fi +# USE_A3S=1 points RL_AGENT_API_URL at the local A3S service, unless already set. +if [[ "${USE_A3S:-0}" == "1" && -z "${RL_AGENT_API_URL:-}" ]]; then + RL_AGENT_API_URL="http://caba3s:5010/api/v1/recommendation" +fi echo "RL_AGENT_API_URL=${RL_AGENT_API_URL:-https://interactiveagent.passerelle.irt-systemx.fr/api/v1/recommendation}" >> .env echo "RL_AGENT_API_TOKEN=${RL_AGENT_API_TOKEN:-}" >> .env echo "VITE_POWERGRID_SIMU=${VITE_POWERGRID_SIMU:-/powergrid-simu}" >> .env @@ -59,4 +63,8 @@ echo "COGNITIVE_TOKEN=${COGNITIVE_TOKEN:-}" >> .env # terminal (and in any CI log that runs this script). sed -E 's/^([A-Z_]*(TOKEN|SECRET|PASSWORD)[A-Z_]*)=(.+)$/\1=/; s/^([A-Z_]*(TOKEN|SECRET|PASSWORD)[A-Z_]*)=$/\1=/' .env -docker compose up -d +if [[ "${USE_A3S:-0}" == "1" ]]; then + docker compose --profile a3s up -d +else + docker compose up -d +fi diff --git a/config/dev/cab-standalone/docker-compose.yml b/config/dev/cab-standalone/docker-compose.yml index 077f4b77..ca02c70b 100644 --- a/config/dev/cab-standalone/docker-compose.yml +++ b/config/dev/cab-standalone/docker-compose.yml @@ -189,6 +189,8 @@ services: - TZ=UTC # Set timezone to UTC - RL_AGENT_API_URL=${RL_AGENT_API_URL} - RL_AGENT_API_TOKEN=${RL_AGENT_API_TOKEN} + # A3S rollouts can take tens of seconds to answer. + - RL_AGENT_API_TIMEOUT=${RL_AGENT_API_TIMEOUT:-120} command: sh -c "./entrypoint.sh" ports: - 5400:5000 @@ -197,6 +199,26 @@ services: depends_on: - db_postgres_recommendation + # Local A3S (agent-as-a-service) instance. Opt-in via the "a3s" profile. + caba3s: + container_name: cab_a3s + image: cab/caba3s + profiles: ["a3s"] + build: + context: ../../../a3s-service + dockerfile: docker/Dockerfile + args: + # Baked-in default policy; overridable via `environment:` below without a rebuild. + A3S_POWERGRID_AGENT: ${A3S_POWERGRID_AGENT:-t2.1} + restart: unless-stopped + environment: + - FLASK_APP=app:create_app('dev') + - TZ=UTC + # Which policy serves PowerGrid: "t2.1" (default) or "xd". + - A3S_POWERGRID_AGENT=${A3S_POWERGRID_AGENT:-t2.1} + ports: + - 5010:5010 + db_postgres_recommendation: container_name: db_postgres_recommendation image: postgres:14.7 diff --git a/config/dev/cab-standalone/nginx-cors-permissive.conf b/config/dev/cab-standalone/nginx-cors-permissive.conf index f99158f4..de3e4fe3 100644 --- a/config/dev/cab-standalone/nginx-cors-permissive.conf +++ b/config/dev/cab-standalone/nginx-cors-permissive.conf @@ -3,6 +3,9 @@ resolver 127.0.0.11 ipv6=off; server { listen 80; server_name localhost; + + # PowerGrid environment_state payloads exceed the 1 MB nginx default. + client_max_body_size 64m; ### CUSTOMIZATION - BEGIN # Url of the Authentication provider set $KeycloakBaseUrl "http://keycloak:8080"; diff --git a/config/dev/cab-standalone/nginx-kubernetes.conf b/config/dev/cab-standalone/nginx-kubernetes.conf index 11f75cc3..a7e61651 100644 --- a/config/dev/cab-standalone/nginx-kubernetes.conf +++ b/config/dev/cab-standalone/nginx-kubernetes.conf @@ -13,6 +13,9 @@ log_format upstreamlog '[$time_local] $remote_addr - $remote_user - $server_name server { listen 80; server_name localhost cab-dev.irtsystemx.org; + + # PowerGrid environment_state payloads exceed the 1 MB nginx default. + client_max_body_size 64m; error_log /var/log/nginx/error.log debug; access_log /var/log/nginx/access.log opfab-log; diff --git a/config/dev/cab-standalone/nginx.conf b/config/dev/cab-standalone/nginx.conf index 75078c35..ee2ecec7 100644 --- a/config/dev/cab-standalone/nginx.conf +++ b/config/dev/cab-standalone/nginx.conf @@ -11,6 +11,9 @@ log_format opfab-log '$remote_addr - $time_local_ms' server { listen 80; server_name localhost; + + # PowerGrid environment_state payloads exceed the 1 MB nginx default. + client_max_body_size 64m; access_log /var/log/nginx/access.log opfab-log; ### CUSTOMIZATION - BEGIN diff --git a/config/dev/recommendation-service/nginx.conf b/config/dev/recommendation-service/nginx.conf index b9ad9014..eca4b28c 100644 --- a/config/dev/recommendation-service/nginx.conf +++ b/config/dev/recommendation-service/nginx.conf @@ -3,6 +3,9 @@ resolver 127.0.0.11 ipv6=off; server { listen 80; server_name localhost; + + # PowerGrid environment_state payloads exceed the 1 MB nginx default. + client_max_body_size 64m; ### CUSTOMIZATION - BEGIN # Url of the Authentication provider set $KeycloakBaseUrl "http://keycloak:8080"; diff --git a/frontend/default.conf b/frontend/default.conf index e76df39a..8c95ebe8 100644 --- a/frontend/default.conf +++ b/frontend/default.conf @@ -5,6 +5,9 @@ server { listen 80; server_name localhost; + # PowerGrid environment_state payloads exceed the 1 MB nginx default. + client_max_body_size 64m; + gzip on; gzip_types application/javascript text/css; diff --git a/frontend/src/api/services.ts b/frontend/src/api/services.ts index 5892be71..300fbac8 100644 --- a/frontend/src/api/services.ts +++ b/frontend/src/api/services.ts @@ -13,6 +13,7 @@ export function getRecommendation(payload: { event: Card['data']['metadata'] context: Context cognitive_snapshot?: CognitiveSnapshot + options?: { kpi_prediction_steps?: number } }) { return http.post[]>('/cab_recommendation/api/v1/recommendation', payload) } diff --git a/frontend/src/entities/PowerGrid/CAB/Assistant.vue b/frontend/src/entities/PowerGrid/CAB/Assistant.vue index b1fef2d3..e9073f86 100644 --- a/frontend/src/entities/PowerGrid/CAB/Assistant.vue +++ b/frontend/src/entities/PowerGrid/CAB/Assistant.vue @@ -30,6 +30,9 @@ + diff --git a/frontend/src/entities/PowerGrid/CAB/kpiProjection.ts b/frontend/src/entities/PowerGrid/CAB/kpiProjection.ts new file mode 100644 index 00000000..cc5d026d --- /dev/null +++ b/frontend/src/entities/PowerGrid/CAB/kpiProjection.ts @@ -0,0 +1,39 @@ +import type { Entity } from '@/types/entities' +import type { Recommendation } from '@/types/services' + +export type KpiProjectionGroup = { + branchIndex: number + title: string + values: (number | undefined)[] +} + +// Groups rollout steps of the same candidate action together. +export function branchKey(recommendation: Recommendation) { + return recommendation.branch_index ?? recommendation.title +} + +// Regroups the flat API list (one item per rollout step) into one KPI series per candidate. +export function groupKpiProjection( + recommendations: Recommendation[], + kpiKey: string +): { steps: number[]; groups: KpiProjectionGroup[] } { + const steps = [...new Set(recommendations.map((r) => r.step ?? 1))].sort((a, b) => a - b) + const groups = new Map() + + recommendations.forEach((recommendation, index) => { + const key = branchKey(recommendation) + let group = groups.get(key) + if (!group) { + group = { + branchIndex: recommendation.branch_index ?? index, + title: recommendation.title.replace(/_step_\d+$/, ''), + values: steps.map(() => undefined) + } + groups.set(key, group) + } + const value = recommendation.kpis?.[kpiKey] + if (isFinite(value)) group.values[steps.indexOf(recommendation.step ?? 1)] = Number(value) + }) + + return { steps, groups: [...groups.values()].sort((a, b) => a.branchIndex - b.branchIndex) } +} diff --git a/frontend/src/entities/PowerGrid/locales/en.json b/frontend/src/entities/PowerGrid/locales/en.json index e11cecd4..0abcf683 100644 --- a/frontend/src/entities/PowerGrid/locales/en.json +++ b/frontend/src/entities/PowerGrid/locales/en.json @@ -17,5 +17,13 @@ "PowerGrid.kpis.redispatching_volume": "Redispatch volume", "PowerGrid.kpis.renewable_energy_share": "Proportion of renewable", "PowerGrid.kpis.total_consumption": "Total consumption", - "PowerGrid.kpis.type_of_the_reco": "Type of recommendation" + "PowerGrid.kpis.type_of_the_reco": "Type of recommendation", + "kpiProjection.show": "Show KPI projections", + "kpiProjection.close": "Close", + "kpiProjection.title": "{kpi} — projection over the next timesteps", + "kpiProjection.description": "For each candidate recommendation, projected value of the selected KPI if the action is applied and the grid then evolves under the agent's own policy for the next timesteps.", + "kpiProjection.xAxis": "Timesteps ahead", + "kpiProjection.tableCaption": "Projected {kpi} value per candidate recommendation and timestep ahead", + "kpiProjection.tableRecommendation": "Recommendation", + "kpiProjection.step": "t+{step}" } diff --git a/frontend/src/entities/PowerGrid/locales/fr.json b/frontend/src/entities/PowerGrid/locales/fr.json index 4c1666cb..64593b72 100644 --- a/frontend/src/entities/PowerGrid/locales/fr.json +++ b/frontend/src/entities/PowerGrid/locales/fr.json @@ -17,5 +17,13 @@ "PowerGrid.kpis.distance_from_reference_topology": "Distance à la topologie de référence", "PowerGrid.kpis.curtailment_volume": "Volume de curtailment", "PowerGrid.kpis.redispatching_volume": "Volume de redispatching", - "PowerGrid.kpis.type_of_the_reco": "Type de la parade" + "PowerGrid.kpis.type_of_the_reco": "Type de la parade", + "kpiProjection.show": "Afficher les projections de KPI", + "kpiProjection.close": "Fermer", + "kpiProjection.title": "{kpi} — projection sur les prochains pas de temps", + "kpiProjection.description": "Pour chaque parade candidate, valeur projetée du KPI sélectionné si l'action est appliquée puis que le réseau évolue selon la politique de l'agent sur les prochains pas de temps.", + "kpiProjection.xAxis": "Pas de temps à venir", + "kpiProjection.tableCaption": "Valeur projetée du KPI {kpi} par parade candidate et pas de temps à venir", + "kpiProjection.tableRecommendation": "Parade", + "kpiProjection.step": "t+{step}" } diff --git a/frontend/src/entities/PowerGrid/types.ts b/frontend/src/entities/PowerGrid/types.ts index d963a438..4ad4ce40 100644 --- a/frontend/src/entities/PowerGrid/types.ts +++ b/frontend/src/entities/PowerGrid/types.ts @@ -61,6 +61,12 @@ export type PowerGrid = { year: [number] } topology: string + // Opaque state envelope, forwarded to the recommendation service so A3S can rebuild the environment. + environment_state?: { + serializer: string + state: Record + metadata?: Record + } } Metadata: { event_type: 'KPI' | 'anticipation' | 'agent' | 'consignation' diff --git a/frontend/src/stores/services.ts b/frontend/src/stores/services.ts index 582f5c0a..fad01b3e 100644 --- a/frontend/src/stores/services.ts +++ b/frontend/src/stores/services.ts @@ -1,8 +1,8 @@ import { defineStore } from 'pinia' import { ref } from 'vue' -import { fetchCognitiveSnapshot } from '@/api/cognitive' import type { CognitiveSnapshot } from '@/api/cognitive' +import { fetchCognitiveSnapshot } from '@/api/cognitive' import * as servicesApi from '@/api/services' import i18n from '@/plugins/i18n' import type { Card } from '@/types/cards' @@ -132,9 +132,12 @@ export const useServicesStore = defineStore('services', () => { event: Card['data']['metadata'] context: Context cognitive_snapshot?: CognitiveSnapshot + options?: { kpi_prediction_steps?: number } } = { event: getRootCard(event).data.metadata, - context: contextForAgent + context: contextForAgent, + // A3S rolls this out; other managers ignore it. + options: { kpi_prediction_steps: 6 } } if (hasCognitiveConsent()) { payload.cognitive_snapshot = await fetchCognitiveSnapshot() diff --git a/frontend/src/types/services.ts b/frontend/src/types/services.ts index 75756293..1fc30c0d 100644 --- a/frontend/src/types/services.ts +++ b/frontend/src/types/services.ts @@ -8,6 +8,13 @@ export type Recommendation = { title: string actions: Action[] kpis?: { [key: string]: any } + // A3S multi-step rollouts: candidate index and rollout timestep. + branch_index?: number + step?: number + // Whether this rollout step ended the episode (terminal-state KPIs). + done?: boolean + // Environment's absolute clock, vs `step`'s rollout-relative count. + env_timestep?: number } export type FullContext = { diff --git a/local_setup.sh b/local_setup.sh new file mode 100755 index 00000000..b5ef494f --- /dev/null +++ b/local_setup.sh @@ -0,0 +1,413 @@ +#!/usr/bin/env bash +# +# local_setup.sh — one-shot local setup for the InteractiveAI backend + PowerGrid simulator. +# +# Steps: start the backend, wait for Keycloak + frontend, configure Keycloak via +# the admin REST API (manual prompt as fallback), load OperatorFabric resources, +# build and start the PowerGrid simulator, reload the frontend nginx and verify +# the recommendation path. +# +# Usage: +# ./local_setup.sh # full setup (prompts if containers already run) +# ./local_setup.sh --clean # tear down existing containers first, no prompt +# ./local_setup.sh --wipe # tear down existing containers AND volumes, no prompt +# +# Overridable via environment: +# KC_ADMIN (admin) KC_PW (admin) FRONTEND_URL (http://localhost:3200) +# +# Secrets (RL_AGENT_API_URL / RL_AGENT_API_TOKEN / VITE_COGNITIVE_TOKEN / USE_A3S) +# are read from config/dev/cab-standalone/.secrets if present (see .secrets.example). + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BACKEND_DIR="$REPO_ROOT/config/dev/cab-standalone" +RESOURCES_DIR="$REPO_ROOT/resources" +SIM_DIR="$REPO_ROOT/usecases_examples/PowerGrid" +SIM_COMPOSE="docker-compose.local.yml" # server config lives in docker-compose.yml +SIM_PORT=5122 # must match POWERGRID_SIMU_UPSTREAM in .env +A3S_PORT=5010 # host port the compose `caba3s` service publishes +# Serializer name the simulator stamps into environment_state; must match what A3S accepts. +SIM_SERIALIZER_SRC="$REPO_ROOT/usecases_examples/PowerGrid/app/models/env_serialization.py" + +KC_BASE="http://localhost:89/auth" # Keycloak 16.x (legacy /auth base path) +KC_REALM="dev" +KC_CLIENT="opfab-client" +KC_ADMIN="${KC_ADMIN:-admin}" +KC_PW="${KC_PW:-admin}" + +FRONTEND_URL="${FRONTEND_URL:-http://localhost:3200}" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +log() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; } +ok() { printf '\033[1;32m ✓ %s\033[0m\n' "$*"; } +warn() { printf '\033[1;33m ! %s\033[0m\n' "$*"; } +die() { printf '\033[1;31m ✗ %s\033[0m\n' "$*" >&2; exit 1; } + +# Emit an OSC 8 terminal hyperlink; degrades to plain text if unsupported. +link() { printf '\033]8;;%s\033\\%s\033]8;;\033\\' "$1" "$1"; } + +# Block until an HTTP endpoint answers with the wanted status, or time out. +wait_for_http() { + local url="$1" want="${2:-200}" tries="${3:-90}" i=1 code + while (( i <= tries )); do + code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 "$url" || true)" + [[ "$code" == "$want" ]] && return 0 + printf ' waiting for %s (%s/%s, last=%s)\r' "$url" "$i" "$tries" "$code" + sleep 2; (( i++ )) + done + printf '\n'; return 1 +} + +require() { command -v "$1" >/dev/null 2>&1 || die "'$1' is required but not installed."; } + +# True if $1 is the name of a currently running container. +container_running() { docker ps --format '{{.Names}}' | grep -qx "$1"; } + +# --------------------------------------------------------------------------- +# Gateway / A3S robustness +# --------------------------------------------------------------------------- +# nginx caches upstream IPs at config load, so a container recreated afterwards +# 502s until reloaded. Reload against the substituted config (-c), not the raw +# templated one nginx -s reload would otherwise validate. +frontend_nginx_reload() { + container_running frontend || { warn "frontend not running — skipping nginx reload"; return 0; } + if docker exec frontend nginx -c /personal-conf/nginx.conf -s reload >/dev/null 2>&1; then + ok "frontend nginx reloaded (upstream IPs re-resolved)" + elif docker exec frontend nginx -s reload >/dev/null 2>&1; then + ok "frontend nginx reloaded" + else + warn "nginx reload failed — restarting the frontend container instead" + docker restart frontend >/dev/null 2>&1 || { warn "frontend restart failed"; return 0; } + wait_for_http "$FRONTEND_URL/" 200 >/dev/null || warn "frontend not answering 200 yet" + ok "frontend restarted" + fi +} + +# A standalone a3s-service/docker/local_setup.sh can grab port 5010 before the +# compose `caba3s` service starts, leaving it unable to bind. Free the port, +# unless RL_AGENT_API_URL is set explicitly (the operator is pointing elsewhere). +free_a3s_port() { + [[ "${USE_A3S:-0}" == "1" ]] || return 0 + if [[ -n "${RL_AGENT_API_URL:-}" ]]; then + warn "both USE_A3S=1 and RL_AGENT_API_URL are set in .secrets — the explicit URL wins" + warn " using: $RL_AGENT_API_URL" + warn " cab_a3s will still be built and started, but nothing will call it" + warn " for a self-contained stack, comment RL_AGENT_API_URL out and keep USE_A3S=1" + ok "leaving port $A3S_PORT and any standalone A3S as-is" + return 0 + fi + local holder + holder="$(docker ps --format '{{.Names}}\t{{.Ports}}' \ + | awk -F'\t' -v p=":$A3S_PORT->" 'index($2, p) {print $1}' \ + | grep -vx cab_a3s || true)" + [[ -z "$holder" ]] && return 0 + warn "port $A3S_PORT is needed by cab_a3s but is held by container '$holder'" + warn "(that is the standalone A3S from a3s-service/docker/local_setup.sh — this stack builds its own)" + local c + for c in $holder; do + docker stop "$c" >/dev/null 2>&1 && ok "stopped '$c' to free port $A3S_PORT (start it again later if you need it)" + done +} + +# Checks the gateway route, the RL agent, and the simulator/A3S serializer contract. +verify_recommendation_path() { + local failures=0 + + if wait_for_http "$FRONTEND_URL/cab_recommendation/api/v1/health" 200 15 >/dev/null; then + ok "gateway route $FRONTEND_URL/cab_recommendation/ reaches the recommendation service" + else + warn "gateway route to the recommendation service is not answering 200" + warn " the browser calls it at /cab_recommendation/ — a 502 here means a stale nginx upstream" + failures=$(( failures + 1 )) + fi + + if [[ "${USE_A3S:-0}" == "1" && -z "${RL_AGENT_API_URL:-}" ]]; then + if ! container_running cab_a3s; then + warn "USE_A3S=1 but the cab_a3s container is not running" + warn " check: cd $BACKEND_DIR && docker compose --profile a3s logs caba3s" + failures=$(( failures + 1 )) + elif wait_for_http "http://localhost:$A3S_PORT/api/v1/health" 200 15 >/dev/null; then + ok "A3S is healthy on port $A3S_PORT" + verify_serializer_contract cab_a3s || failures=$(( failures + 1 )) + else + warn "cab_a3s is running but not answering on port $A3S_PORT" + failures=$(( failures + 1 )) + fi + elif [[ -n "${RL_AGENT_API_URL:-}" ]]; then + # Resolve from inside the recommendation container, whose network namespace RL_AGENT_API_URL targets. + local health_url="${RL_AGENT_API_URL%/recommendation}/health" + if container_running cab_recommendation && docker exec cab_recommendation python -c " +import sys, urllib.request, ssl +ctx = ssl._create_unverified_context() +try: + urllib.request.urlopen('$health_url', timeout=8, context=ctx) +except Exception as e: + sys.exit(str(e) or 'unreachable') +" >/dev/null 2>&1; then + ok "RL agent reachable from cab_recommendation at $health_url" + # May point at a standalone A3S on the host; find it by its published port. + local port a3s_container + port="$(sed -E 's#.*://[^/:]+:([0-9]+).*#\1#' <<<"$RL_AGENT_API_URL")" + if [[ "$port" =~ ^[0-9]+$ ]]; then + a3s_container="$(docker ps --format '{{.Names}}\t{{.Ports}}' \ + | awk -F'\t' -v p=":$port->" 'index($2, p) {print $1; exit}')" + if [[ -n "$a3s_container" ]]; then + verify_serializer_contract "$a3s_container" || failures=$(( failures + 1 )) + fi + fi + else + warn "RL agent at \$RL_AGENT_API_URL is NOT reachable from inside cab_recommendation" + warn " URL: $RL_AGENT_API_URL" + warn " a container on another compose network is not reachable by name —" + warn " use http://host.docker.internal:/api/v1/recommendation" + failures=$(( failures + 1 )) + fi + else + warn "neither USE_A3S=1 nor RL_AGENT_API_URL is set — only the ontology recommender will run" + fi + + return "$failures" +} + +# Compares the simulator's serializer name against what A3S accepts. +# $1 = name of the container running A3S. +verify_serializer_contract() { + local a3s="$1" emitted accepted + emitted="$(grep -oE '"serializer": [A-Z0-9_]+' "$SIM_SERIALIZER_SRC" 2>/dev/null | awk '{print $2}' | head -1)" + [[ -n "$emitted" ]] || { warn "could not determine the simulator's serializer from $SIM_SERIALIZER_SRC"; return 0; } + emitted="$(grep -oE "^${emitted} = \"[a-z0-9_]+\"" "$SIM_SERIALIZER_SRC" | sed 's/.*"\(.*\)"/\1/' | head -1)" + [[ -n "$emitted" ]] || { warn "could not resolve the simulator's serializer constant"; return 0; } + + accepted="$(docker exec "$a3s" sh -c 'grep -hoE "grid2op_observation_v[0-9]+" /my_app/agent_as_a_service/powergrid/serialization.py 2>/dev/null | sort -u' 2>/dev/null || true)" + if [[ -z "$accepted" ]]; then + warn "could not read the serializers '$a3s' accepts — skipping the contract check" + return 0 + fi + if grep -qx "$emitted" <<<"$accepted"; then + ok "serializer contract OK — simulator emits '$emitted', '$a3s' accepts it" + return 0 + fi + warn "SERIALIZER MISMATCH — recommendations will silently come back empty" + warn " simulator emits: $emitted" + warn " '$a3s' accepts: $(tr '\n' ' ' <<<"$accepted")" + warn " that A3S image predates the simulator's payload format — rebuild it:" + if [[ "$a3s" == "cab_a3s" ]]; then + warn " cd $BACKEND_DIR && docker compose --profile a3s up -d --build --force-recreate caba3s" + else + warn " cd $REPO_ROOT/a3s-service && ./docker/local_setup.sh --rebuild" + fi + return 1 +} + +# --------------------------------------------------------------------------- +# Keycloak configuration via the admin REST API +# --------------------------------------------------------------------------- +kc_admin_token() { + curl -s --max-time 10 -X POST \ + "$KC_BASE/realms/master/protocol/openid-connect/token" \ + -d client_id=admin-cli -d "username=$KC_ADMIN" -d "password=$KC_PW" \ + -d grant_type=password \ + | python3 -c 'import sys,json; print(json.load(sys.stdin).get("access_token",""))' 2>/dev/null +} + +# Returns 0 on success, non-zero if anything went wrong (caller then prompts). +kc_configure() { + local token realm updated code + token="$(kc_admin_token)" + [[ -n "$token" ]] || { warn "could not obtain a Keycloak admin token"; return 1; } + + # Without a matching Frontend URL, token issuer URLs mismatch and auth returns 401. + realm="$(curl -s --max-time 10 -H "Authorization: Bearer $token" "$KC_BASE/admin/realms/$KC_REALM")" + updated="$(printf '%s' "$realm" | FRONTEND_URL="$FRONTEND_URL" python3 -c ' +import sys, json, os +d = json.load(sys.stdin) +attrs = d.get("attributes") or {} +attrs["frontendUrl"] = os.environ["FRONTEND_URL"] +d["attributes"] = attrs +print(json.dumps(d))' 2>/dev/null)" + [[ -n "$updated" ]] || { warn "could not read/patch the '$KC_REALM' realm"; return 1; } + code="$(curl -s -o /dev/null -w '%{http_code}' -X PUT \ + "$KC_BASE/admin/realms/$KC_REALM" \ + -H "Authorization: Bearer $token" -H "Content-Type: application/json" \ + -d "$updated")" + [[ "$code" == 2* ]] || { warn "realm update returned HTTP $code"; return 1; } + ok "realm '$KC_REALM' Frontend URL set to $FRONTEND_URL" + return 0 +} + +# Manual fallback: pause and let the operator configure Keycloak by hand. +kc_manual_prompt() { + cat < General -> Frontend URL = $FRONTEND_URL -> Save + 4. Clients -> '$KC_CLIENT' -> Valid Redirect URIs must include + ${FRONTEND_URL%/}/* (and Web Origins ${FRONTEND_URL}) -> Save + ------------------------------------------------------------------ +EOF + read -r -p " Press ENTER once Keycloak is configured to continue... " _ +} + +# --------------------------------------------------------------------------- +# Existing-container detection / teardown +# --------------------------------------------------------------------------- +# Lists running containers of the compose project rooted at $1. $2 = optional compose file. +compose_running() { + ( cd "$1" && docker compose ${2:+-f "$2"} ps --format ' {{.Name}} ({{.Status}})' 2>/dev/null ) || true +} + +# Remove both compose stacks. $1 = extra `down` args (e.g. "-v" to drop volumes). +teardown_stacks() { + local extra="${1:-}" + log "Tearing down existing containers${extra:+ and volumes} for a clean rebuild" + ( cd "$SIM_DIR" && docker compose -f "$SIM_COMPOSE" down $extra 2>/dev/null ) || true + ( cd "$BACKEND_DIR" && docker compose down $extra 2>/dev/null ) || true + ok "existing containers removed" +} + +# Ask what to do if our containers are already running. $1: "" ask, "clean" down, "wipe" down -v. +handle_existing_containers() { + local mode="${1:-}" running + running="$(compose_running "$BACKEND_DIR"; compose_running "$SIM_DIR" "$SIM_COMPOSE")" + + if [[ -z "$running" ]]; then + ok "no existing project containers running" + return 0 + fi + + warn "Found running containers from this setup:" + printf '%s\n' "$running" + + case "$mode" in + clean) teardown_stacks "" ; return 0 ;; + wipe) teardown_stacks "-v" ; return 0 ;; + esac + + if [[ ! -t 0 ]]; then + warn "non-interactive shell and no --clean/--wipe flag: leaving containers as-is" + return 0 + fi + + local reply + read -r -p " Kill them and rebuild clean? [y]es / [w]ipe data too / [N]o, keep running: " reply + case "${reply,,}" in + y|yes) teardown_stacks "" ;; + w|wipe) teardown_stacks "-v" ;; + *) warn "leaving existing containers in place (continuing)" ;; + esac +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +main() { + local CLEAN_MODE="" + for arg in "$@"; do + case "$arg" in + --clean) CLEAN_MODE="clean" ;; + --wipe) CLEAN_MODE="wipe" ;; + -h|--help) sed -n '3,26p' "${BASH_SOURCE[0]}"; exit 0 ;; + *) die "unknown argument: $arg (see --help)" ;; + esac + done + + log "Checking prerequisites" + require docker; require curl; require python3 + docker compose version >/dev/null 2>&1 || die "'docker compose' (v2) is required." + ok "docker, docker compose, curl, python3 present" + + # Source .secrets in THIS shell (docker-compose.sh sources it in a subshell), + # so USE_A3S is visible to the rebuild check further down. + if [[ -f "$BACKEND_DIR/.secrets" ]]; then + # shellcheck disable=SC1091 # path is runtime-resolved, gitignored + source "$BACKEND_DIR/.secrets" + else + warn "no config/dev/cab-standalone/.secrets file — using default RL agent API and no cognitive token" + warn "copy .secrets.example to .secrets to override (see docker-compose.sh)" + fi + + log "Checking for existing containers" + handle_existing_containers "$CLEAN_MODE" + + log "Checking the A3S port is available" + free_a3s_port + ok "port $A3S_PORT ready for cab_a3s" + + log "Step 1/6 — Starting the InteractiveAI backend" + ( cd "$BACKEND_DIR" && ./docker-compose.sh ) + ok "backend compose brought up" + + # Force a rebuild from this repo's source (docker-compose.sh's `up -d` reuses + # existing images as-is); caba3s only when USE_A3S=1 brought it up. + local rebuild=(frontend cabrecommendation) profile=() + if [[ "${USE_A3S:-0}" == "1" ]]; then + rebuild+=(caba3s) + profile=(--profile a3s) + fi + log "Rebuilding ${rebuild[*]} from this repo's source" + ( cd "$BACKEND_DIR" && docker compose "${profile[@]}" up -d --build --force-recreate "${rebuild[@]}" ) + ok "${rebuild[*]} rebuilt from source" + + log "Step 2/6 — Waiting for Keycloak" + wait_for_http "$KC_BASE/realms/master" 200 || die "Keycloak did not come up on :89" + ok "Keycloak is up" + + log "Step 3/6 — Configuring Keycloak" + if kc_configure; then + ok "Keycloak configured automatically" + else + kc_manual_prompt + fi + log "Restarting the frontend to pick up the Keycloak change" + docker restart frontend >/dev/null && ok "frontend restarted" + wait_for_http "$FRONTEND_URL/" 200 || warn "frontend not answering 200 yet (continuing)" + + log "Step 4/6 — Loading resources and registering use cases" + # Wait until auth actually works end-to-end before loading (avoids 401s). + local i=1 + while true; do + unset token + source "$RESOURCES_DIR/getToken.sh" admin "${FRONTEND_URL%:*}" >/dev/null 2>&1 || true + [[ -n "${token:-}" ]] && break + (( i > 24 )) && die "auth never became ready ($FRONTEND_URL/auth/token)" + printf ' waiting for auth to be ready (%s/24)\r' "$i"; sleep 5; (( i++ )) + done + printf '\n'; ok "auth is ready" + ( cd "$RESOURCES_DIR" && ./loadTestConf.sh ) + ok "resources loaded, use cases registered" + + log "Step 5/6 — Building and starting the PowerGrid simulator" + # The default docker-compose.yml is the server config (wrong port); use the local one. + ( cd "$SIM_DIR" && docker compose -f "$SIM_COMPOSE" up -d --build --force-recreate app ) + ok "PowerGrid simulator started" + + # Last, once nothing else will be recreated, so the re-resolved upstream IPs stick. + log "Step 6/6 — Re-pointing the gateway and verifying the recommendation path" + frontend_nginx_reload + if verify_recommendation_path; then + ok "recommendation path verified end to end" + else + warn "the recommendation path is NOT fully working — see the warnings above" + warn "the UI will come up, but the PowerGrid recommendation panel may stay empty" + fi + + printf '\n\033[1;32mSetup complete.\033[0m\n\n' + # powergrid_user is provisioned with the PowerGrid entity; publisher_test isn't. + printf ' InteractiveAI UI %s (powergrid_user / test)\n' "$(link "$FRONTEND_URL")" + printf ' PowerGrid simulator %s (also proxied same-origin at %s/powergrid-simu/)\n' "$(link "http://localhost:$SIM_PORT")" "$FRONTEND_URL" + printf ' Keycloak admin %s (admin / admin)\n' "$(link "$KC_BASE/admin")" + printf '\n In the simulator, pick server %s and log in.\n' "$(link "http://host.docker.internal:3200/")" +} + +main "$@" diff --git a/local_stop.sh b/local_stop.sh new file mode 100755 index 00000000..f576d8fe --- /dev/null +++ b/local_stop.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# +# local_stop.sh — tear down the local InteractiveAI backend + PowerGrid simulator. +# +# Usage: +# ./local_stop.sh # stop & remove containers, KEEP data volumes (default) +# ./local_stop.sh --wipe # also delete data volumes (Postgres/Mongo/Keycloak) — fresh start next time +# ./local_stop.sh --pause # just stop containers, keep them (fastest; `./local_setup.sh` or `docker compose start` to resume) +# ./local_stop.sh --help +# +# Both Docker Compose projects are handled: the backend (config/dev/cab-standalone) +# and the PowerGrid simulator (usecases_examples/PowerGrid). + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BACKEND_DIR="$REPO_ROOT/config/dev/cab-standalone" +SIM_DIR="$REPO_ROOT/usecases_examples/PowerGrid" +# Local dev uses docker-compose.local.yml, not the default (server) compose file. +SIM_COMPOSE="docker-compose.local.yml" + +log() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; } +ok() { printf '\033[1;32m ✓ %s\033[0m\n' "$*"; } +die() { printf '\033[1;31m ✗ %s\033[0m\n' "$*" >&2; exit 1; } + +case "${1:-}" in + "") MSG="Stopping everything (containers removed, data volumes kept)" + COMPOSE_CMD="down" ;; + --wipe|--volumes) MSG="Stopping everything and DELETING data volumes (fresh start next time)" + COMPOSE_CMD="down -v" ;; + --pause|--stop) MSG="Pausing everything (containers kept, resume later)" + COMPOSE_CMD="stop" ;; + -h|--help) sed -n '3,13p' "${BASH_SOURCE[0]}"; exit 0 ;; + *) die "unknown argument: ${1} (see --help)" ;; +esac + +# Run the chosen teardown command in a compose project directory. +# $2 = optional compose file. +run() { + ( cd "$1" && docker compose ${2:+-f "$2"} $COMPOSE_CMD 2>/dev/null ) || true +} + +log "$MSG" + +# Simulator first, then backend it depends on. +run "$SIM_DIR" "$SIM_COMPOSE" +run "$BACKEND_DIR" + +ok "done" diff --git a/usecases_examples/PowerGrid/Dockerfile.app b/usecases_examples/PowerGrid/Dockerfile.app index 782ea872..8f9d46ba 100644 --- a/usecases_examples/PowerGrid/Dockerfile.app +++ b/usecases_examples/PowerGrid/Dockerfile.app @@ -7,7 +7,6 @@ RUN mkdir /code COPY . /code/ WORKDIR /code -RUN apt-get update && apt-get install ffmpeg libsm6 libxext6 -y RUN pip3 install -r requirements-app.txt CMD ["python3", "PowerGrid_poc_simulator_app.py"] \ No newline at end of file diff --git a/usecases_examples/PowerGrid/app/models/Communicate.py b/usecases_examples/PowerGrid/app/models/Communicate.py index 9e779eb8..28793a58 100644 --- a/usecases_examples/PowerGrid/app/models/Communicate.py +++ b/usecases_examples/PowerGrid/app/models/Communicate.py @@ -7,6 +7,7 @@ import numpy as np from config.config import logging, set_pause, get_pause_status from app.models.recommendation_store import store as recommendation_store +from app.models.env_serialization import build_environment_state class Communicate: """ @@ -227,7 +228,22 @@ def login(self, username, password): authorization = True return url, authorization - def send_context_online(self, obs, scn_first_step, context_date, img_b64): + def environment_state_payload(self, obs, env_identity, recorder): + """Builds the `environment_state` envelope, or None if disabled/unavailable.""" + if not env_identity or recorder is None or not len(recorder): + return None + + context_config = self.outputs_config['Outputs']['Context'] + if str(context_config.get('serialize_env_state', 'yes')).lower() != 'yes': + return None + + return build_environment_state( + obs, env_identity, recorder, + float(context_config.get('max_state_mb', 32)) + ) + + def send_context_online(self, obs, scn_first_step, context_date, img_b64, + env_identity=None, recorder=None): """ Sends the context online with the observation image. @@ -236,6 +252,8 @@ def send_context_online(self, obs, scn_first_step, context_date, img_b64): scn_first_step: First step of the scenario. context_date: Date of the context. img_b64: Base64 encoded image. + env_identity: Environment identity from `build_env_identity`. + recorder: ReplayRecorder of actions since reset; published as `environment_state`. """ if obs.current_step < scn_first_step or not self.cab_api_on: return @@ -243,12 +261,17 @@ def send_context_online(self, obs, scn_first_step, context_date, img_b64): try: url = self.cab_url + \ self.outputs_config['Outputs']['Context']['context_port'] + data = { + "observation": obs.to_json(), + "topology": img_b64 + } + environment_state = self.environment_state_payload( + obs, env_identity or {}, recorder) + if environment_state is not None: + data["environment_state"] = environment_state payload = json.dumps({ "date": f"{context_date}", - "data": { - "observation": obs.to_json(), - "topology": img_b64 - }, + "data": data, "use_case": "PowerGrid" }) headers = { diff --git a/usecases_examples/PowerGrid/app/models/Simulator.py b/usecases_examples/PowerGrid/app/models/Simulator.py index fd1bb734..17fa4075 100644 --- a/usecases_examples/PowerGrid/app/models/Simulator.py +++ b/usecases_examples/PowerGrid/app/models/Simulator.py @@ -11,6 +11,7 @@ import matplotlib matplotlib.use('agg') from app.models.Listener import Listener +from app.models.env_serialization import ReplayRecorder, build_env_identity from config.config import logging, set_pause, get_pause_status from app.models.utils import (create_observation_image, get_alert_lines, search_chronic_num_from_name, get_curent_lines_in_bad_kpi, get_curent_lines_lost, @@ -35,6 +36,9 @@ def __init__(self, socketio): self.env = None self.obs = None self.act = None + # Replay history published with the context, and the env it replays against. + self.replay_recorder = ReplayRecorder() + self.env_identity = {} self.listen = None self.local_assistant = None self.com = None @@ -91,6 +95,9 @@ def initialize_simulation(self, com, session): self.config['scenario_name'], self.env) self.env.set_id(id_scenario) # Scenario choice self.obs = self.env.reset() + # Reset the replay history for this new episode. + self.replay_recorder.reset() + self.env_identity = build_env_identity(self.config) logging.info("Loaded scenario: %s \n", self.env.chronics_handler.get_name()) session['message'].append(f"Loaded scenario: {self.env.chronics_handler.get_name()}") @@ -173,6 +180,9 @@ def run_simulator(self, com): topo_before = self.obs.topo_vect.copy() line_status_before = self.obs.line_status.copy() + # Record before applying, so the replay history stays gap-free. + self.replay_recorder.record(act) + # Beginning of step: observation update self.obs, _, done, info = self.env.step(act) @@ -284,7 +294,9 @@ def run_simulator(self, com): com.send_context_online(self.obs, self.config['scenario_first_step'], context_date, - img_b64_current) + img_b64_current, + self.env_identity, + self.replay_recorder) event_resolved_trigger = False context_just_sent = True @@ -307,7 +319,9 @@ def run_simulator(self, com): com.send_context_online(self.obs, self.config['scenario_first_step'], context_date, - img_b64_current) + img_b64_current, + self.env_identity, + self.replay_recorder) context_just_sent = True logging.info("Status: Overload detected on the network") @@ -384,7 +398,9 @@ def run_simulator(self, com): com.send_context_online(self.obs, self.config['scenario_first_step'], context_date, - img_b64_current) + img_b64_current, + self.env_identity, + self.replay_recorder) context_just_sent = True logging.info("Status: AI agent raised an alarm") @@ -420,7 +436,9 @@ def run_simulator(self, com): com.send_context_online(self.obs, self.config['scenario_first_step'], context_date, - img_b64_current) + img_b64_current, + self.env_identity, + self.replay_recorder) context_just_sent = True logging.info("Status: AI agent raised an alert") @@ -454,7 +472,9 @@ def run_simulator(self, com): com.send_context_online(self.obs, self.config['scenario_first_step'], context_date, - img_b64_current) + img_b64_current, + self.env_identity, + self.replay_recorder) context_just_sent = True logging.info( @@ -505,7 +525,9 @@ def run_simulator(self, com): com.send_context_online(self.obs, self.config['scenario_first_step'], context_date, - img_b64_current) + img_b64_current, + self.env_identity, + self.replay_recorder) context_just_sent = True logging.info("Status: Line loss detected: %s", diff --git a/usecases_examples/PowerGrid/app/models/env_serialization.py b/usecases_examples/PowerGrid/app/models/env_serialization.py new file mode 100644 index 00000000..f754e094 --- /dev/null +++ b/usecases_examples/PowerGrid/app/models/env_serialization.py @@ -0,0 +1,99 @@ +# Serializes the simulator's environment state (identity + action history) so +# consumers like A3S can rebuild it exactly by replay instead of approximating it. +import base64 +import gzip +import json +import logging + +# Must stay in sync with GRID2OP_OBSERVATION_V2 in the A3S service +# (a3s-service/agent_as_a_service/powergrid/serialization.py). +GRID2OP_OBSERVATION_V2 = "grid2op_observation_v2" + +# Framing of replay_actions, stamped in metadata. +REPLAY_COMPRESSION = "gzip+base64" + + +class ReplayRecorder: + """Records actions applied since the last reset; the history must be gap-free to replay.""" + + def __init__(self): + self._actions = [] + self._broken = False + + def reset(self) -> None: + self._actions = [] + self._broken = False + + def record(self, action) -> None: + """Appends an action; marks the recorder broken (for the rest of the episode) on failure.""" + if self._broken: + return + try: + self._actions.append({"vect": action.to_vect().tolist()}) + except Exception as e: + logging.error( + "Failed to record an action for replay; no environment state " + "will be published for the rest of this episode: %s", e) + self._actions = [] + self._broken = True + + @property + def actions(self) -> list: + return self._actions + + @property + def broken(self) -> bool: + return self._broken + + def __len__(self) -> int: + return len(self._actions) + + +def build_env_identity(config: dict) -> dict: + """Captures which environment (seed, scenario, library versions) to replay against.""" + identity = { + "seed": int(config["env_seed"]), + "scenario_name": str(config["scenario_name"]), + } + for name, package in (("grid2op_version", "grid2op"), + ("lightsim2grid_version", "lightsim2grid")): + try: + identity[name] = __import__(package).__version__ + except Exception as e: + logging.warning("Could not read the %s version: %s", package, e) + return identity + + +def encode_replay_actions(replay_actions: list) -> str: + """Compresses the action history to gzipped, base64-encoded JSON.""" + raw = json.dumps(replay_actions, separators=(",", ":")).encode("utf-8") + return base64.b64encode(gzip.compress(raw, 6)).decode("ascii") + + +def build_environment_state(obs, env_identity: dict, recorder: ReplayRecorder, + max_state_mb: float) -> dict: + """Builds the environment_state envelope, or None if there's nothing replayable or it's too large.""" + if recorder.broken or not len(recorder): + return None + + encoded = encode_replay_actions(recorder.actions) + size_mb = len(encoded) / (1024 * 1024) + if size_mb > max_state_mb: + logging.warning( + "Replay history dropped: %.1f MB over the %.1f MB cap (%d actions).", + size_mb, max_state_mb, len(recorder)) + return None + + return { + "serializer": GRID2OP_OBSERVATION_V2, + "state": { + "observation": obs.to_json(), + "replay_actions": encoded, + **env_identity, + }, + "metadata": { + "current_step": int(obs.current_step), + "replayed_actions": len(recorder), + "compression": REPLAY_COMPRESSION, + }, + } diff --git a/usecases_examples/PowerGrid/config/API_POWERGRID_CAB.toml b/usecases_examples/PowerGrid/config/API_POWERGRID_CAB.toml index 6fe1a593..ee008a94 100644 --- a/usecases_examples/PowerGrid/config/API_POWERGRID_CAB.toml +++ b/usecases_examples/PowerGrid/config/API_POWERGRID_CAB.toml @@ -19,6 +19,10 @@ event_port = "cab_event/api/v1/events" [Outputs.Context] context_port = "cabcontext/api/v1/contexts" tempo = 30 +# Publish environment_state (identity + replay history) alongside the observation. +serialize_env_state = "yes" +# Cap on the published (compressed) history size, in MB. +max_state_mb = 32 [Inputs.Act] url = "http://127.0.0.1:5000/api/v1/recommendations" diff --git a/usecases_examples/PowerGrid/tests/test_env_serialization.py b/usecases_examples/PowerGrid/tests/test_env_serialization.py new file mode 100644 index 00000000..75047c95 --- /dev/null +++ b/usecases_examples/PowerGrid/tests/test_env_serialization.py @@ -0,0 +1,125 @@ +# Tests the producer side of environment_state, without the Grid2Op stack. +# Run from usecases_examples/PowerGrid: python -m pytest tests -q +import base64 +import gzip +import json + +from app.models.env_serialization import ( + GRID2OP_OBSERVATION_V2, + REPLAY_COMPRESSION, + ReplayRecorder, + build_env_identity, + build_environment_state, +) + +CONFIG = {"env_seed": "2118338672", "scenario_name": "jan_28_1"} + + +class StubAction: + """Stands in for a Grid2Op action, vectorizable or not.""" + + def __init__(self, vector: list, vectorizable: bool): + self._vector = vector + self._vectorizable = vectorizable + + def to_vect(self): + if not self._vectorizable: + raise RuntimeError("cannot vectorize this action") + return _Vector(self._vector) + + +class _Vector: + """Minimal stand-in for the numpy array to_vect() returns.""" + + def __init__(self, values: list): + self._values = values + + def tolist(self) -> list: + return list(self._values) + + +class StubObservation: + """Stands in for the Grid2Op observation being published.""" + + current_step = 12 + + def to_json(self) -> dict: + return {"current_step": [self.current_step]} + + +def _decode(encoded: str) -> list: + """Decodes a published history back into a list of actions.""" + return json.loads(gzip.decompress(base64.b64decode(encoded)).decode("utf-8")) + + +def test_the_published_state_carries_the_history_and_the_identity(): + recorder = ReplayRecorder() + for step in range(3): + recorder.record(StubAction([float(step), 0.0], vectorizable=True)) + + state = build_environment_state( + StubObservation(), build_env_identity(CONFIG), recorder, 32 + ) + + assert state["serializer"] == GRID2OP_OBSERVATION_V2 + assert state["metadata"]["compression"] == REPLAY_COMPRESSION + assert state["metadata"]["replayed_actions"] == 3 + assert state["state"]["seed"] == 2118338672 + assert state["state"]["scenario_name"] == "jan_28_1" + assert _decode(state["state"]["replay_actions"]) == [ + {"vect": [0.0, 0.0]}, + {"vect": [1.0, 0.0]}, + {"vect": [2.0, 0.0]}, + ] + + +def test_a_failed_recording_publishes_nothing_for_the_rest_of_the_episode(): + recorder = ReplayRecorder() + recorder.record(StubAction([1.0], vectorizable=True)) + recorder.record(StubAction([2.0], vectorizable=False)) + recorder.record(StubAction([3.0], vectorizable=True)) + + assert recorder.broken + assert len(recorder) == 0 + assert ( + build_environment_state( + StubObservation(), build_env_identity(CONFIG), recorder, 32 + ) + is None + ) + + # A new episode records again. + recorder.reset() + recorder.record(StubAction([4.0], vectorizable=True)) + + assert not recorder.broken + assert len(recorder) == 1 + + +def test_an_empty_history_publishes_nothing(): + assert ( + build_environment_state( + StubObservation(), build_env_identity(CONFIG), ReplayRecorder(), 32 + ) + is None + ) + + +def test_an_oversized_history_is_dropped(): + recorder = ReplayRecorder() + for step in range(50): + recorder.record(StubAction([float(step)], vectorizable=True)) + + assert ( + build_environment_state( + StubObservation(), build_env_identity(CONFIG), recorder, 0.0 + ) + is None + ) + + +def test_the_identity_reports_the_libraries_it_can_read(): + identity = build_env_identity(CONFIG) + + assert identity["seed"] == 2118338672 + assert identity["scenario_name"] == "jan_28_1" From 9b6085e311e585f154490f78b8c5de32df1d2697 Mon Sep 17 00:00:00 2001 From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com> Date: Tue, 15 Sep 2026 08:46:37 +0200 Subject: [PATCH 5/8] refactor: update installation instructions --- .gitignore | 2 +- README.md | 279 +++++++------------ config/dev/cab-standalone/.secrets.example | 3 +- config/dev/cab-standalone/docker-compose.sh | 15 +- config/dev/cab-standalone/docker-compose.yml | 20 -- local_setup.sh | 122 ++++---- 6 files changed, 169 insertions(+), 272 deletions(-) diff --git a/.gitignore b/.gitignore index c12aeb57..617c9465 100644 --- a/.gitignore +++ b/.gitignore @@ -200,4 +200,4 @@ go.sh !frontend/env/.env /config/dev/cab-standalone/.secrets.pre-simplify -hmisurveys/ \ No newline at end of file +hmisurveys/ diff --git a/README.md b/README.md index 349f0c69..4f82d389 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,12 @@ _Backend_ Getting Started -
  • Usage
  • Development
  • Docs
  • @@ -48,164 +50,114 @@ The platform uses the project **OperatorFabric** for notification management. ### Prerequisites -- [Git (version 2.40.1)](https://git-scm.com/) -- [Docker Engine (version 27)](https://www.docker.com/) -- [Docker Compose V2](https://www.docker.com/) +- Git, Docker Engine 27+, Docker Compose V2, `curl`, `python3` - -### Setting Up the Environment - -Clone the repo of the assistant +### Install ```sh -git clone [repo-url] +git clone [repo-url] && cd InteractiveAI +cp config/dev/cab-standalone/.secrets.example config/dev/cab-standalone/.secrets +# edit .secrets — at minimum RL_AGENT_API_URL / RL_AGENT_API_TOKEN, see "Configuration" +./local_setup.sh ``` -## Usage - -InteractiveAI offers versatile deployment options, leveraging either Docker or Kubernetes. The primary method entails initiating InteractiveAI via Docker to launch all services concurrently. However, recognizing potential resource strain in this mode, we've introduced alternative configurations. These configurations enable selective startup of essential services with minimal dependencies, catering to streamlined versions of certain APIs. -Below are the steps to start all services. For other methods, please consult the developer guide. - -### Running All Services (Dev Mode) - -1. **Set-up environment variables** - -Configuration is read from a gitignored `.secrets` file that `docker-compose.sh` sources. -Copy the template and fill in your values: +To use the local [A3S](a3s-service/README.md) service instead of a remote RL agent, +start it first and add `--a3s` — no `.secrets` change needed: ```sh -cd config/dev/cab-standalone -cp .secrets.example .secrets -# then edit .secrets +cd a3s-service && ./docker/local_setup.sh && cd .. # see a3s-service/README.md +./local_setup.sh --a3s ``` -Key variables (see `.secrets.example` for all options and per-environment values): - -- `VITE_POWERGRID_SIMU` — the frontend's simulator endpoint. Use the same-origin proxy - value `/powergrid-simu` (avoids CORS); set it to `false` to disable the PowerGrid UI. +`local_setup.sh` starts the backend, configures Keycloak, loads the OperatorFabric +resources, rebuilds the frontend and recommendation service from this source tree, +builds and starts the PowerGrid simulator, and verifies the recommendation path end +to end. It prints the URLs and credentials when it is done. + +| Flag | Effect | +| --- | --- | +| *(none)* | full setup; asks what to do if containers from a previous run are up | +| `--clean` | tear those containers down first, no prompt | +| `--wipe` | tear down containers **and** volumes, no prompt | +| `--a3s [URL]` | take recommendations from an already-running [A3S](a3s-service/README.md); default URL `http://host.docker.internal:5010/api/v1/recommendation` | + +It never starts A3S — start that yourself first, or `--a3s` aborts before touching +any container. + +Then log in at http://localhost:3200 as `powergrid_user` / `test`, and in the +simulator (http://localhost:5122) pick server `http://host.docker.internal:3200/`. +Stop everything with `./local_stop.sh` (`--wipe` to drop the data volumes too). + +The last step prints a warning for anything it could not verify — a stale nginx +upstream, an unreachable agent, a simulator/A3S payload mismatch. The UI still comes +up; the PowerGrid recommendation panel is what stays empty. + +### Configuration + +Everything lives in `config/dev/cab-standalone/.secrets` (gitignored, +`docker-compose.sh` sources it). The values that matter: + +- `RL_AGENT_API_URL` / `RL_AGENT_API_TOKEN` — the agent producing PowerGrid + recommendations. Options: + - the **deep expert agent** on this host: `http://host.docker.internal:5123/api/v1/recommendation` + (see [below](#the-powergrid-expert-agent-api)); it requires a token + - the hosted one: `https://interactiveagent.passerelle.irt-systemx.fr/api/v1/recommendation`, + also with a token + - a local **[A3S](a3s-service/README.md)** — no token, and no need to set the URL: + `./local_setup.sh --a3s` overrides it for that run +- `POWERGRID_SIMU_UPSTREAM` — where nginx forwards `/powergrid-simu/`. Local dev: + `http://host.docker.internal:5122/`. +- `VITE_POWERGRID_SIMU` — the frontend's simulator endpoint; keep the same-origin + proxy value `/powergrid-simu` (avoids CORS), or `false` to hide the PowerGrid UI. `VITE_RAILWAY_SIMU` / `VITE_ATM_SIMU` are the equivalents for the other use cases. -- `POWERGRID_SIMU_UPSTREAM` — where nginx actually forwards `/powergrid-simu/`: - - Local dev : `http://host.docker.internal:5122/` (simulator container on the host) - - LAN : `http://192.168.208.61:5100/` - - Public/k8s: same variable, set as an env var on the **frontend pod** (see - `deploy-chart/values.ovh.yaml`). -- `COGNITIVE_TOKEN` — bearer token for the INESCTEC cognitive API. nginx attaches it to - every `/cognitive-api/` request, so the frontend never sees it. It used to be - `VITE_COGNITIVE_TOKEN`, a build-time value inlined into the public JS bundle; that meant - any visitor could read it and rotating it required a full image rebuild. -- `RL_AGENT_API_URL` / `RL_AGENT_API_TOKEN` — the deep expert agent that powers PowerGrid - recommendations (see [The PowerGrid expert agent API](#the-powergrid-expert-agent-api) below to - install it). A token is required in every mode: - - Local dev : `http://host.docker.internal:5123/api/v1/recommendation` (agent on the host) - - Server : `http://192.168.208.61:5000/api/v1/recommendation` - - Public : `https://interactiveagent.passerelle.irt-systemx.fr/api/v1/recommendation` - -> **_NOTE:_** `host.docker.internal` lets the containers reach services (simulator, expert agent) -> running on the host — this is how local dev connects to them. Make sure those host services -> listen on `0.0.0.0` (not only `127.0.0.1`) so the containers can reach them. -> -> **_NOTE:_** For the simulator itself, you can use the example we provide — follow the tutorial -> in [InteractiveAI/usecases_examples/PowerGrid/](/usecases_examples/PowerGrid/README.md). -> -> -### How runtime nginx configuration works - -`POWERGRID_SIMU_UPSTREAM` and `COGNITIVE_TOKEN` are **runtime** values, not -build-time ones. They appear in the nginx config as `__NAME__` placeholders, and -`frontend/start-webui.sh` substitutes them from the matching env var when the container -starts. Changing one is: update the env var (or the k8s secret) and restart the -frontend — no image rebuild. - -To add another: give it a default in `start-webui.sh`, append its name to `SUBST_VARS`, and -use `__NAME__` in the config. If a placeholder survives substitution the container exits -with the name of the missing variable, and the generated config is checked with `nginx -t` -before the daemon starts — so a misconfiguration fails loudly at startup instead of -producing a silently broken proxy. - -`REQUIRED_VARS` (space- or comma-separated) lists the variables that must be **non-empty**; -an empty one aborts startup. It is opt-in because an absent value is not always wrong — -local dev runs the whole stack with no cognitive token and just loses that panel — whereas -on a public deploy an empty token means nginx sends `Bearer ` with nothing after it and -every `/cognitive-api/` call 401s while the pod still reports itself healthy. -`deploy-chart/values.ovh.yaml` therefore sets `REQUIRED_VARS=COGNITIVE_TOKEN`, so the pod -crashloops with the reason in its log and k8s keeps the previous pod serving. - -Two ordering rules follow from all of this, and breaking the first is what silently broke -`/cognitive-api/` once already: - -- **Never push a conf ahead of the pod that has to substitute it.** A placeholder the - running image does not know is left in the config *literally* and goes out in the proxied - request. `deploy-chart/apply-nginx-conf.sh` now refuses to push in that case: it checks - every `__NAME__` in the conf against the deployment's env, and resolves `secretKeyRef`s - to confirm the secret and key actually exist. -- **Verify the config nginx loaded, not the ConfigMap.** nginx runs with an explicit - `-c /personal-conf/nginx.conf`; a bare `nginx -T` re-reads `/etc/nginx/nginx.conf` and the - raw ConfigMap mount, where `proxy_pass __POWERGRID_SIMU_UPSTREAM__;` is not a valid URL — - so it exits non-zero and prints nothing, which reads as a missing location. - -Two things to keep in mind: - -- **In k8s the config does not come from the image.** The `cab-assistant-platform-config` - ConfigMap is mounted over `/etc/nginx/conf.d` and **overrides** the `default.conf` baked - into the image, so every placeholder and every `location` must be present in the ConfigMap - too (`deploy-chart/apply-nginx-conf.sh` pushes just that key). A missing - `/powergrid-simu/` location, for instance, lets the apply POST fall through to the static - `location /`, and nginx answers 405. -- **nginx reads `conf.d` only at startup**, so restart the frontend after any change: - `kubectl -n cab rollout restart deploy/cab-frontend`. - -2. **Run InteractiveAI assistant** -```sh -cd config/dev/cab-standalone -./docker-compose.sh -``` -> **_NOTE:_** You will see the word cab on most files in the project. Note that it was the initial project name of InteractiveAI. Might be updated later. - -3. **Setting up Keycloak `Frontend URL`** - * Access Keycloak Interface: - - Ensure that your Keycloak instance is running and accessible. - - Open a web browser and navigate to the Keycloak admin console, typically available at `http://localhost:89/auth/admin`. - * Login to Keycloak Admin Console: - - Log in to the Keycloak admin console using your administrator credentials (`admin:admin` by default) - * Configure frontendUrl: - - On the Keycloak admin console, locate and click on the "Realm Settings" section. - - In the Frontend URL field, add the URL of InteractiveAI frontend. If your frontend is hosted locally for development purposes, you might add `http://localhost:3200/`. - - After adding the frontend URL, save the changes. - * Configure Valid Redirect URIs: - - On the Keycloak admin console, locate and click on the "Clients" section. - - Select the client (opfab-client). - - Within the client settings, look for the "Valid Redirect URIs" field. - - Add the URL of the frontend with /*, if it's local deployment: `http://localhost:3200/*`. - - After adding the Valid Redirect URIs, save the changes to update the client settings. - - -4. **Load resources** - -**WARNING:** You need to restart the frontend after updating the URL on keycloak do it before loading the resources. -```sh -docker restart frontend -``` - -```sh -cd resources -./loadTestConf.sh -``` - -5. If you encounter CORS errors (which can happen if you start the platform in a non-HTTPS environment), you can start your browser with security mode disabled. - -```sh -your-chromium-browser --disable-web-security --user-data-dir="[some directory here]" # replace your-chromium-browser with your browser -``` - -> **_NOTE:_** If you encounter any issues, please refer to our [troubleshooting guide](docs/troubleshooting.md). +- `COGNITIVE_TOKEN` — bearer token for the INESCTEC cognitive API. nginx attaches it + to every `/cognitive-api/` request, so it never reaches the browser. Empty is fine + locally; you just lose that panel. + +`host.docker.internal` is how the containers reach services on the host (simulator, +agent). Those services must listen on `0.0.0.0`, not only `127.0.0.1`. + +If you hit CORS errors (the platform running without HTTPS), start a Chromium +browser with `--disable-web-security --user-data-dir="[some directory]"`. + +Anything else: [troubleshooting guide](docs/troubleshooting.md). + +### Manual setup + +The same steps by hand, in order. `local_setup.sh` does all of them for you — use +this only when you need to run one in isolation. + +1. **Backend** — `cd config/dev/cab-standalone && ./docker-compose.sh` + (it writes `.env` from `.secrets` and brings the compose project up). +2. **Rebuild from source** — `docker compose up -d --build --force-recreate frontend + cabrecommendation`, in the same directory. Step 1 reuses existing images, so + without this your local changes are not in the running containers. +3. **Keycloak** — in the admin console (http://localhost:89/auth/admin, + `admin`/`admin`), realm `dev`: set **Realm Settings → Frontend URL** to + `http://localhost:3200/`, and add `http://localhost:3200/*` to + **Clients → opfab-client → Valid Redirect URIs**. +4. **Restart the frontend** — `docker restart frontend`, so it picks up that change. + Required before the next step. +5. **Resources** — `cd resources && ./loadTestConf.sh` (registers the use cases). +6. **Simulator** — `cd usecases_examples/PowerGrid && docker compose -f + docker-compose.local.yml up -d --build app`. Use the `.local` compose file: the + default one is the server config and binds the wrong port. See its + [README](/usecases_examples/PowerGrid/README.md). +7. **Reload the gateway** — `docker exec frontend nginx -c /personal-conf/nginx.conf + -s reload`. nginx caches upstream IPs at load, so containers recreated after it + started answer 502 until this is done. + +Check it: `curl localhost:3200/cab_recommendation/api/v1/health` should answer 200, +and the recommendation service must be able to reach `RL_AGENT_API_URL` from inside +its own container. + +> **_NOTE:_** `cab` appears all over the project — it was the original name of +> InteractiveAI. ### The PowerGrid expert agent API -PowerGrid recommendations are produced by a separate service — the **deep expert agent**. The -`cab_recommendation` service calls it at `RL_AGENT_API_URL`, so it must be running (and reachable) -for recommendations to appear in InteractiveAI. - -1. Clone the agent repository and check out the API branch: +`cab_recommendation` calls the deep expert agent at `RL_AGENT_API_URL`, so that agent +must be running for recommendations to appear. ```sh git clone https://github.com/ainetus/T2.1_deep_expert.git @@ -213,23 +165,17 @@ cd T2.1_deep_expert git checkout feat/api-auth-compose ``` -2. Start it by following that repository's README (the `feat/api-auth-compose` branch ships a - Docker Compose and adds token authentication). For local development: - - expose it on port **5123**, and - - make it listen on `0.0.0.0` (not only `127.0.0.1`) so the InteractiveAI containers can reach - it through `host.docker.internal`. - -3. Point InteractiveAI at it in `config/dev/cab-standalone/.secrets`, with a token that matches - the one the agent expects: +Start it per that repo's README (that branch ships a Docker Compose and token auth), +on port **5123** and bound to `0.0.0.0`. Then set in `.secrets`: ```sh export RL_AGENT_API_URL=http://host.docker.internal:5123/api/v1/recommendation export RL_AGENT_API_TOKEN= ``` -Then (re)run `./docker-compose.sh` so `cab_recommendation` picks up the values. For the LAN and -public deployments, use the corresponding `RL_AGENT_API_URL` from step 1 of -[Running All Services](#running-all-services-dev-mode) instead. +The local alternative is [A3S](a3s-service/README.md), which serves the same API and +can project KPIs several timesteps ahead: start it from `a3s-service/` with +`./docker/local_setup.sh`, then run `./local_setup.sh --a3s` here. ### Default ports @@ -244,6 +190,7 @@ Companion services for the PowerGrid use case run on the host (local dev) and ar containers via `host.docker.internal`: * PowerGrid simulator (provided example): 5122 * PowerGrid expert agent API: 5123 +* A3S (if used instead of the expert agent): 5010 ### Authentication data @@ -262,22 +209,6 @@ Some examples of credentials: By default, the system allows the user to be connected only from a single machine. Which means if you try to connect using the same credentials from another machine, you will be disconnected on the first machine. -### OPTIONAL: connecting a local A3S service - -[A3S](a3s-service/README.md) is a local drop-in replacement for the external RL agent API, -also able to project KPIs several timesteps ahead. - -```bash -USE_A3S=1 ./local_setup.sh # full local stack, A3S instead of the remote RL agent -curl localhost:5010/api/v1/health # {"message": "Ok"} -``` - -To iterate on A3S alone, run `./docker/local_setup.sh` from `a3s-service/`, then set -`RL_AGENT_API_URL=http://host.docker.internal:5010/api/v1/recommendation` in -`config/dev/cab-standalone/.secrets` and re-run `./docker-compose.sh` there. The same override -connects any other agent exposing that contract, e.g. a local -[T2.1_deep_expert](https://github.com/ainetus/T2.1_deep_expert) build. Stop A3S with `./local_stop.sh`. - # Development Contributions to the InteractiveAI Assistant Platform are welcome! To contribute, please make sure to use [developer guide](docs/developer-guide.md) diff --git a/config/dev/cab-standalone/.secrets.example b/config/dev/cab-standalone/.secrets.example index c21a7d7d..a8da8ece 100644 --- a/config/dev/cab-standalone/.secrets.example +++ b/config/dev/cab-standalone/.secrets.example @@ -1,8 +1,7 @@ # Copy to .secrets and fill in your values — this file is gitignored # -# export USE_A3S=1 # use the local A3S service instead of the external RL agent -# # RL agent (T2.1_deep_expert) — pick ONE RL_AGENT_API_URL for your environment: +# To use a local A3S instead, do not set this: run ../../../local_setup.sh --a3s. # Local dev : RL agent running on THIS host on port 5123, reached via host.docker.internal # (cab_recommendation has the host-gateway mapping) # Server : RL agent on the LAN host diff --git a/config/dev/cab-standalone/docker-compose.sh b/config/dev/cab-standalone/docker-compose.sh index e2e0f895..1157bf8e 100755 --- a/config/dev/cab-standalone/docker-compose.sh +++ b/config/dev/cab-standalone/docker-compose.sh @@ -45,13 +45,14 @@ echo "HOST_IP=${HOST_IP}" >> .env # Secrets — sourced from .secrets if present (gitignored), otherwise from shell env # In CI these are injected by GitHub Actions as environment variables. +# An RL_AGENT_API_URL already in the environment wins over the file: that is how +# `local_setup.sh --a3s` points this stack at a running A3S without editing +# .secrets, and it would otherwise be silently overwritten here. +_ENV_RL_AGENT_API_URL="${RL_AGENT_API_URL:-}" if [[ -f .secrets ]]; then source .secrets fi -# USE_A3S=1 points RL_AGENT_API_URL at the local A3S service, unless already set. -if [[ "${USE_A3S:-0}" == "1" && -z "${RL_AGENT_API_URL:-}" ]]; then - RL_AGENT_API_URL="http://caba3s:5010/api/v1/recommendation" -fi +RL_AGENT_API_URL="${_ENV_RL_AGENT_API_URL:-${RL_AGENT_API_URL:-}}" echo "RL_AGENT_API_URL=${RL_AGENT_API_URL:-https://interactiveagent.passerelle.irt-systemx.fr/api/v1/recommendation}" >> .env echo "RL_AGENT_API_TOKEN=${RL_AGENT_API_TOKEN:-}" >> .env echo "VITE_POWERGRID_SIMU=${VITE_POWERGRID_SIMU:-/powergrid-simu}" >> .env @@ -63,8 +64,4 @@ echo "COGNITIVE_TOKEN=${COGNITIVE_TOKEN:-}" >> .env # terminal (and in any CI log that runs this script). sed -E 's/^([A-Z_]*(TOKEN|SECRET|PASSWORD)[A-Z_]*)=(.+)$/\1=/; s/^([A-Z_]*(TOKEN|SECRET|PASSWORD)[A-Z_]*)=$/\1=/' .env -if [[ "${USE_A3S:-0}" == "1" ]]; then - docker compose --profile a3s up -d -else - docker compose up -d -fi +docker compose up -d diff --git a/config/dev/cab-standalone/docker-compose.yml b/config/dev/cab-standalone/docker-compose.yml index ca02c70b..cbfa1b6a 100644 --- a/config/dev/cab-standalone/docker-compose.yml +++ b/config/dev/cab-standalone/docker-compose.yml @@ -199,26 +199,6 @@ services: depends_on: - db_postgres_recommendation - # Local A3S (agent-as-a-service) instance. Opt-in via the "a3s" profile. - caba3s: - container_name: cab_a3s - image: cab/caba3s - profiles: ["a3s"] - build: - context: ../../../a3s-service - dockerfile: docker/Dockerfile - args: - # Baked-in default policy; overridable via `environment:` below without a rebuild. - A3S_POWERGRID_AGENT: ${A3S_POWERGRID_AGENT:-t2.1} - restart: unless-stopped - environment: - - FLASK_APP=app:create_app('dev') - - TZ=UTC - # Which policy serves PowerGrid: "t2.1" (default) or "xd". - - A3S_POWERGRID_AGENT=${A3S_POWERGRID_AGENT:-t2.1} - ports: - - 5010:5010 - db_postgres_recommendation: container_name: db_postgres_recommendation image: postgres:14.7 diff --git a/local_setup.sh b/local_setup.sh index b5ef494f..86768583 100755 --- a/local_setup.sh +++ b/local_setup.sh @@ -11,12 +11,17 @@ # ./local_setup.sh # full setup (prompts if containers already run) # ./local_setup.sh --clean # tear down existing containers first, no prompt # ./local_setup.sh --wipe # tear down existing containers AND volumes, no prompt +# ./local_setup.sh --a3s [URL] # take recommendations from an already-running A3S +# # (default URL http://host.docker.internal:5010/api/v1/recommendation) +# +# This script never starts A3S. Start it first from a3s-service/ with +# ./docker/local_setup.sh, then pass --a3s here. # # Overridable via environment: # KC_ADMIN (admin) KC_PW (admin) FRONTEND_URL (http://localhost:3200) # -# Secrets (RL_AGENT_API_URL / RL_AGENT_API_TOKEN / VITE_COGNITIVE_TOKEN / USE_A3S) -# are read from config/dev/cab-standalone/.secrets if present (see .secrets.example). +# Secrets (RL_AGENT_API_URL / RL_AGENT_API_TOKEN / COGNITIVE_TOKEN) are read from +# config/dev/cab-standalone/.secrets if present (see .secrets.example). set -euo pipefail @@ -29,7 +34,9 @@ RESOURCES_DIR="$REPO_ROOT/resources" SIM_DIR="$REPO_ROOT/usecases_examples/PowerGrid" SIM_COMPOSE="docker-compose.local.yml" # server config lives in docker-compose.yml SIM_PORT=5122 # must match POWERGRID_SIMU_UPSTREAM in .env -A3S_PORT=5010 # host port the compose `caba3s` service publishes +# Where --a3s points the recommendation service when no URL is given. host.docker.internal +# is how the containers reach a service published on the host. +A3S_DEFAULT_URL="http://host.docker.internal:5010/api/v1/recommendation" # Serializer name the simulator stamps into environment_state; must match what A3S accepts. SIM_SERIALIZER_SRC="$REPO_ROOT/usecases_examples/PowerGrid/app/models/env_serialization.py" @@ -89,30 +96,22 @@ frontend_nginx_reload() { fi } -# A standalone a3s-service/docker/local_setup.sh can grab port 5010 before the -# compose `caba3s` service starts, leaving it unable to bind. Free the port, -# unless RL_AGENT_API_URL is set explicitly (the operator is pointing elsewhere). -free_a3s_port() { - [[ "${USE_A3S:-0}" == "1" ]] || return 0 - if [[ -n "${RL_AGENT_API_URL:-}" ]]; then - warn "both USE_A3S=1 and RL_AGENT_API_URL are set in .secrets — the explicit URL wins" - warn " using: $RL_AGENT_API_URL" - warn " cab_a3s will still be built and started, but nothing will call it" - warn " for a self-contained stack, comment RL_AGENT_API_URL out and keep USE_A3S=1" - ok "leaving port $A3S_PORT and any standalone A3S as-is" +# --a3s: A3S runs on its own (a3s-service/docker/local_setup.sh), so all this +# does is point the recommendation service at it — after confirming it answers, +# because otherwise the only symptom is an empty recommendation panel at the end +# of a ten-minute setup. +require_a3s() { + local health="${RL_AGENT_API_URL%/recommendation}/health" + # The containers reach it via host.docker.internal; from here it is localhost. + local host_health="${health/host.docker.internal/localhost}" + if wait_for_http "$host_health" 200 3 >/dev/null; then + ok "A3S is answering at $host_health" return 0 fi - local holder - holder="$(docker ps --format '{{.Names}}\t{{.Ports}}' \ - | awk -F'\t' -v p=":$A3S_PORT->" 'index($2, p) {print $1}' \ - | grep -vx cab_a3s || true)" - [[ -z "$holder" ]] && return 0 - warn "port $A3S_PORT is needed by cab_a3s but is held by container '$holder'" - warn "(that is the standalone A3S from a3s-service/docker/local_setup.sh — this stack builds its own)" - local c - for c in $holder; do - docker stop "$c" >/dev/null 2>&1 && ok "stopped '$c' to free port $A3S_PORT (start it again later if you need it)" - done + warn "no A3S answering at $host_health" + warn " start it first: cd $REPO_ROOT/a3s-service && ./docker/local_setup.sh" + warn " or pass the URL: ./local_setup.sh --a3s http://host.docker.internal:/api/v1/recommendation" + die "A3S is not running" } # Checks the gateway route, the RL agent, and the simulator/A3S serializer contract. @@ -127,19 +126,7 @@ verify_recommendation_path() { failures=$(( failures + 1 )) fi - if [[ "${USE_A3S:-0}" == "1" && -z "${RL_AGENT_API_URL:-}" ]]; then - if ! container_running cab_a3s; then - warn "USE_A3S=1 but the cab_a3s container is not running" - warn " check: cd $BACKEND_DIR && docker compose --profile a3s logs caba3s" - failures=$(( failures + 1 )) - elif wait_for_http "http://localhost:$A3S_PORT/api/v1/health" 200 15 >/dev/null; then - ok "A3S is healthy on port $A3S_PORT" - verify_serializer_contract cab_a3s || failures=$(( failures + 1 )) - else - warn "cab_a3s is running but not answering on port $A3S_PORT" - failures=$(( failures + 1 )) - fi - elif [[ -n "${RL_AGENT_API_URL:-}" ]]; then + if [[ -n "${RL_AGENT_API_URL:-}" ]]; then # Resolve from inside the recommendation container, whose network namespace RL_AGENT_API_URL targets. local health_url="${RL_AGENT_API_URL%/recommendation}/health" if container_running cab_recommendation && docker exec cab_recommendation python -c " @@ -169,7 +156,7 @@ except Exception as e: failures=$(( failures + 1 )) fi else - warn "neither USE_A3S=1 nor RL_AGENT_API_URL is set — only the ontology recommender will run" + warn "RL_AGENT_API_URL is not set — only the ontology recommender will run" fi return "$failures" @@ -184,7 +171,7 @@ verify_serializer_contract() { emitted="$(grep -oE "^${emitted} = \"[a-z0-9_]+\"" "$SIM_SERIALIZER_SRC" | sed 's/.*"\(.*\)"/\1/' | head -1)" [[ -n "$emitted" ]] || { warn "could not resolve the simulator's serializer constant"; return 0; } - accepted="$(docker exec "$a3s" sh -c 'grep -hoE "grid2op_observation_v[0-9]+" /my_app/agent_as_a_service/powergrid/serialization.py 2>/dev/null | sort -u' 2>/dev/null || true)" + accepted="$(docker exec "$a3s" sh -c 'grep -hoE "grid2op_observation_v[0-9]+" /my_app/integrations/powergrid/serialization.py 2>/dev/null | sort -u' 2>/dev/null || true)" if [[ -z "$accepted" ]]; then warn "could not read the serializers '$a3s' accepts — skipping the contract check" return 0 @@ -197,11 +184,7 @@ verify_serializer_contract() { warn " simulator emits: $emitted" warn " '$a3s' accepts: $(tr '\n' ' ' <<<"$accepted")" warn " that A3S image predates the simulator's payload format — rebuild it:" - if [[ "$a3s" == "cab_a3s" ]]; then - warn " cd $BACKEND_DIR && docker compose --profile a3s up -d --build --force-recreate caba3s" - else - warn " cd $REPO_ROOT/a3s-service && ./docker/local_setup.sh --rebuild" - fi + warn " cd $REPO_ROOT/a3s-service && ./docker/local_setup.sh --rebuild" return 1 } @@ -312,14 +295,20 @@ handle_existing_containers() { # Main # --------------------------------------------------------------------------- main() { - local CLEAN_MODE="" - for arg in "$@"; do - case "$arg" in + # Deliberately not named USE_A3S: .secrets is sourced into this scope below, + # and an old `export USE_A3S=1` there would silently turn this flag on. + local CLEAN_MODE="" A3S_MODE=0 A3S_URL="" + while (( $# )); do + case "$1" in --clean) CLEAN_MODE="clean" ;; --wipe) CLEAN_MODE="wipe" ;; - -h|--help) sed -n '3,26p' "${BASH_SOURCE[0]}"; exit 0 ;; - *) die "unknown argument: $arg (see --help)" ;; + # --a3s takes an optional URL: anything that is not another flag. + --a3s) A3S_MODE=1 + [[ "${2:-}" == -* || -z "${2:-}" ]] || { A3S_URL="$2"; shift; } ;; + -h|--help) sed -n '3,24p' "${BASH_SOURCE[0]}"; exit 0 ;; + *) die "unknown argument: $1 (see --help)" ;; esac + shift done log "Checking prerequisites" @@ -327,8 +316,8 @@ main() { docker compose version >/dev/null 2>&1 || die "'docker compose' (v2) is required." ok "docker, docker compose, curl, python3 present" - # Source .secrets in THIS shell (docker-compose.sh sources it in a subshell), - # so USE_A3S is visible to the rebuild check further down. + # Source .secrets in THIS shell too (docker-compose.sh sources it in a + # subshell), so the verification step below knows which agent is configured. if [[ -f "$BACKEND_DIR/.secrets" ]]; then # shellcheck disable=SC1091 # path is runtime-resolved, gitignored source "$BACKEND_DIR/.secrets" @@ -337,27 +326,28 @@ main() { warn "copy .secrets.example to .secrets to override (see docker-compose.sh)" fi + # --a3s wins over whatever .secrets says: it is the more explicit statement of + # where recommendations come from for this run. docker-compose.sh sources + # .secrets in its own shell, so export it rather than just setting it. + if (( A3S_MODE )); then + log "Using A3S for recommendations" + export RL_AGENT_API_URL="${A3S_URL:-$A3S_DEFAULT_URL}" + require_a3s + ok "recommendation service will call $RL_AGENT_API_URL" + fi + log "Checking for existing containers" handle_existing_containers "$CLEAN_MODE" - log "Checking the A3S port is available" - free_a3s_port - ok "port $A3S_PORT ready for cab_a3s" - log "Step 1/6 — Starting the InteractiveAI backend" ( cd "$BACKEND_DIR" && ./docker-compose.sh ) ok "backend compose brought up" - # Force a rebuild from this repo's source (docker-compose.sh's `up -d` reuses - # existing images as-is); caba3s only when USE_A3S=1 brought it up. - local rebuild=(frontend cabrecommendation) profile=() - if [[ "${USE_A3S:-0}" == "1" ]]; then - rebuild+=(caba3s) - profile=(--profile a3s) - fi - log "Rebuilding ${rebuild[*]} from this repo's source" - ( cd "$BACKEND_DIR" && docker compose "${profile[@]}" up -d --build --force-recreate "${rebuild[@]}" ) - ok "${rebuild[*]} rebuilt from source" + # Force a rebuild from this repo's source: docker-compose.sh's `up -d` reuses + # existing images as-is, so edits here would otherwise not reach the containers. + log "Rebuilding frontend and cabrecommendation from this repo's source" + ( cd "$BACKEND_DIR" && docker compose up -d --build --force-recreate frontend cabrecommendation ) + ok "frontend, cabrecommendation rebuilt from source" log "Step 2/6 — Waiting for Keycloak" wait_for_http "$KC_BASE/realms/master" 200 || die "Keycloak did not come up on :89" From ad57c1bbb36fa241daf73b166d4ee0cda2ab1d72 Mon Sep 17 00:00:00 2001 From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:33:44 +0200 Subject: [PATCH 6/8] refactor: add local credentials in local_setup.sh --- local_setup.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/local_setup.sh b/local_setup.sh index 86768583..3f134797 100755 --- a/local_setup.sh +++ b/local_setup.sh @@ -395,7 +395,7 @@ main() { printf '\n\033[1;32mSetup complete.\033[0m\n\n' # powergrid_user is provisioned with the PowerGrid entity; publisher_test isn't. printf ' InteractiveAI UI %s (powergrid_user / test)\n' "$(link "$FRONTEND_URL")" - printf ' PowerGrid simulator %s (also proxied same-origin at %s/powergrid-simu/)\n' "$(link "http://localhost:$SIM_PORT")" "$FRONTEND_URL" + printf ' PowerGrid simulator %s (powergrid_user / test) (also proxied same-origin at %s/powergrid-simu/)\n' "$(link "http://localhost:$SIM_PORT")" "$FRONTEND_URL" printf ' Keycloak admin %s (admin / admin)\n' "$(link "$KC_BASE/admin")" printf '\n In the simulator, pick server %s and log in.\n' "$(link "http://host.docker.internal:3200/")" } From 8269fab082469608b0de7af988dd700f9443e3ff Mon Sep 17 00:00:00 2001 From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:33:52 +0200 Subject: [PATCH 7/8] fix(nginx): resolve cognitive API upstream per request so a dead host cannot block startup --- config/dev/cab-standalone/nginx-cors-permissive.conf | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/config/dev/cab-standalone/nginx-cors-permissive.conf b/config/dev/cab-standalone/nginx-cors-permissive.conf index de3e4fe3..de68ddc4 100644 --- a/config/dev/cab-standalone/nginx-cors-permissive.conf +++ b/config/dev/cab-standalone/nginx-cors-permissive.conf @@ -360,7 +360,7 @@ server { proxy_pass __POWERGRID_SIMU_UPSTREAM__; } - location /cognitive-api/ { + location ~ ^/cognitive-api/(?.*)$ { # Proxy for the INESCTEC cognitive API (avoids browser CORS restrictions) if ($request_method = 'OPTIONS') { @@ -382,7 +382,12 @@ server { proxy_set_header Authorization "Bearer __COGNITIVE_TOKEN__"; proxy_ssl_server_name on; proxy_ssl_verify off; - proxy_pass https://wesenss.inesctec.pt/api/v1/; + # The host is kept in a variable on purpose: a literal name here is resolved when + # nginx loads its config, so this third-party host being unreachable would stop the + # whole frontend from starting. As a variable it is resolved per request instead, + # and only this endpoint fails. The location captures the path so no rewrite is needed. + set $cognitive_host https://wesenss.inesctec.pt; + proxy_pass $cognitive_host/api/v1/$cognitive_path$is_args$args; } location /rl-api/ { From af4315a649502aaafb5ea80681c84541817a93e2 Mon Sep 17 00:00:00 2001 From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:20:34 +0200 Subject: [PATCH 8/8] refactor: update readme --- README.md | 265 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 160 insertions(+), 105 deletions(-) diff --git a/README.md b/README.md index 4f82d389..33d32a2e 100644 --- a/README.md +++ b/README.md @@ -22,12 +22,10 @@ _Backend_ Getting Started +
  • Usage
  • Development
  • Docs
  • @@ -50,114 +48,166 @@ The platform uses the project **OperatorFabric** for notification management. ### Prerequisites -- Git, Docker Engine 27+, Docker Compose V2, `curl`, `python3` +- [Git (version 2.40.1)](https://git-scm.com/) +- [Docker Engine (version 27)](https://www.docker.com/) +- [Docker Compose V2](https://www.docker.com/) -### Install + +### Setting Up the Environment + +Clone the repo of the assistant ```sh -git clone [repo-url] && cd InteractiveAI -cp config/dev/cab-standalone/.secrets.example config/dev/cab-standalone/.secrets -# edit .secrets — at minimum RL_AGENT_API_URL / RL_AGENT_API_TOKEN, see "Configuration" -./local_setup.sh +git clone [repo-url] ``` -To use the local [A3S](a3s-service/README.md) service instead of a remote RL agent, -start it first and add `--a3s` — no `.secrets` change needed: +## Usage + +InteractiveAI offers versatile deployment options, leveraging either Docker or Kubernetes. The primary method entails initiating InteractiveAI via Docker to launch all services concurrently. However, recognizing potential resource strain in this mode, we've introduced alternative configurations. These configurations enable selective startup of essential services with minimal dependencies, catering to streamlined versions of certain APIs. +Below are the steps to start all services. For other methods, please consult the developer guide. + +> **_NOTE:_** `./local_setup.sh` runs all the steps below (`./local_stop.sh` stops everything). With `--a3s`, recommendations come from a running [A3S]() instead of the expert agent. + +### Running All Services (Dev Mode) + +1. **Set-up environment variables** + +Configuration is read from a gitignored `.secrets` file that `docker-compose.sh` sources. +Copy the template and fill in your values: ```sh -cd a3s-service && ./docker/local_setup.sh && cd .. # see a3s-service/README.md -./local_setup.sh --a3s +cd config/dev/cab-standalone +cp .secrets.example .secrets +# then edit .secrets ``` -`local_setup.sh` starts the backend, configures Keycloak, loads the OperatorFabric -resources, rebuilds the frontend and recommendation service from this source tree, -builds and starts the PowerGrid simulator, and verifies the recommendation path end -to end. It prints the URLs and credentials when it is done. - -| Flag | Effect | -| --- | --- | -| *(none)* | full setup; asks what to do if containers from a previous run are up | -| `--clean` | tear those containers down first, no prompt | -| `--wipe` | tear down containers **and** volumes, no prompt | -| `--a3s [URL]` | take recommendations from an already-running [A3S](a3s-service/README.md); default URL `http://host.docker.internal:5010/api/v1/recommendation` | - -It never starts A3S — start that yourself first, or `--a3s` aborts before touching -any container. - -Then log in at http://localhost:3200 as `powergrid_user` / `test`, and in the -simulator (http://localhost:5122) pick server `http://host.docker.internal:3200/`. -Stop everything with `./local_stop.sh` (`--wipe` to drop the data volumes too). - -The last step prints a warning for anything it could not verify — a stale nginx -upstream, an unreachable agent, a simulator/A3S payload mismatch. The UI still comes -up; the PowerGrid recommendation panel is what stays empty. - -### Configuration - -Everything lives in `config/dev/cab-standalone/.secrets` (gitignored, -`docker-compose.sh` sources it). The values that matter: - -- `RL_AGENT_API_URL` / `RL_AGENT_API_TOKEN` — the agent producing PowerGrid - recommendations. Options: - - the **deep expert agent** on this host: `http://host.docker.internal:5123/api/v1/recommendation` - (see [below](#the-powergrid-expert-agent-api)); it requires a token - - the hosted one: `https://interactiveagent.passerelle.irt-systemx.fr/api/v1/recommendation`, - also with a token - - a local **[A3S](a3s-service/README.md)** — no token, and no need to set the URL: - `./local_setup.sh --a3s` overrides it for that run -- `POWERGRID_SIMU_UPSTREAM` — where nginx forwards `/powergrid-simu/`. Local dev: - `http://host.docker.internal:5122/`. -- `VITE_POWERGRID_SIMU` — the frontend's simulator endpoint; keep the same-origin - proxy value `/powergrid-simu` (avoids CORS), or `false` to hide the PowerGrid UI. +Key variables (see `.secrets.example` for all options and per-environment values): + +- `VITE_POWERGRID_SIMU` — the frontend's simulator endpoint. Use the same-origin proxy + value `/powergrid-simu` (avoids CORS); set it to `false` to disable the PowerGrid UI. `VITE_RAILWAY_SIMU` / `VITE_ATM_SIMU` are the equivalents for the other use cases. -- `COGNITIVE_TOKEN` — bearer token for the INESCTEC cognitive API. nginx attaches it - to every `/cognitive-api/` request, so it never reaches the browser. Empty is fine - locally; you just lose that panel. - -`host.docker.internal` is how the containers reach services on the host (simulator, -agent). Those services must listen on `0.0.0.0`, not only `127.0.0.1`. - -If you hit CORS errors (the platform running without HTTPS), start a Chromium -browser with `--disable-web-security --user-data-dir="[some directory]"`. - -Anything else: [troubleshooting guide](docs/troubleshooting.md). - -### Manual setup - -The same steps by hand, in order. `local_setup.sh` does all of them for you — use -this only when you need to run one in isolation. - -1. **Backend** — `cd config/dev/cab-standalone && ./docker-compose.sh` - (it writes `.env` from `.secrets` and brings the compose project up). -2. **Rebuild from source** — `docker compose up -d --build --force-recreate frontend - cabrecommendation`, in the same directory. Step 1 reuses existing images, so - without this your local changes are not in the running containers. -3. **Keycloak** — in the admin console (http://localhost:89/auth/admin, - `admin`/`admin`), realm `dev`: set **Realm Settings → Frontend URL** to - `http://localhost:3200/`, and add `http://localhost:3200/*` to - **Clients → opfab-client → Valid Redirect URIs**. -4. **Restart the frontend** — `docker restart frontend`, so it picks up that change. - Required before the next step. -5. **Resources** — `cd resources && ./loadTestConf.sh` (registers the use cases). -6. **Simulator** — `cd usecases_examples/PowerGrid && docker compose -f - docker-compose.local.yml up -d --build app`. Use the `.local` compose file: the - default one is the server config and binds the wrong port. See its - [README](/usecases_examples/PowerGrid/README.md). -7. **Reload the gateway** — `docker exec frontend nginx -c /personal-conf/nginx.conf - -s reload`. nginx caches upstream IPs at load, so containers recreated after it - started answer 502 until this is done. - -Check it: `curl localhost:3200/cab_recommendation/api/v1/health` should answer 200, -and the recommendation service must be able to reach `RL_AGENT_API_URL` from inside -its own container. - -> **_NOTE:_** `cab` appears all over the project — it was the original name of -> InteractiveAI. +- `POWERGRID_SIMU_UPSTREAM` — where nginx actually forwards `/powergrid-simu/`: + - Local dev : `http://host.docker.internal:5122/` (simulator container on the host) + - LAN : `http://192.168.208.61:5100/` + - Public/k8s: same variable, set as an env var on the **frontend pod** (see + `deploy-chart/values.ovh.yaml`). +- `COGNITIVE_TOKEN` — bearer token for the INESCTEC cognitive API. nginx attaches it to + every `/cognitive-api/` request, so the frontend never sees it. It used to be + `VITE_COGNITIVE_TOKEN`, a build-time value inlined into the public JS bundle; that meant + any visitor could read it and rotating it required a full image rebuild. +- `RL_AGENT_API_URL` / `RL_AGENT_API_TOKEN` — the deep expert agent that powers PowerGrid + recommendations (see [The PowerGrid expert agent API](#the-powergrid-expert-agent-api) below to + install it). A token is required in every mode: + - Local dev : `http://host.docker.internal:5123/api/v1/recommendation` (agent on the host) + - Server : `http://192.168.208.61:5000/api/v1/recommendation` + - Public : `https://interactiveagent.passerelle.irt-systemx.fr/api/v1/recommendation` + +> **_NOTE:_** `host.docker.internal` lets the containers reach services (simulator, expert agent) +> running on the host — this is how local dev connects to them. Make sure those host services +> listen on `0.0.0.0` (not only `127.0.0.1`) so the containers can reach them. +> +> **_NOTE:_** For the simulator itself, you can use the example we provide — follow the tutorial +> in [InteractiveAI/usecases_examples/PowerGrid/](/usecases_examples/PowerGrid/README.md). +> +> +### How runtime nginx configuration works + +`POWERGRID_SIMU_UPSTREAM` and `COGNITIVE_TOKEN` are **runtime** values, not +build-time ones. They appear in the nginx config as `__NAME__` placeholders, and +`frontend/start-webui.sh` substitutes them from the matching env var when the container +starts. Changing one is: update the env var (or the k8s secret) and restart the +frontend — no image rebuild. + +To add another: give it a default in `start-webui.sh`, append its name to `SUBST_VARS`, and +use `__NAME__` in the config. If a placeholder survives substitution the container exits +with the name of the missing variable, and the generated config is checked with `nginx -t` +before the daemon starts — so a misconfiguration fails loudly at startup instead of +producing a silently broken proxy. + +`REQUIRED_VARS` (space- or comma-separated) lists the variables that must be **non-empty**; +an empty one aborts startup. It is opt-in because an absent value is not always wrong — +local dev runs the whole stack with no cognitive token and just loses that panel — whereas +on a public deploy an empty token means nginx sends `Bearer ` with nothing after it and +every `/cognitive-api/` call 401s while the pod still reports itself healthy. +`deploy-chart/values.ovh.yaml` therefore sets `REQUIRED_VARS=COGNITIVE_TOKEN`, so the pod +crashloops with the reason in its log and k8s keeps the previous pod serving. + +Two ordering rules follow from all of this, and breaking the first is what silently broke +`/cognitive-api/` once already: + +- **Never push a conf ahead of the pod that has to substitute it.** A placeholder the + running image does not know is left in the config *literally* and goes out in the proxied + request. `deploy-chart/apply-nginx-conf.sh` now refuses to push in that case: it checks + every `__NAME__` in the conf against the deployment's env, and resolves `secretKeyRef`s + to confirm the secret and key actually exist. +- **Verify the config nginx loaded, not the ConfigMap.** nginx runs with an explicit + `-c /personal-conf/nginx.conf`; a bare `nginx -T` re-reads `/etc/nginx/nginx.conf` and the + raw ConfigMap mount, where `proxy_pass __POWERGRID_SIMU_UPSTREAM__;` is not a valid URL — + so it exits non-zero and prints nothing, which reads as a missing location. + +Two things to keep in mind: + +- **In k8s the config does not come from the image.** The `cab-assistant-platform-config` + ConfigMap is mounted over `/etc/nginx/conf.d` and **overrides** the `default.conf` baked + into the image, so every placeholder and every `location` must be present in the ConfigMap + too (`deploy-chart/apply-nginx-conf.sh` pushes just that key). A missing + `/powergrid-simu/` location, for instance, lets the apply POST fall through to the static + `location /`, and nginx answers 405. +- **nginx reads `conf.d` only at startup**, so restart the frontend after any change: + `kubectl -n cab rollout restart deploy/cab-frontend`. + +2. **Run InteractiveAI assistant** +```sh +cd config/dev/cab-standalone +./docker-compose.sh +``` +> **_NOTE:_** You will see the word cab on most files in the project. Note that it was the initial project name of InteractiveAI. Might be updated later. + +3. **Setting up Keycloak `Frontend URL`** + * Access Keycloak Interface: + - Ensure that your Keycloak instance is running and accessible. + - Open a web browser and navigate to the Keycloak admin console, typically available at `http://localhost:89/auth/admin`. + * Login to Keycloak Admin Console: + - Log in to the Keycloak admin console using your administrator credentials (`admin:admin` by default) + * Configure frontendUrl: + - On the Keycloak admin console, locate and click on the "Realm Settings" section. + - In the Frontend URL field, add the URL of InteractiveAI frontend. If your frontend is hosted locally for development purposes, you might add `http://localhost:3200/`. + - After adding the frontend URL, save the changes. + * Configure Valid Redirect URIs: + - On the Keycloak admin console, locate and click on the "Clients" section. + - Select the client (opfab-client). + - Within the client settings, look for the "Valid Redirect URIs" field. + - Add the URL of the frontend with /*, if it's local deployment: `http://localhost:3200/*`. + - After adding the Valid Redirect URIs, save the changes to update the client settings. + + +4. **Load resources** + +**WARNING:** You need to restart the frontend after updating the URL on keycloak do it before loading the resources. +```sh +docker restart frontend +``` + +```sh +cd resources +./loadTestConf.sh +``` + +5. If you encounter CORS errors (which can happen if you start the platform in a non-HTTPS environment), you can start your browser with security mode disabled. + +```sh +your-chromium-browser --disable-web-security --user-data-dir="[some directory here]" # replace your-chromium-browser with your browser +``` + +> **_NOTE:_** If you encounter any issues, please refer to our [troubleshooting guide](docs/troubleshooting.md). ### The PowerGrid expert agent API -`cab_recommendation` calls the deep expert agent at `RL_AGENT_API_URL`, so that agent -must be running for recommendations to appear. +PowerGrid recommendations are produced by a separate service — the **deep expert agent**. The +`cab_recommendation` service calls it at `RL_AGENT_API_URL`, so it must be running (and reachable) +for recommendations to appear in InteractiveAI. + +1. Clone the agent repository and check out the API branch: ```sh git clone https://github.com/ainetus/T2.1_deep_expert.git @@ -165,17 +215,23 @@ cd T2.1_deep_expert git checkout feat/api-auth-compose ``` -Start it per that repo's README (that branch ships a Docker Compose and token auth), -on port **5123** and bound to `0.0.0.0`. Then set in `.secrets`: +2. Start it by following that repository's README (the `feat/api-auth-compose` branch ships a + Docker Compose and adds token authentication). For local development: + - expose it on port **5123**, and + - make it listen on `0.0.0.0` (not only `127.0.0.1`) so the InteractiveAI containers can reach + it through `host.docker.internal`. + +3. Point InteractiveAI at it in `config/dev/cab-standalone/.secrets`, with a token that matches + the one the agent expects: ```sh export RL_AGENT_API_URL=http://host.docker.internal:5123/api/v1/recommendation export RL_AGENT_API_TOKEN= ``` -The local alternative is [A3S](a3s-service/README.md), which serves the same API and -can project KPIs several timesteps ahead: start it from `a3s-service/` with -`./docker/local_setup.sh`, then run `./local_setup.sh --a3s` here. +Then (re)run `./docker-compose.sh` so `cab_recommendation` picks up the values. For the LAN and +public deployments, use the corresponding `RL_AGENT_API_URL` from step 1 of +[Running All Services](#running-all-services-dev-mode) instead. ### Default ports @@ -190,7 +246,6 @@ Companion services for the PowerGrid use case run on the host (local dev) and ar containers via `host.docker.internal`: * PowerGrid simulator (provided example): 5122 * PowerGrid expert agent API: 5123 -* A3S (if used instead of the expert agent): 5010 ### Authentication data