From 5f3a26e7d7c37ab12e5d9e87f1e43fd027367255 Mon Sep 17 00:00:00 2001 From: Bertrand Benjamin Date: Sun, 26 Jul 2026 05:17:49 +0200 Subject: [PATCH] feat: balayage de re-extraction de toute la base Nouvelle page /re-extraction, accessible depuis la liste des documents : elle rejoue l'extraction de chaque PDF stocke, document par document, avec une progression et une estimation du temps restant (une extraction prend plusieurs secondes) et un bouton d'arret. Rien n'est ecrit en base pendant le balayage. Les documents dont l'extraction change sont listes avec le nombre de differences par section, deja coches ; les inchanges sont replies et les documents sans PDF ou en erreur isoles. Le detail depliable reutilise l'affichage de diff de la re-extraction unitaire (extrait dans ExtractionDiffDetails). Un seul bouton enregistre toute la selection. Les tags de depenses sont reportes par signature d'operation et non par index : une operation ajoutee ou retiree par le nouveau parser ne decale plus les categories. Ce qui n'est pas reapparie recoit une prediction, modifiable dans le detail avant enregistrement. L'edition d'un document et l'application du balayage passent par PUT /api/documents/{id}, qui vise le document par son ID. Co-Authored-By: Claude Opus 5 --- .../src/components/ExtractionDiffDetails.vue | 77 +++ frontend/src/components/ReExtractionDiff.vue | 68 +-- frontend/src/pages/DocumentsPage.vue | 15 + frontend/src/pages/EditDocumentPage.vue | 13 +- frontend/src/pages/ReExtractionPage.vue | 578 ++++++++++++++++++ frontend/src/router.js | 6 + frontend/src/utils/diffExtraction.js | 4 +- frontend/src/utils/tagRemap.js | 95 +++ 8 files changed, 784 insertions(+), 72 deletions(-) create mode 100644 frontend/src/components/ExtractionDiffDetails.vue create mode 100644 frontend/src/pages/ReExtractionPage.vue create mode 100644 frontend/src/utils/tagRemap.js diff --git a/frontend/src/components/ExtractionDiffDetails.vue b/frontend/src/components/ExtractionDiffDetails.vue new file mode 100644 index 0000000..e319a13 --- /dev/null +++ b/frontend/src/components/ExtractionDiffDetails.vue @@ -0,0 +1,77 @@ + + + diff --git a/frontend/src/components/ReExtractionDiff.vue b/frontend/src/components/ReExtractionDiff.vue index 22e0c2c..ee3a451 100644 --- a/frontend/src/components/ReExtractionDiff.vue +++ b/frontend/src/components/ReExtractionDiff.vue @@ -47,65 +47,15 @@ -
- -
-
Métadonnées
-
-
- {{ c.label }} - {{ c.before }} - - {{ c.after }} -
-
-
- - -
-
Locataires
-
-
-
- - {{ kindLabel(loc.kind) }} - - {{ loc.title }} -
-
- {{ f.label }} - {{ f.before }} - - {{ f.after }} -
-
-
-
- - -
-
Opérations
-
-
-
- - {{ kindLabel(op.kind) }} - - opération #{{ op.index + 1 }} -
-
-
{{ op.before }}
-
{{ op.after }}
-
-
-
-
+
+
diff --git a/frontend/src/pages/DocumentsPage.vue b/frontend/src/pages/DocumentsPage.vue index cb824ec..8c4190b 100644 --- a/frontend/src/pages/DocumentsPage.vue +++ b/frontend/src/pages/DocumentsPage.vue @@ -10,6 +10,20 @@ {{ documents.length }} document{{ documents.length > 1 ? 's' : '' }} en base

+ +
+ + + + + + Re-extraire la base + +
diff --git a/frontend/src/pages/EditDocumentPage.vue b/frontend/src/pages/EditDocumentPage.vue index f7667b2..1c85d45 100644 --- a/frontend/src/pages/EditDocumentPage.vue +++ b/frontend/src/pages/EditDocumentPage.vue @@ -262,16 +262,17 @@ async function handleSave(depensesTags, shouldOverwrite) { saveError.value = null try { - const response = await fetch('/api/save', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' + // Mise à jour ciblée par ID : si la nouvelle extraction corrige la référence + // ou la date, c'est bien ce document qui est mis à jour, pas un doublon. + const response = await fetch(`/api/documents/${documentId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' }, body: JSON.stringify({ source_file: extractedData.value.source_file, data: extractedData.value.data, - depenses_tags: depensesTags, - overwrite: true // Toujours true en mode édition + depenses_tags: depensesTags }) }) diff --git a/frontend/src/pages/ReExtractionPage.vue b/frontend/src/pages/ReExtractionPage.vue new file mode 100644 index 0000000..8d54453 --- /dev/null +++ b/frontend/src/pages/ReExtractionPage.vue @@ -0,0 +1,578 @@ + + + diff --git a/frontend/src/router.js b/frontend/src/router.js index 073c018..45ed659 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -5,6 +5,7 @@ import AnalyticsPage from './pages/AnalyticsPage.vue' import RevenusPage from './pages/RevenusPage.vue' import DocumentsPage from './pages/DocumentsPage.vue' import EditDocumentPage from './pages/EditDocumentPage.vue' +import ReExtractionPage from './pages/ReExtractionPage.vue' import IAPage from './pages/IAPage.vue' import ConfigPage from './pages/ConfigPage.vue' @@ -48,6 +49,11 @@ const routes = [ path: '/documents/:id/edit', name: 'edit-document', component: EditDocumentPage + }, + { + path: '/re-extraction', + name: 're-extraction', + component: ReExtractionPage } ] diff --git a/frontend/src/utils/diffExtraction.js b/frontend/src/utils/diffExtraction.js index 61d3713..baab06b 100644 --- a/frontend/src/utils/diffExtraction.js +++ b/frontend/src/utils/diffExtraction.js @@ -110,7 +110,9 @@ function locataireTitle(loc) { return `${lot} — ${nom}` } -function opSig(op) { +// Signature d'une opération : sert au diff (appariement par contenu) comme au +// report des tags d'une extraction à l'autre. +export function opSig(op) { if (!op) return '∅' const m = op.montants || {} return ( diff --git a/frontend/src/utils/tagRemap.js b/frontend/src/utils/tagRemap.js new file mode 100644 index 0000000..51e6178 --- /dev/null +++ b/frontend/src/utils/tagRemap.js @@ -0,0 +1,95 @@ +// Report des tags de dépenses d'une extraction sur la suivante. +// +// Les tags sont stockés par *index* d'opération. Une ré-extraction peut ajouter, +// retirer ou réordonner des opérations : reporter les tags par index décalerait +// alors silencieusement les catégories. On les réapparie donc par signature +// d'opération (fournisseur, description, montants), et ce qui ne se réapparie +// pas est signalé plutôt que deviné. + +import { opSig } from './diffExtraction.js' + +/** + * Réapparie les tags de l'ancienne extraction sur la nouvelle. + * + * @param {Array} previousOps opérations de l'extraction en base + * @param {Array} tags tags actuels, [{ index, tag_id, tag_nom }] + * @param {Array} newOps opérations de la nouvelle extraction + * @returns {{ tags: Object, reported: number, missing: number[] }} + * `tags` associe l'index d'une nouvelle opération à { tag_id, tag_nom, origin }, + * `missing` liste les index des nouvelles opérations restées sans tag. + */ +export function remapTags(previousOps, tags, newOps) { + const previous = previousOps || [] + const next = newOps || [] + + // Signature -> file des tags disponibles (plusieurs opérations peuvent + // partager la même signature, on les consomme dans l'ordre). + const available = new Map() + for (const t of tags || []) { + const op = previous[t.index] + if (!op) continue + const sig = opSig(op) + if (!available.has(sig)) available.set(sig, []) + available.get(sig).push(t) + } + + const remapped = {} + const missing = [] + next.forEach((op, i) => { + const queue = available.get(opSig(op)) + const t = queue && queue.length ? queue.shift() : null + if (t) { + remapped[i] = { tag_id: t.tag_id, tag_nom: t.tag_nom, origin: 'reporte' } + } else { + missing.push(i) + } + }) + + return { tags: remapped, reported: Object.keys(remapped).length, missing } +} + +/** + * Complète les opérations sans tag par une prédiction du backend. + * + * Modifie `remapped` en place et retourne le nombre de tags prédits. Une panne + * de prédiction n'est pas bloquante : les opérations restent simplement à taguer. + */ +export async function fillPredictions(remapped, newOps, missing) { + if (!missing.length) return 0 + + try { + const response = await fetch('/api/predict-tags', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ depenses: missing.map((i) => newOps[i]) }), + }) + if (!response.ok) return 0 + + const { predictions } = await response.json() + let predicted = 0 + for (const p of predictions || []) { + if (p.tag_id == null) continue + const opIndex = missing[p.index] + if (opIndex == null) continue + remapped[opIndex] = { + tag_id: p.tag_id, + tag_nom: p.tag_name || null, + origin: 'predit', + confidence: p.confidence, + reason: p.reason, + } + predicted++ + } + return predicted + } catch (err) { + console.error('Prédiction de tags indisponible:', err) + return 0 + } +} + +// Format attendu par l'API de sauvegarde : [{ index, tag_id }]. +export function toDepensesTags(remapped) { + return Object.entries(remapped) + .filter(([, t]) => t && t.tag_id != null) + .map(([index, t]) => ({ index: Number(index), tag_id: t.tag_id })) +}