Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
a68c073
Merge pull request #25 from ainetus/fix/session-refresh-and-powergrid…
abdratsd Aug 27, 2026
4cc0486
chore: bump version to 1.3.7
abdratsd Aug 27, 2026
ca4e325
fix nginx conf
abdratsd Aug 27, 2026
17eca5f
fix: restore real applyRecommendation calls and handle apply failures
abdratsd Aug 28, 2026
80524c4
feat(frontend): configure nginx at runtime and move the cognitive tok…
abdratsd Sep 1, 2026
966f977
bump version to 1.3.8
abdratsd Sep 4, 2026
81c2cdd
fix apply nginx conf & bump version to 1.3.9
abdratsd Sep 4, 2026
b534997
release 1.4.0: fail fast on a missing cognitive token, keep it out of…
abdratsd Sep 4, 2026
7c6ac4e
fix context image type (png->svg) and add observation to logging
abdratsd Sep 16, 2026
9c0ad7b
feat(frontend): on logout confirmation to delete or keep remaining al…
abdratsd Sep 16, 2026
7b67748
feat(frontend): ask operators to fill the HMI survey on logout
abdratsd Sep 16, 2026
c7fc2f4
feat(frontend): offer the session report after the survey
abdratsd Sep 16, 2026
833c8ab
feat(frontend): add copy button for session id in report
abdratsd Sep 16, 2026
5c933c0
feat(frontend): measure the human decision time in the session report
abdratsd Sep 16, 2026
c1c9f5b
feat(frontend): confirm before skipping the post-logout survey
abdratsd Sep 17, 2026
b9fcccb
fix(frontend): stop cancelling HTML report download
abdratsd Sep 17, 2026
45082b0
Merge branch 'feat/hmi-survey-post-logout' into main
abdratsd Sep 17, 2026
e7f3afa
release 1.4.1: post-logout HMI survey and human decision time KPI
abdratsd Sep 17, 2026
af5b449
deploy 1.4.1 to OVH
abdratsd Sep 17, 2026
53022da
feat: Railway use case integration (Flatland scenarios, ZWL frontend,…
Janick96733 Sep 22, 2026
3b50a4c
feat: Railway use case integration (Flatland scenarios, ZWL frontend,…
Janick96733 Sep 22, 2026
297b374
feat: Railway use case integration (Flatland scenarios, ZWL frontend,…
Janick96733 Sep 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/docker-build-push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,10 @@ jobs:
# VITE_* values are baked into the bundle at build time. VITE_POWERGRID_SIMU
# is the same-origin base the frontend posts "apply action" to; without it the
# POST lands on / and nginx answers 405. Blank lines are ignored by the action.
# Secrets must NOT be passed here: a build-arg ends up readable in the public
# bundle. The cognitive API token is a runtime env var on the container instead
# (COGNITIVE_TOKEN, injected into the nginx proxy by start-webui.sh).
build-args: |
${{ matrix.service == 'cab-standalone-frontend' && format('VITE_COGNITIVE_TOKEN={0}', secrets.VITE_COGNITIVE_TOKEN) || '' }}
${{ matrix.service == 'cab-standalone-frontend' && 'VITE_POWERGRID_SIMU=/powergrid-simu' || '' }}
cache-from: type=gha
cache-to: type=gha,mode=max
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -197,4 +197,5 @@ go.sh
# Frontend
!frontend/.vscode
!frontend/env/
!frontend/env/.env
!frontend/env/.env
hmisurveys/
Empty file added .secrets.tmp
Empty file.
66 changes: 66 additions & 0 deletions HANDOVER.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Railway Use Case — Deployment Handover

## What was built
A Flatland railway dispatcher training tool integrated into InteractiveAI (SystemX).
Two scenarios (Kreuzungskonflikt, Fahrt auf Sichtweite) with Recommendation and Co-Learning modes.

## New services (not in original docker-compose)

### 1. Flask Railway Brain (`railway-brain`)
- **Source:** `usecases_examples/Railway/`
- **Dockerfile:** `usecases_examples/Railway/Railway.Dockerfile`
- **Port:** 5001
- **Runs:** Flatland simulation + scenario player + card publishing

### 2. ZWL Angular Frontend (`zwl-frontend`)
- **Source:** `flatland-hmi-hack4rail/frontend/`
- **Dockerfile:** `flatland-hmi-hack4rail/zwl.Dockerfile`
- **Port:** 4200
- **Shows:** Kartenansicht (map) + ZWL Diagramm (Marey)

## To deploy on server

1. Copy `.env.example` → `.env` and fill in:
```
VITE_RAILWAY_SIMU=http://<SERVER_PUBLIC_IP>:5001
RL_AGENT_API_URL=http://railway-brain:5001/recommendations
```

2. Run:
```bash
docker compose \
-f config/dev/cab-standalone/docker-compose.yml \
-f config/dev/cab-standalone/docker-compose-railway.yml \
up --build
```

3. On first start, run the MongoDB perimeter init manually (timing issue with auto-init):
```bash
docker exec cab-standalone-mongodb-1 mongo operator-fabric \
-u root -p password --authenticationDatabase admin \
/docker-entrypoint-initdb.d/01-cabprocess.js
```

## Decisions for SystemX developer

- **Port 5001 public access:** Flask brain must be reachable from the browser.
Options: expose port directly, or proxy via nginx at `/railway-api/`.
If proxied, update all `BACKEND_URL` in the Angular services + Vue frontend.

- **Authentication:** `AUTH_DISABLED=true` is set everywhere.
Enable auth when deploying to production.

- **ZWL build output path:** Check `angular.json` `outputPath` — the Dockerfile
assumes `dist/frontend/browser`. Adjust if different.

- **Maps volume:** `usecases_examples/Railway/maps/` contains JSON map files
needed at runtime. Mount as volume or bake into Docker image.

## Files changed from original InteractiveAI repo
- `frontend/src/entities/Railway/CAB/` — all CAB Vue components
- `usecases_examples/Railway/app.py` — Flask brain (heavily extended)
- `usecases_examples/Railway/ScenarioPlayer.py` — new file
- `usecases_examples/Railway/FlatlandMapLoader.py` — new file
- `usecases_examples/Railway/ExperimentLogger.py` — new file
- `experiment_scenarios/` — new directory with scenario definitions
- `flatland-hmi-hack4rail/frontend/src/app/` — ZWL Angular components
138 changes: 135 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,15 @@ 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.
- `POWERGRID_SIMU_UPSTREAM` — where nginx actually forwards `/powergrid-simu/`. This is the
only value that changes per environment:
- `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: handled by `nginx-kubernetes.conf` via the helm chart (not this variable)
- 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:
Expand All @@ -104,6 +108,52 @@ Key variables (see `.secrets.example` for all options and per-environment values
> 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
Expand Down Expand Up @@ -219,3 +269,85 @@ Contributions to the InteractiveAI Assistant Platform are welcome! To contribute
# Docs
A postman collection is under docs/postman_collections.
You can also check the openapi through the URL http://localhost:[Service port]/docs


---

## Railway Use Case (FHNW / AI4REALNET)

The Railway use case adds a Flatland train simulation with interactive scenario-based dispatcher training. It requires **two additional services** beyond the main Docker stack.

> See also `HANDOVER.md` for deployment decisions.

### Additional Prerequisites

- **Python 3.10** (exact version required for `flatland-rl`)
- **Node.js 18+**

### Step-by-step Local Setup

After completing the standard InteractiveAI setup above, add `VITE_RAILWAY_SIMU=http://localhost:5001` to your `.secrets` file, then:

**1. Install Python dependencies (first time only)**
```bash
cd usecases_examples/Railway
python3.10 -m venv .venv
source .venv/bin/activate # Linux/Mac
# .venv\Scripts\activate # Windows
pip install -r requirements.txt
cd ../..
```

**2. Start Flask Railway brain** *(new terminal)*
```bash
cd usecases_examples/Railway
source .venv/bin/activate
python app.py
```
Ready when: `Running on http://0.0.0.0:5001`

**3. Start ZWL Angular frontend** *(new terminal)*
```bash
cd flatland-hmi-hack4rail/frontend
npm install # first time only
npm start
```
Ready when: `Local: http://localhost:4200`

**4. MongoDB perimeter setup** *(required after every Docker restart)*
```bash
docker exec cab-standalone-mongodb-1 mongo operator-fabric \
-u root -p password --authenticationDatabase admin \
--eval 'db.perimeter.updateOne({_id:"cabProcess"},{$set:{process:"cabProcess",stateRights:[{state:"messageState",right:"ReceiveAndWrite"}]}},{upsert:true}); db.group.updateOne({_id:"Planner"},{$addToSet:{perimeters:"cabProcess"}}); db.group.updateOne({_id:"Dispatcher"},{$addToSet:{perimeters:"cabProcess"}}); print("done")'
```

Open `http://localhost:3200/cab/Railway` and log in as `railway_user` / `test`.

### Scenarios

| ID | Name | Description |
|----|------|-------------|
| `scenario1` | Kreuzungskonflikt | Single-track crossing conflict |
| `scenario2` | Fahrt auf Sichtweite | Speed restriction causing dispatch conflict |
| `scenario3` | Zugreihenfolge | Multiple delays disrupting train order |

### Server Deployment (Docker)

Dockerfiles are provided to containerise Flask and the ZWL frontend:

```bash
docker compose \
-f config/dev/cab-standalone/docker-compose.yml \
-f config/dev/cab-standalone/docker-compose-railway.yml \
up --build
```

Set `VITE_RAILWAY_SIMU=http://<SERVER_PUBLIC_IP>:5001` in `.secrets` before building. See `HANDOVER.md` for open deployment decisions (port exposure, auth, nginx proxy).

### Troubleshooting

| Problem | Solution |
|---------|----------|
| Kartenansicht: "cannot connect to localhost:4200" | ZWL Angular not running — run step 3 |
| No train data | Flask not running — run step 2 |
| No notification cards | Re-run step 4 (MongoDB command) |
112 changes: 39 additions & 73 deletions backend/recommendation-service/resources/Railway/manager.py
Original file line number Diff line number Diff line change
@@ -1,85 +1,51 @@
# backend/recommendation-service/resources/Railway/manager.py
import json
from api.manager.base_manager import BaseRecommendationManager
from .mockRecommendations.mockRecommendations import RECOMMENDATION_CATALOG
from .sncf_recommender import SNCF_RECO3, SNCF_deontic, SNCF_risk, SNCF_risk_tie_break

import logging

logger = logging.getLogger(__name__)
import os
import requests
from api.manager.base_manager import BaseRecommendationManager
from settings import logger


class RailwayManager(BaseRecommendationManager):
def __init__(self):
# URL of our Flask brain's /recommendations endpoint
# Set RL_AGENT_API_URL in .env to point at the Flask brain
self.agent_api_url = os.environ.get(
"RL_AGENT_API_URL",
"http://host.docker.internal:5001/recommendations",
)
self.agent_api_token = os.environ.get("RL_AGENT_API_TOKEN", "")
super().__init__()

def _transform_recommendation(self, reco_json):
"""Transform a recommendation from catalog format to API output format."""
reco = json.loads(reco_json)
return {
"title": reco["data"]["title"],
"description": reco["data"]["description"],
"use_case": reco["data"]["use_case"],
"agent_type": reco["data"]["agent_type"],
"actions": [{}],
"kpis": reco["data"]["kpis"],
}

def get_recommendation(self, request_data):
"""
Return recommendations for a Railway event.

Supports four modes controlled by request_data["event"]["mode"]:

- "basic" (default): best-first ordering via SNCF_RECO3
- "deontic": filter by KPI threshold, then sort ascending via SNCF_deontic
requires: sort_type ("passengers"|"delay"|"cost"|"total_cost")
threshold_value (int, or delay string e.g. "1h30" for delay)
- "risk": sort all recommendations by KPI ascending via SNCF_risk
requires: sort_type
- "risk_tie_break": sort by primary KPI, break ties with secondary via SNCF_risk_tie_break
requires: sort_type
optional: tie_breaker (same values as sort_type)
Calls our Flask brain's /recommendations endpoint and returns
the result in the format InteractiveAI expects.
"""
event_data = request_data.get("event", {})
context_data = request_data.get("context", {})

# Ensure id_event has a fallback so catalog lookup always has a key
event_for_sncf = {**event_data, "id_event": str(event_data.get("id_event", "1"))}

# Wrap into the structure expected by SNCF functions
event_json = json.dumps({"data": event_for_sncf})
context_json = json.dumps({"data": context_data})

mode = event_data.get("mode", "deontic")
logger.info(f"Railway recommendation — event_id: {event_for_sncf['id_event']}, mode: {mode}")

if mode == "deontic":
sort_type = event_data.get("sort_type", "cost")
threshold_value = event_data.get("threshold_value")
recommendations = SNCF_deontic(
event_json, context_json, RECOMMENDATION_CATALOG,
type=sort_type, threshold_value=threshold_value,
)
elif mode == "risk":
sort_type = event_data.get("sort_type", "cost")
recommendations = SNCF_risk(
event_json, context_json, RECOMMENDATION_CATALOG,
type=sort_type,
headers = {"Content-Type": "application/json"}
if self.agent_api_token:
headers["Authorization"] = "Bearer " + self.agent_api_token

try:
response = requests.post(
self.agent_api_url,
json=request_data,
headers=headers,
timeout=10,
verify=False,
)
elif mode == "risk_tie_break":
sort_type = event_data.get("sort_type", "cost")
tie_breaker = event_data.get("tie_breaker")
recommendations = SNCF_risk_tie_break(
event_json, context_json, RECOMMENDATION_CATALOG,
type=sort_type, tie_breaker=tie_breaker,
)
else: # "basic" or any unrecognised mode
recommendations = SNCF_RECO3(event_json, context_json, RECOMMENDATION_CATALOG)

# Surface catalog errors as an empty list rather than crashing downstream
if recommendations and isinstance(recommendations[0], dict) and "error" in recommendations[0]:
logger.error(f"Recommendation error: {recommendations[0]['error']}")
return []

return [self._transform_recommendation(reco) for reco in recommendations]
recommendations = response.json()
logger.info("Railway recommendations received: " + str(len(recommendations)))
return recommendations

except Exception as e:
logger.error("Failed to get Railway recommendations: " + str(e))
# Return a fallback so the UI doesn't break
return [{
"title": "No recommendations available",
"description": "Could not reach the simulation brain. Please try again.",
"use_case": "Railway",
"agent_type": "AI",
"actions": [{}],
"kpis": {},
}]
7 changes: 7 additions & 0 deletions config/dev/cab-standalone/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ RL_AGENT_API_TOKEN=
# not from this variable.)
POWERGRID_SIMU_UPSTREAM=http://host.docker.internal:5122/

# Bearer token for the INESCTEC cognitive API, injected into the nginx /cognitive-api/
# proxy at container start. Runtime, not build-time: restart the container to change it.
COGNITIVE_TOKEN=
# Leaving it empty is fine here: the stack starts and only the cognitive panel is
# unavailable. Public deploys set REQUIRED_VARS=COGNITIVE_TOKEN instead, which makes
# an empty value abort container start rather than serve an unauthenticated proxy.

# VITE build-time variables (also required in frontend/env/.env.local for local dev)
VITE_POWERGRID_SIMU=false
VITE_ATM_SIMU=false
Expand Down
Loading