feat: finaliser l’intégration au Référentiel IA
This commit is contained in:
50
client/src/components/ReferentielIaCard.tsx
Normal file
50
client/src/components/ReferentielIaCard.tsx
Normal file
@@ -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 <Card className="border-primary/20 bg-gradient-to-br from-primary/[0.03] to-transparent">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary"><Link2 size={19} /></div>
|
||||
<div><CardTitle className="text-base">Intégration Référentiel IA</CardTitle><CardDescription className="mt-1">Le moteur est décidé et appelé exclusivement par la passerelle Santinova.</CardDescription></div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="gap-2 self-start" onClick={() => testMutation.mutate()} disabled={testMutation.isPending}>
|
||||
{testMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <Activity size={14} />} Tester la connexion au Référentiel
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{statusQuery.isLoading ? <div className="flex items-center gap-2 rounded-lg bg-muted/50 px-3 py-3 text-sm text-muted-foreground"><Loader2 size={15} className="animate-spin" /> Vérification de la configuration serveur…</div> : statusQuery.error ? <div className="flex items-start gap-2 rounded-lg border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-800"><CircleAlert size={16} className="mt-0.5 shrink-0" /> Impossible de vérifier le Référentiel IA : {statusQuery.error.message}</div> : <>
|
||||
<div className="flex flex-wrap gap-2"><Badge className={connected ? "bg-emerald-100 text-emerald-800" : "bg-amber-100 text-amber-800"}>{connected ? <CheckCircle2 size={13} className="mr-1" /> : <CircleAlert size={13} className="mr-1" />}{status ? STATE_LABELS[status.state] : "Indisponible"}</Badge><Badge variant="outline">Moteur : {status?.engine?.configured ? "Prêt" : "Non configuré"}</Badge></div>
|
||||
<div className="grid gap-3 text-sm sm:grid-cols-2"><div className="rounded-lg border bg-background/70 p-3"><p className="text-xs text-muted-foreground">Moteur choisi</p><p className="mt-1 font-medium">{status?.engine?.name ?? "Non disponible"}</p><p className="mt-0.5 text-xs text-muted-foreground">{status?.engine ? `${status.engine.provider} · ${status.engine.model ?? "Modèle non disponible"}` : "Aucune décision disponible"}</p></div><div className="rounded-lg border bg-background/70 p-3"><p className="text-xs text-muted-foreground">Origine de la décision</p><p className="mt-1 font-medium">{status?.engine ? ORIGIN_LABELS[status.engine.origin] : "Non disponible"}</p><p className="mt-0.5 text-xs text-muted-foreground">Dernier contrôle : {displayDate(status?.checkedAt)}</p></div></div>
|
||||
<div className="flex items-start gap-2 rounded-lg border border-primary/15 bg-primary/5 px-3 py-2.5 text-xs text-muted-foreground"><ShieldCheck size={15} className="mt-0.5 shrink-0 text-primary" /><span>{status?.message ?? "Le statut Référentiel IA sera disponible après vérification."}</span></div>
|
||||
</>}
|
||||
</CardContent>
|
||||
</Card>;
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
<Tabs defaultValue="source">
|
||||
<TabsList className="bg-muted/50">
|
||||
<TabsTrigger value="ai">Référentiel IA</TabsTrigger>
|
||||
<TabsTrigger value="source">Source des fichiers</TabsTrigger>
|
||||
<TabsTrigger value="import">Import & Planification</TabsTrigger>
|
||||
<TabsTrigger value="auth">Authentification</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="ai" className="space-y-4 mt-4">
|
||||
<ReferentielIaCard />
|
||||
</TabsContent>
|
||||
|
||||
{/* ─── Source des fichiers ─────────────────────────────────────── */}
|
||||
<TabsContent value="source" className="space-y-4 mt-4">
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string> {
|
||||
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<T>(payload: Record<string, unknown>, fetcher: typeof fetch = fetch): Promise<T> {
|
||||
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<T>(payload: Record<string, 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}`);
|
||||
throw new Error("Référentiel IA : le moteur a renvoyé une erreur métier.");
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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 ?? "",
|
||||
aiReferentielUrl: process.env.REFERENTIEL_IA_BASE_URL ?? "",
|
||||
aiReferentielToken: process.env.REFERENTIEL_IA_TOKEN ?? "",
|
||||
aiReferentielAppSlug: process.env.REFERENTIEL_IA_APP_SLUG ?? "",
|
||||
};
|
||||
|
||||
26
server/ai-reference.test.ts
Normal file
26
server/ai-reference.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getAiEngineStatus } from "./ai-reference";
|
||||
|
||||
const config = { url: "https://referentiel-ia.recette.santinova-soft.org", token: "jeton-test", applicationSlug: "veille-reglementaire" };
|
||||
const decision = { application: { slug: "veille-reglementaire" }, decision: { selected: { origin: "manual", engine: { name: "Manus Forge", provider: "Manus", slug: "manus-forge" }, connection: { configured: true, model: "gemini-2.5-flash", testStatus: "success" } } } };
|
||||
|
||||
describe("statut Référentiel IA", () => {
|
||||
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" });
|
||||
});
|
||||
});
|
||||
@@ -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" }] };
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user