feat: synchroniser la configuration IA active du dashboard
All checks were successful
Validation Dashboard / Tests backend et build frontend (push) Successful in 43s
All checks were successful
Validation Dashboard / Tests backend et build frontend (push) Successful in 43s
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
50
src/backend/test/active-ai-configuration.test.js
Normal file
50
src/backend/test/active-ai-configuration.test.js
Normal 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);
|
||||
});
|
||||
@@ -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, {
|
||||
|
||||
@@ -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*</);
|
||||
assert.match(appCard, /Moteur IA actif/);
|
||||
assert.match(appCard, /Non déclaré/);
|
||||
assert.match(appCard, /engine\.billing === 'free'/);
|
||||
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, /Utilisation du référentiel IA/);
|
||||
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/);
|
||||
|
||||
console.log('applications-ui-contract: OK');
|
||||
|
||||
@@ -222,7 +222,7 @@ export default function AppCard({ app, commits, onRefresh }) {
|
||||
{/* Moteurs IA déclarés dans app.json */}
|
||||
<div>
|
||||
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Moteur IA
|
||||
{app.ai?.runtimeStatus === 'active' ? 'Moteur IA actif' : 'Moteur IA'}
|
||||
</span>
|
||||
<div className="mt-1.5 space-y-2">
|
||||
{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">
|
||||
{engine.model}{engine.role ? ` — ${engine.role}` : ''}
|
||||
</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'
|
||||
? '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>
|
||||
<p className="text-sm text-gray-600">
|
||||
{app.ai?.activeConfigurationPath ? 'Configuration active indisponible' : 'Non déclaré'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 <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
|
||||
───────────────────────────────────────────── */
|
||||
@@ -267,7 +361,7 @@ export default function DocumentationPage() {
|
||||
</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">
|
||||
<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>
|
||||
|
||||
@@ -300,6 +394,8 @@ export default function DocumentationPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AiDecisionWorkflow />
|
||||
|
||||
{/* ── Grille Recette / CI-CD / Production ── */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[1fr_auto_1fr] gap-6">
|
||||
|
||||
|
||||
28
src/frontend/test/documentation-ai.test.mjs
Normal file
28
src/frontend/test/documentation-ai.test.mjs
Normal 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/);
|
||||
});
|
||||
Reference in New Issue
Block a user