Checkpoint: Ajout de la configuration des champs obligatoires pour le score LLM

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.
This commit is contained in:
Manus
2026-02-13 03:39:41 -05:00
parent 96f6367094
commit d32359fc10
14 changed files with 1903 additions and 30 deletions

View File

@@ -9,6 +9,70 @@ import { Switch } from "@/components/ui/switch";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { Loader2, Save, CheckCircle } from "lucide-react"; import { Loader2, Save, CheckCircle } from "lucide-react";
import { toast } from "sonner"; 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 (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
</div>
);
}
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
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.
</p>
<Table>
<TableHeader>
<TableRow>
<TableHead>Champ</TableHead>
<TableHead className="text-center">Obligatoire</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{fields?.map((field) => (
<TableRow key={field.id}>
<TableCell className="font-medium">{field.displayName}</TableCell>
<TableCell className="text-center">
<Checkbox
checked={field.isRequired === 1}
onCheckedChange={() => handleToggle(field.fieldName, field.isRequired)}
disabled={updateFieldMutation.isPending}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
export default function Settings() { export default function Settings() {
const { data: settings, isLoading } = trpc.settings.get.useQuery(); const { data: settings, isLoading } = trpc.settings.get.useQuery();
@@ -145,6 +209,19 @@ export default function Settings() {
</CardContent> </CardContent>
</Card> </Card>
{/* LLM Fields Configuration */}
<Card>
<CardHeader>
<CardTitle>Champs de détection</CardTitle>
<CardDescription>
Configurez quels champs sont obligatoires pour atteindre un score de reconnaissance de 100%
</CardDescription>
</CardHeader>
<CardContent>
<LlmFieldsConfigSection />
</CardContent>
</Card>
{/* Keywords Configuration */} {/* Keywords Configuration */}
<Card> <Card>
<CardHeader> <CardHeader>

View File

@@ -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`)
);

File diff suppressed because it is too large Load Diff

View File

@@ -78,6 +78,13 @@
"when": 1770831408967, "when": 1770831408967,
"tag": "0010_late_thor_girl", "tag": "0010_late_thor_girl",
"breakpoints": true "breakpoints": true
},
{
"idx": 11,
"version": "5",
"when": 1770971673626,
"tag": "0011_woozy_sabretooth",
"breakpoints": true
} }
] ]
} }

View File

@@ -283,3 +283,22 @@ export const automationRules = mysqlTable("automationRules", {
export type AutomationRule = typeof automationRules.$inferSelect; export type AutomationRule = typeof automationRules.$inferSelect;
export type InsertAutomationRule = typeof automationRules.$inferInsert; 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;

View File

@@ -76,6 +76,9 @@
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pdf-parse": "^2.4.5",
"pdf2json": "^4.0.2",
"pdfjs-dist": "^5.4.624",
"react": "^19.2.1", "react": "^19.2.1",
"react-day-picker": "^9.11.1", "react-day-picker": "^9.11.1",
"react-dom": "^19.2.1", "react-dom": "^19.2.1",

148
pnpm-lock.yaml generated
View File

@@ -205,6 +205,15 @@ importers:
pdf-lib: pdf-lib:
specifier: ^1.17.1 specifier: ^1.17.1
version: 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: react:
specifier: ^19.2.1 specifier: ^19.2.1
version: 19.2.1 version: 19.2.1
@@ -1111,54 +1120,108 @@ packages:
'@mermaid-js/parser@0.6.3': '@mermaid-js/parser@0.6.3':
resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==} 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': '@napi-rs/canvas-android-arm64@0.1.88':
resolution: {integrity: sha512-KEaClPnZuVxJ8smUWjV1wWFkByBO/D+vy4lN+Dm5DFH514oqwukxKGeck9xcKJhaWJGjfruGmYGiwRe//+/zQQ==} resolution: {integrity: sha512-KEaClPnZuVxJ8smUWjV1wWFkByBO/D+vy4lN+Dm5DFH514oqwukxKGeck9xcKJhaWJGjfruGmYGiwRe//+/zQQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [android] 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': '@napi-rs/canvas-darwin-arm64@0.1.88':
resolution: {integrity: sha512-Xgywz0dDxOKSgx3eZnK85WgGMmGrQEW7ZLA/E7raZdlEE+xXCozobgqz2ZvYigpB6DJFYkqnwHjqCOTSDGlFdg==} resolution: {integrity: sha512-Xgywz0dDxOKSgx3eZnK85WgGMmGrQEW7ZLA/E7raZdlEE+xXCozobgqz2ZvYigpB6DJFYkqnwHjqCOTSDGlFdg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [darwin] 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': '@napi-rs/canvas-darwin-x64@0.1.88':
resolution: {integrity: sha512-Yz4wSCIQOUgNucgk+8NFtQxQxZV5NO8VKRl9ePKE6XoNyNVC8JDqtvhh3b3TPqKK8W5p2EQpAr1rjjm0mfBxdg==} resolution: {integrity: sha512-Yz4wSCIQOUgNucgk+8NFtQxQxZV5NO8VKRl9ePKE6XoNyNVC8JDqtvhh3b3TPqKK8W5p2EQpAr1rjjm0mfBxdg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [darwin] 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': '@napi-rs/canvas-linux-arm-gnueabihf@0.1.88':
resolution: {integrity: sha512-9gQM2SlTo76hYhxHi2XxWTAqpTOb+JtxMPEIr+H5nAhHhyEtNmTSDRtz93SP7mGd2G3Ojf2oF5tP9OdgtgXyKg==} resolution: {integrity: sha512-9gQM2SlTo76hYhxHi2XxWTAqpTOb+JtxMPEIr+H5nAhHhyEtNmTSDRtz93SP7mGd2G3Ojf2oF5tP9OdgtgXyKg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm] cpu: [arm]
os: [linux] 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': '@napi-rs/canvas-linux-arm64-gnu@0.1.88':
resolution: {integrity: sha512-7qgaOBMXuVRk9Fzztzr3BchQKXDxGbY+nwsovD3I/Sx81e+sX0ReEDYHTItNb0Je4NHbAl7D0MKyd4SvUc04sg==} resolution: {integrity: sha512-7qgaOBMXuVRk9Fzztzr3BchQKXDxGbY+nwsovD3I/Sx81e+sX0ReEDYHTItNb0Je4NHbAl7D0MKyd4SvUc04sg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] 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': '@napi-rs/canvas-linux-arm64-musl@0.1.88':
resolution: {integrity: sha512-kYyNrUsHLkoGHBc77u4Unh067GrfiCUMbGHC2+OTxbeWfZkPt2o32UOQkhnSswKd9Fko/wSqqGkY956bIUzruA==} resolution: {integrity: sha512-kYyNrUsHLkoGHBc77u4Unh067GrfiCUMbGHC2+OTxbeWfZkPt2o32UOQkhnSswKd9Fko/wSqqGkY956bIUzruA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] 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': '@napi-rs/canvas-linux-riscv64-gnu@0.1.88':
resolution: {integrity: sha512-HVuH7QgzB0yavYdNZDRyAsn/ejoXB0hn8twwFnOqUbCCdkV+REna7RXjSR7+PdfW0qMQ2YYWsLvVBT5iL/mGpw==} resolution: {integrity: sha512-HVuH7QgzB0yavYdNZDRyAsn/ejoXB0hn8twwFnOqUbCCdkV+REna7RXjSR7+PdfW0qMQ2YYWsLvVBT5iL/mGpw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [riscv64] cpu: [riscv64]
os: [linux] 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': '@napi-rs/canvas-linux-x64-gnu@0.1.88':
resolution: {integrity: sha512-hvcvKIcPEQrvvJtJnwD35B3qk6umFJ8dFIr8bSymfrSMem0EQsfn1ztys8ETIFndTwdNWJKWluvxztA41ivsEw==} resolution: {integrity: sha512-hvcvKIcPEQrvvJtJnwD35B3qk6umFJ8dFIr8bSymfrSMem0EQsfn1ztys8ETIFndTwdNWJKWluvxztA41ivsEw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] 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': '@napi-rs/canvas-linux-x64-musl@0.1.88':
resolution: {integrity: sha512-eSMpGYY2xnZSQ6UxYJ6plDboxq4KeJ4zT5HaVkUnbObNN6DlbJe0Mclh3wifAmquXfrlgTZt6zhHsUgz++AK6g==} resolution: {integrity: sha512-eSMpGYY2xnZSQ6UxYJ6plDboxq4KeJ4zT5HaVkUnbObNN6DlbJe0Mclh3wifAmquXfrlgTZt6zhHsUgz++AK6g==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
@@ -1171,12 +1234,22 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [win32] 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': '@napi-rs/canvas-win32-x64-msvc@0.1.88':
resolution: {integrity: sha512-ROVqbfS4QyZxYkqmaIBBpbz/BQvAR+05FXM5PAtTYVc0uyY8Y4BHJSMdGAaMf6TdIVRsQsiq+FG/dH9XhvWCFQ==} resolution: {integrity: sha512-ROVqbfS4QyZxYkqmaIBBpbz/BQvAR+05FXM5PAtTYVc0uyY8Y4BHJSMdGAaMf6TdIVRsQsiq+FG/dH9XhvWCFQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@napi-rs/canvas@0.1.80':
resolution: {integrity: sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==}
engines: {node: '>= 10'}
'@napi-rs/canvas@0.1.88': '@napi-rs/canvas@0.1.88':
resolution: {integrity: sha512-/p08f93LEbsL5mDZFQ3DBxcPv/I4QG9EDYRRq1WNlCOXVfAHBTHMSVMwxlqG/AtnSfUr9+vgfN7MKiyDo0+Weg==} resolution: {integrity: sha512-/p08f93LEbsL5mDZFQ3DBxcPv/I4QG9EDYRRq1WNlCOXVfAHBTHMSVMwxlqG/AtnSfUr9+vgfN7MKiyDo0+Weg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
@@ -3951,6 +4024,9 @@ packages:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true hasBin: true
node-readable-to-web-readable-stream@0.4.2:
resolution: {integrity: sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==}
node-releases@2.0.23: node-releases@2.0.23:
resolution: {integrity: sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==} resolution: {integrity: sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==}
@@ -4018,10 +4094,24 @@ packages:
pdf-lib@1.17.1: pdf-lib@1.17.1:
resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==} 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: pdfjs-dist@5.4.296:
resolution: {integrity: sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==} resolution: {integrity: sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==}
engines: {node: '>=20.16.0 || >=22.3.0'} 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: peberminta@0.9.0:
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
@@ -5718,39 +5808,82 @@ snapshots:
dependencies: dependencies:
langium: 3.3.1 langium: 3.3.1
'@napi-rs/canvas-android-arm64@0.1.80':
optional: true
'@napi-rs/canvas-android-arm64@0.1.88': '@napi-rs/canvas-android-arm64@0.1.88':
optional: true optional: true
'@napi-rs/canvas-darwin-arm64@0.1.80':
optional: true
'@napi-rs/canvas-darwin-arm64@0.1.88': '@napi-rs/canvas-darwin-arm64@0.1.88':
optional: true optional: true
'@napi-rs/canvas-darwin-x64@0.1.80':
optional: true
'@napi-rs/canvas-darwin-x64@0.1.88': '@napi-rs/canvas-darwin-x64@0.1.88':
optional: true optional: true
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.80':
optional: true
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.88': '@napi-rs/canvas-linux-arm-gnueabihf@0.1.88':
optional: true optional: true
'@napi-rs/canvas-linux-arm64-gnu@0.1.80':
optional: true
'@napi-rs/canvas-linux-arm64-gnu@0.1.88': '@napi-rs/canvas-linux-arm64-gnu@0.1.88':
optional: true optional: true
'@napi-rs/canvas-linux-arm64-musl@0.1.80':
optional: true
'@napi-rs/canvas-linux-arm64-musl@0.1.88': '@napi-rs/canvas-linux-arm64-musl@0.1.88':
optional: true optional: true
'@napi-rs/canvas-linux-riscv64-gnu@0.1.80':
optional: true
'@napi-rs/canvas-linux-riscv64-gnu@0.1.88': '@napi-rs/canvas-linux-riscv64-gnu@0.1.88':
optional: true optional: true
'@napi-rs/canvas-linux-x64-gnu@0.1.80':
optional: true
'@napi-rs/canvas-linux-x64-gnu@0.1.88': '@napi-rs/canvas-linux-x64-gnu@0.1.88':
optional: true optional: true
'@napi-rs/canvas-linux-x64-musl@0.1.80':
optional: true
'@napi-rs/canvas-linux-x64-musl@0.1.88': '@napi-rs/canvas-linux-x64-musl@0.1.88':
optional: true optional: true
'@napi-rs/canvas-win32-arm64-msvc@0.1.88': '@napi-rs/canvas-win32-arm64-msvc@0.1.88':
optional: true optional: true
'@napi-rs/canvas-win32-x64-msvc@0.1.80':
optional: true
'@napi-rs/canvas-win32-x64-msvc@0.1.88': '@napi-rs/canvas-win32-x64-msvc@0.1.88':
optional: true 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': '@napi-rs/canvas@0.1.88':
optionalDependencies: optionalDependencies:
'@napi-rs/canvas-android-arm64': 0.1.88 '@napi-rs/canvas-android-arm64': 0.1.88
@@ -8976,6 +9109,9 @@ snapshots:
node-gyp-build@4.8.4: {} node-gyp-build@4.8.4: {}
node-readable-to-web-readable-stream@0.4.2:
optional: true
node-releases@2.0.23: {} node-releases@2.0.23: {}
nodemailer@7.0.13: {} nodemailer@7.0.13: {}
@@ -9040,10 +9176,22 @@ snapshots:
pako: 1.0.11 pako: 1.0.11
tslib: 1.14.1 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: pdfjs-dist@5.4.296:
optionalDependencies: optionalDependencies:
'@napi-rs/canvas': 0.1.88 '@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: {} peberminta@0.9.0: {}
picocolors@1.1.1: {} picocolors@1.1.1: {}

View File

@@ -7,4 +7,5 @@ export const ENV = {
isProduction: process.env.NODE_ENV === "production", isProduction: process.env.NODE_ENV === "production",
forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "", forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "",
forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "", forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "",
mistralApiKey: process.env.MISTRAL_API_KEY ?? "",
}; };

View File

@@ -209,17 +209,32 @@ const normalizeToolChoice = (
return toolChoice; return toolChoice;
}; };
const resolveApiUrl = () => const resolveApiUrl = () => {
ENV.forgeApiUrl && ENV.forgeApiUrl.trim().length > 0 // 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` ? `${ENV.forgeApiUrl.replace(/\/$/, "")}/v1/chat/completions`
: "https://forge.manus.im/v1/chat/completions"; : "https://forge.manus.im/v1/chat/completions";
};
const assertApiKey = () => { const assertApiKey = () => {
if (!ENV.forgeApiKey) { if (!ENV.mistralApiKey && !ENV.forgeApiKey) {
throw new Error("OPENAI_API_KEY is not configured"); 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 = ({ const normalizeResponseFormat = ({
responseFormat, responseFormat,
response_format, response_format,
@@ -279,8 +294,13 @@ export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {
response_format, response_format,
} = params; } = 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<string, unknown> = { const payload: Record<string, unknown> = {
model: "gemini-2.5-flash", model,
messages: messages.map(normalizeMessage), messages: messages.map(normalizeMessage),
}; };
@@ -297,9 +317,13 @@ export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {
} }
payload.max_tokens = 32768 payload.max_tokens = 32768
// Only add thinking parameter for Gemini models
if (!(ENV.mistralApiKey && ENV.mistralApiKey.trim().length > 0)) {
payload.thinking = { payload.thinking = {
"budget_tokens": 128 "budget_tokens": 128
} }
}
const normalizedResponseFormat = normalizeResponseFormat({ const normalizedResponseFormat = normalizeResponseFormat({
responseFormat, responseFormat,
@@ -316,7 +340,7 @@ export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {
method: "POST", method: "POST",
headers: { headers: {
"content-type": "application/json", "content-type": "application/json",
authorization: `Bearer ${ENV.forgeApiKey}`, authorization: `Bearer ${getApiKey()}`,
}, },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });

View File

@@ -29,7 +29,10 @@ import {
AccountingAllocation, AccountingAllocation,
automationRules, automationRules,
InsertAutomationRule, InsertAutomationRule,
AutomationRule AutomationRule,
llmFieldsConfig,
InsertLlmFieldConfig,
LlmFieldConfig
} from "../drizzle/schema"; } from "../drizzle/schema";
import { ENV } from './_core/env'; import { ENV } from './_core/env';
@@ -606,3 +609,61 @@ export async function initializeDefaultLists(userId: number): Promise<void> {
} }
} }
} }
// ============= LLM FIELDS CONFIG OPERATIONS =============
export async function getLlmFieldsConfigByUser(userId: number): Promise<LlmFieldConfig[]> {
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<LlmFieldConfig> {
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<void> {
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
}
}
}
}

View File

@@ -1,6 +1,7 @@
import { invokeLLM } from "./_core/llm"; import { invokeLLM } 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";
export interface ExtractedInvoiceData { export interface ExtractedInvoiceData {
supplierName: string | null; supplierName: string | null;
@@ -22,19 +23,55 @@ 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 { async function extractTextFromPdf(pdfBuffer: Buffer): Promise<string> {
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 { try {
const base64Pdf = pdfBuffer.toString("base64"); let text = "";
const dataUri = `data:application/pdf;base64,${base64Pdf}`;
console.log("[Mistral] PDF converted to base64, size:", Math.round(base64Pdf.length / 1024), "KB"); // Extract text from all pages
return dataUri; if (pdfData.Pages) {
} catch (error) { for (const page of pdfData.Pages) {
console.error("Error converting PDF to base64:", error); if (page.Texts) {
throw new Error("Failed to convert PDF to base64"); 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);
});
}
/** /**
* Clean JSON response from LLM by removing markdown code blocks * Clean JSON response from LLM by removing markdown code blocks
@@ -106,13 +143,30 @@ export async function extractInvoicesWithMistral(
subscription?: string | null; subscription?: string | null;
} }
): Promise<MultiInvoiceResult> { ): Promise<MultiInvoiceResult> {
// 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(); const startTime = Date.now();
try { try {
console.log("[Mistral] Starting invoice extraction..."); console.log("[Mistral] Starting invoice extraction...");
// Convert PDF to base64 // Extract text from PDF
const pdfDataUri = convertPdfToBase64(pdfBuffer); const pdfText = await extractTextFromPdf(pdfBuffer);
// Get PDF page count // Get PDF page count
const pdfDoc = await PDFDocument.load(pdfBuffer); 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) - orderNumber: Numéro de commande client (si présent)
- totalAmount: Montant total TTC (nombre décimal) - totalAmount: Montant total TTC (nombre décimal)
- pageRange: Plage de pages de cette facture (ex: "1-2" ou "5") - 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) - extractedText: Texte complet extrait de la facture (tout le texte visible sur les pages de cette facture)
${subscriptionInstruction}${keywordsHint} ${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.`; 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({ const response = await invokeLLM({
messages: [ messages: [
{ {
role: "user", role: "user",
content: [ content: fullPrompt,
{ type: "text", text: prompt },
{ type: "file_url", file_url: { url: pdfDataUri, mime_type: "application/pdf" } },
],
}, },
], ],
}); });

View File

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

View File

@@ -48,6 +48,9 @@ import {
createAccountingAllocation, createAccountingAllocation,
deleteAccountingAllocation, deleteAccountingAllocation,
initializeDefaultLists, initializeDefaultLists,
getLlmFieldsConfigByUser,
upsertLlmFieldConfig,
initializeDefaultLlmFields,
getAutomationRulesByUser, getAutomationRulesByUser,
getAutomationRuleById, getAutomationRuleById,
createAutomationRule, createAutomationRule,
@@ -93,7 +96,7 @@ export const appRouter = router({
// Set auth cookie // Set auth cookie
ctx.res.cookie("auth_token", result.token, { ctx.res.cookie("auth_token", result.token, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === "production", secure: false, // Désactivé pour VPS sans HTTPS
sameSite: "lax", sameSite: "lax",
path: "/", path: "/",
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
@@ -304,9 +307,11 @@ export const appRouter = router({
} catch (error: any) { } catch (error: any) {
console.error("[Upload] Extraction failed:", error); 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, { await updateSourceFile(sourceFile.id, {
processingStatus: "error", processingStatus: "error",
processingProgress: `Erreur: ${error.message}`, processingProgress: `Erreur: ${errorMsg}`,
}); });
} }
})(); })();
@@ -737,6 +742,15 @@ export const appRouter = router({
await initializeDefaultLists(ctx.user.id); 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({ return await createDepartment({
userId: ctx.user.id, userId: ctx.user.id,
name: input.name, name: input.name,
@@ -770,6 +784,15 @@ export const appRouter = router({
await initializeDefaultLists(ctx.user.id); 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({ return await createAccountingAllocation({
userId: ctx.user.id, userId: ctx.user.id,
name: input.name, 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; export type AppRouter = typeof appRouter;

43
todo.md
View File

@@ -341,3 +341,46 @@
- [x] Intégrer l'appel automatique après activation d'une règle - [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] Ajouter une notification avec le nombre de factures mises à jour
- [x] Tester avec différents scénarios (création, modification, activation) - [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