DocuForge : rendre le pipeline fiable
Après le bras robotisé : des instructions qui disparaissent, des tags un peu trop enthousiastes, et le travail moins spectaculaire pour fiabiliser un pipeline documentaire IA local.
Dans le précédent article sur DocuForge, je racontais le passage d’un orchestrateur sur RTX 4090, avec un bras robotisé qui appuyait sur le bouton du PC, à un pipeline plus léger pour paperless-ngx.
L’idée était de séparer les tâches : un petit modèle pour l’OCR, un modèle plus lourd pour la classification, et une fenêtre horaire configurable pour que le second laisse mon MacBook tranquille pendant la journée.
Depuis, le travail a été moins photogénique. Garder les instructions de classification dans le contexte du modèle. Éviter que les tags se multiplient. Vérifier qu’un appel API qui échoue ne fait pas discrètement disparaître un document de la file d’attente.
Le bras robotisé était plus facile à raconter au dîner.
L’architecture est restée petite
DocuForge reçoit toujours un webhook quand paperless-ngx ajoute un document. Il exécute l’OCR, renvoie le texte dans paperless, puis met le document en attente pour l’enrichissement des métadonnées :
paperless-ngx
→ webhook
→ OCR
→ texte enregistré dans paperless
→ file de tagging SQLite
→ classification planifiée
→ métadonnées mises à jour dans paperless
Les deux étapes ont toujours des paramètres de modèle séparés. Le précédent article parlait de GLM-OCR et de Qwen 3.5 9B, mais aucun des deux n’est imposé par le pipeline. Il peut aussi utiliser le texte OCR déjà extrait par paperless-ngx et se passer entièrement du modèle de vision.
J’ai gardé cette souplesse parce que ce qui compte, c’est le workflow. Il n’y a pas de raison de refaire l’OCR de chaque document si le texte fourni par paperless est déjà suffisamment bon.
Les changements les plus intéressants concernent ce qui se passe quand ce workflow reçoit une entrée un peu pénible.
Du JSON valide, qui ne contient absolument rien
Un problème particulièrement agaçant est apparu sur les documents longs. Le modèle renvoyait du JSON valide, mais tous les champs utiles étaient vides.
La requête aboutissait. La réponse était parsée. Il n’y avait simplement rien d’intéressant à appliquer au document.
La cause était le budget d’entrée. DocuForge assemblait d’abord les instructions de classification, puis ajoutait jusqu’à 64 000 caractères de texte documentaire. Avec un contexte configuré à 8 192 tokens, c’était beaucoup trop pour la requête concernée.
Le log Ollama consigné lors du problème montrait une entrée ramenée d’environ 18 000 tokens à 4 100. Dans ce cas, c’était la fin du prompt qui était conservée. Le texte du document survivait ; les instructions au début, non.
J’avais soigneusement expliqué le schéma JSON attendu, les règles de nommage et la façon de classer le document, puis supprimé cette explication avant que le modèle puisse s’en servir.
Pas mon meilleur moment de prompt engineering.
La correction est assez simple : garder les instructions entières et donner au texte du document son propre budget configurable. La valeur par défaut est maintenant de 8 000 caractères. Chaque troncature est loggée, et chaque réponse de tagging indique le nombre de tokens du prompt évalués par Ollama.
Cette limite en caractères est un réglage pratique, pas un calcul exact en tokens. Les instructions et les noms de métadonnées injectés occupent aussi de la place. Un vocabulaire plus fourni ou un autre modèle peuvent changer ce qui tient dans le contexte.
Il y a aussi un compromis : au-delà de la limite, la classification ne voit que le début du document. Si l’information importante se trouve sur la dernière page, elle peut passer à côté. DocuForge ne découpe pas encore les documents longs en morceaux pour combiner leurs résultats.
Pour l’instant, je préfère une limite visible et compréhensible. Ça me donne un paramètre concret à ajuster, plutôt qu’une réponse vide et le soupçon assez vague que le modèle est de mauvaise humeur.
Un bulletin de paie n’a pas besoin de douze tags
Obtenir une réponse ne règle que la moitié du problème. Il faut aussi que cette réponse corresponde au système de classement.
Le prompt fourni avec le projet est devenu assez précis sur ce point. Un bulletin de paie doit être de type Bulletin de paie, avec le tag Salaire. Il n’a pas besoin d’un petit nuage de concepts associés : emploi, finance, rémunération, droit du travail.
Tous ces mots peuvent être pertinents. Ce ne sont pas forcément des étiquettes utiles pour retrouver un bulletin plus tard.
DocuForge peut injecter dans le prompt les tags, types de documents et correspondants existants dans paperless. Un détail important était de conserver leur orthographe d’origine. Les tables internes normalisent les noms pour les comparer, mais ces clés normalisées sont de mauvais exemples à donner au modèle : des noms en minuscules et sans accents ont tendance à revenir en minuscules et sans accents.
Le prompt reçoit maintenant les noms d’origine. La normalisation reste dans le code de recherche, là où elle est utile.
Quelques réglages permettent aussi de contrôler les nouveaux tags :
- des correspondances explicites entre synonymes, comme
Paie:Salaire; - une option pour refuser les tags inconnus ;
- une comparaison approximative optionnelle avec les tags existants ;
- un tag de vérification optionnel quand un nouveau tag de classification est créé.
La comparaison approximative est désactivée par défaut. Deux noms qui se ressemblent, c’est une indication utile, mais ça ne suffit pas toujours à décider qu’ils veulent dire la même chose.
Le marqueur de vérification est exclu des tags montrés au modèle. Sinon, un tag qui signifie « à vérifier plus tard » pourrait devenir une catégorie de plus que le modèle applique gentiment à tout.
L’automatisation doit réduire le classement manuel. Elle ne doit pas me donner un deuxième système de classement à entretenir.
Quel service a renvoyé le 404 ?
La file de tagging repose sur SQLite. Les identifiants des documents en attente survivent au redémarrage du container, et l’identifiant est la clé primaire : remettre le même document en attente ne crée pas une deuxième ligne.
Mais la persistance ne sert à rien si l’application prend la mauvaise décision au moment de supprimer une ligne.
Une mise à jour réussie des métadonnées retire le document de la file. Un document supprimé de paperless doit aussi être retiré : il n’y a plus rien à traiter.
Le cas subtil, c’est le 404.
Si paperless renvoie 404 pendant la récupération du document, il n’existe plus. Si Ollama renvoie 404 parce que le modèle demandé est absent, le document est toujours là. Traiter les deux erreurs comme « le document n’existe plus » ferait disparaître silencieusement un travail qui devrait être retenté.
Le code distingue maintenant explicitement ces cas. Seul un document introuvable lors de sa récupération dans paperless produit l’exception qui autorise son retrait. Les autres échecs gardent le document en attente et incrémentent son compteur d’erreurs. Une mise à jour de métadonnées refusée est aussi un échec, même si l’inférence a réussi.
Le seuil d’échecs configuré produit un avertissement pour intervention manuelle. Il ne supprime pas le document de la file et n’arrête pas les tentatives suivantes.
Ce n’est pas un système de retry compliqué. C’est suffisant pour qu’une panne temporaire d’une dépendance reste une panne temporaire.
Modifier un champ sans perdre les autres
DocuForge utilise un champ booléen personnalisé, AI Processed, pour marquer les documents traités. Le chemin webhook le vérifie avant de lancer l’OCR, ce qui permet d’ignorer un document déjà terminé.
L’ajout de ce marqueur a fait ressortir un autre détail classique d’API : la liste custom_fields envoyée dans une mise à jour remplace la liste existante.
Envoyer uniquement le marqueur IA supprimerait donc les autres champs personnalisés du document. La mise à jour conserve maintenant ces champs et ajoute ou remplace uniquement celui de DocuForge.
Les tags existants ont leur propre réglage. DocuForge peut fusionner ses suggestions avec eux, mais le remplacement reste le comportement par défaut. Le choix compte si certains tags viennent du classement manuel ou d’un autre workflow paperless.
Le modèle peut proposer des métadonnées. L’application doit quand même comprendre les conséquences de leur application.
Les imports et les redémarrages font partie du workflow
Les imports par lots demandaient aussi un peu de retenue. L’OCR déclenché par webhook passe maintenant par un pool de workers dédié, avec un seul worker par défaut. Plusieurs documents peuvent attendre leur tour plutôt que de tous lancer une inférence de vision en même temps.
Les requêtes OCR ont des timeouts configurables et un nombre limité de tentatives en cas d’erreur de connexion ou de timeout. Lors d’un arrêt normal, le service attend la fin des tâches OCR et de tagging soumises avant de fermer les ressources partagées.
Il reste une limite : la file SQLite persiste l’étape de tagging, alors que les tâches OCR des webhooks en attente vivent en mémoire. L’arrêt propre aide pour les redémarrages ordinaires ; il ne protège pas chaque tâche OCR acceptée contre un arrêt brutal du processus ou une coupure de courant.
Ça mérite d’être dit clairement. Une file de tagging persistante est utile, mais elle ne garantit pas que tout le pipeline récupère tout le travail après n’importe quelle interruption.
Tester les pannes sans intérêt
Le projet dispose maintenant de tests de régression sur ces comportements : préserver les champs personnalisés, conserver le tagging en échec dans la file, distinguer les deux sortes de 404, garder les instructions du prompt et attendre les workers à l’arrêt.
Ils utilisent des substituts contrôlés pour paperless et Ollama, avec la vraie logique de file d’attente là où ça compte. Je peux vérifier qu’une requête qui échoue laisse un document en attente sans démarrer de modèle ni attendre minuit.
Ces tests ne disent pas si un modèle classera correctement chaque facture. Ça demande toujours de vrais documents et une inspection des résultats. Ils permettent de vérifier que le code autour se comporte correctement quand la classification ou un appel API échoue.
DocuForge reste mon premier vrai projet Python. Une bonne partie de ce travail ressemble à ce que je connais côté backend : savoir ce qu’une mise à jour remplace, garder une trace du travail inachevé et rendre les erreurs visibles.
Il reste des choses à améliorer, notamment sur les documents longs et la reprise avant la file de tagging. Mais l’architecture est restée reconnaissable, et les derniers changements rendent ses limites plus faciles à voir.
Le bras robotisé reste la meilleure anecdote. La file qui conserve un document après une requête ratée, c’est la partie sur laquelle je préfère compter.