Auf den ppedv Websites gibt es eine Stelle an der Lieferanten ihre Rechnungen hoch laden können. Dahinter verbirgt sich bei uns ein einfacher Workflow der Zahlungsdaten generieren kann.
Seit langer Zeit hadere ich mit PDF und Texterkennung. Die einfache Frage, Wie lautet die IBAN? ist per OCR nicht wirklich zu beantworten. Geht das mit AI?
Ja
Kann ich das ohne Cloud, lokal betreiben?
Ja
Meine Motivation dahinter: ich will bestimmte Daten schlicht nicht in der Cloud haben.
OLLAMA
Zunächst benötige ich ein Vision Modell das man per Sprache befragen kann. Phi4 kann das oder auch Mistral. Dann benötigt man eine Art Laufzeitumgebung um ein Modell zu hosten. Ich kann das in Process in meiner .NET Anwendung tun, erkaufe mir dabei aber Probleme. Das Modell muss vollständig in Memory geladen sein. Mistral benötigt 16GB, Phi4 8. Schlimmer ist der Entwicklungsprozess mit zb Visual Studio, weil bei jedem recompile debug- die Ladedauer des Models inakzeptabel ausfällt.
Ollama ist eine Runtime, die auf einen dedizierten (windows oder LINUX) Server laufen kann und dann per OpenAI kompatiblen API Calls genutzt wird.
Auf einer WIndows Maschine Ollama installieren. Das benötige Model runterladen und bereitstellen per
ollama run mistral-small3.2
PDF konvertieren
AI mag kein PDF. Warum auch immer. Also muss zuerst ein PDF in eine PNG oder JPG konvertiert werden. Da gibt es durchaus Hürden, wie Multi Page.
Ich habe eine sehr sehr alte Library ausgewählt:
PdfiumViewer
Diese benötigt eine C++ Library pfdium.dll. Da gibt es aktuelle Versionen, habe aber Probleme mit der Kompatibilität. Geklappt hat schlussendlich Nuget Pakete zu installieren
Install-Package PdfiumViewer
Install-Package PdfiumViewer.Native.x86_64.v8-xfa
Background per httphandler
Mein Target ist die ppedv Website, die auf Webforms mit VB.NET basiert. Um in eriner ASPX Seite einen non Blocking Callback hin zu bekommen, brauche ich ein Schippchen JavasScript und einen Service. Das hat man früher als HttpHandler implementiert.

Da das ganze Asynchron läuft statt IHttpHandler –>
1: Public Class uploadhandler
2: Inherits System.Web.HttpTaskAsyncHandler
Mein Ziel ist es mit einem LLM zu sprechen, Json zu erhalten und das in ein Poco zu persistieren. Also brauche ich zwei Klassen
1: Private Class OllamaResponse
2: Public Property response As String
3: End Class
4: Public Class Rechnung
5: Public Property Absender As String
6: Public Property Email As String
7: Public Property IBAN As String
8: Public Property Bankname As String
9: Public Property Rechnungsnummer As String
10: Public Property Leistung As String
11: Public Property Datum As String
12:
13: <JsonPropertyName("Gesamtbetrag in EUR")>
14: <JsonProperty("Gesamtbetrag in EUR")>
15: Public Property GesamtbetragInEur As Decimal
16: End Class
Ich habe zuerst mit System.Text.Josn gearbeitet und fest gestellt, das es dort keine Möglichkeit gibt, die Culture mit zu berücksichtigen. In JSON muss ein Preis im Format 122.80 mit Punkt angegeben werden. Komma macht Fehler.
Entsprechend finden sich hier noch die Attribute zur Serialisierung für beide JSON Parser.
Dann noch Helper Methoden für PDF zu Image
1: Private Function RenderFirstPageAsImage(pdfPath As String) As Image
2: Using doc = PdfiumViewer.PdfDocument.Load(pdfPath)
3: Return doc.Render(0, 1024, 1440, True)
4: End Using
5: End Function
6:
7: Private Function ImageToBase64(img As Image) As String
8: Using ms As New MemoryStream()
9: img.Save(ms, ImageFormat.Png)
10: Return Convert.ToBase64String(ms.ToArray())
11: End Using
12: End Function
Wenden wir uns dem eigentlichen API Call zum Ollama Service zu. Wir senden per Post das Bild des PDF Base64 encoded, den Prompt und welches Modell wir nutzen wollen. Ihre IP müssen Sie schon selbst wissen und Port ist Standard 11434. Unser Rechner hat eine Public Adresse.
1: Private Async Function CallOllamaAsync(base64Img As String, prompt As String) As Task(Of String)
2: Using client As New HttpClient()
3:
4: Dim payload = New With {
5: .model = "mistral-small3.2:latest",
6: .prompt = prompt,
7: .images = New String() {base64Img},
8: .stream = False
9: }
10:
11: Dim json = JsonConvert.SerializeObject(payload)
12: Dim content = New StringContent(json, Encoding.UTF8, "application/json")
13:
14: Dim response = Await client.PostAsync("http://x.x.x.x:11434/api/generate", content)
15: Dim raw = Await response.Content.ReadAsStringAsync()
16:
17: If Not response.IsSuccessStatusCode Then
18: Return System.Text.Json.JsonSerializer.Serialize("HTTP " & response.StatusCode & ": " & raw)
19: End If
20:
21: Return System.Text.Json.JsonSerializer.Serialize(ParseOllamaResponse(raw))
22: End Using
23: End Function
Soweit so einfach. Tatsächlich ist ein wenig Aufwand erforderlich, das auch “Save” zu bekommen, da ein LLM eben keine reproduzierbaren Antworten liefert. Das Parsen der Antwort dient dazu dem HTML/JS Frontend sauberes JSON zu liefern.
1: Public Function ParseOllamaResponse(jsonInput As String) As Rechnung
2: Dim doc As JsonDocument = JsonDocument.Parse(jsonInput)
3: Dim rawResponse As String = doc.RootElement.GetProperty("response").GetString()
4: Dim innerJson As String = Regex.Replace
(rawResponse, "^```json\s*|\s*```$", "", RegexOptions.Multiline).Trim()
5: Dim settings As New JsonSerializerSettings With {
6: .Culture = CultureInfo.GetCultureInfo("en-US")
7: }
8: Dim rechnung = JsonConvert.DeserializeObject(Of Rechnung)(innerJson, settings)
9: Return rechnung
10: End Function
Bisher war alles nur Helper. Nun geht es an den Prompt bzw. den Upload der Rechnungsdatei.
1: Public Overrides Async Function ProcessRequestAsync(context As HttpContext) As Task
2: context.Response.ContentType = "application/json"
3:
4: If context.Request.Files.Count = 0 Then
5: context.Response.StatusCode = 400
6: Await context.Response.Output.WriteAsync("{""error"":""Keine Datei hochgeladen""}")
7: Return
8: End If
9:
10: Try
11: ' Datei speichern
12: Dim file = context.Request.Files(0)
13: Dim filePath = context.Server.MapPath("~/App_Data/" & Path.GetFileName(file.FileName))
14: file.SaveAs(filePath)
15:
16: ' PDF → Bild → Base64
17: Dim img As Image = RenderFirstPageAsImage(filePath)
18: Dim base64Img As String = ImageToBase64(img)
19:
20: ' Prompt definieren
21: Dim prompt As String = "Dies ist ein Bild einer Rechnung. Bitte extrahiere: Absender, Email, IBAN (Kontonummer), Bankname, Rechnungsnummer, Leistung, Datum, Gesamtbetrag in EUR.Erstelle ein korrekt formatiertes JSON-Dokument mit den folgenden Feldern (einschließlich korrekter Datentypen). Achte darauf, dass das Feld 'Gesamtbetrag In EUR' genau so geschrieben ist – mit Leerzeichen und Großschreibung. Gib ausschließlich das JSON aus, ohne weitere Erklärungen oder Kommentare.
22: Felder:
23: Absender (String)
24: Email (String, gültiges E-Mail-Format)
25: IBAN (String)
26: Bankname (String)
27: Rechnungsnummer (String)
28: Leistung (String)
29: Datum (String im Format YYYY-MM-DD)
30: 'Gesamtbetrag In EUR' (Dezimalzahl mit Punkt als Dezimaltrennzeichen)"
31:
32: ' API-Aufruf an Ollama
33: Dim jsonResponse As String = Await CallOllamaAsync(base64Img, prompt)
34:
35: Await context.Response.Output.WriteAsync(jsonResponse)
36:
37: Catch ex As Exception
38: context.Response.Clear()
39: context.Response.StatusCode = 500
40: context.Response.ContentType = "application/json"
41:
42: Dim errorResponse As String = $"{{""error"":""{ex.Message.Replace("""", "\""")}""}}"
43: context.Response.Write(errorResponse)
44: End Try
45: End Function
Webforms Page
Da das ganze dauert, habe ich mir einen Spinner gegönnt. Benutzer wählt PDF aus, drückt und analysieren und wartet ca 20-40sekunden.

Aber wenn es mal da ist, ist es verdammt gut

Also den sparsamen ASPX Part, der auch pures HTML oder eine Razor Page sein könnte.
1: <input type="file" id="fileInput" />
2: <button id="btnUpload" type="button">Analysieren</button>
3: <div id="status"></div>
4: <pre id="result"></pre>
5: <div id="spinner" style="display: none; margin: 20px;">
6: <img src="spinner.gif" alt="Lade..." />
7: </div>
Ein wenig komplizierter aber durchaus noch überschaubar die JavaScript Logik. Da ich durchaus mit den Rückgabewerten über zwei Service Grenzen gekämpft habe, ein paar Console log extra 
1: <script>
2: document.getElementById('btnUpload').addEventListener('click', async () => {
3: const file = document.getElementById('fileInput').files[0];
4: if (!file) { alert('Bitte Datei wählen'); return; }
5:
6: const form = new FormData();
7: form.append('file', file);
8: document.getElementById('spinner').style.display = 'block';
9: document.getElementById('status').textContent = ' Upload...';
10: document.getElementById('result').textContent = '';
11:
12: try {
13: console.log("fetch");
14: const resp = await fetch('UploadHandler.ashx', {
15: method: 'POST',
16: body: form
17: });
18: if (!resp.ok) throw new Error(`Server-Fehler ${resp.status}`);
19: console.log(resp);
20: document.getElementById('status').textContent = '✅ Auswertung abgeschlossen.';
21: const json = await resp.json();
22: console.log(json);
23: document.getElementById('result').textContent = JSON.stringify(json, null, 2);
24: } catch (ex) {
25: console.log(ex);
26: document.getElementById('status').textContent = '❌ Fehler: ' + ex.message;
27: }
28: finally {
29: // Spinner ausblenden
30: document.getElementById('spinner').style.display = 'none';
31: }
32: });
33: </script>
Man könnte natürlich den Call zu Ollama direkt vom Client machen. Allerdings antwortet Ollama recht umfangreich und ich habe den Parsing und Cleanup Job lieber in .net erledigt
Dieser Blog Artikel zeigt wie man mit klassischen .NET Framework recht einfach LLM in seine bestehenden Anwendungen einbaut.