feat: route invoice analysis through referentiel ia
This commit is contained in:
@@ -9,4 +9,6 @@ export const ENV = {
|
||||
forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "",
|
||||
mistralApiKey: process.env.MISTRAL_API_KEY ?? "",
|
||||
geminiApiKey: process.env.GEMINI_API_KEY ?? "",
|
||||
referentielIaUrl: process.env.REFERENTIEL_IA_URL ?? "",
|
||||
referentielIaToken: process.env.REFERENTIEL_IA_TOKEN ?? "",
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ENV } from "./env";
|
||||
import { invokeReferentialIa } from "../referentialIa";
|
||||
|
||||
export type Role = "system" | "user" | "assistant" | "tool" | "function";
|
||||
|
||||
@@ -97,8 +98,8 @@ export type InvokeResult = {
|
||||
};
|
||||
};
|
||||
|
||||
export type LlmProvider = "mistral" | "manus" | "gemini";
|
||||
export type LlmTransport = "mistral-api" | "manus-forge" | "gemini-api";
|
||||
export type LlmProvider = "mistral" | "manus" | "gemini" | "referentiel";
|
||||
export type LlmTransport = "mistral-api" | "manus-forge" | "gemini-api" | "referentiel-gateway";
|
||||
|
||||
export type UserLlmSettings = {
|
||||
aiProvider?: string | null;
|
||||
@@ -344,6 +345,9 @@ export function resolveLlmTrace(settings?: UserLlmSettings): ResolvedLlmTrace {
|
||||
if (provider === "gemini") {
|
||||
return { provider: "gemini", model: "gemini-2.0-flash", transport: "gemini-api" };
|
||||
}
|
||||
if (provider === "referentiel") {
|
||||
return { provider: "referentiel", model: "referentiel-ia", transport: "referentiel-gateway" };
|
||||
}
|
||||
return { provider: "mistral", model: "mistral-large-latest", transport: "mistral-api" };
|
||||
}
|
||||
|
||||
@@ -355,14 +359,17 @@ export async function invokeLLMWithUserSettings(
|
||||
params: InvokeParams,
|
||||
userSettings?: UserLlmSettings
|
||||
): Promise<ResolvedLlmInvocation> {
|
||||
const trace = resolveLlmTrace(userSettings);
|
||||
if (trace.provider === "referentiel") {
|
||||
return { response: await invokeReferentialIa(params), trace };
|
||||
}
|
||||
|
||||
const apiKey = getApiKeyWithSettings(userSettings);
|
||||
if (!apiKey || apiKey.trim().length === 0) {
|
||||
throw new Error("Aucune clé API configurée pour le moteur IA sélectionné");
|
||||
}
|
||||
|
||||
const trace = resolveLlmTrace(userSettings);
|
||||
const provider = trace.provider;
|
||||
|
||||
const {
|
||||
messages,
|
||||
tools,
|
||||
|
||||
@@ -35,4 +35,11 @@ describe("routage et persistance de déploiement", () => {
|
||||
expect(guardScript).toContain('REQUIRE_STORAGE_VOLUME:-false');
|
||||
expect(guardScript).toContain('mkdir -p "$storage_path"');
|
||||
});
|
||||
|
||||
it("injecte uniquement les secrets serveur de la passerelle Référentiel IA", () => {
|
||||
const composeFile = readFileSync(join(process.cwd(), "docker-compose.yml"), "utf8");
|
||||
|
||||
expect(composeFile).toContain("REFERENTIEL_IA_URL: ${REFERENTIEL_IA_URL:-}");
|
||||
expect(composeFile).toContain("REFERENTIEL_IA_TOKEN: ${REFERENTIEL_IA_TOKEN:-}");
|
||||
});
|
||||
});
|
||||
|
||||
14
server/drizzleMigrationHistory.test.ts
Normal file
14
server/drizzleMigrationHistory.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const migrationPath = resolve(process.cwd(), "drizzle/0036_broken_rattler.sql");
|
||||
const recordedHash = "3c636f379884a82b48aea91af65532affc0bdd8f6c56fdac35e583c31ce3a8e3";
|
||||
|
||||
describe("historique Drizzle", () => {
|
||||
it("conserve l'empreinte de la migration 0036 déjà enregistrée", () => {
|
||||
const hash = createHash("sha256").update(readFileSync(migrationPath)).digest("hex");
|
||||
expect(hash).toBe(recordedHash);
|
||||
});
|
||||
});
|
||||
79
server/referentialIa.test.ts
Normal file
79
server/referentialIa.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
invokeReferentialIa,
|
||||
testReferentialIaConnection,
|
||||
type ReferentielIaConnection,
|
||||
} from "./referentialIa";
|
||||
|
||||
const connection: ReferentielIaConnection = {
|
||||
baseUrl: "https://referentiel-ia.recette.santinova-soft.org",
|
||||
token: "test-token",
|
||||
};
|
||||
|
||||
function jsonResponse(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("Référentiel IA", () => {
|
||||
it("transmet une requête OpenAI-compatible à la passerelle sélectionnée", async () => {
|
||||
let receivedUrl = "";
|
||||
let receivedInit: RequestInit | undefined;
|
||||
const result = await invokeReferentialIa(
|
||||
{
|
||||
messages: [{ role: "user", content: "Extrais la facture" }],
|
||||
max_tokens: 1200,
|
||||
},
|
||||
connection,
|
||||
async (url, init) => {
|
||||
receivedUrl = String(url);
|
||||
receivedInit = init;
|
||||
return jsonResponse({
|
||||
id: "ref-1",
|
||||
created: 1,
|
||||
model: "selected-by-reference",
|
||||
choices: [{ index: 0, message: { role: "assistant", content: "{}" }, finish_reason: "stop" }],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
expect(receivedUrl).toBe("https://referentiel-ia.recette.santinova-soft.org/api/integration/v1/invoke");
|
||||
expect(receivedInit?.headers).toMatchObject({ authorization: "Bearer test-token" });
|
||||
expect(JSON.parse(String(receivedInit?.body))).toMatchObject({
|
||||
payload: {
|
||||
messages: [{ role: "user", content: "Extrais la facture" }],
|
||||
max_tokens: 1200,
|
||||
stream: false,
|
||||
},
|
||||
});
|
||||
expect(result.choices[0]?.message.content).toBe("{}");
|
||||
});
|
||||
|
||||
it("retourne un diagnostic non sensible lorsqu'aucun jeton n'est configuré", async () => {
|
||||
await expect(
|
||||
invokeReferentialIa({ messages: [{ role: "user", content: "test" }] }, { baseUrl: "", token: "" }),
|
||||
).rejects.toThrow("Référentiel IA");
|
||||
});
|
||||
|
||||
it("confirme le moteur choisi sans déclencher d'analyse de facture", async () => {
|
||||
const result = await testReferentialIaConnection(
|
||||
connection,
|
||||
async (_url, init) => {
|
||||
expect(JSON.parse(String(init?.body))).toEqual({
|
||||
useCase: "texte",
|
||||
optimizeCost: true,
|
||||
needsFreeTier: false,
|
||||
includeConnection: false,
|
||||
});
|
||||
return jsonResponse({
|
||||
application: { name: "Démat. Facturation DSI" },
|
||||
decision: { selected: { engine: { name: "Manus Forge", slug: "manus-forge" }, connection: undefined } },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({ applicationName: "Démat. Facturation DSI", engineName: "Manus Forge", model: null });
|
||||
});
|
||||
});
|
||||
128
server/referentialIa.ts
Normal file
128
server/referentialIa.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { InvokeParams, InvokeResult } from "./_core/llm";
|
||||
import { ENV } from "./_core/env";
|
||||
|
||||
export type ReferentielIaConnection = {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
type Fetcher = (input: string | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
const RECOMMENDATIONS_PATH = "/api/integration/v1/recommendations";
|
||||
const INVOCATION_PATH = "/api/integration/v1/invoke";
|
||||
|
||||
/**
|
||||
* Le jeton est exclusivement fourni au serveur via l'environnement. Il n'est
|
||||
* jamais exposé à l'interface ni stocké dans les paramètres de l'utilisateur.
|
||||
*/
|
||||
export function getReferentielIaConnection(): ReferentielIaConnection | null {
|
||||
const baseUrl = ENV.referentielIaUrl.trim().replace(/\/$/, "");
|
||||
const token = ENV.referentielIaToken.trim();
|
||||
if (!baseUrl || !token) return null;
|
||||
|
||||
try {
|
||||
const url = new URL(baseUrl);
|
||||
if (url.protocol !== "https:") return null;
|
||||
return { baseUrl: url.toString().replace(/\/$/, ""), token };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function requireConnection(connection?: ReferentielIaConnection | null): ReferentielIaConnection {
|
||||
if (!connection?.baseUrl.trim() || !connection.token.trim()) {
|
||||
throw new Error("Le Référentiel IA n'est pas configuré pour cette application.");
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
async function readJson(response: Response): Promise<unknown> {
|
||||
const raw = await response.text();
|
||||
try {
|
||||
return JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function toGatewayPayload(params: InvokeParams): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {
|
||||
messages: params.messages,
|
||||
max_tokens: params.maxTokens ?? params.max_tokens ?? 32768,
|
||||
stream: false,
|
||||
};
|
||||
if (params.tools?.length) payload.tools = params.tools;
|
||||
if (params.toolChoice ?? params.tool_choice) payload.tool_choice = params.toolChoice ?? params.tool_choice;
|
||||
if (params.outputSchema ?? params.output_schema) payload.response_format = {
|
||||
type: "json_schema",
|
||||
json_schema: params.outputSchema ?? params.output_schema,
|
||||
};
|
||||
if (params.responseFormat ?? params.response_format) payload.response_format = params.responseFormat ?? params.response_format;
|
||||
return payload;
|
||||
}
|
||||
|
||||
function gatewayError(status: number): Error {
|
||||
if (status === 401 || status === 403) return new Error("Le Référentiel IA refuse l'accès de cette application.");
|
||||
if (status === 422) return new Error("Le moteur choisi dans le Référentiel IA n'est pas opérationnel.");
|
||||
if (status === 429) return new Error("La limite d'usage définie dans le Référentiel IA est atteinte.");
|
||||
return new Error(`Le Référentiel IA est indisponible (HTTP ${status}).`);
|
||||
}
|
||||
|
||||
/** Exécute l'appel sur le moteur décidé et tracé par le Référentiel IA. */
|
||||
export async function invokeReferentialIa(
|
||||
params: InvokeParams,
|
||||
connection = getReferentielIaConnection(),
|
||||
fetcher: Fetcher = fetch,
|
||||
): Promise<InvokeResult> {
|
||||
const config = requireConnection(connection);
|
||||
const response = await fetcher(`${config.baseUrl}${INVOCATION_PATH}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${config.token}`,
|
||||
},
|
||||
body: JSON.stringify({ payload: toGatewayPayload(params) }),
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
});
|
||||
if (!response.ok) throw gatewayError(response.status);
|
||||
return (await readJson(response)) as InvokeResult;
|
||||
}
|
||||
|
||||
export type ReferentielIaConnectionTest = {
|
||||
applicationName: string | null;
|
||||
engineName: string | null;
|
||||
model: string | null;
|
||||
};
|
||||
|
||||
/** Vérifie l'autorisation et le moteur choisi, sans envoyer de facture au modèle. */
|
||||
export async function testReferentialIaConnection(
|
||||
connection = getReferentielIaConnection(),
|
||||
fetcher: Fetcher = fetch,
|
||||
): Promise<ReferentielIaConnectionTest> {
|
||||
const config = requireConnection(connection);
|
||||
const response = await fetcher(`${config.baseUrl}${RECOMMENDATIONS_PATH}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${config.token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
useCase: "texte",
|
||||
optimizeCost: true,
|
||||
needsFreeTier: false,
|
||||
includeConnection: false,
|
||||
}),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!response.ok) throw gatewayError(response.status);
|
||||
|
||||
const data = (await readJson(response)) as {
|
||||
application?: { name?: unknown };
|
||||
decision?: { selected?: { engine?: { name?: unknown }; connection?: { model?: unknown } } | null } | null;
|
||||
};
|
||||
return {
|
||||
applicationName: typeof data.application?.name === "string" ? data.application.name : null,
|
||||
engineName: typeof data.decision?.selected?.engine?.name === "string" ? data.decision.selected.engine.name : null,
|
||||
model: typeof data.decision?.selected?.connection?.model === "string" ? data.decision.selected.connection.model : null,
|
||||
};
|
||||
}
|
||||
@@ -103,6 +103,7 @@ import pathSync from "path";
|
||||
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
|
||||
import { calculateFileSha256 } from "./fileFingerprint";
|
||||
import { localStoragePut, generateStorageKey } from "./localStorage";
|
||||
import { testReferentialIaConnection } from "./referentialIa";
|
||||
import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport";
|
||||
import { drawBapCartouche } from "./bapCartouche";
|
||||
import { resolveBapExportDestination } from "./exportDestinationResolver";
|
||||
@@ -1237,7 +1238,7 @@ export const appRouter = router({
|
||||
sftpAutoExport: z.number().optional(),
|
||||
llmLogsRetentionMonths: z.number().optional(),
|
||||
learningConfidenceThreshold: z.number().min(1).optional(),
|
||||
aiProvider: z.enum(["mistral", "manus", "gemini"]).optional(),
|
||||
aiProvider: z.enum(["mistral", "manus", "gemini", "referentiel"]).optional(),
|
||||
mistralApiKey: z.string().optional(),
|
||||
manusForgeApiKey: z.string().optional(),
|
||||
manusForgeApiUrl: z.string().optional(),
|
||||
@@ -1250,6 +1251,12 @@ export const appRouter = router({
|
||||
});
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
/** Vérifie le choix centralisé sans transmettre de facture ni de clé au navigateur. */
|
||||
testReferentielIa: protectedProcedure.mutation(async () => {
|
||||
const result = await testReferentialIaConnection();
|
||||
return { success: true, ...result };
|
||||
}),
|
||||
}),
|
||||
|
||||
// ============= ADMIN ROUTES =============
|
||||
|
||||
@@ -8,8 +8,9 @@ const migration = readFileSync(
|
||||
);
|
||||
|
||||
describe("webImportSources migration", () => {
|
||||
it("peut être appliquée sans échec si la table a déjà été créée", () => {
|
||||
expect(migration).toMatch(/CREATE TABLE IF NOT EXISTS `webImportSources`/);
|
||||
it("conserve l'instruction historique enregistrée", () => {
|
||||
expect(migration).toMatch(/CREATE TABLE `webImportSources`/);
|
||||
expect(migration).not.toMatch(/IF NOT EXISTS/);
|
||||
});
|
||||
|
||||
it("définit les colonnes nécessaires au connecteur sans modifier les factures", () => {
|
||||
|
||||
Reference in New Issue
Block a user