[{"data":1,"prerenderedAt":537},["ShallowReactive",2],{"seite-\u002Ffr\u002Fopen-source\u002Frechnung-fotografieren\u002F":3},{"id":4,"title":5,"body":6,"description":528,"extension":529,"meta":530,"navigation":531,"path":532,"robots":533,"seo":534,"stem":535,"__hash__":536},"seiten_fr\u002Fopen-source\u002Frechnung-fotografieren.md","Photographier la facture, vérifier les données",{"type":7,"value":8,"toc":517},"minimark",[9,13,24,27,32,35,43,193,208,218,221,225,228,244,247,251,254,261,269,276,290,296,302,306,309,324,331,335,338,341,344,348,351,354,357,368,379,427,430,434,437,448,452,455,458,462,465,488,513],[10,11,5],"h1",{"id":12},"photographier-la-facture-vérifier-les-données",[14,15,16,23],"p",{},[17,18,22],"a",{"href":19,"rel":20},"https:\u002F\u002Fwartungsheft.ch",[21],"nofollow","Wartungsheft"," est une application web pour le carnet\nd’entretien des voitures, construite avec Vue 3 comme PWA installable. Le cœur\nest une photo : on photographie la facture du garage, et l’application en\nextrait le garage, la date, le kilométrage, le montant et les positions. Les\npositions deviennent des entretiens, et à partir des entretiens l’application\ncalcule ce qui est dû ensuite.",[14,25,26],{},"Cela ressemble à un appel d’API. En pratique, c’est une chaîne dont le modèle de\nlangage n’est qu’un maillon. Cet article décrit quels maillons se sont ajoutés\net pourquoi.",[28,29,31],"h2",{"id":30},"deux-étapes-au-lieu-dune","Deux étapes au lieu d’une",[14,33,34],{},"Avec Document Annotation, Mistral propose une voie en une seule étape : l’image\nentre, du JSON structuré sort. Je l’ai intégrée en premier et testée avec neuf\nvraies images de factures. Une sur neuf est revenue correcte. Pour les autres,\nle modèle inventait des montants qui ne figuraient sur aucune facture.",[14,36,37,38,42],{},"La voie qui est restée a deux étapes. D’abord, ",[39,40,41],"code",{},"mistral-ocr-latest"," lit l’image\net renvoie du Markdown, tableaux compris. Ensuite, un modèle de chat reçoit\nuniquement ce texte, pas l’image, et remplit avec lui un schéma. Avec les mêmes\nneuf images, les neuf étaient correctes.",[44,45,50],"pre",{"className":46,"code":47,"language":48,"meta":49,"style":49},"language-ts shiki shiki-themes github-light github-dark","const { object } = await withRetry(() => generateObject({\n  model,\n  maxRetries: 0,\n  temperature: 0,\n  schema,\n  messages: [{\n    role: 'user',\n    content: `${prompt}\\n\\n--- OCR-TEXT DES DOKUMENTS ---\\n${ocrText}`,\n  }],\n}))\n","ts","",[39,51,52,94,100,112,122,128,134,146,181,187],{"__ignoreMap":49},[53,54,57,61,65,69,72,75,78,82,85,88,91],"span",{"class":55,"line":56},"line",1,[53,58,60],{"class":59},"szBVR","const",[53,62,64],{"class":63},"sVt8B"," { ",[53,66,68],{"class":67},"sj4cs","object",[53,70,71],{"class":63}," } ",[53,73,74],{"class":59},"=",[53,76,77],{"class":59}," await",[53,79,81],{"class":80},"sScJk"," withRetry",[53,83,84],{"class":63},"(() ",[53,86,87],{"class":59},"=>",[53,89,90],{"class":80}," generateObject",[53,92,93],{"class":63},"({\n",[53,95,97],{"class":55,"line":96},2,[53,98,99],{"class":63},"  model,\n",[53,101,103,106,109],{"class":55,"line":102},3,[53,104,105],{"class":63},"  maxRetries: ",[53,107,108],{"class":67},"0",[53,110,111],{"class":63},",\n",[53,113,115,118,120],{"class":55,"line":114},4,[53,116,117],{"class":63},"  temperature: ",[53,119,108],{"class":67},[53,121,111],{"class":63},[53,123,125],{"class":55,"line":124},5,[53,126,127],{"class":63},"  schema,\n",[53,129,131],{"class":55,"line":130},6,[53,132,133],{"class":63},"  messages: [{\n",[53,135,137,140,144],{"class":55,"line":136},7,[53,138,139],{"class":63},"    role: ",[53,141,143],{"class":142},"sZZnC","'user'",[53,145,111],{"class":63},[53,147,149,152,155,158,161,164,167,170,173,176,179],{"class":55,"line":148},8,[53,150,151],{"class":63},"    content: ",[53,153,154],{"class":142},"`${",[53,156,157],{"class":63},"prompt",[53,159,160],{"class":142},"}",[53,162,163],{"class":67},"\\n\\n",[53,165,166],{"class":142},"--- OCR-TEXT DES DOKUMENTS ---",[53,168,169],{"class":67},"\\n",[53,171,172],{"class":142},"${",[53,174,175],{"class":63},"ocrText",[53,177,178],{"class":142},"}`",[53,180,111],{"class":63},[53,182,184],{"class":55,"line":183},9,[53,185,186],{"class":63},"  }],\n",[53,188,190],{"class":55,"line":189},10,[53,191,192],{"class":63},"}))\n",[14,194,195,196,199,200,203,204,207],{},"Le schéma est un objet Zod via le Vercel AI SDK. La catégorie d’une position\nest un ",[39,197,198],{},"z.enum"," avec les types d’entretien de l’application. Le modèle ne peut\ndonc pas inventer « Motorservice » là où l’application connaît ",[39,201,202],{},"inspektion",".\n",[39,205,206],{},"temperature: 0"," ne rend pas les réponses correctes, mais reproductibles, et\nc’est la seule façon d’observer une erreur deux fois.",[14,209,210,213,214,217],{},[39,211,212],{},"maxRetries: 0"," est là parce que sinon le SDK répète lui-même deux fois, en plus\ndu ",[39,215,216],{},"withRetry"," maison. Ces répétitions silencieuses épuisaient la limite de\ndébit.",[14,219,220],{},"L’OCR est la partie coûteuse et lente. Son résultat est mis en cache sous le\nSHA-256 de l’image, en mémoire et dans la base de données. Si la même photo est\nanalysée à nouveau, par exemple après une erreur, l’OCR ne la lit pas une\ndeuxième fois.",[28,222,224],{"id":223},"le-prompt-connaît-la-suisse","Le prompt connaît la Suisse",[14,226,227],{},"La plupart des erreurs de la deuxième étape n’étaient pas des erreurs de\nlecture. Le modèle lisait correctement et attribuait mal. Le prompt est donc\nune liste de distinctions qu’une personne de Suisse fait sans réfléchir :",[229,230,231,235,238,241],"ul",{},[232,233,234],"li",{},"« SG 218574 » est une plaque d’immatriculation de Saint-Gall, pas un\nnuméro de châssis. Celui-ci a 17 caractères.",[232,236,237],{},"Si une date de réparation figure sur la facture, c’est elle qui compte, pas\nla date de facturation. Le carnet d’entretien a besoin du jour des travaux.",[232,239,240],{},"« Fr. » et un montant sans devise signifient CHF. EUR seulement si c’est écrit.",[232,242,243],{},"« 1 014.80 » est un nombre avec un espace de séparation des milliers, pas deux nombres.",[14,245,246],{},"Pour le permis de circulation, c’est la même chose. Les champs sont numérotés\net libellés en quatre langues. Le prompt nomme les quatre champs qui comptent :\n15 plaque de contrôle, 21 marque et type, 23 numéro de châssis, 36 première mise\nen circulation. Et il dit explicitement que le numéro matricule (champ 18) et la\nréception par type (champ 24) ne sont pas des numéros de châssis.",[28,248,250],{"id":249},"ce-que-le-code-vérifie","Ce que le code vérifie",[14,252,253],{},"Un prompt est une demande. Ce qui peut être vérifié, le code le vérifie donc,\ndans des fonctions pures avec des tests unitaires.",[14,255,256,260],{},[257,258,259],"strong",{},"Positions contre le total."," Sur beaucoup de factures, plusieurs lignes de\ndescription se trouvent au-dessus d’une seule ligne de travail :",[44,262,267],{"className":263,"code":265,"language":266},[264],"language-text","Auspuff reparieren\nAuto auf Ölverlust kontrollieren\nArbeit 1.50 Std.   130.00   195.00\n","text",[39,268,265],{"__ignoreMap":49},[14,270,271,272,275],{},"Le modèle attachait les 195 francs à chaque ligne. Sur une vraie facture, cela\na donné quatre positions de 195 francs chacune, 844.40 au total pour un total de\n280.40. Le prompt explique désormais ce cas. En plus, ",[39,273,274],{},"repairItems"," recalcule :\nsi la somme des positions dépasse le total de plus d’un franc, elle regroupe les\npositions consécutives de même montant. Mais elle ne conserve ce regroupement\nque si la somme correspond ensuite. Sinon, le formulaire affiche un avertissement\net ne devine pas plus loin.",[14,277,278,281,282,285,286,289],{},[257,279,280],{},"Catégories par mot-clé."," Une liste d’expressions régulières remplace\nl’attribution du modèle quand la description est univoque. « Auspuff\nreparieren » est ",[39,283,284],{},"auspuff",", quoi qu’en pense le modèle. La première\ncorrespondance l’emporte, l’ordre est voulu : « Service mit Ölwechsel » devient\n",[39,287,288],{},"oelwechsel",", parce que la règle plus précise se trouve avant la règle générale\npour les inspections.",[14,291,292,295],{},[257,293,294],{},"Factures en double."," La même facture arrive souvent deux fois, une fois en\nphoto et plus tard dans le PDF groupé du garage. Est considérée comme doublon\nune facture avec le même montant au centime près et une date à 14 jours au plus\nd’écart. Je ne compare volontairement pas le nom du garage : l’OCR l’écrit de\nfaçon trop irrégulière, et les 14 jours rattrapent les factures sur lesquelles\non a lu une fois la date de réparation et une fois la date de facturation.",[14,297,298,301],{},[257,299,300],{},"La plaque d’immatriculation."," Si elle figure sur la facture, l’application\nl’attribue au véhicule correspondant, même si un autre est ouvert à ce\nmoment-là. Les graphies comme « SG 218 574 » et « CH-SG218574 » sont\nauparavant ramenées à une seule forme. Si la plaque n’appartient à aucun\nvéhicule, la facture n’est pas présélectionnée dans la liste de contrôle.",[28,303,305],{"id":304},"un-pdf-page-par-page","Un PDF, page par page",[14,307,308],{},"Les garages envoient volontiers un PDF avec toutes les factures de l’année. La\npremière approche donnait le PDF entier au modèle en un seul appel. Avec neuf\npages, des factures manquaient ensuite, et le garage de la première figurait\nsur toutes.",[14,310,311,312,315,316,319,320,323],{},"Maintenant, l’OCR lit toutes les pages, et le modèle analyse chaque page\nséparément. Il détermine aussi le type de page : ",[39,313,314],{},"rechnung"," avec son propre\nen-tête, ",[39,317,318],{},"fortsetzung"," de la précédente, ou ",[39,321,322],{},"andere"," pour les CGV et les pages\nvides. Pour qu’il puisse en décider, il reçoit les 1200 premiers caractères de\nla page précédente, explicitement pour le classement uniquement. Le garage, la\ndate et le montant ne peuvent provenir que de la page elle-même.",[14,325,326,327,330],{},"L’assemblage est de nouveau du code : ",[39,328,329],{},"mergePdfPages"," rattache les suites à la\nfacture précédente et n’y remplit que les champs vides. Les pages sont traitées\npar trois en parallèle, pas toutes à la fois. Tous les appels passent par un\nproxy maison, qui autorise au plus 20 requêtes par minute.",[28,332,334],{"id":333},"rien-nest-enregistré-avant-que-quelquun-lait-vu","Rien n’est enregistré avant que quelqu’un l’ait vu",[14,336,337],{},"Le contrôle trouve des contradictions, mais pas les erreurs plausibles. Un\nkilométrage mal lu, plus élevé que le dernier, ressemble à un kilométrage\ncorrect. C’est pourquoi aucun scan n’enregistre directement.",[14,339,340],{},"Dans le formulaire de facture, un scan ne remplit que les champs vides. Ce que\nl’utilisateur a déjà saisi reste en place ; la devise seulement tant qu’il ne\nl’a pas changée lui-même. Plusieurs photos ou un PDF groupé apparaissent sous\nforme de liste de contrôle. Chaque ligne indique sa provenance (« Seite 3–4 »),\nle véhicule reconnu et si la facture est déjà saisie. Les lignes en double et\nles lignes peu claires sont désélectionnées.",[14,342,343],{},"Hors ligne, il en va de même. Sans connexion, l’application enregistre la photo\navec un marqueur. Quand la connexion revient, elle effectue le scan après coup\net, là aussi, ne remplit que les champs vides.",[28,345,347],{"id":346},"le-chat-prétend-volontiers-avoir-enregistré","Le chat prétend volontiers avoir enregistré",[14,349,350],{},"L’application comporte aussi un chat. Il peut créer des véhicules, saisir des\nfactures et des entretiens et définir le plan d’entretien, chaque fois via un\ntool. Avant de saisir une facture, il affiche tous les champs et attend un\n« Ja ».",[14,352,353],{},"Le vrai problème était l’inverse. Le modèle écrivait « Die Wartung wurde\neingetragen » sans avoir appelé le tool. Dans le chat, cela ressemble à un\nsuccès ; dans la base de données, il n’y a rien.",[14,355,356],{},"Une phrase dans le prompt n’a pas réglé cela. Pire : les phrases d’exemple de\nmessages de succès dans le prompt, le modèle les reprenait mot pour mot, même\nsans tool. Le prompt ne décrit donc plus les messages de succès que par leur\nforme.",[14,358,359,360,363,364,367],{},"Ce qui a aidé, c’est un garde-fou dans le code. ",[39,361,362],{},"claimsActionWithoutTool","\ncherche dans le texte de la réponse un participe avec auxiliaire (« wurde\neingetragen », « habe ich gespeichert ») et vérifie si un tool d’écriture a\ntourné dans la même réponse. Les tools de lecture comme ",[39,365,366],{},"list_vehicles"," ne\ncomptent pas. Les phrases négatives comme « noch keine Wartung eingetragen »\nsont de l’information et restent à l’écart, et « Ich habe folgende Daten\nerfasst: » est l’aperçu avant la confirmation, pas un succès.",[14,369,370,371,374,375,378],{},"Si le garde-fou se déclenche, le code relance une fois, cette fois avec\n",[39,372,373],{},"toolChoice: 'required'",". La première version l’imposait pour toutes les\nétapes. Après le résultat du tool partait alors une nouvelle requête avec\n",[39,376,377],{},"tool_choice: \"any\""," vers Mistral, et elle ne revenait jamais. L’entretien était\nenregistré, le chat restait bloqué sur l’indicateur de chargement, sur la page\ndu véhicule dans quatre essais sur six. Maintenant, la contrainte ne vaut que\npour la première étape :",[44,380,382],{"className":46,"code":381,"language":48,"meta":49,"style":49},"prepareStep: ({ stepNumber }) => (stepNumber === 0 ? { toolChoice: 'required' } : {}),\n",[39,383,384],{"__ignoreMap":49},[53,385,386,389,392,396,399,401,404,407,410,413,416,419,421,424],{"class":55,"line":56},[53,387,388],{"class":80},"prepareStep",[53,390,391],{"class":63},": ({ ",[53,393,395],{"class":394},"s4XuR","stepNumber",[53,397,398],{"class":63}," }) ",[53,400,87],{"class":59},[53,402,403],{"class":63}," (stepNumber ",[53,405,406],{"class":59},"===",[53,408,409],{"class":67}," 0",[53,411,412],{"class":59}," ?",[53,414,415],{"class":63}," { toolChoice: ",[53,417,418],{"class":142},"'required'",[53,420,71],{"class":63},[53,422,423],{"class":59},":",[53,425,426],{"class":63}," {}),\n",[14,428,429],{},"Ensuite, huit essais sur huit ont abouti, dont cinq via le garde-fou.",[28,431,433],{"id":432},"dicter-via-la-même-deuxième-étape","Dicter via la même deuxième étape",[14,435,436],{},"Qui préfère parler plutôt que taper peut dicter la facture. La dictée passe par\nla même deuxième étape que la photo, seul le texte vient de la transcription au\nlieu de l’OCR.",[14,438,439,440,443,444,447],{},"Quel modèle convient pour cela, je l’ai mesuré plutôt que de le reprendre de la\ndocumentation, avec six phrases du quotidien d’un garage. Les phrases étaient\nsynthétisées avec Piper, pas prononcées par moi ; pour des termes techniques\ncomme « Lambdasonde », cela colore les chiffres. ",[39,441,442],{},"voxtral-mini-latest"," les a\ntranscrites avec 16,4 pour cent d’erreurs de mots. ",[39,445,446],{},"voxtral-small-latest","\ncomprend l’audio et peut appeler des tools, cela semblait la voie la plus\nélégante. Mais il reformulait et a répondu une fois en anglais : « Zahnriemen\nmit Wasserpumpe ersetzt » est devenu « The water pump replaced the fan belt. »\nLa voie par transcription et schéma a en revanche trouvé les cinq champs.",[28,449,451],{"id":450},"comment-cest-testé","Comment c’est testé",[14,453,454],{},"Les règles ci-dessus sont des fonctions pures avec des tests Vitest : positions,\ncatégories, doublons, assemblage des pages, le garde-fou. Elles n’ont besoin ni\nde réseau ni de base de données.",[14,456,457],{},"Les tests Playwright interceptent les appels à Mistral et fournissent des\nréponses fixes. Ils vérifient ainsi le formulaire, la liste de contrôle et le\nchat, sans qu’une exécution coûte de l’argent ou dépende du modèle. Un projet de\ntest à part envoie de vraies photos et un PDF groupé de neuf pages au vrai\nmodèle. Je le lance à la main, pas à chaque modification. Lors de l’une de ces\nexécutions, il est apparu que des champs numériques affichaient « 214,583 km »,\nparce qu’il leur manquait le formatage suisse.",[28,459,461],{"id":460},"ce-qui-est-transposable","Ce qui est transposable",[14,463,464],{},"Rien de tout cela ne dépend des voitures. Qui veut extraire des documents avec\nun modèle de langage peut en retenir ceci :",[229,466,467,470,473,476,479,482,485],{},[232,468,469],{},"Séparer lire et comprendre. L’OCR lit, le modèle de chat ne reçoit que du\ntexte et un schéma.",[232,471,472],{},"Les champs de schéma à valeurs fixes en enum, pas en texte libre.",[232,474,475],{},"Écrire le prompt pour les distinctions sur lesquelles le modèle échoue, pas\npour celles qu’il maîtrise déjà.",[232,477,478],{},"Tout ce qui peut être recalculé, le recalculer dans le code et, en cas de\ncontradiction, afficher un avertissement au lieu de deviner.",[232,480,481],{},"Analyser les longs documents page par page et les assembler dans le code.",[232,483,484],{},"Ne remplir que les champs vides et montrer avant l’enregistrement ce qui sera\nenregistré.",[232,486,487],{},"Ne pas croire un modèle qui annonce un succès, mais vérifier si le tool a\ntourné.",[14,489,490,491,496,497,500,501,504,505,508,509,512],{},"Le code source est ouvert :\n",[17,492,495],{"href":493,"rel":494},"https:\u002F\u002Fgithub.com\u002Fgstrainovic\u002Fwartungsheft",[21],"github.com\u002Fgstrainovic\u002Fwartungsheft",".\nLe pipeline se trouve dans ",[39,498,499],{},"src\u002Fservices\u002Fai.ts",", le contrôle dans\n",[39,502,503],{},"src\u002Fservices\u002Finvoice-scan.ts"," et ",[39,506,507],{},"src\u002Fservices\u002Finvoice-items.ts",", le garde-fou\ndans ",[39,510,511],{},"src\u002Fservices\u002Fchat-guard.ts",".",[514,515,516],"style",{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":49,"searchDepth":96,"depth":96,"links":518},[519,520,521,522,523,524,525,526,527],{"id":30,"depth":96,"text":31},{"id":223,"depth":96,"text":224},{"id":249,"depth":96,"text":250},{"id":304,"depth":96,"text":305},{"id":333,"depth":96,"text":334},{"id":346,"depth":96,"text":347},{"id":432,"depth":96,"text":433},{"id":450,"depth":96,"text":451},{"id":460,"depth":96,"text":461},"Comment Wartungsheft lit les factures de garage avec Mistral. Deux étapes au lieu d’une, contrôle dans le code, et pourquoi rien n’est enregistré avant que quelqu’un l’ait vu.","md",{},true,"\u002Fopen-source\u002Frechnung-fotografieren",null,{"title":5,"description":528},"open-source\u002Frechnung-fotografieren","8BG7KfQETcqQZQXsm3XMQGamad_OxNlR4K-9xos_8YY",1790517213002]