If you prefer video, there is a YouTube version of this blog-post.

Since the release of LLMs, I have been extremely enthusiastic about exploring such a fun tool to play with. I won’t delve into discussions about how transformative it is or will be, but rather focus on the enjoyable aspect of using this technology.

In the broader context of ML/AI and data science, Python stands out as the most commonly used programming language. Despite my open-source enthusiasm, I have always found C# to be remarkable. With the introduction of Mono.NET and later dotnet core, I have happily used it in Linux environments. It’s no surprise that when working on projects related to ML/AI, C# and .NET are my preferred choices.

Over the past few months, I’ve transitioned from being a mere user to someone who shares what I’ve learned. I find the topics of evaluating models and Responsible AI particularly fascinating. I’ve created some code samples that I’m excited to share.

However, in line with my approach to sharing knowledge in other topics, I prefer to establish a common ground or baseline understanding. Therefore, I’ve decided to start from the ground up, sharing and teaching how to build a small tool based on OpenAI or other LLMs. I’ll then progress to unit testing, evaluating models, and observability – all in C# and dotnet. I acknowledge that there are already numerous Python projects in this space, and it is my desire to include the dotnet community.

So, buckle up! This will be a 4-5 post series, and I may even record a couple of videos for the first time in my life.

Prerequisites

Knowledge

Tech stuff

The Idea Overview

I have decided we will build a short post content generator. Think of something to generate tweets/toots, mastodon posts, or LinkedIn posts, but very short (100-200 characters). Feel free to build something different, I actually encourage to try something different, this tutorial should be still helpful.

We will provide 3 inputs: style, topic, and persona field. The prompt would look like this:

You are a expert. Generate a short tweet in a style about .

For example:

You are a marketing expert. Generate a short tweet in a sarcastic style about the challenges of creating a viral campaign in summer.

Let’s try it in ChatGPT 3.5:

So, as you can see it works. You may be asking, what is the different between creating something from scratch than only using a ChatGPT agent? Well, you could integrate it with other products, platforms by doing your own API. You could start improving it with your own examples, you could even use it with a very small non-OpenAI local LLM to run it in a non-cloud environment. And of course, you can just have fun learning. There is a lot of benefits.

Step 0

If you already have dotnet installed skip to Step 1. If you don’t, you can use a ready to use devcontainer from this repo, just clone the repo, open it in vscode and start the remote container. If you don’t have a docker enabled machine, you could always use GitHub Codespaces.

This is one of those things where there are many posts out there that explain this part better than me, so I am on purpose not adding instructions.

Step 1

So, we will start by creating a new command line project, we will open a terminal (bash or powershell should work) and execute:

dotnet new console -n PostGenerator

this will generate a Program.cs and postgenerator.csproj

Now we will add a couple of packages, the first one is Semantic Kernel. Semantic Kernel is one of my favorite libraries, to play with OpenAI. So we enter the folder PostGenerator and execute:

dotnet add package Microsoft.SemanticKernel

We will also add a couple of extensions that are useful from the beginning, the first is about Dependency Injection, and the second logging:

dotnet add package Microsoft.Extensions.DependencyInjection
dotnet add package Microsoft.Extensions.Logging
dotnet add package Microsoft.Extensions.Logging.Console

You may argue, why we need this from day zero or with a prototype. With the time and experience you start answering to each question with “depends” and at the same time getting more opinionated in the way of work. This is one of those topics where there is no right or wrong (depends), but I have found very useful to use DI and Logging extensions from the beginning to avoid refactor my stuff later. You may agree or not, and that is fine.

The next one is more controversial, it is a library to handle the arguments in my command-line tool. I think (opinion) that is the same effort to handle the arguments array (and ugly effort), than implement it with the right devx with a utility library. So let’s install, System.CommandLine, which is in preview, hence the need of the –prerelease` option.

dotnet add package System.CommandLine --prerelease

Let’s verify that our program still builds:

dotnet build
...
Build succeeded.
    0 Warning(s)
    0 Error(s)

Time Elapsed 00:00:04.13

Amazing.

Step 2

Now to the fun part, let’s scallfold the command line utility. We will start by adding 3 parameters: --persona, --topic, and --style:

using System.CommandLine.Parsing;
using System.CommandLine;

var personaOption = new Option<string>("--persona")
{
    IsRequired = true,
};

var topicOption = new Option<string>("--topic")
{
    IsRequired = true,
};

var styleOption = new Option<string>("--style")
{
    IsRequired = true,
};

We will also add a “root” command, and the “create-post” command.

var rootCommand = new RootCommand();

var createPostCommand = new Command("create-post", "Create a new post");

createPostCommand.AddOption(personaOption);
createPostCommand.AddOption(topicOption);
createPostCommand.AddOption(styleOption);

rootCommand.AddCommand(createPostCommand);

Note: Fun fact, if you don’t add the AddOption, it will still work, but things like IsRequired will be ignored.

We finally execute the command and return the result. Notice how we don’t need to specify the main void static args, but we will use the args variable. Cool, right?

var result = await rootCommand.InvokeAsync(args);

return result;

Let’s test it:

$ dotnet run
Required command was not provided.

Description:

Usage:
  PostGenerator [command] [options]

Options:
  --version       Show version information
  -?, -h, --help  Show help and usage information

Commands:
  create-post  Create a new post

And if we provide the create-post command, we get a blank response:

$dotnet run -- create-post --persona "software developer" --topic "unit tests" --style "sarcastic"

This is because there is no handler for the command, let’s add one just before adding the createPost command to the root command.

createPostCommand.SetHandler((persona, topic, option) =>
{
    Console.WriteLine($"Command requested for {persona} {topic} {option}");

    throw new NotImplementedException($"Command not implemented");
}, personaOption, topicOption, styleOption);

The other of personaOption, topicOption, styleOption, does not really matter, the user can provide them in any order, we just need to make sure it matches the order of the arguments.

Step 3 - DI & Logging

Let’s implement Depencency Injection with Logging. Observability has 3 pilars: logging, metrics, and traces. Logging is basically all messages that would be useful to troubleshoot an issue later, but also helpful to send messages to the user. Dotnet provides an excellent logging mechanism, and several extensions. We can include these two:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

Dependency Injection is just a fancy repository for objects that will be used on the application. In long running systems, it is more clear the advantage of it, for example in web systems we can reuse the same http client and avoid instantiating a new object in each request and stagnating memory.

There are many ways to store/instantiate these objects, the two extremes is to create one each time we require one from the service provider, and the other is having a singleton, the same exact object.

We will create a new method at the end to configure these services. As it becomes more complex it can be their own class, or even extensions, but for now a method will be enough.

static void ConfigureServices(ServiceCollection serviceCollection, string[] args)
{
    
}

You would want to call this as one of the first things in your script, to avoid doing nasty things and not using it, so we add this, just after the usings.

var serviceCollection = new ServiceCollection();

ConfigureServices(serviceCollection, args);

using var serviceProvider = serviceCollection.BuildServiceProvider();

Now we will configure our logging using one of the built-in extensions:

static void ConfigureServices(ServiceCollection serviceCollection, string[] args)
{
    serviceCollection.AddLogging(configure =>
        {
            configure.AddSimpleConsole(options => options.TimestampFormat = "hh:mm:ss ");

            if (args.Any("--debug".Contains))
            {
                configure.SetMinimumLevel(LogLevel.Debug);
            }
        })
}

If you want to play with the formatters just check this.

As you may notice we are hijacking the --debug argument here, we need to add it to all the commands as well:

var debugOption = new Option<bool>(new[] { "--debug" }, "Enables debug logging");

rootCommand.AddGlobalOption(debugOption);

Finally we will replace that nasty Console.WriteLine.

createPostCommand.SetHandler((persona, topic, option) =>
{
    var logger = serviceProvider.GetRequiredService<ILogger>();
    
    logger?.LogDebug($"Command requested for {persona} {topic} {option}");

    throw new NotImplementedException($"Command not implemented");
}, personaOption, topicOption, styleOption);

Beautiful. We could abstract the handler to a different class, but for now we should be happy with the result. Let’s test it:

dotnet run -- create-post --debug --persona "influencer" --topic "software dev" --style "sarcastic"

And with debug:

dotnet run -- create-post --debug --persona "influencer" --topic "software dev" --style "sarcastic" --debug

Amazing, we are ready to start adding LLMs stuff, our full program should look like this.