️️

In diesem Beitrag zeige ich ein Ende-zu-Ende-Beispiel, das eingehende E-Mails mit Rechnungs-PDFs automatisch verarbeitet, die relevanten Felder extrahiert und einen Datensatz in Dataverse (Dynamics 365) anlegt. Die Orchestrierung übernimmt eine Azure Logic App. Für OCR und Layout kommt Azure AI Document Intelligence (Form Recognizer) zum Einsatz; Azure OpenAI normalisiert die Daten in ein sauberes JSON-Schema. Optional bereiten wir die Ausgabe in einer Azure Function (isolated worker) auf.

Hinweis: Im Artikel maskiere ich Mandanten- und Subscriptions-IDs. Ersetzen Sie Platzhalter wie und durch Ihre Werte.


LogicApp in 7 Schritten

  1. Trigger: When a new email arrives (V3) im Ordner „Inbox“ (Office 365 Connector), splitOn auf @triggerBody()?[‚value‘] → jede Mail wird als eigener Run verarbeitet.
  1. Schleife pro Anhang: For each über @triggerBody()?[‚attachments‘].
  1. OCR & Layout: Aufruf von Form Recognizer prebuilt-read (api-version=2023-07-31) mit dem Anhang als Binärdaten.
  2. Extrahierter Volltext: Speichern des Ergebnisses in der Variablen Words:
"value": "@body('Analyze_Document_for_Prebuilt_or_Custom_models_(v3.x_API)')?['analyzeResult']?['content']"
  1. (Optional) Nachverfolgung: SharePoint-Listeneintrag „Title = Betreff“ als einfacher Audit-Trail.
  • Strukturierung via LLM: Chat-Completion gegen Azure OpenAI (gpt-4.1-mini) mit einem Prompt, der aus dem Volltext ein JSON für zwei Tabellen erzeugt: Invoice Header & Invoice Lines.
  • Parsing & Persistenz:
  • Azure Function „ReadChatGPTMessageAndConvertToCSharp“ normalisiert die Antwort (z. B. entfernt Markdown, validiert JSON).
  • Parse JSON validiert das Schema.
  • Add a new row schreibt das Header-Objekt in die Dataverse-Tabelle cr9c6_invoiceheaders.

Warum zwei KI-Stufen?

  • Form Recognizer (prebuilt-read): Liefert robusten, positionsunabhängigen Volltext – schnell und günstig.
  • Azure OpenAI: Übernimmt die Semantik: Werte zuordnen, Datentypen formen (Datum, Zahl), Felder im gewünschten Output-Schema bereitstellen.

In vielen Projekten ist diese Kombination stabiler als ein einziger, „magischer“ Schritt. Wenn Sie Rechnungen mit sehr konsistentem Layout verarbeiten, lohnt sich zusätzlich ein Blick auf prebuilt-invoice oder ein Custom Model – dann kann OpenAI nur noch zum Fuzzy-Matching oder zur Fehlerbehandlung eingesetzt werden.


Wichtige Ausschnitte aus der Definition

1) Trigger mit splitOn

"splitOn": "@triggerBody()?['value']",
"type": "ApiConnectionNotification",
"inputs": { "...": "..." }

Damit wird jede E-Mail im Batch zu einem separaten Run – das verhindert große Arrays im Flow und vereinfacht das Monitoring.

2) OCR-Aufruf (Form Recognizer)

"path": "/documentModels/@{encodeURIComponent('prebuilt-read')}:analyze",
"queries": { "api-version": "2023-07-31" },
"body": "@base64ToBinary(item()?['contentBytes'])"

Tipp: PDF und Images (PNG/JPG) funktionieren out of the box. Achten Sie auf Größenlimits der Mail-Anhänge und des Connectors.

3) Prompting für Azure OpenAI

Der Flow baut die Nachricht ungefähr so:

{OCR-Volltext aus Words}.
befülle diese felder richtig:
  "Invoice Header",
  ListTemplateType.GenericList,
  ...
  "Invoice Lines",
  ...
und gebe mir das Ergebnis als JSON aus

Verbesserungsvorschläge:

  • Ergänzen Sie eine System-Message („Du bist ein strenger JSON-Generator…“).
  • Nutzen Sie response_format: { "type": "json_object" } (falls verfügbar), damit garantiert valide JSON zurückkommt.
  • Erzwingen Sie ISO-Datumsformat (YYYY-MM-DD) und Dezimalpunkt (.) im Prompt, um spätere @decimal()-Konvertierungen sicher zu machen.

4) JSON-Schema & Casting

Das verwendete Parse JSON-Schema deklariert die Felder als string. Danach werden Beträge beim Schreiben nach Dataverse via Expression konvertiert:

"cr9c6_totalgross": "@decimal(body('Parse_JSON')?['header']?['TotalGross'])"

Achten Sie hier unbedingt auf Locale-Effekte (Komma vs. Punkt). Lassen Sie das LLM Beträge mit Punkt liefern oder normalisieren Sie in der Azure Function.


Azure Function (isolated worker) als „JSON-Guard“

Die Function „ReadChatGPTMessageAndConvertToCSharp“ bekommt das choices-Array und gibt reines JSON zurück. Typische Aufgaben:

  • Ersten Choice-Text extrahieren (choices[0].message.content).
  • Markdown-Codefences entfernen.
  • JSON validieren; bei Fehlern korrigieren (z. B. fehlende Kommas, falsche Quotes).
  • Optional: Mapping auf C#-DTOs und erneut serialisieren (sauberer Schlüssel-Satz, Nummern als decimal).

ReadChatGPTMessageAndConvertToCSharp Funktion

using System;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using Google.Protobuf.WellKnownTypes;
using System.Xml.Linq;

namespace ConvertChatGPTMessageToCSharpStructure
{
    public class ReadChatGPTMessageAndConvertToCSharp
    {
        private readonly ILogger<ReadChatGPTMessageAndConvertToCSharp> _logger;

        public ReadChatGPTMessageAndConvertToCSharp(ILogger<ReadChatGPTMessageAndConvertToCSharp> logger)
            => _logger = logger;

        [Function("ReadChatGPTMessageAndConvertToCSharp")]
        public async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequest req)
        {
            _logger.LogInformation("C# HTTP trigger function processed a request.");

            // 1) kompletten Body einlesen
            string body;
            using (var rdr = new StreamReader(req.Body))
                body = await rdr.ReadToEndAsync();

            if (string.IsNullOrWhiteSpace(body))
                return new BadRequestObjectResult("Bitte JSON im Body mitgeben.");

            try
            {
                // 2) Als JsonNode parsen (wir erwarten ein Array ganz oben)
                var root = JsonNode.Parse(body);
                if (root == null || root is not JsonArray arr || arr.Count == 0)
                    return new BadRequestObjectResult("Erwartet ein Array mit mindestens einem Element.");

                // 3) message.content herausziehen
                var messageNode = arr[0]?["message"] as JsonObject;
                var content = messageNode?["content"]?.GetValue<string>() ?? "";
                if (string.IsNullOrWhiteSpace(content))
                    return new BadRequestObjectResult("Kein message.content gefunden.");

                // 4) Den JSON‑Block aus den ```json … ``` Fences extrahieren
                var start = content.IndexOf('{');
                var end = content.LastIndexOf('}');
                if (start < 0 || end < 0 || end <= start)
                    return new BadRequestObjectResult("Kein JSON‑Block in content gefunden.");
                var jsonBlock = content[start..(end + 1)];

                // 5) JSON‑Block parsen
                var invoiceRoot = JsonNode.Parse(jsonBlock)?.AsObject();
                if (invoiceRoot == null)
                    return new BadRequestObjectResult("Ungültiges JSON im content.");

                // 6) Header flach auslesen
                var headerNode = invoiceRoot["Invoice Header"]?.AsObject();
                var headerDict = new Dictionary<string, string>();
                foreach (var kv in headerNode)
                {
                    var node = kv.Value;
                    string extracted;

                    if (node is JsonArray arr2 && arr2.Count > 2)
                    {
                        // Schema: ["label","type","value"]
                        extracted = arr2[2]?.GetValue<string>() ?? "";
                    }
                    else if (node is JsonObject obj && obj.TryGetPropertyValue("value", out var vNode))
                    {
                        // Schema: { "label": "...", "type": "...", "value": "AR-4200041640" }

                        extracted = vNode.ToString();
                    }
                    else
                    {
                        // Fallback: komplettes JSON, als String
                        extracted = node?.ToJsonString().Trim('"') ?? "";
                    }

                    headerDict[kv.Key] = extracted;
                }

                // 7) Lines auslesen
                var linesList = new List<Dictionary<string, string>>();
                if (invoiceRoot["Invoice Lines"] is JsonArray linesArr)
                {
                    foreach (var item in linesArr)
                    {
                        if (item is JsonObject obj)
                        {
                            var dict = new Dictionary<string, string>();
                            foreach (var kv in obj)
                            {
                                dict[kv.Key] = kv.Value?.ToJsonString().Trim('"') ?? "";

                                dict[kv.Key] = dict[kv.Key]
                                                .Replace("\\", "")   // remove backslashes
                                                .Replace("\"", "");  // remove double‑quotes
                            }
                            linesList.Add(dict);
                        }
                    }
                }

                // 8) Logging (optional)
                _logger.LogInformation("Parsed Header:");
                foreach (var kv in headerDict)
                    _logger.LogInformation("{0} = {1}", kv.Key, kv.Value);
                _logger.LogInformation("Parsed {0} Lines", linesList.Count);

                // 9) Ergebnis zurückgeben
                var result = new
                {
                    Header = headerDict,
                    Lines = linesList
                };
                return new OkObjectResult(result);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Fehler beim Verarbeiten des Payloads");
                return new BadRequestObjectResult($"Parsing-Fehler: {ex.Message}");
            }
        }
    }
}

Dataverse: nur Header? Und was ist mit den Zeilen?

Der Flow legt bislang nur den Header in cr9c6_invoiceheaders an. Für eine vollständige Lösung:

  • Header anlegen und ID (cr9c6_invoiceheaderid) aus der Antwort speichern.
  • Apply to each über body('Parse JSON')?['lines'].
  • In jeder Iteration Add a new row in cr9c6_invoicelines (oder Ihre Tabelle).
  • Lookup-Spalte auf den Header per GUID setzen (nicht über InvoiceNumber „joinen“).

Das ist robuster, da Rechnungsnummern nicht immer eindeutig sind.


Fehlerbehandlung & Wiederholbarkeit

  • „Configure run after“ für die KI-Schritte: Bei Fehlversuch → SharePoint „Fehlerqueue“ mit Mail-ID, Attachment-Name, Run-URL.
  • Idempotenz: Deduplizieren nach MessageId + AttachmentId. Eine einfache Dataverse-Spalte mit Hash aus beidem verhindert Doppelanlagen.
  • Parallelität: Setzen Sie Concurrency im For each auf 1, falls Ihre nachgelagerten Systeme keine Parallel-Creates mögen.
  • Logging: Aktivieren Sie Run History & Tracked Properties. Für die Function: Application Insights (Request, Dependencies, Custom Dimensions).

Sicherheit & Governance

  • Managed Identity nutzen, wo möglich (z. B. bei Form Recognizer und Azure Functions), und Secrets aus Verbindungen auf ein Minimum reduzieren.
  • DLP-Policies in Power Platform: KI-Connectoren (OpenAI) und Dataverse sollten in einer erlaubten Data Group liegen.
  • PII-Minimierung: Speichern Sie niemals den vollständigen OCR-Text dauerhaft, sondern nur extrahierte Felder und die Originaldatei im DMS.

Kosten & Limits (kurz & praxisnah)

  • Form Recognizer (Read): abgerechnet pro Seite. Für große Scans kann ein Seitenlimit helfen.
  • Azure OpenAI: Tokenbasiert (Eingabe+Ausgabe). Reduzieren Sie Prompt-Länge, indem Sie nur die relevanten Seiten oder den erkannten Abschnitt (z. B. „Invoice Header“) an das Modell übergeben.
  • Dataverse: API-Limits im Blick behalten (Burst-Creates bei vielen Anhängen).

(Präzise Preise ändern sich – im Projekt bitte die aktuelle Kalkulation prüfen.)


Häufige Stolperfallen & Fixes

  • Zahlenformat: „1.234,56“ vs. „1234.56“. Im Prompt Dezimalpunkt

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert