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 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 05:17:49 +02:00
parent 6f2530014f
commit 5f3a26e7d7
8 changed files with 784 additions and 72 deletions

View File

@@ -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 (

View File

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