feat: route invoice analysis through referentiel ia
This commit is contained in:
@@ -677,7 +677,7 @@ export default function Settings() {
|
|||||||
const [sftpAutoExport, setSftpAutoExport] = useState(false);
|
const [sftpAutoExport, setSftpAutoExport] = useState(false);
|
||||||
const [sftpRecipientFilter, setSftpRecipientFilter] = useState("");
|
const [sftpRecipientFilter, setSftpRecipientFilter] = useState("");
|
||||||
const [llmLogsRetentionMonths, setLlmLogsRetentionMonths] = useState(3);
|
const [llmLogsRetentionMonths, setLlmLogsRetentionMonths] = useState(3);
|
||||||
const [aiProvider, setAiProvider] = useState<"mistral" | "manus" | "gemini">("mistral");
|
const [aiProvider, setAiProvider] = useState<"mistral" | "manus" | "gemini" | "referentiel">("mistral");
|
||||||
const [mistralApiKey, setMistralApiKey] = useState("");
|
const [mistralApiKey, setMistralApiKey] = useState("");
|
||||||
const [manusForgeApiKey, setManusForgeApiKey] = useState("");
|
const [manusForgeApiKey, setManusForgeApiKey] = useState("");
|
||||||
const [manusForgeApiUrl, setManusForgeApiUrl] = useState("");
|
const [manusForgeApiUrl, setManusForgeApiUrl] = useState("");
|
||||||
@@ -722,6 +722,17 @@ export default function Settings() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const testReferentielIaMutation = trpc.settings.testReferentielIa.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
const engine = data.engineName ? ` Moteur sélectionné : ${data.engineName}.` : "";
|
||||||
|
const model = data.model ? ` Modèle : ${data.model}.` : "";
|
||||||
|
toast.success(`Connexion au Référentiel IA réussie.${engine}${model}`);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message || "Connexion au Référentiel IA impossible");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const testSftpMutation = trpc.sftp.testConnection.useMutation({
|
const testSftpMutation = trpc.sftp.testConnection.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
@@ -857,7 +868,7 @@ export default function Settings() {
|
|||||||
<CardContent className="space-y-6 pt-6">
|
<CardContent className="space-y-6 pt-6">
|
||||||
|
|
||||||
{/* Provider selector */}
|
{/* Provider selector */}
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setAiProvider("mistral")}
|
onClick={() => setAiProvider("mistral")}
|
||||||
@@ -939,6 +950,32 @@ export default function Settings() {
|
|||||||
<Badge className="bg-blue-500 text-white text-xs">Actif</Badge>
|
<Badge className="bg-blue-500 text-white text-xs">Actif</Badge>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAiProvider("referentiel")}
|
||||||
|
className={`relative flex flex-col items-center gap-3 p-5 rounded-xl border-2 transition-all cursor-pointer ${
|
||||||
|
aiProvider === "referentiel"
|
||||||
|
? "border-violet-500 bg-violet-50 dark:bg-violet-950/20 shadow-md"
|
||||||
|
: "border-muted hover:border-violet-300 bg-background"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{aiProvider === "referentiel" && (
|
||||||
|
<div className="absolute top-2 right-2">
|
||||||
|
<CheckCircle className="w-5 h-5 text-violet-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="p-3 bg-violet-100 dark:bg-violet-900/30 rounded-xl">
|
||||||
|
<Brain className="w-8 h-8 text-violet-600" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="font-bold text-base">Référentiel IA</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Choix centralisé<br/>Moteur et modèle décidés par le référentiel</p>
|
||||||
|
</div>
|
||||||
|
{aiProvider === "referentiel" && (
|
||||||
|
<Badge className="bg-violet-500 text-white text-xs">Actif</Badge>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mistral config */}
|
{/* Mistral config */}
|
||||||
@@ -1055,6 +1092,32 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{aiProvider === "referentiel" && (
|
||||||
|
<div className="space-y-4 p-4 bg-violet-50 dark:bg-violet-950/10 rounded-xl border border-violet-200 dark:border-violet-800">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-violet-700 dark:text-violet-400 uppercase tracking-wide">Configuration centralisée</p>
|
||||||
|
<p className="text-sm text-violet-900 dark:text-violet-100 mt-2">
|
||||||
|
L’analyse est envoyée à la passerelle du Référentiel IA. Le moteur, le modèle, les droits et les limites d’usage sont ceux sélectionnés pour <strong>Démat. Facturation DSI</strong> dans le Référentiel.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-violet-700 dark:text-violet-300 mt-2">
|
||||||
|
Aucune clé du Référentiel n’est affichée ni enregistrée dans ces paramètres.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="border-violet-300 text-violet-700 hover:bg-violet-100 shrink-0"
|
||||||
|
onClick={() => testReferentielIaMutation.mutate()}
|
||||||
|
disabled={testReferentielIaMutation.isPending}
|
||||||
|
>
|
||||||
|
{testReferentielIaMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <CheckCircle className="w-4 h-4 mr-2" />}
|
||||||
|
Vérifier la connexion
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -1073,6 +1136,12 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6 pt-6">
|
<CardContent className="space-y-6 pt-6">
|
||||||
|
{aiProvider === "referentiel" ? (
|
||||||
|
<div className="space-y-2 p-4 rounded-lg bg-violet-50 dark:bg-violet-950/10 border border-violet-200 dark:border-violet-800">
|
||||||
|
<p className="font-medium text-violet-900 dark:text-violet-100">Modèle décidé par le Référentiel IA</p>
|
||||||
|
<p className="text-sm text-violet-700 dark:text-violet-300">La surcharge locale est volontairement désactivée pour garantir un moteur unique, piloté et tracé au niveau Santinova.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label htmlFor="llmModel" className="text-base font-semibold">Modèle IA (override)</Label>
|
<Label htmlFor="llmModel" className="text-base font-semibold">Modèle IA (override)</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -1084,9 +1153,10 @@ export default function Settings() {
|
|||||||
/>
|
/>
|
||||||
<p className="text-sm text-muted-foreground flex items-center gap-2">
|
<p className="text-sm text-muted-foreground flex items-center gap-2">
|
||||||
<AlertCircle className="w-4 h-4" />
|
<AlertCircle className="w-4 h-4" />
|
||||||
Nom du modèle Mistral à utiliser pour l'extraction
|
Nom du modèle à utiliser pour l'extraction directe
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label htmlFor="llmLogsRetentionMonths" className="text-base font-semibold">
|
<Label htmlFor="llmLogsRetentionMonths" className="text-base font-semibold">
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ services:
|
|||||||
DATABASE_URL: mysql://root:demat_facturation_db_pass_2026@demat-facturation-db:3306/gestion_facturation
|
DATABASE_URL: mysql://root:demat_facturation_db_pass_2026@demat-facturation-db:3306/gestion_facturation
|
||||||
JWT_SECRET: demat-facturation-jwt-secret-recette-2026
|
JWT_SECRET: demat-facturation-jwt-secret-recette-2026
|
||||||
MISTRAL_API_KEY: ${MISTRAL_API_KEY:-}
|
MISTRAL_API_KEY: ${MISTRAL_API_KEY:-}
|
||||||
|
# Passerelle centralisée : le jeton reste dans l'environnement serveur.
|
||||||
|
REFERENTIEL_IA_URL: ${REFERENTIEL_IA_URL:-}
|
||||||
|
REFERENTIEL_IA_TOKEN: ${REFERENTIEL_IA_TOKEN:-}
|
||||||
UPLOAD_DIR: /app/uploads
|
UPLOAD_DIR: /app/uploads
|
||||||
# Chemin unique utilisé par le serveur et vérifié au démarrage.
|
# Chemin unique utilisé par le serveur et vérifié au démarrage.
|
||||||
STORAGE_BASE_PATH: /app/storage
|
STORAGE_BASE_PATH: /app/storage
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
CREATE TABLE IF NOT EXISTS `webImportSources` (
|
CREATE TABLE `webImportSources` (
|
||||||
`id` int AUTO_INCREMENT NOT NULL,
|
`id` int AUTO_INCREMENT NOT NULL,
|
||||||
`userId` int NOT NULL,
|
`userId` int NOT NULL,
|
||||||
`name` varchar(100) NOT NULL,
|
`name` varchar(100) NOT NULL,
|
||||||
|
|||||||
2
drizzle/0041_acoustic_microchip.sql
Normal file
2
drizzle/0041_acoustic_microchip.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `importSettings` MODIFY COLUMN `aiProvider` enum('mistral','manus','gemini','referentiel') NOT NULL DEFAULT 'mistral';--> statement-breakpoint
|
||||||
|
ALTER TABLE `userSettings` MODIFY COLUMN `aiProvider` enum('mistral','manus','gemini','referentiel') NOT NULL DEFAULT 'mistral';
|
||||||
2517
drizzle/meta/0041_snapshot.json
Normal file
2517
drizzle/meta/0041_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -288,6 +288,13 @@
|
|||||||
"when": 1788942350737,
|
"when": 1788942350737,
|
||||||
"tag": "0040_sudden_flatman",
|
"tag": "0040_sudden_flatman",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 41,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1790785209695,
|
||||||
|
"tag": "0041_acoustic_microchip",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -153,7 +153,7 @@ export const userSettings = mysqlTable("userSettings", {
|
|||||||
// Seuil de confiance pour les apprentissages IA
|
// Seuil de confiance pour les apprentissages IA
|
||||||
learningConfidenceThreshold: int("learningConfidenceThreshold").default(2).notNull(), // Nombre minimum d'applications pour marquer un apprentissage comme Confirmé
|
learningConfidenceThreshold: int("learningConfidenceThreshold").default(2).notNull(), // Nombre minimum d'applications pour marquer un apprentissage comme Confirmé
|
||||||
// AI Engine configuration
|
// AI Engine configuration
|
||||||
aiProvider: mysqlEnum("aiProvider", ["mistral", "manus", "gemini"]).default("mistral").notNull(), // AI provider: 'mistral', 'manus' or 'gemini'
|
aiProvider: mysqlEnum("aiProvider", ["mistral", "manus", "gemini", "referentiel"]).default("mistral").notNull(), // Moteur direct ou sélection centralisée par le Référentiel IA
|
||||||
mistralApiKey: text("mistralApiKey"), // Mistral API key (overrides env MISTRAL_API_KEY)
|
mistralApiKey: text("mistralApiKey"), // Mistral API key (overrides env MISTRAL_API_KEY)
|
||||||
manusForgeApiKey: text("manusForgeApiKey"), // Manus Forge API key (overrides env BUILT_IN_FORGE_API_KEY)
|
manusForgeApiKey: text("manusForgeApiKey"), // Manus Forge API key (overrides env BUILT_IN_FORGE_API_KEY)
|
||||||
manusForgeApiUrl: text("manusForgeApiUrl"), // Manus Forge API URL (overrides env BUILT_IN_FORGE_API_URL)
|
manusForgeApiUrl: text("manusForgeApiUrl"), // Manus Forge API URL (overrides env BUILT_IN_FORGE_API_URL)
|
||||||
@@ -201,7 +201,7 @@ export const importSettings = mysqlTable("importSettings", {
|
|||||||
azureSecretExpiresAt: timestamp("azureSecretExpiresAt"), // Azure AD Client Secret expiration date
|
azureSecretExpiresAt: timestamp("azureSecretExpiresAt"), // Azure AD Client Secret expiration date
|
||||||
|
|
||||||
// AI Engine settings
|
// AI Engine settings
|
||||||
aiProvider: mysqlEnum("aiProvider", ["mistral", "manus", "gemini"]).default("mistral").notNull(), // AI provider for invoice extraction
|
aiProvider: mysqlEnum("aiProvider", ["mistral", "manus", "gemini", "referentiel"]).default("mistral").notNull(), // Moteur direct ou sélection centralisée par le Référentiel IA
|
||||||
mistralApiKey: text("mistralApiKey"), // Mistral API key (overrides env MISTRAL_API_KEY)
|
mistralApiKey: text("mistralApiKey"), // Mistral API key (overrides env MISTRAL_API_KEY)
|
||||||
manusForgeApiKey: text("manusForgeApiKey"), // Manus Forge API key (overrides env BUILT_IN_FORGE_API_KEY)
|
manusForgeApiKey: text("manusForgeApiKey"), // Manus Forge API key (overrides env BUILT_IN_FORGE_API_KEY)
|
||||||
manusForgeApiUrl: text("manusForgeApiUrl"), // Manus Forge API URL (overrides env BUILT_IN_FORGE_API_URL)
|
manusForgeApiUrl: text("manusForgeApiUrl"), // Manus Forge API URL (overrides env BUILT_IN_FORGE_API_URL)
|
||||||
|
|||||||
@@ -9,4 +9,6 @@ export const ENV = {
|
|||||||
forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "",
|
forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "",
|
||||||
mistralApiKey: process.env.MISTRAL_API_KEY ?? "",
|
mistralApiKey: process.env.MISTRAL_API_KEY ?? "",
|
||||||
geminiApiKey: process.env.GEMINI_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 { ENV } from "./env";
|
||||||
|
import { invokeReferentialIa } from "../referentialIa";
|
||||||
|
|
||||||
export type Role = "system" | "user" | "assistant" | "tool" | "function";
|
export type Role = "system" | "user" | "assistant" | "tool" | "function";
|
||||||
|
|
||||||
@@ -97,8 +98,8 @@ export type InvokeResult = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LlmProvider = "mistral" | "manus" | "gemini";
|
export type LlmProvider = "mistral" | "manus" | "gemini" | "referentiel";
|
||||||
export type LlmTransport = "mistral-api" | "manus-forge" | "gemini-api";
|
export type LlmTransport = "mistral-api" | "manus-forge" | "gemini-api" | "referentiel-gateway";
|
||||||
|
|
||||||
export type UserLlmSettings = {
|
export type UserLlmSettings = {
|
||||||
aiProvider?: string | null;
|
aiProvider?: string | null;
|
||||||
@@ -344,6 +345,9 @@ export function resolveLlmTrace(settings?: UserLlmSettings): ResolvedLlmTrace {
|
|||||||
if (provider === "gemini") {
|
if (provider === "gemini") {
|
||||||
return { provider: "gemini", model: "gemini-2.0-flash", transport: "gemini-api" };
|
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" };
|
return { provider: "mistral", model: "mistral-large-latest", transport: "mistral-api" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,14 +359,17 @@ export async function invokeLLMWithUserSettings(
|
|||||||
params: InvokeParams,
|
params: InvokeParams,
|
||||||
userSettings?: UserLlmSettings
|
userSettings?: UserLlmSettings
|
||||||
): Promise<ResolvedLlmInvocation> {
|
): Promise<ResolvedLlmInvocation> {
|
||||||
|
const trace = resolveLlmTrace(userSettings);
|
||||||
|
if (trace.provider === "referentiel") {
|
||||||
|
return { response: await invokeReferentialIa(params), trace };
|
||||||
|
}
|
||||||
|
|
||||||
const apiKey = getApiKeyWithSettings(userSettings);
|
const apiKey = getApiKeyWithSettings(userSettings);
|
||||||
if (!apiKey || apiKey.trim().length === 0) {
|
if (!apiKey || apiKey.trim().length === 0) {
|
||||||
throw new Error("Aucune clé API configurée pour le moteur IA sélectionné");
|
throw new Error("Aucune clé API configurée pour le moteur IA sélectionné");
|
||||||
}
|
}
|
||||||
|
|
||||||
const trace = resolveLlmTrace(userSettings);
|
|
||||||
const provider = trace.provider;
|
const provider = trace.provider;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
tools,
|
tools,
|
||||||
|
|||||||
@@ -35,4 +35,11 @@ describe("routage et persistance de déploiement", () => {
|
|||||||
expect(guardScript).toContain('REQUIRE_STORAGE_VOLUME:-false');
|
expect(guardScript).toContain('REQUIRE_STORAGE_VOLUME:-false');
|
||||||
expect(guardScript).toContain('mkdir -p "$storage_path"');
|
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 { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
|
||||||
import { calculateFileSha256 } from "./fileFingerprint";
|
import { calculateFileSha256 } from "./fileFingerprint";
|
||||||
import { localStoragePut, generateStorageKey } from "./localStorage";
|
import { localStoragePut, generateStorageKey } from "./localStorage";
|
||||||
|
import { testReferentialIaConnection } from "./referentialIa";
|
||||||
import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport";
|
import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport";
|
||||||
import { drawBapCartouche } from "./bapCartouche";
|
import { drawBapCartouche } from "./bapCartouche";
|
||||||
import { resolveBapExportDestination } from "./exportDestinationResolver";
|
import { resolveBapExportDestination } from "./exportDestinationResolver";
|
||||||
@@ -1237,7 +1238,7 @@ export const appRouter = router({
|
|||||||
sftpAutoExport: z.number().optional(),
|
sftpAutoExport: z.number().optional(),
|
||||||
llmLogsRetentionMonths: z.number().optional(),
|
llmLogsRetentionMonths: z.number().optional(),
|
||||||
learningConfidenceThreshold: z.number().min(1).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(),
|
mistralApiKey: z.string().optional(),
|
||||||
manusForgeApiKey: z.string().optional(),
|
manusForgeApiKey: z.string().optional(),
|
||||||
manusForgeApiUrl: z.string().optional(),
|
manusForgeApiUrl: z.string().optional(),
|
||||||
@@ -1250,6 +1251,12 @@ export const appRouter = router({
|
|||||||
});
|
});
|
||||||
return { success: true };
|
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 =============
|
// ============= ADMIN ROUTES =============
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ const migration = readFileSync(
|
|||||||
);
|
);
|
||||||
|
|
||||||
describe("webImportSources migration", () => {
|
describe("webImportSources migration", () => {
|
||||||
it("peut être appliquée sans échec si la table a déjà été créée", () => {
|
it("conserve l'instruction historique enregistrée", () => {
|
||||||
expect(migration).toMatch(/CREATE TABLE IF NOT EXISTS `webImportSources`/);
|
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", () => {
|
it("définit les colonnes nécessaires au connecteur sans modifier les factures", () => {
|
||||||
|
|||||||
37
todo.md
37
todo.md
@@ -1,5 +1,42 @@
|
|||||||
# Invoice Analyzer - TODO
|
# Invoice Analyzer - TODO
|
||||||
|
|
||||||
|
## Choix du moteur IA — Paramètres — 30/09/2026
|
||||||
|
- [x] Analyser la configuration IA locale et le contrat du Référentiel IA
|
||||||
|
- [x] Ajouter le choix entre moteur local et moteur sélectionné dans le Référentiel IA
|
||||||
|
- [x] Valider la migration, les tests, TypeScript et le build en sandbox
|
||||||
|
- [ ] Créer un jeton applicatif limité pour Démat. Facturation DSI et configurer la passerelle Référentiel IA
|
||||||
|
- [ ] Vérifier une extraction réelle via la passerelle et sa traçabilité dans le Référentiel IA
|
||||||
|
|
||||||
|
## Import manuel des connecteurs web — 30/09/2026
|
||||||
|
- [x] Analyser le flux serveur d’import web et définir le déclenchement manuel sécurisé
|
||||||
|
- [x] Ajouter l’API de déclenchement, le suivi de résultat et le bouton avec confirmation
|
||||||
|
- [ ] Diagnostiquer l’échec réel du connecteur SFR en recette, puis déployer uniquement après validation
|
||||||
|
|
||||||
|
## Incident création connecteur web — nouvelle tentative production — 30/09/2026
|
||||||
|
- [x] Reproduire l’insertion avec le même profil utilisateur sans exposer d’identifiant
|
||||||
|
- [ ] Journaliser un code technique non sensible en cas de nouvel échec SQL
|
||||||
|
- [ ] Valider et déployer l’instrumentation sans paramètres sensibles
|
||||||
|
|
||||||
|
## Correction création connecteur web en production — 30/09/2026
|
||||||
|
- [x] Diagnostiquer l’échec SQL d’insertion sans exposer les identifiants
|
||||||
|
- [x] Corriger le schéma ou la persistance du connecteur web
|
||||||
|
- [x] Valider de bout en bout la création tRPC, avec connecteur fictif supprimé après le test
|
||||||
|
|
||||||
|
## Correction validation connecteur web — 30/09/2026
|
||||||
|
- [x] Reproduire l’erreur `portalUrl` invalide et identifier l’écart formulaire/API
|
||||||
|
- [x] Corriger la normalisation et la validation de l’URL du portail
|
||||||
|
- [x] Valider les tests, puis déployer en recette et production sans toucher à MySQL ni aux volumes
|
||||||
|
|
||||||
|
## Diagnostic réception e-mail automatique — 24/09/2026
|
||||||
|
- [x] Reproduire le démarrage de la réception automatique et identifier le blocage interface → API → planificateur
|
||||||
|
- [x] Corriger la cause sans lancer d’import ni créer de planification non sollicitée, avec sélection explicite du compte source pour les administrateurs
|
||||||
|
- [x] Valider les contrôles, les tests et le comportement de démarrage/arrêt
|
||||||
|
|
||||||
|
## Diagnostic démarrage OAuth2 après test IMAP concluant — 24/09/2026
|
||||||
|
- [x] Reproduire le démarrage OAuth2 sans importer de facture et tracer le résultat API : la réception automatique est désactivée en production pour le compte configuré
|
||||||
|
- [ ] Corriger la cause du message générique de démarrage
|
||||||
|
- [ ] Vérifier le démarrage et l’arrêt contrôlés avec les tests de non-régression
|
||||||
|
|
||||||
## Phase 1: Base de données et schéma
|
## Phase 1: Base de données et schéma
|
||||||
- [x] Créer table users avec support authentification locale + Azure AD
|
- [x] Créer table users avec support authentification locale + Azure AD
|
||||||
- [x] Créer table sourceFiles pour fichiers PDF uploadés
|
- [x] Créer table sourceFiles pour fichiers PDF uploadés
|
||||||
|
|||||||
Reference in New Issue
Block a user