Crear una API con Semantic Kernel

La semana pasada publiqué en mi canal de YouTube cómo empezar con Semantic Kernel, un SDK que te permite de forma sencilla interactuar con diferentes modelos de Inteligencia Artificial de diferentes proveedores.

En él compartí a través de un notebook, con .NET Interactive, los pasos que puedes seguir para interactuar con los modelos a través de Semantic Functions, Native Functions, Planners y también con Kernel Memory. En este artículo quiero compartir contigo cómo puedes crear una minimal API para trasladar estas funcionalidades a algo un poquito más usable por tus aplicaciones, la cual mostré durante la .NET Conference, donde tuve la suerte de compartir escenario con Carlos Mendible y Manuel Sanchez.

Crear el proyecto y la configuración para Dev Containers

Para este ejemplo voy a utilizar como proveedor Open AI 😙 Por lo que necesitas recuperar una API key de este proveedor.

Para crear el proyecto lo único que he hecho ha sido ejecutar el siguiente comando:

mkdir semantic-kernel-minimal-api && cd 
dotnet new web

E instalar estos dos paquetes de nuget:

dotnet add package Microsoft.SemanticKernel --version 1.0.0-beta8
dotnet add package Microsoft.KernelMemory.Core --version 0.11.231121.1-preview

Por otro lado, para que tengas un entorno totalmente funcional, me he generado una configuración para Dev Containers, la cual me va a permitir tener tanto mi código como un servidor de Qdrant, una base de datos vectorial, ejecutándose como parte de mi IDE. Por un lado tengo el archivo .devcontainer/docker-compose.yaml con esta pinta:

version: '3.9'
services:
  workspace:
    image: mcr.microsoft.com/dotnet/sdk:8.0
    volumes:
      - ../..:/workspaces:cached
    # Overrides default command so things don't shut down after the process ends.
    command: sleep infinity
    network_mode: service:qdrant
  qdrant:
    image: qdrant/qdrant
    ports:
      - 6333:6333 # REST API and Web UI
      - 6334:6334 # GRPC API
    volumes:
      - qdrant_data:/qdrant/storage:z
volumes:
  qdrant_data:

Y por otro lado el archivo .devcontainer/devcontainer.json el cual hace referencia al anterior, además de permitirme instalar algunas extensiones que me permitan trabajar cómodamente con .NET, GitHub Copilot y la extensión REST Client para poder lanzar algunas peticiones de forma sencilla:

{
	"name": "Minimal Semantic Kernel API",
	"dockerComposeFile": "docker-compose.yaml",
	"service": "workspace",
	"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
	"customizations": {
		"vscode": {
			"extensions": [
				"ms-semantic-kernel.semantic-kernel",
				"GitHub.copilot",
				"GitHub.copilot-chat",
				"ms-dotnettools.csdevkit",
				"humao.rest-client"
			]
		}
	},
	"forwardPorts": [
		5170,
		6333
	]
}

Una vez que la estructura se haya generado, lo siguiente que necesitas es crear un archivo appsettings.Development.json (no te olvides de añadir el mismo como parte del .gitignore si creas el proyecto desde cero):

{
  "Values": {
    "model": "gpt-4",   
    "openaiKey": "<open-ai-key>",
    "qdrant": "http://qdrant:6333"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*"
}

En él puedes ver que guardamos los valores que necesitamos para usar el modelo gpt-4 de Open AI además de la URL de nuestro servidor de Qdrant, el cual mi API usará para almacenar la información extraída de los PDFs que le pasaré para poder hacer preguntas sobre los mismos.

Implementar la minimal API

Este ejemplo está basado en el que publicó Kyle Raymond, solo que un poquito más actualizado al que he añadido además cómo interactuar con Kernel Memory:

global using Microsoft.SemanticKernel;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.SemanticKernel.Planners;
using minimal_semantic_kernel_api.Models;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var config = new ConfigurationBuilder()
                .SetBasePath(Directory.GetCurrentDirectory())
                .AddEnvironmentVariables()
                .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
                .AddJsonFile("appsettings.Development.json", optional: true, reloadOnChange: true)
                .Build();
// Get model, apiKey, endpoint and openaiKey from environment variables or appsettings.json
var model = Environment.GetEnvironmentVariable("model") ?? config.GetSection("Values").GetValue<string>("model");
var apiKey = Environment.GetEnvironmentVariable("apiKey") ?? config.GetSection("Values").GetValue<string>("apiKey");
var qdrant = Environment.GetEnvironmentVariable("qdrant") ?? config.GetSection("Values").GetValue<string>("qdrant");
// Check if model, apiKey, endpoint and openaiKey are set
if (string.IsNullOrEmpty(model) || string.IsNullOrEmpty(apiKey))
{
    Console.WriteLine("Please set model, apiKey, endpoint and openaiKey in appsettings.json");
    return;
}

// Build a kernel
var kernel = new KernelBuilder()
              .WithOpenAIChatCompletionService(model, apiKey)
              .Build();

// Get plugins directory path
var pluginsDirectory = Path.Combine(Directory.GetCurrentDirectory(), "Plugins");
app.MapGet("/", () => "Welcome to Semantic Kernel!");

/*****************************************************************************************
*********************************** Semantic Functions ***********************************
*****************************************************************************************/
app.MapPost("plugins/{pluginName}/invoke/{functionName}", async (HttpContext context, string pluginName, string functionName, [FromBody] UserAsk userAsk) =>
{
    try
    {
        var funPluginFunctions = kernel.ImportSemanticFunctionsFromDirectory(pluginsDirectory, pluginName);
        var result = await kernel.RunAsync(userAsk.Ask, funPluginFunctions[functionName]);
        return Results.Json(new { answer = result.GetValue<string>() });

    }
    catch (Exception ex)
    {
        Console.WriteLine(ex.Message);
        throw;
    }
});

/*****************************************************************************************
*********************************** Planner *********************************************
*****************************************************************************************/
app.MapGet("planner", async (HttpContext context, string query) =>
{
    var planner = new SequentialPlanner(kernel);
    kernel.ImportSemanticFunctionsFromDirectory(pluginsDirectory, "FunPlugin");
    var plan = await planner.CreatePlanAsync(query);
    Console.WriteLine("Plan:\n");
    Console.WriteLine(JsonSerializer.Serialize(plan, new JsonSerializerOptions { WriteIndented = true }));
    var result = await kernel.RunAsync(plan);
    Console.WriteLine("Result:\n");
    Console.WriteLine(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
    return Results.Json(new { answer = result.GetValue<string>() });

});

/*****************************************************************************************
*********************************** Kernel Memory ****************************************
*****************************************************************************************/
// First, load some documents... about Minecraft! 😙
Plugins.MemoryPlugin.MemoryKernel.Init(apiKey, qdrant);
app.MapGet("memory", async (HttpContext context, string query) =>
{
    kernel.ImportSemanticFunctionsFromDirectory(pluginsDirectory, "FunPlugin");
    var memoryPlugin = kernel.ImportFunctions(new Plugins.MemoryPlugin.MemoryKernel(), "MemoryPlugin");
    var planner = new SequentialPlanner(kernel);
    var plan = await planner.CreatePlanAsync(query);
    Console.WriteLine("Plan:\n");
    Console.WriteLine(JsonSerializer.Serialize(plan, new JsonSerializerOptions { WriteIndented = true }));
    var result = await kernel.RunAsync(plan);
    Console.WriteLine(result);
    try
    {
        return Results.Json(JsonSerializer.Deserialize<Answer>(result.GetValue<string>()));
    }
    catch (System.Exception)
    {
        return Results.Json(new { answer = result.GetValue<string>() });
    }
});

app.Run();

Como ves, en este código tengo tres ejemplos:

  1. Invocar directamente una Semantic Function pasada por parámetro como parte de la petición.
  2. Utilizar un Planner, para que decida por nosotros qué Semantic Function tiene que ejecutar en base a la petición del usuario.
  3. Utilizar un Planner para que elija entre la Semantic Function o la Native Function con Kernel Memory. Para este último ejemplo he implementado esta clase que, además de cargar algunas guías sobre Minecraft, también tiene un par de métodos que se utilizan como Native Functions:
using System.ComponentModel;
using System.Text.Json;
using Microsoft.KernelMemory;

namespace Plugins.MemoryPlugin;
public class MemoryKernel
{
    static MemoryServerless memory;
    public static void Init(string apiKey, string qdrantHost)
    {
        KernelMemoryBuilder kernel;
        if (!string.IsNullOrEmpty(qdrantHost))
        {            
            kernel = new KernelMemoryBuilder()
                        .WithOpenAIDefaults(apiKey)
                        .WithQdrant(qdrantHost);
        }
        else
        {
            kernel = new KernelMemoryBuilder()
                         .WithOpenAIDefaults(apiKey);
        }
        memory = kernel.BuildServerlessClient();
        LoadTextMemories();
        LoadDocs();
    }
    static async void LoadTextMemories()
    {
        await memory.ImportTextAsync("Carlos Mendible, Manuel Sánchez y Gisela Torres son los ponentes de esta charla", documentId: "charla", tags: new TagCollection { { "type", "people" } });
        await memory.ImportTextAsync("Gisela fue MVP en 2010 y 2011 de Windows Azure", documentId: "gisela", tags: new TagCollection { { "type", "people" } });
        await memory.ImportTextAsync("Carlos fue MVP del 2017 al 2021 de Developer Technologies y Azure (es el más viejo)", documentId: "carlos", tags: new TagCollection { { "type", "people" } });
        await memory.ImportTextAsync("Manu es el único MVP en esta charla", documentId: "manu", tags: new TagCollection { { "type", "people" } });
    }
    static async void LoadDocs()
    {
        await memory.ImportDocumentAsync("docs/Guia completa 2022.pdf", documentId: "doc001");
        await memory.ImportDocumentAsync("docs/Minecraft_la_guia_definitiva.pdf", documentId: "doc002");
    }
    [SKFunction, Description("Responde preguntas sobre Minecraft")]
    public static async Task<string> Minecraft(string ask)
    {
        var answer = await memory.AskAsync(ask);
        // Answer
        Console.WriteLine($"\nAnswer: {answer.Result}");
        // Sources
        foreach (var x in answer.RelevantSources)
        {
            Console.WriteLine($"  - {x.SourceName}  - {x.Link} [{x.Partitions.First().LastUpdate:D}]");
        }
        // return a json string with the answer and the sources
        return JsonSerializer.Serialize(new { answer = answer.Result, references = answer.RelevantSources.Select(x => x.SourceName) });
    }
    [SKFunction, Description("Responde preguntas sobre la charla y personas que imparten en esta charla")]
    public static async Task<string> Charla(string ask)
    {
        var answer = await memory.AskAsync(ask, filter: new MemoryFilter().ByTag("type", "people"));
        // Answer
        Console.WriteLine($"\nAnswer: {answer.Result}");
        // Sources
        foreach (var x in answer.RelevantSources)
        {
            Console.WriteLine($"  - {x.SourceName}  - {x.Link} [{x.Partitions.First().LastUpdate:D}]");
        }
        // return a json string with the answer and the sources
        return JsonSerializer.Serialize(new { answer = answer.Result, references = answer.RelevantSources.Select(x => x.SourceName) });
    }
}

Para ejecutar la API necesitas abrir el proyecto como un Dev Container y, una vez dentro, puedes o bien pulsar F5 o ejecutar el siguiente comando:

dotnet run

Para probarla te he dejado un archivo llamado client.http que, junto con la extensión REST Client te permitirá lanzar peticiones a las tres operaciones expuestas:

### Semantic Functions
POST http://localhost:5170/plugins/FunPlugin/invoke/Joke HTTP/1.1
content-type: application/json
{
    "ask": "Cuentame un chiste sobre Minecraft"    
}

### Planner
GET http://localhost:5170/planner?query=cuentame un chiste HTTP/1.1
### Planner + Native Functions & Kernel Memory
GET http://localhost:5170/memory?query=quienes son los ponentes de esta charla HTTP/1.1
###
GET http://localhost:5170/memory?query=quien puede ayudarme con Minecraft HTTP/1.1
###
GET http://localhost:5170/memory?query=como puedo sobrevivir la primera noche en Minecraft HTTP/1.1

Por último, si quieres ver cómo se ha almacenado la información de los PDFs en Qdrant puedes acceder a la interfaz de este a través de la URL: http://localhost:6333/dashboard.

PDFs guardados en Qdrant a través de Kernel Memory

El código lo tienes en este repositorio de mi cuenta de GitHub.

¡Saludos!