From 122f5ca35462a0a6157e36d6a2b42f5782452e5c Mon Sep 17 00:00:00 2001 From: Edi Muskardin <28546846+emuskardin@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:36:54 +0200 Subject: [PATCH 1/5] fix: restore broken auth, recommendation, and PowerGrid flows - Add setup.sh/stop.sh for one-shot local dev bring-up/teardown - Fix cabcontext/PowerGrid simulator port collision (both on 5100) - Replace hardcoded LAN IP with host.docker.internal in the simulator proxy - Fix missing publisher_test entity assignment in loadTestConf.sh - Fix cache-unfriendly PowerGrid simulator Dockerfile - Fix dangling AgentType import breaking the PowerGrid recommendation manager - Fix broken test fixture reference, add missing pytest-mock dependency, and add an end-to-end smoke test for the recommendation pipeline - Re-enable auth auto-logout/recovery on a dead session token - Restore the use_case query param on recommendation requests (fixes 400s for tokens registered on multiple entities) - Restore real applyRecommendation calls for ATM/Railway/PowerGrid instead of a hardcoded fake-success response - Translate the PowerGrid simulator login page to English - Document connecting a local RL agent in the README - Add changelog documenting all of the above --- .gitignore | 3 + README.md | 28 ++ .../recommendation-service/requirements.txt | Bin 2138 -> 2178 bytes .../resources/PowerGrid/manager.py | 10 +- .../tests/test_smoke_pipeline.py | 58 ++++ .../tests/test_views.py | 2 +- changelog/CHANGELOG.md | 158 +++++++++++ config/dev/cab-standalone/docker-compose.yml | 9 +- .../cab-standalone/nginx-cors-permissive.conf | 6 +- frontend/src/api/services.ts | 28 +- frontend/src/entities/ATM/api.ts | 7 +- frontend/src/entities/Railway/api.ts | 8 +- frontend/src/plugins/http.ts | 46 ++- frontend/src/stores/services.ts | 6 +- resources/loadTestConf.sh | 10 + setup.sh | 265 ++++++++++++++++++ stop.sh | 52 ++++ usecases_examples/PowerGrid/Dockerfile.app | 18 +- .../PowerGrid/app/templates/index.html | 42 +-- .../PowerGrid/config/API_POWERGRID_CAB.toml | 4 + .../PowerGrid/docker-compose.yml | 6 + 21 files changed, 683 insertions(+), 83 deletions(-) create mode 100644 backend/recommendation-service/tests/test_smoke_pipeline.py create mode 100644 changelog/CHANGELOG.md create mode 100755 setup.sh create mode 100755 stop.sh 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..4ad7a337 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,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. +### 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/requirements.txt b/backend/recommendation-service/requirements.txt index 7eb1ae7c3933acbcffd6d68774e5dee981841698..920f0ab71b6dacd4a3906ee077b6824190b4401a 100644 GIT binary patch delta 44 wcmca5&?LA)j8#6Bp_rkBL6;$yA)g_cA)CP#2#p!^7z`OqfTY1@URGab0NG^-J^%m! delta 12 TcmZn?yd|(fjCHdes|zy#8$1Js 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..c9d757cd --- /dev/null +++ b/backend/recommendation-service/tests/test_smoke_pipeline.py @@ -0,0 +1,58 @@ +"""End-to-end smoke test of the PowerGrid recommendation pipeline. + +Ported from interactiveai-a3s-integration/backend/a3s-service/tests/test_smoke_pipeline.py +and adapted to this repo's current architecture: there is no local RL agent +(a3s-service / agent_as_a_service) here anymore — 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..f29efe49 --- /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 `setup.sh` / `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. `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..6df5f278 100644 --- a/frontend/src/api/services.ts +++ b/frontend/src/api/services.ts @@ -9,12 +9,21 @@ 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 +) { + const url = use_case + ? `/cab_recommendation/api/v1/recommendation?use_case=${encodeURIComponent(use_case)}` + : '/cab_recommendation/api/v1/recommendation' + return http.post[]>(url, payload) } export function getContext() { @@ -51,13 +60,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..051ffbef 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 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 http.post<{ message: string }>(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..d97788d8 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 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 http.post<{ message: string }>(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..70ac6cad 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 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/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/setup.sh b/setup.sh new file mode 100755 index 00000000..ddf93660 --- /dev/null +++ b/setup.sh @@ -0,0 +1,265 @@ +#!/usr/bin/env bash +# +# setup.sh — one-shot local setup for the InteractiveAI backend + PowerGrid simulator. +# +# Ported from interactiveai-a3s-integration/setup.sh and adapted to this repo's +# current architecture: +# - no a3s-service / local RL agent: PowerGrid recommendations combine the +# external RL agent API (RL_AGENT_API_URL, proxied same-origin via +# /rl-api/) with the ontology recommender 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: +# ./setup.sh # full setup (prompts if containers already run) +# ./setup.sh --clean # tear down existing containers first, no prompt +# ./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 build frontend cabrecommendation \ + && docker compose up -d 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). + local i=1 + until curl -s --max-time 5 -X POST \ + -d "username=admin&password=test&grant_type=password&client_id=$KC_CLIENT" \ + "$FRONTEND_URL/auth/token" | grep -q access_token; do + (( 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/stop.sh b/stop.sh new file mode 100755 index 00000000..d42c9f0c --- /dev/null +++ b/stop.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# +# stop.sh — tear down the local InteractiveAI backend + PowerGrid simulator. +# +# Usage: +# ./stop.sh # stop & remove containers, KEEP data volumes (default) +# ./stop.sh --wipe # also delete data volumes (Postgres/Mongo/Keycloak) — fresh start next time +# ./stop.sh --pause # just stop containers, keep them (fastest; `./setup.sh` or `docker compose start` to resume) +# ./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; } + +MODE="down" # down | wipe | pause +case "${1:-}" in + "") MODE="down" ;; + --wipe|--volumes) MODE="wipe" ;; + --pause|--stop) MODE="pause" ;; + -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() { + case "$MODE" in + down) ( cd "$1" && docker compose down 2>/dev/null ) || true ;; + wipe) ( cd "$1" && docker compose down -v 2>/dev/null ) || true ;; + pause) ( cd "$1" && docker compose stop 2>/dev/null ) || true ;; + esac +} + +case "$MODE" in + down) log "Stopping everything (containers removed, data volumes kept)" ;; + wipe) log "Stopping everything and DELETING data volumes (fresh start next time)" ;; + pause) log "Pausing everything (containers kept, resume later)" ;; +esac + +# Simulator first, then backend it depends on. +run "$SIM_DIR" +run "$BACKEND_DIR" + +ok "done" diff --git a/usecases_examples/PowerGrid/Dockerfile.app b/usecases_examples/PowerGrid/Dockerfile.app index 782ea872..51e88736 100644 --- a/usecases_examples/PowerGrid/Dockerfile.app +++ b/usecases_examples/PowerGrid/Dockerfile.app @@ -1,13 +1,21 @@ # 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. --no-cache-dir avoids bundling pip's cache in the image. +COPY requirements-app.txt /code/requirements-app.txt +RUN pip3 install --no-cache-dir -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 !

+