[{"data":1,"prerenderedAt":537},["ShallowReactive",2],{"seite-\u002Fit\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_it\u002Fopen-source\u002Frechnung-fotografieren.md","Fotografare la fattura, verificare i dati",{"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},"fotografare-la-fattura-verificare-i-dati",[14,15,16,23],"p",{},[17,18,22],"a",{"href":19,"rel":20},"https:\u002F\u002Fwartungsheft.ch",[21],"nofollow","Wartungsheft"," è una web app per il libretto di\nmanutenzione delle auto, realizzata con Vue 3 come PWA installabile. Il cuore è\nuna foto: si fotografa la fattura dell’officina e l’app ne estrae officina,\ndata, chilometraggio, importo e posizioni. Dalle posizioni nascono gli\ninterventi di manutenzione, e dagli interventi l’app calcola che cosa scade\ndopo.",[14,25,26],{},"Sembra una chiamata API. In pratica è una catena in cui il modello linguistico\nè solo un anello. Questo articolo descrive quali anelli si sono aggiunti e\nperché.",[28,29,31],"h2",{"id":30},"due-fasi-invece-di-una","Due fasi invece di una",[14,33,34],{},"Con Document Annotation, Mistral offre una via in una sola fase: entra\nl’immagine, esce JSON strutturato. L’ho integrata per prima e l’ho verificata\ncon nove immagini di fatture reali. Una su nove è tornata corretta. Per le\naltre il modello inventava importi che non comparivano su nessuna fattura.",[14,36,37,38,42],{},"La via rimasta ha due fasi. Prima ",[39,40,41],"code",{},"mistral-ocr-latest"," legge l’immagine e\nrestituisce Markdown, tabelle comprese. Poi un modello di chat riceve solo\nquesto testo, non l’immagine, e con esso compila uno schema. Con le stesse nove\nimmagini, tutte e nove erano corrette.",[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],{},"Lo schema è un oggetto Zod tramite il Vercel AI SDK. La categoria di una\nposizione è uno ",[39,197,198],{},"z.enum"," con i tipi di manutenzione dell’app. Il modello quindi\nnon può inventare «Motorservice» dove l’app conosce ",[39,201,202],{},"inspektion",".\n",[39,205,206],{},"temperature: 0"," non rende le risposte corrette, ma ripetibili, ed è l’unico\nmodo per osservare un errore due volte.",[14,209,210,213,214,217],{},[39,211,212],{},"maxRetries: 0"," c’è perché altrimenti l’SDK ripete da sé due volte, in aggiunta\nal ",[39,215,216],{},"withRetry"," interno. Queste ripetizioni silenziose esaurivano il rate limit.",[14,219,220],{},"L’OCR è la parte costosa e lenta. Il suo risultato viene messo in cache sotto\nlo SHA-256 dell’immagine, in memoria e nel database. Se la stessa foto viene\nanalizzata di nuovo, per esempio dopo un errore, l’OCR non la legge una\nseconda volta.",[28,222,224],{"id":223},"il-prompt-conosce-la-svizzera","Il prompt conosce la Svizzera",[14,226,227],{},"La maggior parte degli errori della seconda fase non erano errori di lettura.\nIl modello leggeva correttamente e assegnava in modo sbagliato. Il prompt è\nquindi un elenco di distinzioni che una persona in Svizzera fa senza pensarci:",[229,230,231,235,238,241],"ul",{},[232,233,234],"li",{},"«SG 218574» è una targa di San Gallo, non un numero di telaio. Quello ha\n17 caratteri.",[232,236,237],{},"Se sulla fattura c’è una data di riparazione, conta quella, non la data\ndella fattura. Il libretto di manutenzione ha bisogno del giorno del lavoro.",[232,239,240],{},"«Fr.» e un importo senza valuta significano CHF. EUR solo se è scritto.",[232,242,243],{},"«1 014.80» è un numero con lo spazio delle migliaia, non due numeri.",[14,245,246],{},"Con la licenza di circolazione è lo stesso. I campi sono numerati e\nintestati in quattro lingue. Il prompt nomina i quattro campi che contano: 15\ntarga, 21 marca e tipo, 23 numero di telaio, 36 prima messa in circolazione. E\ndice esplicitamente che il numero di matricola (campo 18) e l’approvazione del\ntipo (campo 24) non sono numeri di telaio.",[28,248,250],{"id":249},"che-cosa-verifica-il-codice","Che cosa verifica il codice",[14,252,253],{},"Un prompt è una richiesta. Ciò che si può verificare lo verifica quindi il\ncodice, in funzioni pure con unit test.",[14,255,256,260],{},[257,258,259],"strong",{},"Posizioni contro il totale."," Su molte fatture ci sono più righe di\ndescrizione sopra un’unica riga di lavoro:",[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],{},"Il modello attaccava i 195 franchi a ogni riga. Su una fattura reale ne sono\nrisultate quattro posizioni da 195 franchi ciascuna, in tutto 844.40 a fronte\ndi un totale di 280.40. Il prompt ora spiega il caso. In più ",[39,273,274],{},"repairItems","\nricalcola: se la somma delle posizioni supera il totale di più di un franco,\nunisce le posizioni consecutive con lo stesso importo. Mantiene però l’unione\nsolo se poi la somma torna. Altrimenti il modulo mostra un avviso e non\ntira a indovinare oltre.",[14,277,278,281,282,285,286,289],{},[257,279,280],{},"Categorie per parola chiave."," Un elenco di espressioni regolari sovrascrive\nl’assegnazione del modello quando la descrizione è univoca. «Auspuff\nreparieren» è ",[39,283,284],{},"auspuff",", qualunque cosa pensi il modello. Vince la prima\ncorrispondenza, l’ordine è voluto: «Service mit Ölwechsel» diventa\n",[39,287,288],{},"oelwechsel",", perché la regola più precisa sta prima di quella generale per le\nispezioni.",[14,291,292,295],{},[257,293,294],{},"Fatture doppie."," La stessa fattura arriva spesso due volte, una volta come\nfoto e più tardi nel PDF cumulativo dell’officina. È considerata doppia una\nfattura con lo stesso importo al centesimo e una data distante al massimo 14\ngiorni. Il nome dell’officina di proposito non lo confronto: l’OCR lo scrive in\nmodo troppo disomogeneo, e i 14 giorni intercettano le fatture su cui una volta\nè stata letta la data di riparazione e una volta quella della fattura.",[14,297,298,301],{},[257,299,300],{},"La targa."," Se è sulla fattura, l’app la assegna al veicolo corrispondente,\nanche se in quel momento ne è aperto un altro. Grafie come «SG 218 574» e\n«CH-SG218574» vengono prima ricondotte a un’unica forma. Se la targa non\nappartiene a nessun veicolo, la fattura non è preselezionata nella lista di\ncontrollo.",[28,303,305],{"id":304},"un-pdf-pagina-per-pagina","Un PDF, pagina per pagina",[14,307,308],{},"Le officine mandano volentieri un PDF con tutte le fatture dell’anno. Il primo\napproccio passava l’intero PDF al modello in una sola chiamata. Con nove pagine\npoi mancavano fatture, e l’officina della prima compariva su tutte.",[14,310,311,312,315,316,319,320,323],{},"Ora l’OCR legge tutte le pagine e il modello analizza ogni pagina singolarmente.\nDetermina anche il tipo di pagina: ",[39,313,314],{},"rechnung"," con una propria intestazione,\n",[39,317,318],{},"fortsetzung"," della precedente, oppure ",[39,321,322],{},"andere"," per condizioni generali e pagine\nvuote. Per poterlo decidere riceve i primi 1200 caratteri della pagina\nprecedente, espressamente solo per la classificazione. Officina, data e importo\npossono provenire solo dalla pagina stessa.",[14,325,326,327,330],{},"L’assemblaggio è di nuovo codice: ",[39,328,329],{},"mergePdfPages"," aggancia le continuazioni alla\nfattura precedente e lì compila solo i campi vuoti. Le pagine vengono elaborate\na tre a tre in parallelo, non tutte insieme. Tutte le chiamate passano per un\nproxy proprio, che consente al massimo 20 richieste al minuto.",[28,332,334],{"id":333},"nulla-viene-salvato-prima-che-qualcuno-labbia-visto","Nulla viene salvato prima che qualcuno l’abbia visto",[14,336,337],{},"Il controllo trova le contraddizioni, ma non gli errori plausibili. Un\nchilometraggio letto male, più alto dell’ultimo, sembra uno corretto. Per\nquesto nessuna scansione salva direttamente.",[14,339,340],{},"Nel modulo della fattura una scansione compila solo i campi vuoti. Ciò che\nl’utente ha già inserito resta; la valuta solo finché non l’ha cambiata lui\nstesso. Più foto o un PDF cumulativo compaiono come lista di controllo. Ogni\nriga indica la sua provenienza («Seite 3–4»), il veicolo riconosciuto e se la\nfattura è già registrata. Le righe doppie e quelle poco chiare sono\ndeselezionate.",[14,342,343],{},"Offline vale lo stesso. Senza connessione l’app salva la foto con un\ncontrassegno. Quando la connessione torna, recupera la scansione e anche allora\ncompila solo i campi vuoti.",[28,345,347],{"id":346},"la-chat-afferma-volentieri-di-aver-salvato","La chat afferma volentieri di aver salvato",[14,349,350],{},"Nell’app c’è anche una chat. Può creare veicoli, registrare fatture e\nmanutenzioni e impostare il piano di manutenzione, ogni volta tramite un tool.\nPrima di registrare una fattura mostra tutti i campi e aspetta un «Ja».",[14,352,353],{},"Il vero problema era l’opposto. Il modello scriveva «Die Wartung wurde\neingetragen» senza aver chiamato il tool. Nella chat sembra un successo, nel\ndatabase non c’è niente.",[14,355,356],{},"Una frase nel prompt non l’ha risolto. Peggio: le frasi di esempio per i\nmessaggi di successo nel prompt il modello le riprendeva alla lettera, anche\nsenza tool. Il prompt descrive quindi i messaggi di successo ormai solo nella\nloro forma.",[14,358,359,360,363,364,367],{},"Ha aiutato un guardiano nel codice. ",[39,361,362],{},"claimsActionWithoutTool"," cerca nel testo\ndella risposta un participio con verbo ausiliare («wurde eingetragen», «habe\nich gespeichert») e verifica se nella stessa risposta è stato eseguito un tool\ndi scrittura. I tool di lettura come ",[39,365,366],{},"list_vehicles"," non contano. Le frasi\nnegative come «noch keine Wartung eingetragen» sono informazioni e restano\nescluse, e «Ich habe folgende Daten erfasst:» è l’anteprima prima della\nconferma, non un successo.",[14,369,370,371,374,375,378],{},"Se il guardiano scatta, il codice chiede una volta di nuovo, questa volta con\n",[39,372,373],{},"toolChoice: 'required'",". La prima versione lo imponeva per tutti i passi. Allora,\ndopo il risultato del tool, partiva un’altra richiesta con\n",[39,376,377],{},"tool_choice: \"any\""," verso Mistral, che non tornava mai. La manutenzione era\nsalvata, la chat restava bloccata sull’indicatore di caricamento, sulla pagina\ndel veicolo in quattro tentativi su sei. Ora l’obbligo vale solo nel primo\npasso:",[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],{},"Dopo, otto tentativi su otto sono andati a buon fine, cinque dei quali tramite\nil guardiano.",[28,431,433],{"id":432},"dettare-tramite-la-stessa-seconda-fase","Dettare tramite la stessa seconda fase",[14,435,436],{},"Chi preferisce parlare anziché digitare può dettare la fattura. La dettatura\npassa per la stessa seconda fase della foto, solo che il testo viene dalla\ntrascrizione invece che dall’OCR.",[14,438,439,440,443,444,447],{},"Quale modello sia adatto l’ho misurato, non ripreso dalla documentazione, con\nsei frasi dalla quotidianità dell’officina. Le frasi erano sintetizzate con\nPiper, non pronunciate da me; con termini tecnici come «Lambdasonde» questo\ninfluenza i numeri. ",[39,441,442],{},"voxtral-mini-latest"," le ha trascritte con il 16,4 per\ncento di errori di parola. ",[39,445,446],{},"voxtral-small-latest"," capisce l’audio e può\nchiamare tool, sembrava la via più elegante. Però riformulava e una volta ha\nrisposto in inglese: «Zahnriemen mit Wasserpumpe ersetzt» è diventato «The\nwater pump replaced the fan belt.» La via tramite trascrizione e schema ha\ninvece azzeccato tutti e cinque i campi.",[28,449,451],{"id":450},"come-viene-testato","Come viene testato",[14,453,454],{},"Le regole sopra sono funzioni pure con test Vitest: posizioni, categorie,\nduplicati, assemblaggio delle pagine, il guardiano. Non hanno bisogno né di\nrete né di database.",[14,456,457],{},"I test Playwright intercettano le chiamate a Mistral e forniscono risposte\nfisse. Così verificano modulo, lista di controllo e chat, senza che\nun’esecuzione costi denaro o dipenda dal modello. Un progetto di test a parte\nmanda foto reali e un PDF cumulativo di nove pagine al modello reale. Lo avvio a\nmano, non a ogni modifica. In una di queste esecuzioni è emerso che i campi\nnumerici mostravano «214,583 km», perché mancava loro la formattazione svizzera.",[28,459,461],{"id":460},"che-cosa-si-può-trasferire","Che cosa si può trasferire",[14,463,464],{},"Niente di tutto questo dipende dalle auto. Chi vuole estrarre dati da documenti\ncon un modello linguistico può portarsi via questo:",[229,466,467,470,473,476,479,482,485],{},[232,468,469],{},"Separare lettura e comprensione. L’OCR legge, il modello di chat riceve solo\ntesto e uno schema.",[232,471,472],{},"Campi dello schema con valori fissi come enum, non come testo libero.",[232,474,475],{},"Scrivere il prompt per le distinzioni su cui il modello fallisce, non per\nquelle che sa già fare.",[232,477,478],{},"Tutto ciò che si può ricalcolare, ricalcolarlo nel codice e, in caso di\ncontraddizione, mostrare un avviso invece di tirare a indovinare.",[232,480,481],{},"Analizzare i documenti lunghi pagina per pagina e assemblarli nel codice.",[232,483,484],{},"Compilare solo i campi vuoti e mostrare prima del salvataggio che cosa verrà\nsalvato.",[232,486,487],{},"Non credere a un modello che segnala un successo, ma controllare se il tool è\nstato eseguito.",[14,489,490,491,496,497,500,501,504,505,508,509,512],{},"Il codice sorgente è aperto:\n",[17,492,495],{"href":493,"rel":494},"https:\u002F\u002Fgithub.com\u002Fgstrainovic\u002Fwartungsheft",[21],"github.com\u002Fgstrainovic\u002Fwartungsheft",".\nLa pipeline si trova in ",[39,498,499],{},"src\u002Fservices\u002Fai.ts",", il controllo in\n",[39,502,503],{},"src\u002Fservices\u002Finvoice-scan.ts"," e ",[39,506,507],{},"src\u002Fservices\u002Finvoice-items.ts",", il guardiano\nin ",[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},"Come Wartungsheft legge le fatture dell’officina con Mistral. Due fasi invece di una, controllo nel codice, e perché nulla viene salvato prima che qualcuno l’abbia visto.","md",{},true,"\u002Fopen-source\u002Frechnung-fotografieren",null,{"title":5,"description":528},"open-source\u002Frechnung-fotografieren","QqJ32eFwtDlluiOgtXIT0neuYZknwG9LVgRErPIU0_Q",1790517213147]