Thursday, April 25, 2024

.NET Aspire with VS Code, SQLite & SQL Server

In this Tutorial, we will explore .NET Aspire. At first, we will use it with SQLite. Thereafter, we will modify the solution so that it uses a SQL Server Docker image instead of SQLite. All this is done in a terminal window with the help of VS Code. The objective is to serve those who do not use the higher end Visual Studio 2022 IDE.

.NET Aspire Setup

In any terminal window folder, run the following command before you install .NET Aspire:

dotnet workload update 

To install the .NET Aspire workload from the .NET CLI, execute this command:

dotnet workload install 

Check your version of .NET Aspire, with this command:

dotnet workload list

Startup Application

We will start with a .NET 8.0 application that involves a Minimal API backend and a Blazor frontend. Grab the code from this GitHub site:

https://github.com/medhatelmasry/SoccerFIFA

To get a good sense of what the application does, follow these steps:

1) Inside the WebApiFIFA folder, run the following command in a terminal window:

dotnet watch


Try out the GET /api/games endpoint, you should see the following output:


2) Next, let us try the frontend. Inside a terminal window in the BlazorFIFA folder, run this command:

dotnet watch


We know that the application works. However, it is a pain to have to start both projects to get the solution to work. This is where .NET Aspire will come to the rescue.

Converting solution to .NET Aspire

Close both terminal windows by hitting CTRL C in each.

TO add the basic .NET Aspire projects to our solution, run the following command inside the root SoccerFIFA folder:

dotnet new aspire

This adds these artifacts:

  • SoccerFIFA.sln file
  • SoccerFIFA.AppHost folder
  • SoccerFIFA.ServiceDefaults folder

We will add our previous API & Blazor projects to the newly created .sln file by executing the following commands inside the root SoccerFIFA folder:

dotnet sln add ./BlazorFIFA/BlazorFIFA.csproj
dotnet sln add ./WebApiFIFA/WebApiFIFA.csproj
dotnet sln add ./LibraryFIFA/LibraryFIFA.csproj

Next, we need to add references in the SoccerFIFA.AppHost project to the BlazorFIFA and WebApiFIFA projects with these commands:

dotnet add ./SoccerFIFA.AppHost/SoccerFIFA.AppHost.csproj reference ./BlazorFIFA/BlazorFIFA.csproj

dotnet add ./SoccerFIFA.AppHost/SoccerFIFA.AppHost.csproj reference ./WebApiFIFA/WebApiFIFA.csproj

Also, both BlazorFIFA and WebApiFIFA projects need to have references into SoccerFIFA.ServiceDefaults with:

dotnet add ./BlazorFIFA/BlazorFIFA.csproj reference ./SoccerFIFA.ServiceDefaults/SoccerFIFA.ServiceDefaults.csproj

dotnet add ./WebApiFIFA/WebApiFIFA.csproj reference ./SoccerFIFA.ServiceDefaults/SoccerFIFA.ServiceDefaults.csproj

Inside the SoccerFIFA root folder, start VS Code with:

code .

Then, in the Program.cs files of both BlazorFIFA and WebApiFIFA, add this code:

// Add service defaults & Aspire components.
builder.AddServiceDefaults();

In the Program.cs file in SoccerFIFA.AppHost, add this code right before “builder.Build().Run();”:

var api = builder.AddProject<Projects.WebApiFIFA>("backend");
builder.AddProject<Projects.BlazorFIFA>("frontend")
    .WithReference(api);

Now, the relative name for the API app is “backend”. Therefore, we can change the base address to http://backend. Change Constants.cs file in BlazorFIFA to:

public const string ApiBaseUrl = "http://backend/";

Test .NET Aspire Solution

To test the solution, in the SoccerFIFA.AppHost folder, start the application with:

dotnet run

Copy the URL that looks like that underlined below and paste it into your browser:


This is what you should see in your browser:


Click on the app represented by the link on the second row. You should experience the Blazor app:


At this stage we get a sense of what .NET Aspire can do for us. It essentially orchestrates the connection between multiple projects and produce a single starting point in the Host project. Let us take this one step further by converting our backend API so it uses a SQL Server container instead of SQLite.

Using SQL Server instead of SQLite

Stop the application by hitting CTRL C.

IMPORTANT: You will need to ensure that Docker Desktop is running on your computer because SQL Server will start in a container.

Add this package to the WebApiFIFA project:

dotnet add package Aspire.Microsoft.EntityFrameworkCore.SqlServer -v 8.0.0-preview.6.24214.1

Also in WebApiSoccer project Program.cs file, comment out (or delete) this code:

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlite(connectionString));

Place the below code just before builder.AddServiceDefaults():

builder.AddSqlServerDbContext<ApplicationDbContext>("sqldata");

In the same the Program.cs file, add the following code right before app.Run() to automatically run Entity Framework migrations during startup:

if (app.Environment.IsDevelopment())
{
    using (var scope = app.Services.CreateScope())
    {
        var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
        context.Database.EnsureCreated();
    }
}
else
{
    app.UseExceptionHandler("/Error", createScopeForErrors: true);
    // The default HSTS value is 30 days.
    // You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}

Cleanup the backend API solution from all traces of SQLite with:

  1. Delete SQLite files college.db, college.db-shm, and college.db-wal.
  2. In WebApiFIFA.csproj, delete: <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.4" />
  3. Delete the Data/Migrations folder.
We will create new migrations that work with SQL Server, instead of SQLite. Therefore, run the following command from within a terminal window inside folder WebApiFIFA.

dotnet ef migrations add M1 -o Data/Migrations

Configure AppHost to use SQL Server

The AppHost project is the orchestrator for your app. It's responsible for connecting and configuring the different projects and services of your app. Add the .NET Aspire Entity Framework Core Sql Server library package to your SoccerFIFA.AppHost project with:

dotnet add package Aspire.Hosting.SqlServer -v 8.0.0-preview.6.24214.1

Update the contents of the Program.cs file in AppHost project with the following code:

var builder = DistributedApplication.CreateBuilder(args);
var sql = builder.AddSqlServer("sql")
                 .AddDatabase("sqldata");
var api = builder.AddProject<Projects.WebApiFIFA>("backend")
    .WithReference(sql);
    
builder.AddProject<Projects.BlazorFIFA>("frontend")
    .WithReference(api);
 
builder.Build().Run();

The preceding code adds a SQL Server Container resource to your app and configures a connection to a database called sqldata. The Entity Framework classes you configured earlier will automatically use this connection when migrating and connecting to the database.

Run SQL Server Solution

Run the application inside the SoccerFIFA.AppHost folder with:

dotnet run

As we did before, copy and paste the URL from the terminal window into your browser:


Your browser will look like this:


Click on the highlighted link above. Our application works just as it did before. The only difference is that this time we are using SQL Server running in a container:


I hope you found this useful and are able to see the possibilities of this new addition to .NET.

Thursday, February 29, 2024

OpenAI Function Calling with Semantic Kernel, C#, & Entity Framework

In this article, we will create a Semantic Kernel plugin that contains four functions that interact with live SQLite data. Entity Framework will be used to access the SQLite database. The end result is to use the powers of the OpenAI natural language models to ask questions and get answers about our custom data.

Source code: https://github.com/medhatelmasry/EfFuncCallSK

Companion Video: https://youtu.be/4sKRwflEyHk

Getting Started

Let’s start by creating an ASP.NET Razor pages web application. Select a suitable working folder on your computer, then enter the following terminal window commands:

dotnet new razor --auth individual -o EfFuncCallSK
cd EfFuncCallSK

Te above creates a Razor Pages app with support for Entity Framework and SQLite.

Add these packages:

dotnet add package CsvHelper
dotnet add package Microsoft.SemanticKernel 
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet add package Microsoft.EntityFrameworkCore.Tools
dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.SQLite.Design

The CsvHelper package will help us load a list of products from a CSV file named students.csv and hydrate a list of Student objects. The second package is needed to work with Semantic Kernel. The rest of the packages support Entity Framework and SQLite.

Let’s Code

appsettings.json

Add these to appsettings.json:

"AIService": "OpenAI", /* Azure or OpenAI */
"AzureOpenAiSettings": {
   "Endpoint": "https://YOUR_RESOURCE_NAME.openai.azure.com/",
   "Model": "gpt-35-turbo",
   "ApiKey": "fake-key-fake-key-fake-key-fake-key"
},
"OpenAiSettings": {
  "ModelType": "gpt-3.5-turbo",
  "ApiKey": "fake-key-fake-key-fake-key-fake-key"
}

The first setting allows you to choose between using OpenAI or Azure OpenAI.

Of course, you need to adjust the endpoint setting with the appropriate value that pertains to the OpenAI and Azure OpenAI services. Also, enter the correct value for the ApiKey.

NOTE: You can use OpenAI or Azure OpenAI, or both.

Data

Create a folder named Models. Inside the Models folder, add the following Student class: 

public class Student {
   public int StudentId { get; set; }
   public string? FirstName { get; set; }
   public string? LastName { get; set; }
   public string? School { get; set; }
 
   public override string ToString() {
      return $"Student ID: {StudentId}, First Name: {FirstName}, Last Name: {LastName}, School: {School}";
   }
}

Developers like having sample data when building data driven applications. Therefore, we will create sample data to ensure that our application behaves as expected. Copy the following data and save it in a text file wwwroot/students.csv:

StudentId,FirstName,LastName,School
1,Tom,Max,Nursing
2,Ann,Fay,Mining
3,Joe,Sun,Nursing
4,Sue,Fox,Computing
5,Ben,Ray,Mining
6,Zoe,Cox,Business
7,Sam,Ray,Mining
8,Dan,Ash,Medicine
9,Pat,Lee,Computing
10,Kim,Day,Nursing
11,Tim,Rex,Computing
12,Rob,Ram,Nursing
13,Jan,Fry,Mining
14,Jim,Tex,Nursing
15,Ben,Kid,Business
16,Mia,Chu,Medicine
17,Ted,Tao,Computing
18,Amy,Day,Nursing
19,Ian,Roy,Nursing
20,Liz,Kit,Nursing
21,Mat,Tan,Medicine
22,Deb,Roy,Medicine
23,Ana,Ray,Mining
24,Lyn,Poe,Computing
25,Amy,Raj,Nursing
26,Kim,Ash,Mining
27,Bec,Kid,Nursing
28,Eva,Fry,Computing
29,Eli,Lap,Business
30,Sam,Yim,Nursing
31,Joe,Hui,Mining
32,Liz,Jin,Nursing
33,Ric,Kuo,Business
34,Pam,Mak,Computing
35,Cat,Yao,Medicine
36,Lou,Zhu,Mining
37,Tom,Dag,Business
38,Stu,Day,Business
39,Tom,Gad,Mining
40,Bob,Bee,Business
41,Jim,Ots,Business
42,Tom,Mag,Business
43,Hal,Doe,Mining
44,Roy,Kim,Mining
45,Vis,Cox,Nursing
46,Kay,Aga,Nursing
47,Reo,Hui,Nursing
48,Bob,Roe,Mining
49,Jay,Eff,Computing
50,Eva,Chu,Business
51,Lex,Rae,Nursing
52,Lin,Dex,Mining
53,Tom,Dag,Business
54,Ben,Shy,Computing
55,Rob,Bos,Nursing
56,Ali,Mac,Business
57,Edi,Gee,Computing
58,Eva,Cao,Mining
59,Jun,Lam,Computing
60,Eli,Tao,Computing
61,Ana,Bay,Computing
62,Gil,Tal,Mining
63,Wes,Dey,Nursing
64,Nea,Tan,Computing
65,Ava,Day,Nursing
66,Rie,Ray,Business
67,Ken,Sim,Nursing

Add the following code inside the ApplicationDbContext class located inside the Data folder:

public DbSet<Student> Students => Set<Student>();    
 
protected override void OnModelCreating(ModelBuilder modelBuilder) {
    base.OnModelCreating(modelBuilder);
    modelBuilder.Entity<Student>().HasData(LoadStudents());
}  
 
// Load students from a csv file named students.csv in the wwwroot folder
public static List<Student> LoadStudents() {
    var students = new List<Student>();
    using (var reader = new StreamReader(Path.Combine("wwwroot", "students.csv"))) {
        using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
        students = csv.GetRecords<Student>().ToList();
    }
    return students;
}

Let us add a migration and subsequently update the database. Execute the following CLI commands in a terminal window.

dotnet ef migrations add Students -o Data/Migrations
dotnet ef database update

At this point the database and tables are created in a SQLite database named app.db.

Helper Methods

We need a couple of static helper methods to assist us along the way. In the Models folder, add a class named Utils and add to it the following class definition:

public class Utils {
  public static string GetConfigValue(string config) {
    IConfigurationBuilder builder = new ConfigurationBuilder();
    if (System.IO.File.Exists("appsettings.json"))
      builder.AddJsonFile("appsettings.json", false, true);
    if (System.IO.File.Exists("appsettings.Development.json"))
      builder.AddJsonFile("appsettings.Development.json", false, true);
    IConfigurationRoot root = builder.Build();
    return root[config]!;
  }
 
  public static ApplicationDbContext GetDbContext() {
    var optionsBuilder = new DbContextOptionsBuilder<ApplicationDbContext>();
    var connStr = Utils.GetConfigValue("ConnectionStrings:DefaultConnection");
    optionsBuilder.UseSqlite(connStr);
    ApplicationDbContext db = new ApplicationDbContext(optionsBuilder.Options);
    return db;
  }
}

Method GetConfigValue() will read values in appsettings.json from any static method. The second GetDbContext() method gets an instance of the ApplicationDbContext class, also from any static method.

Plugins

Create a folder named Plugins and add to it the following class file named StudentPlugin.cs with this code:

public class StudentPlugin {
  [KernelFunction, Description("Get student details by first name and last name")]
  public static string? GetStudentDetails(
  [Description("student first name, e.g. Kim")]
  string firstName,
  [Description("student last name, e.g. Ash")]
  string lastName
  ) {
    var db = Utils.GetDbContext();
    var studentDetails = db.Students
      .Where(s => s.FirstName == firstName && s.LastName == lastName).FirstOrDefault();
    if (studentDetails == null)
      return null;
    return studentDetails.ToString();
  }

  [KernelFunction, Description("Get students in a school given the school name")]
  public static string? GetStudentsBySchool(
    [Description("The school name, e.g. Nursing")]
    string school
  ) {
    var studentsBySchool = Utils.GetDbContext().Students
      .Where(s => s.School == school).ToList();
    if (studentsBySchool.Count == 0)
      return null;
    return JsonSerializer.Serialize(studentsBySchool);
  }


  [KernelFunction, Description("Get the school with most or least students. Takes boolean argument with true for most and false for least.")]
  static public string? GetSchoolWithMostOrLeastStudents(
    [Description("isMost is a boolean argument with true for most and false for least. Default is true.")]
    bool isMost = true
  ) {
    var students = Utils.GetDbContext().Students.ToList();
    IGrouping<string, Student>? schoolGroup = null;
    if (isMost)
      schoolGroup = students.GroupBy(s => s.School)
          .OrderByDescending(g => g.Count()).FirstOrDefault()!;
    else
        schoolGroup = students.GroupBy(s => s.School)
            .OrderBy(g => g.Count()).FirstOrDefault()!;
    if (schoolGroup != null)
      return $"{schoolGroup.Key} has {schoolGroup.Count()} students";
    else
      return null;
  }

  [KernelFunction, Description("Get students grouped by school.")]
  static public string? GetStudentsInSchool() {
    var students = Utils.GetDbContext().Students.ToList().GroupBy(s => s.School)
      .OrderByDescending(g => g.Count());
    if (students == null)
      return null;
    else
      return JsonSerializer.Serialize(students);
  }
}

 In the above code, there are four methods with these purposes:

GetStudentDetails()Gets student details given first and last names
GetStudentsBySchool()Gets students in a school given the name of the school
GetSchoolWithMostOrLeastStudents()Takes a Boolean value isMost – true returns school with most students and false returns school with least students.
GetStudentsInSchool()Takes no arguments and returns a count of students by school.

The User Interface

We will re-purpose the Index.cshtml and Index.cshtml.cs files so the user can enter a prompt in natural language and receive a response that comes from the OpenAI model working with our semantic kernel plugin. 

Index.chtml

Replace the content of Pages/Index.cshtml with:

@page
@model IndexModel
@{
    ViewData["Title"] = Model.Service + " Function Calling with Semantic Kernel";
}
<div class="text-center">
    <h3 class="display-6">@ViewData["Title"]</h3>
    <form method="post">
        <input type="text" name="prompt" size="80" required />
        <input type="submit" value="Submit" />
    </form>
    <div style="text-align: left">
        <h5>Example prompts:</h5>
        <p>Which school does Mat Tan go to?</p>
        <p>Which school has the most students?</p>
        <p>Which school has the least students?</p>
        <p>Get the count of students in each school.</p>
        <p>How many students are there in the school of Mining?</p>
        <p>What is the ID of Jan Fry and which school does she go to?</p>
        <p>Which students belong to the school of Business? Respond only in JSON format.</p>
        <p>Which students in the school of Nursing have their first or last name start with the letter 'J'?</p>
    </div>
    @if (Model.Reply != null)
    {
        <p class="alert alert-success">@Model.Reply</p>
    }
</div>

The above markup displays an HTML form that accepts a prompt from a user. The prompt is then submitted to the server and the response is displayed in a paragraph (<p> tag) with a green background (Bootstrap class alert-success).

Meantime, at the bottom of the page there are some suggested prompts – namely:

Which school does Mat Tan go to?
Which school has the most students?
Which school has the least students?
Get the count of students in each school.
How many students are there in the school of Mining?
What is the ID of Jan Fry and which school does she go to?
Which students belong to the school of Business? Respond only in JSON format.
Which students in the school of Nursing have their first or last name start with the letter 'J'?

Index.chtml.cs

Replace the IndexModel class definition in Pages/Index.cshtml.cs with:

public class IndexModel : PageModel {
  private readonly ILogger<IndexModel> _logger;
  private readonly IConfiguration _config;
 
  [BindProperty]
  public string? Reply { get; set; }
 
  [BindProperty]
  public string? Service { get; set; }
 
  public IndexModel(ILogger<IndexModel> logger, IConfiguration config) {
    _logger = logger;
    _config = config;
    Service = _config["AIService"]!;
  }
  public void OnGet() { }
  // action method that receives prompt from the form
  public async Task<IActionResult> OnPostAsync(string prompt) {
    // call the Azure Function
    var response = await CallFunction(prompt);
    Reply = response;
    return Page();
  }
 
  private async Task<string> CallFunction(string question) {
    string azEndpoint = _config["AzureOpenAiSettings:Endpoint"]!;
    string azApiKey = _config["AzureOpenAiSettings:ApiKey"]!;
    string azModel = _config["AzureOpenAiSettings:Model"]!;
    string oaiModelType = _config["OpenAiSettings:ModelType"]!;
    string oaiApiKey = _config["OpenAiSettings:ApiKey"]!;
    string oaiModel = _config["OpenAiSettings:Model"]!;
    string oaiOrganization = _config["OpenAiSettings:Organization"]!;
    var builder = Kernel.CreateBuilder();
    if (Service!.ToLower() == "openai")
      builder.Services.AddOpenAIChatCompletion(oaiModelType, oaiApiKey);
    else
      builder.Services.AddAzureOpenAIChatCompletion(azModel, azEndpoint, azApiKey);
    builder.Services.AddLogging(c => c.AddDebug().SetMinimumLevel(LogLevel.Trace));
    builder.Plugins.AddFromType<StudentPlugin>();
    var kernel = builder.Build();
    // Create chat history
    ChatHistory history = [];
    // Get chat completion service
    var chatCompletionService = kernel.GetRequiredService<IChatCompletionService>();
    // Get user input
    history.AddUserMessage(question);
    // Enable auto function calling
    OpenAIPromptExecutionSettings openAIPromptExecutionSettings = new() {
      ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions
    };
    // Get the response from the AI
    var result = chatCompletionService.GetStreamingChatMessageContentsAsync(
      history,
      executionSettings: openAIPromptExecutionSettings,
      kernel: kernel);
    string fullMessage = "";
    await foreach (var content in result) {
      fullMessage += content.Content;
    }
    // Add the message to the chat history
    history.AddAssistantMessage(fullMessage);
    return fullMessage;
  }
}

In the above code, the prompt entered by the user is posted to the OnPostAsync() method. The prompt is then passed to the CallFunction() method, which returns the final response from Azure OpenAI.

The CallFunction() method reads the OpenAI or Azure OpenAI settings from appsettings.json, depending on the AIService key.

A builder object is created from Semantic Kernel. If we are using OpenAI, then the AddOpenAIChatCompletion service is added. Otherwise, the AddAzureOpenAIChatCompletion service is added.

The StudentPlugin is then added to the builder object Plugins collection.

The builder Build() method is then called returning a kernel object. From the kernel object we then get a chatCompletionService object by calling the GetRequiredService() method.

Thereafter:

  • Add the prompt to the history
  • Make a call to the chat message service and receive a response
  • Concatenate response into a single string
  • Return the concatenated message

Trying the application

In a terminal window, at the root of the Razor Pages web application, enter the following command:

dotnet watch

The following page will display in your default browser:


You can enter any of the suggested prompts to ensure we are getting the proper results. I entered the last prompt and got these results:




Conclusion

We have seen how Semantic Kernel and Function Calling can be used with data coming from a database. In this example we are using SQLite. However, an other database source can be used using the same technique.

Thursday, February 15, 2024

Azure OpenAI Function Calling - a practical example using C# and ASP.NET Razor Pages

In as much as one can obtain valuable information by prompting the various OpenAI language models, it becomes even more valuable when these models can be integrated with custom systems and tools. In this demo, we will integrate an LLM with the Azure OpenAI Function Calling capability to query local data in the form of a products.csv file. Of course, this concept can easily be extended to more complex systems and tools. The sample application is based on ASP.NET Razor Pages. It receives a natural language prompt from the user and goes through this three-step process before responding:

  1. A call is made to a chat completions API with function definitions and the user’s prompt.
  2. The model’s response initiates calls to a custom function
  3. The chat completion API is called again with the response from the custom function, resulting in a final response.

Source Code: https://github.com/medhatelmasry/OaiFuncCall

Companion Video: https://youtu.be/3yyq3GWIj4o

Getting Started

Let’s start by creating an ASP.NET Razor pages web application. Select a suitable working folder on your computer, then enter the following terminal window commands:

dotnet new razor -o OaiFuncCall
cd OaiFuncCall

Add these packages:

dotnet add package CsvHelper
dotnet add package Azure.AI.OpenAI -v 1.0.0-beta.13

The CsvHelper package will help us load a list of products from a CSV file named products.csv and hydrate a list of Product objects. The second package is needed to work with Azure OpenAI.

Let’s Code

appsettings.json

Add this to appsettings.json:

"AzureOpenAiSettings": {
  "Endpoint": "https://YOUR_RESOURCE_NAME.openai.azure.com/",
  "Model": "gpt-35-turbo-16k",
  "ApiKey": "fake-key-fake-key-fake-key-fake-key"
}

Of course, you need to adjust the endpoint setting with the appropriate value that pertains to the Azure OpenAI service that you created. Also, enter the correct value for the ApiKey.

products.csv

Create a text file named products.csv in the wwwroot folder. Copy some sample data from  https://gist.github.com/medhatelmasry/b250023f3b4b5b14713cfc5165f1d030 and paste it into your products.csv file. 

Contents of products.csv looks like this:

ProductId,ProductName,UnitsInStock,UnitPrice
1,Aniseed Syrup,39,18
2,Chef Anton's Cajun Seasoning,17,19
3,Chef Anton's Gumbo Mix,13,10
4,Grandma's Boysenberry Spread,53,22
5,Uncle Bob's Organic Dried Pears,0,21.35
6,Northwoods Cranberry Sauce,120,25
7,Mishi Kobe Niku,15,30
8,Ikura,6,40
9,Queso Cabrales,29,97
10,Queso Manchego La Pastora,31,31
11,Konbu,22,21
12,Tofu,86,38
13,Genen Shouyu,24,6
14,Pavlova,35,23.25
15,Alice Mutton,39,15.5
16,Carnarvon Tigers,29,17.45
17,Teatime Chocolate Biscuits,0,39
18,Sir Rodney's Marmalade,42,62.5
19,Sir Rodney's Scones,25,9.2
20,Gustaf's KnŠckebršd,40,81
21,Tunnbršd,3,10
22,Guaran‡ Fant‡stica,104,21
23,NuNuCa Nu§-Nougat-Creme,61,9
24,GumbŠr GummibŠrchen,20,4.5
25,Schoggi Schokolade,76,14
26,Ršssle Sauerkraut,15,31.23
27,ThŸringer Rostbratwurst,49,43.9
28,Nord-Ost Matjeshering,26,45.6
29,Gorgonzola Telino,0,123.79
30,Mascarpone Fabioli,10,25.89
31,Geitost,0,12.5
32,Sasquatch Ale,9,32
33,Steeleye Stout,112,2.5
34,Inlagd Sill,111,14
35,Gravad lax,20,18
36,C™te de Blaye,112,19
37,Chartreuse verte,11,26
38,Boston Crab Meat,17,263.5
39,Jack's New England Clam Chowder,69,18
40,Singaporean Hokkien Fried Mee,123,18.4
41,Ipoh Coffee,85,9.65
42,Gula Malacca,26,14
43,Rogede sild,17,46
44,Spegesild,27,19.45
45,Zaanse koeken,5,9.5
46,Chocolade,95,12
47,Maxilaku,36,9.5
48,Valkoinen suklaa,15,12.75
49,Manjimup Dried Apples,10,20
50,Filo Mix,65,16.25
51,Perth Pasties,20,53
52,Tourtire,38,7
53,P‰tŽ chinois,0,32.8
54,Gnocchi di nonna Alice,21,7.45
55,Ravioli Angelo,115,24
56,Escargots de Bourgogne,21,38
57,Raclette Courdavault,36,19.5
58,Camembert Pierrot,62,13.25
59,Sirop d'Žrable,79,55
60,Tarte au sucre,19,34
61,Vegie-spread,113,28.5
62,Wimmers gute Semmelknšdel,17,49.3
63,Louisiana Fiery Hot Pepper Sauce,24,43.9
64,Louisiana Hot Spiced Okra,22,33.25
65,Laughing Lumberjack Lager,76,21.05
66,Scottish Longbreads,4,17
67,Gudbrandsdalsost,52,14
68,Outback Lager,6,12.5
69,Flotemysost,26,36
70,Mozzarella di Giovanni,15,15
71,Ršd Kaviar,26,21.5
72,Longlife Tofu,14,34.8
73,RhšnbrŠu Klosterbier,101,15
74,Lakkalikššri,4,10
75,Original Frankfurter grŸne So§e,125,7.75

Note that the above data was taken from the Northwind database that used to come with early versions of SQL Server.

Function Definitions

To demonstrate the power of Azure OpenAI Function Calling, we will create two separate function definitions. The first is a class named ProductAgent, which returns details about a product given the ‘Product Name’. The second is a class named MostExpensiveProductAgent, which returns the most expensive product.

Create a folder named Models and add to it three C# classes, namely: ProductProductAgent  and MostExpensiveProductAgent.

Product.cs

Add the following class to Product.cs:

public class Product{
  public int ProductId { get; set; }
  public string? ProductName { get; set; }
  public int UnitsInStock { get; set; }
  public float UnitPrice { get; set; } 
  
  // Load products from a csv file named products.csv in the wwwroot folder
  public static List<Product> LoadProducts() {
    var products = new List<Product>();
    using (var reader = new StreamReader(Path.Combine("wwwroot", "products.csv"))) {
      using (var csv = new CsvReader(reader, CultureInfo.InvariantCulture)) {
          products = csv.GetRecords<Product>().ToList();
      }
  }
    return products;
  }
  
  public override string ToString() {
    return $"Product ID: {ProductId}, Product Name: {ProductName}, Units In Stock: {UnitsInStock}, Unit Price: {UnitPrice}";
  }
}

The above code declares a class named Product. The class properties match the column names in the products.csv file. The LoadProducts() static method reads contents of the CSV file and returns a hydrated list of Product objects. Note that the Product class also has a ToString() method.

ProductAgent.cs

public class ProductAgent {
  static public string Name = "get_product_details";
  static private List<Product> products = Product.LoadProducts(); 
  
  // Return the function metadata
  static public FunctionDefinition GetFunctionDefinition() {
    return new FunctionDefinition() {
        Name = Name,
        Description = "Get product details by product name",
        Parameters = BinaryData.FromObjectAsJson(
        new{
          Type = "object",
          Properties = new {
            ProductName = new {
              Type = "string",
              Description = "The product name, e.g. Pavlova",
            }
          },
          Required = new[] { "productName" },
        },
        new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }),
    };
  }
  
  static public string? GetProductDetails(string product){
    var productDetails = products.Where(p => p.ProductName == product).FirstOrDefault();
    if (productDetails == null){
      return null;
    }
    return productDetails.ToString();
  }
} 
  
// Argument for the function
public class ProductInput {
    public string ProductName { get; set; } = string.Empty;
}

The above code does the following:

  • The function definition is created as a JSON object with information about the function name, description, and required parameters.
  • The GetProductDetails() method receives a product parameter (essentially ProductName) and returns a string with product details.
  • The ProductInput class exists in the ProductAgent.cs file. It will be later used as the argument that is passed to the GetProductDetails() method.

MostExpensiveProductAgent.cs

public class MostExpensiveProductAgent {
  static public string Name = "get_most_expensive_product";
  static private List<Product> products = Product.LoadProducts(); 
  
  // Return the function metadata
  static public FunctionDefinition GetFunctionDefinition() {
    return new FunctionDefinition() {
      Name = Name,
      Description = "Get details of the most expensive product",
    };
  }
  
  static public string? GetMostExpensiveProductDetails() {
    var productDetails = products.OrderByDescending(p => p.UnitPrice).FirstOrDefault();
    if (productDetails == null) {
      return null;
    }
    return productDetails.ToString();
  }
}

T
he above code does the following:

  • Just as with the previous ProductAgent class, the function definition is created as a JSON object with information about the function name, description, and required parameters.
  •  The GetMostExpensiveProductDetails() method takes no arguments and simply returns a string with details of the most expensive product.

The User Interface

We will re-purpose the Index.cshtml and Index.cshtml.cs files so the user can enter a prompt in natural language and receive a response that comes from the OpenAI model working with our custom function. 

Index.chtml

Replace the content of Pages/Index.cshtml with:

@page
@model IndexModel
@{
    ViewData["Title"] = "OpenAI Function Calling";
}
<div class="text-center">
    <h1 class="display-4">@ViewData["Title"]</h1>
    <form method="post">
        <input type="text" name="prompt" size="80" required/>
        <input type="submit" value="Submit" />
    </form>
    <pre style="text-align: left">
        
        Example prompts:
        
        What is the id number of product: Louisiana Hot Spiced Okra?
        What is the unit price of product: Sir Rodney's Marmalade?
        How many units in stock for product: Tofu?
        What is the most expensive product?
    </pre>
    @if (Model.Reply != null) {
        <p class="alert alert-success">@Model.Reply</p>
    }
</div>

T
he above markup displays an HTML form that accepts a prompt from a user. The prompt is then submitted to the server and the response is displayed in a paragraph (<p> tag) with a green background (Bootstrap class alert-success).

Meantime, at the bottom of the page there are some suggested prompts – namely:

What is the id number of product: Louisiana Hot Spiced Okra?
What is the unit price of product: Sir Rodney's Marmalade?
How many units in stock for product: Tofu?
What is the most expensive product?

Index.chtml.cs

Replace the IndexModel class definition in Pages/Index.cshtml.cs with:

public class IndexModel : PageModel {
  private readonly ILogger<IndexModel> _logger;
  private readonly IConfiguration _config;
  
  [BindProperty]
  public string? Reply { get; set; }
  
  public IndexModel(ILogger<IndexModel> logger, IConfiguration config) {
    _logger = logger;
    _config = config;
  }
  
  public void OnGet() { }
  
  // action method that receives prompt from the form
  public async Task<IActionResult> OnPostAsync(string prompt) {
    // call the Azure Function
    var response = await CallFunction(prompt);
    Reply = response;
    return Page();
  }
  
  private async Task<string> CallFunction(string question) {
    string endpoint = _config["AzureOpenAiSettings:Endpoint"]!;
    string apiKey = _config["AzureOpenAiSettings:ApiKey"]!;
    string model = _config["AzureOpenAiSettings:Model"]!;
    
    Uri openAIUri = new(endpoint);
    
    // Instantiate OpenAIClient for Azure Open AI.
    OpenAIClient client = new(openAIUri, new AzureKeyCredential(apiKey));
    ChatCompletionsOptions chatCompletionsOptions = new();
    chatCompletionsOptions.DeploymentName = model;
    ChatChoice responseChoice;
    Response<ChatCompletions> responseWithoutStream;
    
    // Add function definitions
    FunctionDefinition getProductFunctionDefinition = ProductAgent.GetFunctionDefinition();
    FunctionDefinition getMostExpensiveProductDefinition = MostExpensiveProductAgent.GetFunctionDefinition();
    chatCompletionsOptions.Functions.Add(getProductFunctionDefinition);
    chatCompletionsOptions.Functions.Add(getMostExpensiveProductDefinition);

    chatCompletionsOptions.Messages.Add(
        new ChatRequestUserMessage(question)
    );
    responseWithoutStream =
        await client.GetChatCompletionsAsync(chatCompletionsOptions);
    responseChoice = responseWithoutStream.Value.Choices[0];
    
    while (responseChoice.FinishReason!.Value == CompletionsFinishReason.FunctionCall) {
      // Add message as a history.
      chatCompletionsOptions.Messages.Add(new ChatRequestUserMessage(responseChoice.Message.ToString()));
      if (responseChoice.Message.FunctionCall.Name == ProductAgent.Name) {
        string unvalidatedArguments = responseChoice.Message.FunctionCall.Arguments;
        ProductInput input = JsonSerializer.Deserialize<ProductInput>(unvalidatedArguments,
          new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })!;
        var functionResultData = ProductAgent.GetProductDetails(input.ProductName);
        var functionResponseMessage = new ChatRequestFunctionMessage(
          ProductAgent.Name,
          JsonSerializer.Serialize(
            functionResultData,
            new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }));
        chatCompletionsOptions.Messages.Add(functionResponseMessage);
      } else if (responseChoice.Message.FunctionCall.Name == MostExpensiveProductAgent.Name) {
        
        var functionResultData = MostExpensiveProductAgent.GetMostExpensiveProductDetails();
        var functionResponseMessage = new ChatRequestFunctionMessage(
          MostExpensiveProductAgent.Name,
          JsonSerializer.Serialize(
            functionResultData,
            new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }));
        chatCompletionsOptions.Messages.Add(functionResponseMessage);
      }

      // Call LLM again to generate the response.
      responseWithoutStream = await client.GetChatCompletionsAsync(chatCompletionsOptions);
      responseChoice = responseWithoutStream.Value.Choices[0];
    }
    return responseChoice.Message.Content;
  }
}

In the above code, the prompt entered by the user is posted to the OnPostAsync() method. The prompt is then passed to the CallFunction() method, which returns the final response from Azure OpenAI.

  • The CallFunction() method reads the Azure OpenAI settings from appsettings.json.
  • An OpenAIClient class is instantiated.
  • An ChatCompletionsOptions class is instantiated and the message property is set with the original prompt from the user.
  • Function definitions for ProductAgent and MostExpensiveProductAgent are obtained.
  • A GetChatCompletionsAsync call is then made to Azure Open AI. The response involves a call to a local custom function. The service is smart enough to call the correct function based on the context of the prompt. Responses from local custom function calls are added to the history of messages and sent back to OpenAI. Eventually, after no more local custom function calls are required, the final message is returned.

Trying the application

In a terminal window, at the root of the Razor Pages web application, enter the following command:

dotnet watch

The following page will display in your default browser:

After separately entering the four suggested prompts, you will receive the following responses:

=======================================================================

=======================================================================

=======================================================================

Conclusion

The opportunities that Function Calling opens is enormous. I am simply scratching the surface of what the possibilities are. 

Resources