diff --git a/demo/Demos/StructuredOutputConsole.cs b/demo/Demos/StructuredOutputConsole.cs new file mode 100644 index 0000000..a81b293 --- /dev/null +++ b/demo/Demos/StructuredOutputConsole.cs @@ -0,0 +1,242 @@ +using System.Text.Json; +using System.Text.Json.Schema; +using System.Text.Json.Serialization; +using OllamaSharp; +using OllamaSharp.Models.Chat; +using Spectre.Console; + +namespace OllamaApiConsole.Demos; + +/// +/// Demonstrates structured outputs by extracting a recipe in a strongly-typed JSON schema. +/// The user types a dish name and the model returns structured data that is rendered as a formatted table. +/// +public class StructuredOutputConsole(IOllamaApiClient ollama) : OllamaConsole(ollama) +{ + private static readonly JsonSerializerOptions SERIALIZER_OPTIONS = new() + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + /// + public override async Task Run() + { + AnsiConsole.Write(new Rule("Structured outputs").LeftJustified()); + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("This demo asks the model to return data that exactly matches a predefined JSON schema."); + AnsiConsole.MarkupLine($"Type the name of any dish and get back a structured [{AccentTextColor}]recipe[/] — no free-form text, only typed data."); + AnsiConsole.WriteLine(); + + Ollama.SelectedModel = await SelectModel("Select a model you want to use:"); + SetThink(new ThinkValue(false)); + + if (string.IsNullOrEmpty(Ollama.SelectedModel)) + return; + + var schema = JsonSerializerOptions.Default.GetJsonSchemaAsNode(typeof(RecipeSchema)); + + AnsiConsole.Write(new Rule($"[{HintTextColor}]Expected JSON schema[/]").LeftJustified()); + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLineInterpolated($"[{HintTextColor}]{Markup.Escape(JsonSerializer.Serialize(schema, new JsonSerializerOptions { WriteIndented = true }))}[/]"); + AnsiConsole.WriteLine(); + + WriteChatInstructionHint(); + + var keepChatting = true; + + do + { + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLineInterpolated($"You are talking to [{AccentTextColor}]{Ollama.SelectedModel}[/] now."); + + string message; + + do + { + AnsiConsole.WriteLine(); + message = ReadInput($"Enter a [{AccentTextColor}]dish name[/] [{HintTextColor}](e.g. \"Pasta Carbonara\" or \"Vegan Chocolate Cake\")[/]"); + + if (string.IsNullOrWhiteSpace(message)) + continue; + + if (message.Equals(EXIT_COMMAND, StringComparison.OrdinalIgnoreCase)) + { + keepChatting = false; + break; + } + + if (message.Equals(TOGGLETHINK_COMMAND, StringComparison.OrdinalIgnoreCase)) + { + ToggleThink(); + keepChatting = true; + continue; + } + + if (message.Equals(START_NEW_COMMAND, StringComparison.OrdinalIgnoreCase)) + { + keepChatting = true; + break; + } + + var prompt = $"Create a detailed recipe for: {message}. Respond only with the JSON object, no markdown, no explanation."; + var json = new System.Text.StringBuilder(); + + // Step 1: stream the raw model output live + AnsiConsole.Write(new Rule($"[{HintTextColor}]Raw model output[/]").LeftJustified()); + AnsiConsole.WriteLine(); + AnsiConsole.WriteLine(); + + var chat = new Chat(Ollama) { Think = Think }; + chat.OnThink += (sender, thoughts) => AnsiConsole.MarkupInterpolated($"[{AiThinkTextColor}]{thoughts}[/]"); + + await foreach (var token in chat.SendAsync(prompt, tools: null, format: schema)) + { + AnsiConsole.MarkupInterpolated($"[{HintTextColor}]{Markup.Escape(token)}[/]"); + json.Append(token); + } + + AnsiConsole.WriteLine(); + AnsiConsole.WriteLine(); + + // Step 2: pretty-print the JSON + AnsiConsole.Write(new Rule($"[{HintTextColor}]Parsed JSON[/]").LeftJustified()); + AnsiConsole.WriteLine(); + RenderPrettyJson(json.ToString()); + AnsiConsole.WriteLine(); + + // Step 3: the final recipe card + AnsiConsole.Write(new Rule($"[{HintTextColor}]Recipe[/]").LeftJustified()); + AnsiConsole.WriteLine(); + RenderRecipe(json.ToString(), message); + } + while (!string.IsNullOrEmpty(message)); + } + while (keepChatting); + } + + private static void RenderPrettyJson(string json) + { + try + { + using var doc = JsonDocument.Parse(json); + var pretty = JsonSerializer.Serialize(doc, new JsonSerializerOptions { WriteIndented = true }); + AnsiConsole.MarkupLineInterpolated($"[{AiTextColor}]{Markup.Escape(pretty)}[/]"); + } + catch (JsonException) + { + // Not yet valid JSON — just print as-is + AnsiConsole.MarkupLineInterpolated($"[{HintTextColor}]{Markup.Escape(json)}[/]"); + } + } + + private static void RenderRecipe(string json, string userInput) + { + RecipeSchema? recipe = null; + + try + { + recipe = JsonSerializer.Deserialize(json, SERIALIZER_OPTIONS); + } + catch (JsonException) + { + AnsiConsole.MarkupLineInterpolated($"[{ErrorTextColor}]Could not parse the model's response as a recipe. Raw output:[/]"); + AnsiConsole.WriteLine(json); + return; + } + + if (recipe is null) + { + AnsiConsole.MarkupLineInterpolated($"[{WarningTextColor}]The model returned an empty response.[/]"); + return; + } + + AnsiConsole.MarkupLineInterpolated($"[bold {AccentTextColor}]{Markup.Escape(recipe.Name ?? userInput)}[/]"); + + if (!string.IsNullOrWhiteSpace(recipe.Description)) + AnsiConsole.MarkupLineInterpolated($"[italic]{Markup.Escape(recipe.Description)}[/]"); + + AnsiConsole.WriteLine(); + + // Meta row: timing, servings, difficulty + var metaTable = new Table().NoBorder().HideHeaders(); + metaTable.AddColumn(new TableColumn("").Width(22)); + metaTable.AddColumn(new TableColumn("")); + + if (recipe.PrepTimeMinutes > 0) + metaTable.AddRow($"[{HintTextColor}]Prep time[/]", $"{recipe.PrepTimeMinutes} min"); + + if (recipe.CookTimeMinutes > 0) + metaTable.AddRow($"[{HintTextColor}]Cook time[/]", $"{recipe.CookTimeMinutes} min"); + + if (recipe.PrepTimeMinutes > 0 && recipe.CookTimeMinutes > 0) + metaTable.AddRow($"[{HintTextColor}]Total time[/]", $"{recipe.PrepTimeMinutes + recipe.CookTimeMinutes} min"); + + if (recipe.Servings > 0) + metaTable.AddRow($"[{HintTextColor}]Servings[/]", $"{recipe.Servings}"); + + if (!string.IsNullOrWhiteSpace(recipe.Difficulty)) + metaTable.AddRow($"[{HintTextColor}]Difficulty[/]", recipe.Difficulty); + + AnsiConsole.Write(metaTable); + AnsiConsole.WriteLine(); + + // Ingredients + if (recipe.Ingredients?.Length > 0) + { + AnsiConsole.MarkupLine($"[bold]Ingredients[/]"); + foreach (var ingredient in recipe.Ingredients) + AnsiConsole.MarkupLineInterpolated($" [{AiTextColor}]•[/] {Markup.Escape(ingredient)}"); + + AnsiConsole.WriteLine(); + } + + // Steps + if (recipe.Steps?.Length > 0) + { + AnsiConsole.MarkupLine($"[bold]Steps[/]"); + for (var i = 0; i < recipe.Steps.Length; i++) + AnsiConsole.MarkupLineInterpolated($" [{AiTextColor}]{i + 1,2}.[/] {Markup.Escape(recipe.Steps[i])}"); + + AnsiConsole.WriteLine(); + } + } + + /// + /// Defines the JSON schema the model must respond with. + /// + private sealed class RecipeSchema + { + /// The official name of the dish. + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// A short, appetising description of the dish. + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// List of ingredients with quantities. + [JsonPropertyName("ingredients")] + public string[]? Ingredients { get; set; } + + /// Ordered list of preparation steps. + [JsonPropertyName("steps")] + public string[]? Steps { get; set; } + + /// Preparation time in minutes, before any cooking starts. + [JsonPropertyName("prepTimeMinutes")] + public int PrepTimeMinutes { get; set; } + + /// Cooking or baking time in minutes. + [JsonPropertyName("cookTimeMinutes")] + public int CookTimeMinutes { get; set; } + + /// Number of portions the recipe yields. + [JsonPropertyName("servings")] + public int Servings { get; set; } + + /// Subjective difficulty, e.g. "Easy", "Medium", "Hard". + [JsonPropertyName("difficulty")] + public string? Difficulty { get; set; } + } +} diff --git a/demo/OllamaConsole.cs b/demo/OllamaConsole.cs index 4294e83..4512936 100644 --- a/demo/OllamaConsole.cs +++ b/demo/OllamaConsole.cs @@ -144,7 +144,15 @@ protected void WriteChatInstructionHint() internal void ToggleThink() { // null -> false -> true -> null -> ... - Think = Think == null ? false : ((bool?)Think == false ? true : ((bool?)Think == true ? null : false)); + SetThink(Think == null ? false : ((bool?)Think == false ? true : ((bool?)Think == true ? null : false))); + } + + /// + /// Toggles the think mode between null, false, and true. + /// + internal void SetThink(object? value) + { + Think = new ThinkValue(value); AnsiConsole.MarkupLine($"[{HintTextColor}]Think mode is [{AccentTextColor}]{Think?.ToString()?.ToLower() ?? "(null)"}[/].[/]"); } diff --git a/demo/Program.cs b/demo/Program.cs index 6b81409..7c46ae5 100644 --- a/demo/Program.cs +++ b/demo/Program.cs @@ -55,7 +55,7 @@ new SelectionPrompt() .PageSize(10) .Title("What demo do you want to run?") - .AddChoices("Chat", "Image chat", "Image generation (experimental)", "Tool chat", "Tool chat (Microsoft.Extensions.AI)", "Model manager", "Exit")); + .AddChoices("Chat", "Image chat", "Image generation (experimental)", "Tool chat", "Tool chat (Microsoft.Extensions.AI)", "Structured outputs", "Model manager", "Exit")); AnsiConsole.Clear(); @@ -83,6 +83,10 @@ await new ExtensionsAiToolConsole(ollama!).Run(); break; + case "Structured outputs": + await new StructuredOutputConsole(ollama!).Run(); + break; + case "Model manager": await new ModelManagerConsole(ollama!).Run(); break;