diff --git a/APP_MANIFEST.md b/APP_MANIFEST.md index e0c824a..2126778 100644 --- a/APP_MANIFEST.md +++ b/APP_MANIFEST.md @@ -24,7 +24,9 @@ Le dashboard et le portail applicatif utilisent exclusivement le manifeste plac { "provider": "Google Gemini", "model": "gemini-2.5-flash", - "role": "Assistant conversationnel" + "role": "Assistant conversationnel", + "billing": "paid", + "source": "application" } ] }, @@ -44,12 +46,12 @@ Le dashboard et le portail applicatif utilisent exclusivement le manifeste plac | `giteaRepo` | Dépôt source | Sert au webhook CI/CD et à l’inventaire. | | `giteaOwner` | Organisation/propriétaire Gitea | Facultatif si la valeur par défaut suffit. | | `image` | Image locale du portail | Chemin relatif à `portail-santinova/images/`. | -| `ai.engines` | Moteurs IA déclarés | Tableau optionnel de métadonnées publiques : `provider`, `model` et `role` optionnel. Ne jamais y mettre une clé, un jeton ni un endpoint privé. | +| `ai.engines` | Moteurs IA déclarés | Tableau optionnel de métadonnées publiques : `provider`, `model`, `role` optionnel, `billing` (`free` ou `paid`) et `source` (`application` ou `referential`). Ne jamais y mettre une clé, un jeton ni un endpoint privé. | | `urls.recette` | URL de recette | Obligatoire et HTTPS pour un dashboard recette. | | `urls.prod` | URL de production | Obligatoire et HTTPS pour un dashboard de production. | ## Règles de déploiement -Chaque premier déploiement doit créer ou mettre à jour le manifeste dans le même commit que le `docker-compose.yml` et les éléments nécessaires à l’application. Lorsqu’une application est promue, son manifeste de production doit contenir l’URL de production avant le démarrage du dashboard. Lorsque l’application utilise un moteur IA, le même commit renseigne `ai.engines` avec des informations descriptives vérifiées, jamais avec des secrets. +Chaque premier déploiement doit créer ou mettre à jour le manifeste dans le même commit que le `docker-compose.yml` et les éléments nécessaires à l’application. Lorsqu’une application est promue, son manifeste de production doit contenir l’URL de production avant le démarrage du dashboard. Lorsque l’application utilise un moteur IA, le même commit renseigne `ai.engines` avec des informations descriptives vérifiées, jamais avec des secrets. `billing: "free"` signifie qu’aucune facturation d’API externe n’est appliquée ; `billing: "paid"` couvre les accès facturés ou consommant un crédit. `source: "application"` affiche « Moteur IA configuré dans l’applicatif » tandis que `source: "referential"` affiche « Utilisation du référentiel IA ». Le dashboard ne maintient plus de liste statique d’applications, et le portail ne maintient plus de tuiles codées en dur. Cette règle élimine les écarts de version, d’URL, de catégorie et de statut entre les deux interfaces. diff --git a/src/backend/src/ai-catalog.js b/src/backend/src/ai-catalog.js index 0586f58..60d691c 100644 --- a/src/backend/src/ai-catalog.js +++ b/src/backend/src/ai-catalog.js @@ -3,63 +3,67 @@ * contient pas encore ai.engines. Un champ ai.engines dans app.json reste * prioritaire et permet à chaque application de remplacer cette valeur. * - * Ce catalogue contient uniquement des informations descriptives publiques : - * aucun secret, endpoint privé ou paramètre d'exécution n'y figure. + * billing : coût d'accès au moteur (« free » = aucune facturation d'API externe, + * « paid » = accès fournisseur ou service hébergé payant). Les ressources de la + * VM restent hors de ce statut. + * source : « application » indique un moteur configuré par l'applicatif ; + * « referential » est réservé aux moteurs consommés via le Référentiel IA. + * Aucune clé, aucun jeton ni endpoint privé n'est publié ici. */ const AI_CATALOG = Object.freeze({ 'demat-facturation-dsi': [ - { provider: 'Mistral AI', model: 'mistral-large-latest', role: 'Extraction de factures' }, - { provider: 'Google Gemini', model: 'gemini-2.0-flash', role: 'Relève de traitement' }, + { provider: 'Mistral AI', model: 'mistral-large-latest', role: 'Extraction de factures', billing: 'paid', source: 'application' }, + { provider: 'Google Gemini', model: 'gemini-2.0-flash', role: 'Relève de traitement', billing: 'paid', source: 'application' }, ], 'facturation-santinova': [ - { provider: 'OpenAI', model: 'gpt-4.1-mini', role: 'Traitement documentaire' }, + { provider: 'OpenAI', model: 'gpt-4.1-mini', role: 'Traitement documentaire', billing: 'paid', source: 'application' }, ], 'falc-generator': [ - { provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Transformation FALC' }, - { provider: 'Google Gemini', model: 'gemini-3.1-pro-preview', role: 'Récits de vie' }, + { provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Transformation FALC', billing: 'paid', source: 'application' }, + { provider: 'Google Gemini', model: 'gemini-3.1-pro-preview', role: 'Récits de vie', billing: 'paid', source: 'application' }, ], 'formation-manager-itinova': [ - { provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Assistant conversationnel' }, + { provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Assistant conversationnel', billing: 'paid', source: 'application' }, ], 'gestion-at-v2': [ - { provider: 'Manus Forge', model: 'gemini-3-flash-preview', role: 'Analyse CERFA' }, - { provider: 'OpenAI', model: 'gpt-image-2', role: 'Génération d’images' }, + { provider: 'Manus Forge', model: 'gemini-3-flash-preview', role: 'Analyse CERFA', billing: 'paid', source: 'application' }, + { provider: 'OpenAI', model: 'gpt-image-2', role: 'Génération d’images', billing: 'paid', source: 'application' }, ], 'gestion-llm-locaux': [ - { provider: 'Ollama local', model: 'llama3.2:1b', role: 'Moteur privé approuvé' }, + { provider: 'Ollama local', model: 'llama3.2:1b', role: 'Moteur privé approuvé', billing: 'free', source: 'application' }, ], 'itinova-budget-si': [ - { provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Assistant conversationnel' }, + { provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Assistant conversationnel', billing: 'paid', source: 'application' }, ], 'itinova-contacts': [ - { provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Assistant conversationnel' }, - { provider: 'Anthropic Claude', model: 'claude-sonnet-4-6', role: 'Connexion configurée' }, + { provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Assistant conversationnel', billing: 'paid', source: 'application' }, + { provider: 'Anthropic Claude', model: 'claude-sonnet-4-6', role: 'Connexion configurée', billing: 'paid', source: 'application' }, ], 'itinova-podcasts': [ - { provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Assistant conversationnel' }, + { provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Assistant conversationnel', billing: 'paid', source: 'application' }, ], 'itinova-vehicle-exchange': [ - { provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Assistant conversationnel' }, + { provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Assistant conversationnel', billing: 'paid', source: 'application' }, ], 'pilotage-masse-salariale': [ - { provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Assistant conversationnel' }, + { provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Assistant conversationnel', billing: 'paid', source: 'application' }, ], portee: [ - { provider: 'OpenAI', model: 'gpt-image-2', role: 'Génération d’images' }, - { provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Assistant conversationnel' }, + { provider: 'OpenAI', model: 'gpt-image-2', role: 'Génération d’images', billing: 'paid', source: 'application' }, + { provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Assistant conversationnel', billing: 'paid', source: 'application' }, ], 'referentiel-ia': [ - { provider: 'Google Gemini', model: 'gemini-2.5-flash', role: 'Moteur référencé' }, - { provider: 'OpenAI', model: 'gpt-5.4-mini', role: 'Moteur référencé' }, - { provider: 'Anthropic Claude', model: 'claude-sonnet-4-6', role: 'Moteur référencé' }, - { provider: 'Mistral AI', model: 'mistral-large', role: 'Moteur référencé' }, - { provider: 'Ollama local', model: 'llama3.2:1b', role: 'Moteur référencé' }, + { provider: 'Google Gemini', model: 'gemini-2.5-flash', role: 'Moteur référencé', billing: 'paid', source: 'application' }, + { provider: 'OpenAI', model: 'gpt-5.4-mini', role: 'Moteur référencé', billing: 'paid', source: 'application' }, + { provider: 'Anthropic Claude', model: 'claude-sonnet-4-6', role: 'Moteur référencé', billing: 'paid', source: 'application' }, + { provider: 'Mistral AI', model: 'mistral-large', role: 'Moteur référencé', billing: 'paid', source: 'application' }, + { provider: 'Ollama local', model: 'llama3.2:1b', role: 'Moteur référencé', billing: 'free', source: 'application' }, ], sonum: [ - { provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Assistant conversationnel' }, + { provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Assistant conversationnel', billing: 'paid', source: 'application' }, ], 'veille-reglementaire': [ - { provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Classification réglementaire' }, + { provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Classification réglementaire', billing: 'paid', source: 'application' }, ], }); diff --git a/src/backend/src/app-registry.js b/src/backend/src/app-registry.js index 3d74ce4..a3cdfec 100644 --- a/src/backend/src/app-registry.js +++ b/src/backend/src/app-registry.js @@ -11,6 +11,8 @@ const { getAiCatalogEntry } = require('./ai-catalog'); const CATEGORIES = new Set(['ITINOVA', 'SANTINOVA', 'INFRA']); const ENVIRONMENTS = new Set(['recette', 'prod']); +const AI_BILLING = new Set(['free', 'paid']); +const AI_SOURCES = new Set(['application', 'referential']); function isHttpsUrl(value) { if (typeof value !== 'string' || value.length === 0) return false; @@ -23,8 +25,8 @@ function isHttpsUrl(value) { /** * Normalise les moteurs IA déclarés dans app.json. Seules des métadonnées - * descriptives (fournisseur, modèle, rôle) sont acceptées : aucune clé ni URL - * technique ne doit être publiée par le dashboard. + * descriptives (fournisseur, modèle, rôle, coût et provenance) sont acceptées : + * aucune clé ni URL technique ne doit être publiée par le dashboard. */ function normalizeAi(ai, appId) { if (ai === undefined) return { engines: getAiCatalogEntry(appId) }; @@ -46,10 +48,20 @@ function normalizeAi(ai, appId) { if (engine.role !== undefined && (typeof engine.role !== 'string' || engine.role.trim().length === 0)) { throw new Error(`ai.engines[${index}].role invalide`); } + if (engine.billing !== undefined && !AI_BILLING.has(engine.billing)) { + throw new Error(`ai.engines[${index}].billing doit être free ou paid`); + } + if (engine.source !== undefined && !AI_SOURCES.has(engine.source)) { + throw new Error(`ai.engines[${index}].source doit être application ou referential`); + } return { provider: engine.provider.trim(), model: engine.model.trim(), ...(engine.role ? { role: engine.role.trim() } : {}), + // Un moteur non qualifié est traité comme payant et configuré dans + // l'applicatif pour ne pas afficher une gratuité non vérifiée. + billing: engine.billing || 'paid', + source: engine.source || 'application', }; }), }; diff --git a/src/backend/test/app-registry.test.js b/src/backend/test/app-registry.test.js index bfd1692..223a168 100644 --- a/src/backend/test/app-registry.test.js +++ b/src/backend/test/app-registry.test.js @@ -25,7 +25,7 @@ try { containerName: 'itinova-contacts', image: 'images/itinova-contacts.png', ai: { - engines: [{ provider: 'Google Gemini', model: 'gemini-2.5-flash', role: 'Assistant conversationnel' }], + engines: [{ provider: 'Google Gemini', model: 'gemini-2.5-flash', role: 'Assistant conversationnel', billing: 'free', source: 'referential' }], }, urls: { recette: 'https://contacts.recette.santinova-soft.org', @@ -63,24 +63,32 @@ try { ai: { engines: [{ provider: 'Google Gemini' }] }, urls: { recette: 'https://ia-invalide.recette.santinova-soft.org' }, }); + writeManifest(sandbox, 'tarif-ia-invalide', { + id: 'app-tarif-ia-invalide', + name: 'Tarif IA invalide', + category: 'ITINOVA', + ai: { engines: [{ provider: 'Google Gemini', model: 'gemini-2.5-flash', billing: 'inconnu' }] }, + urls: { recette: 'https://tarif-ia-invalide.recette.santinova-soft.org' }, + }); const discovery = discoverApps({ appsBasePath: sandbox, environment: 'recette' }); assert.equal(discovery.apps.length, 3, 'un manifeste invalide doit être exclu'); - assert.equal(discovery.errors.length, 2, 'une erreur explicite doit être retournée pour chaque manifeste invalide'); + assert.equal(discovery.errors.length, 3, 'une erreur explicite doit être retournée pour chaque manifeste invalide'); + assert.ok(discovery.errors.some((error) => error.error.includes('billing doit être free ou paid'))); const app = discovery.apps.find((candidate) => candidate.id === 'itinova-contacts'); assert.equal(app.directory, 'contacts', 'le dossier doit être déduit du chemin du manifeste'); assert.equal(app.healthCheckUrl, app.urls.recette, 'la vérification doit cibler l’environnement courant'); assert.equal(app.containerName, 'itinova-contacts'); assert.deepEqual(app.ai, { - engines: [{ provider: 'Google Gemini', model: 'gemini-2.5-flash', role: 'Assistant conversationnel' }], - }, 'la métadonnée IA descriptive doit être conservée'); + engines: [{ provider: 'Google Gemini', model: 'gemini-2.5-flash', role: 'Assistant conversationnel', billing: 'free', source: 'referential' }], + }, 'la métadonnée IA descriptive, tarifaire et de provenance doit être conservée'); const falc = discovery.apps.find((candidate) => candidate.id === 'falc-generator'); assert.deepEqual(falc.ai, { engines: [ - { provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Transformation FALC' }, - { provider: 'Google Gemini', model: 'gemini-3.1-pro-preview', role: 'Récits de vie' }, + { provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Transformation FALC', billing: 'paid', source: 'application' }, + { provider: 'Google Gemini', model: 'gemini-3.1-pro-preview', role: 'Récits de vie', billing: 'paid', source: 'application' }, ], }, 'le catalogue IA doit compléter un manifeste historique sans moteur déclaré'); diff --git a/src/backend/test/applications-ui-contract.test.js b/src/backend/test/applications-ui-contract.test.js index 816834a..6c34ee6 100644 --- a/src/backend/test/applications-ui-contract.test.js +++ b/src/backend/test/applications-ui-contract.test.js @@ -16,6 +16,13 @@ assert.doesNotMatch(appCard, /\['portail-santinova', 'manus-dashboard'\]\.includ assert.match(appCard, /const aiEngines = Array\.isArray\(app\.ai\?\.engines\) \? app\.ai\.engines : \[\];/); assert.match(appCard, />\s*Moteur IA\s*); assert.match(appCard, /Non déclaré/); +assert.match(appCard, /engine\.billing === 'free'/); +assert.match(appCard, /Gratuit/); +assert.match(appCard, /Payant/); +assert.match(appCard, /bg-emerald-900\/40 text-emerald-300/); +assert.match(appCard, /bg-orange-900\/40 text-orange-300/); +assert.match(appCard, /Utilisation du référentiel IA/); +assert.match(appCard, /Moteur IA configuré dans l'applicatif/); assert.match(appSorting, /app\?\.category === 'INFRA' \? 0 : 1/); console.log('applications-ui-contract: OK'); diff --git a/src/frontend/src/components/AppCard.jsx b/src/frontend/src/components/AppCard.jsx index 419f454..1e323cd 100644 --- a/src/frontend/src/components/AppCard.jsx +++ b/src/frontend/src/components/AppCard.jsx @@ -227,10 +227,26 @@ export default function AppCard({ app, commits, onRefresh }) {
{engine.provider}
+{engine.provider}
+ + {engine.billing === 'free' ? 'Gratuit' : 'Payant'} + +{engine.model}{engine.role ? ` — ${engine.role}` : ''}
++ {engine.source === 'referential' + ? 'Utilisation du référentiel IA' + : "Moteur IA configuré dans l'applicatif"} +
Non déclaré