diff --git a/APP_MANIFEST.md b/APP_MANIFEST.md index 2126778..a094c75 100644 --- a/APP_MANIFEST.md +++ b/APP_MANIFEST.md @@ -28,7 +28,8 @@ Le dashboard et le portail applicatif utilisent exclusivement le manifeste plac "billing": "paid", "source": "application" } - ] + ], + "activeConfigurationPath": "/api/ai/active-configuration" }, "urls": { "recette": "https://contacts.recette.santinova-soft.org", @@ -47,6 +48,7 @@ Le dashboard et le portail applicatif utilisent exclusivement le manifeste plac | `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`, `role` optionnel, `billing` (`free` ou `paid`) et `source` (`application` ou `referential`). Ne jamais y mettre une clé, un jeton ni un endpoint privé. | +| `ai.activeConfigurationPath` | Configuration IA active | Route locale facultative sous `/api/` qui renvoie la sélection active sous la forme `{ active, engines }`. Le dashboard la compose avec l’URL locale du manifeste ; aucune URL externe ni donnée sensible n’est acceptée. | | `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. | diff --git a/docs/ai-infrastructure-workflow.mmd b/docs/ai-infrastructure-workflow.mmd new file mode 100644 index 0000000..f86c2a9 --- /dev/null +++ b/docs/ai-infrastructure-workflow.mmd @@ -0,0 +1,16 @@ +flowchart TD + APP[Application cliente] --> REF[Référentiel IA Santinova] + REF --> DEC{Mode de décision} + DEC -->|Recommandation| REC[Meilleur moteur autorisé] + DEC -->|Choix manuel| MAN[Moteur externe autorisé] + DEC -->|Moteur IA local| LOCAL[Moteur IA local] + LOCAL --> OLLAMA[Ollama : modèle disponible] + OLLAMA -->|Disponible| LOCAL_RESPONSE[Réponse locale] + OLLAMA -->|Indisponible + repli activé| FALLBACK[Moteur externe de repli autorisé] + REC --> EXTERNAL[Passerelle fournisseur] + MAN --> EXTERNAL + FALLBACK --> EXTERNAL + EXTERNAL --> EXTERNAL_RESPONSE[Réponse externe] + REF --> BUDGETS[Budgets, plafonds et traçabilité] + LOCAL_RESPONSE --> BUDGETS + EXTERNAL_RESPONSE --> BUDGETS diff --git a/docs/ai-infrastructure.md b/docs/ai-infrastructure.md new file mode 100644 index 0000000..6a7cf7b --- /dev/null +++ b/docs/ai-infrastructure.md @@ -0,0 +1,25 @@ +# Architecture IA — Référentiel IA et Moteur IA local + +## Finalité + +Le dispositif IA de Santinova sépare la **décision de routage**, assurée par le Référentiel IA, de l’**exécution locale**, assurée par le projet Moteur IA local. Chaque application cliente utilise uniquement le contrat sécurisé du Référentiel IA : aucune clé fournisseur, URL Ollama privée ou information d’accès interne n’est exposée aux applications. + +| Composant | Rôle | Informations exposées aux applications | +|---|---|---| +| Référentiel IA Santinova | Catalogue, recommandation, décision effective, budgets, limites et passerelle d’invocation. | Moteur retenu, modèle, règles d’usage et contrat d’appel sécurisé. | +| Moteur IA local | Disponibilité du service local, modèle Ollama prêt et exécution privée des conversations. | Modèle et statut public uniquement. | +| Fournisseurs externes | Exécution des appels explicitement autorisés par application. | Aucun secret n’est transmis par le Référentiel. | + +## Décision appliquée par application + +Le Référentiel IA applique l’un des trois modes définis par l’administrateur. En mode **Recommandation**, il retient le moteur le mieux adapté au cas d’usage, aux capacités demandées et aux règles de coût. En mode **Choix manuel**, il utilise exclusivement le moteur externe choisi parmi ceux autorisés pour l’application. En mode **Moteur IA local**, il utilise le modèle local disponible, actuellement `llama3.2:1b` en recette. + +Le mode local peut comporter un repli externe. Ce repli est **désactivé par défaut**, doit être activé application par application et ne peut désigner qu’un moteur expressément autorisé. Lorsqu’il est déclenché, l’événement est journalisé comme un repli : l’administrateur peut distinguer les appels locaux des appels externes dans les compteurs et les plafonds. + +## Contrôles de sécurité et de budget + +Les budgets et limites externes restent indépendants des plafonds propres au moteur local. Une limite locale atteinte bloque l’appel local avec un retour contrôlé ; elle ne déclenche pas de repli automatique. Le repli est réservé à l’indisponibilité du service local. Les clés API externes restent chiffrées côté serveur et les communications entre le Référentiel IA et le Moteur IA local utilisent un jeton de service conservé uniquement dans les environnements serveur. + +## Exploitation en recette + +Les deux applications sont accessibles en environnement de recette : le **Référentiel IA Santinova** pilote la décision applicative et le **Moteur IA local** supervise Ollama ainsi que les modèles disponibles. Le dashboard d’infrastructure fournit la vue graphique de ce workflow, tandis que la page Applications du Référentiel IA permet d’ajuster le mode de décision, les autorisations, les plafonds et le moteur de repli de chaque application. diff --git a/src/backend/package.json b/src/backend/package.json index 0e20165..6efc1f9 100644 --- a/src/backend/package.json +++ b/src/backend/package.json @@ -6,7 +6,7 @@ "scripts": { "start": "node src/index.js", "dev": "nodemon src/index.js", - "test": "node test/app-registry.test.js && node test/healthcheck.test.js && node test/healthcheck-category.test.js && node test/applications-ui-contract.test.js && node test/webhook.test.js && node test/gitea.test.js && node test/gitea-auth.test.js && node test/token-rotation.test.js && node test/ci-alerts.test.js && node test/ci-email-alert.test.js && node test/webhook-setup.test.js && node test/dashboard-alert-token.test.js" + "test": "node test/app-registry.test.js && node test/healthcheck.test.js && node test/healthcheck-category.test.js && node test/active-ai-configuration.test.js && node test/applications-ui-contract.test.js && node test/webhook.test.js && node test/gitea.test.js && node test/gitea-auth.test.js && node test/token-rotation.test.js && node test/ci-alerts.test.js && node test/ci-email-alert.test.js && node test/webhook-setup.test.js && node test/dashboard-alert-token.test.js" }, "dependencies": { "express": "^4.21.0", diff --git a/src/backend/src/app-registry.js b/src/backend/src/app-registry.js index a3cdfec..1c732e3 100644 --- a/src/backend/src/app-registry.js +++ b/src/backend/src/app-registry.js @@ -30,40 +30,53 @@ function isHttpsUrl(value) { */ function normalizeAi(ai, appId) { if (ai === undefined) return { engines: getAiCatalogEntry(appId) }; - if (!ai || typeof ai !== 'object' || Array.isArray(ai) || !Array.isArray(ai.engines)) { - throw new Error('ai doit être un objet avec un tableau engines'); + if (!ai || typeof ai !== 'object' || Array.isArray(ai)) { + throw new Error('ai doit être un objet'); + } + if (ai.activeConfigurationPath !== undefined && ( + typeof ai.activeConfigurationPath !== 'string' || + !/^\/api\/[a-z0-9/_-]+$/i.test(ai.activeConfigurationPath) + )) { + throw new Error('ai.activeConfigurationPath doit viser une route /api locale'); } + const declaredEngines = ai.engines === undefined ? getAiCatalogEntry(appId) : ai.engines; + if (!Array.isArray(declaredEngines)) { + throw new Error('ai.engines doit être un tableau'); + } + const engines = declaredEngines.map((engine, index) => { + if (!engine || typeof engine !== 'object' || Array.isArray(engine)) { + throw new Error(`ai.engines[${index}] doit être un objet`); + } + if (typeof engine.provider !== 'string' || engine.provider.trim().length === 0) { + throw new Error(`ai.engines[${index}].provider absent ou invalide`); + } + if (typeof engine.model !== 'string' || engine.model.trim().length === 0) { + throw new Error(`ai.engines[${index}].model absent ou invalide`); + } + 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', + }; + }); + return { - engines: ai.engines.map((engine, index) => { - if (!engine || typeof engine !== 'object' || Array.isArray(engine)) { - throw new Error(`ai.engines[${index}] doit être un objet`); - } - if (typeof engine.provider !== 'string' || engine.provider.trim().length === 0) { - throw new Error(`ai.engines[${index}].provider absent ou invalide`); - } - if (typeof engine.model !== 'string' || engine.model.trim().length === 0) { - throw new Error(`ai.engines[${index}].model absent ou invalide`); - } - 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', - }; - }), + engines, + ...(ai.activeConfigurationPath ? { activeConfigurationPath: ai.activeConfigurationPath } : {}), }; } diff --git a/src/backend/src/healthcheck.js b/src/backend/src/healthcheck.js index ac63c91..64ea24a 100644 --- a/src/backend/src/healthcheck.js +++ b/src/backend/src/healthcheck.js @@ -51,6 +51,41 @@ async function checkUrl(url) { } } +/** + * Charge une configuration IA active exclusivement depuis une route /api locale + * validée par le registre. Le dashboard n’envoie aucun secret applicatif et ne + * propage que les métadonnées descriptives explicitement renvoyées. + */ +async function getActiveAiConfiguration(appConfig, localUrl) { + const activePath = appConfig.ai?.activeConfigurationPath; + if (!activePath || !localUrl) return null; + + try { + const response = await axios.get(new URL(activePath, localUrl).toString(), { + timeout: 3500, + httpsAgent, + validateStatus: (status) => status === 200, + }); + const engines = Array.isArray(response.data?.engines) ? response.data.engines : []; + const safeEngines = engines + .filter((engine) => engine && typeof engine.provider === 'string' && typeof engine.model === 'string') + .map((engine) => ({ + provider: engine.provider, + model: engine.model, + ...(typeof engine.role === 'string' ? { role: engine.role } : {}), + billing: engine.billing === 'free' ? 'free' : 'paid', + source: engine.source === 'referential' ? 'referential' : 'application', + })); + return { + active: response.data?.active === true && safeEngines.length > 0, + engines: safeEngines, + runtimeStatus: response.data?.active === true && safeEngines.length > 0 ? 'active' : 'unavailable', + }; + } catch { + return { active: false, engines: [], runtimeStatus: 'unavailable' }; + } +} + /** * Vérifie une application déployée sur l'environnement du dashboard courant. * Le dashboard recette ne sonde plus les URLs prod, et inversement : cela évite @@ -74,11 +109,18 @@ async function checkApp(appConfig) { health: { recette: null, prod: null }, }; - const [containerInfo, health] = await Promise.all([ + const [containerInfo, health, activeAiConfiguration] = await Promise.all([ getContainerInfo(appConfig.containerName), localUrl ? checkUrl(localUrl) : Promise.resolve(null), + getActiveAiConfiguration(appConfig, localUrl), ]); + if (activeAiConfiguration) { + result.ai = { + ...appConfig.ai, + ...activeAiConfiguration, + }; + } result.container = containerInfo; result.health[environment] = health; @@ -163,6 +205,7 @@ function getAllStatuses() { module.exports = { checkUrl, + getActiveAiConfiguration, checkApp, checkAllApps, mapWithConcurrency, diff --git a/src/backend/test/active-ai-configuration.test.js b/src/backend/test/active-ai-configuration.test.js new file mode 100644 index 0000000..6a469db --- /dev/null +++ b/src/backend/test/active-ai-configuration.test.js @@ -0,0 +1,50 @@ +const assert = require('node:assert/strict'); +const axios = require('axios'); +const { getActiveAiConfiguration } = require('../src/healthcheck'); + +(async () => { + const originalGet = axios.get; + try { + let requestedUrl = null; + axios.get = async (url) => { + requestedUrl = url; + return { + data: { + active: true, + engines: [{ + provider: 'Ollama local', + model: 'llama3.2:1b', + role: 'Moteur sélectionné par le Référentiel IA', + billing: 'free', + source: 'referential', + token: 'ne-doit-jamais-etre-publie', + }], + }, + }; + }; + + const result = await getActiveAiConfiguration({ + ai: { activeConfigurationPath: '/api/ai/active-configuration' }, + }, 'https://contacts.recette.santinova-soft.org'); + + assert.equal(requestedUrl, 'https://contacts.recette.santinova-soft.org/api/ai/active-configuration'); + assert.deepEqual(result, { + active: true, + runtimeStatus: 'active', + engines: [{ + provider: 'Ollama local', + model: 'llama3.2:1b', + role: 'Moteur sélectionné par le Référentiel IA', + billing: 'free', + source: 'referential', + }], + }); + assert.equal(JSON.stringify(result).includes('ne-doit-jamais-etre-publie'), false); + console.log('active-ai-configuration: OK'); + } finally { + axios.get = originalGet; + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/backend/test/app-registry.test.js b/src/backend/test/app-registry.test.js index 223a168..735edb1 100644 --- a/src/backend/test/app-registry.test.js +++ b/src/backend/test/app-registry.test.js @@ -26,6 +26,7 @@ try { image: 'images/itinova-contacts.png', ai: { engines: [{ provider: 'Google Gemini', model: 'gemini-2.5-flash', role: 'Assistant conversationnel', billing: 'free', source: 'referential' }], + activeConfigurationPath: '/api/ai/active-configuration', }, urls: { recette: 'https://contacts.recette.santinova-soft.org', @@ -82,7 +83,8 @@ try { assert.equal(app.containerName, 'itinova-contacts'); assert.deepEqual(app.ai, { 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'); + activeConfigurationPath: '/api/ai/active-configuration', + }, 'la métadonnée IA descriptive, tarifaire, de provenance et de configuration active doit être conservée'); const falc = discovery.apps.find((candidate) => candidate.id === 'falc-generator'); assert.deepEqual(falc.ai, { diff --git a/src/backend/test/applications-ui-contract.test.js b/src/backend/test/applications-ui-contract.test.js index 6c34ee6..694d8cd 100644 --- a/src/backend/test/applications-ui-contract.test.js +++ b/src/backend/test/applications-ui-contract.test.js @@ -14,7 +14,7 @@ const appSorting = fs.readFileSync( assert.match(appCard, /const isInfra = app\.category === 'INFRA';/); assert.doesNotMatch(appCard, /\['portail-santinova', 'manus-dashboard'\]\.includes\(app\.id\)/); assert.match(appCard, /const aiEngines = Array\.isArray\(app\.ai\?\.engines\) \? app\.ai\.engines : \[\];/); -assert.match(appCard, />\s*Moteur IA\s* - Moteur IA + {app.ai?.runtimeStatus === 'active' ? 'Moteur IA actif' : 'Moteur IA'}
{aiEngines.length > 0 ? aiEngines.map((engine, index) => ( @@ -242,15 +242,22 @@ export default function AppCard({ app, commits, onRefresh }) {

{engine.model}{engine.role ? ` — ${engine.role}` : ''}

-

+

{engine.source === 'referential' ? 'Utilisation du référentiel IA' : "Moteur IA configuré dans l'applicatif"}

)) : ( -

Non déclaré

+

+ {app.ai?.activeConfigurationPath ? 'Configuration active indisponible' : 'Non déclaré'} +

)} + diff --git a/src/frontend/src/pages/DocumentationPage.jsx b/src/frontend/src/pages/DocumentationPage.jsx index 4e59256..33c64b2 100644 --- a/src/frontend/src/pages/DocumentationPage.jsx +++ b/src/frontend/src/pages/DocumentationPage.jsx @@ -19,6 +19,16 @@ import { Clock, Zap, Package, + Bot, + BrainCircuit, + Workflow, + ShieldCheck, + Route, + ArrowDown, + CircleAlert, + LockKeyhole, + ServerCog, + Sparkles, } from 'lucide-react'; /* ───────────────────────────────────────────── @@ -95,6 +105,22 @@ const RECETTE = { url: 'contacts.recette.santinova-soft.org', href: 'https://contacts.recette.santinova-soft.org', }, + { + icon: BrainCircuit, + color: 'purple', + name: 'Référentiel IA Santinova', + url: 'referentiel-ia.recette.santinova-soft.org', + desc: 'Décisions, contrats sécurisés et budgets IA', + href: 'https://referentiel-ia.recette.santinova-soft.org', + }, + { + icon: ServerCog, + color: 'cyan', + name: 'Moteur IA local', + url: 'llm-local.recette.santinova-soft.org', + desc: 'Ollama privé · modèle local disponible', + href: 'https://llm-local.recette.santinova-soft.org', + }, { icon: '🚗', color: 'yellow', @@ -248,6 +274,74 @@ function SectionTitle({ children, color = 'amber' }) { return

{children}

; } +function WorkflowNode({ icon: Icon, eyebrow, title, description, tone = 'blue' }) { + const tones = { + blue: 'border-blue-500/30 bg-blue-500/10 text-blue-300', + purple: 'border-purple-500/30 bg-purple-500/10 text-purple-300', + teal: 'border-teal-500/30 bg-teal-500/10 text-teal-300', + emerald: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300', + }; + + return ( +
+
+
+
+

{eyebrow}

+

{title}

+

{description}

+
+
+
+ ); +} + +function AiDecisionWorkflow() { + const modes = [ + { icon: Sparkles, title: 'Recommandation', description: 'Le Référentiel retient le moteur adapté au cas d’usage et aux règles de coût.', tone: 'blue' }, + { icon: Route, title: 'Choix manuel', description: 'L’administrateur sélectionne un moteur externe parmi ceux autorisés.', tone: 'purple' }, + { icon: ServerCog, title: 'Moteur IA local', description: 'L’application utilise le modèle Ollama privé disponible.', tone: 'teal' }, + ]; + + return ( +
+
+
+
+
+

Parcours applicatif

+

Quel moteur IA utilise chaque application ?

+

Le Référentiel IA décide et protège le parcours ; le Moteur IA local exécute les demandes privées sur Ollama lorsque ce mode est retenu.

+
+
+
+
+ +
+
+
+ + +
+ {modes.map((mode) =>
)} +
+ +
+ + + +
+ +
+
+
+
+
+ ); +} + /* ───────────────────────────────────────────── Page principale ───────────────────────────────────────────── */ @@ -267,7 +361,7 @@ export default function DocumentationPage() {
- Mis à jour le 04 Mai 2026 + Mis à jour le 05 Septembre 2026
@@ -300,6 +394,8 @@ export default function DocumentationPage() { )} + + {/* ── Grille Recette / CI-CD / Production ── */}
diff --git a/src/frontend/test/documentation-ai.test.mjs b/src/frontend/test/documentation-ai.test.mjs new file mode 100644 index 0000000..f3048b5 --- /dev/null +++ b/src/frontend/test/documentation-ai.test.mjs @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFile } from 'node:fs/promises'; + +const sourcePath = new URL('../src/pages/DocumentationPage.jsx', import.meta.url); + +test('la documentation présente les deux applications IA et les trois modes de décision', async () => { + const source = await readFile(sourcePath, 'utf8'); + + assert.match(source, /Référentiel IA Santinova/); + assert.match(source, /Moteur IA local/); + assert.match(source, /Recommandation/); + assert.match(source, /Choix manuel/); + assert.match(source, /Moteur IA local/); + assert.match(source, /Basculement externe autorisé/); +}); + +test('le workflow rappelle la séparation des budgets et des accès internes', async () => { + const source = await readFile(sourcePath, 'utf8'); + + assert.match(source, /Plafonds séparés/); + assert.match(source, /Aucune clé fournisseur ni URL Ollama privée/); + assert.match(source, /aria-labelledby="ia-workflow-title"/); + assert.match(source, /aria-label="Modes de décision du moteur IA"/); + assert.match(source, /referentiel-ia\.recette\.santinova-soft\.org/); + assert.match(source, /llm-local\.recette\.santinova-soft\.org/); + assert.doesNotMatch(source, /http:\/\/ollama:11434/); +});