So far, the Kladde’s model has written text, and a human has read it. Now the program itself should work with the answer: The Kladde assesses every tender from the TED interface, sorts the list by the assessment, and shows reasons and risks. A friendly paragraph isn’t enough for that. The program needs fields with fixed names and values it can check: a suitability from a fixed list, a score, reasons, risks.

This is the Kladde’s first AI feature. Along the way you learn how to request an answer as a C# type, what a strict JSON schema is, what it can’t do, and why the code checks every result itself anyway. The packages are the same as in the lessons before.

From text to a type

Microsoft.Extensions.AI brings an extension method for this: GetResponseAsync<T>. It derives a JSON schema from the type T, sends it with the request, reads the answer as JSON and turns it into a T. The result is a ChatResponse<T>, an ordinary response with two ways to get at the type:

  • TryGetResult(out T? result) returns true and the result, or false if the answer can’t be read. It doesn’t throw.
  • Result returns the result or throws an exception, such as a JsonException for broken JSON.

Because a model can write a broken answer at any time, a broken answer isn’t an exceptional case but an expected one. So the Kladde always uses TryGetResult.

GetResponseAsync<T> puts the schema into the request’s options as a ChatResponseFormatJson, under ChatOptions.ResponseFormat. You can also create such a format yourself with ChatResponseFormat.ForJsonSchema<T>(), for example if you’d rather read the answer as text. In the Kladde, GetResponseAsync<T> takes care of both, schema and reading.

The following example needs no model. A small class stands in its place again, this time one that prints which schema it gets and returns a prepared answer. That way you see what GetResponseAsync<T> makes of a record:

C#
// schema.cs
#:package Microsoft.Extensions.AI@10.11.0
#:property PublishAot=false

using System.ComponentModel.DataAnnotations;
using System.Runtime.CompilerServices;
using System.Text.Json;
using Microsoft.Extensions.AI;

var model = new ShowTheRequest("""{"suitability":"PartlySuitable","reasons":["Network fits","Deadline is tight"],"risks":[],"score":5}""");

ChatResponse<Rating> response = await model.GetResponseAsync<Rating>("Assess the tender.");
if (response.TryGetResult(out Rating? rating))
{
    Console.WriteLine($"Read: {rating.Suitability}, {rating.Score} points, reasons: {string.Join(" / ", rating.Reasons)}");
}

await model.GetResponseAsync<RatingWithRange>("Assess the tender.");

enum Suitability { Suitable, PartlySuitable, Unsuitable }

record Rating(Suitability Suitability, IReadOnlyList<string> Reasons, IReadOnlyList<string> Risks, int Score);

record RatingWithRange(Suitability Suitability, IReadOnlyList<string> Reasons, IReadOnlyList<string> Risks, [property: Range(0, 10)] int Score);

// Prints the schema it is asked for and gives a prepared answer.
sealed class ShowTheRequest(string answer) : IChatClient
{
    public Task<ChatResponse> GetResponseAsync(
        IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
    {
        if (options?.ResponseFormat is ChatResponseFormatJson { Schema: JsonElement schema } json)
        {
            Console.WriteLine($"Schema {json.SchemaName}: {JsonSerializer.Serialize(schema)}");
        }

        return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, answer)));
    }

    public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
        IEnumerable<ChatMessage> messages, ChatOptions? options = null,
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        ChatResponse response = await GetResponseAsync(messages, options, cancellationToken);
        foreach (ChatResponseUpdate update in response.ToChatResponseUpdates())
        {
            yield return update;
        }
    }

    public object? GetService(Type serviceType, object? serviceKey = null) => null;

    public void Dispose()
    {
    }
}
Schema Rating: {"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"suitability":{"type":"string","enum":["Suitable","PartlySuitable","Unsuitable"]},"reasons":{"type":"array","items":{"type":"string"}},"risks":{"type":"array","items":{"type":"string"}},"score":{"type":"integer"}},"required":["suitability","reasons","risks","score"]}
Read: PartlySuitable, 5 points, reasons: Network fits / Deadline is tight
Schema RatingWithRange: {"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"suitability":{"type":"string","enum":["Suitable","PartlySuitable","Unsuitable"]},"reasons":{"type":"array","items":{"type":"string"}},"risks":{"type":"array","items":{"type":"string"}},"score":{"type":"integer","minimum":0,"maximum":10}},"required":["suitability","reasons","risks","score"]}

What you see in it:

  • The field names start in lowercase (suitability), as is usual on the web. When reading, the capitalization doesn’t matter.
  • The enumeration becomes "enum" with its names as text. The model can write only one of these three values if it keeps to the schema.
  • Every field is listed under "required". The type has no optional fields, so the schema demands all of them.
  • [Range(0, 10)] becomes "minimum" and "maximum". That looks helpful, but it’s a trap, as you’ll see in a moment.

The line #:property PublishAot=false is needed for file-based apps with GetResponseAsync<T>. File-based apps are set up for Native AOT by default, and there, reading and writing JSON through reflection is switched off. Without the line, the example stops with a NotSupportedException: “JsonTypeInfo metadata for type 'Rating' was not provided”. In an ordinary project like Kladde.Ai, you need none of this.