From ba342331898ec0ec593e076189b2e693ac2ded9a Mon Sep 17 00:00:00 2001 From: manus-admin Date: Tue, 6 Oct 2026 16:58:11 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20finaliser=20l=E2=80=99int=C3=A9gration?= =?UTF-8?q?=20au=20R=C3=A9f=C3=A9rentiel=20IA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/ReferentielIaCard.tsx | 50 ++++++ client/src/pages/Settings.tsx | 6 + docker-compose.recette.yml | 5 +- server/_core/aiReferenceClient.test.ts | 52 ++++-- server/_core/aiReferenceClient.ts | 65 +++++-- server/_core/env.ts | 5 +- server/ai-reference.test.ts | 26 +++ server/ai-reference.ts | 187 ++++---------------- server/routers.ts | 5 + 9 files changed, 218 insertions(+), 183 deletions(-) create mode 100644 client/src/components/ReferentielIaCard.tsx create mode 100644 server/ai-reference.test.ts diff --git a/client/src/components/ReferentielIaCard.tsx b/client/src/components/ReferentielIaCard.tsx new file mode 100644 index 0000000..a7f4185 --- /dev/null +++ b/client/src/components/ReferentielIaCard.tsx @@ -0,0 +1,50 @@ +import { Activity, CheckCircle2, CircleAlert, Link2, Loader2, ShieldCheck } from "lucide-react"; +import { toast } from "sonner"; +import { trpc } from "@/lib/trpc"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; + +const STATE_LABELS = { available: "Connecté", unconfigured: "Non configuré", authentication_error: "Erreur d’authentification", unreachable: "Indisponible" } as const; +const ORIGIN_LABELS = { proposal: "Proposition du Référentiel", manual: "Choix administrateur", local: "Moteur IA local" } as const; + +function displayDate(value?: string) { + if (!value) return "Aucun contrôle"; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? "Aucun contrôle" : date.toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" }); +} + +export function ReferentielIaCard() { + const statusQuery = trpc.aiReference.status.useQuery(); + const testMutation = trpc.aiReference.test.useMutation({ + onSuccess: async result => { + await statusQuery.refetch(); + if (result.state === "available") toast.success("Connexion au Référentiel IA vérifiée"); + else toast.error("Connexion Référentiel IA non opérationnelle", { description: result.message }); + }, + onError: error => toast.error("Test du Référentiel IA impossible", { description: error.message }), + }); + const status = statusQuery.data; + const connected = status?.state === "available"; + + return + +
+
+
+
Intégration Référentiel IALe moteur est décidé et appelé exclusivement par la passerelle Santinova.
+
+ +
+
+ + {statusQuery.isLoading ?
Vérification de la configuration serveur…
: statusQuery.error ?
Impossible de vérifier le Référentiel IA : {statusQuery.error.message}
: <> +
{connected ? : }{status ? STATE_LABELS[status.state] : "Indisponible"}Moteur : {status?.engine?.configured ? "Prêt" : "Non configuré"}
+

Moteur choisi

{status?.engine?.name ?? "Non disponible"}

{status?.engine ? `${status.engine.provider} · ${status.engine.model ?? "Modèle non disponible"}` : "Aucune décision disponible"}

Origine de la décision

{status?.engine ? ORIGIN_LABELS[status.engine.origin] : "Non disponible"}

Dernier contrôle : {displayDate(status?.checkedAt)}

+
{status?.message ?? "Le statut Référentiel IA sera disponible après vérification."}
+ } +
+
; +} diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx index f2b855e..4ce9a6b 100644 --- a/client/src/pages/Settings.tsx +++ b/client/src/pages/Settings.tsx @@ -7,6 +7,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Switch } from "@/components/ui/switch"; import { Badge } from "@/components/ui/badge"; +import { ReferentielIaCard } from "@/components/ReferentielIaCard"; import { Settings as SettingsIcon, HardDrive, @@ -362,11 +363,16 @@ export default function SettingsPage() { + Référentiel IA Source des fichiers Import & Planification Authentification + + + + {/* ─── Source des fichiers ─────────────────────────────────────── */} diff --git a/docker-compose.recette.yml b/docker-compose.recette.yml index 5bb2ada..bda9f15 100644 --- a/docker-compose.recette.yml +++ b/docker-compose.recette.yml @@ -26,8 +26,9 @@ services: - AZURE_AD_CLIENT_SECRET=d.T8Q~RR9iobI9bin2hqecoBWDjvyD7I11U5QcPR - AZURE_AD_TENANT_ID=487d0a81-de35-44ce-8847-03bb74ec553e - AZURE_AD_REDIRECT_URI=https://veille.recette.santinova-soft.org/api/auth/azure/callback - - IA_REFERENTIEL_URL=${IA_REFERENTIEL_URL:?IA_REFERENTIEL_URL requis} - - IA_REFERENTIEL_TOKEN=${IA_REFERENTIEL_TOKEN:?IA_REFERENTIEL_TOKEN requis} + - REFERENTIEL_IA_BASE_URL=${REFERENTIEL_IA_BASE_URL:?REFERENTIEL_IA_BASE_URL requis} + - REFERENTIEL_IA_TOKEN=${REFERENTIEL_IA_TOKEN:?REFERENTIEL_IA_TOKEN requis} + - REFERENTIEL_IA_APP_SLUG=${REFERENTIEL_IA_APP_SLUG:?REFERENTIEL_IA_APP_SLUG requis} depends_on: veille-db-recette: condition: service_healthy diff --git a/server/_core/aiReferenceClient.test.ts b/server/_core/aiReferenceClient.test.ts index f0f131e..5c8251d 100644 --- a/server/_core/aiReferenceClient.test.ts +++ b/server/_core/aiReferenceClient.test.ts @@ -2,25 +2,51 @@ import { describe, expect, it, vi } from "vitest"; import { ENV } from "./env"; import { invokeSelectedReference } from "./aiReferenceClient"; +const recommendation = { + application: { slug: "veille-reglementaire" }, + decision: { + selected: { + origin: "manual", + engine: { slug: "manus-forge", name: "Manus Forge", provider: "Manus" }, + connection: { configured: true, model: "gemini-2.5-flash" }, + }, + }, +}; + describe("client Référentiel IA", () => { - it("transmet seulement le payload métier et restitue la trace résolue", async () => { + it("résout le moteur du Référentiel puis transmet son slug exact", async () => { ENV.aiReferentielUrl = "https://referentiel-ia.recette.santinova-soft.org"; ENV.aiReferentielToken = "token-de-test"; - const fetcher = vi.fn(async () => new Response(JSON.stringify({ choices: [] }), { - status: 200, - headers: { - "x-santinova-ia-provider": "Manus", - "x-santinova-ia-model": "gemini-2.5-flash", - "x-santinova-ia-transport": "manus-forge", - "x-santinova-ia-origin": "manual", - }, - })); + ENV.aiReferentielAppSlug = "veille-reglementaire"; + const fetcher = vi.fn() + .mockResolvedValueOnce(new Response(JSON.stringify(recommendation), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ choices: [] }), { + status: 200, + headers: { + "x-santinova-ia-provider": "Manus", + "x-santinova-ia-model": "gemini-2.5-flash", + "x-santinova-ia-transport": "manus-forge", + "x-santinova-ia-origin": "manual", + }, + })); const response = await invokeSelectedReference<{ choices: unknown[] }>({ messages: [{ role: "user", content: "test" }] }, fetcher as typeof fetch); expect(response.choices).toEqual([]); - expect(fetcher).toHaveBeenCalledOnce(); - const [, options] = fetcher.mock.calls[0] as [string, RequestInit]; - expect(JSON.parse(String(options.body))).toEqual({ payload: { messages: [{ role: "user", content: "test" }] } }); + expect(fetcher).toHaveBeenCalledTimes(2); + const [, invokeOptions] = fetcher.mock.calls[1] as [string, RequestInit]; + expect(JSON.parse(String(invokeOptions.body))).toEqual({ + engineSlug: "manus-forge", + payload: { messages: [{ role: "user", content: "test" }] }, + }); + }); + + it("refuse une décision rattachée à une autre application", async () => { + ENV.aiReferentielUrl = "https://referentiel-ia.recette.santinova-soft.org"; + ENV.aiReferentielToken = "token-de-test"; + ENV.aiReferentielAppSlug = "veille-reglementaire"; + const fetcher = vi.fn().mockResolvedValue(new Response(JSON.stringify({ ...recommendation, application: { slug: "autre-app" } }), { status: 200 })); + + await expect(invokeSelectedReference({ messages: [] }, fetcher as typeof fetch)).rejects.toThrow("application attendue"); }); }); diff --git a/server/_core/aiReferenceClient.ts b/server/_core/aiReferenceClient.ts index d51fb1c..3384b78 100644 --- a/server/_core/aiReferenceClient.ts +++ b/server/_core/aiReferenceClient.ts @@ -7,34 +7,70 @@ export type ResolvedAiTrace = { origin: string; }; +type ReferenceDecision = { + application?: { slug?: string }; + decision?: { selected?: { engine?: { slug?: string } } | null } | null; +}; + function configuration() { const url = ENV.aiReferentielUrl.trim().replace(/\/$/, ""); const token = ENV.aiReferentielToken.trim(); - if (!url || !token) { + const applicationSlug = ENV.aiReferentielAppSlug.trim(); + if (!url || !token || !applicationSlug) { throw new Error("Référentiel IA non configuré pour cette application."); } - return { url, token }; + const parsed = new URL(url); + const allowedHosts = new Set([ + "referentiel-ia.recette.santinova-soft.org", + "referentiel-ia.santinova-soft.org", + ]); + if (parsed.protocol !== "https:" || !allowedHosts.has(parsed.hostname)) { + throw new Error("URL du Référentiel IA non autorisée."); + } + return { url, token, applicationSlug }; +} + +/** Résout à chaque appel le moteur choisi par le Référentiel, sans cache durable. */ +async function resolveSelectedEngine( + currentConfiguration: { url: string; token: string; applicationSlug: string }, + fetcher: typeof fetch, +): Promise { + const response = await fetcher(`${currentConfiguration.url}/api/integration/v1/recommendations`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${currentConfiguration.token}` }, + body: JSON.stringify({ useCase: "texte", optimizeCost: false, needsFreeTier: false, includeConnection: false }), + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) { + throw new Error(`Référentiel IA : décision indisponible (HTTP ${response.status}).`); + } + const decision = await response.json() as ReferenceDecision; + if (decision.application?.slug !== currentConfiguration.applicationSlug) { + throw new Error("Référentiel IA : la décision ne correspond pas à l’application attendue."); + } + const engineSlug = decision.decision?.selected?.engine?.slug?.trim(); + if (!engineSlug) { + throw new Error("Référentiel IA : aucun moteur n’est sélectionné."); + } + return engineSlug; } /** - * Le Référentiel décide seul du moteur et écrase tout modèle éventuellement - * présent dans le payload. La trace détaillée est persistée centralement. + * Le Référentiel décide seul du moteur. La Veille transmet ensuite son slug exact + * à la passerelle centrale et ne connaît jamais de clé fournisseur. */ export async function invokeSelectedReference(payload: Record, fetcher: typeof fetch = fetch): Promise { - const { url, token } = configuration(); - const response = await fetcher(`${url}/api/integration/v1/invoke`, { + const currentConfiguration = configuration(); + const engineSlug = await resolveSelectedEngine(currentConfiguration, fetcher); + const response = await fetcher(`${currentConfiguration.url}/api/integration/v1/invoke`, { method: "POST", - headers: { - "content-type": "application/json", - authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ payload }), + headers: { "content-type": "application/json", authorization: `Bearer ${currentConfiguration.token}` }, + body: JSON.stringify({ engineSlug, payload }), signal: AbortSignal.timeout(120_000), }); if (!response.ok) { throw new Error(`Référentiel IA : invocation refusée (HTTP ${response.status}).`); } - const trace: ResolvedAiTrace = { provider: response.headers.get("x-santinova-ia-provider") || "unknown", resolvedModel: response.headers.get("x-santinova-ia-model") || "unknown", @@ -44,10 +80,7 @@ export async function invokeSelectedReference(payload: Record { + it("retourne une décision assainie sans exposer le jeton", async () => { + const fetcher = vi.fn().mockResolvedValue(new Response(JSON.stringify(decision), { status: 200 })); + const status = await getAiEngineStatus(config, fetcher as typeof fetch); + expect(status).toMatchObject({ state: "available", engine: { name: "Manus Forge", model: "gemini-2.5-flash", origin: "manual", operationalStatus: "ready" } }); + expect(JSON.stringify(status)).not.toContain("jeton-test"); + }); + + it("rejette une identité applicative incohérente", async () => { + const fetcher = vi.fn().mockResolvedValue(new Response(JSON.stringify({ ...decision, application: { slug: "autre-app" } }), { status: 200 })); + const status = await getAiEngineStatus(config, fetcher as typeof fetch); + expect(status).toMatchObject({ state: "authentication_error" }); + }); + + it("retourne un statut explicite pour un jeton invalide", async () => { + const fetcher = vi.fn().mockResolvedValue(new Response(null, { status: 401 })); + const status = await getAiEngineStatus(config, fetcher as typeof fetch); + expect(status).toMatchObject({ state: "authentication_error" }); + }); +}); diff --git a/server/ai-reference.ts b/server/ai-reference.ts index b483b07..2f00914 100644 --- a/server/ai-reference.ts +++ b/server/ai-reference.ts @@ -1,167 +1,54 @@ import { ENV } from "./_core/env"; export type AiEngineStatus = { - state: "available" | "unconfigured" | "unreachable"; - engine?: { - name: string; - provider: string; - slug: string; - model?: string; - accessMode?: "gateway" | "recommendation_only"; - configured: boolean; - origin: "proposal" | "manual" | "local"; - rationale?: string; - }; - proposal?: { name: string; provider: string; model?: string; origin: "proposal"; rationale?: string }; - usage?: UsageSummary; + state: "available" | "unconfigured" | "authentication_error" | "unreachable"; + engine?: { name: string; provider: string; slug: string; model?: string; configured: boolean; origin: "proposal" | "manual" | "local"; operationalStatus: "ready" | "not_tested" | "not_configured" | "not_functional" | "unavailable"; rationale?: string }; checkedAt: string; message: string; }; -type ReferenceConfiguration = { - url: string; - token: string; -}; - -type RecommendationResponse = { - decision?: { - proposal?: DecisionEngine; - selected?: DecisionEngine; - }; - recommendations?: DecisionEngine[]; - usage?: UsageSummary; -}; - -type UsageMetric = { used: number; limit: number | null; percent: number | null; status: "not_configured" | "normal" | "alert" | "limit_reached" | "unpriced"; shouldBlock?: boolean }; -type UsageAlert = { metric: "budget" | "requests" | "tokens"; label: string; status: "alert" | "limit_reached"; percent: number; message: string }; -type UsageSummary = { canInvoke: boolean; blockReason?: string | null; budget: UsageMetric & { currency: string; estimated?: boolean }; requests: UsageMetric; tokens: UsageMetric; alerts: UsageAlert[] }; - -type DecisionEngine = { - origin?: "proposal" | "manual" | "local"; - rationale?: string; - engine?: { name?: string; provider?: string; slug?: string }; - connection?: { model?: string; accessMode?: "gateway" | "recommendation_only"; configured?: boolean }; -}; +type ReferenceConfiguration = { url: string; token: string; applicationSlug: string }; +type DecisionEngine = { origin?: "proposal" | "manual" | "local"; rationale?: string; engine?: { name?: string; provider?: string; slug?: string }; connection?: { model?: string; configured?: boolean; testStatus?: "not_tested" | "success" | "failed" } }; +type RecommendationResponse = { application?: { slug?: string }; decision?: { selected?: DecisionEngine | null } | null }; const now = () => new Date().toISOString(); +const configuration = (): ReferenceConfiguration => ({ url: ENV.aiReferentielUrl, token: ENV.aiReferentielToken, applicationSlug: ENV.aiReferentielAppSlug }); -export async function getAiEngineStatus( - configuration: ReferenceConfiguration = { - url: ENV.aiReferentielUrl, - token: ENV.aiReferentielToken, - }, - fetcher: typeof fetch = fetch, -): Promise { - if (!configuration.url || !configuration.token) { - return { - state: "unconfigured", - checkedAt: now(), - message: "Le Référentiel IA n’est pas encore configuré pour cette application.", - }; - } +function operationalStatus(selected: DecisionEngine): NonNullable["operationalStatus"] { + if (selected.origin === "local") return selected.connection?.configured ? "ready" : "unavailable"; + if (!selected.connection?.configured) return "not_configured"; + if (selected.connection.testStatus === "success") return "ready"; + if (selected.connection.testStatus === "failed") return "not_functional"; + return "not_tested"; +} - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 6_000); +/** Test serveur à serveur : décision seulement, sans lancer d’inférence fournisseur. */ +export async function getAiEngineStatus(input: ReferenceConfiguration = configuration(), fetcher: typeof fetch = fetch): Promise { + const checkedAt = now(); + if (!input.url || !input.token || !input.applicationSlug) return { state: "unconfigured", checkedAt, message: "Le Référentiel IA n’est pas configuré pour cette application." }; + let baseUrl: URL; + try { baseUrl = new URL(input.url); } catch { return { state: "unconfigured", checkedAt, message: "L’URL du Référentiel IA est invalide." }; } + if (baseUrl.protocol !== "https:" || !["referentiel-ia.recette.santinova-soft.org", "referentiel-ia.santinova-soft.org"].includes(baseUrl.hostname)) return { state: "unconfigured", checkedAt, message: "L’URL du Référentiel IA n’est pas autorisée." }; try { - const response = await fetcher(`${configuration.url.replace(/\/$/, "")}/api/integration/v1/recommendations`, { - method: "POST", - headers: { - Authorization: `Bearer ${configuration.token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - useCase: "texte", - optimizeCost: true, - needsFreeTier: true, - includeConnection: true, - }), - signal: controller.signal, + const response = await fetcher(`${baseUrl.toString().replace(/\/$/, "")}/api/integration/v1/recommendations`, { + method: "POST", headers: { Authorization: `Bearer ${input.token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ useCase: "texte", optimizeCost: false, needsFreeTier: false, includeConnection: true }), signal: AbortSignal.timeout(15_000), }); - - if (!response.ok) { - return { - state: "unreachable", - checkedAt: now(), - message: `Le Référentiel IA est indisponible (HTTP ${response.status}).`, - }; - } - - const payload = (await response.json()) as RecommendationResponse; - const selected = payload.decision?.selected ?? payload.recommendations?.[0]; - if (!selected?.engine?.name || !selected.engine.provider || !selected.engine.slug) { - return { - state: "unreachable", - checkedAt: now(), - message: "Le Référentiel IA n’a retourné aucun moteur autorisé.", - }; - } - - const proposal = payload.decision?.proposal; + if (response.status === 401) return { state: "authentication_error", checkedAt, message: "Erreur d’authentification auprès du Référentiel IA." }; + if (!response.ok) return { state: "unreachable", checkedAt, message: `Le Référentiel IA est indisponible (HTTP ${response.status}).` }; + const payload = await response.json() as RecommendationResponse; + if (payload.application?.slug !== input.applicationSlug) return { state: "authentication_error", checkedAt, message: "La décision Référentiel ne correspond pas à cette application." }; + const selected = payload.decision?.selected; + if (!selected?.engine?.name || !selected.engine.provider || !selected.engine.slug) return { state: "unconfigured", checkedAt, message: "Le Référentiel IA n’a retourné aucun moteur sélectionné." }; const origin = selected.origin === "manual" || selected.origin === "local" ? selected.origin : "proposal"; - return { - state: "available", - checkedAt: now(), - message: selected.connection?.configured - ? origin === "local" - ? "Moteur IA local sélectionné par le Référentiel IA." - : origin === "manual" - ? "Moteur choisi par un administrateur pour cette application." - : "Proposition du Référentiel IA appliquée à cette application." - : "Moteur décidé par le Référentiel IA, en attente de configuration fournisseur.", - engine: { - name: selected.engine.name, - provider: selected.engine.provider, - slug: selected.engine.slug, - model: selected.connection?.model, - accessMode: selected.connection?.accessMode, - configured: Boolean(selected.connection?.configured), - origin, - rationale: selected.rationale, - }, - proposal: proposal?.engine?.name && proposal.engine.provider ? { - name: proposal.engine.name, - provider: proposal.engine.provider, - model: proposal.connection?.model, - origin: "proposal", - rationale: proposal.rationale, - } : undefined, - usage: payload.usage, - }; - } catch { - return { - state: "unreachable", - checkedAt: now(), - message: "La vérification auprès du Référentiel IA a échoué.", - }; - } finally { - clearTimeout(timeout); - } + const status = operationalStatus(selected); + return { state: "available", checkedAt, message: status === "ready" ? "Connexion établie et moteur prêt." : "Connexion établie ; le moteur sélectionné nécessite une action dans le Référentiel.", engine: { name: selected.engine.name, provider: selected.engine.provider, slug: selected.engine.slug, model: selected.connection?.model, configured: Boolean(selected.connection?.configured), origin, operationalStatus: status, rationale: selected.rationale } }; + } catch { return { state: "unreachable", checkedAt, message: "La vérification auprès du Référentiel IA a échoué." }; } } -/** - * Métadonnées strictement descriptives de la décision active. Cette fonction - * ne retourne jamais le jeton applicatif ni une URL d'intégration privée. - */ -export async function getActiveAiConfiguration( - configuration: ReferenceConfiguration = { - url: ENV.aiReferentielUrl, - token: ENV.aiReferentielToken, - }, - fetcher: typeof fetch = fetch, -) { - const status = await getAiEngineStatus(configuration, fetcher); - if (status.state !== "available" || !status.engine?.configured || !status.engine.model) { - return { active: false, engines: [] }; - } - - return { - active: true, - engines: [{ - provider: status.engine.provider, - model: status.engine.model, - role: "Moteur sélectionné par le Référentiel IA", - billing: status.engine.origin === "local" ? "free" : "paid", - source: "referential", - }], - }; +/** Conserve seulement les métadonnées affichables ; aucun secret ne quitte le backend. */ +export async function getActiveAiConfiguration() { + const status = await getAiEngineStatus(); + if (status.state !== "available" || !status.engine?.configured || !status.engine.model) return { active: false, engines: [] }; + return { active: true, engines: [{ provider: status.engine.provider, model: status.engine.model, role: "Moteur sélectionné par le Référentiel IA", billing: status.engine.origin === "local" ? "free" : "paid", source: "referential" }] }; } diff --git a/server/routers.ts b/server/routers.ts index 1ac38d6..856703e 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -43,6 +43,7 @@ import { scheduleDailyImport } from "./_core/index"; import { loginLocalUser, hashPassword, ensureAdminExists } from "./localAuth"; import { isAzureAdConfigured, getAzureAuthUrl } from "./azureAuth"; import { classifyAap, classifyArticle } from "./aiClassifier"; +import { getAiEngineStatus } from "./ai-reference"; import { veilleItems, aapItems, processedDedupKeys } from "../drizzle/schema"; import { isNull, or, eq as eqDrizzle } from "drizzle-orm"; @@ -59,6 +60,10 @@ const adminProcedure = protectedProcedure.use(({ ctx, next }) => { export const appRouter = router({ system: systemRouter, + aiReference: router({ + status: adminProcedure.query(() => getAiEngineStatus()), + test: adminProcedure.mutation(() => getAiEngineStatus()), + }), // ─── Auth ─────────────────────────────────────────────────────────────────── auth: router({