Checkpoint: Apprentissage déclenché depuis les selects serviceConcerne/typeAchat/ventilation dans la liste Factures BAP. Page Apprentissages IA refaite avec indicateur de confiance (Confirmé/En observation) et seuil configurable.

This commit is contained in:
Manus
2026-04-12 15:11:24 -04:00
parent 3ae1e47ca6
commit e80f899498
2 changed files with 223 additions and 78 deletions

View File

@@ -158,6 +158,8 @@ export default function InvoicesBAP() {
}, },
}); });
const upsertLearningMutation = trpc.learnings.upsert.useMutation();
const reprocessMutation = trpc.invoices.reprocessSelected.useMutation({ const reprocessMutation = trpc.invoices.reprocessSelected.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
if (data.errors > 0) { if (data.errors > 0) {
@@ -624,10 +626,20 @@ export default function InvoicesBAP() {
// Remove field from autoFilledFields when manually edited // Remove field from autoFilledFields when manually edited
const autoFilledFields = invoice.autoFilledFields ? JSON.parse(invoice.autoFilledFields) : []; const autoFilledFields = invoice.autoFilledFields ? JSON.parse(invoice.autoFilledFields) : [];
const updatedAutoFields = autoFilledFields.filter((f: string) => f !== "serviceConcerne"); const updatedAutoFields = autoFilledFields.filter((f: string) => f !== "serviceConcerne");
const newVal = e.target.value || undefined;
// Apprentissage : mémoriser la correction manuelle
if (invoice.supplierName && newVal !== (invoice.serviceConcerne || undefined)) {
upsertLearningMutation.mutate({
supplierName: invoice.supplierName,
fieldName: 'serviceConcerne',
originalValue: invoice.serviceConcerne || undefined,
correctedValue: newVal || '',
});
}
updateFieldMutation.mutate({ updateFieldMutation.mutate({
id: invoice.id, id: invoice.id,
data: { data: {
serviceConcerne: e.target.value || undefined, serviceConcerne: newVal,
autoFilledFields: updatedAutoFields.length > 0 ? JSON.stringify(updatedAutoFields) : null autoFilledFields: updatedAutoFields.length > 0 ? JSON.stringify(updatedAutoFields) : null
}, },
}); });
@@ -649,10 +661,20 @@ export default function InvoicesBAP() {
// Remove field from autoFilledFields when manually edited // Remove field from autoFilledFields when manually edited
const autoFilledFields = invoice.autoFilledFields ? JSON.parse(invoice.autoFilledFields) : []; const autoFilledFields = invoice.autoFilledFields ? JSON.parse(invoice.autoFilledFields) : [];
const updatedAutoFields = autoFilledFields.filter((f: string) => f !== "typeAchat"); const updatedAutoFields = autoFilledFields.filter((f: string) => f !== "typeAchat");
const newTypeAchat = e.target.value as "CAPEX" | "OPEX" | undefined;
// Apprentissage : mémoriser la correction manuelle
if (invoice.supplierName && newTypeAchat !== (invoice.typeAchat || undefined)) {
upsertLearningMutation.mutate({
supplierName: invoice.supplierName,
fieldName: 'typeAchat',
originalValue: invoice.typeAchat || undefined,
correctedValue: newTypeAchat || '',
});
}
updateFieldMutation.mutate({ updateFieldMutation.mutate({
id: invoice.id, id: invoice.id,
data: { data: {
typeAchat: e.target.value as "CAPEX" | "OPEX" | undefined, typeAchat: newTypeAchat,
autoFilledFields: updatedAutoFields.length > 0 ? JSON.stringify(updatedAutoFields) : null autoFilledFields: updatedAutoFields.length > 0 ? JSON.stringify(updatedAutoFields) : null
}, },
}); });
@@ -677,10 +699,20 @@ export default function InvoicesBAP() {
// Remove field from autoFilledFields when manually edited // Remove field from autoFilledFields when manually edited
const autoFilledFields = invoice.autoFilledFields ? JSON.parse(invoice.autoFilledFields) : []; const autoFilledFields = invoice.autoFilledFields ? JSON.parse(invoice.autoFilledFields) : [];
const updatedAutoFields = autoFilledFields.filter((f: string) => f !== "ventilationComptable"); const updatedAutoFields = autoFilledFields.filter((f: string) => f !== "ventilationComptable");
const newVentil = e.target.value || undefined;
// Apprentissage : mémoriser la correction manuelle
if (invoice.supplierName && newVentil !== (invoice.ventilationComptable || undefined)) {
upsertLearningMutation.mutate({
supplierName: invoice.supplierName,
fieldName: 'ventilationComptable',
originalValue: invoice.ventilationComptable || undefined,
correctedValue: newVentil || '',
});
}
updateFieldMutation.mutate({ updateFieldMutation.mutate({
id: invoice.id, id: invoice.id,
data: { data: {
ventilationComptable: e.target.value || undefined, ventilationComptable: newVentil,
autoFilledFields: updatedAutoFields.length > 0 ? JSON.stringify(updatedAutoFields) : null autoFilledFields: updatedAutoFields.length > 0 ? JSON.stringify(updatedAutoFields) : null
}, },
}); });

View File

@@ -3,9 +3,11 @@ import DashboardLayout from "@/components/DashboardLayout";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { toast } from "sonner"; import { toast } from "sonner";
import { Brain, Trash2, RefreshCw, AlertTriangle } from "lucide-react"; import { Brain, Trash2, RefreshCw, CheckCircle2, Eye, Settings2 } from "lucide-react";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -32,9 +34,14 @@ const FIELD_COLORS: Record<string, string> = {
isSubscription: "bg-orange-100 text-orange-800", isSubscription: "bg-orange-100 text-orange-800",
}; };
const DEFAULT_CONFIDENCE_THRESHOLD = 2;
export default function LearningSettings() { export default function LearningSettings() {
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const { data: learnings, isLoading } = trpc.learnings.list.useQuery(); const { data: learnings, isLoading } = trpc.learnings.list.useQuery();
const [confidenceThreshold, setConfidenceThreshold] = useState(DEFAULT_CONFIDENCE_THRESHOLD);
const [editingThreshold, setEditingThreshold] = useState(false);
const [thresholdInput, setThresholdInput] = useState(String(DEFAULT_CONFIDENCE_THRESHOLD));
const deleteMutation = trpc.learnings.delete.useMutation({ const deleteMutation = trpc.learnings.delete.useMutation({
onSuccess: () => { onSuccess: () => {
@@ -62,6 +69,36 @@ export default function LearningSettings() {
const supplierCount = Object.keys(grouped).length; const supplierCount = Object.keys(grouped).length;
const totalCount = learnings?.length || 0; const totalCount = learnings?.length || 0;
const confirmedCount = (learnings || []).filter(l => (l?.applyCount || 0) >= confidenceThreshold).length;
const observationCount = totalCount - confirmedCount;
const getConfidenceBadge = (applyCount: number) => {
if (applyCount >= confidenceThreshold) {
return (
<span className="flex items-center gap-1 text-xs text-emerald-700 bg-emerald-50 border border-emerald-200 rounded-full px-2 py-0.5 font-medium">
<CheckCircle2 className="w-3 h-3" />
Confirmé · {applyCount}×
</span>
);
}
return (
<span className="flex items-center gap-1 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-full px-2 py-0.5 font-medium">
<Eye className="w-3 h-3" />
Observation · {applyCount}×
</span>
);
};
const handleSaveThreshold = () => {
const val = parseInt(thresholdInput, 10);
if (isNaN(val) || val < 1) {
toast.error("Le seuil doit être un entier ≥ 1");
return;
}
setConfidenceThreshold(val);
setEditingThreshold(false);
toast.success(`Seuil de confiance mis à jour : ${val} application(s)`);
};
return ( return (
<DashboardLayout> <DashboardLayout>
@@ -110,7 +147,7 @@ export default function LearningSettings() {
</div> </div>
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="text-3xl font-bold text-indigo-600">{supplierCount}</div> <div className="text-3xl font-bold text-indigo-600">{supplierCount}</div>
@@ -123,8 +160,65 @@ export default function LearningSettings() {
<div className="text-sm text-muted-foreground mt-1">Correction(s) mémorisée(s)</div> <div className="text-sm text-muted-foreground mt-1">Correction(s) mémorisée(s)</div>
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardContent className="pt-6">
<div className="text-3xl font-bold text-emerald-600">{confirmedCount}</div>
<div className="text-sm text-muted-foreground mt-1">Confirmée(s) ( {confidenceThreshold}×)</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-3xl font-bold text-amber-500">{observationCount}</div>
<div className="text-sm text-muted-foreground mt-1">En observation (&lt; {confidenceThreshold}×)</div>
</CardContent>
</Card>
</div> </div>
{/* Seuil de confiance */}
<Card className="border-indigo-200">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Settings2 className="w-4 h-4 text-indigo-600" />
Seuil de confiance
</CardTitle>
<CardDescription>
Un apprentissage est marqué <strong>Confirmé</strong> quand il a é appliqué au moins{" "}
<strong>{confidenceThreshold} fois</strong>. En dessous, il est <strong>En observation</strong>.
</CardDescription>
</CardHeader>
<CardContent>
{editingThreshold ? (
<div className="flex items-center gap-3">
<Label htmlFor="threshold" className="text-sm whitespace-nowrap">
Nombre minimum d'applications :
</Label>
<Input
id="threshold"
type="number"
min={1}
max={100}
value={thresholdInput}
onChange={(e) => setThresholdInput(e.target.value)}
className="w-24"
/>
<Button size="sm" onClick={handleSaveThreshold}>Appliquer</Button>
<Button size="sm" variant="ghost" onClick={() => { setEditingThreshold(false); setThresholdInput(String(confidenceThreshold)); }}>
Annuler
</Button>
</div>
) : (
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">
Seuil actuel : <strong>{confidenceThreshold} application(s)</strong>
</span>
<Button size="sm" variant="outline" onClick={() => { setEditingThreshold(true); setThresholdInput(String(confidenceThreshold)); }}>
Modifier
</Button>
</div>
)}
</CardContent>
</Card>
{/* Comment ça fonctionne */} {/* Comment ça fonctionne */}
<Card className="border-indigo-200 bg-indigo-50/50"> <Card className="border-indigo-200 bg-indigo-50/50">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
@@ -136,13 +230,17 @@ export default function LearningSettings() {
<CardContent className="text-sm text-muted-foreground space-y-1"> <CardContent className="text-sm text-muted-foreground space-y-1">
<p> <p>
Quand vous modifiez manuellement le <strong>type d'achat</strong>, le <strong>service concerné</strong> ou Quand vous modifiez manuellement le <strong>type d'achat</strong>, le <strong>service concerné</strong> ou
la <strong>ventilation comptable</strong> d'une facture dans sa page de détail, le système mémorise cette la <strong>ventilation comptable</strong> d'une facture (dans le détail ou dans la liste BAP), le système mémorise cette
correction pour ce fournisseur. correction pour ce fournisseur.
</p> </p>
<p> <p>
Lors des prochains imports, si une facture du même fournisseur est détectée, les corrections mémorisées Lors des prochains imports, si une facture du même fournisseur est détectée, les corrections mémorisées
sont appliquées automatiquement après l'extraction IA et les règles d'automatisme. sont appliquées automatiquement après l'extraction IA et les règles d'automatisme.
</p> </p>
<p>
Le compteur <strong>Appliqué N×</strong> indique combien de fois cette correction a é utilisée lors d'imports.
Une correction <strong>Confirmée</strong> a atteint le seuil de confiance configuré.
</p>
</CardContent> </CardContent>
</Card> </Card>
@@ -165,14 +263,26 @@ export default function LearningSettings() {
</Card> </Card>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{Object.entries(grouped).map(([supplierKey, entries]) => ( {Object.entries(grouped).map(([supplierKey, entries]) => {
const supplierConfirmed = (entries || []).filter(l => (l?.applyCount || 0) >= confidenceThreshold).length;
const supplierObservation = (entries?.length || 0) - supplierConfirmed;
return (
<Card key={supplierKey}> <Card key={supplierKey}>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between flex-wrap gap-2">
<CardTitle className="text-base capitalize">{supplierKey}</CardTitle> <CardTitle className="text-base capitalize">{supplierKey}</CardTitle>
<Badge variant="outline" className="text-xs"> <div className="flex items-center gap-2">
{entries?.length} correction(s) {supplierConfirmed > 0 && (
<Badge className="text-xs bg-emerald-100 text-emerald-800 border-emerald-200">
{supplierConfirmed} confirmée(s)
</Badge> </Badge>
)}
{supplierObservation > 0 && (
<Badge className="text-xs bg-amber-100 text-amber-800 border-amber-200">
{supplierObservation} en observation
</Badge>
)}
</div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -180,7 +290,11 @@ export default function LearningSettings() {
{entries?.map((learning) => ( {entries?.map((learning) => (
<div <div
key={learning.id} key={learning.id}
className="flex items-center justify-between p-3 rounded-lg border bg-muted/30" className={`flex items-center justify-between p-3 rounded-lg border ${
(learning.applyCount || 0) >= confidenceThreshold
? "bg-emerald-50/40 border-emerald-200"
: "bg-amber-50/40 border-amber-200"
}`}
> >
<div className="flex items-center gap-3 flex-1 min-w-0"> <div className="flex items-center gap-3 flex-1 min-w-0">
<Badge className={`text-xs shrink-0 ${FIELD_COLORS[learning.fieldName] || "bg-gray-100 text-gray-800"}`}> <Badge className={`text-xs shrink-0 ${FIELD_COLORS[learning.fieldName] || "bg-gray-100 text-gray-800"}`}>
@@ -201,9 +315,7 @@ export default function LearningSettings() {
</div> </div>
</div> </div>
<div className="flex items-center gap-3 shrink-0 ml-3"> <div className="flex items-center gap-3 shrink-0 ml-3">
<span className="text-xs text-muted-foreground"> {getConfidenceBadge(learning.applyCount || 0)}
Appliqué {learning.applyCount}×
</span>
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button <Button
@@ -239,7 +351,8 @@ export default function LearningSettings() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
))} );
})}
</div> </div>
)} )}
</div> </div>