feat: finaliser l’intégration au Référentiel IA
This commit is contained in:
@@ -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<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.",
|
||||
};
|
||||
}
|
||||
function operationalStatus(selected: DecisionEngine): NonNullable<AiEngineStatus["engine"]>["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<AiEngineStatus> {
|
||||
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" }] };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user