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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -198,4 +198,6 @@ go.sh
!frontend/.vscode
!frontend/env/
!frontend/env/.env
hmisurveys/
/config/dev/cab-standalone/.secrets.pre-simplify

hmisurveys/
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ git clone [repo-url]
InteractiveAI offers versatile deployment options, leveraging either Docker or Kubernetes. The primary method entails initiating InteractiveAI via Docker to launch all services concurrently. However, recognizing potential resource strain in this mode, we've introduced alternative configurations. These configurations enable selective startup of essential services with minimal dependencies, catering to streamlined versions of certain APIs.
Below are the steps to start all services. For other methods, please consult the developer guide.

> **_NOTE:_** `./local_setup.sh` runs all the steps below (`./local_stop.sh` stops everything). With `--a3s`, recommendations come from a running [A3S](<A3S repo link>) instead of the expert agent.

### Running All Services (Dev Mode)

1. **Set-up environment variables**
Expand Down
2 changes: 2 additions & 0 deletions backend/cab_common/cab_common_auth/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
}
},
"root": {"level": "DEBUG", "handlers": ["wsgi"]},
# Avoid silently disabling loggers already set up by the host service.
"disable_existing_loggers": False,
}
logging.config.dictConfig(DEFAULT_LOGGING)

Expand Down
2 changes: 2 additions & 0 deletions backend/context-service/resources/PowerGrid/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@
class MetadataSchemaPowerGrid(MetadataSchema):
topology = String(allow_none=False)
observation = Dict(allow_none=False)
# Serialized environment state, so agents can rebuild it by replay. Optional.
environment_state = Dict(allow_none=True, required=False)
13 changes: 13 additions & 0 deletions backend/recommendation-service/api/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,16 @@
class InvalidUseCase(HTTPError):
status_code = 400
message = 'Use case invalid'


class UpstreamAgentError(HTTPError):
"""The RL agent API could not be reached, or answered with an error.

Distinct from "the agent ran and had nothing to recommend", which is an
empty list and a success. Collapsing the two into an empty 200 makes an
outage indistinguishable from a quiet grid: the caller waits on a spinner
with nothing to retry and nothing in the UI to say why.
"""

status_code = 502
message = 'The recommendation agent is unavailable'
5 changes: 5 additions & 0 deletions backend/recommendation-service/api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ class RecommendationAsk(Schema):
# only when the operator has consented. Declared so it survives schema
# validation and is forwarded verbatim to the RL agent.
cognitive_snapshot = Dict()
# Optional per-request tuning, e.g. kpi_prediction_steps for A3S.
options = Dict()


class RecommendationOut(Schema):
Expand All @@ -20,6 +22,9 @@ class RecommendationOut(Schema):
agent_type = String()
actions = List(Dict())
kpis = Dict(allow_none=True)
# A3S multi-step rollouts: candidate index and rollout timestep.
branch_index = Integer(allow_none=True)
step = Integer(allow_none=True)


class ProcedureOut(Schema):
Expand Down
67 changes: 57 additions & 10 deletions backend/recommendation-service/resources/PowerGrid/manager.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import os
import time

import requests
import urllib3
from api.exceptions import UpstreamAgentError
from api.manager.base_manager import BaseRecommendationManager
from settings import logger

Expand All @@ -24,6 +26,10 @@ def __init__(self):
"http://frontend:80/rl-api/recommendation",
)
self.rl_agent_api_token = os.environ.get("RL_AGENT_API_TOKEN", "")
# A3S rollouts can take tens of seconds; 30s was too tight.
self.rl_agent_api_timeout = float(
os.environ.get("RL_AGENT_API_TIMEOUT", "120")
)
super().__init__()

def get_recommendation(self, request_data):
Expand All @@ -45,35 +51,76 @@ def _get_rl_parades(self, request_data):
request_data (dict): Full request payload with keys "event" and "context"

Returns:
list[dict]: List of parade recommendations, empty on failure
list[dict]: List of parade recommendations. Empty only when the
agent ran and had nothing to propose.

Raises:
UpstreamAgentError: If the agent API could not be reached or
answered with an error. Deliberately not swallowed into an
empty list — see the exception's docstring.
"""
try:
headers = {}
if self.rl_agent_api_token:
headers["Authorization"] = f"Bearer {self.rl_agent_api_token}"
started = time.monotonic()
response = requests.post(
self.rl_agent_api_url,
json=request_data,
headers=headers,
timeout=30,
timeout=self.rl_agent_api_timeout,
verify=False, # SSL cert may not be trusted inside the container
)
response.raise_for_status()
data = response.json()
elapsed = time.monotonic() - started
if elapsed > self.rl_agent_api_timeout / 2:
logger.warning(
"RL agent call took %.1fs of a %.0fs budget",
elapsed,
self.rl_agent_api_timeout,
)
else:
logger.info("RL agent call took %.1fs", elapsed)
logger.info(f"RL agent returned {len(data)} recommendation(s)")
return data
except requests.exceptions.SSLError as e:
logger.error(f"SSL error calling RL agent API: {e}")
return []
raise UpstreamAgentError(
message="Could not establish a secure connection to the "
"recommendation agent"
) from e
except requests.exceptions.HTTPError as e:
logger.error(f"HTTP error calling RL agent API: {e} — response body: {e.response.text[:500] if e.response is not None else 'N/A'}")
return []
body = e.response.text[:500] if e.response is not None else 'N/A'
logger.error(f"HTTP error calling RL agent API: {e} — response body: {body}")
status = e.response.status_code if e.response is not None else None
raise UpstreamAgentError(
message=f"The recommendation agent returned an error "
f"({status})" if status else
"The recommendation agent returned an error",
# The agent's own message goes in the detail rather than the
# user-facing message: it is a Python traceback summary, not
# something an operator can act on.
detail={"upstream_status": status, "upstream_body": body},
) from e
except requests.exceptions.Timeout as e:
# Before ConnectionError: requests' Timeout subclasses it for the
# connect-timeout case, so the narrower except has to come first.
logger.error(
"Timeout calling RL agent API (%s) after %.0fs",
self.rl_agent_api_url,
self.rl_agent_api_timeout,
)
raise UpstreamAgentError(
message="The recommendation agent did not answer in time"
) from e
except requests.exceptions.ConnectionError as e:
logger.error(f"Connection error calling RL agent API ({self.rl_agent_api_url}): {e}")
return []
except requests.exceptions.Timeout:
logger.error(f"Timeout calling RL agent API ({self.rl_agent_api_url}) after 30s")
return []
raise UpstreamAgentError(
message="Could not reach the recommendation agent"
) from e
except Exception as e:
logger.error(f"Unexpected error calling RL agent API: {type(e).__name__}: {e}")
return []
raise UpstreamAgentError(
detail={"error": f"{type(e).__name__}: {e}"}
) from e
4 changes: 3 additions & 1 deletion backend/recommendation-service/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
'root': {
'level': 'INFO',
'handlers': ['wsgi']
}
},
# Avoid silently disabling loggers already set up elsewhere.
'disable_existing_loggers': False,
}
logging.config.dictConfig(DEFAULT_LOGGING)

Expand Down
39 changes: 39 additions & 0 deletions backend/recommendation-service/tests/test_smoke_pipeline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Smoke test: post a context, stub out the RL agent, expect an ontology recommendation back."""
import json

POWERGRID_BEARER_TOKEN = "dummy-token-see-PowerGrid_auth_mocker-fixture"


def test_pipeline_smoke_context_to_recommendation(
client, create_usecases, PowerGrid_auth_mocker, mocker
):
mocker.patch(
"resources.PowerGrid.manager.PowerGridManager._get_rl_parades",
return_value=[],
)

with open("tests/tests_resources/rte_recommendation.json") as json_file:
payload = json.load(json_file)

headers = {"Authorization": f"Bearer {POWERGRID_BEARER_TOKEN}"}
response = client.post(
"/api/v1/recommendation?use_case=PowerGrid",
headers=headers,
json=payload,
)

assert response.status_code == 200
recommendations = response.get_json()
assert isinstance(recommendations, list) and len(recommendations) >= 1

for reco in recommendations:
assert reco["use_case"] == "PowerGrid"
assert reco["agent_type"] in {"IA", "onto"}
assert reco["title"]
assert "kpis" in reco

onto_recos = [r for r in recommendations if r["agent_type"] == "onto"]
assert onto_recos
assert any(
"efficiency_of_the_reco" in (r["kpis"] or {}) for r in onto_recos
)
1 change: 1 addition & 0 deletions config/dev/cab-standalone/.secrets.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Copy to .secrets and fill in your values — this file is gitignored
#
# RL agent (T2.1_deep_expert) — pick ONE RL_AGENT_API_URL for your environment:
# To use a local A3S instead, do not set this: run ../../../local_setup.sh --a3s.
# Local dev : RL agent running on THIS host on port 5123, reached via host.docker.internal
# (cab_recommendation has the host-gateway mapping)
# Server : RL agent on the LAN host
Expand Down
5 changes: 5 additions & 0 deletions config/dev/cab-standalone/docker-compose.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,14 @@ echo "HOST_IP=${HOST_IP}" >> .env

# Secrets — sourced from .secrets if present (gitignored), otherwise from shell env
# In CI these are injected by GitHub Actions as environment variables.
# An RL_AGENT_API_URL already in the environment wins over the file: that is how
# `local_setup.sh --a3s` points this stack at a running A3S without editing
# .secrets, and it would otherwise be silently overwritten here.
_ENV_RL_AGENT_API_URL="${RL_AGENT_API_URL:-}"
if [[ -f .secrets ]]; then
source .secrets
fi
RL_AGENT_API_URL="${_ENV_RL_AGENT_API_URL:-${RL_AGENT_API_URL:-}}"
echo "RL_AGENT_API_URL=${RL_AGENT_API_URL:-https://interactiveagent.passerelle.irt-systemx.fr/api/v1/recommendation}" >> .env
echo "RL_AGENT_API_TOKEN=${RL_AGENT_API_TOKEN:-}" >> .env
echo "VITE_POWERGRID_SIMU=${VITE_POWERGRID_SIMU:-/powergrid-simu}" >> .env
Expand Down
2 changes: 2 additions & 0 deletions config/dev/cab-standalone/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,8 @@ services:
- TZ=UTC # Set timezone to UTC
- RL_AGENT_API_URL=${RL_AGENT_API_URL}
- RL_AGENT_API_TOKEN=${RL_AGENT_API_TOKEN}
# A3S rollouts can take tens of seconds to answer.
- RL_AGENT_API_TIMEOUT=${RL_AGENT_API_TIMEOUT:-120}
command: sh -c "./entrypoint.sh"
ports:
- 5400:5000
Expand Down
12 changes: 10 additions & 2 deletions config/dev/cab-standalone/nginx-cors-permissive.conf
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ resolver 127.0.0.11 ipv6=off;
server {
listen 80;
server_name localhost;

# PowerGrid environment_state payloads exceed the 1 MB nginx default.
client_max_body_size 64m;
### CUSTOMIZATION - BEGIN
# Url of the Authentication provider
set $KeycloakBaseUrl "http://keycloak:8080";
Expand Down Expand Up @@ -357,7 +360,7 @@ server {
proxy_pass __POWERGRID_SIMU_UPSTREAM__;
}

location /cognitive-api/ {
location ~ ^/cognitive-api/(?<cognitive_path>.*)$ {

# Proxy for the INESCTEC cognitive API (avoids browser CORS restrictions)
if ($request_method = 'OPTIONS') {
Expand All @@ -379,7 +382,12 @@ server {
proxy_set_header Authorization "Bearer __COGNITIVE_TOKEN__";
proxy_ssl_server_name on;
proxy_ssl_verify off;
proxy_pass https://wesenss.inesctec.pt/api/v1/;
# The host is kept in a variable on purpose: a literal name here is resolved when
# nginx loads its config, so this third-party host being unreachable would stop the
# whole frontend from starting. As a variable it is resolved per request instead,
# and only this endpoint fails. The location captures the path so no rewrite is needed.
set $cognitive_host https://wesenss.inesctec.pt;
proxy_pass $cognitive_host/api/v1/$cognitive_path$is_args$args;
}

location /rl-api/ {
Expand Down
3 changes: 3 additions & 0 deletions config/dev/cab-standalone/nginx-kubernetes.conf
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ log_format upstreamlog '[$time_local] $remote_addr - $remote_user - $server_name
server {
listen 80;
server_name localhost cab-dev.irtsystemx.org;

# PowerGrid environment_state payloads exceed the 1 MB nginx default.
client_max_body_size 64m;
error_log /var/log/nginx/error.log debug;
access_log /var/log/nginx/access.log opfab-log;

Expand Down
3 changes: 3 additions & 0 deletions config/dev/cab-standalone/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ log_format opfab-log '$remote_addr - $time_local_ms'
server {
listen 80;
server_name localhost;

# PowerGrid environment_state payloads exceed the 1 MB nginx default.
client_max_body_size 64m;
access_log /var/log/nginx/access.log opfab-log;

### CUSTOMIZATION - BEGIN
Expand Down
3 changes: 3 additions & 0 deletions config/dev/recommendation-service/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ resolver 127.0.0.11 ipv6=off;
server {
listen 80;
server_name localhost;

# PowerGrid environment_state payloads exceed the 1 MB nginx default.
client_max_body_size 64m;
### CUSTOMIZATION - BEGIN
# Url of the Authentication provider
set $KeycloakBaseUrl "http://keycloak:8080";
Expand Down
3 changes: 3 additions & 0 deletions frontend/default.conf
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ server {
listen 80;
server_name localhost;

# PowerGrid environment_state payloads exceed the 1 MB nginx default.
client_max_body_size 64m;

gzip on;
gzip_types application/javascript text/css;

Expand Down
1 change: 1 addition & 0 deletions frontend/src/api/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export function getRecommendation<E extends Entity = Entity>(payload: {
event: Card<E>['data']['metadata']
context: Context<E>
cognitive_snapshot?: CognitiveSnapshot
options?: { kpi_prediction_steps?: number }
}) {
return http.post<Recommendation<E>[]>('/cab_recommendation/api/v1/recommendation', payload)
}
Expand Down
Loading