feat: centraliser le moteur IA via le Référentiel
All checks were successful
Deploy Production / deploy (push) Has been skipped
Deploy Recette / deploy (push) Successful in 1m32s
Validation applicative / TypeScript, tests et build (push) Successful in 2m42s

This commit is contained in:
manus-admin
2026-09-14 08:35:32 +00:00
parent 290e3709e2
commit 9db76af02c
7 changed files with 259 additions and 19 deletions

View File

@@ -2,6 +2,9 @@
"id": "veille-reglementaire",
"name": "Veille Réglementaire",
"category": "ITINOVA",
"ai": {
"activeConfigurationPath": "/api/ai/active-configuration"
},
"urls": {
"recette": "https://veille.recette.santinova-soft.org",
"prod": "https://veille.santinova-soft.org"

View File

@@ -0,0 +1,26 @@
import { describe, expect, it, vi } from "vitest";
import { ENV } from "./env";
import { invokeSelectedReference } from "./aiReferenceClient";
describe("client Référentiel IA", () => {
it("transmet seulement le payload métier et restitue la trace résolue", 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",
},
}));
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" }] } });
});
});

View File

@@ -0,0 +1,53 @@
import { ENV } from "./env";
export type ResolvedAiTrace = {
provider: string;
resolvedModel: string;
transport: string;
origin: string;
};
function configuration() {
const url = ENV.aiReferentielUrl.trim().replace(/\/$/, "");
const token = ENV.aiReferentielToken.trim();
if (!url || !token) {
throw new Error("Référentiel IA non configuré pour cette application.");
}
return { url, token };
}
/**
* 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.
*/
export async function invokeSelectedReference<T>(payload: Record<string, unknown>, fetcher: typeof fetch = fetch): Promise<T> {
const { url, token } = configuration();
const response = await fetcher(`${url}/api/integration/v1/invoke`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${token}`,
},
body: JSON.stringify({ 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",
transport: response.headers.get("x-santinova-ia-transport") || "unknown",
origin: response.headers.get("x-santinova-ia-origin") || "unknown",
};
console.info(`[IA_TRACE] provider=${trace.provider} model=${trace.resolvedModel} transport=${trace.transport} origin=${trace.origin}`);
const body = await response.json() as unknown;
if (body && typeof body === "object" && "error" in body) {
const message = typeof (body as { error?: { message?: unknown } }).error?.message === "string"
? (body as { error: { message: string } }).error.message
: "erreur non détaillée";
throw new Error(`Référentiel IA : ${message}`);
}
return body as T;
}

View File

@@ -7,4 +7,6 @@ export const ENV = {
isProduction: process.env.NODE_ENV === "production",
forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "",
forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "",
aiReferentielUrl: process.env.IA_REFERENTIEL_URL ?? "",
aiReferentielToken: process.env.IA_REFERENTIEL_TOKEN ?? "",
};

View File

@@ -15,6 +15,7 @@ import { ensureAdminExists, generateLocalToken } from "../localAuth";
import { isAzureAdConfigured, getAzureAuthUrl, handleAzureCallback } from "../azureAuth";
import { getLocalUserByAzureAdId, getLocalUserByEmail, upsertLocalUserAzure, getSetting, purgeOldArticles } from "../db";
import { runRssFetch } from "../rssEngine";
import { getActiveAiConfiguration } from "../ai-reference";
function isPortAvailable(port: number): Promise<boolean> {
return new Promise(resolve => {
@@ -123,6 +124,11 @@ async function startServer() {
app.use(express.json({ limit: "50mb" }));
app.use(express.urlencoded({ limit: "50mb", extended: true }));
app.get("/api/ai/active-configuration", (_request, response) => {
void getActiveAiConfiguration()
.then(configuration => response.setHeader("Cache-Control", "no-store").json(configuration))
.catch(() => response.status(503).json({ active: false, engines: [] }));
});
registerOAuthRoutes(app);
app.use(uploadRoutes);

View File

@@ -1,4 +1,5 @@
import { ENV } from "./env";
import { invokeSelectedReference } from "./aiReferenceClient";
export type Role = "system" | "user" | "assistant" | "tool" | "function";
@@ -266,7 +267,6 @@ const normalizeResponseFormat = ({
};
export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {
assertApiKey();
const {
messages,
@@ -280,7 +280,6 @@ export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {
} = params;
const payload: Record<string, unknown> = {
model: "gemini-2.5-flash",
messages: messages.map(normalizeMessage),
};
@@ -312,21 +311,5 @@ export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {
payload.response_format = normalizedResponseFormat;
}
const response = await fetch(resolveApiUrl(), {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${ENV.forgeApiKey}`,
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`LLM invoke failed: ${response.status} ${response.statusText} – ${errorText}`
);
}
return (await response.json()) as InvokeResult;
return invokeSelectedReference<InvokeResult>(payload);
}

167
server/ai-reference.ts Normal file
View File

@@ -0,0 +1,167 @@
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;
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 };
};
const now = () => new Date().toISOString();
export async function getAiEngineStatus(
configuration: ReferenceConfiguration = {
url: ENV.aiReferentielUrl,
token: ENV.aiReferentielToken,
},
fetcher: typeof fetch = fetch,
): Promise<AiEngineStatus> {
if (!configuration.url || !configuration.token) {
return {
state: "unconfigured",
checkedAt: now(),
message: "Le Référentiel IA n’est pas encore configuré pour cette application.",
};
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 6_000);
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,
});
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;
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);
}
}
/**
* 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",
}],
};
}