feat: afficher coût et provenance des moteurs IA
All checks were successful
Validation Dashboard / Tests backend et build frontend (push) Successful in 50s
All checks were successful
Validation Dashboard / Tests backend et build frontend (push) Successful in 50s
This commit is contained in:
@@ -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' },
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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é');
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -227,10 +227,26 @@ export default function AppCard({ app, commits, onRefresh }) {
|
||||
<div className="mt-1.5 space-y-2">
|
||||
{aiEngines.length > 0 ? aiEngines.map((engine, 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">
|
||||
{engine.model}{engine.role ? ` — ${engine.role}` : ''}
|
||||
</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>
|
||||
)) : (
|
||||
<p className="text-sm text-gray-600">Non déclaré</p>
|
||||
|
||||
Reference in New Issue
Block a user