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(variableAPP_TRINO_USER) :infra/trino/rules.jsonne lui laisse queSELECTsursemanticetshared_*du catalogueanalytics. 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éclareraitlab_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 formesemantic.<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 dansdim_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.