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)returnstrueand the result, orfalseif the answer can’t be read. It doesn’t throw.Resultreturns the result or throws an exception, such as aJsonExceptionfor 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:
// 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.