Checkpoint: Bouton Relancer corrigé (automatismes uniquement, sans LLM). Système d'apprentissage complet : table invoiceLearnings, routes tRPC, déclenchement depuis InvoiceDetail, application lors des imports, page LearningSettings dans Configuration > Apprentissages IA.

This commit is contained in:
Manus
2026-04-12 14:54:29 -04:00
parent 120f56bb23
commit 3ae1e47ca6
10 changed files with 2187 additions and 74 deletions

View File

@@ -41,7 +41,10 @@ import {
ServiceSignature,
bapHistory,
InsertBapHistory,
BapHistory
BapHistory,
invoiceLearnings,
InsertInvoiceLearning,
InvoiceLearning
} from "../drizzle/schema";
import { ENV } from './_core/env';
@@ -811,3 +814,80 @@ export async function deleteBapHistoryEntry(id: number): Promise<void> {
if (!db) return;
await db.delete(bapHistory).where(eq(bapHistory.id, id));
}
// ── Invoice Learnings (corrections manuelles apprises) ────────────────────────
/** Normalise le nom du fournisseur pour la clé de correspondance */
export function normalizeSupplierId(supplierName: string): string {
return supplierName.trim().toLowerCase().replace(/\s+/g, ' ');
}
/** Récupère tous les apprentissages d'un utilisateur */
export async function getLearningsByUser(userId: number): Promise<InvoiceLearning[]> {
const db = await getDb();
if (!db) return [];
return db.select().from(invoiceLearnings).where(eq(invoiceLearnings.userId, userId));
}
/** Récupère les apprentissages pour un fournisseur donné */
export async function getLearningsBySupplier(userId: number, supplierName: string): Promise<InvoiceLearning[]> {
const db = await getDb();
if (!db) return [];
const key = normalizeSupplierId(supplierName);
return db.select().from(invoiceLearnings).where(
and(eq(invoiceLearnings.userId, userId), eq(invoiceLearnings.supplierKey, key))
);
}
/** Enregistre ou met à jour un apprentissage (upsert par userId + supplierKey + fieldName) */
export async function upsertLearning(data: {
userId: number;
supplierName: string;
fieldName: string;
originalValue?: string;
correctedValue: string;
}): Promise<void> {
const db = await getDb();
if (!db) return;
const supplierKey = normalizeSupplierId(data.supplierName);
// Chercher si une entrée existe déjà
const existing = await db.select().from(invoiceLearnings).where(
and(
eq(invoiceLearnings.userId, data.userId),
eq(invoiceLearnings.supplierKey, supplierKey),
eq(invoiceLearnings.fieldName, data.fieldName)
)
);
if (existing.length > 0) {
await db.update(invoiceLearnings)
.set({
correctedValue: data.correctedValue,
originalValue: data.originalValue ?? existing[0].originalValue,
applyCount: (existing[0].applyCount || 1) + 1,
})
.where(eq(invoiceLearnings.id, existing[0].id));
} else {
await db.insert(invoiceLearnings).values({
userId: data.userId,
supplierKey,
fieldName: data.fieldName,
originalValue: data.originalValue,
correctedValue: data.correctedValue,
applyCount: 1,
});
}
}
/** Supprime un apprentissage par ID */
export async function deleteLearning(id: number): Promise<void> {
const db = await getDb();
if (!db) return;
await db.delete(invoiceLearnings).where(eq(invoiceLearnings.id, id));
}
/** Supprime tous les apprentissages d'un utilisateur */
export async function deleteAllLearnings(userId: number): Promise<void> {
const db = await getDb();
if (!db) return;
await db.delete(invoiceLearnings).where(eq(invoiceLearnings.userId, userId));
}

View File

@@ -66,6 +66,11 @@ import {
createBapHistoryEntry,
getBapHistoryByUser,
deleteBapHistoryEntry,
getLearningsByUser,
getLearningsBySupplier,
upsertLearning,
deleteLearning,
deleteAllLearnings,
} from "./db";
import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured, generateToken } from "./auth";
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
@@ -287,6 +292,28 @@ export const appRouter = router({
console.error("[Automation] Error applying rules:", autoError);
// Don't fail the import if automation fails
}
// Apply learnings (corrections manuelles mémorisées) after automation rules
try {
if (newInvoice.supplierName) {
const learnings = await getLearningsBySupplier(userId, newInvoice.supplierName);
if (learnings.length > 0) {
const learningUpdates: Record<string, string> = {};
for (const learning of learnings) {
if (learning.fieldName === 'typeAchat' || learning.fieldName === 'serviceConcerne' || learning.fieldName === 'ventilationComptable') {
learningUpdates[learning.fieldName] = learning.correctedValue;
}
}
if (Object.keys(learningUpdates).length > 0) {
await updateInvoice(newInvoice.id, learningUpdates as any);
console.log(`[Learning] Applied ${Object.keys(learningUpdates).length} learning(s) to invoice ${newInvoice.id} (${newInvoice.supplierName})`);
}
}
}
} catch (learningError) {
console.error("[Learning] Error applying learnings:", learningError);
// Don't fail the import if learning application fails
}
importedCount++;
} catch (error: any) {
@@ -870,89 +897,23 @@ export const appRouter = router({
invoiceIds: z.array(z.number()).min(1),
}))
.mutation(async ({ input, ctx }) => {
// Relance UNIQUEMENT les automatismes (sans re-extraction LLM)
const { applyAutomationRules } = await import("./automationEngine");
const { localStoragePut, generateStorageKey } = await import('./localStorage');
const path = await import('path');
const fs = await import('fs/promises');
const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), 'storage');
const allInvoices = await getInvoicesByUser(ctx.user.id);
const selected = allInvoices.filter(inv => input.invoiceIds.includes(inv.id));
if (selected.length === 0) throw new TRPCError({ code: 'NOT_FOUND', message: 'Aucune facture trouvée' });
const userSettings = await getUserSettings(ctx.user.id);
const model = userSettings?.llmModel || 'mistral-large-latest';
const customKeywords = {
invoiceNumber: userSettings?.invoiceNumberKeywords || null,
deliveryNote: userSettings?.deliveryNoteKeywords || null,
orderNumber: userSettings?.orderNumberKeywords || null,
supplier: userSettings?.supplierKeywords || null,
totalAmount: userSettings?.totalAmountKeywords || null,
subscription: userSettings?.subscriptionKeywords || null,
recipient: userSettings?.recipientKeywords || null,
};
let processed = 0;
let errors = 0;
const results: Array<{ id: number; success: boolean; qualityScore?: number; error?: string }> = [];
const results: Array<{ id: number; success: boolean; error?: string }> = [];
for (const invoice of selected) {
try {
// Lire le PDF source
let pdfBuffer: Buffer;
const localPath = path.join(STORAGE_BASE_PATH, invoice.fileKey);
try {
pdfBuffer = await fs.readFile(localPath);
} catch (_) {
const fileUrl = invoice.fileUrl;
if (!fileUrl) throw new Error('Fichier PDF introuvable');
let absoluteUrl = fileUrl;
if (fileUrl.startsWith('/')) {
const baseUrl = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
absoluteUrl = `${baseUrl}${fileUrl}`;
}
const resp = await fetch(absoluteUrl);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
pdfBuffer = Buffer.from(await resp.arrayBuffer());
const automationUpdates = await applyAutomationRules(ctx.user.id, invoice);
if (Object.keys(automationUpdates).length > 0) {
await updateInvoice(invoice.id, automationUpdates);
}
// Relancer l'extraction LLM
const result = await extractInvoicesWithMistral(pdfBuffer, ctx.user.id, invoice.sourceFileId!, model, customKeywords);
// Prendre la facture correspondante dans le résultat (par index ou la première)
const idx = (invoice.invoiceIndexInFile || 1) - 1;
const extracted = result.invoices[idx] || result.invoices[0];
if (!extracted) throw new Error('Extraction vide');
// Mettre à jour les champs extraits
const metadataJson = generateMetadataJSON(extracted);
const metadataKey = generateStorageKey(ctx.user.id, `${invoice.fileName}-reprocess-metadata.json`);
const { url: metadataUrl } = await localStoragePut(metadataKey, Buffer.from(metadataJson), 'application/json');
await updateInvoice(invoice.id, {
supplierName: extracted.supplierName,
invoiceNumber: extracted.invoiceNumber,
invoiceDate: extracted.invoiceDate,
deliveryNoteNumber: extracted.deliveryNoteNumber,
orderNumber: extracted.orderNumber,
totalAmount: extracted.totalAmount?.toString(),
recipientName: extracted.recipientName,
qualityScore: extracted.qualityScore,
extractedText: extracted.extractedText,
isSubscription: extracted.isSubscription ? 1 : 0,
metadataFileKey: metadataKey,
metadataFileUrl: metadataUrl,
});
// Réappliquer les règles d'automatisme
const updatedInvoice = await getInvoiceById(invoice.id);
if (updatedInvoice) {
const automationUpdates = await applyAutomationRules(ctx.user.id, updatedInvoice);
if (Object.keys(automationUpdates).length > 0) {
await updateInvoice(invoice.id, automationUpdates);
}
}
results.push({ id: invoice.id, success: true, qualityScore: extracted.qualityScore });
results.push({ id: invoice.id, success: true });
processed++;
} catch (err: any) {
console.error(`[Reprocess] Error on invoice ${invoice.id}:`, err);
@@ -1949,5 +1910,50 @@ export const appRouter = router({
return { success: true };
}),
}),
// ============= LEARNINGS ROUTES =============
learnings: router({
/** Liste tous les apprentissages de l'utilisateur */
list: protectedProcedure.query(async ({ ctx }) => {
return await getLearningsByUser(ctx.user.id);
}),
/** Enregistre ou met à jour un apprentissage suite à une correction manuelle */
upsert: protectedProcedure
.input(z.object({
supplierName: z.string().min(1),
fieldName: z.string().min(1),
originalValue: z.string().optional(),
correctedValue: z.string(),
}))
.mutation(async ({ input, ctx }) => {
await upsertLearning({
userId: ctx.user.id,
supplierName: input.supplierName,
fieldName: input.fieldName,
originalValue: input.originalValue,
correctedValue: input.correctedValue,
});
return { success: true };
}),
/** Supprime un apprentissage par ID */
delete: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => {
const all = await getLearningsByUser(ctx.user.id);
const entry = all.find(l => l.id === input.id);
if (!entry) throw new TRPCError({ code: 'NOT_FOUND' });
await deleteLearning(input.id);
return { success: true };
}),
/** Supprime tous les apprentissages de l'utilisateur */
deleteAll: protectedProcedure
.mutation(async ({ ctx }) => {
await deleteAllLearnings(ctx.user.id);
return { success: true };
}),
}),
});
export type AppRouter = typeof appRouter;