Skip to content
Merged
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
3 changes: 3 additions & 0 deletions backend/recommendation-service/resources/PowerGrid/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ def __init__(self):
self.owl_file_path = os.path.join(
script_dir, "ontology/Grid2onto_v2_3_1.owl"
)
# Runtime value comes from the RL_AGENT_API_URL env var (set via
# .secrets -> docker-compose.sh -> .env for local Docker, or extraEnv
# for k8s). The fallback is a safe in-cluster default only.
self.rl_agent_api_url = os.environ.get(
"RL_AGENT_API_URL",
"http://frontend:80/rl-api/recommendation",
Expand Down
74 changes: 56 additions & 18 deletions backend/recommendation-service/resources/Railway/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,19 @@
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__)


class RailwayManager(BaseRecommendationManager):
def __init__(self):
super().__init__()

def _transform_recommendation(self, reco_json):
"""Transform a recommendation from catalog to output format."""
"""Transform a recommendation from catalog format to API output format."""
reco = json.loads(reco_json)
return {
"title": reco["data"]["title"],
Expand All @@ -25,23 +27,59 @@ def _transform_recommendation(self, reco_json):

def get_recommendation(self, request_data):
"""
Override to provide recommendations specific to the Railway use case.

This method generates and returns recommendations tailored for Railway events.
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)
"""
event_data = request_data.get("event", {})
event_id = str(event_data.get("id_event", "1"))

logger.info(f"Processing event_id: {event_id}")

# Get recommendations from catalog
if event_id == "1":
recommendations = RECOMMENDATION_CATALOG.get(event_id, RECOMMENDATION_CATALOG["1"])
elif event_id == "2":
recommendations = RECOMMENDATION_CATALOG.get(event_id, RECOMMENDATION_CATALOG["1"])
elif event_id == "3":
recommendations = RECOMMENDATION_CATALOG.get(event_id, RECOMMENDATION_CATALOG["1"])
# Transform each recommendation to output format
return [self._transform_recommendation(reco) for reco in recommendations]

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,
)
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]
158 changes: 158 additions & 0 deletions backend/recommendation-service/resources/Railway/sncf_recommender.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import json
import re


def parse_delay_to_minutes(delay_str):
if delay_str is None:
return 0
delay_str = delay_str.lower().strip()
hours = 0
minutes = 0
h_match = re.search(r'(\d+)h', delay_str)
m_match = re.search(r'(\d+)\s*min', delay_str)
if h_match:
hours = int(h_match.group(1))
if m_match:
minutes = int(m_match.group(1))
if "h" in delay_str and "min" not in delay_str:
parts = delay_str.split("h")
if len(parts) > 1 and parts[1].isdigit():
minutes = int(parts[1])
return hours * 60 + minutes


def SNCF_RECO3(event_json, context_json, recommendation_catalog):
"""
Selects recommendations using IF–THEN rules based on id_event.
Returns 4 recommendations ordered with best=True first.
"""
event = json.loads(event_json)
id_event = event["data"].get("id_event")

if id_event not in recommendation_catalog:
return [json.dumps({"error": f"No recommendations defined for event_id {id_event}"})]

recos = recommendation_catalog[id_event]

def best_first(reco_json):
reco = json.loads(reco_json)
return 0 if reco["data"]["kpis"].get("best") == "True" else 1

return sorted(recos, key=best_first)


def SNCF_deontic(event_json, context_json, recommendation_catalog, type="passengers", threshold_value=200):
"""
Filters recommendations by a KPI threshold, then sorts by that KPI ascending.

Parameters
----------
type : str
KPI to filter and sort on: "passengers", "delay", "cost", "total_cost"
threshold_value : int | str
Upper bound for the KPI (use a delay string like "1h30" for type="delay")
"""
event = json.loads(event_json)
id_event = event["data"].get("id_event")

if id_event not in recommendation_catalog:
return [json.dumps({"error": f"No recommendations defined for event_id {id_event}"})]

recos = recommendation_catalog[id_event]

# When no threshold is provided, skip filtering and return all recommendations sorted
if threshold_value is None:
filtered = list(recos)
else:
filtered = []
for reco_json in recos:
reco = json.loads(reco_json)
kpis = reco["data"]["kpis"]
passengers = int(kpis.get("nb_impacted_passengers", 0))
cost = int(kpis.get("cost", 0))
total_cost = int(kpis.get("total_cost", 0))
delay_minutes = parse_delay_to_minutes(kpis.get("delay", "0min"))

if type == "passengers" and passengers <= threshold_value:
filtered.append(reco_json)
elif type == "delay":
if delay_minutes <= parse_delay_to_minutes(threshold_value):
filtered.append(reco_json)
elif type == "cost" and cost <= threshold_value:
filtered.append(reco_json)
elif type == "total_cost" and total_cost <= threshold_value:
filtered.append(reco_json)

def _key(reco_json):
reco = json.loads(reco_json)
kpis = reco["data"]["kpis"]
if type == "passengers":
return int(kpis.get("nb_impacted_passengers", 0))
if type == "delay":
return parse_delay_to_minutes(kpis.get("delay", "0min"))
if type == "cost":
return int(kpis.get("cost", 0))
if type == "total_cost":
return int(kpis.get("total_cost", 0))
return 0

if type not in ("passengers", "delay", "cost", "total_cost"):
return [json.dumps({"error": "type must be 'passengers', 'delay', 'cost', or 'total_cost'"})]

return sorted(filtered, key=_key)


def SNCF_risk(event_json, context_json, recommendation_catalog, type):
"""
Returns all recommendations sorted by a KPI ascending (no threshold filtering).
"""
event = json.loads(event_json)
id_event = event["data"].get("id_event")

if id_event not in recommendation_catalog:
return [json.dumps({"error": f"No recommendations defined for event_id {id_event}"})]

recos = recommendation_catalog[id_event]

kpi_keys = {
"passengers": lambda r: int(json.loads(r)["data"]["kpis"].get("nb_impacted_passengers", 0)),
"delay": lambda r: parse_delay_to_minutes(json.loads(r)["data"]["kpis"].get("delay", "0min")),
"cost": lambda r: int(json.loads(r)["data"]["kpis"].get("cost", 0)),
"total_cost": lambda r: int(json.loads(r)["data"]["kpis"].get("total_cost", 0)),
}

if type not in kpi_keys:
return [json.dumps({"error": "type must be 'passengers', 'delay', 'cost', or 'total_cost'"})]

return sorted(recos, key=kpi_keys[type])


def SNCF_risk_tie_break(event_json, context_json, recommendation_catalog, type, tie_breaker=None):
"""
Orders recommendations by a primary KPI, using a secondary KPI to break ties.
"""
event = json.loads(event_json)
id_event = event["data"].get("id_event")

if id_event not in recommendation_catalog:
return [json.dumps({"error": f"No recommendations defined for event_id {id_event}"})]

recos = recommendation_catalog[id_event]

kpi_keys = {
"passengers": lambda r: int(json.loads(r)["data"]["kpis"].get("nb_impacted_passengers", 0)),
"delay": lambda r: parse_delay_to_minutes(json.loads(r)["data"]["kpis"].get("delay", "0min")),
"cost": lambda r: int(json.loads(r)["data"]["kpis"].get("cost", 0)),
"total_cost": lambda r: int(json.loads(r)["data"]["kpis"].get("total_cost", 0)),
}

if type not in kpi_keys:
return [json.dumps({"error": "Invalid type"})]

if tie_breaker is None:
return sorted(recos, key=kpi_keys[type])

if tie_breaker not in kpi_keys:
return [json.dumps({"error": "Invalid tie_breaker"})]

return sorted(recos, key=lambda r: (kpi_keys[type](r), kpi_keys[tie_breaker](r)))
1 change: 1 addition & 0 deletions config/dev/cab-standalone/docker-compose.sh
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ if [[ -f .secrets ]]; then
fi
echo "RL_AGENT_API_URL=${RL_AGENT_API_URL:-https://interactiveagent.passerelle.irt-systemx.fr/api/v1/recommendation}" >> .env
echo "RL_AGENT_API_TOKEN=${RL_AGENT_API_TOKEN:-}" >> .env
echo "VITE_POWERGRID_SIMU=${VITE_POWERGRID_SIMU:-/powergrid-simu}" >> .env
echo "VITE_COGNITIVE_TOKEN=${VITE_COGNITIVE_TOKEN:-}" >> .env

cat .env
Expand Down
6 changes: 6 additions & 0 deletions config/dev/cab-standalone/nginx-cors-permissive.conf
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,12 @@ server {
proxy_set_header X-Forwarded-For $remote_addr;
}

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/;
}

location /cognitive-api/ {

# Proxy for the INESCTEC cognitive API (avoids browser CORS restrictions)
Expand Down
Loading
Loading