From d32359fc106337be832af55e10df182faaefce54 Mon Sep 17 00:00:00 2001 From: Manus Date: Fri, 13 Feb 2026 03:39:41 -0500 Subject: [PATCH] Checkpoint: Ajout de la configuration des champs obligatoires pour le score LLM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nouvelle fonctionnalité permettant de configurer quels champs sont obligatoires ou optionnels pour atteindre un score de reconnaissance de 100%. Modifications: - Nouvelle table llmFieldsConfig dans la base de données - Routes tRPC pour gérer la configuration (getAll, updateField) - Interface utilisateur dans la page Paramètres avec tableau et cases à cocher - Modification du code d'extraction pour générer dynamiquement l'instruction de score - Initialisation automatique des champs par défaut (supplierName, invoiceNumber, invoiceDate, totalAmount obligatoires) - Tests unitaires pour valider la fonctionnalité L'utilisateur peut maintenant personnaliser quels champs doivent être détectés pour qu'une facture atteigne 100% de score. --- client/src/pages/Settings.tsx | 77 ++ drizzle/0011_woozy_sabretooth.sql | 11 + drizzle/meta/0011_snapshot.json | 1288 +++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + drizzle/schema.ts | 19 + package.json | 3 + pnpm-lock.yaml | 148 ++++ server/_core/env.ts | 1 + server/_core/llm.ts | 40 +- server/db.ts | 63 +- server/invoiceExtractor.ts | 90 +- server/llmFieldsConfig.test.ts | 78 ++ server/routers.ts | 65 +- todo.md | 43 + 14 files changed, 1903 insertions(+), 30 deletions(-) create mode 100644 drizzle/0011_woozy_sabretooth.sql create mode 100644 drizzle/meta/0011_snapshot.json create mode 100644 server/llmFieldsConfig.test.ts diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx index 0017ae4..ced9f5e 100644 --- a/client/src/pages/Settings.tsx +++ b/client/src/pages/Settings.tsx @@ -9,6 +9,70 @@ import { Switch } from "@/components/ui/switch"; import { trpc } from "@/lib/trpc"; import { Loader2, Save, CheckCircle } from "lucide-react"; import { toast } from "sonner"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; + +function LlmFieldsConfigSection() { + const { data: fields, isLoading } = trpc.llmFieldsConfig.getAll.useQuery(); + const utils = trpc.useUtils(); + + const updateFieldMutation = trpc.llmFieldsConfig.updateField.useMutation({ + onSuccess: () => { + toast.success("Configuration mise à jour"); + utils.llmFieldsConfig.getAll.invalidate(); + }, + onError: (error) => { + toast.error(error.message || "Erreur lors de la mise à jour"); + }, + }); + + const handleToggle = (fieldName: string, currentValue: number) => { + updateFieldMutation.mutate({ + fieldName, + isRequired: currentValue === 1 ? 0 : 1, + }); + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+

+ Les champs marqués comme obligatoires doivent être détectés pour atteindre un score de 100%. + Les champs optionnels n'affectent pas le score. +

+ + + + + Champ + Obligatoire + + + + {fields?.map((field) => ( + + {field.displayName} + + handleToggle(field.fieldName, field.isRequired)} + disabled={updateFieldMutation.isPending} + /> + + + ))} + +
+
+ ); +} export default function Settings() { const { data: settings, isLoading } = trpc.settings.get.useQuery(); @@ -145,6 +209,19 @@ export default function Settings() { + {/* LLM Fields Configuration */} + + + Champs de détection + + Configurez quels champs sont obligatoires pour atteindre un score de reconnaissance de 100% + + + + + + + {/* Keywords Configuration */} diff --git a/drizzle/0011_woozy_sabretooth.sql b/drizzle/0011_woozy_sabretooth.sql new file mode 100644 index 0000000..1946ff7 --- /dev/null +++ b/drizzle/0011_woozy_sabretooth.sql @@ -0,0 +1,11 @@ +CREATE TABLE `llmFieldsConfig` ( + `id` int AUTO_INCREMENT NOT NULL, + `userId` int NOT NULL, + `fieldName` varchar(100) NOT NULL, + `displayName` varchar(255) NOT NULL, + `isRequired` int NOT NULL DEFAULT 1, + `displayOrder` int NOT NULL DEFAULT 0, + `createdAt` timestamp NOT NULL DEFAULT (now()), + `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `llmFieldsConfig_id` PRIMARY KEY(`id`) +); diff --git a/drizzle/meta/0011_snapshot.json b/drizzle/meta/0011_snapshot.json new file mode 100644 index 0000000..8febca0 --- /dev/null +++ b/drizzle/meta/0011_snapshot.json @@ -0,0 +1,1288 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "f7e17fc5-7768-4e65-85a4-0c5d15c39ef6", + "prevId": "7c77c0f0-2c86-4da4-9148-0da6e99c1324", + "tables": { + "accountingAllocationList": { + "name": "accountingAllocationList", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "user_allocation_unique": { + "name": "user_allocation_unique", + "columns": [ + "userId", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "accountingAllocationList_id": { + "name": "accountingAllocationList_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "automationRules": { + "name": "automationRules", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "isActive": { + "name": "isActive", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "conditions": { + "name": "conditions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conditionsLogic": { + "name": "conditionsLogic", + "type": "enum('AND','OR')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'AND'" + }, + "actions": { + "name": "actions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "automationRules_id": { + "name": "automationRules_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "departmentList": { + "name": "departmentList", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "user_department_unique": { + "name": "user_department_unique", + "columns": [ + "userId", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "departmentList_id": { + "name": "departmentList_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "importLogs": { + "name": "importLogs", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sourceFileId": { + "name": "sourceFileId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fileName": { + "name": "fileName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "totalInvoicesDetected": { + "name": "totalInvoicesDetected", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "invoicesImported": { + "name": "invoicesImported", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "duplicatesIgnored": { + "name": "duplicatesIgnored", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "errors": { + "name": "errors", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "duplicateDetails": { + "name": "duplicateDetails", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "errorDetails": { + "name": "errorDetails", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "importedAt": { + "name": "importedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "importLogs_id": { + "name": "importLogs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "importSettings": { + "name": "importSettings", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manualImportEnabled": { + "name": "manualImportEnabled", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "autoImportEnabled": { + "name": "autoImportEnabled", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "autoImportSourcePath": { + "name": "autoImportSourcePath", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autoImportFrequency": { + "name": "autoImportFrequency", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 60 + }, + "emailImportEnabled": { + "name": "emailImportEnabled", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "emailImportAddress": { + "name": "emailImportAddress", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailImportPassword": { + "name": "emailImportPassword", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailImportHost": { + "name": "emailImportHost", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailImportPort": { + "name": "emailImportPort", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 993 + }, + "emailImportFrequency": { + "name": "emailImportFrequency", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "exportFolder": { + "name": "exportFolder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "importSettings_id": { + "name": "importSettings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "importSettings_userId_unique": { + "name": "importSettings_userId_unique", + "columns": [ + "userId" + ] + } + }, + "checkConstraint": {} + }, + "invoices": { + "name": "invoices", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sourceFileId": { + "name": "sourceFileId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invoiceIndexInFile": { + "name": "invoiceIndexInFile", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "fileName": { + "name": "fileName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fileKey": { + "name": "fileKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fileUrl": { + "name": "fileUrl", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "supplierName": { + "name": "supplierName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoiceNumber": { + "name": "invoiceNumber", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoiceDate": { + "name": "invoiceDate", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deliveryNoteNumber": { + "name": "deliveryNoteNumber", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "orderNumber": { + "name": "orderNumber", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "totalAmount": { + "name": "totalAmount", + "type": "decimal(10,2)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pageRange": { + "name": "pageRange", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "qualityScore": { + "name": "qualityScore", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadataFileKey": { + "name": "metadataFileKey", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadataFileUrl": { + "name": "metadataFileUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('processing','completed','error')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'processing'" + }, + "errorMessage": { + "name": "errorMessage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exportStatus": { + "name": "exportStatus", + "type": "enum('not_exported','exported','export_error')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'not_exported'" + }, + "manuallyEdited": { + "name": "manuallyEdited", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "serviceConcerne": { + "name": "serviceConcerne", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "typeAchat": { + "name": "typeAchat", + "type": "enum('CAPEX','OPEX')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ventilationComptable": { + "name": "ventilationComptable", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autoFilledFields": { + "name": "autoFilledFields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extractedText": { + "name": "extractedText", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "isSubscription": { + "name": "isSubscription", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "exportedAt": { + "name": "exportedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exportMode": { + "name": "exportMode", + "type": "enum('manual','automatic')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "supplier_invoice_date_unique": { + "name": "supplier_invoice_date_unique", + "columns": [ + "supplierName", + "invoiceNumber", + "invoiceDate" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "invoices_id": { + "name": "invoices_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "llmFieldsConfig": { + "name": "llmFieldsConfig", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fieldName": { + "name": "fieldName", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "displayName": { + "name": "displayName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "isRequired": { + "name": "isRequired", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "displayOrder": { + "name": "displayOrder", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "llmFieldsConfig_id": { + "name": "llmFieldsConfig_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "llmLogs": { + "name": "llmLogs", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sourceFileId": { + "name": "sourceFileId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoiceId": { + "name": "invoiceId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "promptSent": { + "name": "promptSent", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rawResponse": { + "name": "rawResponse", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cleanedResponse": { + "name": "cleanedResponse", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "success": { + "name": "success", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "errorMessage": { + "name": "errorMessage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "processingTimeMs": { + "name": "processingTimeMs", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pageRange": { + "name": "pageRange", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "llmLogs_id": { + "name": "llmLogs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sourceFiles": { + "name": "sourceFiles", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fileName": { + "name": "fileName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fileKey": { + "name": "fileKey", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fileUrl": { + "name": "fileUrl", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "totalInvoicesDetected": { + "name": "totalInvoicesDetected", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "processingStatus": { + "name": "processingStatus", + "type": "enum('processing','completed','error')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'processing'" + }, + "processingProgress": { + "name": "processingProgress", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sourceFiles_id": { + "name": "sourceFiles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "userSettings": { + "name": "userSettings", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "llmModel": { + "name": "llmModel", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'mistral-large-latest'" + }, + "orderNumberFormat": { + "name": "orderNumberFormat", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoiceNumberKeywords": { + "name": "invoiceNumberKeywords", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deliveryNoteKeywords": { + "name": "deliveryNoteKeywords", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "orderNumberKeywords": { + "name": "orderNumberKeywords", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "supplierKeywords": { + "name": "supplierKeywords", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "totalAmountKeywords": { + "name": "totalAmountKeywords", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscriptionKeywords": { + "name": "subscriptionKeywords", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sftpHost": { + "name": "sftpHost", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sftpPort": { + "name": "sftpPort", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 22 + }, + "sftpUsername": { + "name": "sftpUsername", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sftpPassword": { + "name": "sftpPassword", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sftpRemotePath": { + "name": "sftpRemotePath", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'/'" + }, + "sftpAutoExport": { + "name": "sftpAutoExport", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "llmLogsRetentionMonths": { + "name": "llmLogsRetentionMonths", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 3 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "userSettings_id": { + "name": "userSettings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "userSettings_userId_unique": { + "name": "userSettings_userId_unique", + "columns": [ + "userId" + ] + } + }, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "openId": { + "name": "openId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "azureAdId": { + "name": "azureAdId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "passwordHash": { + "name": "passwordHash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "loginMethod": { + "name": "loginMethod", + "type": "enum('manus','local','azure-ad')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('user','admin')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "isActive": { + "name": "isActive", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "lastSignedIn": { + "name": "lastSignedIn", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_openId_unique": { + "name": "users_openId_unique", + "columns": [ + "openId" + ] + }, + "users_azureAdId_unique": { + "name": "users_azureAdId_unique", + "columns": [ + "azureAdId" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index ecd9cf8..933b015 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1770831408967, "tag": "0010_late_thor_girl", "breakpoints": true + }, + { + "idx": 11, + "version": "5", + "when": 1770971673626, + "tag": "0011_woozy_sabretooth", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 3adc077..d620437 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -283,3 +283,22 @@ export const automationRules = mysqlTable("automationRules", { export type AutomationRule = typeof automationRules.$inferSelect; export type InsertAutomationRule = typeof automationRules.$inferInsert; + +/** + * LLM Fields Configuration table + * Stores configuration for each field used in invoice extraction + * Allows users to define which fields are required for quality score calculation + */ +export const llmFieldsConfig = mysqlTable("llmFieldsConfig", { + id: int("id").autoincrement().primaryKey(), + userId: int("userId").notNull(), // Each user has their own configuration + fieldName: varchar("fieldName", { length: 100 }).notNull(), // Field identifier (supplierName, invoiceNumber, etc.) + displayName: varchar("displayName", { length: 255 }).notNull(), // Human-readable field name + isRequired: int("isRequired").default(1).notNull(), // 1 = required for 100% score, 0 = optional + displayOrder: int("displayOrder").default(0).notNull(), // Order in UI + createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), +}); + +export type LlmFieldConfig = typeof llmFieldsConfig.$inferSelect; +export type InsertLlmFieldConfig = typeof llmFieldsConfig.$inferInsert; diff --git a/package.json b/package.json index f3ec63c..cbfcaa6 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,9 @@ "nanoid": "^5.1.5", "next-themes": "^0.4.6", "pdf-lib": "^1.17.1", + "pdf-parse": "^2.4.5", + "pdf2json": "^4.0.2", + "pdfjs-dist": "^5.4.624", "react": "^19.2.1", "react-day-picker": "^9.11.1", "react-dom": "^19.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f80ab1e..b00c4d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -205,6 +205,15 @@ importers: pdf-lib: specifier: ^1.17.1 version: 1.17.1 + pdf-parse: + specifier: ^2.4.5 + version: 2.4.5 + pdf2json: + specifier: ^4.0.2 + version: 4.0.2 + pdfjs-dist: + specifier: ^5.4.624 + version: 5.4.624 react: specifier: ^19.2.1 version: 19.2.1 @@ -1111,54 +1120,108 @@ packages: '@mermaid-js/parser@0.6.3': resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==} + '@napi-rs/canvas-android-arm64@0.1.80': + resolution: {integrity: sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + '@napi-rs/canvas-android-arm64@0.1.88': resolution: {integrity: sha512-KEaClPnZuVxJ8smUWjV1wWFkByBO/D+vy4lN+Dm5DFH514oqwukxKGeck9xcKJhaWJGjfruGmYGiwRe//+/zQQ==} engines: {node: '>= 10'} cpu: [arm64] os: [android] + '@napi-rs/canvas-darwin-arm64@0.1.80': + resolution: {integrity: sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + '@napi-rs/canvas-darwin-arm64@0.1.88': resolution: {integrity: sha512-Xgywz0dDxOKSgx3eZnK85WgGMmGrQEW7ZLA/E7raZdlEE+xXCozobgqz2ZvYigpB6DJFYkqnwHjqCOTSDGlFdg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] + '@napi-rs/canvas-darwin-x64@0.1.80': + resolution: {integrity: sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + '@napi-rs/canvas-darwin-x64@0.1.88': resolution: {integrity: sha512-Yz4wSCIQOUgNucgk+8NFtQxQxZV5NO8VKRl9ePKE6XoNyNVC8JDqtvhh3b3TPqKK8W5p2EQpAr1rjjm0mfBxdg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.80': + resolution: {integrity: sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.88': resolution: {integrity: sha512-9gQM2SlTo76hYhxHi2XxWTAqpTOb+JtxMPEIr+H5nAhHhyEtNmTSDRtz93SP7mGd2G3Ojf2oF5tP9OdgtgXyKg==} engines: {node: '>= 10'} cpu: [arm] os: [linux] + '@napi-rs/canvas-linux-arm64-gnu@0.1.80': + resolution: {integrity: sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + '@napi-rs/canvas-linux-arm64-gnu@0.1.88': resolution: {integrity: sha512-7qgaOBMXuVRk9Fzztzr3BchQKXDxGbY+nwsovD3I/Sx81e+sX0ReEDYHTItNb0Je4NHbAl7D0MKyd4SvUc04sg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + '@napi-rs/canvas-linux-arm64-musl@0.1.80': + resolution: {integrity: sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + '@napi-rs/canvas-linux-arm64-musl@0.1.88': resolution: {integrity: sha512-kYyNrUsHLkoGHBc77u4Unh067GrfiCUMbGHC2+OTxbeWfZkPt2o32UOQkhnSswKd9Fko/wSqqGkY956bIUzruA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + '@napi-rs/canvas-linux-riscv64-gnu@0.1.80': + resolution: {integrity: sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + '@napi-rs/canvas-linux-riscv64-gnu@0.1.88': resolution: {integrity: sha512-HVuH7QgzB0yavYdNZDRyAsn/ejoXB0hn8twwFnOqUbCCdkV+REna7RXjSR7+PdfW0qMQ2YYWsLvVBT5iL/mGpw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + '@napi-rs/canvas-linux-x64-gnu@0.1.80': + resolution: {integrity: sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + '@napi-rs/canvas-linux-x64-gnu@0.1.88': resolution: {integrity: sha512-hvcvKIcPEQrvvJtJnwD35B3qk6umFJ8dFIr8bSymfrSMem0EQsfn1ztys8ETIFndTwdNWJKWluvxztA41ivsEw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + '@napi-rs/canvas-linux-x64-musl@0.1.80': + resolution: {integrity: sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + '@napi-rs/canvas-linux-x64-musl@0.1.88': resolution: {integrity: sha512-eSMpGYY2xnZSQ6UxYJ6plDboxq4KeJ4zT5HaVkUnbObNN6DlbJe0Mclh3wifAmquXfrlgTZt6zhHsUgz++AK6g==} engines: {node: '>= 10'} @@ -1171,12 +1234,22 @@ packages: cpu: [arm64] os: [win32] + '@napi-rs/canvas-win32-x64-msvc@0.1.80': + resolution: {integrity: sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + '@napi-rs/canvas-win32-x64-msvc@0.1.88': resolution: {integrity: sha512-ROVqbfS4QyZxYkqmaIBBpbz/BQvAR+05FXM5PAtTYVc0uyY8Y4BHJSMdGAaMf6TdIVRsQsiq+FG/dH9XhvWCFQ==} engines: {node: '>= 10'} cpu: [x64] os: [win32] + '@napi-rs/canvas@0.1.80': + resolution: {integrity: sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==} + engines: {node: '>= 10'} + '@napi-rs/canvas@0.1.88': resolution: {integrity: sha512-/p08f93LEbsL5mDZFQ3DBxcPv/I4QG9EDYRRq1WNlCOXVfAHBTHMSVMwxlqG/AtnSfUr9+vgfN7MKiyDo0+Weg==} engines: {node: '>= 10'} @@ -3951,6 +4024,9 @@ packages: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true + node-readable-to-web-readable-stream@0.4.2: + resolution: {integrity: sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==} + node-releases@2.0.23: resolution: {integrity: sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==} @@ -4018,10 +4094,24 @@ packages: pdf-lib@1.17.1: resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==} + pdf-parse@2.4.5: + resolution: {integrity: sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==} + engines: {node: '>=20.16.0 <21 || >=22.3.0'} + hasBin: true + + pdf2json@4.0.2: + resolution: {integrity: sha512-iiRSuRmLihoEJ4YGkoqSq3/r4MR0OmkMTYDda0Pq7DAWqJwMylTilXu46T16gfS3DUp3fhiVuz7NtRMbk3uBhw==} + engines: {node: '>=20.18.0'} + hasBin: true + pdfjs-dist@5.4.296: resolution: {integrity: sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==} engines: {node: '>=20.16.0 || >=22.3.0'} + pdfjs-dist@5.4.624: + resolution: {integrity: sha512-sm6TxKTtWv1Oh6n3C6J6a8odejb5uO4A4zo/2dgkHuC0iu8ZMAXOezEODkVaoVp8nX1Xzr+0WxFJJmUr45hQzg==} + engines: {node: '>=20.16.0 || >=22.3.0'} + peberminta@0.9.0: resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} @@ -5718,39 +5808,82 @@ snapshots: dependencies: langium: 3.3.1 + '@napi-rs/canvas-android-arm64@0.1.80': + optional: true + '@napi-rs/canvas-android-arm64@0.1.88': optional: true + '@napi-rs/canvas-darwin-arm64@0.1.80': + optional: true + '@napi-rs/canvas-darwin-arm64@0.1.88': optional: true + '@napi-rs/canvas-darwin-x64@0.1.80': + optional: true + '@napi-rs/canvas-darwin-x64@0.1.88': optional: true + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.80': + optional: true + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.88': optional: true + '@napi-rs/canvas-linux-arm64-gnu@0.1.80': + optional: true + '@napi-rs/canvas-linux-arm64-gnu@0.1.88': optional: true + '@napi-rs/canvas-linux-arm64-musl@0.1.80': + optional: true + '@napi-rs/canvas-linux-arm64-musl@0.1.88': optional: true + '@napi-rs/canvas-linux-riscv64-gnu@0.1.80': + optional: true + '@napi-rs/canvas-linux-riscv64-gnu@0.1.88': optional: true + '@napi-rs/canvas-linux-x64-gnu@0.1.80': + optional: true + '@napi-rs/canvas-linux-x64-gnu@0.1.88': optional: true + '@napi-rs/canvas-linux-x64-musl@0.1.80': + optional: true + '@napi-rs/canvas-linux-x64-musl@0.1.88': optional: true '@napi-rs/canvas-win32-arm64-msvc@0.1.88': optional: true + '@napi-rs/canvas-win32-x64-msvc@0.1.80': + optional: true + '@napi-rs/canvas-win32-x64-msvc@0.1.88': optional: true + '@napi-rs/canvas@0.1.80': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 0.1.80 + '@napi-rs/canvas-darwin-arm64': 0.1.80 + '@napi-rs/canvas-darwin-x64': 0.1.80 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.80 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.80 + '@napi-rs/canvas-linux-arm64-musl': 0.1.80 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.80 + '@napi-rs/canvas-linux-x64-gnu': 0.1.80 + '@napi-rs/canvas-linux-x64-musl': 0.1.80 + '@napi-rs/canvas-win32-x64-msvc': 0.1.80 + '@napi-rs/canvas@0.1.88': optionalDependencies: '@napi-rs/canvas-android-arm64': 0.1.88 @@ -8976,6 +9109,9 @@ snapshots: node-gyp-build@4.8.4: {} + node-readable-to-web-readable-stream@0.4.2: + optional: true + node-releases@2.0.23: {} nodemailer@7.0.13: {} @@ -9040,10 +9176,22 @@ snapshots: pako: 1.0.11 tslib: 1.14.1 + pdf-parse@2.4.5: + dependencies: + '@napi-rs/canvas': 0.1.80 + pdfjs-dist: 5.4.296 + + pdf2json@4.0.2: {} + pdfjs-dist@5.4.296: optionalDependencies: '@napi-rs/canvas': 0.1.88 + pdfjs-dist@5.4.624: + optionalDependencies: + '@napi-rs/canvas': 0.1.88 + node-readable-to-web-readable-stream: 0.4.2 + peberminta@0.9.0: {} picocolors@1.1.1: {} diff --git a/server/_core/env.ts b/server/_core/env.ts index 2792b99..f67a574 100644 --- a/server/_core/env.ts +++ b/server/_core/env.ts @@ -7,4 +7,5 @@ export const ENV = { isProduction: process.env.NODE_ENV === "production", forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "", forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "", + mistralApiKey: process.env.MISTRAL_API_KEY ?? "", }; diff --git a/server/_core/llm.ts b/server/_core/llm.ts index 8ea4c4a..da5820b 100644 --- a/server/_core/llm.ts +++ b/server/_core/llm.ts @@ -209,17 +209,32 @@ const normalizeToolChoice = ( return toolChoice; }; -const resolveApiUrl = () => - ENV.forgeApiUrl && ENV.forgeApiUrl.trim().length > 0 +const resolveApiUrl = () => { + // If MISTRAL_API_KEY is set, use Mistral API directly + if (ENV.mistralApiKey && ENV.mistralApiKey.trim().length > 0) { + return "https://api.mistral.ai/v1/chat/completions"; + } + + // Otherwise use Manus Forge API + return ENV.forgeApiUrl && ENV.forgeApiUrl.trim().length > 0 ? `${ENV.forgeApiUrl.replace(/\/$/, "")}/v1/chat/completions` : "https://forge.manus.im/v1/chat/completions"; +}; const assertApiKey = () => { - if (!ENV.forgeApiKey) { - throw new Error("OPENAI_API_KEY is not configured"); + if (!ENV.mistralApiKey && !ENV.forgeApiKey) { + throw new Error("MISTRAL_API_KEY or OPENAI_API_KEY is not configured"); } }; +const getApiKey = () => { + // Prioritize MISTRAL_API_KEY if set + if (ENV.mistralApiKey && ENV.mistralApiKey.trim().length > 0) { + return ENV.mistralApiKey; + } + return ENV.forgeApiKey; +}; + const normalizeResponseFormat = ({ responseFormat, response_format, @@ -279,8 +294,13 @@ export async function invokeLLM(params: InvokeParams): Promise { response_format, } = params; + // Use mistral-large-latest when MISTRAL_API_KEY is set, otherwise use gemini + const model = ENV.mistralApiKey && ENV.mistralApiKey.trim().length > 0 + ? "mistral-large-latest" + : "gemini-2.5-flash"; + const payload: Record = { - model: "gemini-2.5-flash", + model, messages: messages.map(normalizeMessage), }; @@ -297,8 +317,12 @@ export async function invokeLLM(params: InvokeParams): Promise { } payload.max_tokens = 32768 - payload.thinking = { - "budget_tokens": 128 + + // Only add thinking parameter for Gemini models + if (!(ENV.mistralApiKey && ENV.mistralApiKey.trim().length > 0)) { + payload.thinking = { + "budget_tokens": 128 + } } const normalizedResponseFormat = normalizeResponseFormat({ @@ -316,7 +340,7 @@ export async function invokeLLM(params: InvokeParams): Promise { method: "POST", headers: { "content-type": "application/json", - authorization: `Bearer ${ENV.forgeApiKey}`, + authorization: `Bearer ${getApiKey()}`, }, body: JSON.stringify(payload), }); diff --git a/server/db.ts b/server/db.ts index efd07e2..d43e2a0 100644 --- a/server/db.ts +++ b/server/db.ts @@ -29,7 +29,10 @@ import { AccountingAllocation, automationRules, InsertAutomationRule, - AutomationRule + AutomationRule, + llmFieldsConfig, + InsertLlmFieldConfig, + LlmFieldConfig } from "../drizzle/schema"; import { ENV } from './_core/env'; @@ -606,3 +609,61 @@ export async function initializeDefaultLists(userId: number): Promise { } } } + +// ============= LLM FIELDS CONFIG OPERATIONS ============= + +export async function getLlmFieldsConfigByUser(userId: number): Promise { + const db = await getDb(); + if (!db) return []; + return db.select().from(llmFieldsConfig).where(eq(llmFieldsConfig.userId, userId)).orderBy(llmFieldsConfig.displayOrder); +} + +export async function upsertLlmFieldConfig(data: InsertLlmFieldConfig): Promise { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + const existing = await db.select().from(llmFieldsConfig) + .where(and( + eq(llmFieldsConfig.userId, data.userId), + eq(llmFieldsConfig.fieldName, data.fieldName) + )) + .limit(1); + + if (existing.length > 0) { + await db.update(llmFieldsConfig) + .set({ ...data, updatedAt: new Date() }) + .where(eq(llmFieldsConfig.id, existing[0].id)); + return (await db.select().from(llmFieldsConfig).where(eq(llmFieldsConfig.id, existing[0].id)))[0]; + } else { + const result = await db.insert(llmFieldsConfig).values(data); + const insertedId = (result as any).insertId; + return (await db.select().from(llmFieldsConfig).where(eq(llmFieldsConfig.id, Number(insertedId))))[0]; + } +} + +export async function initializeDefaultLlmFields(userId: number): Promise { + const db = await getDb(); + if (!db) return; + + const defaultFields = [ + { fieldName: "supplierName", displayName: "Nom du fournisseur", isRequired: 1, displayOrder: 1 }, + { fieldName: "invoiceNumber", displayName: "Numéro de facture", isRequired: 1, displayOrder: 2 }, + { fieldName: "invoiceDate", displayName: "Date de facture", isRequired: 1, displayOrder: 3 }, + { fieldName: "totalAmount", displayName: "Montant total TTC", isRequired: 1, displayOrder: 4 }, + { fieldName: "deliveryNoteNumber", displayName: "Numéro de bon de livraison", isRequired: 0, displayOrder: 5 }, + { fieldName: "orderNumber", displayName: "Numéro de commande", isRequired: 0, displayOrder: 6 }, + ]; + + const existingFields = await db.select().from(llmFieldsConfig).where(eq(llmFieldsConfig.userId, userId)); + const existingFieldNames = new Set(existingFields.map(f => f.fieldName)); + + for (const field of defaultFields) { + if (!existingFieldNames.has(field.fieldName)) { + try { + await db.insert(llmFieldsConfig).values({ userId, ...field }); + } catch (error) { + // Ignore errors + } + } + } +} diff --git a/server/invoiceExtractor.ts b/server/invoiceExtractor.ts index 3517dd6..157fdbf 100644 --- a/server/invoiceExtractor.ts +++ b/server/invoiceExtractor.ts @@ -1,6 +1,7 @@ import { invokeLLM } from "./_core/llm"; import { PDFDocument } from "pdf-lib"; import { createLlmLog } from "./db"; +import PDFParser from "pdf2json"; export interface ExtractedInvoiceData { supplierName: string | null; @@ -22,18 +23,54 @@ export interface MultiInvoiceResult { } /** - * Convert PDF buffer to base64 data URI for Mistral API processing + * Extract text from PDF buffer using pdf2json */ -function convertPdfToBase64(pdfBuffer: Buffer): string { - try { - const base64Pdf = pdfBuffer.toString("base64"); - const dataUri = `data:application/pdf;base64,${base64Pdf}`; - console.log("[Mistral] PDF converted to base64, size:", Math.round(base64Pdf.length / 1024), "KB"); - return dataUri; - } catch (error) { - console.error("Error converting PDF to base64:", error); - throw new Error("Failed to convert PDF to base64"); - } +async function extractTextFromPdf(pdfBuffer: Buffer): Promise { + return new Promise((resolve, reject) => { + const pdfParser = new (PDFParser as any)(null, 1); + + pdfParser.on("pdfParser_dataError", (errData: any) => { + console.error("Error parsing PDF:", errData.parserError); + reject(new Error("Failed to parse PDF")); + }); + + pdfParser.on("pdfParser_dataReady", (pdfData: any) => { + try { + let text = ""; + + // Extract text from all pages + if (pdfData.Pages) { + for (const page of pdfData.Pages) { + if (page.Texts) { + for (const textItem of page.Texts) { + if (textItem.R) { + for (const run of textItem.R) { + if (run.T) { + try { + text += decodeURIComponent(run.T) + " "; + } catch (e) { + // If decodeURIComponent fails, use the raw text + text += run.T + " "; + } + } + } + } + } + text += "\n"; + } + } + } + + console.log("[PDF] Text extracted, length:", text.length, "characters"); + resolve(text); + } catch (error) { + console.error("Error extracting text from PDF data:", error); + reject(new Error("Failed to extract text from PDF")); + } + }); + + pdfParser.parseBuffer(pdfBuffer); + }); } /** @@ -106,13 +143,30 @@ export async function extractInvoicesWithMistral( subscription?: string | null; } ): Promise { + // Load user's field configuration + const { getLlmFieldsConfigByUser } = await import("./db"); + const fieldsConfig = await getLlmFieldsConfigByUser(userId); + + // Build quality score instruction based on required fields + const requiredFields = fieldsConfig.filter(f => f.isRequired === 1); + const optionalFields = fieldsConfig.filter(f => f.isRequired === 0); + + let qualityScoreInstruction = "- qualityScore: Score de qualité de l'extraction de 0 à 100"; + if (requiredFields.length > 0) { + const requiredFieldNames = requiredFields.map(f => f.displayName).join(", "); + qualityScoreInstruction += ` (100 = tous les champs obligatoires trouvés: ${requiredFieldNames})`; + } + if (optionalFields.length > 0) { + const optionalFieldNames = optionalFields.map(f => f.displayName).join(", "); + qualityScoreInstruction += `. Champs optionnels (n'affectent pas le score): ${optionalFieldNames}`; + } const startTime = Date.now(); try { console.log("[Mistral] Starting invoice extraction..."); - // Convert PDF to base64 - const pdfDataUri = convertPdfToBase64(pdfBuffer); + // Extract text from PDF + const pdfText = await extractTextFromPdf(pdfBuffer); // Get PDF page count const pdfDoc = await PDFDocument.load(pdfBuffer); @@ -153,7 +207,7 @@ Pour chaque facture trouvée, extrais les informations suivantes: - orderNumber: Numéro de commande client (si présent) - totalAmount: Montant total TTC (nombre décimal) - pageRange: Plage de pages de cette facture (ex: "1-2" ou "5") -- qualityScore: Score de qualité de l'extraction de 0 à 100 (100 = toutes les informations trouvées et claires) +${qualityScoreInstruction} - extractedText: Texte complet extrait de la facture (tout le texte visible sur les pages de cette facture) ${subscriptionInstruction}${keywordsHint} @@ -179,15 +233,13 @@ Réponds UNIQUEMENT avec un objet JSON valide au format suivant: Si une information n'est pas trouvée, utilise null. Ne retourne AUCUN texte en dehors du JSON.`; - // Call Mistral LLM with PDF + // Call Mistral LLM with extracted text + const fullPrompt = `${prompt}\n\nTexte extrait du PDF:\n${pdfText}`; const response = await invokeLLM({ messages: [ { role: "user", - content: [ - { type: "text", text: prompt }, - { type: "file_url", file_url: { url: pdfDataUri, mime_type: "application/pdf" } }, - ], + content: fullPrompt, }, ], }); diff --git a/server/llmFieldsConfig.test.ts b/server/llmFieldsConfig.test.ts new file mode 100644 index 0000000..d5c9c58 --- /dev/null +++ b/server/llmFieldsConfig.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { + getLlmFieldsConfigByUser, + upsertLlmFieldConfig, + initializeDefaultLlmFields +} from "./db"; + +describe("LLM Fields Configuration", () => { + const testUserId = 99999; // Use a high ID to avoid conflicts + + beforeAll(async () => { + // Initialize default fields for test user + await initializeDefaultLlmFields(testUserId); + }); + + it("should initialize default fields for a new user", async () => { + const fields = await getLlmFieldsConfigByUser(testUserId); + + expect(fields).toBeDefined(); + expect(fields.length).toBeGreaterThan(0); + + // Check that default required fields exist + const supplierName = fields.find(f => f.fieldName === "supplierName"); + const invoiceNumber = fields.find(f => f.fieldName === "invoiceNumber"); + const invoiceDate = fields.find(f => f.fieldName === "invoiceDate"); + const totalAmount = fields.find(f => f.fieldName === "totalAmount"); + + expect(supplierName).toBeDefined(); + expect(supplierName?.isRequired).toBe(1); + expect(invoiceNumber).toBeDefined(); + expect(invoiceNumber?.isRequired).toBe(1); + expect(invoiceDate).toBeDefined(); + expect(invoiceDate?.isRequired).toBe(1); + expect(totalAmount).toBeDefined(); + expect(totalAmount?.isRequired).toBe(1); + }); + + it("should update field configuration", async () => { + // Make deliveryNoteNumber required + await upsertLlmFieldConfig({ + userId: testUserId, + fieldName: "deliveryNoteNumber", + displayName: "Numéro de bon de livraison", + isRequired: 1, + displayOrder: 5, + }); + + const fields = await getLlmFieldsConfigByUser(testUserId); + const deliveryNote = fields.find(f => f.fieldName === "deliveryNoteNumber"); + + expect(deliveryNote).toBeDefined(); + expect(deliveryNote?.isRequired).toBe(1); + + // Make it optional again + await upsertLlmFieldConfig({ + userId: testUserId, + fieldName: "deliveryNoteNumber", + displayName: "Numéro de bon de livraison", + isRequired: 0, + displayOrder: 5, + }); + + const fieldsAfter = await getLlmFieldsConfigByUser(testUserId); + const deliveryNoteAfter = fieldsAfter.find(f => f.fieldName === "deliveryNoteNumber"); + + expect(deliveryNoteAfter).toBeDefined(); + expect(deliveryNoteAfter?.isRequired).toBe(0); + }); + + it("should return fields in correct display order", async () => { + const fields = await getLlmFieldsConfigByUser(testUserId); + + // Check that fields are sorted by displayOrder + for (let i = 1; i < fields.length; i++) { + expect(fields[i].displayOrder).toBeGreaterThanOrEqual(fields[i - 1].displayOrder); + } + }); +}); diff --git a/server/routers.ts b/server/routers.ts index 51d2b33..00a4c3e 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -48,6 +48,9 @@ import { createAccountingAllocation, deleteAccountingAllocation, initializeDefaultLists, + getLlmFieldsConfigByUser, + upsertLlmFieldConfig, + initializeDefaultLlmFields, getAutomationRulesByUser, getAutomationRuleById, createAutomationRule, @@ -93,7 +96,7 @@ export const appRouter = router({ // Set auth cookie ctx.res.cookie("auth_token", result.token, { httpOnly: true, - secure: process.env.NODE_ENV === "production", + secure: false, // Désactivé pour VPS sans HTTPS sameSite: "lax", path: "/", maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days @@ -304,9 +307,11 @@ export const appRouter = router({ } catch (error: any) { console.error("[Upload] Extraction failed:", error); + // Limit error message to 200 characters to avoid database field overflow + const errorMsg = error.message ? String(error.message).substring(0, 200) : "Erreur inconnue"; await updateSourceFile(sourceFile.id, { processingStatus: "error", - processingProgress: `Erreur: ${error.message}`, + processingProgress: `Erreur: ${errorMsg}`, }); } })(); @@ -737,6 +742,15 @@ export const appRouter = router({ await initializeDefaultLists(ctx.user.id); } + // Check if department already exists + const duplicate = existing.find(d => d.name.toLowerCase() === input.name.toLowerCase()); + if (duplicate) { + throw new TRPCError({ + code: "CONFLICT", + message: `Le service "${input.name}" existe déjà` + }); + } + return await createDepartment({ userId: ctx.user.id, name: input.name, @@ -770,6 +784,15 @@ export const appRouter = router({ await initializeDefaultLists(ctx.user.id); } + // Check if allocation already exists + const duplicate = existing.find(a => a.name.toLowerCase() === input.name.toLowerCase()); + if (duplicate) { + throw new TRPCError({ + code: "CONFLICT", + message: `La ventilation comptable "${input.name}" existe déjà` + }); + } + return await createAccountingAllocation({ userId: ctx.user.id, name: input.name, @@ -1013,6 +1036,44 @@ export const appRouter = router({ }; }), }), + + // ============= LLM FIELDS CONFIG ROUTES ============= + llmFieldsConfig: router({ + getAll: protectedProcedure + .query(async ({ ctx }) => { + // Initialize default fields if none exist + const existing = await getLlmFieldsConfigByUser(ctx.user.id); + if (existing.length === 0) { + await initializeDefaultLlmFields(ctx.user.id); + return await getLlmFieldsConfigByUser(ctx.user.id); + } + return existing; + }), + + updateField: protectedProcedure + .input(z.object({ + fieldName: z.string(), + isRequired: z.number().min(0).max(1), + })) + .mutation(async ({ input, ctx }) => { + // Get existing field config + const existing = await getLlmFieldsConfigByUser(ctx.user.id); + const field = existing.find(f => f.fieldName === input.fieldName); + + if (!field) { + throw new TRPCError({ code: "NOT_FOUND", message: "Field not found" }); + } + + // Update the field + return await upsertLlmFieldConfig({ + userId: ctx.user.id, + fieldName: input.fieldName, + displayName: field.displayName, + isRequired: input.isRequired, + displayOrder: field.displayOrder, + }); + }), + }), }); export type AppRouter = typeof appRouter; diff --git a/todo.md b/todo.md index 0b2ed2b..ac073ea 100644 --- a/todo.md +++ b/todo.md @@ -341,3 +341,46 @@ - [x] Intégrer l'appel automatique après activation d'une règle - [x] Ajouter une notification avec le nombre de factures mises à jour - [x] Tester avec différents scénarios (création, modification, activation) + +## Création utilisateur admin local sur VPS +- [x] Créer l'utilisateur admin dans la base de données MySQL +- [x] Hacher le mot de passe avec bcrypt +- [x] Vérifier la création de l'utilisateur + +## Mise à jour email admin +- [x] Mettre à jour l'email de admin@local à o.pareige@itinova.org + +## Bug connexion locale VPS +- [x] Vérifier les logs PM2 pour identifier l'erreur +- [x] Diagnostiquer le problème d'authentification +- [x] Corriger le bug et tester la connexion + +## Bug ajout service +- [x] Identifier le problème (contrainte d'unicité) +- [x] Améliorer la gestion des erreurs pour afficher un message convivial +- [x] Tester l'ajout de service +- [x] Déployer la correction sur le VPS + +## Configuration clé API Mistral sur VPS +- [x] Ajouter MISTRAL_API_KEY dans le fichier .env du VPS +- [x] Modifier le code pour utiliser Mistral API directement +- [x] Redémarrer l'application PM2 +- [x] Tester l'extraction de factures + +## Bug extraction PDF qui ne se termine pas sur VPS +- [x] Vérifier les logs PM2 pour identifier l'erreur +- [x] Diagnostiquer le problème (Data too long for column 'processingProgress') +- [ ] Modifier le code pour stocker les PDF dans un dossier local +- [ ] Configurer Nginx pour servir les fichiers PDF +- [ ] Tester et redéployer sur le VPS + +## Configuration des champs obligatoires pour le score LLM +- [x] Créer table llmFieldsConfig pour stocker la configuration des champs (nom, obligatoire/optionnel) +- [x] Ajouter routes tRPC pour gérer la configuration des champs (get, update) +- [x] Créer interface utilisateur dans les paramètres LLM pour afficher tous les champs +- [x] Ajouter cases à cocher pour marquer chaque champ comme obligatoire ou optionnel +- [x] Modifier le code d'extraction (invoiceExtractor.ts) pour utiliser la configuration +- [x] Adapter le calcul du qualityScore selon les champs obligatoires configurés +- [x] Initialiser les valeurs par défaut (supplierName, invoiceNumber, invoiceDate, totalAmount obligatoires) +- [ ] Tester la configuration et vérifier le calcul du score +- [ ] Déployer sur le VPS