feat: centraliser le moteur IA via le Référentiel
This commit is contained in:
3
app.json
3
app.json
@@ -2,6 +2,9 @@
|
|||||||
"id": "veille-reglementaire",
|
"id": "veille-reglementaire",
|
||||||
"name": "Veille Réglementaire",
|
"name": "Veille Réglementaire",
|
||||||
"category": "ITINOVA",
|
"category": "ITINOVA",
|
||||||
|
"ai": {
|
||||||
|
"activeConfigurationPath": "/api/ai/active-configuration"
|
||||||
|
},
|
||||||
"urls": {
|
"urls": {
|
||||||
"recette": "https://veille.recette.santinova-soft.org",
|
"recette": "https://veille.recette.santinova-soft.org",
|
||||||
"prod": "https://veille.santinova-soft.org"
|
"prod": "https://veille.santinova-soft.org"
|
||||||
|
|||||||
26
server/_core/aiReferenceClient.test.ts
Normal file
26
server/_core/aiReferenceClient.test.ts
Normal 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" }] } });
|
||||||
|
});
|
||||||
|
});
|
||||||
53
server/_core/aiReferenceClient.ts
Normal file
53
server/_core/aiReferenceClient.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -7,4 +7,6 @@ export const ENV = {
|
|||||||
isProduction: process.env.NODE_ENV === "production",
|
isProduction: process.env.NODE_ENV === "production",
|
||||||
forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "",
|
forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "",
|
||||||
forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "",
|
forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "",
|
||||||
|
aiReferentielUrl: process.env.IA_REFERENTIEL_URL ?? "",
|
||||||
|
aiReferentielToken: process.env.IA_REFERENTIEL_TOKEN ?? "",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { ensureAdminExists, generateLocalToken } from "../localAuth";
|
|||||||
import { isAzureAdConfigured, getAzureAuthUrl, handleAzureCallback } from "../azureAuth";
|
import { isAzureAdConfigured, getAzureAuthUrl, handleAzureCallback } from "../azureAuth";
|
||||||
import { getLocalUserByAzureAdId, getLocalUserByEmail, upsertLocalUserAzure, getSetting, purgeOldArticles } from "../db";
|
import { getLocalUserByAzureAdId, getLocalUserByEmail, upsertLocalUserAzure, getSetting, purgeOldArticles } from "../db";
|
||||||
import { runRssFetch } from "../rssEngine";
|
import { runRssFetch } from "../rssEngine";
|
||||||
|
import { getActiveAiConfiguration } from "../ai-reference";
|
||||||
|
|
||||||
function isPortAvailable(port: number): Promise<boolean> {
|
function isPortAvailable(port: number): Promise<boolean> {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
@@ -123,6 +124,11 @@ async function startServer() {
|
|||||||
|
|
||||||
app.use(express.json({ limit: "50mb" }));
|
app.use(express.json({ limit: "50mb" }));
|
||||||
app.use(express.urlencoded({ limit: "50mb", extended: true }));
|
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);
|
registerOAuthRoutes(app);
|
||||||
app.use(uploadRoutes);
|
app.use(uploadRoutes);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ENV } from "./env";
|
import { ENV } from "./env";
|
||||||
|
import { invokeSelectedReference } from "./aiReferenceClient";
|
||||||
|
|
||||||
export type Role = "system" | "user" | "assistant" | "tool" | "function";
|
export type Role = "system" | "user" | "assistant" | "tool" | "function";
|
||||||
|
|
||||||
@@ -266,7 +267,6 @@ const normalizeResponseFormat = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {
|
export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {
|
||||||
assertApiKey();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
@@ -280,7 +280,6 @@ export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {
|
|||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
model: "gemini-2.5-flash",
|
|
||||||
messages: messages.map(normalizeMessage),
|
messages: messages.map(normalizeMessage),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -312,21 +311,5 @@ export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {
|
|||||||
payload.response_format = normalizedResponseFormat;
|
payload.response_format = normalizedResponseFormat;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(resolveApiUrl(), {
|
return invokeSelectedReference<InvokeResult>(payload);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
167
server/ai-reference.ts
Normal file
167
server/ai-reference.ts
Normal 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",
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user