fix: tracer le moteur IA réellement invoqué
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 1m52s

This commit is contained in:
Manus Deployment
2026-09-09 08:27:43 +00:00
parent f7cfa73206
commit 7754affe96
7 changed files with 2659 additions and 16 deletions

View 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);

File diff suppressed because it is too large Load Diff

View File

@@ -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
} }
] ]
} }

View File

@@ -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

View File

@@ -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> {

View File

@@ -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
View 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" });
});
});