[{"data":1,"prerenderedAt":537},["ShallowReactive",2],{"seite-\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\u002Fopen-source\u002Frechnung-fotografieren.md","Rechnung fotografieren, Daten prüfen",{"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},"rechnung-fotografieren-daten-prüfen",[14,15,16,23],"p",{},[17,18,22],"a",{"href":19,"rel":20},"https:\u002F\u002Fwartungsheft.ch",[21],"nofollow","Wartungsheft"," ist eine Web-App für das Serviceheft\nvon Autos, gebaut mit Vue 3 als installierbare PWA. Der Kern ist ein Foto: Man\nfotografiert die Werkstattrechnung, und die App liest Werkstatt, Datum,\nKilometerstand, Betrag und Positionen heraus. Aus den Positionen entstehen\nWartungen, und aus den Wartungen rechnet die App, was als Nächstes fällig ist.",[14,25,26],{},"Das klingt nach einem API-Aufruf. In der Praxis ist es eine Kette, in der das\nSprachmodell nur ein Glied ist. Dieser Artikel beschreibt, welche Glieder dazu\nkamen und warum.",[28,29,31],"h2",{"id":30},"zwei-stufen-statt-einer","Zwei Stufen statt einer",[14,33,34],{},"Mistral bietet mit Document Annotation einen Weg in einer Stufe: Bild rein,\nstrukturiertes JSON raus. Den habe ich zuerst eingebaut und mit neun echten\nRechnungsbildern geprüft. Eine von neun kam richtig zurück. Bei den anderen\nerfand das Modell Beträge, die auf keiner Rechnung standen.",[14,36,37,38,42],{},"Der Weg, der blieb, hat zwei Stufen. Zuerst liest ",[39,40,41],"code",{},"mistral-ocr-latest"," das Bild\nund gibt Markdown zurück, Tabellen eingeschlossen. Danach bekommt ein\nChat-Modell nur diesen Text, nicht das Bild, und füllt damit ein Schema. Mit\ndenselben neun Bildern waren alle neun richtig.",[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],{},"Das Schema ist ein Zod-Objekt über das Vercel AI SDK. Die Kategorie einer\nPosition ist ein ",[39,197,198],{},"z.enum"," mit den Wartungsarten der App. Das Modell kann also\nnicht „Motorservice\" erfinden, wo die App ",[39,201,202],{},"inspektion"," kennt. ",[39,205,206],{},"temperature: 0","\nmacht die Antworten nicht richtig, aber wiederholbar, und nur so lässt sich ein\nFehler zweimal beobachten.",[14,209,210,213,214,217],{},[39,211,212],{},"maxRetries: 0"," steht dort, weil das SDK sonst selbst zweimal wiederholt,\nzusätzlich zum eigenen ",[39,215,216],{},"withRetry",". Diese stillen Wiederholungen brauchten das\nRate-Limit auf.",[14,219,220],{},"Die OCR ist der teure und langsame Teil. Ihr Ergebnis wird unter dem SHA-256\ndes Bildes zwischengespeichert, im Speicher und in der Datenbank. Wird dasselbe\nFoto erneut ausgewertet, etwa nach einem Fehler, liest die OCR es kein zweites\nMal.",[28,222,224],{"id":223},"der-prompt-kennt-die-schweiz","Der Prompt kennt die Schweiz",[14,226,227],{},"Die meisten Fehler der zweiten Stufe waren keine Lesefehler. Das Modell las\nrichtig und ordnete falsch zu. Der Prompt ist deshalb eine Liste von\nUnterscheidungen, die ein Mensch aus der Schweiz ohne Nachdenken trifft:",[229,230,231,235,238,241],"ul",{},[232,233,234],"li",{},"„SG 218574\" ist ein Kontrollschild aus St. Gallen, keine\nFahrgestellnummer. Die hat 17 Zeichen.",[232,236,237],{},"Steht ein Reparaturdatum auf der Rechnung, zählt dieses, nicht das\nRechnungsdatum. Das Serviceheft braucht den Tag der Arbeit.",[232,239,240],{},"„Fr.\" und ein Betrag ohne Währung heissen CHF. EUR nur, wenn es dasteht.",[232,242,243],{},"„1 014.80\" ist eine Zahl mit Tausenderlücke, nicht zwei Zahlen.",[14,245,246],{},"Beim Fahrzeugausweis ist es dasselbe. Die Felder sind nummeriert und\nviersprachig beschriftet. Der Prompt nennt die vier Felder, die zählen: 15\nKontrollschild, 21 Marke und Typ, 23 Fahrgestellnummer, 36 erste\nInverkehrsetzung. Und er sagt ausdrücklich, dass Stammnummer (Feld 18) und\nTypengenehmigung (Feld 24) keine Fahrgestellnummer sind.",[28,248,250],{"id":249},"was-der-code-nachprüft","Was der Code nachprüft",[14,252,253],{},"Ein Prompt ist eine Bitte. Was sich prüfen lässt, prüft deshalb der Code, in\nreinen Funktionen mit Unit-Tests.",[14,255,256,260],{},[257,258,259],"strong",{},"Positionen gegen das Total."," Auf vielen Rechnungen stehen mehrere\nBeschreibungszeilen über einer einzigen Arbeitszeile:",[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],{},"Das Modell hängte die 195 Franken an jede Zeile. Auf einer echten Rechnung\nwurden so vier Positionen zu je 195 Franken daraus, zusammen 844.40 bei einem\nTotal von 280.40. Der Prompt erklärt den Fall inzwischen. Zusätzlich\nrechnet ",[39,273,274],{},"repairItems"," nach: Liegt die Summe der Positionen mehr als einen\nFranken über dem Total, fasst sie aufeinanderfolgende Positionen mit gleichem\nBetrag zusammen. Das behält sie aber nur, wenn die Summe danach passt. Sonst\nzeigt das Formular einen Hinweis und rät nicht weiter.",[14,277,278,281,282,285,286,289],{},[257,279,280],{},"Kategorien per Stichwort."," Eine Liste regulärer Ausdrücke überschreibt die\nZuordnung des Modells, wenn die Beschreibung eindeutig ist. „Auspuff\nreparieren\" ist ",[39,283,284],{},"auspuff",", egal was das Modell meint. Der erste Treffer\ngewinnt, die Reihenfolge ist Absicht: „Service mit Ölwechsel\" wird\n",[39,287,288],{},"oelwechsel",", weil die genauere Regel vor der allgemeinen für Inspektionen\nsteht.",[14,291,292,295],{},[257,293,294],{},"Doppelte Rechnungen."," Dieselbe Rechnung kommt oft zweimal, einmal als Foto\nund später im Sammel-PDF der Werkstatt. Als doppelt gilt eine Rechnung mit\ndemselben Betrag auf den Rappen und einem Datum höchstens 14 Tage daneben. Den\nWerkstattnamen vergleiche ich bewusst nicht: Die OCR schreibt ihn zu\nuneinheitlich, und die 14 Tage fangen Rechnungen, auf denen einmal das\nReparatur- und einmal das Rechnungsdatum gelesen wurde.",[14,297,298,301],{},[257,299,300],{},"Das Kontrollschild."," Steht es auf der Rechnung, ordnet die App sie dem\npassenden Fahrzeug zu, auch wenn gerade ein anderes offen ist. Schreibweisen\nwie „SG 218 574\" und „CH-SG218574\" werden vorher auf eine Form gebracht. Gehört\ndas Schild zu keinem Fahrzeug, ist die Rechnung in der Prüfliste nicht\nvorausgewählt.",[28,303,305],{"id":304},"ein-pdf-seite-für-seite","Ein PDF, Seite für Seite",[14,307,308],{},"Werkstätten schicken gern ein PDF mit allen Rechnungen des Jahres. Der erste\nAnsatz gab das ganze PDF in einem Aufruf an das Modell. Bei neun Seiten fehlten\ndanach Rechnungen, und die Werkstatt der ersten stand auf allen.",[14,310,311,312,315,316,319,320,323],{},"Jetzt liest die OCR alle Seiten, und das Modell wertet jede Seite einzeln aus.\nEs bestimmt dabei auch die Art der Seite: ",[39,313,314],{},"rechnung"," mit eigenem Kopf,\n",[39,317,318],{},"fortsetzung"," der vorherigen, oder ",[39,321,322],{},"andere"," für AGB und leere Seiten. Damit es\ndas entscheiden kann, bekommt es die ersten 1200 Zeichen der vorherigen Seite\nmit, ausdrücklich nur zur Einordnung. Werkstatt, Datum und Betrag dürfen nur\nvon der eigenen Seite kommen.",[14,325,326,327,330],{},"Das Zusammensetzen ist wieder Code: ",[39,328,329],{},"mergePdfPages"," hängt Fortsetzungen an die\nvorherige Rechnung und füllt dort nur leere Felder. Die Seiten laufen zu dritt\nparallel, nicht alle auf einmal. Alle Aufrufe gehen über einen eigenen Proxy,\nund der lässt höchstens 20 Anfragen pro Minute zu.",[28,332,334],{"id":333},"nichts-wird-gespeichert-bevor-es-jemand-gesehen-hat","Nichts wird gespeichert, bevor es jemand gesehen hat",[14,336,337],{},"Die Nachkontrolle findet Widersprüche, aber keine plausiblen Fehler. Ein\nfalsch gelesener Kilometerstand, der höher ist als der letzte, sieht aus wie\nein richtiger. Deshalb speichert kein Scan direkt.",[14,339,340],{},"Im Rechnungsformular füllt ein Scan nur leere Felder. Was der Nutzer schon\neingetragen hat, bleibt stehen; die Währung nur, solange er sie nicht selbst\numgestellt hat. Mehrere Fotos oder ein Sammel-PDF erscheinen als Prüfliste. Jede\nZeile nennt ihre Herkunft („Seite 3–4\"), das erkannte Fahrzeug und ob die\nRechnung schon erfasst ist. Doppelte und unklare Zeilen sind abgewählt.",[14,342,343],{},"Offline gilt dasselbe. Ohne Verbindung speichert die App das Foto mit einer\nMarkierung. Kommt die Verbindung zurück, holt sie den Scan nach und füllt auch\ndann nur leere Felder.",[28,345,347],{"id":346},"der-chat-behauptet-gern-er-habe-gespeichert","Der Chat behauptet gern, er habe gespeichert",[14,349,350],{},"In der App gibt es auch einen Chat. Er kann Fahrzeuge anlegen, Rechnungen und\nWartungen eintragen und den Wartungsplan setzen, jeweils über ein Tool. Vor dem\nEintragen einer Rechnung zeigt er alle Felder und wartet auf ein „Ja\".",[14,352,353],{},"Das eigentliche Problem war das Gegenteil. Das Modell schrieb „Die Wartung\nwurde eingetragen\", ohne das Tool aufgerufen zu haben. Im Chat sieht das aus\nwie Erfolg, in der Datenbank steht nichts.",[14,355,356],{},"Ein Satz im Prompt hat das nicht behoben. Schlimmer: Beispielsätze für\nErfolgsmeldungen im Prompt übernahm das Modell wörtlich, auch ohne Tool. Der\nPrompt beschreibt Erfolgsmeldungen deshalb nur noch in ihrer Form.",[14,358,359,360,363,364,367],{},"Geholfen hat ein Wächter im Code. ",[39,361,362],{},"claimsActionWithoutTool"," sucht im Text der\nAntwort nach einem Partizip mit Hilfsverb („wurde eingetragen\", „habe ich\ngespeichert\") und prüft, ob in derselben Antwort ein schreibendes Tool lief.\nLesende Tools wie ",[39,365,366],{},"list_vehicles"," zählen nicht. Verneinte Sätze wie „noch\nkeine Wartung eingetragen\" sind Auskunft und bleiben aussen vor, und „Ich\nhabe folgende Daten erfasst:\" ist die Vorschau vor der Bestätigung, kein\nErfolg.",[14,369,370,371,374,375,378],{},"Schlägt der Wächter an, fragt der Code einmal nach, diesmal mit\n",[39,372,373],{},"toolChoice: 'required'",". Die erste Fassung erzwang das für alle Schritte. Dann\nlief nach dem Tool-Ergebnis eine weitere Anfrage mit ",[39,376,377],{},"tool_choice: \"any\""," an\nMistral, und die kam nie zurück. Die Wartung war gespeichert, der Chat hing\nin der Ladeanzeige, auf der Fahrzeugseite in vier von sechs Versuchen. Jetzt\ngilt der Zwang nur im ersten Schritt:",[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],{},"Danach liefen acht von acht Versuchen durch, fünf davon über den Wächter.",[28,431,433],{"id":432},"diktieren-über-dieselbe-zweite-stufe","Diktieren über dieselbe zweite Stufe",[14,435,436],{},"Wer lieber spricht als tippt, kann die Rechnung diktieren. Das Diktat läuft\ndurch dieselbe zweite Stufe wie das Foto, nur kommt der Text aus der\nTranskription statt aus der OCR.",[14,438,439,440,443,444,447],{},"Welches Modell dafür taugt, habe ich gemessen, nicht aus der Dokumentation\nübernommen, mit sechs Sätzen aus dem Werkstattalltag. Die Sätze waren mit\nPiper vertont, nicht selbst gesprochen; bei Fachwörtern wie „Lambdasonde\"\nfärbt das die Zahlen. ",[39,441,442],{},"voxtral-mini-latest"," schrieb sie mit 16,4 Prozent\nWortfehlern mit. ",[39,445,446],{},"voxtral-small-latest"," versteht Audio und\nkann Tools aufrufen, das klang nach dem eleganteren Weg. Es formulierte aber um\nund antwortete einmal auf Englisch: „Zahnriemen mit Wasserpumpe ersetzt\" wurde\nzu „The water pump replaced the fan belt.\" Der Weg über Transkript und Schema\ntraf dagegen alle fünf Felder.",[28,449,451],{"id":450},"wie-das-getestet-wird","Wie das getestet wird",[14,453,454],{},"Die Regeln oben sind reine Funktionen mit Vitest-Tests: Positionen,\nKategorien, Duplikate, Seiten zusammensetzen, der Wächter. Sie brauchen weder\nNetz noch Datenbank.",[14,456,457],{},"Die Playwright-Tests fangen die Aufrufe an Mistral ab und liefern feste\nAntworten. So prüfen sie Formular, Prüfliste und Chat, ohne dass ein Lauf\nGeld kostet oder vom Modell abhängt. Ein eigenes Testprojekt schickt echte\nFotos und ein Sammel-PDF mit neun Seiten an das echte Modell. Das starte ich\nvon Hand, nicht bei jeder Änderung. Bei einem dieser Läufe fiel auf, dass Zahlenfelder „214,583 km\" anzeigten, weil ihnen die\nSchweizer Formatierung fehlte.",[28,459,461],{"id":460},"was-sich-übertragen-lässt","Was sich übertragen lässt",[14,463,464],{},"Nichts davon hängt an Autos. Wer Dokumente mit einem Sprachmodell auslesen\nwill, kann das mitnehmen:",[229,466,467,470,473,476,479,482,485],{},[232,468,469],{},"Lesen und Verstehen trennen. Die OCR liest, das Chat-Modell bekommt nur\nText und ein Schema.",[232,471,472],{},"Schema-Felder mit festen Werten als Enum, nicht als freier Text.",[232,474,475],{},"Den Prompt für die Unterscheidungen schreiben, an denen das Modell scheitert,\nnicht für die, die es schon kann.",[232,477,478],{},"Alles, was sich nachrechnen lässt, im Code nachrechnen und bei Widerspruch\neinen Hinweis zeigen statt raten.",[232,480,481],{},"Lange Dokumente Seite für Seite auswerten und im Code zusammensetzen.",[232,483,484],{},"Nur leere Felder füllen und vor dem Speichern zeigen, was gespeichert wird.",[232,486,487],{},"Einem Modell, das Erfolg meldet, nicht glauben, sondern nachsehen, ob das\nTool lief.",[14,489,490,491,496,497,500,501,504,505,508,509,512],{},"Der Quelltext liegt offen:\n",[17,492,495],{"href":493,"rel":494},"https:\u002F\u002Fgithub.com\u002Fgstrainovic\u002Fwartungsheft",[21],"github.com\u002Fgstrainovic\u002Fwartungsheft",".\nDie Pipeline steht in ",[39,498,499],{},"src\u002Fservices\u002Fai.ts",", die Nachkontrolle in\n",[39,502,503],{},"src\u002Fservices\u002Finvoice-scan.ts"," und ",[39,506,507],{},"src\u002Fservices\u002Finvoice-items.ts",", der Wächter\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},"Wie Wartungsheft Werkstattrechnungen mit Mistral liest. Zwei Stufen statt einer, Nachkontrolle im Code, und warum nichts gespeichert wird, bevor jemand es gesehen hat.","md",{},true,"\u002Fopen-source\u002Frechnung-fotografieren",null,{"title":5,"description":528},"open-source\u002Frechnung-fotografieren","DaXzrse2RFuCxg48eHCGJl3WHM9eLegqmYeNa4sDWT4",1790025703543]