feat: afficher coût et provenance des moteurs IA
All checks were successful
Validation Dashboard / Tests backend et build frontend (push) Successful in 50s

This commit is contained in:
Santinova Deploy
2026-09-05 15:06:08 +00:00
parent 692762674b
commit 7d7006ab29
6 changed files with 87 additions and 38 deletions

View File

@@ -24,7 +24,9 @@ Le dashboard et le portail applicatif utilisent exclusivement le manifeste plac
{ {
"provider": "Google Gemini", "provider": "Google Gemini",
"model": "gemini-2.5-flash", "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. | | `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. | | `giteaOwner` | Organisation/propriétaire Gitea | Facultatif si la valeur par défaut suffit. |
| `image` | Image locale du portail | Chemin relatif à `portail-santinova/images/`. | | `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.recette` | URL de recette | Obligatoire et HTTPS pour un dashboard recette. |
| `urls.prod` | URL de production | Obligatoire et HTTPS pour un dashboard de production. | | `urls.prod` | URL de production | Obligatoire et HTTPS pour un dashboard de production. |
## Règles de déploiement ## 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. 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.

View File

@@ -3,63 +3,67 @@
* contient pas encore ai.engines. Un champ ai.engines dans app.json reste * contient pas encore ai.engines. Un champ ai.engines dans app.json reste
* prioritaire et permet à chaque application de remplacer cette valeur. * prioritaire et permet à chaque application de remplacer cette valeur.
* *
* Ce catalogue contient uniquement des informations descriptives publiques : * billing : coût d'accès au moteur (« free » = aucune facturation d'API externe,
* aucun secret, endpoint privé ou paramètre d'exécution n'y figure. * « 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({ const AI_CATALOG = Object.freeze({
'demat-facturation-dsi': [ 'demat-facturation-dsi': [
{ provider: 'Mistral AI', model: 'mistral-large-latest', role: 'Extraction de factures' }, { 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' }, { provider: 'Google Gemini', model: 'gemini-2.0-flash', role: 'Relève de traitement', billing: 'paid', source: 'application' },
], ],
'facturation-santinova': [ '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': [ 'falc-generator': [
{ provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Transformation FALC' }, { 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' }, { provider: 'Google Gemini', model: 'gemini-3.1-pro-preview', role: 'Récits de vie', billing: 'paid', source: 'application' },
], ],
'formation-manager-itinova': [ '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': [ 'gestion-at-v2': [
{ provider: 'Manus Forge', model: 'gemini-3-flash-preview', role: 'Analyse CERFA' }, { 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' }, { provider: 'OpenAI', model: 'gpt-image-2', role: 'Génération d’images', billing: 'paid', source: 'application' },
], ],
'gestion-llm-locaux': [ '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': [ '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': [ 'itinova-contacts': [
{ 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' },
{ provider: 'Anthropic Claude', model: 'claude-sonnet-4-6', role: 'Connexion configurée' }, { provider: 'Anthropic Claude', model: 'claude-sonnet-4-6', role: 'Connexion configurée', billing: 'paid', source: 'application' },
], ],
'itinova-podcasts': [ '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': [ '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': [ '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: [ portee: [
{ provider: 'OpenAI', model: 'gpt-image-2', role: 'Génération d’images' }, { 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' }, { provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Assistant conversationnel', billing: 'paid', source: 'application' },
], ],
'referentiel-ia': [ 'referentiel-ia': [
{ provider: 'Google Gemini', model: 'gemini-2.5-flash', 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é' }, { 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é' }, { 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é' }, { 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é' }, { provider: 'Ollama local', model: 'llama3.2:1b', role: 'Moteur référencé', billing: 'free', source: 'application' },
], ],
sonum: [ 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': [ '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' },
], ],
}); });

View File

@@ -11,6 +11,8 @@ const { getAiCatalogEntry } = require('./ai-catalog');
const CATEGORIES = new Set(['ITINOVA', 'SANTINOVA', 'INFRA']); const CATEGORIES = new Set(['ITINOVA', 'SANTINOVA', 'INFRA']);
const ENVIRONMENTS = new Set(['recette', 'prod']); const ENVIRONMENTS = new Set(['recette', 'prod']);
const AI_BILLING = new Set(['free', 'paid']);
const AI_SOURCES = new Set(['application', 'referential']);
function isHttpsUrl(value) { function isHttpsUrl(value) {
if (typeof value !== 'string' || value.length === 0) return false; 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 * 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 * descriptives (fournisseur, modèle, rôle, coût et provenance) sont acceptées :
* technique ne doit être publiée par le dashboard. * aucune clé ni URL technique ne doit être publiée par le dashboard.
*/ */
function normalizeAi(ai, appId) { function normalizeAi(ai, appId) {
if (ai === undefined) return { engines: getAiCatalogEntry(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)) { if (engine.role !== undefined && (typeof engine.role !== 'string' || engine.role.trim().length === 0)) {
throw new Error(`ai.engines[${index}].role invalide`); 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 { return {
provider: engine.provider.trim(), provider: engine.provider.trim(),
model: engine.model.trim(), model: engine.model.trim(),
...(engine.role ? { role: engine.role.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',
}; };
}), }),
}; };

View File

@@ -25,7 +25,7 @@ try {
containerName: 'itinova-contacts', containerName: 'itinova-contacts',
image: 'images/itinova-contacts.png', image: 'images/itinova-contacts.png',
ai: { 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: { urls: {
recette: 'https://contacts.recette.santinova-soft.org', recette: 'https://contacts.recette.santinova-soft.org',
@@ -63,24 +63,32 @@ try {
ai: { engines: [{ provider: 'Google Gemini' }] }, ai: { engines: [{ provider: 'Google Gemini' }] },
urls: { recette: 'https://ia-invalide.recette.santinova-soft.org' }, 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' }); const discovery = discoverApps({ appsBasePath: sandbox, environment: 'recette' });
assert.equal(discovery.apps.length, 3, 'un manifeste invalide doit être exclu'); 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'); 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.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.healthCheckUrl, app.urls.recette, 'la vérification doit cibler l’environnement courant');
assert.equal(app.containerName, 'itinova-contacts'); assert.equal(app.containerName, 'itinova-contacts');
assert.deepEqual(app.ai, { assert.deepEqual(app.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' }],
}, 'la métadonnée IA descriptive doit être conservée'); }, 'la métadonnée IA descriptive, tarifaire et de provenance doit être conservée');
const falc = discovery.apps.find((candidate) => candidate.id === 'falc-generator'); const falc = discovery.apps.find((candidate) => candidate.id === 'falc-generator');
assert.deepEqual(falc.ai, { assert.deepEqual(falc.ai, {
engines: [ engines: [
{ provider: 'Manus Forge', model: 'gemini-2.5-flash', role: 'Transformation FALC' }, { 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' }, { 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é'); }, 'le catalogue IA doit compléter un manifeste historique sans moteur déclaré');

View File

@@ -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, /const aiEngines = Array\.isArray\(app\.ai\?\.engines\) \? app\.ai\.engines : \[\];/);
assert.match(appCard, />\s*Moteur IA\s*</); assert.match(appCard, />\s*Moteur IA\s*</);
assert.match(appCard, /Non déclaré/); 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/); assert.match(appSorting, /app\?\.category === 'INFRA' \? 0 : 1/);
console.log('applications-ui-contract: OK'); console.log('applications-ui-contract: OK');

View File

@@ -227,10 +227,26 @@ export default function AppCard({ app, commits, onRefresh }) {
<div className="mt-1.5 space-y-2"> <div className="mt-1.5 space-y-2">
{aiEngines.length > 0 ? aiEngines.map((engine, index) => ( {aiEngines.length > 0 ? aiEngines.map((engine, index) => (
<div key={`${engine.provider}-${engine.model}-${index}`}> <div key={`${engine.provider}-${engine.model}-${index}`}>
<p className="text-sm text-gray-200">{engine.provider}</p> <div className="flex items-center gap-2">
<p className="text-sm text-gray-200">{engine.provider}</p>
<span
className={`inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-semibold ${
engine.billing === 'free'
? 'border-emerald-700/40 bg-emerald-900/40 text-emerald-300'
: 'border-orange-700/40 bg-orange-900/40 text-orange-300'
}`}
>
{engine.billing === 'free' ? 'Gratuit' : 'Payant'}
</span>
</div>
<p className="text-xs text-primary-400 break-words"> <p className="text-xs text-primary-400 break-words">
{engine.model}{engine.role ? ` — ${engine.role}` : ''} {engine.model}{engine.role ? ` — ${engine.role}` : ''}
</p> </p>
<p className="mt-0.5 text-[11px] leading-snug text-gray-500">
{engine.source === 'referential'
? 'Utilisation du référentiel IA'
: "Moteur IA configuré dans l'applicatif"}
</p>
</div> </div>
)) : ( )) : (
<p className="text-sm text-gray-600">Non déclaré</p> <p className="text-sm text-gray-600">Non déclaré</p>