feat: synchroniser la configuration IA active du dashboard
All checks were successful
Validation Dashboard / Tests backend et build frontend (push) Successful in 43s

This commit is contained in:
Manus Deploy
2026-09-10 00:28:08 +00:00
parent 7d7006ab29
commit 85a1415e05
12 changed files with 326 additions and 40 deletions

View File

@@ -28,7 +28,8 @@ Le dashboard et le portail applicatif utilisent exclusivement le manifeste plac
"billing": "paid", "billing": "paid",
"source": "application" "source": "application"
} }
] ],
"activeConfigurationPath": "/api/ai/active-configuration"
}, },
"urls": { "urls": {
"recette": "https://contacts.recette.santinova-soft.org", "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. | | `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`, `role` optionnel, `billing` (`free` ou `paid`) et `source` (`application` ou `referential`). 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é. |
| `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.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. |

View File

@@ -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

25
docs/ai-infrastructure.md Normal file
View File

@@ -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.

View File

@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"start": "node src/index.js", "start": "node src/index.js",
"dev": "nodemon 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": { "dependencies": {
"express": "^4.21.0", "express": "^4.21.0",

View File

@@ -30,12 +30,21 @@ function isHttpsUrl(value) {
*/ */
function normalizeAi(ai, appId) { function normalizeAi(ai, appId) {
if (ai === undefined) return { engines: getAiCatalogEntry(appId) }; if (ai === undefined) return { engines: getAiCatalogEntry(appId) };
if (!ai || typeof ai !== 'object' || Array.isArray(ai) || !Array.isArray(ai.engines)) { if (!ai || typeof ai !== 'object' || Array.isArray(ai)) {
throw new Error('ai doit être un objet avec un tableau engines'); 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');
} }
return { const declaredEngines = ai.engines === undefined ? getAiCatalogEntry(appId) : ai.engines;
engines: ai.engines.map((engine, index) => { 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)) { if (!engine || typeof engine !== 'object' || Array.isArray(engine)) {
throw new Error(`ai.engines[${index}] doit être un objet`); throw new Error(`ai.engines[${index}] doit être un objet`);
} }
@@ -63,7 +72,11 @@ function normalizeAi(ai, appId) {
billing: engine.billing || 'paid', billing: engine.billing || 'paid',
source: engine.source || 'application', source: engine.source || 'application',
}; };
}), });
return {
engines,
...(ai.activeConfigurationPath ? { activeConfigurationPath: ai.activeConfigurationPath } : {}),
}; };
} }

View File

@@ -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. * 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 * 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 }, health: { recette: null, prod: null },
}; };
const [containerInfo, health] = await Promise.all([ const [containerInfo, health, activeAiConfiguration] = await Promise.all([
getContainerInfo(appConfig.containerName), getContainerInfo(appConfig.containerName),
localUrl ? checkUrl(localUrl) : Promise.resolve(null), localUrl ? checkUrl(localUrl) : Promise.resolve(null),
getActiveAiConfiguration(appConfig, localUrl),
]); ]);
if (activeAiConfiguration) {
result.ai = {
...appConfig.ai,
...activeAiConfiguration,
};
}
result.container = containerInfo; result.container = containerInfo;
result.health[environment] = health; result.health[environment] = health;
@@ -163,6 +205,7 @@ function getAllStatuses() {
module.exports = { module.exports = {
checkUrl, checkUrl,
getActiveAiConfiguration,
checkApp, checkApp,
checkAllApps, checkAllApps,
mapWithConcurrency, mapWithConcurrency,

View File

@@ -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);
});

View File

@@ -26,6 +26,7 @@ try {
image: 'images/itinova-contacts.png', image: 'images/itinova-contacts.png',
ai: { ai: {
engines: [{ provider: 'Google Gemini', model: 'gemini-2.5-flash', role: 'Assistant conversationnel', billing: 'free', source: 'referential' }], engines: [{ provider: 'Google Gemini', model: 'gemini-2.5-flash', role: 'Assistant conversationnel', billing: 'free', source: 'referential' }],
activeConfigurationPath: '/api/ai/active-configuration',
}, },
urls: { urls: {
recette: 'https://contacts.recette.santinova-soft.org', recette: 'https://contacts.recette.santinova-soft.org',
@@ -82,7 +83,8 @@ try {
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', billing: 'free', source: 'referential' }], 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'); const falc = discovery.apps.find((candidate) => candidate.id === 'falc-generator');
assert.deepEqual(falc.ai, { assert.deepEqual(falc.ai, {

View File

@@ -14,7 +14,7 @@ const appSorting = fs.readFileSync(
assert.match(appCard, /const isInfra = app\.category === 'INFRA';/); assert.match(appCard, /const isInfra = app\.category === 'INFRA';/);
assert.doesNotMatch(appCard, /\['portail-santinova', 'manus-dashboard'\]\.includes\(app\.id\)/); 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, /const aiEngines = Array\.isArray\(app\.ai\?\.engines\) \? app\.ai\.engines : \[\];/);
assert.match(appCard, />\s*Moteur IA\s*</); assert.match(appCard, /Moteur IA actif/);
assert.match(appCard, /Non déclaré/); assert.match(appCard, /Non déclaré/);
assert.match(appCard, /engine\.billing === 'free'/); assert.match(appCard, /engine\.billing === 'free'/);
assert.match(appCard, /Gratuit/); assert.match(appCard, /Gratuit/);
@@ -23,6 +23,10 @@ assert.match(appCard, /bg-emerald-900\/40 text-emerald-300/);
assert.match(appCard, /bg-orange-900\/40 text-orange-300/); assert.match(appCard, /bg-orange-900\/40 text-orange-300/);
assert.match(appCard, /Utilisation du référentiel IA/); assert.match(appCard, /Utilisation du référentiel IA/);
assert.match(appCard, /Moteur IA configuré dans l'applicatif/); assert.match(appCard, /Moteur IA configuré dans l'applicatif/);
assert.match(appCard, /Moteur IA actif/);
assert.match(appCard, /text-emerald-300/);
assert.match(appCard, /text-orange-300/);
assert.match(appCard, /Configuration active indisponible/);
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

@@ -222,7 +222,7 @@ export default function AppCard({ app, commits, onRefresh }) {
{/* Moteurs IA déclarés dans app.json */} {/* Moteurs IA déclarés dans app.json */}
<div> <div>
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider"> <span className="text-xs font-medium text-gray-500 uppercase tracking-wider">
Moteur IA {app.ai?.runtimeStatus === 'active' ? 'Moteur IA actif' : 'Moteur IA'}
</span> </span>
<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) => (
@@ -242,15 +242,22 @@ export default function AppCard({ app, commits, onRefresh }) {
<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"> <p
className={`mt-0.5 text-[11px] leading-snug ${
engine.source === 'referential' ? 'text-emerald-300' : 'text-orange-300'
}`}
>
{engine.source === 'referential' {engine.source === 'referential'
? 'Utilisation du référentiel IA' ? 'Utilisation du référentiel IA'
: "Moteur IA configuré dans l'applicatif"} : "Moteur IA configuré dans l'applicatif"}
</p> </p>
</div> </div>
)) : ( )) : (
<p className="text-sm text-gray-600">Non déclaré</p> <p className="text-sm text-gray-600">
{app.ai?.activeConfigurationPath ? 'Configuration active indisponible' : 'Non déclaré'}
</p>
)} )}
</div> </div>
</div> </div>

View File

@@ -19,6 +19,16 @@ import {
Clock, Clock,
Zap, Zap,
Package, Package,
Bot,
BrainCircuit,
Workflow,
ShieldCheck,
Route,
ArrowDown,
CircleAlert,
LockKeyhole,
ServerCog,
Sparkles,
} from 'lucide-react'; } from 'lucide-react';
/* ───────────────────────────────────────────── /* ─────────────────────────────────────────────
@@ -95,6 +105,22 @@ const RECETTE = {
url: 'contacts.recette.santinova-soft.org', url: 'contacts.recette.santinova-soft.org',
href: 'https://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: '🚗', icon: '🚗',
color: 'yellow', color: 'yellow',
@@ -248,6 +274,74 @@ function SectionTitle({ children, color = 'amber' }) {
return <h3 className={`text-xs font-bold uppercase tracking-widest ${cls} mb-3`}>{children}</h3>; return <h3 className={`text-xs font-bold uppercase tracking-widest ${cls} mb-3`}>{children}</h3>;
} }
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 (
<div className={`rounded-2xl border p-4 ${tones[tone]}`}>
<div className="flex items-start gap-3">
<div className="rounded-xl bg-dark-950/35 p-2.5"><Icon className="h-5 w-5" aria-hidden="true" /></div>
<div className="min-w-0">
<p className="text-[10px] font-bold uppercase tracking-[0.16em] opacity-70">{eyebrow}</p>
<h4 className="mt-1 text-sm font-bold text-white">{title}</h4>
<p className="mt-1 text-xs leading-5 text-gray-300">{description}</p>
</div>
</div>
</div>
);
}
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 (
<section className="rounded-2xl border border-cyan-500/25 bg-gradient-to-br from-cyan-500/[0.07] via-dark-900 to-purple-500/[0.06] p-6" aria-labelledby="ia-workflow-title">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex gap-3">
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-cyan-500/15 text-cyan-300"><Workflow className="h-5 w-5" aria-hidden="true" /></div>
<div>
<p className="text-xs font-bold uppercase tracking-[0.16em] text-cyan-400">Parcours applicatif</p>
<h2 id="ia-workflow-title" className="mt-1 text-xl font-bold text-white">Quel moteur IA utilise chaque application ?</h2>
<p className="mt-2 max-w-3xl text-sm leading-6 text-gray-300">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.</p>
</div>
</div>
<div className="flex items-center gap-2 rounded-xl border border-emerald-500/25 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-300"><ShieldCheck className="h-4 w-4" aria-hidden="true" />Aucune clé fournisseur ni URL Ollama privée</div>
</div>
<div className="mt-7 flex flex-col items-stretch gap-3 lg:flex-row lg:items-center lg:justify-center">
<div className="w-full max-w-sm lg:w-72"><WorkflowNode icon={Bot} eyebrow="1 · demande" title="Application cliente" description="Envoie sa demande à la passerelle du Référentiel IA." tone="blue" /></div>
<ArrowRight className="mx-auto h-5 w-5 rotate-90 text-cyan-400 lg:rotate-0" aria-hidden="true" />
<div className="w-full max-w-sm lg:w-80"><WorkflowNode icon={BrainCircuit} eyebrow="2 · décision" title="Référentiel IA Santinova" description="Applique le choix, les autorisations, les budgets et la traçabilité." tone="purple" /></div>
</div>
<div className="mx-auto flex h-8 w-px items-center justify-center bg-gradient-to-b from-purple-400 to-cyan-400" aria-hidden="true"><ArrowDown className="mt-6 h-4 w-4 text-cyan-300" /></div>
<div className="grid gap-3 md:grid-cols-3" role="list" aria-label="Modes de décision du moteur IA">
{modes.map((mode) => <div key={mode.title} role="listitem"><WorkflowNode icon={mode.icon} eyebrow="3 · mode de décision" title={mode.title} description={mode.description} tone={mode.tone} /></div>)}
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-[1fr_auto_1fr] lg:items-stretch">
<WorkflowNode icon={ServerCog} eyebrow="4a · exécution privée" title="Moteur IA local" description="Le service exécute localement sur Ollama. Les requêtes et jetons locaux disposent de plafonds dédiés." tone="teal" />
<div className="flex items-center justify-center" aria-hidden="true"><ArrowRight className="h-5 w-5 rotate-90 text-gray-500 lg:rotate-0" /></div>
<WorkflowNode icon={Globe} eyebrow="4b · exécution externe" title="Moteur externe autorisé" description="La passerelle appelle exclusivement le fournisseur prévu par la décision ou le choix manuel." tone="emerald" />
</div>
<div className="mt-4 grid gap-3 lg:grid-cols-[1fr_1.35fr]">
<div className="flex gap-3 rounded-xl border border-amber-500/25 bg-amber-500/[0.08] p-4"><CircleAlert className="h-5 w-5 flex-shrink-0 text-amber-400" aria-hidden="true" /><div><p className="text-sm font-semibold text-amber-200">Basculement externe autorisé</p><p className="mt-1 text-xs leading-5 text-gray-300">Uniquement si le moteur local est indisponible, si l’option est activée et vers le moteur externe autorisé choisi par l’administrateur.</p></div></div>
<div className="flex gap-3 rounded-xl border border-dark-700 bg-dark-950/40 p-4"><LockKeyhole className="h-5 w-5 flex-shrink-0 text-cyan-300" aria-hidden="true" /><div><p className="text-sm font-semibold text-white">Plafonds séparés et traces de décision</p><p className="mt-1 text-xs leading-5 text-gray-400">Les consommations locales et externes restent séparées. Une limite locale atteinte bloque l’appel local sans déclencher de repli.</p></div></div>
</div>
</section>
);
}
/* ───────────────────────────────────────────── /* ─────────────────────────────────────────────
Page principale Page principale
───────────────────────────────────────────── */ ───────────────────────────────────────────── */
@@ -267,7 +361,7 @@ export default function DocumentationPage() {
</div> </div>
<div className="ml-auto flex items-center gap-2 px-3 py-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/20"> <div className="ml-auto flex items-center gap-2 px-3 py-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/20">
<Clock className="w-3.5 h-3.5 text-emerald-400" /> <Clock className="w-3.5 h-3.5 text-emerald-400" />
<span className="text-xs text-emerald-400">Mis à jour le 04 Mai 2026</span> <span className="text-xs text-emerald-400">Mis à jour le 05 Septembre 2026</span>
</div> </div>
</div> </div>
@@ -300,6 +394,8 @@ export default function DocumentationPage() {
)} )}
</div> </div>
<AiDecisionWorkflow />
{/* ── Grille Recette / CI-CD / Production ── */} {/* ── Grille Recette / CI-CD / Production ── */}
<div className="grid grid-cols-1 xl:grid-cols-[1fr_auto_1fr] gap-6"> <div className="grid grid-cols-1 xl:grid-cols-[1fr_auto_1fr] gap-6">

View File

@@ -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/);
});