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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user