fix: tracer le moteur IA réellement invoqué
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 1m52s
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 1m52s
This commit is contained in:
3
drizzle/0040_sudden_flatman.sql
Normal file
3
drizzle/0040_sudden_flatman.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE `llmLogs` ADD `resolvedProvider` varchar(30);--> statement-breakpoint
|
||||
ALTER TABLE `llmLogs` ADD `resolvedModel` varchar(80);--> statement-breakpoint
|
||||
ALTER TABLE `llmLogs` ADD `transport` varchar(30);
|
||||
2517
drizzle/meta/0040_snapshot.json
Normal file
2517
drizzle/meta/0040_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -281,6 +281,13 @@
|
||||
"when": 1788350633569,
|
||||
"tag": "0039_good_carmella_unuscione",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 40,
|
||||
"version": "5",
|
||||
"when": 1788942350737,
|
||||
"tag": "0040_sudden_flatman",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -248,7 +248,14 @@ export const llmLogs = mysqlTable("llmLogs", {
|
||||
sourceFileId: int("sourceFileId"), // Optional: link to source file if applicable
|
||||
invoiceId: int("invoiceId"), // Optional: link to invoice if applicable
|
||||
operation: varchar("operation", { length: 50 }).notNull(), // "detection" or "extraction"
|
||||
model: varchar("model", { length: 50 }).notNull(), // LLM model used
|
||||
/** Modèle demandé ou historique, conservé pour compatibilité. */
|
||||
model: varchar("model", { length: 50 }).notNull(),
|
||||
/** Fournisseur réellement résolu par l’adaptateur au moment de l’appel. */
|
||||
resolvedProvider: varchar("resolvedProvider", { length: 30 }),
|
||||
/** Modèle réellement inclus dans le payload envoyé au fournisseur. */
|
||||
resolvedModel: varchar("resolvedModel", { length: 80 }),
|
||||
/** Catégorie de transport, sans URL ni secret. */
|
||||
transport: varchar("transport", { length: 30 }),
|
||||
promptSent: text("promptSent").notNull(), // Full prompt sent to LLM
|
||||
rawResponse: text("rawResponse").notNull(), // Raw response from LLM (before cleaning)
|
||||
cleanedResponse: text("cleanedResponse"), // Response after markdown cleaning
|
||||
|
||||
@@ -97,6 +97,29 @@ export type InvokeResult = {
|
||||
};
|
||||
};
|
||||
|
||||
export type LlmProvider = "mistral" | "manus" | "gemini";
|
||||
export type LlmTransport = "mistral-api" | "manus-forge" | "gemini-api";
|
||||
|
||||
export type UserLlmSettings = {
|
||||
aiProvider?: string | null;
|
||||
mistralApiKey?: string | null;
|
||||
manusForgeApiKey?: string | null;
|
||||
manusForgeApiUrl?: string | null;
|
||||
geminiApiKey?: string | null;
|
||||
};
|
||||
|
||||
/** Métadonnées non sensibles décrivant l’appel LLM réellement résolu. */
|
||||
export type ResolvedLlmTrace = {
|
||||
provider: LlmProvider;
|
||||
model: string;
|
||||
transport: LlmTransport;
|
||||
};
|
||||
|
||||
export type ResolvedLlmInvocation = {
|
||||
response: InvokeResult;
|
||||
trace: ResolvedLlmTrace;
|
||||
};
|
||||
|
||||
export type JsonSchema = {
|
||||
name: string;
|
||||
schema: Record<string, unknown>;
|
||||
@@ -283,7 +306,7 @@ const normalizeResponseFormat = ({
|
||||
/**
|
||||
* Résout l'URL API en tenant compte des paramètres utilisateur (DB) en priorité sur les variables d'environnement.
|
||||
*/
|
||||
function resolveApiUrlWithSettings(settings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; manusForgeApiUrl?: string | null; geminiApiKey?: string | null }): string {
|
||||
function resolveApiUrlWithSettings(settings?: UserLlmSettings): string {
|
||||
const provider = settings?.aiProvider || "mistral";
|
||||
if (provider === "mistral") {
|
||||
return "https://api.mistral.ai/v1/chat/completions";
|
||||
@@ -297,7 +320,7 @@ function resolveApiUrlWithSettings(settings?: { aiProvider?: string | null; mist
|
||||
return `${forgeUrl.replace(/\/$/, "")}/v1/chat/completions`;
|
||||
}
|
||||
|
||||
function getApiKeyWithSettings(settings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; geminiApiKey?: string | null }): string {
|
||||
function getApiKeyWithSettings(settings?: UserLlmSettings): string {
|
||||
const provider = settings?.aiProvider || "mistral";
|
||||
if (provider === "mistral") {
|
||||
return settings?.mistralApiKey || ENV.mistralApiKey || "";
|
||||
@@ -308,11 +331,20 @@ function getApiKeyWithSettings(settings?: { aiProvider?: string | null; mistralA
|
||||
return settings?.manusForgeApiKey || ENV.forgeApiKey || "";
|
||||
}
|
||||
|
||||
function getModelForProvider(provider: string): string {
|
||||
if (provider === "mistral") return "mistral-large-latest";
|
||||
if (provider === "gemini") return "gemini-2.0-flash";
|
||||
// Manus Forge → Gemini 2.5 Flash via proxy
|
||||
return "gemini-2.5-flash";
|
||||
/**
|
||||
* Résout le fournisseur, le modèle et la passerelle effectivement utilisés.
|
||||
* Cette trace ne contient aucun secret ni URL personnalisée.
|
||||
*/
|
||||
export function resolveLlmTrace(settings?: UserLlmSettings): ResolvedLlmTrace {
|
||||
const provider = settings?.aiProvider || "mistral";
|
||||
|
||||
if (provider === "manus") {
|
||||
return { provider: "manus", model: "gemini-2.5-flash", transport: "manus-forge" };
|
||||
}
|
||||
if (provider === "gemini") {
|
||||
return { provider: "gemini", model: "gemini-2.0-flash", transport: "gemini-api" };
|
||||
}
|
||||
return { provider: "mistral", model: "mistral-large-latest", transport: "mistral-api" };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -321,14 +353,15 @@ function getModelForProvider(provider: string): string {
|
||||
*/
|
||||
export async function invokeLLMWithUserSettings(
|
||||
params: InvokeParams,
|
||||
userSettings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; manusForgeApiUrl?: string | null; geminiApiKey?: string | null }
|
||||
): Promise<InvokeResult> {
|
||||
userSettings?: UserLlmSettings
|
||||
): Promise<ResolvedLlmInvocation> {
|
||||
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 provider = userSettings?.aiProvider || "mistral";
|
||||
const trace = resolveLlmTrace(userSettings);
|
||||
const provider = trace.provider;
|
||||
|
||||
const {
|
||||
messages,
|
||||
@@ -341,7 +374,7 @@ export async function invokeLLMWithUserSettings(
|
||||
response_format,
|
||||
} = params;
|
||||
|
||||
const model = getModelForProvider(provider);
|
||||
const model = trace.model;
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
model,
|
||||
@@ -378,7 +411,10 @@ export async function invokeLLMWithUserSettings(
|
||||
throw new Error(`LLM invoke failed: ${response.status} ${response.statusText} – ${errorText}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as InvokeResult;
|
||||
return {
|
||||
response: (await response.json()) as InvokeResult,
|
||||
trace,
|
||||
};
|
||||
}
|
||||
|
||||
export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { invokeLLMWithUserSettings } from "./_core/llm";
|
||||
import { invokeLLMWithUserSettings, type UserLlmSettings } from "./_core/llm";
|
||||
import { PDFDocument } from "pdf-lib";
|
||||
import { createLlmLog } from "./db";
|
||||
import PDFParser from "pdf2json";
|
||||
@@ -144,7 +144,7 @@ export async function extractInvoicesWithMistral(
|
||||
subscription?: string | null;
|
||||
recipient?: string | null;
|
||||
},
|
||||
aiSettings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; manusForgeApiUrl?: string | null }
|
||||
aiSettings?: UserLlmSettings
|
||||
): Promise<MultiInvoiceResult> {
|
||||
// Load user's field configuration
|
||||
const { getLlmFieldsConfigByUser } = await import("./db");
|
||||
@@ -241,7 +241,7 @@ Si une information n'est pas trouvée, utilise null. Ne retourne AUCUN texte en
|
||||
|
||||
// Call Mistral LLM with extracted text
|
||||
const fullPrompt = `${prompt}\n\nTexte extrait du PDF:\n${pdfText}`;
|
||||
const response = await invokeLLMWithUserSettings(
|
||||
const invocation = await invokeLLMWithUserSettings(
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
@@ -252,6 +252,7 @@ Si une information n'est pas trouvée, utilise null. Ne retourne AUCUN texte en
|
||||
},
|
||||
aiSettings
|
||||
);
|
||||
const { response, trace } = invocation;
|
||||
|
||||
const rawResponse = typeof response.choices[0]?.message?.content === "string"
|
||||
? response.choices[0].message.content
|
||||
@@ -276,6 +277,9 @@ Si une information n'est pas trouvée, utilise null. Ne retourne AUCUN texte en
|
||||
sourceFileId,
|
||||
operation: "extraction",
|
||||
model,
|
||||
resolvedProvider: trace.provider,
|
||||
resolvedModel: trace.model,
|
||||
transport: trace.transport,
|
||||
promptSent: prompt,
|
||||
rawResponse: rawResponse.substring(0, 10000),
|
||||
cleanedResponse: cleanedResponse.substring(0, 10000),
|
||||
@@ -293,6 +297,9 @@ Si une information n'est pas trouvée, utilise null. Ne retourne AUCUN texte en
|
||||
sourceFileId,
|
||||
operation: "extraction",
|
||||
model,
|
||||
resolvedProvider: trace.provider,
|
||||
resolvedModel: trace.model,
|
||||
transport: trace.transport,
|
||||
promptSent: prompt.substring(0, 10000),
|
||||
rawResponse: rawResponse.substring(0, 10000),
|
||||
cleanedResponse: cleanedResponse.substring(0, 10000),
|
||||
|
||||
66
server/llm-trace.test.ts
Normal file
66
server/llm-trace.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { invokeLLMWithUserSettings, resolveLlmTrace } from "./_core/llm";
|
||||
|
||||
describe("resolveLlmTrace", () => {
|
||||
it("trace Manus Forge avec le modèle réellement résolu", () => {
|
||||
expect(resolveLlmTrace({ aiProvider: "manus" })).toEqual({
|
||||
provider: "manus",
|
||||
model: "gemini-2.5-flash",
|
||||
transport: "manus-forge",
|
||||
});
|
||||
});
|
||||
|
||||
it("trace Mistral et Gemini sans déduire le modèle depuis un champ historique", () => {
|
||||
expect(resolveLlmTrace({ aiProvider: "mistral" })).toEqual({
|
||||
provider: "mistral",
|
||||
model: "mistral-large-latest",
|
||||
transport: "mistral-api",
|
||||
});
|
||||
expect(resolveLlmTrace({ aiProvider: "gemini" })).toEqual({
|
||||
provider: "gemini",
|
||||
model: "gemini-2.0-flash",
|
||||
transport: "gemini-api",
|
||||
});
|
||||
});
|
||||
|
||||
it("reste sur Mistral lorsque le fournisseur est absent", () => {
|
||||
expect(resolveLlmTrace()).toEqual({
|
||||
provider: "mistral",
|
||||
model: "mistral-large-latest",
|
||||
transport: "mistral-api",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("invokeLLMWithUserSettings", () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("retourne la trace Manus correspondant au payload réellement envoyé", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ id: "test", created: 0, model: "gemini-2.5-flash", choices: [] }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const invocation = await invokeLLMWithUserSettings(
|
||||
{ messages: [{ role: "user", content: "test non sensible" }] },
|
||||
{
|
||||
aiProvider: "manus",
|
||||
manusForgeApiKey: "test-only-not-a-secret",
|
||||
manusForgeApiUrl: "https://forge.example.test",
|
||||
},
|
||||
);
|
||||
|
||||
expect(invocation.trace).toEqual({
|
||||
provider: "manus",
|
||||
model: "gemini-2.5-flash",
|
||||
transport: "manus-forge",
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://forge.example.test/v1/chat/completions",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
const request = fetchMock.mock.calls[0][1] as RequestInit;
|
||||
expect(JSON.parse(String(request.body))).toMatchObject({ model: "gemini-2.5-flash" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user