ลองเขียน C# WebAPI เรียกใช้ Azure Document Intelligent อ่านใบเสร็จ

จาก Blog ที่แล้วผมได้แนะนำ

  • สมัคร Azure + เปิดใช้ Azure Document Intelligent
  • curl / postman เพื่อเทส เราจะรู้ว่ามันยิง 2 รอบ
    - รอบแรก Request ส่งเอกสารเข้าไป เราจะได้ Id ตัว apim-request-id
    - รอบที่สอง ส่งอีก Request ไปกับ apim-request-id เพื่อเอาผลลัพธ์ออกมาครับ
  • pre-built model ผลลัพธ์มันจะออกตาม Schema ที่กำหนด อย่างของ Reciepts ตามนี้ Supported document fields 

รายละเอียดเต็มๆจาก Blog ด้านล่างเลยครับ

มาใน Blog นี้บ้าง อันนี้ อันนี้มาลองเขียน Code แล้วครับ โดยจะเป็นการทำงานลอง Concept โดยมี 2 แบบ

ก่อนจะเริ่มกัน ผมลองสร้าง WebAPI แบบง่าย เปิด API ให้ Upload File ตัว pdf โยนเข้าไป จากนั้นฝั่ง WebAPI เข้าไปทำหน้าที่ติดต่อกับ Azure Document Intelligent และส่งผลลัพธ์ตามแต่ละ Endpoint กลับมาครับ

การทดสอบสามารถใช้ Postman / REST Client เลือก form-data ชื่อ field "file" และก็โยนใบเสร็จเข้าไปได้เลยครับ ใน Repo ผมจะมีตัวอย่างแบบที่ใช้ REST Client อยู่ .http

C# + Azure Document Intelligent REST API

อันนี้เหมือนอันที่แล้วเลย จากเดิมเรา Manual ยิง REST จาก curl / postman มาเป็น Coding แทนครับ ผมทำ API ง่ายๆ เลย โดยมี Step คร่าวๆ ตามนี้

  • new dotnet webapi ขึ้นมาครับ อย่างของผมมันจะ default เป็น dotnet 9 เลยครับ
dotnet new webapi
  • มากำหนดโครงสร้าง Project กัน
MyApi/
├── Controllers
│   ├── InvoiceRecognizerController.cs
├── Services
│   ├── InvoiceRecognizerService.cs
├── Program.cs
├── appsettings.json
  • มาที่ appsettings.json เพิ่ม Section สำหรับเก็บ Config ในการต่อ Azure Document Intelligent REST API
ตรงนี้จาก Endpoint กับ Key ไปใส่
"DocumentIntelligentAPI": {
    "Endpoint": "YOUR_ENDPOINT",
    "ApiKey": "YOUR_API_KEY",
    "ApiVersion": "YOUR_API_VERSION"
  }

ส่วนตรงนี้ API Version ดูจาก doc Document Models - Analyze Document
ปล. ผม เพิ่งรู้ว่ามี Version ใหม่ตอนเขียน Blog เนี่ยแหละ

  • มาทำที่ Controllers InvoiceRecognizerController.cs อันนี้ pass request ไป code ครับ โดยมี Endpoint {{base_url}}/api/invoicerecognizer/analyze
  • ต่อมาส่วน Services InvoiceRecognizerService.cs อันนี้ Code ง่ายเลยครับ เรารู้แล้วว่าต้องยิง 2 รอบ ส่งไฟล์ / เอา id มาแสดงผลลัพธ์
public async Task<JsonDocument> AnalyzeDocumentAsync(Stream documentStream)
{
    HttpRequestMessage analyzeRequest = new HttpRequestMessage
    {
        Method = HttpMethod.Post,
        RequestUri = new Uri(
            $"{_endpoint}/formrecognizer/documentModels/prebuilt-receipt:analyze?api-version={_apiversion}"
        ),
        Content = new StreamContent(documentStream)
    };

    analyzeRequest.Headers.Add("Ocp-Apim-Subscription-Key", _apiKey);
    analyzeRequest.Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");

    HttpResponseMessage analyzeResponse = await _httpClient.SendAsync(analyzeRequest);

    // Check if the response is successful
    analyzeResponse.EnsureSuccessStatusCode();

    if (!analyzeResponse.Headers.Contains("apim-request-id"))
    {
        throw new InvalidOperationException(
            "Response does not contain the header 'apim-request-id'."
        );
    }

    string requestId = analyzeResponse.Headers.GetValues("apim-request-id").FirstOrDefault() ?? string.Empty;

    //===============================================================
    string status = "running";
    JsonDocument result = null;
    while (status == "running" || status == "notStarted")
    {
        HttpRequestMessage resultRequest = new HttpRequestMessage
        {
            Method = HttpMethod.Get,
            RequestUri = new Uri($"{_endpoint}/formrecognizer/documentModels/prebuilt-receipt/analyzeResults/{requestId}?api-version={_apiversion}")
        };
        resultRequest.Headers.Add("Ocp-Apim-Subscription-Key", _apiKey);

        HttpResponseMessage resultResponse = await _httpClient.SendAsync(resultRequest);
        resultResponse.EnsureSuccessStatusCode();

        String resultJson = await resultResponse.Content.ReadAsStringAsync();
        result = JsonDocument.Parse(resultJson);

        status = result.RootElement.GetProperty("status").GetString();

        if (status == "running")
        {
            // Wait for a specific interval before polling again
            await Task.Delay(TimeSpan.FromSeconds(1));
        }

        if (status == "failed")
        {
            throw new Exception("Document analysis failed.");
        }
    }

    if ((result != null) && (status == "succeeded"))
    {
        return result;
    }
    else
    {
        throw new Exception("Document analysis failed.");
    }
}

ที่นี้เราจะเห็นว่าผมมีดัก Status running / failed / succeeded อันนี้ผมไปดูจาก API Doc ของมัน ส่วน AnalyzeOperation

  • program.cs อันนี้ register service / controller ครับ

ลองยิง REST API ตอนนี้ผลลัพธ์ที่ได้จะมาเยอะๆ เหมือนกันที่ลองใน Blog ก่อนหน้า อันนี้ง่ายขึ้นด้วย ไม่ต้องมาแปลง base64 แล้ว กำหนด REST + เลือก Body เป็น From File ได้เลย

ต่อมาลอง Filter ให้เล็กลง เพราะรู้ Supported document fields  เราจะมาลองดึงยอดรวม / tax ออกมาครับ จาห JSON ที่เป็น Result มันจะมีส่วนของ documents > fields มีข้อมูลที่ผมต้องการอ่านในเสร็จครับ MerchantName / Total / TotalTax / TransactionDate และ ถ้าดูจากรูปแต่ละ Field จะบอก confidence ด้วย

เท่าที่ผมลองมาพวก pdf ตัวพิมพ์ ที่ไม่ใช่แบบสแกนมา ความแม่นยำ (Confident) ในแต่ละ Field สูงครับ

ที่นี้ เราลองเอา JsonDocument ที่ได้จาก Method AnalyzeDocumentAsync มาแกะต่อแบบง่ายๆ ดังนี้

public async Task<ExtractReceiptDTO> ExtractReceipt(Stream documentStream)
{
    JsonDocument result = await AnalyzeDocumentAsync(documentStream);

    // Extract the documents property and count the number of documents
    JsonElement documents = result.RootElement.GetProperty("analyzeResult").GetProperty("documents");
    if (documents.GetArrayLength() == 0)
    {
        throw new Exception("Document analysis failed.");
    }
        
    //result.Documents can contain multiple documents, but we only get first one in this example
    JsonElement fields = result.RootElement
            .GetProperty("analyzeResult")
            .GetProperty("documents")[0].GetProperty("fields");

    // Extract the values
    string merchantName = fields.GetProperty("MerchantName").GetProperty("valueString").GetString();
    double total = fields.GetProperty("Total").GetProperty("valueNumber").GetDouble();
    double totalTax = fields.GetProperty("TotalTax").GetProperty("valueNumber").GetDouble();
    DateTime transactionDate = fields.GetProperty("TransactionDate").GetProperty("valueDate").GetDateTime();

    return new ExtractReceiptDTO
    {
        MerchantName = merchantName,
        Total = total,
        TotalTax = totalTax,
        TransactionDate = transactionDate
    };
}

พอผ่าน Controller {baseurl}/api/InvoiceRecognizer/extractreceipt (ถ้าจะไล่ Code แกะจาก path นี้ได้) ผลลัพธ์เป็น JSON สรุปประมาณนี้ครับ

{
    "merchantName": "Coffee Concepts Retail Co., Ltd.",
    "total": 75,
    "totalTax": 4.91,
    "transactionDate": "2024-02-14T00:00:00"
}

ที่นี้เอาข้อมูลตรงนี้เก็บลง DB / Spreadsheet ทำข้อมูลสรุปได้แล้วครับ

C# + Azure Document Intelligent SDK

นอกจากยิง REST API แล้ว ทาง Azure เค้ามีตัว SDK เป็น Library ในรูปแบบ NuGet Package นี่แหละ

จาก project เดิม ผมติดตั้ง NuGet Package ใน vscode ส่วน terminal run คำสั่งดังนี้

dotnet add package Azure.AI.DocumentIntelligence --version 1.0.0

ใน Program.cs ผมจะลองเพิ่มการ intial ตัว DocumentIntelligenceClient ลงไป

string endpoint = builder.Configuration.GetSection("DocumentIntelligentAPI:Endpoint").Value;
string apiKey = builder.Configuration.GetSection("DocumentIntelligentAPI:ApiKey").Value;

builder.Services.AddSingleton<DocumentIntelligenceClient>(new DocumentIntelligenceClient(new Uri(endpoint), new AzureKeyCredential(apiKey)));

Code เดิมที่ผมทำไปตอนปีก่อน DocumentAnalysisClient มันจะของ Azure.AI.FormRecognizer จะงงๆหน่อยครับ เปลี่ยนชื่อ Service พอมาเขียน Blog เลยงงๆ หน่อย ถ้าเจออันไหนยังเขียนเป็น DocumentAnalysisClient ทักได้นะครับ

ต่อจากนั้นผมจะเอาให้ DI Inject DocumentIntelligenceClient มาได้แล้ว ใน Controller เพิ่มให้ส่ง pDocumentIntelligenceClient มาด้วย

[ApiController]
[Route("api/[controller]")]
public class InvoiceRecognizerController : ControllerBase
{
    private readonly InvoiceRecognizerService _invoiceRecognizerService;
    private readonly DocumentIntelligenceClient _documentIntelligenceClient;

    public InvoiceRecognizerController(InvoiceRecognizerService pInvoiceRecognizerService, DocumentIntelligenceClient pDocumentIntelligenceClient)
    {
        _invoiceRecognizerService = pInvoiceRecognizerService;
        _documentIntelligenceClient = pDocumentIntelligenceClient;
    }
    ....

ที่นี้จากเดิมใน Endpoint analyze (Method AnalyzeDocument) เราจะเพิ่มอีกอัน AnalyzeDocumentWithSDK โดยเรียกใช้ documentIntelligenceClient.AnalyzeDocumentAsync ซึ่งไฟล์ที่เราโยนเข้ามันส่งให้ Service Azure ในรูปแบบ Binary ครับ (ถ้าดูจากแบบ Rest ก็ Method InvoiceRecognizerService.AnalyzeDocumentAsync ครับ)

[HttpPost("analyzesdk")]
public async Task<IActionResult> AnalyzeDocumentWithSDK(IFormFile file)
{
    if (file == null || file.Length == 0)
    {
        return BadRequest("No file uploaded.");
    }

    using (var stream = new MemoryStream())
    {
        await file.CopyToAsync(stream);
        stream.Position = 0;

        var binaryData = BinaryData.FromStream(stream);
        var modelId = "prebuilt-receipt";

        try
        {
            Operation<AnalyzeResult> operation = await _documentIntelligenceClient.AnalyzeDocumentAsync(WaitUntil.Completed, modelId, binaryData);
            AnalyzeResult result = operation.Value;
            return Ok(result);
        }
        catch (RequestFailedException ex)
        {
            return StatusCode((int)ex.Status, ex.Message);
        }
    }
}

นอกจากส่งเป็นแบบ Binary แล้วยังมีส่งตัว base64 ได้ด้วยนะ ผมลองทำตัวอย่างไว้ในอีก endpoint analyzesdk2 (ชื่อสิ้นคิด) ลองครับ

สำหรับการ Extract ข้อมูล มันจะมี Class Strcuture ชัดเจนครับ อย่าง Class AnalyzeResult / AnalyzedDocument ให้เราเข้าไปดึงข้อมูล และจัดการ Type ตามใน Endpoint extractreceiptsdk / method ExtractReceiptWithSDK

[HttpPost("extractreceiptsdk")]
public async Task<IActionResult> ExtractReceiptWithSDK(IFormFile file)
{
    if (file == null || file.Length == 0)
    {
        return BadRequest("No file uploaded.");
    }

    using (var stream = new MemoryStream())
    {
        await file.CopyToAsync(stream);
        stream.Position = 0;

        var binaryData = BinaryData.FromStream(stream);
        var modelId = "prebuilt-receipt";

        try
        {
            Operation<AnalyzeResult> operation = await _documentIntelligenceClient.AnalyzeDocumentAsync(WaitUntil.Completed, modelId, binaryData);
            AnalyzeResult result = operation.Value;

            if (result.Documents.Count == 0)
            {
                return BadRequest("No document was recognized.");
            }
            //result.Documents can contain multiple documents, but we only get first one in this example
            AnalyzedDocument document = result.Documents[0];

            string _MerchantName = "N/A";
            if (document.Fields.TryGetValue("MerchantName", out DocumentField MerchantNameField)
                && MerchantNameField.FieldType == DocumentFieldType.String)
            {
                _MerchantName = MerchantNameField.ValueString;
            }

            double? _Total = null;
            if (document.Fields.TryGetValue("Total", out DocumentField TotalField)
                && TotalField.FieldType == DocumentFieldType.Currency)
            {
                _Total = TotalField.ValueCurrency.Amount;
            }

            double? _TotalTax = null;
            if (document.Fields.TryGetValue("Total", out DocumentField TotalTaxField)
                && TotalTaxField.FieldType == DocumentFieldType.Currency)
            {
                _TotalTax = TotalTaxField.ValueCurrency.Amount;
            }

            DateTimeOffset? _TransactionDate = null;
            if (document.Fields.TryGetValue("TransactionDate", out DocumentField TransactionDateField)
                && TransactionDateField.FieldType == DocumentFieldType.Date)
            {
                _TransactionDate = TransactionDateField.ValueDate;
            }

            ExtractReceiptDTO extractReceiptDTO = new ExtractReceiptDTO
            {
                MerchantName = _MerchantName,
                Total = _Total.GetValueOrDefault(0),
                TotalTax = _TotalTax.GetValueOrDefault(0),
                TransactionDate = _TransactionDate.GetValueOrDefault(DateTimeOffset.MinValue).DateTime
            };
            return Ok(extractReceiptDTO);
        }
        catch (RequestFailedException ex)
        {
            return StatusCode((int)ex.Status, ex.Message);
        }
    }
}

ถ้าอยากรู้เพิ่มเติมสามารถดู Doc ได้ตามนี้ครับ Azure Document Intelligence client library for .NET

ลอง Run - ผมลัพธ์คล้ายกันจาก RESTAPI เรียกว่าคล้าย เพราะของผมช่อง merchantName ระหว่างยิงผ่าน API / SDK มันออกมาไม่เหมือนกัน

จริงมันมีเรื่องอื่นๆ แบบพวกถ้าเอาไฟล์ที่สแกนเข้าไป จะเจอค่าเพื้ยน ตอน Code อาจจะต้องดู confidence มาเสริมด้วยนะ

สรุป

พอได้ตรงนี้แล้วหลายคนน่าจะมี Idea ทำต่อครับ อย่างผมเอาข้อมูลตรงนี้เก็บลง DB / Spreadsheet ทำข้อมูลสรุปได้แล้วครับ ผมทำใช้เองมาพักนึงเหมือนกันครับ เดี๋ยวค่อยมาเขียน Blog ขยายเรื่อยๆ ^__^

Code ทั้งหมด dotnet9 นะ

ตามนี้ครับ https://github.com/pingkunga/sampledotnetcallazureaidoc ตอนแรกตั้งใจแบบจะเอาของเดิมที่ทำใช้ส่วนตัวเมื่อปลายปี 2023 มาเขียน Blog แต่พอมาลองแล้ว SDK มีเปลี่ยนเลยลองตัวใหม่เลย

เดี๋ยวถ้าว่างแปลงของเดิมมาเป็น ver ใหม่เสร็จ จะมีเขียนอีก Blog แชร์ไว้อีกทีครับ หวังว่าเปลี่ยนเสร็จ Service จะไม่เปลี่ยนชื่อนะ 555


Discover more from naiwaen@DebuggingSoft

Subscribe to get the latest posts sent to your email.