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,
|
"when": 1788350633569,
|
||||||
"tag": "0039_good_carmella_unuscione",
|
"tag": "0039_good_carmella_unuscione",
|
||||||
"breakpoints": true
|
"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
|
sourceFileId: int("sourceFileId"), // Optional: link to source file if applicable
|
||||||
invoiceId: int("invoiceId"), // Optional: link to invoice if applicable
|
invoiceId: int("invoiceId"), // Optional: link to invoice if applicable
|
||||||
operation: varchar("operation", { length: 50 }).notNull(), // "detection" or "extraction"
|
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
|
promptSent: text("promptSent").notNull(), // Full prompt sent to LLM
|
||||||
rawResponse: text("rawResponse").notNull(), // Raw response from LLM (before cleaning)
|
rawResponse: text("rawResponse").notNull(), // Raw response from LLM (before cleaning)
|
||||||
cleanedResponse: text("cleanedResponse"), // Response after markdown 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 = {
|
export type JsonSchema = {
|
||||||
name: string;
|
name: string;
|
||||||
schema: Record<string, unknown>;
|
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.
|
* 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";
|
const provider = settings?.aiProvider || "mistral";
|
||||||
if (provider === "mistral") {
|
if (provider === "mistral") {
|
||||||
return "https://api.mistral.ai/v1/chat/completions";
|
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`;
|
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";
|
const provider = settings?.aiProvider || "mistral";
|
||||||
if (provider === "mistral") {
|
if (provider === "mistral") {
|
||||||
return settings?.mistralApiKey || ENV.mistralApiKey || "";
|
return settings?.mistralApiKey || ENV.mistralApiKey || "";
|
||||||
@@ -308,11 +331,20 @@ function getApiKeyWithSettings(settings?: { aiProvider?: string | null; mistralA
|
|||||||
return settings?.manusForgeApiKey || ENV.forgeApiKey || "";
|
return settings?.manusForgeApiKey || ENV.forgeApiKey || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getModelForProvider(provider: string): string {
|
/**
|
||||||
if (provider === "mistral") return "mistral-large-latest";
|
* Résout le fournisseur, le modèle et la passerelle effectivement utilisés.
|
||||||
if (provider === "gemini") return "gemini-2.0-flash";
|
* Cette trace ne contient aucun secret ni URL personnalisée.
|
||||||
// Manus Forge → Gemini 2.5 Flash via proxy
|
*/
|
||||||
return "gemini-2.5-flash";
|
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(
|
export async function invokeLLMWithUserSettings(
|
||||||
params: InvokeParams,
|
params: InvokeParams,
|
||||||
userSettings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; manusForgeApiUrl?: string | null; geminiApiKey?: string | null }
|
userSettings?: UserLlmSettings
|
||||||
): Promise<InvokeResult> {
|
): Promise<ResolvedLlmInvocation> {
|
||||||
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 provider = userSettings?.aiProvider || "mistral";
|
const trace = resolveLlmTrace(userSettings);
|
||||||
|
const provider = trace.provider;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
@@ -341,7 +374,7 @@ export async function invokeLLMWithUserSettings(
|
|||||||
response_format,
|
response_format,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
const model = getModelForProvider(provider);
|
const model = trace.model;
|
||||||
|
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
model,
|
model,
|
||||||
@@ -378,7 +411,10 @@ export async function invokeLLMWithUserSettings(
|
|||||||
throw new Error(`LLM invoke failed: ${response.status} ${response.statusText} – ${errorText}`);
|
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> {
|
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 { PDFDocument } from "pdf-lib";
|
||||||
import { createLlmLog } from "./db";
|
import { createLlmLog } from "./db";
|
||||||
import PDFParser from "pdf2json";
|
import PDFParser from "pdf2json";
|
||||||
@@ -144,7 +144,7 @@ export async function extractInvoicesWithMistral(
|
|||||||
subscription?: string | null;
|
subscription?: string | null;
|
||||||
recipient?: string | null;
|
recipient?: string | null;
|
||||||
},
|
},
|
||||||
aiSettings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; manusForgeApiUrl?: string | null }
|
aiSettings?: UserLlmSettings
|
||||||
): Promise<MultiInvoiceResult> {
|
): Promise<MultiInvoiceResult> {
|
||||||
// Load user's field configuration
|
// Load user's field configuration
|
||||||
const { getLlmFieldsConfigByUser } = await import("./db");
|
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
|
// Call Mistral LLM with extracted text
|
||||||
const fullPrompt = `${prompt}\n\nTexte extrait du PDF:\n${pdfText}`;
|
const fullPrompt = `${prompt}\n\nTexte extrait du PDF:\n${pdfText}`;
|
||||||
const response = await invokeLLMWithUserSettings(
|
const invocation = await invokeLLMWithUserSettings(
|
||||||
{
|
{
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
@@ -252,6 +252,7 @@ Si une information n'est pas trouvée, utilise null. Ne retourne AUCUN texte en
|
|||||||
},
|
},
|
||||||
aiSettings
|
aiSettings
|
||||||
);
|
);
|
||||||
|
const { response, trace } = invocation;
|
||||||
|
|
||||||
const rawResponse = typeof response.choices[0]?.message?.content === "string"
|
const rawResponse = typeof response.choices[0]?.message?.content === "string"
|
||||||
? response.choices[0].message.content
|
? 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,
|
sourceFileId,
|
||||||
operation: "extraction",
|
operation: "extraction",
|
||||||
model,
|
model,
|
||||||
|
resolvedProvider: trace.provider,
|
||||||
|
resolvedModel: trace.model,
|
||||||
|
transport: trace.transport,
|
||||||
promptSent: prompt,
|
promptSent: prompt,
|
||||||
rawResponse: rawResponse.substring(0, 10000),
|
rawResponse: rawResponse.substring(0, 10000),
|
||||||
cleanedResponse: cleanedResponse.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,
|
sourceFileId,
|
||||||
operation: "extraction",
|
operation: "extraction",
|
||||||
model,
|
model,
|
||||||
|
resolvedProvider: trace.provider,
|
||||||
|
resolvedModel: trace.model,
|
||||||
|
transport: trace.transport,
|
||||||
promptSent: prompt.substring(0, 10000),
|
promptSent: prompt.substring(0, 10000),
|
||||||
rawResponse: rawResponse.substring(0, 10000),
|
rawResponse: rawResponse.substring(0, 10000),
|
||||||
cleanedResponse: cleanedResponse.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