Aller au contenu

Exemple : revenue management hôtelier

Application d'exemple sur les données synthétiques du labo : quatre écrans (tableau de bord, prévision, tarifs, commentaire IA) à http://app.openflake.nuc. Elle montre la chaîne complète de bout en bout : couche semantic construite par dbt, lecture par Trino, prévision t0-gamma (forecast.forecast), fonction Cortex complete routée par cortex_lab vers LiteLLM. Il n'existe pas d'équivalent Snowflake direct d'un front web sur mesure : ce que l'application lit et appelle (tables semantic, snowflake.cortex.complete) se transpose, pas l'application elle-même.

Écrans

Une page unique, quatre onglets (role="tablist", flèches du clavier, Début et Fin). Deux filtres communs, l'hôtel et la période, gardés dans l'URL : ?hotel=h001&from=2026-10-01&to=2026-10-28&tab=forecast. Une vue se partage par son adresse. Trois boutons prédéfinissent la période : les 28 derniers jours réalisés, les 60 jours suivants, les 90 jours suivants (relativement au dernier jour réalisé, car les tarifs et la prévision portent sur des dates futures). Nombres, devises et dates sont mis en forme par Intl en fr-FR.

Écran Contenu
Tableau de bord Tuiles occupation, ADR et RevPAR avec l'écart à la période précédente de même longueur ; courbe quotidienne ; comparaison des cinq hôtels. Les jours sans donnée restent des trous, jamais une valeur de remplacement ; leur nombre est affiché
Prévision Médiane p50 et bande p10 à p90, réalisé en surimpression pour les dates passées, date « as of » de la prévision, modèle et révision ; second graphique : courbe de réservation (occupation sur les livres selon le nombre de jours avant le séjour)
Tarifs L'hôtel face à la fourchette (min, médiane, max) de son comp set par date de séjour ; jours au-dessus de la médiane marqués en couleur d'alerte et par une forme distincte ; tableau des valeurs en alternative au graphique
Commentaire IA Bouton « Générer » ; texte avec la mention « généré par un modèle local via LiteLLM » et l'alias utilisé

Chaque bloc a un état de chargement, un état vide et un état d'erreur lisible (avec « Réessayer »). Thème clair et thème sombre par variables CSS et prefers-color-scheme ; les graphiques sont reconstruits quand le thème change et redimensionnés avec la fenêtre. Mise en page utilisable dès 360 px de large. Aucune ressource externe à l'exécution : pile de polices du système, bibliothèque de graphiques servie par l'application.

Chaîne de données

flowchart LR
    A[standardized] -->|dbt build| B[semantic: fct_hotel_daily<br/>fct_rate_position<br/>fct_booking_pace]
    F[forecast.forecast] -->|dbt build| G[semantic: fct_forecast]
    B --> T[Trino, utilisateur lab_reader]
    G --> T
    T --> API[API FastAPI]
    API --> W[Front web]
    API -->|cortex_lab.compat.complete| L[LiteLLM, alias cortex-complete]

Trois modèles dbt ajoutés à la couche semantic (dbt/models/semantic/), identifiants en minuscules, SQL portable vers dbt-snowflake, construits sur les cibles duckdb et trino :

Modèle Grain Contenu
fct_rate_position hôtel, date de séjour Dernier relevé du rate shop pour la date : nombre de concurrents, min, médiane, max, tarif propre (voir ci-dessous), above_median
fct_booking_pace hôtel, date de séjour, date d'extraction Chambres et chiffre d'affaires sur les livres, jours avant le séjour, occupation sur les livres
fct_forecast hôtel, métrique, date cible, date « as of », révision p10, p50, p90 (pivot des quantiles), réalisé joint depuis fct_hotel_daily

La médiane du comp set se calcule par fenêtre (rangs centraux) : Trino n'a pas de fonction median, et la division entière diffère selon les moteurs. Le pivot des quantiles compare un quantile arrondi, car une égalité exacte sur un double est fragile.

Règle du tarif propre

Le rate shop ne contient que les tarifs des concurrents, jamais celui de l'hôtel. Le tarif affiché pour l'hôtel est donc l'ADR des réservations enregistrées : chiffre d'affaires divisé par chambres de la dernière extraction de std_bookings prise au plus tard le jour du relevé, pour la même date de séjour. Sa nature est affichée à l'écran (« ADR des réservations enregistrées (proxy) »). La colonne own_rate_source vaut adr_on_books et own_rate_confidence_class vaut proxy ; sans chambre réservée, le tarif est NULL avec unknown. Limites : ce n'est pas un tarif public, et sur les dates lointaines peu de réservations existent, donc la valeur est bruitée. above_median est faux quand le tarif égale la médiane, NULL quand le tarif est NULL.

Prévision absente

forecast.forecast est créée par le service de prévision au premier passage ; avant cela elle n'existe pas. fct_forecast teste l'existence de la source (macro relation_exists, adapter.get_relation) et construit alors une table vide aux mêmes colonnes : dbt build reste vert sur une pile neuve et l'écran Prévision affiche son état vide. Le test n'a lieu que pour dbt run et dbt build ; compile, show et parse ne regardent pas l'entrepôt. Ordre d'exploitation : dbt build, prévision, puis dbt build -s fct_forecast.

Application

Élément Choix
Backend FastAPI, Uvicorn, Python 3.12, Pydantic v2 (app/api/)
Front HTML, CSS et modules JavaScript natifs (app/web/), sans Node ni npm ; Apache ECharts 6.1.0 récupéré à la construction de l'image, vérifié par SHA-256, jamais commité
Accès aux données Trino, utilisateur lab_reader, lecture seule (app/api/trino_reader.py)
Service app du compose : non root, read_only, cap_drop: [ALL], no-new-privileges, aucun port publié, réseaux lab, web, data, route Traefik openflake-app avec local-only@file

Points d'accès : /api/hotels, /api/kpi, /api/forecast, /api/rates (paramètres hotel, start, end), /api/commentary (POST, même corps), /api/health. La documentation interactive de FastAPI est désactivée : elle charge ses actifs depuis un CDN.

Moindre privilège et injection SQL

  • Utilisateur Trino lab_reader (variable APP_TRINO_USER) : infra/trino/rules.json ne lui laisse que SELECT sur semantic et shared_* du catalogue analytics. Trino n'ayant pas d'authentification au labo, cette limite repose sur le nom déclaré, pas sur un secret : elle protège contre une erreur de l'application, pas contre un poste du LAN qui se déclarerait lab_tech (risque déjà accepté pour les routes Traefik). Le conteneur ne reçoit aucun identifiant Polaris ni MinIO.
  • Une constante SQL par besoin (app/api/queries.py), valeurs seulement en paramètres ?. Les tables sont lues sous la forme semantic.<table> : la connexion Trino est ouverte sur le catalogue nommé comme la base Snowflake (LAB_DATABASE), donc aucun nom de base n'est inséré dans un texte SQL.
  • Validation avant toute requête : hôtel au motif ^h[0-9]{3}$ puis présent dans dim_hotel (sinon 422), dates ISO, début avant fin, 366 jours au plus.

Commentaire IA

Le prompt est construit côté serveur à partir des seuls agrégats chiffrés des trois autres écrans (indicateurs et écarts, somme et bande de la prévision, jours au-dessus de la médiane), plus l'identifiant de l'hôtel et les bornes de la période : jamais de ligne de détail ni de texte saisi. L'appel passe par cortex_lab.compat.complete avec l'alias lu dans CORTEX_COMPLETE_MODEL (cortex-complete), donc par LiteLLM, seule sortie vers un modèle ; max_tokens vaut 400 et la température 0,2. La réponse est traitée comme un texte non fiable : 1 500 caractères au plus (troncature signalée), caractères de contrôle retirés, insertion dans la page par textContent seulement, jamais transmise à la bibliothèque de graphiques ; la politique Content-Security-Policy limitée à 'self' est une seconde barrière.

  • Une génération à la fois par processus (le GPU est partagé) : la suivante reçoit 429 tant que la première n'est pas terminée, même si celle-ci a dépassé le délai.
  • Délai global de 45 secondes (réponse 504), LITELLM_TIMEOUT_SECONDS à 30 dans le conteneur.
  • Erreur LiteLLM, fenêtre de contexte dépassée ou réponse vide : message lisible, sans trace, URL ni clé.
  • Clé LiteLLM : le code lit APP_LITELLM_API_KEY. Une clé virtuelle dédiée, limitée à l'alias du commentaire avec une limite de débit, est à créer sur la brique LiteLLM du NUC puis à placer dans .env. Tant qu'elle manque (variable vide ou valeur factice de .env.example), le commentaire répond 503 avec un message lisible et le reste de l'application fonctionne.

Cache

Cache en mémoire par (écran, hôtel, période), borné à 256 entrées, durée de vie de 5 minutes pour les données, 10 minutes pour la liste des hôtels, 30 minutes pour un commentaire. Un échec n'est jamais mis en cache. Les valeurs sont dans app/api/settings.py.

En-têtes

Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none', X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, pas de version dans Server.

Exploitation

uv run --env-file .env dbt build --project-dir dbt --profiles-dir dbt
docker compose --profile forecast run --rm forecast forecast --as-of 2026-10-22
uv run --env-file .env dbt build --project-dir dbt --profiles-dir dbt -s fct_forecast
docker compose up -d --build app

Détail des commandes, des variables (APP_URL, APP_LITELLM_API_KEY) et du dépannage : exploitation.md. Tests : tests/app/ (API avec faux lecteur Trino et faux client LiteLLM, requêtes, commentaire, conventions, intégration) et tests/ingest/test_dbt_project.py pour les trois modèles.

Limites

  • Le front n'est pas testé par un navigateur automatisé : les contrôles visuels (quatre onglets, formats, thèmes, 360 px, clavier, contrastes AA, onglet réseau sans requête externe) se font à la main.
  • Pas d'authentification propre à l'application : la route est ouverte au LAN, comme les autres routes du labo.
  • Le tarif propre est un proxy (voir plus haut). Aucune écriture de données, aucun SQL saisi par l'utilisateur.
  • Données synthétiques : la prévision, le réalisé et les réservations ne reflètent aucun établissement réel.