diff --git a/.gitignore b/.gitignore
index ae6f754d..212d5268 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,9 @@
# Sonarqube
.scannerwork
+# Externally cloned service (T2.1 deep expert agent), not part of this repo
+/T2.1_deep_expert/
+
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
diff --git a/README.md b/README.md
index ff525969..4d661aaa 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,7 @@ _Backend_
Getting Started
@@ -46,6 +47,20 @@ The platform uses the project **OperatorFabric** for notification management.
## Getting Started
+### Quick Start: Local Install on a Single Machine
+
+If you just want everything running on one machine, you don't need to follow the
+manual steps below — the repo ships two helper scripts that do it all:
+
+```sh
+./local_setup.sh # start the whole stack (backend, frontend, Keycloak, PowerGrid simulator)
+./local_stop.sh # tear it down again
+```
+
+`./local_setup.sh --help` and `./local_stop.sh --help` list the available options
+(e.g. `--clean` / `--wipe` for a fresh start, `--pause` to keep containers around).
+The rest of this section describes the manual/multi-machine setup.
+
### Prerequisites
- [Git (version 2.40.1)](https://git-scm.com/)
@@ -155,6 +170,34 @@ 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 RL agent (T2.1 deep-expert)
+
+By default, PowerGrid recommendations use the external RL agent API. To run and
+connect a local agent instead:
+
+```bash
+git clone -b docker https://github.com/ainetus/T2.1_deep_expert.git
+cd T2.1_deep_expert
+docker build -t t2-1-deep-expert .
+docker run -d --name t2-1-deep-expert -p 8000:8000 t2-1-deep-expert
+```
+
+Then point `cabrecommendation` at it:
+1. In `config/dev/cab-standalone/docker-compose.yml`, add to the `cabrecommendation` service:
+ ```yaml
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
+ ```
+2. In `config/dev/cab-standalone/.secrets` (copy from `.secrets.example` if needed), set:
+ ```bash
+ export RL_AGENT_API_URL=http://host.docker.internal:8000/api/v1/recommendation
+ ```
+3. Re-run `./docker-compose.sh` from that directory to regenerate `.env` and recreate `cabrecommendation`.
+
+To disconnect, remove the `extra_hosts` block and `.secrets` (or its
+`RL_AGENT_API_URL` line) and re-run `./docker-compose.sh` again — this restores
+the default external RL agent URL.
+
# 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/Recommendation-Service-Dockerfile b/backend/Recommendation-Service-Dockerfile
index 842f23e6..94f848e6 100644
--- a/backend/Recommendation-Service-Dockerfile
+++ b/backend/Recommendation-Service-Dockerfile
@@ -8,19 +8,21 @@ RUN apt-get update && apt-get install -y default-jre
RUN mkdir /my_app
RUN mkdir /cab_common
-# Install recommendation-service modules
-COPY ./recommendation-service/ /my_app/
-COPY ./cab_common/ /cab_common/
-
+# Install the (large: torch/tensorflow/grid2op/lightsim2grid) Python deps
+# before copying the rest of the source, so this slow layer is cached and
+# only rebuilt when requirements.txt actually changes, not on every source
+# edit. The pip cache mount persists downloaded wheels across builds too,
+# so even a real requirements.txt change or a retry after a network blip
+# doesn't have to re-download unchanged packages.
WORKDIR /my_app
-
-RUN pip3 install -r requirements.txt
+COPY ./recommendation-service/requirements.txt .
+RUN --mount=type=cache,target=/root/.cache/pip pip3 install -r requirements.txt
WORKDIR /cab_common
-
-RUN pip3 install .
-
+COPY ./cab_common/ .
+RUN --mount=type=cache,target=/root/.cache/pip pip3 install .
WORKDIR /my_app
+COPY ./recommendation-service/ .
CMD ["./entrypoint.sh"]
diff --git a/backend/recommendation-service/requirements.txt b/backend/recommendation-service/requirements.txt
index 7eb1ae7c..920f0ab7 100644
Binary files a/backend/recommendation-service/requirements.txt and b/backend/recommendation-service/requirements.txt differ
diff --git a/backend/recommendation-service/resources/PowerGrid/manager.py b/backend/recommendation-service/resources/PowerGrid/manager.py
index bb4a12b8..1464dabc 100644
--- a/backend/recommendation-service/resources/PowerGrid/manager.py
+++ b/backend/recommendation-service/resources/PowerGrid/manager.py
@@ -1,6 +1,7 @@
import json
import os
import warnings
+from enum import Enum
import requests
import urllib3
@@ -8,11 +9,16 @@
from owlready2 import get_ontology
from settings import logger
-from .PowerGridgrid2op_poc_simulator.assistant_manager import AgentType
-
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
+class AgentType(Enum):
+ """Recommendations' agent type: "onto" (ontology) or "IA" (RL agent)."""
+
+ onto = 1
+ IA = 2
+
+
class PowerGridManager(BaseRecommendationManager):
"""PowerGrid recomendation service
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..d9c8b84f
--- /dev/null
+++ b/backend/recommendation-service/tests/test_smoke_pipeline.py
@@ -0,0 +1,56 @@
+"""End-to-end smoke test of the PowerGrid recommendation pipeline.
+
+There is no local RL agent service in this repo — PowerGrid recommendations
+combine an *external* RL agent API (best-effort, network-dependent) with the
+ontology recommender that ships in this service. This test isolates the part
+that must always work offline: post a context/event exactly as the simulator
+serializes it, go through the real Flask view + auth + use-case dispatch, and
+get back at least one well-formed ontology recommendation. The RL agent call
+is stubbed out so the test doesn't depend on network access to the external
+service.
+"""
+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
+):
+ """Post a simulator-shaped context and get an actionable ontology recommendation back."""
+ # No local/reachable RL agent in this environment — stub it out so the
+ # pipeline is exercised deterministically, offline, end to end.
+ 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
+
+ # Every recommendation is well-formed and tagged with its source.
+ for reco in recommendations:
+ assert reco["use_case"] == "PowerGrid"
+ assert reco["agent_type"] in {"IA", "onto"}
+ assert reco["title"]
+ assert "kpis" in reco
+
+ # With the RL agent stubbed out, only the ontology recommender can have
+ # answered — confirm the pipeline actually produced one (not just the
+ # "no recommendation found" default), with an efficiency KPI attached.
+ 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/backend/recommendation-service/tests/test_views.py b/backend/recommendation-service/tests/test_views.py
index 01c0ba42..e28d2a27 100644
--- a/backend/recommendation-service/tests/test_views.py
+++ b/backend/recommendation-service/tests/test_views.py
@@ -12,7 +12,7 @@
def test_PowerGrid_get_recommendation(client, create_usecases, PowerGrid_auth_mocker):
recommendation_data = {}
# Opening JSON file
- with open("tests/tests_resources/PowerGrid_recommendation.json") as json_file:
+ with open("tests/tests_resources/rte_recommendation.json") as json_file:
recommendation_data = json.load(json_file)
headers = {"Authorization": f"Bearer {POWERGRID_BEARER_TOKEN}"}
response = client.post(
diff --git a/changelog/CHANGELOG.md b/changelog/CHANGELOG.md
new file mode 100644
index 00000000..4badf4c2
--- /dev/null
+++ b/changelog/CHANGELOG.md
@@ -0,0 +1,158 @@
+# Changelog
+
+Bug fixes and infrastructure/feature changes made to this repo, in the order
+they were done. Each entry says what was broken, how it was found/verified,
+and what changed.
+
+---
+
+## 1. Added `local_setup.sh` / `local_stop.sh`
+
+One-shot scripts to bring the local stack up and down: start the
+`cab-standalone` backend, wait for Keycloak and configure it via the admin
+REST API, rebuild `frontend`/`cabrecommendation` from source, load
+OperatorFabric resources and register use cases, and build/start the
+PowerGrid simulator. `local_stop.sh` supports `--pause` (stop, keep containers),
+default (remove containers, keep data volumes), and `--wipe` (remove
+volumes too). Verified with a full live `docker compose` bring-up and both
+teardown modes.
+
+## 2. Port conflict: `cabcontext` vs. PowerGrid simulator (both `5100`)
+
+`config/dev/cab-standalone/docker-compose.yml` published `cabcontext` on host
+port `5100`, colliding with the PowerGrid simulator app (also `5100`).
+Remapped `cabcontext` to `5101:5000`.
+
+## 3. Hardcoded personal LAN IP in the PowerGrid simulator proxy
+
+`nginx-cors-permissive.conf`'s `/powergrid-simu/` location proxied to
+`http://192.168.208.61:5100/` — a machine-specific address that only worked
+on the machine it was written on. Replaced with `http://host.docker.internal:5100/`,
+and added `extra_hosts: ["host.docker.internal:host-gateway"]` to the
+`frontend` service (cab-standalone) and to the PowerGrid simulator's `app`
+service, so the mapping resolves on any machine. Also added
+`cab_server_url_0 = "http://host.docker.internal:3200/"` to
+`API_POWERGRID_CAB.toml` for a working local-dev server option in the
+simulator's login page.
+
+## 4. Entity assignment to `publisher_test` missing from `resources/loadTestConf.sh`
+
+Without it, `publisher_test` had no entities assigned and the Home page
+showed no entity cards, making PowerGrid/ATM/Railway unreachable. Added a
+`PUT /users/users/publisher_test` call (assigning `PowerGrid`, `ATM`,
+`Railway`) after use cases are registered.
+
+## 5. `usecases_examples/PowerGrid/Dockerfile.app` was cache-unfriendly and bloated
+
+Code was copied before `requirements-app.txt` (busts the dependency layer
+cache on every source edit), the apt install used no
+`--no-install-recommends` and didn't clean `/var/lib/apt/lists`, and a stray
+`EXPOSE 5000` remained despite port publishing being handled by compose.
+Reordered (deps before source) and cleaned up.
+
+## 6. PowerGrid recommendation-service could not import
+
+`backend/recommendation-service/resources/PowerGrid/manager.py` imported
+`AgentType` from a `PowerGridgrid2op_poc_simulator` package that doesn't
+exist in this repo. Every PowerGrid use-case registration
+(`POST /api/v1/usecases`) raised `ModuleNotFoundError` — the ontology
+recommendation pipeline for PowerGrid was dead on arrival. Fixed by inlining
+the small `AgentType` enum directly in `manager.py`. Verified live: use-case
+registration, `POST /api/v1/recommendation`, and the smoke test (below) all
+pass.
+
+## 7. Broken/incomplete test infrastructure in `backend/recommendation-service`
+
+- `tests/test_views.py::test_PowerGrid_get_recommendation` opened
+ `tests/tests_resources/PowerGrid_recommendation.json`, which doesn't exist —
+ only `rte_recommendation.json` (same shape/content) is present. Fixed the
+ filename.
+- `pytest-mock` was missing from `requirements.txt` despite `conftest.py`'s
+ `PowerGrid_auth_mocker` fixture already depending on the `mocker` fixture —
+ the existing test suite could not have run as committed. Added
+ `pytest-mock==3.14.0`.
+- Added `test_smoke_pipeline.py`: an end-to-end smoke test of the PowerGrid
+ recommendation pipeline. It stubs out the external RL call (no local agent
+ to exercise by default) and asserts the ontology-recommendation path
+ returns a well-formed recommendation through the real Flask view/auth
+ stack. All 3 tests in the service pass (verified inside the actual
+ `cab_recommendation` container, which has the full dependency stack —
+ running `pytest` bare on a host machine without `cab_common` installed and
+ the right `PYTHONPATH`/cwd will not work).
+
+## 8. Frontend: auto-logout on a dead session token was disabled ("TEMP HACK (eval-demo)")
+
+Reported symptom: repeated `Unauthorized` on `/cabcontext/api/v1/contexts`
+with no recovery. Root cause: `frontend/src/plugins/http.ts` had the recovery
+block (call `authStore.checkToken()` on a failed request, log out and
+redirect to `/login` if the token is truly dead) fully commented out, marked
+`TODO: TEMP HACK (eval-demo) — MUST BE REMOVED before next release`. Once a
+token stopped being valid (e.g. idle session timeout), the frontend had no
+recovery path and kept resending the same dead token forever. Re-enabled the
+block. Rebuilt and confirmed the bundle no longer contains the hack.
+
+## 9. Frontend: `getRecommendation` silently dropped the `use_case` query parameter
+
+Reported symptom: clicking "fetch recommendations" failed with `400 Bad
+Request`. Root cause: `frontend/src/api/services.ts`'s `getRecommendation`
+didn't accept or send a `use_case` parameter at all, but the
+recommendation-service requires one when a token is registered for more than
+one entity (e.g. `publisher_test` with `Railway;ATM;PowerGrid`). Fixed by
+adding `use_case` as an optional query param on `getRecommendation` in
+`api/services.ts`, passed from `stores/services.ts` as
+`event.entityRecipients[0]`. Reproduced the exact 400 with a real
+multi-entity token and confirmed the fix resolves it (200 with a real
+ontology recommendation).
+
+## 10. Frontend: `applyRecommendation` was faking success for ATM/Railway/PowerGrid ("TEMP HACK (eval-demo)")
+
+Three separate copies of the same disabled-with-fake-success pattern:
+
+- `frontend/src/api/services.ts` — dead code, not imported/used anywhere
+ (each entity has its own `applyRecommendation` in `entities//api.ts`).
+ Restored the real `http.post('/api/v1/recommendations', data)` call anyway,
+ for consistency.
+- `frontend/src/entities/ATM/api.ts` and `frontend/src/entities/Railway/api.ts`
+ — these ARE live/used by the UI. Restored their real simulator calls
+ (`VITE_ATM_SIMU + '/update-flight-plan'`, `VITE_RAILWAY_SIMU + '/transport_plan'`).
+ Note: this repo has no ATM or Railway simulator (only
+ `usecases_examples/PowerGrid` exists, and `VITE_ATM_SIMU`/`VITE_RAILWAY_SIMU`
+ default to `"false"`), so applying a recommendation for those two entities
+ will now fail with a real network error instead of silently pretending to
+ succeed — expected until those simulators exist or are pointed at a real
+ endpoint.
+
+PowerGrid's own `applyRecommendation` (`entities/PowerGrid/api.ts`) was
+already live and was not touched.
+
+## 11. PowerGrid simulator login page was in French
+
+`usecases_examples/PowerGrid/app/templates/index.html` had `lang="fr"` and
+all UI text (labels, buttons, alerts) in French, while the rest of the app
+(flash messages) is in English. Translated the page to English (`lang="en"`,
+"Login", "Select a server", "Username", "Password", error alerts, etc).
+Verified live at `http://localhost:5100/`.
+
+## 12. Documented how to connect a local RL agent
+
+Added a README section describing how to clone, build, and run the
+`T2.1_deep_expert` RL agent service locally, and how to wire it into
+`cabrecommendation` (`extra_hosts` + `RL_AGENT_API_URL` in `.secrets`) or
+revert back to the default external agent URL. Verified end-to-end with a
+live local agent: a PowerGrid recommendation request returned both a real
+RL-agent recommendation and the ontology recommendation together. The
+default state of this repo is disconnected (external agent URL, no local
+`extra_hosts`/`.secrets` override).
+
+**Known follow-up, not yet fixed:** the RL agent returns `agent_type` as a
+raw enum int (`2`) rather than the string `"IA"` that the ontology path uses
+and that the frontend (`Recommendations.vue`/`Assistant.vue`) checks
+against — a recommendation from that agent may not render/behave correctly
+in the UI as a result.
+
+## Not investigated / out of scope so far
+
+- The PowerGrid simulator's "line lost" event branch does not call the CAB
+ recommendation API the way the "overload anticipation" branch does (only
+ pauses for manual continuation).
+- The `agent_type` int-vs-string mismatch noted in item 12.
diff --git a/config/dev/cab-standalone/docker-compose.yml b/config/dev/cab-standalone/docker-compose.yml
index 83efc64d..e0944d12 100644
--- a/config/dev/cab-standalone/docker-compose.yml
+++ b/config/dev/cab-standalone/docker-compose.yml
@@ -19,7 +19,9 @@ services:
restart: unless-stopped
command: sh -c "./entrypoint.sh"
ports:
- - 5100:5000
+ # was 5100:5000, which collides with the PowerGrid simulator app (also
+ # published on host port 5100) — see usecases_examples/PowerGrid/docker-compose.yml
+ - 5101:5000
volumes:
- ../../../backend/context-service:/code
depends_on:
@@ -149,6 +151,11 @@ services:
- './nginx-cors-permissive.conf:/etc/nginx/conf.d/default.conf'
ports:
- '3200:80'
+ # nginx proxies /powergrid-simu/ to the PowerGrid simulator running on the
+ # host (see nginx-cors-permissive.conf); host.docker.internal resolves to
+ # the host gateway on Linux only if mapped explicitly.
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
depends_on:
- cabrecommendation
- cabevent
diff --git a/config/dev/cab-standalone/nginx-cors-permissive.conf b/config/dev/cab-standalone/nginx-cors-permissive.conf
index d7b19b5f..ff6f014b 100644
--- a/config/dev/cab-standalone/nginx-cors-permissive.conf
+++ b/config/dev/cab-standalone/nginx-cors-permissive.conf
@@ -345,7 +345,11 @@ server {
location /powergrid-simu/ {
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $remote_addr;
- proxy_pass http://192.168.208.61:5100/;
+ # FIX: was a hardcoded machine-specific LAN IP (192.168.208.61), which
+ # only worked on the machine it was written on. host.docker.internal
+ # resolves to the host gateway on any machine (mapped via extra_hosts
+ # on the frontend service in docker-compose.yml).
+ proxy_pass http://host.docker.internal:5100/;
}
location /cognitive-api/ {
diff --git a/frontend/src/api/services.ts b/frontend/src/api/services.ts
index cdfda14d..9c144e45 100644
--- a/frontend/src/api/services.ts
+++ b/frontend/src/api/services.ts
@@ -9,12 +9,20 @@ import type { Procedure } from '@/types/procedure'
import type { FullContext, Recommendation, Trace } from '@/types/services'
import { recordTraceForSession } from '@/utils/traceSessionExport'
-export function getRecommendation(payload: {
- event: Card['data']['metadata']
- context: Context
- cognitive_snapshot?: CognitiveSnapshot
-}) {
- return http.post[]>('/cab_recommendation/api/v1/recommendation', payload)
+export function getRecommendation(
+ payload: {
+ event: Card['data']['metadata']
+ context: Context
+ cognitive_snapshot?: CognitiveSnapshot
+ },
+ // FIX: the recommendation-service returns 400 ("specify use_case") when the
+ // user's token is registered for more than one entity (here Railway;ATM;PowerGrid)
+ // and no use_case is given. Pass the entity so the right use-case manager is picked.
+ use_case?: string
+) {
+ return http.post[]>('/cab_recommendation/api/v1/recommendation', payload, {
+ params: { use_case }
+ })
}
export function getContext() {
@@ -51,13 +59,8 @@ export function sendTrace(payload: Trace) {
return http.post>('/cabhistoric/api/v1/traces', tracePayload)
}
-// TODO: TEMP HACK (eval-demo) — MUST BE REMOVED before next release
-// The real API call is disabled and replaced with a fake success response for demo purposes.
export function applyRecommendation(data: Action) {
- // [DISABLED] Simulator API is inactive — returning fake success for demo
- // To restore: uncomment the http.post and remove the Promise.resolve
- // return http.post<{ message: string }>('/api/v1/recommendations', data)
- return Promise.resolve({ data: { message: 'ok (simulated)' } }) // TEMP HACK: remove this line
+ return http.post<{ message: string }>('/api/v1/recommendations', data)
}
export function getProcedure(event_type: string) {
diff --git a/frontend/src/entities/ATM/api.ts b/frontend/src/entities/ATM/api.ts
index ec9caf8c..8e907809 100644
--- a/frontend/src/entities/ATM/api.ts
+++ b/frontend/src/entities/ATM/api.ts
@@ -1,11 +1,6 @@
-import http from '@/plugins/http'
+import { postSimulatorAction } from '@/plugins/http'
import type { Action } from '@/types/entities'
-// TODO: TEMP HACK (eval-demo) — MUST BE REMOVED before next release
-// The real ATM simulator API call is disabled and replaced with a fake success response for demo purposes.
export function applyRecommendation(data: Action<'ATM'>) {
- // [DISABLED] Simulator API is inactive — returning fake success for demo
- // To restore: uncomment the http.post and remove the Promise.resolve
- // return http.post<{ message: string }>(import.meta.env.VITE_ATM_SIMU + '/update-flight-plan', data)
- return Promise.resolve({ data: { message: 'ok (simulated)' } }) // TEMP HACK: remove this line
+ return postSimulatorAction(import.meta.env.VITE_ATM_SIMU, '/update-flight-plan', data)
}
diff --git a/frontend/src/entities/Railway/api.ts b/frontend/src/entities/Railway/api.ts
index c1157ea1..662383d4 100644
--- a/frontend/src/entities/Railway/api.ts
+++ b/frontend/src/entities/Railway/api.ts
@@ -1,12 +1,6 @@
-import http from '@/plugins/http'
+import { postSimulatorAction } from '@/plugins/http'
import type { Action } from '@/types/entities'
-// TODO: TEMP HACK (eval-demo) — MUST BE REMOVED before next release
-// The real Railway simulator API call is disabled and replaced with a fake success response for demo purposes.
-// To restore: uncomment the http.post line and delete the Promise.resolve line.
export function applyRecommendation(data: Action<'Railway'>) {
- // [DISABLED] Simulator API is inactive — returning fake success for demo
- // To restore: uncomment the http.post and remove the Promise.resolve
- // return http.post<{ message: string }>(import.meta.env.VITE_RAILWAY_SIMU + '/transport_plan', data)
- return Promise.resolve({ data: { message: 'ok (simulated)' } }) // TEMP HACK: remove this line
+ return postSimulatorAction(import.meta.env.VITE_RAILWAY_SIMU, '/transport_plan', data)
}
diff --git a/frontend/src/plugins/http.ts b/frontend/src/plugins/http.ts
index 73da579b..7b5ed384 100644
--- a/frontend/src/plugins/http.ts
+++ b/frontend/src/plugins/http.ts
@@ -43,32 +43,28 @@ http.interceptors.response.use(
async function (error: AxiosError) {
const authStore = useAuthStore()
const appStore = useAppStore()
- // TODO: TEMP HACK (eval-demo) — MUST BE REMOVED before next release
- // Auto-logout on token expiry is fully disabled. Users stay logged in even when the token expires.
- // This was done to avoid interruptions during the demo. Re-enable the block below when done.
- // [DISABLED] Auto-logout on expired token — commented out to stay logged in despite failed requests
// If request failed, check if token is expired
- // if (error.config?.url !== '/auth/check_token' && authStore.token?.access_token) {
- // const res = await authStore.checkToken()
- // if (!res) {
- // appStore._modals = []
- // appStore.addModal({
- // data: t('modal.error.DISCONNECTED'),
- // type: 'info',
- // callback: () => {
- // appStore.status.requests = []
- // }
- // })
- // authStore.logout()
- // router.push({ name: 'login' })
- // return
- // }
- // } else {
- // // If the request that failed was the token check,
- // // then it is probably a network error and simply log out the user
- // authStore.logout()
- // router.push({ name: 'login' })
- // }
+ if (error.config?.url !== '/auth/check_token' && authStore.token?.access_token) {
+ const res = await authStore.checkToken()
+ if (!res) {
+ appStore._modals = []
+ appStore.addModal({
+ data: t('modal.error.DISCONNECTED'),
+ type: 'info',
+ callback: () => {
+ appStore.status.requests = []
+ }
+ })
+ authStore.logout()
+ router.push({ name: 'login' })
+ return
+ }
+ } else {
+ // If the request that failed was the token check,
+ // then it is probably a network error and simply log out the user
+ authStore.logout()
+ router.push({ name: 'login' })
+ }
appStore.status.requests[appStore.status.requests.findIndex((el) => el.data.url)] = {
state: 'ERROR',
data: error
@@ -100,3 +96,10 @@ http.interceptors.response.use(
}
)
export default http
+
+// Shared by the per-entity `applyRecommendation` calls (ATM, Railway, …),
+// which all post an action to `` and expect the
+// same `{ message: string }` shape back.
+export function postSimulatorAction(baseUrl: string, path: string, data: T) {
+ return http.post<{ message: string }>(baseUrl + path, data)
+}
diff --git a/frontend/src/stores/services.ts b/frontend/src/stores/services.ts
index 92f3c7f7..9a990056 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'
@@ -123,7 +123,9 @@ export const useServicesStore = defineStore('services', () => {
if (hasCognitiveConsent()) {
payload.cognitive_snapshot = await fetchCognitiveSnapshot()
}
- const { data } = await servicesApi.getRecommendation(payload)
+ // FIX: pass the card's entity as use_case so the recommendation-service
+ // can select the right use-case manager (avoids 400 for multi-entity tokens).
+ const { data } = await servicesApi.getRecommendation(payload, event.entityRecipients[0])
_recommendations.value = data
}
diff --git a/local_setup.sh b/local_setup.sh
new file mode 100755
index 00000000..e5f2890c
--- /dev/null
+++ b/local_setup.sh
@@ -0,0 +1,267 @@
+#!/usr/bin/env bash
+#
+# local_setup.sh — one-shot local setup for the InteractiveAI backend + PowerGrid simulator.
+#
+# Assumptions about the stack this brings up:
+# - there is no local RL agent service in this repo: PowerGrid
+# recommendations combine the external RL agent API (RL_AGENT_API_URL,
+# proxied same-origin via /rl-api/) with the ontology recommender that
+# ships in recommendation-service.
+# - the PowerGrid simulator's mailbox is reached same-origin through the
+# frontend gateway at /powergrid-simu/, not a separate host:5100 URL.
+#
+# Steps automated:
+# 1. Start the InteractiveAI backend (cab-standalone compose)
+# 2. Wait for Keycloak + the frontend gateway to come up
+# 3. Configure Keycloak (realm Frontend URL) via the admin REST API — no
+# manual admin-console clicking. Falls back to a manual prompt only if
+# the API call fails.
+# 4. Load OperatorFabric resources / register use cases / assign entities
+# 5. Build and start the PowerGrid simulator
+#
+# 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) are
+# read from config/dev/cab-standalone/.secrets if present (see .secrets.example);
+# docker-compose.sh falls back to safe defaults otherwise.
+
+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"
+
+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 so URLs are clickable even in terminals that
+# don't auto-detect bare "localhost" URLs. Degrades to plain text where the
+# escape isn't supported (the URL is used as the visible label either way).
+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."; }
+
+# ---------------------------------------------------------------------------
+# 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; }
+
+ # Set the realm Frontend URL — without it, token issuer URLs don't match what
+ # the backend expects and every authenticated call returns 401. (The client
+ # redirect URIs / web origins already ship correct in the dev-realm export.)
+ 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 the script 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
+# ---------------------------------------------------------------------------
+# List running containers of the compose project rooted at $1, one
+# " name (status)" line each (empty output means none are running).
+compose_running() {
+ ( cd "$1" && docker compose 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 down $extra 2>/dev/null ) || true
+ ( cd "$BACKEND_DIR" && docker compose down $extra 2>/dev/null ) || true
+ ok "existing containers removed"
+}
+
+# If any of our containers are already running, ask what to do. $1 is the mode
+# decided by flags: "" ask, "clean" down, "wipe" down -v.
+handle_existing_containers() {
+ local mode="${1:-}" running
+ running="$(compose_running "$BACKEND_DIR"; compose_running "$SIM_DIR")"
+
+ 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"
+
+ if [[ ! -f "$BACKEND_DIR/.secrets" ]]; then
+ 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 "Step 1/5 — Starting the InteractiveAI backend"
+ ( cd "$BACKEND_DIR" && ./docker-compose.sh )
+ ok "backend compose brought up"
+
+ # docker-compose.sh does `up -d` WITHOUT --build, so an existing image is
+ # reused as-is. Force a rebuild from THIS repo's source (cache-aware:
+ # unchanged sources hit the layer cache and return instantly) so a stale
+ # image from a different branch/checkout can't leak its baked-in code.
+ log "Rebuilding frontend + recommendation-service from this repo's source"
+ ( cd "$BACKEND_DIR" && docker compose up -d --build frontend cabrecommendation )
+ ok "frontend and recommendation-service rebuilt from source"
+
+ log "Step 2/5 — 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/5 — 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/5 — Loading resources and registering use cases"
+ # Wait until auth actually works end-to-end before loading (avoids 401s).
+ # Reuses resources/getToken.sh (the same helper loadTestConf.sh's own
+ # sub-scripts use) instead of re-implementing the token request/parse here.
+ 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, entities assigned to publisher_test"
+
+ log "Step 5/5 — Building and starting the PowerGrid simulator"
+ ( cd "$SIM_DIR" && docker compose up -d --build app )
+ ok "PowerGrid simulator started"
+
+ printf '\n\033[1;32mSetup complete.\033[0m\n\n'
+ printf ' InteractiveAI UI %s (publisher_test / test)\n' "$(link "$FRONTEND_URL")"
+ printf ' PowerGrid simulator %s (also proxied same-origin at %s/powergrid-simu/)\n' "$(link "http://localhost:5100")" "$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..846333a0
--- /dev/null
+++ b/local_stop.sh
@@ -0,0 +1,46 @@
+#!/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"
+
+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.
+run() {
+ ( cd "$1" && docker compose $COMPOSE_CMD 2>/dev/null ) || true
+}
+
+log "$MSG"
+
+# Simulator first, then backend it depends on.
+run "$SIM_DIR"
+run "$BACKEND_DIR"
+
+ok "done"
diff --git a/resources/loadTestConf.sh b/resources/loadTestConf.sh
index e8bdd466..38e9e1b5 100755
--- a/resources/loadTestConf.sh
+++ b/resources/loadTestConf.sh
@@ -34,4 +34,14 @@ fi
./loadContextServicesUseCase.sh $url
cd ../cabUsecasesRecommendation
./loadRecommendationServicesUseCase.sh $url
+
+ # Assign entities to publisher_test so the UI shows entity selection cards
+ # (cwd here is resources/cabUsecasesRecommendation, so getToken.sh is one level up)
+ source ../getToken.sh "admin" $url
+ echo "Assigning entities to publisher_test"
+ curl -s -X PUT $url:3200/users/users/publisher_test \
+ -H "Content-Type: application/json" \
+ -H "Authorization: Bearer $token" \
+ -d '{"login":"publisher_test","entities":["PowerGrid","ATM","Railway"],"groups":["Dispatcher","ReadOnly","Supervisor"]}'
+ echo ""
)
diff --git a/usecases_examples/PowerGrid/Dockerfile.app b/usecases_examples/PowerGrid/Dockerfile.app
index 782ea872..fbdbf812 100644
--- a/usecases_examples/PowerGrid/Dockerfile.app
+++ b/usecases_examples/PowerGrid/Dockerfile.app
@@ -1,13 +1,24 @@
# syntax=docker/dockerfile:1
FROM python:3.9-slim-bullseye
-EXPOSE 5000
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
+# --no-install-recommends + cleaning the apt lists keeps the image small.
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ ffmpeg libsm6 libxext6 \
+ && rm -rf /var/lib/apt/lists/*
+
+# copy requirements and install BEFORE copying the source, so the (slow)
+# dependency layer stays cached and is only rebuilt when requirements change,
+# not on every code edit. The pip cache mount keeps downloaded wheels out of
+# the final image while still persisting them across builds (so a retry
+# after a network blip, or a real requirements change, doesn't have to
+# re-download unchanged packages).
+COPY requirements-app.txt /code/requirements-app.txt
+RUN --mount=type=cache,target=/root/.cache/pip pip3 install -r requirements-app.txt
+
+COPY . /code/
-CMD ["python3", "PowerGrid_poc_simulator_app.py"]
\ No newline at end of file
+CMD ["python3", "PowerGrid_poc_simulator_app.py"]
diff --git a/usecases_examples/PowerGrid/app/templates/index.html b/usecases_examples/PowerGrid/app/templates/index.html
index b3407160..ea9f1a53 100644
--- a/usecases_examples/PowerGrid/app/templates/index.html
+++ b/usecases_examples/PowerGrid/app/templates/index.html
@@ -1,5 +1,5 @@
-
+
PowerGrid Simulator
@@ -145,16 +145,16 @@
{% endif %}
{% endwith %}
- Welcom to PowerGrid Simulator based on Grid2Op platform !
-
+ Welcome to PowerGrid Simulator based on Grid2Op platform !
+