diff --git a/.gitignore b/.gitignore index d5a18de..dc44d24 100644 --- a/.gitignore +++ b/.gitignore @@ -427,3 +427,4 @@ FodyWeavers.xsd *.msix *.msm *.msp +/src/debate_results/separate_files_demo diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc new file mode 100644 index 0000000..8ddc53e --- /dev/null +++ b/.opencode/opencode.jsonc @@ -0,0 +1,15 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "MSDN": { + "type": "remote", + "url":"https://learn.microsoft.com/api/mcp", + "enabled": true, + }, + "context7": { + "type": "remote", + "url": "https://mcp.context7.com/mcp" + } + }, +} + diff --git a/docs/ChatClientLLMProvider.md b/docs/ChatClientLLMProvider.md new file mode 100644 index 0000000..8b5790d --- /dev/null +++ b/docs/ChatClientLLMProvider.md @@ -0,0 +1,365 @@ +# ChatClientLLMProvider — Pluggable LLM Backends via Microsoft.Extensions.AI + +`ChatClientLLMProvider` is Delibera's universal LLM provider. It wraps **any** +`Microsoft.Extensions.AI.IChatClient` implementation and exposes it as a standard +`Delibera.Core.Providers.LLM.ILLMProvider`. Because every modern .NET LLM SDK ships an +`IChatClient`, you can plug **OpenAI, Azure OpenAI, Anthropic, Ollama, GitHub Copilot, +Microsoft Foundry, LM Studio, vLLM, LocalAI, ONNX**, and more into a Delibera council +without writing a bespoke provider for each one. + +The companion `Delibera.Core.Extensions.MicrosoftAIExtensions` class bridges the two +abstraction worlds in both directions: + +| From → To | Helper | +| ------------------------------------------------- | ----------------------------------------------------------------- | +| `IChatClient` → `ILLMProvider` | `.AsLLMProvider()` or `new ChatClientLLMProvider(...)` | +| `IEmbeddingGenerator>` → `IEmbeddingProvider` | `.AsEmbeddingProvider()` | +| `ILLMProvider` → `IChatClient` | `.AsChatClient()` (returns the inner client when already wrapped) | +| Compose middleware (function invocation, logging) | `.WithMiddleware(enableFunctionInvocation, loggerFactory)` | + +--- + +## 📦 Supported Providers + +Any NuGet package that exposes `Microsoft.Extensions.AI.IChatClient` works out of the box. +Add the package to your project, obtain the `IChatClient`, and hand it to +`ChatClientLLMProvider`. + +| Provider | NuGet Package | Auth / Endpoint | `IChatClient` source | +| -------------------------------- | ------------------------------------------------------ | ----------------------------------------------------- | ---------------------------------------------------------------- | +| **OpenAI** | `Microsoft.Extensions.AI.OpenAI` | API key (`OPENAI_API_KEY`) | `new OpenAI.Chat.ChatClient("gpt-4o", key).AsIChatClient()` | +| **Azure OpenAI** | `Azure.AI.OpenAI` + `Microsoft.Extensions.AI.OpenAI` | Azure endpoint + key / Entra ID | `new AzureOpenAIClient(...).GetChatClient(deployment).AsIChatClient()` | +| **Microsoft Foundry** | `Microsoft.Extensions.AI.AzureAIInference` | Foundry endpoint + key | `new AzureAIInferenceClient(...).AsIChatClient()` | +| **Anthropic Claude** | `Microsoft.Extensions.AI.Anthropic` *(community/port)* | API key (`ANTHROPIC_API_KEY`) | provider-specific builder | +| **Ollama (local or Cloud)** | `OllamaSharp` *(already a Delibera dependency)* | Local `http://localhost:11434` or Cloud `https://api.ollama.com` + key | `new OllamaApiClient(uri, model)` or `OllamaProvider.AsChatClient()` | +| **GitHub Copilot** | `Microsoft.Extensions.AI.GitHubCopilot` | GitHub token | `GitHubCopilotChatClient(...)` | +| **LM Studio** (OpenAI-compatible)| `Microsoft.Extensions.AI.OpenAI` | `http://localhost:1234/v1` + any string key | `new OpenAI.Chat.ChatClient(...).AsIChatClient()` with custom base URL | +| **vLLM / LocalAI** (OpenAI-compat)| `Microsoft.Extensions.AI.OpenAI` | server URL + any string key | same as LM Studio | +| **ONNX Runtime GenAI** | `Microsoft.ML.GenAI` / `Microsoft.Extensions.AI.ONNX` | local model path | `OnnxGenAIChatClient(...)` | +| **Custom `IChatClient`** | — | your own implementation | implement `IChatClient` or derive from `DelegatingChatClient` | + +> ℹ️ Delibera's `OllamaProvider` already natively implements the `IChatClient` contract via +> OllamaSharp — no extra NuGet package is required for the Ollama path. The same is true for +> the embedding side via `OllamaProvider.AsEmbeddingGenerator()`. + +--- + +## 🚀 Minimal Example — OpenAI + +```csharp +using Microsoft.Extensions.AI; +using OpenAI; +using Delibera.Core.Providers.LLM; + +// 1. Build an IChatClient from the OpenAI SDK +var openAi = new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY")!); +IChatClient chatClient = openAi.GetChatClient("gpt-4o-mini").AsIChatClient(); + +// 2. Wrap it as a Delibera ILLMProvider +var llmProvider = new ChatClientLLMProvider(chatClient, "OpenAI"); + +// 3. Use it like any other Delibera provider +string response = await llmProvider.ChatAsync( + model: "gpt-4o-mini", + systemPrompt: "You are a helpful assistant.", + userPrompt: "What is the capital of France?", + temperature: 0.7f); + +Console.WriteLine(response); // The capital of France is Paris. +``` + +### Equivalent one-liner using the extension method + +```csharp +ILLMProvider llmProvider = chatClient.AsLLMProvider("OpenAI"); +``` + +--- + +## 🏛️ OpenAI in a Delibera Council + +```csharp +using Microsoft.Extensions.AI; +using OpenAI; +using Delibera.Core.Council; +using Delibera.Core.Extensions; +using Delibera.Core.Providers; +using Delibera.Core.Providers.LLM; + +var openAi = new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY")!); +IChatClient chatClient = openAi.GetChatClient("gpt-4o-mini").AsIChatClient(); + +// Optional: add Microsoft.Extensions.AI middleware (function invocation + logging) +IChatClient decorated = chatClient.WithMiddleware(enableFunctionInvocation: true); + +// Adopt as a Delibera provider +ILLMProvider openAiProvider = decorated.AsLLMProvider("OpenAI"); + +var result = await new CouncilBuilder() + .AddMember("gpt-4o-mini", openAiProvider, "Analyst") + .AddMember("gpt-4o", openAiProvider, "Skeptic") + .SetChairman(Chairman.CreateStandard("gpt-4o-mini", openAiProvider)) + .WithStandardDebate() + .WithSystemPrompt("You are a software architecture expert.") + .WithUserPrompt("Microservices vs Monolith for a 5-person startup?") + .WithMaxRounds(4) + .SaveResultTo("./openai_debate.md") + .Build() + .ExecuteAsync(); + +Console.WriteLine(result.FinalVerdict); +``` + +--- + +## ☁️ Azure OpenAI + +```csharp +using Azure.AI.OpenAI; +using Microsoft.Extensions.AI; + +var azure = new AzureOpenAIClient( + new Uri("https://.openai.azure.com"), + new AzureKeyCredential(Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY")!)); + +IChatClient chatClient = azure.GetChatClient("gpt-4o-deployment").AsIChatClient(); + +var llmProvider = new ChatClientLLMProvider(chatClient, "AzureOpenAI"); +string answer = await llmProvider.ChatAsync( + "gpt-4o-deployment", + "You are a concise assistant.", + "Summarise the Microsoft.Extensions.AI abstraction in one sentence."); +``` + +--- + +## 🦙 Ollama (Local or Cloud) + +OllamaSharp is already a Delibera dependency, so no extra package is needed — just use the +`OllamaProvider` adapter and grab its `IChatClient`. + +### Local Ollama + +```csharp +using Delibera.Core.Extensions; +using Delibera.Core.Providers.LLM; + +using var ollama = new OllamaProvider("http://localhost:11434"); +IChatClient chatClient = ollama.AsChatClient(); + +var llmProvider = new ChatClientLLMProvider(chatClient, "Ollama Local"); + +string response = await llmProvider.ChatAsync( + "llama3.2", + "You are a helpful assistant.", + "What is the capital of France?", + 0.7f); + +Console.WriteLine(response); +``` + +### Ollama Cloud (with API key) + +```csharp +using var ollama = new OllamaProvider("https://api.ollama.com", apiKey: "YOUR_OLLAMA_CLOUD_KEY"); +var llmProvider = ollama.AsChatClient().AsLLMProvider("Ollama Cloud"); +``` + +--- + +## 🛠️ OpenAI-Compatible Servers (LM Studio, vLLM, LocalAI) + +These servers expose an OpenAI-compatible HTTP API, so the same +`Microsoft.Extensions.AI.OpenAI` package works — point it at the local URL. + +```csharp +using Microsoft.Extensions.AI; +using OpenAI; + +// LM Studio typically serves OpenAI-compatible API at http://localhost:1234/v1 +var client = new OpenAIClient( + new OpenAIAuthentication("lm-studio"), // any non-empty string key works + new System.Net.Http.HttpClient { BaseAddress = new Uri("http://localhost:1234/v1") }); + +IChatClient chatClient = client.GetChatClient("local-model").AsIChatClient(); +var llmProvider = new ChatClientLLMProvider(chatClient, "LM Studio"); +``` + +--- + +## 🧠 Streaming (ChatStreamAsync) + +Because `ChatClientLLMProvider` is backed by an `IChatClient`, it delivers true +token-by-token streaming: + +```csharp +await foreach (var chunk in llmProvider.ChatStreamAsync( + "gpt-4o-mini", + "You are a concise assistant.", + "In one sentence, what is Microsoft.Extensions.AI?")) +{ + Console.Write(chunk); +} +``` + +--- + +## 🧮 Embeddings (IEmbeddingGenerator → IEmbeddingProvider) + +The same interop works for embeddings, so RAG pipelines can use any M.E.AI embedding backend: + +```csharp +using Microsoft.Extensions.AI; +using OpenAI; +using Delibera.Core.Extensions; + +var openAi = new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY")!); + +IEmbeddingGenerator> generator = + openAi.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator(); + +var embeddingProvider = generator.AsEmbeddingProvider("text-embedding-3-small"); + +float[] vector = await embeddingProvider.EmbedAsync("Delibera deliberates."); +Console.WriteLine($"Dimensions: {vector.Length}"); // 1536 for text-embedding-3-small +``` + +For Ollama embeddings, use the built-in helper: + +```csharp +using var ollama = new OllamaProvider("http://localhost:11434"); +var embeddings = ollama.AsEmbeddingGenerator().AsEmbeddingProvider("nomic-embed-text"); +``` + +--- + +## ↩️ Reverse Bridge — Expose a Delibera Provider as IChatClient + +Already have an `ILLMProvider` and want to drop it into a Microsoft.Extensions.AI +middleware pipeline (caching, telemetry, function invocation)? Use `AsChatClient`: + +```csharp +using Delibera.Core.Extensions; + +using var ollama = new OllamaProvider("http://localhost:11434"); +IChatClient chatClient = ollama.AsChatClient() + .WithMiddleware(enableFunctionInvocation: true); + +// Now `chatClient` is a standard Microsoft.Extensions.AI client usable by any +// library that consumes IChatClient — while still talking to Ollama underneath. +``` + +If the provider is already a `ChatClientLLMProvider`, `AsChatClient()` returns its +underlying client directly (no extra wrapping layer). + +--- + +## 🏥 Provider Introspection + +`ChatClientLLMProvider` implements the full `ILLMProvider` surface: + +| Method | Behaviour | +| ---------------------------- | ---------------------------------------------------------------------------------------------- | +| `IsAvailableAsync` | Returns `true` while the client is not disposed (the M.E.AI abstraction has no health-check). | +| `ListModelsAsync` | Returns the default model id from the client metadata, or an empty list when unknown. | +| `GetModelCapabilitiesAsync` | Falls back to the static `ModelContextWindowRegistry` (the M.E.AI abstraction has no caps API). | +| `ChatAsync` | One-shot chat via `IChatClient.GetResponseAsync`. | +| `ChatStreamAsync` | True token streaming via `IChatClient.GetStreamingResponseAsync`. | + +```csharp +var llmProvider = new ChatClientLLMProvider(chatClient, "OpenAI"); + +Console.WriteLine(await llmProvider.IsAvailableAsync()); // True +Console.WriteLine(string.Join(", ", await llmProvider.ListModelsAsync())); // gpt-4o +var caps = await llmProvider.GetModelCapabilitiesAsync("gpt-4o"); // may be null +``` + +--- + +## 🧱 DI Registration + +Register an `IChatClient`-backed provider through `ProviderFactory` so it participates in +the Delibera DI container: + +```csharp +using Delibera.Core.DependencyInjection; +using Delibera.Core.Providers; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using OpenAI; + +var builder = Host.CreateApplicationBuilder(); + +builder.Services.AddChatClient(services => + new OpenAIClient(builder.Configuration["OPENAI_API_KEY"]!) + .GetChatClient("gpt-4o-mini") + .AsIChatClient()) + .UseFunctionInvocation() + .UseLogging(); + +builder.Services.AddDelibera(builder.Configuration, "Delibera"); +// AddDeliberaChatClient(...) wires the registered IChatClient into the council pipeline. + +var app = builder.Build(); +var executor = app.Services.GetRequiredService() + .AddMember("gpt-4o-mini", /* resolved from ILLMProviderFactory */, "Analyst") + .Build(); +``` + +Or wrap it manually and add it to the factory cache: + +```csharp +using var factory = new ProviderFactory(); +IChatClient chatClient = new OpenAIClient(key).GetChatClient("gpt-4o-mini").AsIChatClient(); +ChatClientLLMProvider provider = factory.CreateFromChatClient("openai", chatClient, "OpenAI"); +``` + +--- + +## 📋 Capabilities & Limitations + +**Advantages** + +- ✅ **Flexibility** — works with any `IChatClient` implementation, enabling seamless + integration with multiple AI backends. +- ✅ **Middleware support** — leverages Microsoft.Extensions.AI middleware (function + invocation, caching, telemetry, logging) via `WithMiddleware`. +- ✅ **Streaming** — true token-by-token streaming via `ChatStreamAsync`. +- ✅ **Bidirectional** — `AsLLMProvider` / `AsChatClient` / `AsEmbeddingProvider` let you + move freely between Delibera and M.E.AI worlds. + +**Considerations** + +- ⚠️ **Model enumeration** — the Microsoft.Extensions.AI abstraction does not define a + model-enumeration contract, so `ListModelsAsync` may return limited results (only the + default model id, when known). +- ⚠️ **Capabilities** — model capabilities are not universally defined and rely on the + static `ModelContextWindowRegistry` or per-provider overrides. +- ⚠️ **Health check** — `IsAvailableAsync` reports client liveness only; concrete + transport failures surface on the first `ChatAsync` call. + +--- + +## 🖥️ Runnable Example + +A complete, runnable console example lives at +[`src/Delibera.ConsoleApp/Examples/ChatClientLLMProviderExample.cs`](../src/Delibera.ConsoleApp/Examples/ChatClientLLMProviderExample.cs). + +```bash +cd src/Delibera.ConsoleApp +dotnet run -- --chatclient +``` + +It demonstrates: obtaining an `IChatClient`, composing middleware, adopting as an +`ILLMProvider`, provider introspection, `ChatAsync`, `ChatStreamAsync`, the reverse +`AsChatClient` bridge, and embeddings — using a local Ollama server (the OpenAI variant +is shown in the file's comments). + +--- + +## 📚 Further Reading + +- [Microsoft.Extensions.AI documentation](https://learn.microsoft.com/dotnet/ai/ichatclient) +- [Microsoft.Extensions.AI on NuGet](https://www.nuget.org/packages/Microsoft.Extensions.AI) +- [Delibera README](../README.md) \ No newline at end of file diff --git a/src/Delibera.ConsoleApp/Delibera.ConsoleApp.csproj b/src/Delibera.ConsoleApp/Delibera.ConsoleApp.csproj index 89274c8..71c39d7 100644 --- a/src/Delibera.ConsoleApp/Delibera.ConsoleApp.csproj +++ b/src/Delibera.ConsoleApp/Delibera.ConsoleApp.csproj @@ -24,6 +24,7 @@ + diff --git a/src/Delibera.ConsoleApp/ExampleRegistry.cs b/src/Delibera.ConsoleApp/ExampleRegistry.cs new file mode 100644 index 0000000..5702ab2 --- /dev/null +++ b/src/Delibera.ConsoleApp/ExampleRegistry.cs @@ -0,0 +1,149 @@ +using System.Reflection; +using System.Text; + +namespace Delibera.ConsoleApp; + +/// +/// A discoverable example entry exposed by the interactive Spectre.Console menu. +/// +/// Stable identifier used as the CLI flag (e.g. chatclient--chatclient). +/// Human-friendly title shown in the menu. +/// Short one-line description shown under the title. +/// Logical grouping used to organise the menu. +/// Sort order within a category (lower = earlier). +/// Additional CLI flags that also invoke this example (e.g. di for the DI example). +/// Delegate that runs the example. +public sealed record ExampleEntry( + string Id, + string Title, + string Description, + string Category, + int Order, + IReadOnlyList Aliases, + Func RunAsync); + +/// +/// Dynamically discovers every example in Delibera.ConsoleApp.Examples by scanning +/// for public static RunAsync() methods. Curated metadata (title, description, +/// category, explicit id, aliases) is keyed by class name; unknown examples fall back +/// to sensible defaults so adding a new example file requires zero changes here. +/// +public static class ExampleRegistry +{ + // (Title, Description, Category, Order, explicit Id?, aliases[]) + private static readonly Dictionary Metadata = + new(StringComparer.OrdinalIgnoreCase) + { + ["QuickStart"] = ("Quick Start", "Minimal programmatic council — no appsettings required.", "Getting Started", 0, "quick", []), + ["DependencyInjectionExample"] = ("Dependency Injection", "AddDelibera() DI registration, options binding, resolved services.", "Core", 0, "di", []), + ["MultiProviderExample"] = ("Multi-Provider Council", "Mix models from different LLM providers in one council.", "Core", 1, "multiprovider", ["multi-provider"]), + ["CompressionExample"] = ("Context Compression", "All 4 strategies, cache, token counting, council integration.", "Features", 0, "compression", []), + ["AutoChunkingExample"] = ("AutoChunking", "Progressive disclosure for large documents across rounds.", "Features", 1, "autochunking", ["auto-chunking"]), + ["CancellationExample"] = ("Cooperative Cancellation", "Ctrl+C / CancellationToken across the whole pipeline.", "Features", 2, "cancellation", []), + ["SeparateFilesExample"] = ("Separate File Output", "Export result.md, statistics.md, logs.md independently.", "Features", 3, "separate-files", []), + ["ResilienceExample"] = ("Resilience (Polly v8)", "Named HttpClients + retry pipelines via Microsoft.Extensions.Http.Resilience.", "Infrastructure", 0, "resilience", []), + ["RagExample"] = ("RAG — Qdrant", "Qdrant-backed Knowledge Keeper with semantic retrieval.", "RAG", 0, "rag", []), + ["PgVectorExample"] = ("RAG — pgvector", "PostgreSQL/pgvector-backed Knowledge Keeper.", "RAG", 1, "pgvector", ["pg-vector"]), + ["OperatorExample"] = ("Operator (MCP)", "Operator micro-agent delegating tasks to MCP tools.", "Advanced", 0, "operator", []), + ["OperatorMcpToolsExample"] = ("Operator + MCP Tools", "Full MCP tool wiring with the Operator role.", "Advanced", 1, "operator-mcp", ["operator-mcp-tools"]), + ["MicrosoftExtensionsAiExample"] = ("M.E.AI Integration", "IChatClient ↔ ILLMProvider bridges + middleware + council.", "Microsoft.Extensions.AI", 0, "msai", ["microsoft-extensions-ai"]), + ["ChatClientLLMProviderExample"] = ("ChatClientLLMProvider", "OpenAI/Azure/Ollama via Microsoft.Extensions.AI + streaming.", "Microsoft.Extensions.AI", 1, "chatclient", ["chat-client-llm-provider"]) + }; + + /// Discovers all examples in the Examples namespace, ordered by category then order. + public static IReadOnlyList Discover() + { + const string examplesNamespace = "Delibera.ConsoleApp.Examples"; + var assembly = Assembly.GetExecutingAssembly(); + + var entries = new List(); + foreach (var type in assembly.GetTypes()) + { + // Example classes are `public static class`, which compile to abstract+sealed. + // Allow both instance and static classes; only skip compiler-generated closures. + if (!type.IsClass) continue; + if (type.IsGenericTypeDefinition) continue; + if (type.Name.StartsWith('<') || type.Name.StartsWith("<>")) continue; + if (type.Namespace is null || !type.Namespace.StartsWith(examplesNamespace, StringComparison.Ordinal)) continue; + + var method = type.GetMethod("RunAsync", BindingFlags.Public | BindingFlags.Static, null, [typeof(CancellationToken)], null) + ?? type.GetMethod("RunAsync", BindingFlags.Public | BindingFlags.Static, null, [], null); + if (method is null) continue; + if (method.ReturnType != typeof(Task) && method.ReturnType != typeof(ValueTask)) continue; + + string title, desc, category; + int order; + string? explicitId; + string[] aliases; + if (Metadata.TryGetValue(type.Name, out var meta)) + { + (title, desc, category, order, explicitId, aliases) = meta; + } + else + { + title = SpaceOut(type.Name); + desc = type.Name; + category = "Other"; + order = 100; + explicitId = null; + aliases = []; + } + + // Prefer the curated explicit id; otherwise derive a kebab id from the class name. + var id = !string.IsNullOrWhiteSpace(explicitId) + ? explicitId! + : ToKebab(type.Name.EndsWith("Example", StringComparison.Ordinal) + ? type.Name[..^7] + : type.Name); + + // Build a delegate accepting a CancellationToken. For examples whose RunAsync() + // signature is parameterless, we ignore the token (they own their own Ctrl+C). + Func run = method.GetParameters().Length == 0 + ? _ => (Task)method.Invoke(null, null)! + : ct => (Task)method.Invoke(null, [ct])!; + + entries.Add(new ExampleEntry(id, title, desc, category, order, aliases, run)); + } + + return entries + .OrderBy(e => e.Category, StringComparer.OrdinalIgnoreCase) + .ThenBy(e => e.Order) + .ThenBy(e => e.Title, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + /// Matches a CLI flag (without the leading --) against an entry's id or aliases. + public static ExampleEntry? Find(this IReadOnlyList entries, string flag) + { + var comparer = StringComparer.OrdinalIgnoreCase; + return entries.FirstOrDefault(e => comparer.Equals(e.Id, flag) || e.Aliases.Contains(flag, comparer)); + } + + /// Converts "ChatClientLLMProvider""chatclient-llm-provider". + private static string ToKebab(string s) + { + var sb = new StringBuilder(s.Length + 4); + for (var i = 0; i < s.Length; i++) + { + var c = s[i]; + if (i > 0 && char.IsUpper(c) && !char.IsUpper(s[i - 1])) + sb.Append('-'); + sb.Append(char.ToLowerInvariant(c)); + } + return sb.ToString(); + } + + /// Converts "QuickStart""Quick Start". + private static string SpaceOut(string s) + { + var sb = new StringBuilder(s.Length + 4); + for (var i = 0; i < s.Length; i++) + { + var c = s[i]; + if (i > 0 && char.IsUpper(c) && !char.IsUpper(s[i - 1])) + sb.Append(' '); + sb.Append(c); + } + return sb.ToString(); + } +} \ No newline at end of file diff --git a/src/Delibera.ConsoleApp/Examples/ChatClientLLMProviderExample.cs b/src/Delibera.ConsoleApp/Examples/ChatClientLLMProviderExample.cs new file mode 100644 index 0000000..19f4b94 --- /dev/null +++ b/src/Delibera.ConsoleApp/Examples/ChatClientLLMProviderExample.cs @@ -0,0 +1,159 @@ +using Delibera.Core.Extensions; +using Delibera.Core.Providers.LLM; +using Microsoft.Extensions.AI; + +namespace Delibera.ConsoleApp.Examples; + +/// +/// Demonstrates the universal provider and the +/// interop bridge that lets Delibera talk to any +/// Microsoft.Extensions.AI / +/// (OpenAI, Azure OpenAI, Ollama, Anthropic, LM Studio, vLLM, …) and vice-versa. +/// +/// +/// Run with: dotnet run --project src/Delibera.ConsoleApp -- --chatclient +/// +public static class ChatClientLLMProviderExample +{ + public static async Task RunAsync() + { + Console.WriteLine("🔌 ChatClientLLMProvider — Microsoft.Extensions.AI integration\n"); + + // ────────────────────────────────────────────────────────────── + // 1. Obtain an IChatClient from any backend + // ────────────────────────────────────────────────────────────── + // The repo references OllamaSharp (which natively implements IChatClient), + // so we use a local Ollama server here. The SAME pattern works for any + // Microsoft.Extensions.AI backend, e.g. OpenAI: + // + // using OpenAI; + // var openAiClient = new OpenAIClient(apiKey); + // IChatClient chatClient = openAiClient.GetChatClient("gpt-4").AsIChatClient(); + // + const string endpoint = "http://localhost:11434"; // local Ollama + const string model = "gpt-oss"; + + using var ollama = new OllamaProvider(endpoint); + IChatClient chatClient = ollama.AsChatClient(); + Console.WriteLine($" ✦ Backend IChatClient: {ollama.ProviderName}"); + + // ────────────────────────────────────────────────────────────── + // 2. Compose the Microsoft.Extensions.AI middleware pipeline + // ────────────────────────────────────────────────────────────── + // WithMiddleware adds function-invocation + logging around the inner client. + // (Pass an ILoggerFactory to enable console logging.) + var decoratedClient = chatClient.WithMiddleware(enableFunctionInvocation: true); + Console.WriteLine(" ✦ Middleware: function invocation enabled"); + + // ────────────────────────────────────────────────────────────── + // 3. Adopt the IChatClient as a Delibera ILLMProvider + // ────────────────────────────────────────────────────────────── + // Two equivalent forms: + // var llmProvider = new ChatClientLLMProvider(decoratedClient); + // var llmProvider = decoratedClient.AsLLMProvider(); + var llmProvider = new ChatClientLLMProvider(decoratedClient, "Ollama (via ChatClientLLMProvider)"); + Console.WriteLine($" ✦ ILLMProvider: {llmProvider.ProviderName}"); + + // ────────────────────────────────────────────────────────────── + // 4. Provider introspection + // ────────────────────────────────────────────────────────────── + Console.WriteLine("\n 🩺 Provider introspection:"); + try + { + var available = await llmProvider.IsAvailableAsync(); + Console.WriteLine($" IsAvailableAsync: {available}"); + + var models = await llmProvider.ListModelsAsync(); + Console.WriteLine($" ListModelsAsync: {(models.Count > 0 ? string.Join(", ", models) : "(empty — M.E.AI has no enumeration contract)")}"); + + var caps = await llmProvider.GetModelCapabilitiesAsync(model); + Console.WriteLine(caps is not null + ? $" GetModelCapabilitiesAsync('{model}'): context window = {caps.ContextWindowTokens} tokens" + : $" GetModelCapabilitiesAsync('{model}'): null (falls back to static registry)"); + } + catch (Exception ex) + { + Console.WriteLine($" ⚠️ {ex.Message}"); + } + + // ────────────────────────────────────────────────────────────── + // 5. One-shot chat + // ────────────────────────────────────────────────────────────── + Console.WriteLine("\n 💬 ChatAsync:"); + try + { + var response = await llmProvider.ChatAsync( + model: model, + systemPrompt: "You are a helpful assistant.", + userPrompt: "What is the capital of France?", + temperature: 0.7f); + Console.WriteLine($" {response}"); + } + catch (Exception ex) + { + PrintTips(ex); + return; + } + + // ────────────────────────────────────────────────────────────── + // 6. Streaming chat + // ────────────────────────────────────────────────────────────── + Console.WriteLine("\n 🌊 ChatStreamAsync (token-by-token):\n "); + try + { + await foreach (var chunk in llmProvider.ChatStreamAsync( + model, + "You are a concise assistant.", + "In one sentence, what is Microsoft.Extensions.AI?")) + Console.Write(chunk); + Console.WriteLine(); + } + catch (Exception ex) + { + Console.WriteLine($" ⚠️ {ex.Message}"); + } + + // ────────────────────────────────────────────────────────────── + // 7. Reverse bridge: expose ILLMProvider as IChatClient + // ────────────────────────────────────────────────────────────── + // AsChatClient() returns the underlying IChatClient when the provider + // is already a ChatClientLLMProvider (no extra wrapping layer). + var roundTripped = llmProvider.AsChatClient(); + Console.WriteLine($"\n ↩️ AsChatClient() round-trip: {roundTripped.GetType().Name}"); + + // ────────────────────────────────────────────────────────────── + // 8. Embeddings via IEmbeddingGenerator -> IEmbeddingProvider + // ────────────────────────────────────────────────────────────── + Console.WriteLine("\n 🧮 Embeddings (IEmbeddingGenerator -> IEmbeddingProvider):"); + try + { + var embeddingProvider = ollama + .AsEmbeddingGenerator() + .AsEmbeddingProvider("nomic-embed-text"); + + var vector = await embeddingProvider.EmbedAsync("Delibera deliberates."); + Console.WriteLine($" Model: {embeddingProvider.EmbeddingModelName}"); + Console.WriteLine($" Dimensions: {vector.Length}"); + } + catch (Exception ex) + { + Console.WriteLine($" ⚠️ {ex.Message} (embedding model may not be pulled)"); + } + + llmProvider.Dispose(); + Console.WriteLine("\n✅ Done. See the OpenAI variant in the comments at the top of this file."); + } + + private static void PrintTips(Exception ex) + { + Console.WriteLine($"\n❌ {ex.Message}"); + Console.WriteLine("\n💡 Tips:"); + Console.WriteLine(" • Start a local Ollama server: ollama serve"); + Console.WriteLine(" • Pull the model: ollama pull llama3.2"); + Console.WriteLine(" • Pull an embedding model: ollama pull nomic-embed-text"); + Console.WriteLine(" • To use OpenAI instead, add the Microsoft.Extensions.AI.OpenAI package:"); + Console.WriteLine(" var openAiClient = new OpenAIClient(apiKey);"); + Console.WriteLine(" IChatClient chatClient = openAiClient.GetChatClient(\"gpt-4\").AsIChatClient();"); + Console.WriteLine(" var llmProvider = new ChatClientLLMProvider(chatClient);"); + } +} diff --git a/src/Delibera.ConsoleApp/Program.cs b/src/Delibera.ConsoleApp/Program.cs index a58c4b1..85a61f4 100644 --- a/src/Delibera.ConsoleApp/Program.cs +++ b/src/Delibera.ConsoleApp/Program.cs @@ -11,6 +11,7 @@ using Delibera.Core.Providers.LLM; using Delibera.Core.Providers.RAG; using Microsoft.Extensions.Configuration; +using Spectre.Console; namespace Delibera.ConsoleApp; @@ -26,133 +27,175 @@ public static async Task Main(string[] args) Console.OutputEncoding = Encoding.UTF8; PrintBanner(); - // Top-level crash guard: any unhandled exception is printed in full and the - // console is kept open so the user can read the diagnostic before it closes. - // `alreadyReported` prevents double-printing when RunAsync prints its own - // debate-specific diagnostic (with tips) before re-throwing. - var alreadyReported = false; - // Default Ctrl+C handling: cancel the in-flight debate cooperatively // instead of letting SIGINT terminate the process mid-round. - // Consumers who want to disable this behaviour (e.g. running under a - // debugger) can call `Main(args)` and ignore the default. Examples - // that use their own CancelKeyPress handlers (e.g. --cancellation) - // override this by setting e.Cancel = true on their own. using var appCts = new CancellationTokenSource(); Console.CancelKeyPress += (_, e) => { e.Cancel = true; if (!appCts.IsCancellationRequested) { - Console.WriteLine("\n⚠️ Ctrl+C detected — canceling the debate..."); + AnsiConsole.MarkupLine("\n[yellow]⚠️ Ctrl+C detected — canceling the debate...[/]"); try { appCts.Cancel(); } catch (ObjectDisposedException) { /* race */ } } }; try { - await RunAsync(args, () => alreadyReported = true, appCts.Token).ConfigureAwait(false); + await RunAsync(args, appCts.Token).ConfigureAwait(false); } catch (Exception ex) { - if (!alreadyReported) - PrintFatalError(ex); - - WaitForKeyOnExit("Press any key to exit…", true); + PrintFatalError(ex); + WaitForKeyOnExit("[red]Press any key to exit…[/]", isError: true); return; } - WaitForKeyOnExit("\n🏁 Delibera session complete. Press any key to exit…", false); + WaitForKeyOnExit("\n[green]🏁 Delibera session complete. Press any key to exit…[/]", isError: false); } - private static async Task RunAsync(string[] args, Action onDebateFailed, CancellationToken ct = default) + private static async Task RunAsync(string[] args, CancellationToken ct) { // ═══════════════════════════════════════════════ // 🆕 v3.1: DI & Separate Files Examples // ═══════════════════════════════════════════════ - if (args.Contains("--di")) - { - await DependencyInjectionExample.RunAsync(); - return; - } - if (args.Contains("--separate-files")) - { - await SeparateFilesExample.RunAsync(); - return; - } + // 1. Discover every example in the Examples/ folder dynamically. + var examples = ExampleRegistry.Discover(); - if (args.Contains("--compression")) + // 2. If a CLI flag matches an example id or alias (e.g. "--chatclient"), run it directly. + ExampleEntry? matched = null; + foreach (var flag in args.Where(a => a.StartsWith("--", StringComparison.Ordinal))) { - await CompressionExample.RunAsync(); - return; + matched = examples.Find(flag[2..]); + if (matched is not null) break; } - if (args.Contains("--multiprovider")) + if (matched is not null) { - await MultiProviderExample.RunAsync(); + await RunExampleAsync(matched, ct); return; } - if (args.Contains("--pgvector")) + // 3. "--list" prints the catalog as a Spectre table (useful for scripts / CI). + if (args.Contains("--list")) { - await PgVectorExample.RunAsync(); + PrintExampleCatalog(examples); return; } - if (args.Contains("--rag")) + // 4. Interactive Spectre.Console menu (when stdin is a TTY and no flag was given). + if (IsInteractiveConsole && args.Length == 0) { - await RagExample.RunAsync(); + var selection = ShowExampleMenu(examples); + if (selection is null) + return; + await RunExampleAsync(selection, ct); return; } - if (args.Contains("--operator")) - { - await OperatorExample.RunAsync(); - return; - } + // 5. Default demo: full config-driven council debate (reads appsettings.json). + await RunDefaultDebateAsync(args, ct); + } - if (args.Contains("--operator-mcp")) + // ─── Example dispatch ────────────────────────────────────────────────────── + + private static async Task RunExampleAsync(ExampleEntry example, CancellationToken ct) + { + AnsiConsole.Write(new Rule($"[bold green]{example.Title}[/]") { - await OperatorMcpToolsExample.RunAsync(); - return; - } + Style = Style.Parse("green dim") + }); + AnsiConsole.MarkupLine($"[dim]{example.Description}[/]"); + AnsiConsole.WriteLine(); - if (args.Contains("--msai")) + try { - await MicrosoftExtensionsAiExample.RunAsync(); - return; + await example.RunAsync(ct); } - - if (args.Contains("--resilience")) + catch (OperationCanceledException) when (ct.IsCancellationRequested) { - await ResilienceExample.RunAsync(); - return; + AnsiConsole.MarkupLine("\n[yellow]🛑 Cancelled by user (Ctrl+C).[/]"); } - - if (args.Contains("--autochunking")) + catch (Exception ex) { - await AutoChunkingExample.RunAsync(); - return; + PrintFatalError(ex, $"❌ Example '{example.Title}' failed"); + throw; } + } + + // ─── Interactive menu ──────────────────────────────────────────────────────── + + private static ExampleEntry? ShowExampleMenu(IReadOnlyList examples) + { + // Group by category, present as a flat selection list with section dividers. + var choices = new List(); + var choiceToEntry = new Dictionary(); + string? lastCategory = null; - if (args.Contains("--cancellation")) + foreach (var ex in examples) { - await CancellationExample.RunAsync(); - return; + if (ex.Category != lastCategory) + { + choices.Add($"── {ex.Category} ──"); + lastCategory = ex.Category; + } + var label = $" {ex.Title}"; + choices.Add(label); + choiceToEntry[label] = ex; } + choices.Add("── Default ──"); + choices.Add(" Full Council Debate (appsettings.json)"); + + var selected = AnsiConsole.Prompt( + new SelectionPrompt() + .Title("Choose a [green]Delibera example[/] to run:") + .PageSize(20) + .MoreChoicesText("[grey](Move up and down to see more)[/]") + .AddChoices(choices)); + + // Divider headers are not runnable; treat them as no-ops. + if (selected.Contains("Full Council Debate", StringComparison.OrdinalIgnoreCase)) + return null; + + return choiceToEntry.TryGetValue(selected, out var entry) ? entry : null; + } + + private static void PrintExampleCatalog(IReadOnlyList examples) + { + var table = new Table() + .BorderColor(Color.Green) + .AddColumn(new TableColumn("[bold]Flag[/]")) + .AddColumn(new TableColumn("[bold]Aliases[/]")) + .AddColumn(new TableColumn("[bold]Title[/]")) + .AddColumn(new TableColumn("[bold]Category[/]")) + .AddColumn(new TableColumn("[bold]Description[/]")); + + foreach (var e in examples) + table.AddRow($"--{e.Id}", string.Join(", ", e.Aliases), e.Title, e.Category, e.Description); + + AnsiConsole.Write(table); + AnsiConsole.MarkupLine("[grey]Run with any flag above, or no args for the interactive menu.[/]"); + } + + // ─── Default config-driven debate ────────────────────────────────────────── + + private static async Task RunDefaultDebateAsync(string[] args, CancellationToken ct) + { // Quick DI showcase before main demo - Console.WriteLine("🆕 v3.1 DI Quick Demo:"); - Console.WriteLine(" Run with --di for full DI example"); - Console.WriteLine(" Run with --separate-files for file output demo"); - Console.WriteLine(" Run with --autochunking for AutoChunking demo (large documents)"); - Console.WriteLine(" Run with --cancellation for cooperative cancellation demo (Ctrl+C)\n"); + AnsiConsole.MarkupLine("🆕 [bold]v3.1 DI Quick Demo:[/]"); + AnsiConsole.MarkupLine(" Run with [blue]--list[/] to see all available examples"); + AnsiConsole.MarkupLine(" Run with [blue]--di[/] for full DI example"); + AnsiConsole.MarkupLine(" Run with [blue]--separate-files[/] for file output demo"); + AnsiConsole.MarkupLine(" Run with [blue]--autochunking[/] for AutoChunking demo (large documents)"); + AnsiConsole.MarkupLine(" Run with [blue]--cancellation[/] for cooperative cancellation demo (Ctrl+C)"); + AnsiConsole.MarkupLine(" Run with [blue]--chatclient[/] for ChatClientLLMProvider (M.E.AI) demo\n"); // ═══════════════════════════════════════════════ // 1. Load configuration // ═══════════════════════════════════════════════ - Console.WriteLine("📋 Loading configuration..."); + AnsiConsole.MarkupLine("📋 [bold]Loading configuration...[/]"); var configuration = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) @@ -165,7 +208,7 @@ private static async Task RunAsync(string[] args, Action onDebateFailed, Cancell // ═══════════════════════════════════════════════ // 2. Initialise LLM providers // ═══════════════════════════════════════════════ - Console.WriteLine("🔧 Initialising LLM providers...\n"); + AnsiConsole.MarkupLine("🔧 [bold]Initialising LLM providers...[/]\n"); using var factory = new ProviderFactory(); var providers = new Dictionary(); @@ -174,29 +217,29 @@ private static async Task RunAsync(string[] args, Action onDebateFailed, Cancell { var name = sec.Key; var type = sec["Type"] ?? "Ollama"; - Console.WriteLine($" ✦ {name} (Type: {type}, Endpoint: {sec["Endpoint"]})"); + AnsiConsole.MarkupLine($" ✦ [bold]{name}[/] (Type: {type}, Endpoint: {sec["Endpoint"]})"); providers[name] = factory.Create(name, type, sec); } - Console.WriteLine($"\n ✅ {providers.Count} provider(s) initialised"); + AnsiConsole.MarkupLine($"\n ✅ [green]{providers.Count} provider(s) initialised[/]"); // Check availability - Console.WriteLine("\n🏥 Checking provider availability..."); + AnsiConsole.MarkupLine("\n🏥 [bold]Checking provider availability...[/]"); foreach (var (name, prov) in providers) try { ct.ThrowIfCancellationRequested(); var ok = await prov.IsAvailableAsync(ct); - Console.WriteLine($" {name}: {(ok ? "✅ Available" : "❌ Unavailable")}"); + AnsiConsole.MarkupLine($" {name}: {(ok ? "[green]✅ Available[/]" : "[red]❌ Unavailable[/]")}"); if (!ok) continue; var models = await prov.ListModelsAsync(ct); - Console.WriteLine($" Models: {string.Join(", ", models.Take(10))}"); + AnsiConsole.MarkupLine($" Models: {string.Join(", ", models.Take(10))}"); } catch (Exception ex) { - Console.WriteLine($" {name}: ⚠️ {ex.Message}"); - Console.WriteLine(" (Expected if Ollama Cloud API key is not configured)"); + AnsiConsole.MarkupLine($" {name}: [yellow]⚠️ {Markup.Escape(ex.Message)}[/]"); + AnsiConsole.MarkupLine(" [dim](Expected if Ollama Cloud API key is not configured)[/]"); } // ═══════════════════════════════════════════════ @@ -223,7 +266,7 @@ private static async Task RunAsync(string[] args, Action onDebateFailed, Cancell // Try Qdrant if (qdrantCfg.Exists()) { - Console.WriteLine("\n📚 Setting up RAG with Qdrant..."); + AnsiConsole.MarkupLine("\n📚 [bold]Setting up RAG with Qdrant...[/]"); try { var ragFactory = new RagProviderFactory(); @@ -231,28 +274,28 @@ private static async Task RunAsync(string[] args, Action onDebateFailed, Cancell embeddingProvider, qdrantCfg["Host"] ?? "localhost", qdrantCfg.GetValue("Port") ?? 6334); - Console.WriteLine(" ✅ Qdrant RAG ready"); + AnsiConsole.MarkupLine(" [green]✅ Qdrant RAG ready[/]"); } catch (Exception ex) { - Console.WriteLine($" ⚠️ Qdrant: {ex.Message}"); + AnsiConsole.MarkupLine($" [yellow]⚠️ Qdrant: {Markup.Escape(ex.Message)}[/]"); } } // Fallback to pgvector if (activeRagProvider is null && pgCfg.Exists()) { - Console.WriteLine("\n📚 Setting up RAG with pgvector..."); + AnsiConsole.MarkupLine("\n📚 [bold]Setting up RAG with pgvector...[/]"); try { var connStr = pgCfg["ConnectionString"] ?? "Host=localhost;Database=council_vectors;Username=postgres;Password=postgres"; var ragFactory = new RagProviderFactory(); activeRagProvider = ragFactory.CreatePgVector(embeddingProvider, connStr); - Console.WriteLine(" ✅ pgvector RAG ready"); + AnsiConsole.MarkupLine(" [green]✅ pgvector RAG ready[/]"); } catch (Exception ex) { - Console.WriteLine($" ⚠️ pgvector: {ex.Message}"); + AnsiConsole.MarkupLine($" [yellow]⚠️ pgvector: {Markup.Escape(ex.Message)}[/]"); } } @@ -269,14 +312,14 @@ private static async Task RunAsync(string[] args, Action onDebateFailed, Cancell { ct.ThrowIfCancellationRequested(); var chunks = await knowledgeKeeper.IndexFileAsync(file, ct: ct); - Console.WriteLine($" 📄 Indexed: {file} ({chunks} chunks)"); + AnsiConsole.MarkupLine($" 📄 Indexed: {file} ({chunks} chunks)"); } catch (Exception ex) { - Console.WriteLine($" ⚠️ Could not index {file}: {ex.Message}"); + AnsiConsole.MarkupLine($" [yellow]⚠️ Could not index {file}: {Markup.Escape(ex.Message)}[/]"); } - Console.WriteLine($" ✅ Knowledge Keeper ready ({activeRagProvider.ProviderName})"); + AnsiConsole.MarkupLine($" [green]✅ Knowledge Keeper ready[/] ({activeRagProvider.ProviderName})"); } } } @@ -287,7 +330,7 @@ private static async Task RunAsync(string[] args, Action onDebateFailed, Cancell IKnowledgeBase? knowledgeBase = null; if (cfg.GetValue("Knowledge:Enabled") && knowledgeKeeper is null) { - Console.WriteLine("\n📚 Loading Markdown knowledge base..."); + AnsiConsole.MarkupLine("\n📚 [bold]Loading Markdown knowledge base...[/]"); var kb = new MarkdownKnowledgeBase("Council Knowledge"); foreach (var file in cfg.GetSection("Knowledge:Files").Get() ?? []) @@ -295,11 +338,11 @@ private static async Task RunAsync(string[] args, Action onDebateFailed, Cancell { ct.ThrowIfCancellationRequested(); await kb.LoadAsync(file, ct); - Console.WriteLine($" 📄 Loaded: {file}"); + AnsiConsole.MarkupLine($" 📄 Loaded: {file}"); } catch (FileNotFoundException) { - Console.WriteLine($" ⚠️ Not found: {file}"); + AnsiConsole.MarkupLine($" [yellow]⚠️ Not found: {file}[/]"); } catch (OperationCanceledException) { @@ -309,7 +352,7 @@ private static async Task RunAsync(string[] args, Action onDebateFailed, Cancell if (kb.DocumentCount > 0) { knowledgeBase = kb; - Console.WriteLine($" ✅ {kb.DocumentCount} document(s), {kb.TotalCharacters} chars"); + AnsiConsole.MarkupLine($" [green]✅ {kb.DocumentCount} document(s), {kb.TotalCharacters} chars[/]"); } } @@ -323,7 +366,7 @@ private static async Task RunAsync(string[] args, Action onDebateFailed, Cancell var compCfg = cfg.GetSection("ContextCompression"); if (compCfg.GetValue("Enabled")) { - Console.WriteLine("\n🗜️ Setting up Context Compression..."); + AnsiConsole.MarkupLine("\n🗜️ [bold]Setting up Context Compression...[/]"); var strategyName = compCfg["Strategy"] ?? "Deduplication"; try @@ -359,23 +402,23 @@ private static async Task RunAsync(string[] args, Action onDebateFailed, Cancell compressionCache = new CompressionCache(maxEntries); } - Console.WriteLine($" Strategy: {compressor.StrategyName}"); - Console.WriteLine($" Target ratio: {compressionOptions.TargetRatio:P0}"); + AnsiConsole.MarkupLine($" Strategy: {compressor.StrategyName}"); + AnsiConsole.MarkupLine($" Target ratio: {compressionOptions.TargetRatio:P0}"); if (compressionCache is not null) - Console.WriteLine($" Cache: enabled (max {compressionCache.Count} entries)"); - Console.WriteLine(" ✅ Compression ready"); + AnsiConsole.MarkupLine($" Cache: enabled (max {compressionCache.Count} entries)"); + AnsiConsole.MarkupLine(" [green]✅ Compression ready[/]"); } catch (Exception ex) { - Console.WriteLine($" ⚠️ Compression setup failed: {ex.Message}"); - Console.WriteLine(" ℹ️ Proceeding without compression"); + AnsiConsole.MarkupLine($" [yellow]⚠️ Compression setup failed: {Markup.Escape(ex.Message)}[/]"); + AnsiConsole.MarkupLine(" [dim]ℹ️ Proceeding without compression[/]"); } } // ═══════════════════════════════════════════════ // 6. Build the Council // ═══════════════════════════════════════════════ - Console.WriteLine("\n🏛️ Building the Council...\n"); + AnsiConsole.MarkupLine("\n🏛️ [bold]Building the Council...[/]\n"); var debateCfg = cfg.GetSection("Debate"); var stratName = debateCfg["Strategy"] ?? "Standard"; @@ -414,11 +457,11 @@ private static async Task RunAsync(string[] args, Action onDebateFailed, Cancell if (providers.TryGetValue(provName, out var prov)) { builder.AddMember(modelName, prov, role, persona); - Console.WriteLine($" 👤 {modelName} ({provName}) [{role}]"); + AnsiConsole.MarkupLine($" 👤 {modelName} ({provName}) [{role}]"); } else { - Console.WriteLine($" ⚠️ Provider '{provName}' not found for '{modelName}'"); + AnsiConsole.MarkupLine($" [yellow]⚠️ Provider '{provName}' not found for '{modelName}'[/]"); } } @@ -437,7 +480,7 @@ private static async Task RunAsync(string[] args, Action onDebateFailed, Cancell _ => Chairman.CreateStandard(chairModel, cp) }; builder.SetChairman(chairman); - Console.WriteLine($" ★ Chairman: {chairModel} ({chairProv}) [{chairType}]"); + AnsiConsole.MarkupLine($" ★ Chairman: {chairModel} ({chairProv}) [{chairType}]"); } // Knowledge @@ -452,7 +495,7 @@ private static async Task RunAsync(string[] args, Action onDebateFailed, Cancell builder.WithCompression(compressor, compressionOptions); if (compressionCache is not null) builder.WithCompressionCache(); - Console.WriteLine($" 🗜️ Compression: {compressor.StrategyName}"); + AnsiConsole.MarkupLine($" 🗜️ Compression: {compressor.StrategyName}"); } // Output @@ -462,13 +505,13 @@ private static async Task RunAsync(string[] args, Action onDebateFailed, Cancell builder.SaveResultTo(outputFile); var executor = builder.Build(); - Console.WriteLine($"\n{executor.GetInfo()}"); + AnsiConsole.MarkupLine($"\n{Markup.Escape(executor.GetInfo())}"); // ═══════════════════════════════════════════════ // 7. Run the debate // ═══════════════════════════════════════════════ - Console.WriteLine("🎯 Starting debate...\n"); - Console.WriteLine(new string('═', 60)); + AnsiConsole.MarkupLine("🎯 [bold]Starting debate...[/]\n"); + AnsiConsole.Write(new Rule().RuleStyle(Style.Parse("green dim"))); // Stream every ExecutionLog entry live so the user can watch progress in real time. executor.OnLog += entry => WriteLogEntry(entry); @@ -478,21 +521,21 @@ private static async Task RunAsync(string[] args, Action onDebateFailed, Cancell executor.OnRoundCompleted += round => { - Console.WriteLine($"\n✅ Round {round.RoundNumber}: {round.RoundName} ({round.Duration.TotalSeconds:F1}s)"); - Console.WriteLine(new string('─', 50)); + AnsiConsole.MarkupLine($"\n[green]✅ Round {round.RoundNumber}: {round.RoundName}[/] [dim]({round.Duration.TotalSeconds:F1}s)[/]"); + AnsiConsole.Write(new Rule().RuleStyle(Style.Parse("grey dim"))); if (round.KnowledgeInteractions.Count > 0) { - Console.WriteLine(" 📚 Knowledge Keeper interactions:"); + AnsiConsole.MarkupLine(" 📚 Knowledge Keeper interactions:"); foreach (var ki in round.KnowledgeInteractions) - Console.WriteLine($" Q: {ki.Query[..Math.Min(80, ki.Query.Length)]}… → {ki.SourceChunks} chunks"); + AnsiConsole.MarkupLine($" Q: {Markup.Escape(ki.Query[..Math.Min(80, ki.Query.Length)])}… → {ki.SourceChunks} chunks"); } if (round.OperatorInteractions.Count > 0) { - Console.WriteLine(" 🛠️ Operator interactions:"); + AnsiConsole.MarkupLine(" 🛠️ Operator interactions:"); foreach (var oi in round.OperatorInteractions) - Console.WriteLine($" {oi.RequesterName}: {oi.Task[..Math.Min(80, oi.Task.Length)]}… → {oi.ToolCallCount} tool call(s)"); + AnsiConsole.MarkupLine($" {oi.RequesterName}: {Markup.Escape(oi.Task[..Math.Min(80, oi.Task.Length)])}… → {oi.ToolCallCount} tool call(s)"); } foreach (var (member, response) in round.Responses) @@ -500,129 +543,137 @@ private static async Task RunAsync(string[] args, Action onDebateFailed, Cancell var preview = response.Length > 300 ? response[..300] + "…" : response; - Console.WriteLine($"\n 📝 {member}:"); - Console.WriteLine($" {preview.Replace("\n", "\n ")}"); + AnsiConsole.MarkupLine($"\n 📝 [bold]{Markup.Escape(member)}[/]:"); + AnsiConsole.MarkupLine($" {Markup.Escape(preview).Replace("\n", "\n ")}"); } - Console.WriteLine(new string('─', 50)); + AnsiConsole.Write(new Rule().RuleStyle(Style.Parse("grey dim"))); }; try { var result = await executor.ExecuteAsync(ct); - Console.WriteLine($"\n{new string('═', 60)}"); - Console.WriteLine("🏆 DEBATE COMPLETED!"); - Console.WriteLine($"{new string('═', 60)}\n"); + AnsiConsole.Write(new Rule().RuleStyle(Style.Parse("green"))); + AnsiConsole.MarkupLine("[bold green]🏆 DEBATE COMPLETED![/]"); + AnsiConsole.Write(new Rule().RuleStyle(Style.Parse("green"))); - Console.WriteLine($" Debate ID: {result.DebateId}"); - Console.WriteLine($" Strategy: {result.StrategyName}"); - Console.WriteLine($" Rounds: {result.Rounds.Count}"); - Console.WriteLine($" Duration: {result.TotalDuration.TotalSeconds:F1}s"); - Console.WriteLine($" Participants: {string.Join(", ", result.Participants)}"); + AnsiConsole.MarkupLine($"\n Debate ID: {result.DebateId}"); + AnsiConsole.MarkupLine($" Strategy: {result.StrategyName}"); + AnsiConsole.MarkupLine($" Rounds: {result.Rounds.Count}"); + AnsiConsole.MarkupLine($" Duration: {result.TotalDuration.TotalSeconds:F1}s"); + AnsiConsole.MarkupLine($" Participants: {string.Join(", ", result.Participants)}"); if (result.KnowledgeKeeperName is not null) - Console.WriteLine($" Knowledge Keeper: {result.KnowledgeKeeperName}"); + AnsiConsole.MarkupLine($" Knowledge Keeper: {result.KnowledgeKeeperName}"); if (result.OperatorName is not null) - Console.WriteLine($" Operator: {result.OperatorName}"); + AnsiConsole.MarkupLine($" Operator: {result.OperatorName}"); // Token statistics - if (result.TokenStats is not null) Console.WriteLine($"\n{result.TokenStats.ToSummary()}"); + if (result.TokenStats is not null) AnsiConsole.MarkupLine($"\n{Markup.Escape(result.TokenStats.ToSummary())}"); // Compression log summary if (result.CompressionLogs.Count > 0) { - Console.WriteLine($" 🗜️ Compression ops: {result.CompressionLogs.Count}"); + AnsiConsole.MarkupLine($" 🗜️ Compression ops: {result.CompressionLogs.Count}"); foreach (var log in result.CompressionLogs) - Console.WriteLine($" R{log.RoundNumber}: {log.Description} — {log.Ratio:P0} ({log.Duration.TotalMilliseconds:F0}ms)"); + AnsiConsole.MarkupLine($" R{log.RoundNumber}: {log.Description} — {log.Ratio:P0} ({log.Duration.TotalMilliseconds:F0}ms)"); } // Cache stats if (compressionCache is not null) - Console.WriteLine($"\n {compressionCache.GetSummary()}"); + AnsiConsole.MarkupLine($"\n {Markup.Escape(compressionCache.GetSummary())}"); if (!string.IsNullOrWhiteSpace(result.OpeningStatement)) { - Console.WriteLine("\n══ OPENING STATEMENT ══\n"); - Console.WriteLine(result.OpeningStatement); + AnsiConsole.MarkupLine("\n[bold]══ OPENING STATEMENT ══[/]\n"); + AnsiConsole.MarkupLine(Markup.Escape(result.OpeningStatement)); } if (!string.IsNullOrWhiteSpace(result.FinalVerdict)) { - Console.WriteLine("\n══ FINAL VERDICT ══\n"); - Console.WriteLine(result.FinalVerdict); + AnsiConsole.MarkupLine("\n[bold]══ FINAL VERDICT ══[/]\n"); + AnsiConsole.MarkupLine(Markup.Escape(result.FinalVerdict)); } // 🆕 v3.1: Save separate files - Console.WriteLine("\n 📁 Output files:"); - Console.WriteLine($" Single file: {outputFile}"); + AnsiConsole.MarkupLine("\n 📁 Output files:"); + AnsiConsole.MarkupLine($" Single file: {outputFile}"); try { var separateDir = Path.Combine(outputDir, $"debate_{DateTime.UtcNow:yyyyMMdd_HHmmss}"); var (rp, sp, lp) = await result.SaveAllAsync(separateDir, filePrefix: null, ct: ct); - Console.WriteLine($" Result: {rp}"); - Console.WriteLine($" Statistics: {sp}"); - Console.WriteLine($" Logs: {lp}"); + AnsiConsole.MarkupLine($" Result: {rp}"); + AnsiConsole.MarkupLine($" Statistics: {sp}"); + AnsiConsole.MarkupLine($" Logs: {lp}"); } catch (Exception saveEx) { - Console.WriteLine($" ⚠️ Separate files: {saveEx.Message}"); + AnsiConsole.MarkupLine($" [yellow]⚠️ Separate files: {Markup.Escape(saveEx.Message)}[/]"); } // 🆕 v3.1: Execution logs summary if (result.ExecutionLogs.Count > 0) { - Console.WriteLine($"\n 📋 Execution Logs ({result.ExecutionLogs.Count} entries):"); + AnsiConsole.MarkupLine($"\n 📋 Execution Logs ({result.ExecutionLogs.Count} entries):"); foreach (var log in result.ExecutionLogs.Where(l => l.Level >= ExecutionLogLevel.Info)) - Console.WriteLine($" {log}"); + AnsiConsole.MarkupLine($" {Markup.Escape(log.ToString())}"); } } catch (OperationCanceledException) when (ct.IsCancellationRequested) { - Console.WriteLine("\n🛑 Debate cancelled by user (Ctrl+C or token)."); + AnsiConsole.MarkupLine("\n[yellow]🛑 Debate cancelled by user (Ctrl+C or token).[/]"); // Don't print the full fatal-error panel for a clean cancel. } catch (Exception ex) { PrintFatalError(ex, "❌ Debate failed"); - Console.WriteLine("\n💡 Tips:"); - Console.WriteLine(" • Ensure Ollama Cloud API key is set in appsettings.json"); - Console.WriteLine(" • Or run a local Ollama server: ollama serve"); - Console.WriteLine(" • For Qdrant RAG: docker run -p 6333:6333 -p 6334:6334 qdrant/qdrant"); - Console.WriteLine(" • For pgvector RAG: PostgreSQL 15+ with CREATE EXTENSION vector;"); - onDebateFailed(); + AnsiConsole.MarkupLine("\n💡 [bold]Tips:[/]"); + AnsiConsole.MarkupLine(" • Ensure Ollama Cloud API key is set in appsettings.json"); + AnsiConsole.MarkupLine(" • Or run a local Ollama server: ollama serve"); + AnsiConsole.MarkupLine(" • For Qdrant RAG: docker run -p 6333:6333 -p 6334:6334 qdrant/qdrant"); + AnsiConsole.MarkupLine(" • For pgvector RAG: PostgreSQL 15+ with CREATE EXTENSION vector;"); throw; } } + // ─── Banner & observability helpers ────────────────────────────────────────── + private static void PrintBanner() { - Console.ForegroundColor = ConsoleColor.Green; - Console.WriteLine(""" - - ██████╗ ███████╗██╗ ██╗██████╗ ███████╗██████╗ █████╗ - ██╔══██╗██╔════╝██║ ██║██╔══██╗██╔════╝██╔══██╗██╔══██╗ - ██║ ██║█████╗ ██║ ██║██████╔╝█████╗ ██████╔╝███████║ - ██║ ██║██╔══╝ ██║ ██║██╔══██╗██╔══╝ ██╔══██╗██╔══██║ - ██████╔╝███████╗███████╗██║██████╔╝███████╗██║ ██║██║ ██║ - ╚═════╝ ╚══════╝╚══════╝╚═╝╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ - - ⚖️ Thoughtful AI Decisions · v3.1 - - RAG • pgvector • Knowledge Keeper • Chairman - Context Compression • DI • Execution Logging - - """); - Console.ResetColor(); + AnsiConsole.Write(new FigletText("Delibera") + { + Color = Color.Green + }); + AnsiConsole.Write(new Text(" ⚖️ Thoughtful AI Decisions · v3.1", new Style(Color.Grey)) + { + Justification = Justify.Center + }); + AnsiConsole.WriteLine(); + AnsiConsole.Write(new Text("RAG • pgvector • Knowledge Keeper • Chairman", new Style(Color.Grey)) + { + Justification = Justify.Center + }); + AnsiConsole.WriteLine(); + AnsiConsole.Write(new Text("Context Compression • DI • Execution Logging", new Style(Color.Grey)) + { + Justification = Justify.Center + }); + AnsiConsole.WriteLine(); + AnsiConsole.Write(new Rule().RuleStyle(Style.Parse("green dim"))); + AnsiConsole.WriteLine(); } - // ─── Console observability helpers ───────────────────────────────────────────── - /// /// Writes a single entry to the console in a /// colour-coded, single-line format. Safe to call from the executor's /// streaming events. /// + /// + /// Uses rather than AnsiConsole.MarkupLine because the + /// log text may contain arbitrary model output (including Spectre markup characters) + /// and runs from a non-UI thread where Spectre's single-line writer is not safe. + /// private static void WriteLogEntry(ExecutionLog entry) { var prev = Console.ForegroundColor; @@ -661,49 +712,35 @@ private static void WriteErrorEntry(Exception ex, string context) } /// - /// Prints a full diagnostic panel for a fatal exception: type, message, - /// full stack trace and the live log transcript captured so far. + /// Prints a full diagnostic panel for a fatal exception using Spectre.Console. /// /// The exception that aborted the run. /// Optional header line; defaults to a generic label. private static void PrintFatalError(Exception ex, string header = "❌ Unhandled exception") { - var prev = Console.ForegroundColor; - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine(); - Console.WriteLine(new string('═', 60)); - Console.WriteLine($" {header}"); - Console.WriteLine(new string('═', 60)); - Console.WriteLine($" Type: {ex.GetType().FullName}"); - Console.WriteLine($" Message: {ex.Message}"); - Console.WriteLine(); - Console.WriteLine(" ── Stack trace ──"); - Console.WriteLine(ex.StackTrace); - if (ex.InnerException is not null) - { - Console.WriteLine(); - Console.WriteLine(" ── Inner exception ──"); - Console.WriteLine($" Type: {ex.InnerException.GetType().FullName}"); - Console.WriteLine($" Message: {ex.InnerException.Message}"); - Console.WriteLine(ex.InnerException.StackTrace); - } + var content = new Markup( + $"[bold]Type:[/] {Markup.Escape(ex.GetType().FullName ?? ex.GetType().Name)}\n" + + $"[bold]Message:[/] {Markup.Escape(ex.Message)}\n\n" + + $"[bold]Stack trace:[/]\n{Markup.Escape(ex.StackTrace ?? "(none)")}").LeftJustified(); - Console.ForegroundColor = prev; + AnsiConsole.Write( + new Panel(content) + { + Header = new PanelHeader(header), + Border = BoxBorder.Rounded, + BorderStyle = new Style(Color.Red) + }); } /// /// Pauses the console so the user can read output before the window closes. /// Honoured in both normal and error paths. When - /// is true the prompt is shown in red and the exit code is set to 1. + /// is true the exit code is set to 1. /// private static void WaitForKeyOnExit(string prompt, bool isError) { - if (isError) - Console.ForegroundColor = ConsoleColor.Red; - - Console.WriteLine(); - Console.WriteLine(prompt); - Console.ResetColor(); + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine(prompt); try { @@ -712,7 +749,7 @@ private static void WaitForKeyOnExit(string prompt, bool isError) catch (InvalidOperationException) { // No interactive console (e.g. redirected stdin in CI) — fall back gracefully. - Console.WriteLine("(no interactive console available; exiting.)"); + AnsiConsole.MarkupLine("[grey](no interactive console available; exiting.)[/]"); } Environment.ExitCode = isError @@ -720,7 +757,22 @@ private static void WaitForKeyOnExit(string prompt, bool isError) : 0; } - private static bool FirstLineIsMeaningful(string? frame) + private static bool IsInteractiveConsole + { + get + { + try + { + return !Console.IsInputRedirected && !Console.IsOutputRedirected; + } + catch + { + return false; + } + } + } + + private static bool FirstLineIsMeaningful(string? frame) { if (string.IsNullOrWhiteSpace(frame)) return false; diff --git a/src/Delibera.Core/Delibera.Core.csproj b/src/Delibera.Core/Delibera.Core.csproj index 96806b3..6532d32 100644 --- a/src/Delibera.Core/Delibera.Core.csproj +++ b/src/Delibera.Core/Delibera.Core.csproj @@ -12,7 +12,7 @@ Delibera.Core - 10.2.4 + 10.2.5 Delibera Delibera — Thoughtful AI Decisions. A framework for collective decision making through structured AI deliberation. Supports multi-model councils with RAG (Qdrant, PgVector), debate strategies, Knowledge Keeper, Chairman roles, dependency injection, context compression, AutoChunking (progressive disclosure for large documents), Microsoft.Extensions.Logging, Microsoft.Extensions.Http.Resilience (Polly v8 retry pipelines), IHttpClientFactory wiring, response-language enforcement, parallel Operator requests, and full cooperative CancellationToken support across every async public method (rounds, LLM calls, MCP tools, RAG, file saves). Victor Buzin @@ -26,7 +26,7 @@ https://github.com/techbuzzz/Delibera git https://github.com/techbuzzz/Delibera - v10.2.4 — Full cooperative CancellationToken support across every public async method: a single cancel signal now aborts the entire pipeline (rounds, Chairman synthesis, LLM calls, MCP tool invocations, RAG queries, file writes) via OperationCanceledException. Additive CancellationToken ct = default parameter on IKnowledgeBase.LoadAsync/LoadManyAsync (source-breaking only for external implementers; binary-compatible for callers), MarkdownKnowledgeBase file-path API (LoadAsync, LoadManyAsync, LoadDirectoryAsync), DebateResult save methods (SaveToMarkdownAsync, SaveStatisticsAsync, SaveLogsAsync, SaveAllAsync, SaveToFileAsync), and CouncilExecutor.ExecuteAsync which now forwards the caller's token to the final result.SaveToFileAsync step. New Delibera.Core.Extensions.IAppStoppingToken minimal abstraction (CancellationToken ApplicationStopping) and CouncilExecutorLifetimeExtensions.ExecuteAsync(ICouncilExecutor, IAppStoppingToken, CancellationToken) that links the caller's token with the host stop signal via CancellationTokenSource.CreateLinkedTokenSource so an ASP.NET Core / Worker Service shutdown cancels the debate cooperatively. New MarkdownKnowledgeBase in-memory loaders: KnowledgeDocument record (Name, Content, Metadata) for tagging documents with per-source metadata; LoadTextAsync(string, string, CancellationToken) and LoadTextAsync(KnowledgeDocument, CancellationToken) ingest markdown bodies without temp files; LoadTextsAsync(IEnumerable{KnowledgeDocument>, CancellationToken) for sequential bulk ingest with cooperative cancellation between documents; new DocumentMetadata property exposes the per-source metadata snapshot. New Delibera.ConsoleApp.Examples.CancellationExample (dotnet run -- --cancellation) wires Console.CancelKeyPress to a CancellationTokenSource and forwards the token through executor.ExecuteAsync(cts.Token); Program.Main also wires Ctrl+C for the main demo path. Tests: 9 new in MarkdownKnowledgeBaseTests (in-memory loader happy-paths + 6 cancellation tests), 7 new in DebateResultCancellationTests (all save methods + SaveAllAsync happy-path), 2 new in CouncilExecutorCancellationTests, 5 new in CouncilExecutorLifetimeExtensionsTests covering the IAppStoppingToken helper. New Cancellation Support section in both Delibera.Core/README.md and the repo-root README.md (4 worked examples, cancellable-operations table, ASP.NET Core IHostApplicationLifetime adapter). v10.2.3 — AutoChunking: automatic splitting of large knowledge documents into context-window-sized chunks distributed across debate rounds via progressive disclosure. New Delibera.Core.Chunking namespace with AutoChunker (SemanticBoundary / FixedSize / SlidingWindow strategies), AutoChunkingOrchestrator, and AutoChunkingOptions. ModelContextWindowRegistry with 40+ pre-registered models. ILLMProvider.GetModelCapabilitiesAsync() — OllamaProvider queries /api/show for num_ctx, others fall back to the registry. PromptContext extended with ChunkingPlan, AutoChunkingEnabled, MinContextWindow, and GetChunkedUserPrompt(). All debate strategies (Standard, Critique, Consensus) use BuildChunkedPrompt() for round-aware chunk distribution. CouncilBuilder gains WithOptions(CouncilOptions) / WithOptions(Action{CouncilOptions}) for bulk configuration from DI, appsettings, or inline lambdas; new CouncilBuilder(CouncilOptions) constructor and DI auto-wiring. New AutoChunkingConfig in CouncilOptions with appsettings binding. ConsoleApp example: dotnet run -- --autochunking. Fully backward compatible — AutoChunking is opt-in. + v10.2.5 — OllamaProvider now accepts an optional maxOutputTokens constructor parameter (default -1 = infinite generation) instead of OllamaSharp's NumPredict default of 128, which truncated long responses (chairman council verdicts, schema discovery, summaries) mid-JSON and broke downstream parsers. The new _maxOutputTokens field is forwarded through every constructor and the ForLocal/ForCloud static factories; ProviderFactory.CreateOllama/CreateLocalOllama/CreateCloudOllama accept maxOutputTokens and include it in the cache-key; the config-driven Ollama builder reads MaxOutputTokens from IConfigurationSection. NumCtx is intentionally left unset so Ollama picks the native context window from /api/show Modelfile. Backward-compatible: existing calls without the parameter get -1. v10.2.4 — Full cooperative CancellationToken support across every public async method: a single cancel signal now aborts the entire pipeline (rounds, Chairman synthesis, LLM calls, MCP tool invocations, RAG queries, file writes) via OperationCanceledException. Additive CancellationToken ct = default parameter on IKnowledgeBase.LoadAsync/LoadManyAsync (source-breaking only for external implementers; binary-compatible for callers), MarkdownKnowledgeBase file-path API (LoadAsync, LoadManyAsync, LoadDirectoryAsync), DebateResult save methods (SaveToMarkdownAsync, SaveStatisticsAsync, SaveLogsAsync, SaveAllAsync, SaveToFileAsync), and CouncilExecutor.ExecuteAsync which now forwards the caller's token to the final result.SaveToFileAsync step. New Delibera.Core.Extensions.IAppStoppingToken minimal abstraction (CancellationToken ApplicationStopping) and CouncilExecutorLifetimeExtensions.ExecuteAsync(ICouncilExecutor, IAppStoppingToken, CancellationToken) that links the caller's token with the host stop signal via CancellationTokenSource.CreateLinkedTokenSource so an ASP.NET Core / Worker Service shutdown cancels the debate cooperatively. New MarkdownKnowledgeBase in-memory loaders: KnowledgeDocument record (Name, Content, Metadata) for tagging documents with per-source metadata; LoadTextAsync(string, string, CancellationToken) and LoadTextAsync(KnowledgeDocument, CancellationToken) ingest markdown bodies without temp files; LoadTextsAsync(IEnumerable{KnowledgeDocument>, CancellationToken) for sequential bulk ingest with cooperative cancellation between documents; new DocumentMetadata property exposes the per-source metadata snapshot. New Delibera.ConsoleApp.Examples.CancellationExample (dotnet run -- --cancellation) wires Console.CancelKeyPress to a CancellationTokenSource and forwards the token through executor.ExecuteAsync(cts.Token); Program.Main also wires Ctrl+C for the main demo path. Tests: 9 new in MarkdownKnowledgeBaseTests (in-memory loader happy-paths + 6 cancellation tests), 7 new in DebateResultCancellationTests (all save methods + SaveAllAsync happy-path), 2 new in CouncilExecutorCancellationTests, 5 new in CouncilExecutorLifetimeExtensionsTests covering the IAppStoppingToken helper. New Cancellation Support section in both Delibera.Core/README.md and the repo-root README.md (4 worked examples, cancellable-operations table, ASP.NET Core IHostApplicationLifetime adapter). v10.2.3 — AutoChunking: automatic splitting of large knowledge documents into context-window-sized chunks distributed across debate rounds via progressive disclosure. New Delibera.Core.Chunking namespace with AutoChunker (SemanticBoundary / FixedSize / SlidingWindow strategies), AutoChunkingOrchestrator, and AutoChunkingOptions. ModelContextWindowRegistry with 40+ pre-registered models. ILLMProvider.GetModelCapabilitiesAsync() — OllamaProvider queries /api/show for num_ctx, others fall back to the registry. PromptContext extended with ChunkingPlan, AutoChunkingEnabled, MinContextWindow, and GetChunkedUserPrompt(). All debate strategies (Standard, Critique, Consensus) use BuildChunkedPrompt() for round-aware chunk distribution. CouncilBuilder gains WithOptions(CouncilOptions) / WithOptions(Action{CouncilOptions}) for bulk configuration from DI, appsettings, or inline lambdas; new CouncilBuilder(CouncilOptions) constructor and DI auto-wiring. New AutoChunkingConfig in CouncilOptions with appsettings binding. ConsoleApp example: dotnet run -- --autochunking. Fully backward compatible — AutoChunking is opt-in. true snupkg true diff --git a/src/Delibera.Core/Providers/LLM/ChatClientLLMProvider.cs b/src/Delibera.Core/Providers/LLM/ChatClientLLMProvider.cs index 38028f0..dba23f2 100644 --- a/src/Delibera.Core/Providers/LLM/ChatClientLLMProvider.cs +++ b/src/Delibera.Core/Providers/LLM/ChatClientLLMProvider.cs @@ -54,7 +54,7 @@ public ChatClientLLMProvider(IChatClient chatClient, string? providerName = null } /// The default model id reported by the underlying client metadata (may be null). - public string? DefaultModelId { get; } + private string? DefaultModelId { get; } /// Exposes the wrapped for advanced scenarios and middleware composition. public IChatClient ChatClient { get; } @@ -118,9 +118,9 @@ public async Task ChatAsync( { var response = await ChatClient.GetResponseAsync(messages, options, ct); var text = response.Text.Trim(); - if (string.IsNullOrWhiteSpace(text)) - throw new InvalidOperationException($"Empty response from model '{model}' ({ProviderName})."); - return text; + return string.IsNullOrWhiteSpace(text) + ? throw new InvalidOperationException($"Empty response from model '{model}' ({ProviderName}).") + : text; } catch (OperationCanceledException) when (ct.IsCancellationRequested) { @@ -182,4 +182,4 @@ private ChatOptions BuildOptions(string model, float temperature) Temperature = temperature }; } -} \ No newline at end of file +} diff --git a/src/Delibera.Core/Providers/LLM/OllamaProvider.cs b/src/Delibera.Core/Providers/LLM/OllamaProvider.cs index 76bc863..3c96758 100644 --- a/src/Delibera.Core/Providers/LLM/OllamaProvider.cs +++ b/src/Delibera.Core/Providers/LLM/OllamaProvider.cs @@ -52,13 +52,24 @@ public sealed class OllamaProvider : ILLMProvider private readonly IHttpClientFactory? _httpClientFactory; private readonly string? _httpClientName; private readonly Polly.ResiliencePipeline? _pipeline; + private readonly int _maxOutputTokens; private bool _disposed; - /// Creates an Ollama provider. The mode is inferred from : non-empty selects cloud. - public OllamaProvider(string endpoint, string apiKey = "", TimeSpan? timeout = null) + /// + /// Creates an Ollama provider. The mode is inferred from : non-empty selects cloud. + /// + /// (default -1 = infinite generation) overrides the + /// OllamaSharp-NumPredict default of 128, which truncates long responses (chairman + /// council verdicts, schema discovery, summaries) mid-JSON and breaks downstream parsers. Pass + /// a positive value to cap output per call, or -1 to let the model/provider ceiling apply. + /// + /// + public OllamaProvider(string endpoint, string apiKey = "", TimeSpan? timeout = null, + int maxOutputTokens = -1) : this(endpoint, apiKey, timeout, httpClientFactory: null, resilienceProvider: null, httpClientName: null, pipelineName: null, - mode: string.IsNullOrWhiteSpace(apiKey) ? OllamaConnectionMode.Local : OllamaConnectionMode.Cloud) + mode: string.IsNullOrWhiteSpace(apiKey) ? OllamaConnectionMode.Local : OllamaConnectionMode.Cloud, + maxOutputTokens: maxOutputTokens) { } @@ -76,6 +87,11 @@ public OllamaProvider(string endpoint, string apiKey = "", TimeSpan? timeout = n /// Logical HttpClient name; defaults to Delibera.Ollama.{Mode}. /// Pipeline key; defaults to Local or Cloud pipeline by mode. /// Explicit connection mode override (legacy ForLocal/ForCloud only). + /// + /// Maximum number of tokens to predict per generation. Default -1 = infinite generation + /// (the model/provider ceiling applies); overrides the OllamaSharp default of 128 which + /// truncates long responses mid-JSON. Pass a positive value to cap output per call. + /// public OllamaProvider( string endpoint, string apiKey, @@ -84,7 +100,8 @@ public OllamaProvider( IDeliberaResiliencePipelineProvider? resilienceProvider, string? httpClientName = null, string? pipelineName = null, - OllamaConnectionMode? mode = null) + OllamaConnectionMode? mode = null, + int maxOutputTokens = -1) { ArgumentException.ThrowIfNullOrWhiteSpace(endpoint); @@ -133,6 +150,7 @@ public OllamaProvider( } Client = client; + _maxOutputTokens = maxOutputTokens; } /// The connection mode this provider was configured with. @@ -255,12 +273,19 @@ public async Task ChatAsync( messages.Add(new Message(ChatRole.System, systemPrompt)); messages.Add(new Message(ChatRole.User, userPrompt)); - var request = new ChatRequest - { - Model = model, - Messages = messages, - Options = new RequestOptions { Temperature = temperature } - }; + var request = new ChatRequest + { + Model = model, + Messages = messages, + // NumPredict: -1 (infinite) overrides the OllamaSharp default of 128 tokens, which + // truncates long responses (chairman council verdicts, schema discovery, summaries) + // mid-JSON and breaks downstream parsers. The provider ctor's maxOutputTokens + // (default -1) is the per-instance cap; pass a positive value to budget a call. + // NumCtx is intentionally left unset — Ollama reads the native context window from + // /api/show Modelfile (see GetModelCapabilitiesAsync), so hardcoding 8192 would + // shrink the context for large models (gpt-oss:120b-cloud = 128K, yandexgpt-5-pro = 32K). + Options = new RequestOptions { Temperature = temperature, NumPredict = _maxOutputTokens } + }; // The chat operation is owned by OllamaSharp (it streams response chunks). // When a Polly v8 pipeline is configured we wrap the entire streaming @@ -344,66 +369,81 @@ public void Dispose() Client.Dispose(); } - /// Creates a provider for a local Ollama server (e.g. http://localhost:11434). - public static OllamaProvider ForLocal(string endpoint, TimeSpan? timeout = null) - { - ArgumentException.ThrowIfNullOrWhiteSpace(endpoint); - return new OllamaProvider(endpoint, "", timeout, null, null, - httpClientName: null, pipelineName: null, - mode: OllamaConnectionMode.Local); - } - - /// Creates a provider for Ollama Cloud (e.g. https://api.ollama.com) with an API key. - public static OllamaProvider ForCloud(string endpoint, string apiKey, TimeSpan? timeout = null) - { - ArgumentException.ThrowIfNullOrWhiteSpace(endpoint); - ArgumentException.ThrowIfNullOrWhiteSpace(apiKey); - return new OllamaProvider(endpoint, apiKey, timeout, null, null, - httpClientName: null, pipelineName: null, - mode: OllamaConnectionMode.Cloud); - } - - /// - /// Creates a DI-friendly local Ollama provider with handler pooling and the local retry pipeline. - /// - public static OllamaProvider ForLocal( - string endpoint, - IHttpClientFactory httpClientFactory, - IDeliberaResiliencePipelineProvider resilienceProvider, - string? httpClientName = null, - string? pipelineName = null, - TimeSpan? timeout = null) - { - ArgumentException.ThrowIfNullOrWhiteSpace(endpoint); - ArgumentNullException.ThrowIfNull(httpClientFactory); - ArgumentNullException.ThrowIfNull(resilienceProvider); - return new OllamaProvider(endpoint, "", timeout, - httpClientFactory, resilienceProvider, - httpClientName, pipelineName, - mode: OllamaConnectionMode.Local); - } - - /// - /// Creates a DI-friendly cloud Ollama provider with handler pooling and the cloud retry pipeline. - /// - public static OllamaProvider ForCloud( - string endpoint, - string apiKey, - IHttpClientFactory httpClientFactory, - IDeliberaResiliencePipelineProvider resilienceProvider, - string? httpClientName = null, - string? pipelineName = null, - TimeSpan? timeout = null) - { - ArgumentException.ThrowIfNullOrWhiteSpace(endpoint); - ArgumentException.ThrowIfNullOrWhiteSpace(apiKey); - ArgumentNullException.ThrowIfNull(httpClientFactory); - ArgumentNullException.ThrowIfNull(resilienceProvider); - return new OllamaProvider(endpoint, apiKey, timeout, - httpClientFactory, resilienceProvider, - httpClientName, pipelineName, - mode: OllamaConnectionMode.Cloud); - } + /// Creates a provider for a local Ollama server (e.g. http://localhost:11434). + /// Ollama endpoint URL. + /// HTTP timeout (null = local default). + /// Per-call output token cap; -1 = infinite generation (default). + public static OllamaProvider ForLocal(string endpoint, TimeSpan? timeout = null, + int maxOutputTokens = -1) + { + ArgumentException.ThrowIfNullOrWhiteSpace(endpoint); + return new OllamaProvider(endpoint, "", timeout, null, null, + httpClientName: null, pipelineName: null, + mode: OllamaConnectionMode.Local, + maxOutputTokens: maxOutputTokens); + } + + /// Creates a provider for Ollama Cloud (e.g. https://api.ollama.com) with an API key. + /// Ollama endpoint URL. + /// Ollama Cloud API key. + /// HTTP timeout (null = cloud default). + /// Per-call output token cap; -1 = infinite generation (default). + public static OllamaProvider ForCloud(string endpoint, string apiKey, TimeSpan? timeout = null, + int maxOutputTokens = -1) + { + ArgumentException.ThrowIfNullOrWhiteSpace(endpoint); + ArgumentException.ThrowIfNullOrWhiteSpace(apiKey); + return new OllamaProvider(endpoint, apiKey, timeout, null, null, + httpClientName: null, pipelineName: null, + mode: OllamaConnectionMode.Cloud, + maxOutputTokens: maxOutputTokens); + } + + /// + /// Creates a DI-friendly local Ollama provider with handler pooling and the local retry pipeline. + /// + public static OllamaProvider ForLocal( + string endpoint, + IHttpClientFactory httpClientFactory, + IDeliberaResiliencePipelineProvider resilienceProvider, + string? httpClientName = null, + string? pipelineName = null, + TimeSpan? timeout = null, + int maxOutputTokens = -1) + { + ArgumentException.ThrowIfNullOrWhiteSpace(endpoint); + ArgumentNullException.ThrowIfNull(httpClientFactory); + ArgumentNullException.ThrowIfNull(resilienceProvider); + return new OllamaProvider(endpoint, "", timeout, + httpClientFactory, resilienceProvider, + httpClientName, pipelineName, + mode: OllamaConnectionMode.Local, + maxOutputTokens: maxOutputTokens); + } + + /// + /// Creates a DI-friendly cloud Ollama provider with handler pooling and the cloud retry pipeline. + /// + public static OllamaProvider ForCloud( + string endpoint, + string apiKey, + IHttpClientFactory httpClientFactory, + IDeliberaResiliencePipelineProvider resilienceProvider, + string? httpClientName = null, + string? pipelineName = null, + TimeSpan? timeout = null, + int maxOutputTokens = -1) + { + ArgumentException.ThrowIfNullOrWhiteSpace(endpoint); + ArgumentException.ThrowIfNullOrWhiteSpace(apiKey); + ArgumentNullException.ThrowIfNull(httpClientFactory); + ArgumentNullException.ThrowIfNull(resilienceProvider); + return new OllamaProvider(endpoint, apiKey, timeout, + httpClientFactory, resilienceProvider, + httpClientName, pipelineName, + mode: OllamaConnectionMode.Cloud, + maxOutputTokens: maxOutputTokens); + } /// /// Exposes the underlying OllamaSharp client as a Microsoft.Extensions.AI diff --git a/src/Delibera.Core/Providers/ProviderFactory.cs b/src/Delibera.Core/Providers/ProviderFactory.cs index 091da22..44fce04 100644 --- a/src/Delibera.Core/Providers/ProviderFactory.cs +++ b/src/Delibera.Core/Providers/ProviderFactory.cs @@ -39,9 +39,7 @@ protected IReadOnlyDictionary GetAllInstances() /// Returns a cached instance by name, or null. protected TInstance? GetInstance(string name) { - return _instances.TryGetValue(name, out var p) - ? p - : null; + return _instances.GetValueOrDefault(name); } /// Registers a builder for a new provider type (e.g., "OpenAI", "YandexGPT"). @@ -101,15 +99,16 @@ protected void ClearInstances() public sealed class ProviderFactory : CachingFactory, ILLMProvider>, ILLMProviderFactory { /// Creates a factory with the built-in Ollama provider registered. - public ProviderFactory() - { - RegisterBuilder("Ollama", config => - { - var endpoint = config["Endpoint"] ?? "http://localhost:11434"; - var apiKey = config["ApiKey"] ?? ""; - return new OllamaProvider(endpoint, apiKey); - }); - } + public ProviderFactory() + { + RegisterBuilder("Ollama", config => + { + var endpoint = config["Endpoint"] ?? "http://localhost:11434"; + var apiKey = config["ApiKey"] ?? ""; + var maxOutputTokens = int.TryParse(config["MaxOutputTokens"], out var t) ? t : -1; + return new OllamaProvider(endpoint, apiKey, maxOutputTokens: maxOutputTokens); + }); + } /// ILLMProviderFactory ILLMProviderFactory.RegisterBuilder(string providerType, Func builder) @@ -138,44 +137,59 @@ public IReadOnlyDictionary GetAllProviders() return GetAllInstances(); } - /// - /// Creates an Ollama provider with direct parameters. The connection mode (local vs - /// cloud) is inferred from : non-empty selects cloud, - /// empty selects local. Prefer / - /// for explicit control. - /// - public OllamaProvider CreateOllama(string endpoint, string apiKey = "") - { - var key = $"ollama:{endpoint}:{(string.IsNullOrWhiteSpace(apiKey) ? "local" : "cloud")}"; - if (GetInstance(key) is OllamaProvider existing) return existing; - - var provider = new OllamaProvider(endpoint, apiKey); - CacheInstance(key, provider); - return provider; - } - - /// Creates a provider for a local Ollama server (e.g. http://localhost:11434). - public OllamaProvider CreateLocalOllama(string endpoint, TimeSpan? timeout = null) - { - var key = $"ollama:{endpoint}:local"; - if (GetInstance(key) is OllamaProvider existing) return existing; - - var provider = OllamaProvider.ForLocal(endpoint, timeout); - CacheInstance(key, provider); - return provider; - } - - /// Creates a provider for Ollama Cloud (e.g. https://api.ollama.com). - public OllamaProvider CreateCloudOllama(string endpoint, string apiKey, TimeSpan? timeout = null) - { - ArgumentException.ThrowIfNullOrWhiteSpace(apiKey); - var key = $"ollama:{endpoint}:cloud"; - if (GetInstance(key) is OllamaProvider existing) return existing; - - var provider = OllamaProvider.ForCloud(endpoint, apiKey, timeout); - CacheInstance(key, provider); - return provider; - } + /// + /// Creates an Ollama provider with direct parameters. The connection mode (local vs + /// cloud) is inferred from : non-empty selects cloud, + /// empty selects local. Prefer / + /// for explicit control. + /// + /// Ollama endpoint URL. + /// API key (empty for local server). + /// + /// Per-call output token cap; -1 = infinite generation (default, overrides + /// OllamaSharp's 128-token default which truncates long responses mid-JSON). + /// + public OllamaProvider CreateOllama(string endpoint, string apiKey = "", int maxOutputTokens = -1) + { + var key = $"ollama:{endpoint}:{(string.IsNullOrWhiteSpace(apiKey) ? "local" : "cloud")}:{maxOutputTokens}"; + if (GetInstance(key) is OllamaProvider existing) return existing; + + var provider = new OllamaProvider(endpoint, apiKey, maxOutputTokens: maxOutputTokens); + CacheInstance(key, provider); + return provider; + } + + /// Creates a provider for a local Ollama server (e.g. http://localhost:11434). + /// Ollama endpoint URL. + /// HTTP timeout (null = local default). + /// Per-call output token cap; -1 = infinite generation (default). + public OllamaProvider CreateLocalOllama(string endpoint, TimeSpan? timeout = null, + int maxOutputTokens = -1) + { + var key = $"ollama:{endpoint}:local:{maxOutputTokens}"; + if (GetInstance(key) is OllamaProvider existing) return existing; + + var provider = OllamaProvider.ForLocal(endpoint, timeout, maxOutputTokens); + CacheInstance(key, provider); + return provider; + } + + /// Creates a provider for Ollama Cloud (e.g. https://api.ollama.com). + /// Ollama endpoint URL. + /// Ollama Cloud API key. + /// HTTP timeout (null = cloud default). + /// Per-call output token cap; -1 = infinite generation (default). + public OllamaProvider CreateCloudOllama(string endpoint, string apiKey, TimeSpan? timeout = null, + int maxOutputTokens = -1) + { + ArgumentException.ThrowIfNullOrWhiteSpace(apiKey); + var key = $"ollama:{endpoint}:cloud:{maxOutputTokens}"; + if (GetInstance(key) is OllamaProvider existing) return existing; + + var provider = OllamaProvider.ForCloud(endpoint, apiKey, timeout, maxOutputTokens); + CacheInstance(key, provider); + return provider; + } /// /// Creates (or returns a cached) from any Microsoft.Extensions.AI @@ -211,4 +225,4 @@ protected override void DisposeInstances() { foreach (var p in EnumerateInstances()) p.Dispose(); } -} \ No newline at end of file +} diff --git a/src/README.md b/src/README.md index befa566..560f00f 100644 --- a/src/README.md +++ b/src/README.md @@ -59,6 +59,7 @@ well-reasoned outcomes** rather than single-model guesses. - [Minimal Example](#minimal-example) - [Run](#run) - [Dependency Injection](#-dependency-injection) +- [Pluggable LLM Backends (Microsoft.Extensions.AI)](#-pluggable-llm-backends-microsoftextensionsai) - [Context Compression](#-context-compression) - [RAG Integration](#-rag-integration) - [Debate Strategies](#-debate-strategies) @@ -239,6 +240,77 @@ console app — see [ConsoleApp Examples](#-consoleapp-examples)). --- +## 🔌 Pluggable LLM Backends (Microsoft.Extensions.AI) + +Delibera's `ChatClientLLMProvider` wraps **any** `Microsoft.Extensions.AI.IChatClient` and +exposes it as a standard `ILLMProvider`. Because every modern .NET LLM SDK ships an +`IChatClient`, you can plug **OpenAI, Azure OpenAI, Anthropic, Ollama, GitHub Copilot, +Microsoft Foundry, LM Studio, vLLM, LocalAI, ONNX**, and more into a Delibera council +without writing a bespoke provider for each one. + +```csharp +using Microsoft.Extensions.AI; +using OpenAI; +using Delibera.Core.Providers.LLM; + +var openAi = new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY")!); +IChatClient chatClient = openAi.GetChatClient("gpt-4o-mini").AsIChatClient(); + +var llmProvider = new ChatClientLLMProvider(chatClient, "OpenAI"); + +string response = await llmProvider.ChatAsync( + "gpt-4o-mini", + "You are a helpful assistant.", + "What is the capital of France?", + 0.7f); +``` + +### Supported providers + +| Provider | NuGet package | Notes | +| ------------------------------- | ------------------------------------------------------ | --------------------------------------------------- | +| **OpenAI** | `Microsoft.Extensions.AI.OpenAI` | `OpenAIClient.GetChatClient(...).AsIChatClient()` | +| **Azure OpenAI** | `Azure.AI.OpenAI` + `Microsoft.Extensions.AI.OpenAI` | `AzureOpenAIClient.GetChatClient(deploy).AsIChatClient()` | +| **Microsoft Foundry** | `Microsoft.Extensions.AI.AzureAIInference` | Foundry endpoint + key | +| **Anthropic Claude** | `Microsoft.Extensions.AI.Anthropic` *(community/port)* | Claude models with function tools + streaming | +| **Ollama** (local or Cloud) | `OllamaSharp` *(already a Delibera dependency)* | `OllamaProvider.AsChatClient()` — no extra package | +| **GitHub Copilot** | `Microsoft.Extensions.AI.GitHubCopilot` | GitHub token | +| **LM Studio / vLLM / LocalAI** | `Microsoft.Extensions.AI.OpenAI` | OpenAI-compatible HTTP API (any string key) | +| **ONNX Runtime GenAI** | `Microsoft.ML.GenAI` / `Microsoft.Extensions.AI.ONNX` | Local model path | +| **Custom `IChatClient`** | — | Implement `IChatClient` or derive from `DelegatingChatClient` | + +### Interop bridge (`MicrosoftAIExtensions`) + +| From → To | Helper | +| ---------------------------------------------------------- | ----------------------------------------------------------- | +| `IChatClient` → `ILLMProvider` | `.AsLLMProvider()` / `new ChatClientLLMProvider(...)` | +| `IEmbeddingGenerator>` → `IEmbeddingProvider` | `.AsEmbeddingProvider()` | +| `ILLMProvider` → `IChatClient` | `.AsChatClient()` (returns the inner client when wrapped) | +| Compose middleware (function invocation, logging) | `.WithMiddleware(enableFunctionInvocation, loggerFactory)` | + +```csharp +using Delibera.Core.Extensions; + +// Decorate with Microsoft.Extensions.AI middleware, then adopt as a Delibera provider +var llmProvider = chatClient + .WithMiddleware(enableFunctionInvocation: true) + .AsLLMProvider("OpenAI"); +``` + +### Runnable example + +```bash +cd src/Delibera.ConsoleApp +dotnet run -- --chatclient # ChatClientLLMProvider + Microsoft.Extensions.AI demo +dotnet run -- --msai # Full M.E.AI integration with a council +``` + +📄 **Full provider list, interop details, OpenAI/Azure/Ollama/OpenAI-compatible examples, +streaming, embeddings, DI registration and limitations:** +[docs/ChatClientLLMProvider.md](docs/ChatClientLLMProvider.md) + +--- + ## 🗜️ Context Compression Automatically compress context between deliberation rounds — save **30–70% of tokens** without losing meaning. @@ -406,6 +478,8 @@ dotnet run -- --compression # Context compression demo dotnet run -- --multiprovider # Multi-provider (cloud + local) council dotnet run -- --rag # Qdrant-backed RAG with Knowledge Keeper dotnet run -- --pgvector # pgvector-backed RAG +dotnet run -- --chatclient # ChatClientLLMProvider (Microsoft.Extensions.AI) demo +dotnet run -- --msai # Full M.E.AI integration with a council # Or run the full default demo (reads appsettings.json) dotnet run @@ -477,7 +551,8 @@ docker exec -it psql -U postgres -d council_vectors -c "CREATE EXTEN | Package | Purpose | | ------------------------ | -------------------------------- | -| `OllamaSharp` | Ollama API client | +| `Microsoft.Extensions.AI` | `IChatClient` / `IEmbeddingGenerator` abstractions + middleware | +| `OllamaSharp` | Ollama API client (natively implements `IChatClient`) | | `Qdrant.Client` | Qdrant vector DB gRPC client | | `Npgsql` | PostgreSQL ADO.NET provider | | `Pgvector` | pgvector type support for Npgsql |