From c72e4aa80e4511a62dab0c9e7ab3b797054f0d4e Mon Sep 17 00:00:00 2001 From: Victor Buzin Date: Tue, 16 Jun 2026 10:31:45 +0300 Subject: [PATCH 01/14] feat: add Operator role with MCP integration and upgrade to .NET 10 / C# 15 (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Operator (MCP tools) micro-agent role Introduce a new Operator role — a lightweight micro-agent that connects the council to MCP (Model Context Protocol) servers and exposes their tools (web search, filesystem, Notion, PostgreSQL, debate history, etc.) to debate participants. - Participants delegate tasks at any moment via the [[OPERATOR: task]] marker - Operator uses its own cheaper LLM to plan tool calls, execute MCP tools and interpret results, optionally reusing the council's compression strategy - Wired through all debate strategies (Standard/Critique/Consensus), CouncilExecutor, CouncilBuilder and DI options (Delibera:Operator) - DI-initializable; supports stdio and HTTP MCP transports - Per-round Operator interactions rendered in the final Markdown report - Adds ModelContextProtocol package, console --operator flag, example & docs * feat: upgrade to .NET 10 / C# 15, high-perf optimizations & MCP tools example - Bump LangVersion to preview (C# 15) in both projects - Update Microsoft.SourceLink.GitHub to 10.0.300 (other deps already at latest .NET 10 versions) - Perf: SIMD-vectorized CosineSimilarity (ReadOnlySpan + Vector) - Perf: TokenCounter ReadOnlySpan overload (alloc-free word counting) - Perf: CompressionCache.ComputeKey uses ArrayPool + stackalloc SHA-256 - Add OperatorMcpToolsExample (browser via @playwright/mcp, Marp presentations) + --operator-mcp flag - Fix nullable warning in Program.cs - Add Russian docs: docs/NET10-Upgrade-RU.md * docs: Add English version of .NET 10 upgrade documentation * docs: Update README and QuickStart with Operator role documentation --------- Co-authored-by: techbuzzz Co-authored-by: Delibera Agent --- README-RU.md | 689 ++++++++++++++++++ README.md | 176 ++++- docs/NET10-Upgrade-RU.docx | Bin 0 -> 20175 bytes docs/NET10-Upgrade-RU.md | 301 ++++++++ docs/NET10-Upgrade-RU.pdf | Bin 0 -> 112771 bytes docs/NET10-Upgrade.docx | Bin 0 -> 18738 bytes docs/NET10-Upgrade.md | 306 ++++++++ docs/NET10-Upgrade.pdf | Bin 0 -> 104010 bytes docs/QuickStart-RU.md | 451 ++++++++++++ docs/QuickStart.md | 172 ++++- .../Delibera.ConsoleApp.csproj | 2 +- .../Examples/OperatorExample.cs | 111 +++ .../Examples/OperatorMcpToolsExample.cs | 237 ++++++ src/Delibera.ConsoleApp/Program.cs | 23 +- .../Compression/CompressionCache.cs | 31 +- .../Compression/SemanticCompressor.cs | 36 +- src/Delibera.Core/Compression/TokenCounter.cs | 15 +- src/Delibera.Core/Council/CouncilBuilder.cs | 52 +- src/Delibera.Core/Council/CouncilExecutor.cs | 38 +- src/Delibera.Core/Council/Operator.cs | 354 +++++++++ src/Delibera.Core/Debate/ConsensusDebate.cs | 26 +- src/Delibera.Core/Debate/CritiqueDebate.cs | 26 +- .../Debate/DebateResultBuilder.cs | 4 +- src/Delibera.Core/Debate/DebateScenario.cs | 106 ++- src/Delibera.Core/Debate/StandardDebate.cs | 29 +- src/Delibera.Core/Delibera.Core.csproj | 5 +- .../DependencyInjection/CouncilOptions.cs | 78 ++ .../Interfaces/ICouncilBuilder.cs | 24 + .../Interfaces/ICouncilExecutor.cs | 3 + .../Interfaces/IDebateStrategy.cs | 2 + src/Delibera.Core/Interfaces/IMcpClient.cs | 41 ++ src/Delibera.Core/Interfaces/IOperator.cs | 51 ++ src/Delibera.Core/Models/DebateResult.cs | 22 + src/Delibera.Core/Models/DebateRound.cs | 3 + src/Delibera.Core/Models/OperatorModels.cs | 176 +++++ .../Providers/Mcp/McpClientAdapter.cs | 162 ++++ src/Delibera.Core/README.md | 112 ++- 37 files changed, 3814 insertions(+), 50 deletions(-) create mode 100644 README-RU.md create mode 100644 docs/NET10-Upgrade-RU.docx create mode 100644 docs/NET10-Upgrade-RU.md create mode 100644 docs/NET10-Upgrade-RU.pdf create mode 100644 docs/NET10-Upgrade.docx create mode 100644 docs/NET10-Upgrade.md create mode 100644 docs/NET10-Upgrade.pdf create mode 100644 docs/QuickStart-RU.md create mode 100644 src/Delibera.ConsoleApp/Examples/OperatorExample.cs create mode 100644 src/Delibera.ConsoleApp/Examples/OperatorMcpToolsExample.cs create mode 100644 src/Delibera.Core/Council/Operator.cs create mode 100644 src/Delibera.Core/Interfaces/IMcpClient.cs create mode 100644 src/Delibera.Core/Interfaces/IOperator.cs create mode 100644 src/Delibera.Core/Models/OperatorModels.cs create mode 100644 src/Delibera.Core/Providers/Mcp/McpClientAdapter.cs diff --git a/README-RU.md b/README-RU.md new file mode 100644 index 0000000..d4811dd --- /dev/null +++ b/README-RU.md @@ -0,0 +1,689 @@ +
+ +Delibera + +# Delibera + +### ⚖️ Продуманные решения с помощью ИИ + +**Коллективное принятие решений через структурированное обсуждение ИИ — с RAG, pgvector, Knowledge Keeper, 🛠️ Operator (MCP-инструменты), Chairman, 🔥 сжатием контекста, 💉 Dependency Injection и 📋 журналированием выполнения** + +[![NuGet](https://img.shields.io/nuget/v/Delibera.Core.svg)](https://www.nuget.org/packages/Delibera.Core) +[![License: MIT](https://img.shields.io/badge/License-MIT-10B981.svg)](LICENSE) +[![.NET 10](https://img.shields.io/badge/.NET-10.0-1F2937.svg)](https://dotnet.microsoft.com) +[![C# 15](https://img.shields.io/badge/C%23-15.0--preview-239120.svg)](https://learn.microsoft.com/dotnet/csharp/) + +🇬🇧 [English version (README.md)](README.md) + +
+ +--- + +## 📖 Обзор + +**Delibera** — это фреймворк на C# / .NET 10, который оркеструет **многомодельные обсуждения** +между LLM. Несколько моделей ИИ рассуждают над вопросом в течение структурированных раундов, +критикуют ответы друг друга, а **Chairman** (председатель) взвешивает аргументы, чтобы +синтезировать сбалансированный финальный вердикт — обогащённый **Knowledge Keeper** на базе +**Qdrant** или **PostgreSQL/pgvector** (RAG), с **интеллектуальным сжатием контекста** для +минимизации расхода токенов. + +Название происходит от слова *deliberation* — тщательного взвешивания доказательств и точек зрения +перед принятием решения. Delibera привносит эту дисциплину в ИИ, помогая командам приходить к +**продуманным, хорошо обоснованным результатам**, а не к догадкам одной модели. + +--- + +## ✨ Ключевые возможности + +| Возможность | Описание | +| --------------------------------- | --------------------------------------------------------------------------------- | +| **🏛️ Многомодельные советы** | Оркестрация любого числа LLM-участников по структурированным раундам дебатов | +| **⚖️ Синтез Chairman** | Выделенный модератор открывает, регулирует и синтезирует финальный вердикт | +| **📚 Knowledge Keeper (RAG)** | Семантический поиск по раундам со структурированными ответами и цитированием | +| **🛠️ Operator (MCP-инструменты)** | Микроагент, делегирующий задачи MCP-серверам (веб, файлы, Marp, Notion, …) по запросу в ходе дебатов | +| **🐘 Qdrant + pgvector** | Подключаемые векторные хранилища — отдельная БД или ваш существующий PostgreSQL | +| **🗜️ Сжатие контекста** | 4 стратегии (Semantic, Deduplication, Summarization, Hybrid) экономят 30–70% токенов | +| **💉 Dependency Injection** | Расширение `AddDelibera()` для `IServiceCollection` с полной привязкой опций | +| **📋 Журналирование выполнения** | Модель `ExecutionLog` с `LogLevel` — события Chairman, KK, сжатия и участников | +| **📁 Раздельный вывод файлов** | Экспорт `result.md`, `statistics.md` и `logs.md` по отдельности | +| **🔌 Interface-First** | Чистые абстракции для провайдеров, фабрик, билдеров и исполнителей | +| **🧱 Современный C# 15 (preview)** | Построено на .NET 10 с `LangVersion=preview`, file-scoped namespaces, records, span/SIMD горячие пути | + +--- + +## 📑 Содержание + +- [Быстрый старт](#-быстрый-старт) + - [Требования и модели](#требования-и-модели) + - [Установка](#установка) + - [Минимальный пример](#минимальный-пример) + - [Запуск](#запуск) +- [Dependency Injection](#-dependency-injection) +- [Operator (MCP-инструменты)](#️-operator-mcp-инструменты) +- [Сжатие контекста](#️-сжатие-контекста) +- [Интеграция RAG](#-интеграция-rag) +- [Стратегии дебатов](#️-стратегии-дебатов) +- [Структура выходных файлов](#-структура-выходных-файлов) +- [Примеры ConsoleApp](#-примеры-consoleapp) +- [Установка и сборка](#️-установка-и-сборка) +- [Архитектура](#️-архитектура) +- [Участие в разработке](#-участие-в-разработке) +- [Лицензия](#-лицензия) + +--- + +## 🚀 Быстрый старт + +### Требования и модели + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) (≥ 10.0.301) — проект нацелен на + `net10.0` и собирается с `LangVersion=preview` для включения возможностей **C# 15**. См. + [docs/NET10-Upgrade-RU.md](docs/NET10-Upgrade-RU.md) для полных заметок о миграции. +- Запущенный экземпляр [Ollama](https://ollama.com) — локально (`ollama serve`) или + [Ollama Cloud](https://ollama.com/cloud) (только API-ключ, без установки). +- Минимальный набор моделей, перечисленный ниже. +- **Опционально — для роли Operator:** [Node.js + npx](https://nodejs.org) для запуска MCP-серверов + (например, `@playwright/mcp`, `@marp-team/marp-cli`). См. [Operator (MCP-инструменты)](#️-operator-mcp-инструменты). + +#### 🟢 Минимальный — для небольших дебатов (≈ 2 ГБ всего) + +Подходит для smoke-тестов, слабого железа и быстрых запусков из CLI. + +| Назначение | Модель | Размер | Команда загрузки | +| ------------------- | ------------------- | ------- | ---------------------------------- | +| Участник совета | `llama3.2:1b` | 1.3 ГБ | `ollama pull llama3.2:1b` | +| Участник совета | `qwen2.5:1.5b` | 1.1 ГБ | `ollama pull qwen2.5:1.5b` | +| Эмбеддинги (RAG) | `nomic-embed-text` | 274 МБ | `ollama pull nomic-embed-text` | + +#### 🟡 Стандартный — рекомендуется для большинства случаев (≈ 7 ГБ всего) + +Хорошее качество рассуждений при низкой задержке. **Это набор по умолчанию, используемый по всему README.** + +| Назначение | Модель | Размер | Команда загрузки | +| ------------------- | -------------------- | ------- | ----------------------------------- | +| Участник совета | `llama3.2:3b` | 2.0 ГБ | `ollama pull llama3.2:3b` | +| Участник совета | `qwen2.5:7b` | 4.7 ГБ | `ollama pull qwen2.5:7b` | +| Эмбеддинги (RAG) | `nomic-embed-text` | 274 МБ | `ollama pull nomic-embed-text` | + +#### 🔴 Высокопроизводительный — для продакшн-уровня дебатов (≈ 30+ ГБ) + +Более тяжёлые локальные модели, рекомендуются на GPU с ≥ 24 ГБ VRAM или на Ollama Cloud. + +| Назначение | Модель | Размер | Команда загрузки | +| ------------------- | ---------------------------- | --------- | --------------------------------------------- | +| Участник совета | `llama3.1:8b` | 4.9 ГБ | `ollama pull llama3.1:8b` | +| Участник совета | `qwen2.5:14b` | 9.0 ГБ | `ollama pull qwen2.5:14b` | +| Участник совета | `mistral:7b` | 4.4 ГБ | `ollama pull mistral:7b` | +| Chairman | `qwen2.5:14b` *(или больше)* | 9.0 ГБ | `ollama pull qwen2.5:14b` | +| Эмбеддинги (RAG) | `nomic-embed-text` | 274 МБ | `ollama pull nomic-embed-text` | + +> 💡 **Ollama Cloud** использует те же имена моделей, но не требует места на локальном диске — нужен +> только API-ключ. См. [раздел конфигурации](#-dependency-injection) для его настройки. + +### Установка + +```bash +dotnet add package Delibera.Core +``` + +### Минимальный пример + +```csharp +using Delibera.Core.Council; +using Delibera.Core.Providers; + +using var factory = new ProviderFactory(); +var ollama = factory.CreateOllama("http://localhost:11434"); + +var result = await new CouncilBuilder() + .AddMember("llama3.2:3b", ollama, "Analyst") + .AddMember("qwen2.5:7b", ollama, "Strategist") + .SetChairman(Chairman.CreateStandard("qwen2.5:7b", ollama)) + .WithStandardDebate() + .WithSystemPrompt("You are a software architecture expert.") + .WithUserPrompt("Microservices vs Monolith for a 5-person startup?") + .WithMaxRounds(4) + .SaveResultTo("./deliberation.md") + .Build() + .ExecuteAsync(); + +Console.WriteLine(result.FinalVerdict); +``` + +> 📄 См. [docs/QuickStart-RU.md](docs/QuickStart-RU.md) для пошагового руководства. + +### Запуск + +```bash +dotnet run +``` + +Delibera проведёт структурированные многораундовые дебаты и запишет полную стенограмму и вердикт +председателя в файл `deliberation.md`. + +--- + +## 💉 Dependency Injection + +Зарегистрируйте все сервисы Delibera одной строкой: + +```csharp +using Delibera.Core.DependencyInjection; + +// Вариант A: С привязкой конфигурации (привязывает секцию "Delibera") +services.AddDelibera(configuration, "Delibera"); + +// Вариант B: С делегатом опций +services.AddDelibera(options => +{ + options.Strategy = "Standard"; + options.MaxRounds = 4; + options.Temperature = 0.7f; + options.Compression.Enabled = true; + options.Compression.Strategy = "Hybrid"; + options.Compression.TargetRatio = 0.5; +}); + +// Вариант C: Только значения по умолчанию +services.AddDelibera(); +``` + +Резолвит из DI следующие интерфейсы: + +| Интерфейс | Реализация | Время жизни | +| --------------------- | -------------------- | ----------- | +| `ILLMProviderFactory` | `ProviderFactory` | Singleton | +| `IRagProviderFactory` | `RagProviderFactory` | Singleton | +| `ICompressionFactory` | `CompressionService` | Singleton | +| `ICouncilBuilder` | `CouncilBuilder` | Transient | + +### Конфигурация (`appsettings.json`) + +```json +{ + "Delibera": { + "Strategy": "Standard", + "MaxRounds": 4, + "Temperature": 0.7, + "SystemPrompt": "You are a knowledgeable AI expert participating in a council debate.", + "Providers": { + "DefaultType": "Ollama", + "DefaultEndpoint": "http://localhost:11434", + "ApiKey": "", + "EmbeddingModel": "nomic-embed-text" + }, + "Compression": { + "Enabled": true, + "Strategy": "Hybrid", + "TargetRatio": 0.5, + "EnableCache": true, + "MaxCacheEntries": 256 + }, + "Rag": { + "Enabled": false, + "ProviderType": "Qdrant", + "Host": "localhost", + "Port": 6334, + "CollectionName": "council_knowledge", + "ConnectionString": null + }, + "Output": { + "Directory": "./debate_results", + "SeparateFiles": true, + "FilePrefix": null + } + } +} +``` + +Чтобы использовать **Ollama Cloud**, задайте `Providers:DefaultEndpoint` равным +`https://api.ollama.com` и поместите ключ в `Providers:ApiKey` (или `OllamaCloud:ApiKey` в секции +`DeliberaApp`, используемой консольным приложением — см. [Примеры ConsoleApp](#-примеры-consoleapp)). + +--- + +## 🛠️ Operator (MCP-инструменты) + +**Operator** — это лёгкий микроагент, который соединяет совет с внешним миром через серверы +[**MCP (Model Context Protocol)**](https://modelcontextprotocol.io). Он предоставляет участникам +дебатов любые инструменты, которые дают эти серверы — веб-навигацию, доступ к файловой системе, +генерацию Marp-презентаций, Notion, PostgreSQL и т. д. + +**Как это работает** + +1. Operator подключается к одному или нескольким MCP-серверам и обнаруживает их инструменты при + `InitializeAsync`. +2. Участникам сообщается (в их системном промпте), что умеет Operator, и они могут делегировать + задачу в **любой момент** дебатов, написав маркер в своём сообщении: + + ``` + [[OPERATOR: открой https://modelcontextprotocol.io и кратко перескажи, что такое MCP]] + ``` + +3. Operator интерпретирует запрос своей **собственной (более дешёвой) LLM-моделью**, выбирает и + вызывает нужные MCP-инструменты, интерпретирует результаты и возвращает краткий ответ, который + внедряется в следующий раунд. +4. Если совет использует **сжатие контекста**, Operator может переиспользовать ту же стратегию для + сжатия больших выводов инструментов перед их возвратом в дебаты. Его `DisposeAsync` — + это `ValueTask` (паттерн `IAsyncDisposable` из .NET 10), поэтому MCP-клиенты освобождаются без + аллокации `Task`. + +Все взаимодействия с Operator записываются по раундам и отображаются в финальном Markdown-отчёте в +блоке **🛠️ Operator Interactions**. + +### Настройка MCP-серверов + +```csharp +using Delibera.Core.Models; + +var servers = new[] +{ + // stdio-транспорт — запускает локальный процесс MCP-сервера + McpServerConfig.Stdio( + name: "browser", + command: "npx", + arguments: new[] { "-y", "@playwright/mcp@latest", "--headless" }), + + McpServerConfig.Stdio( + name: "marp", + command: "npx", + arguments: new[] { "-y", "@marp-team/marp-cli", "--server", "./out" }), + + // …или HTTP/SSE-транспорт для удалённого MCP-сервера + // McpServerConfig.Http( + // name: "remote", + // endpoint: "https://my-mcp-host.example.com/mcp", + // additionalHeaders: new Dictionary { ["Authorization"] = "Bearer " }), +}; +``` + +| Сервер | Транспорт | Команда запуска | Что даёт | +| ------------ | --------- | ----------------------------------------------- | ---------------------------------------------------- | +| 🌐 `browser` | stdio | `npx -y @playwright/mcp@latest --headless` | Навигация по сайтам, чтение страниц, клики, скриншоты | +| 🎯 `marp` | stdio | `npx -y @marp-team/marp-cli --server ` | Генерация презентаций (HTML/PDF/PPTX) из Markdown | + +### Быстрое использование (внутри совета) + +```csharp +using Delibera.Core.Council; +using Delibera.Core.Providers.LLM; + +var ollama = new OllamaProvider("http://localhost:11434"); + +var council = new CouncilBuilder() + .AddMember("llama3.2:3b", ollama, "Optimist") + .AddMember("qwen2.5:7b", ollama, "Skeptic") + .SetChairman(Chairman.CreateStandard("qwen2.5:7b", ollama)) + // Operator использует свою более дешёвую модель; reuseCompression разделяет компрессор совета + .WithOperator("llama3.2:3b", ollama, servers, reuseCompression: true) + .WithStandardDebate() + .WithUserPrompt("Research the latest .NET 10 features and prepare a short summary.") + .WithMaxRounds(4) + .Build() + .ExecuteAsync(); +``` + +Предпочитаете создать `Operator` самостоятельно? Передайте готовый экземпляр: + +```csharp +using Delibera.Core.Council; +using Delibera.Core.Interfaces; +using Delibera.Core.Models; +using Delibera.Core.Providers.Mcp; + +var @operator = new Operator( + new CouncilMember("llama3.2:3b", ollama, "Operator"), + new IMcpClient[] { new McpClientAdapter(servers[0]), new McpClientAdapter(servers[1]) }, + compressor: null, // необязательный IContextCompressor + compressionOptions: null); // необязательные CompressionOptions + +var council = new CouncilBuilder() + /* …участники… */ + .WithOperator(@operator) + .Build(); +``` + +### Прямое использование (без совета) + +```csharp +await using var @operator = new Operator( + new CouncilMember("llama3.2:3b", ollama, "Operator"), + new IMcpClient[] { new McpClientAdapter(servers[0]), new McpClientAdapter(servers[1]) }); + +await @operator.InitializeAsync(); + +var result = await @operator.ExecuteTaskAsync( + "Open https://modelcontextprotocol.io and briefly summarize what MCP is."); + +Console.WriteLine(result.FinalAnswer); +``` + +### Dependency Injection + +Настройте Operator декларативно в `appsettings.json` в секции `Delibera:Operator`: + +```json +{ + "Delibera": { + "Operator": { + "Enabled": true, + "ModelName": "llama3.2:3b", + "ReuseCompression": true, + "McpServers": [ + { + "Name": "browser", + "Transport": "Stdio", + "Command": "npx", + "Arguments": [ "-y", "@playwright/mcp@latest", "--headless" ] + }, + { + "Name": "remote", + "Transport": "Http", + "Endpoint": "https://my-mcp-host.example.com/mcp", + "AdditionalHeaders": { "Authorization": "Bearer " } + } + ] + } + } +} +``` + +> ▶️ Полный рабочий пример находится в +> [`OperatorMcpToolsExample.cs`](src/Delibera.ConsoleApp/Examples/OperatorMcpToolsExample.cs). +> Запустите его командой `dotnet run --project src/Delibera.ConsoleApp -- --operator-mcp`. +> Полное техническое описание см. в [docs/NET10-Upgrade-RU.md](docs/NET10-Upgrade-RU.md). + +--- + +## 🗜️ Сжатие контекста + +Автоматически сжимайте контекст между раундами обсуждения — экономьте **30–70% токенов** без потери смысла. + +| Стратегия | Как работает | Лучше всего для | +| ----------------- | -------------------------------------------------------- | -------------------------------- | +| **Semantic** | Эмбеддинг предложений, ранжирование по релевантности, топ-N | Большие контексты знаний | +| **Deduplication** | Удаляет семантически похожие предложения между участниками | Многомодельные дебаты с пересечениями | +| **Summarization** | LLM создаёт краткое резюме, сохраняя ключевые факты | Максимальная степень сжатия | +| **Hybrid** | Конвейер Dedup → Semantic → Summarize | Лучшее общее качество | +| **None** | Без изменений (когда отключено) | Отладка | + +```csharp +using Delibera.Core.Compression; +using Delibera.Core.Providers.LLM; + +var ollama = new OllamaProvider("http://localhost:11434"); +var embeddings = new OllamaEmbeddingProvider(ollama, "nomic-embed-text"); + +var result = await new CouncilBuilder() + .AddMember("llama3.2:3b", ollama, "Analyst") + .AddMember("qwen2.5:7b", ollama, "Strategist") + .SetChairman(Chairman.CreateStandard("qwen2.5:7b", ollama)) + .WithCompression(CompressionStrategy.Hybrid, + llmProvider: ollama, + modelName: "llama3.2:3b", + embeddingProvider: embeddings) + .WithCompressionOptions(new CompressionOptions { TargetRatio = 0.5 }) + .WithCompressionCache() + .WithUserPrompt("Analyze our architecture options...") + .WithMaxRounds(4) + .Build() + .ExecuteAsync(); + +Console.WriteLine(result.TokenStats?.ToSummary()); +``` + +### Конвейер сжатия + +``` +IContextCompressor +├── SemanticCompressor ← Ранжирование предложений на основе эмбеддингов +├── DeduplicationCompressor ← Удаление дубликатов по схожести +├── SummarizationCompressor ← Резюмирование с помощью LLM +├── HybridCompressor ← Многоэтапный конвейер (Dedup → Semantic → Summarize) +└── PassThroughCompressor ← Без операций (когда отключено) + +CompressionFactory ← Статическая фабрика (Create по enum или строке) +CompressionService ← DI-дружественная обёртка над CompressionFactory +CompressionCache ← LRU-кэш с ключами SHA-256 +TokenCounter ← Эвристическая оценка токенов +``` + +--- + +## 📚 Интеграция RAG + +Используйте выделенный экземпляр **Qdrant** или вашу существующую базу **PostgreSQL/pgvector** в +качестве векторного хранилища. + +```csharp +using Delibera.Core.Council; +using Delibera.Core.Models; +using Delibera.Core.Providers.LLM; +using Delibera.Core.Providers.RAG; + +var ollama = new OllamaProvider("http://localhost:11434"); +var embeddings = new OllamaEmbeddingProvider(ollama, "nomic-embed-text"); + +// pgvector — просто добавьте строку подключения +var ragFactory = new RagProviderFactory(); +var rag = ragFactory.CreatePgVector( + embeddings, + "Host=localhost;Database=council_vectors;Username=postgres;Password=postgres"); + +await rag.IndexDocumentAsync("my_collection", documentText); +var results = await rag.SearchAsync("my_collection", "query", limit: 5); + +// Подключаем к Knowledge Keeper +var kkMember = new CouncilMember("llama3.2:3b", ollama, "Knowledge Keeper"); +var keeper = new KnowledgeKeeper(rag, kkMember, "my_knowledge"); +await keeper.IndexFileAsync("./docs/architecture.md"); +``` + +``` +IRagProvider +├── QdrantRagProvider +│ └── QdrantVectorStore ← Qdrant gRPC +└── PgVectorRagProvider + └── PgVectorStore ← PostgreSQL/pgvector + +IEmbeddingProvider +└── OllamaEmbeddingProvider +``` + +Затем Knowledge Keeper присоединяется к совету через `WithKnowledgeKeeper(...)` — см. +[пример RAG](src/Delibera.ConsoleApp/Examples/RagExample.cs) для полного рабочего демо. + +--- + +## 🗣️ Стратегии дебатов + +| Стратегия | Поток | Сценарий использования | +| --------------------- | ------------------------------------------------------- | ----------------------- | +| **StandardDebate** | Initial → Critique → Improved → Verdict | Общий анализ | +| **CritiqueDebate** | Position → Attack → Defence → Judge | Проверка гипотез | +| **ConsensusDebate** | Perspectives → Common Ground → Consensus → Facilitator | Поиск оптимального решения | + +Каждая стратегия реализована как `IDebateStrategy` — см. +[`src/Delibera.Core/Debate/`](src/Delibera.Core/Debate/) для полного исходного кода. + +### Паттерны проектирования + +| Паттерн | Использование | +| ------------------- | ------------------------------------------------------------------------- | +| **Factory** | `ProviderFactory`, `RagProviderFactory`, `CompressionFactory`, `Chairman` | +| **Strategy** | `IDebateStrategy`, `IContextCompressor` | +| **Builder** | Fluent API `CouncilBuilder` | +| **Template Method** | Абстрактный базовый класс `DebateScenario` | +| **Cache** | `CompressionCache` с ключами SHA-256 | +| **Observer** | Событие `OnRoundCompleted` у `CouncilExecutor` | + +--- + +## 📁 Структура выходных файлов + +Каждое обсуждение можно экспортировать как один файл или как три отдельных Markdown-документа: + +```csharp +var result = await executor.ExecuteAsync(); + +// Сохранить в 3 отдельных файла +var (resultPath, statsPath, logsPath) = await result.SaveAllAsync("./output"); +// Создаёт: debate_20260604_120000_result.md +// debate_20260604_120000_statistics.md +// debate_20260604_120000_logs.md + +// Или сохранить по отдельности +await result.SaveToMarkdownAsync("result.md"); +await result.SaveStatisticsAsync("statistics.md"); +await result.SaveLogsAsync("logs.md"); +``` + +| Файл | Содержимое | +| ----------------- | ----------------------------------------------------------------------------- | +| `*_result.md` | Полная стенограмма обсуждения, раунды и финальный вердикт председателя | +| `*_statistics.md` | Статистика использования токенов с разбивкой по раундам | +| `*_logs.md` | Журналы выполнения (`ExecutionLog`) для Chairman, KK, сжатия и участников | + +--- + +## 💻 Примеры ConsoleApp + +В репозитории есть [`Delibera.ConsoleApp`](src/Delibera.ConsoleApp/) — запускаемый демо-проект, +который задействует каждую возможность. Запускайте его из корня репозитория: + +```bash +# Клонировать репозиторий +git clone https://github.com/delibera/Delibera.git +cd Delibera/src/Delibera.ConsoleApp + +# Запустить конкретный пример +dotnet run -- --di # Dependency Injection +dotnet run -- --separate-files # Сохранить result.md, statistics.md, logs.md +dotnet run -- --compression # Демо сжатия контекста +dotnet run -- --multiprovider # Совет с несколькими провайдерами (cloud + local) +dotnet run -- --rag # RAG на базе Qdrant с Knowledge Keeper +dotnet run -- --pgvector # RAG на базе pgvector +dotnet run -- --operator # Основы роли Operator (MCP-инструменты) +dotnet run -- --operator-mcp # 🆕 Operator с MCP-серверами browser + Marp + +# Или запустить полное демо по умолчанию (читает appsettings.json) +dotnet run +``` + +Консольное приложение читает [`appsettings.json`](src/Delibera.ConsoleApp/appsettings.json), который +использует секцию `DeliberaApp` — по умолчанию она указывает на Ollama Cloud и показывает, как +подключить несколько провайдеров, RAG и сжатие в одном месте. + +--- + +## 🛠️ Установка и сборка + +### Клонирование и сборка + +```bash +git clone https://github.com/delibera/Delibera.git +cd Delibera + +# Собрать всё решение +dotnet build --configuration Release + +# Запустить консольное демо +cd src/Delibera.ConsoleApp +dotnet run +``` + +### Инфраструктура одной командой (Docker Compose) + +Поднимите **Qdrant + PostgreSQL/pgvector** за один шаг. (Ollama намеренно не включена — +[установите её нативно](https://ollama.com/download), чтобы GPU-драйвер использовался напрямую.) + +```bash +# Из корня репозитория +docker compose up -d + +# В другом терминале запустите консольное приложение +cd src/Delibera.ConsoleApp +dotnet run +``` + +Compose-стек предоставляет: + +| Сервис | URL | Назначение | +| ------------- | -------------------------- | ----------------------------- | +| Qdrant (REST) | `http://localhost:6333` | UI / REST векторного хранилища | +| Qdrant (gRPC) | `localhost:6334` | gRPC-клиент (используется Delibera) | +| PostgreSQL | `localhost:5432` | RAG-хранилище pgvector | + +Учётные данные по умолчанию: `postgres` / `postgres`, база `council_vectors`. + +> Если эти сервисы уже запущены нативно, просто пропустите `docker compose` и направьте консольное +> приложение на них — `appsettings.json` по умолчанию использует `localhost`. + +### Альтернатива вручную (по одному контейнеру) + +```bash +# Запуск с Qdrant (Docker) +docker run -d -p 6333:6333 -p 6334:6334 qdrant/qdrant + +# Запуск с pgvector (Docker) +docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=postgres pgvector/pgvector:pg16 + +# Затем включите расширение pgvector +docker exec -it psql -U postgres -d council_vectors -c "CREATE EXTENSION IF NOT EXISTS vector;" +``` + +### NuGet-зависимости + +| Пакет | Назначение | +| ------------------------ | -------------------------------- | +| `OllamaSharp` | Клиент API Ollama | +| `Qdrant.Client` | gRPC-клиент векторной БД Qdrant | +| `Npgsql` | ADO.NET-провайдер PostgreSQL | +| `Pgvector` | Поддержка типов pgvector для Npgsql | +| `ModelContextProtocol` | MCP-клиент для роли Operator | +| `Microsoft.Extensions.*` | Конфигурация, DI и Options | + +--- + +## 🏛️ Архитектура + +``` +Delibera.Core +├── Council/ ← CouncilBuilder, CouncilExecutor, Chairman, KnowledgeKeeper, Operator +├── Debate/ ← StandardDebate, CritiqueDebate, ConsensusDebate +├── Compression/ ← Semantic / Deduplication / Summarization / Hybrid +├── Providers/ +│ ├── LLM/ ← OllamaProvider, OllamaEmbeddingProvider +│ ├── RAG/ ← QdrantRagProvider, PgVectorRagProvider +│ └── Mcp/ ← McpClientAdapter (Operator ↔ MCP-серверы) +├── DependencyInjection/ ← AddDelibera() + CouncilOptions +├── Knowledge/ ← MarkdownKnowledgeBase +├── Models/ ← CouncilMember, DebateResult, DebateRound, TokenStatistics, ... +└── Interfaces/ ← ILLMProvider, IRagProvider, IContextCompressor, IOperator, IMcpClient, ... +``` + +--- + +## 🤝 Участие в разработке + +Мы приветствуем вклад! Пожалуйста, прочитайте [CONTRIBUTING.md](CONTRIBUTING.md) для рекомендаций по +настройке окружения разработки, стандартам кодирования и процессу pull-request. + +--- + +## 📄 Лицензия + +Delibera распространяется под [лицензией MIT](LICENSE). + +Copyright © 2026 Delibera Project. + +--- + +
+ +**⚖️ Delibera — Продуманные решения с помощью ИИ** + +*Создано с заботой о коллективном интеллекте на базе ИИ* + +
diff --git a/README.md b/README.md index 5ec7ad6..86caed7 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,14 @@ ### ⚖️ Thoughtful AI Decisions -**Collective decision making through structured AI deliberation — with RAG, pgvector, Knowledge Keeper, Chairman, 🔥 Context Compression, 💉 Dependency Injection & 📋 Execution Logging** +**Collective decision making through structured AI deliberation — with RAG, pgvector, Knowledge Keeper, 🛠️ Operator (MCP tools), Chairman, 🔥 Context Compression, 💉 Dependency Injection & 📋 Execution Logging** [![NuGet](https://img.shields.io/nuget/v/Delibera.Core.svg)](https://www.nuget.org/packages/Delibera.Core) [![License: MIT](https://img.shields.io/badge/License-MIT-10B981.svg)](LICENSE) [![.NET 10](https://img.shields.io/badge/.NET-10.0-1F2937.svg)](https://dotnet.microsoft.com) -[![C# 14](https://img.shields.io/badge/C%23-14.0-239120.svg)](https://learn.microsoft.com/dotnet/csharp/) +[![C# 15](https://img.shields.io/badge/C%23-15.0--preview-239120.svg)](https://learn.microsoft.com/dotnet/csharp/) + +🇷🇺 [Русская версия (README-RU.md)](README-RU.md) @@ -38,13 +40,14 @@ well-reasoned outcomes** rather than single-model guesses. | **🏛️ Multi-Model Councils** | Orchestrate any number of LLM participants across structured debate rounds | | **⚖️ Chairman Synthesis** | A dedicated moderator opens, regulates, and synthesises the final verdict | | **📚 Knowledge Keeper (RAG)** | Per-round semantic retrieval with structured, cited responses | +| **🛠️ Operator (MCP Tools)** | A micro-agent that delegates tasks to MCP servers (web, files, Marp, Notion, …) on demand during the debate | | **🐘 Qdrant + pgvector** | Pluggable vector stores — use a dedicated DB or your existing PostgreSQL | | **🗜️ Context Compression** | 4 strategies (Semantic, Deduplication, Summarization, Hybrid) save 30–70% of tokens | | **💉 Dependency Injection** | `AddDelibera()` extension for `IServiceCollection` with full options binding | | **📋 Execution Logging** | `ExecutionLog` model with `LogLevel` — Chairman, KK, Compression & participant events | | **📁 Separate File Output** | Export `result.md`, `statistics.md`, and `logs.md` independently | | **🔌 Interface-First** | Clean abstractions for providers, factories, builders and executors | -| **🧱 Modern C# 12** | File-scoped namespaces, records, init-only properties, global usings | +| **🧱 Modern C# 15 (preview)** | Built on .NET 10 with `LangVersion=preview`, file-scoped namespaces, records, span/SIMD hot paths | --- @@ -56,6 +59,7 @@ well-reasoned outcomes** rather than single-model guesses. - [Minimal Example](#minimal-example) - [Run](#run) - [Dependency Injection](#-dependency-injection) +- [Operator (MCP Tools)](#️-operator-mcp-tools) - [Context Compression](#-context-compression) - [RAG Integration](#-rag-integration) - [Debate Strategies](#-debate-strategies) @@ -72,10 +76,14 @@ well-reasoned outcomes** rather than single-model guesses. ### Prerequisites & Models -- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) (≥ 10.0.301) — the project targets + `net10.0` and builds with `LangVersion=preview` to enable **C# 15** features. See + [docs/NET10-Upgrade.md](docs/NET10-Upgrade.md) for the full migration notes. - A running [Ollama](https://ollama.com) instance — local (`ollama serve`) or [Ollama Cloud](https://ollama.com/cloud) (just an API key, no install). - The minimal set of models listed below. +- **Optional — for the Operator role:** [Node.js + npx](https://nodejs.org) to launch MCP servers + (e.g. `@playwright/mcp`, `@marp-team/marp-cli`). See [Operator (MCP Tools)](#️-operator-mcp-tools). #### 🟢 Minimal — for a tiny debate (≈ 2 GB total) @@ -234,6 +242,156 @@ console app — see [ConsoleApp Examples](#-consoleapp-examples)). --- +## 🛠️ Operator (MCP Tools) + +The **Operator** is a lightweight micro-agent that connects the council to the outside world through +[**MCP (Model Context Protocol)**](https://modelcontextprotocol.io) servers. It exposes whatever tools +those servers provide — web browsing, file system access, Marp presentation generation, Notion, +PostgreSQL, etc. — to the debate participants. + +**How it works** + +1. The Operator connects to one or more MCP servers and discovers their tools on `InitializeAsync`. +2. Participants are told (in their system prompt) what the Operator can do, and can delegate a task at + **any moment** during the debate by writing a marker in their message: + + ``` + [[OPERATOR: open https://modelcontextprotocol.io and summarize what MCP is]] + ``` + +3. The Operator interprets the request with its **own (cheaper) LLM model**, picks and calls the right + MCP tools, interprets the results, and returns a concise answer that is injected into the next round. +4. If the council uses **context compression**, the Operator can reuse the same strategy to compress + large tool outputs before they re-enter the debate. Its `DisposeAsync` is a `ValueTask` (.NET 10 + `IAsyncDisposable`) so MCP clients are released without a `Task` allocation. + +All Operator interactions are recorded per round and rendered in the final Markdown report under a +**🛠️ Operator Interactions** block. + +### Configuring MCP servers + +```csharp +using Delibera.Core.Models; + +var servers = new[] +{ + // stdio transport — launches a local MCP server process + McpServerConfig.Stdio( + name: "browser", + command: "npx", + arguments: new[] { "-y", "@playwright/mcp@latest", "--headless" }), + + McpServerConfig.Stdio( + name: "marp", + command: "npx", + arguments: new[] { "-y", "@marp-team/marp-cli", "--server", "./out" }), + + // …or an HTTP/SSE transport for a remote MCP server + // McpServerConfig.Http( + // name: "remote", + // endpoint: "https://my-mcp-host.example.com/mcp", + // additionalHeaders: new Dictionary { ["Authorization"] = "Bearer " }), +}; +``` + +| Server | Transport | Launch command | What it provides | +| ------------ | --------- | ----------------------------------------------- | ---------------------------------------------------- | +| 🌐 `browser` | stdio | `npx -y @playwright/mcp@latest --headless` | Site navigation, page reading, clicks, screenshots | +| 🎯 `marp` | stdio | `npx -y @marp-team/marp-cli --server ` | Generating presentations (HTML/PDF/PPTX) from Markdown | + +### Quick usage (inside a council) + +```csharp +using Delibera.Core.Council; +using Delibera.Core.Providers.LLM; + +var ollama = new OllamaProvider("http://localhost:11434"); + +var council = new CouncilBuilder() + .AddMember("llama3.2:3b", ollama, "Optimist") + .AddMember("qwen2.5:7b", ollama, "Skeptic") + .SetChairman(Chairman.CreateStandard("qwen2.5:7b", ollama)) + // Operator uses its own cheaper model; reuseCompression shares the council's compressor + .WithOperator("llama3.2:3b", ollama, servers, reuseCompression: true) + .WithStandardDebate() + .WithUserPrompt("Research the latest .NET 10 features and prepare a short summary.") + .WithMaxRounds(4) + .Build() + .ExecuteAsync(); +``` + +Prefer to build the `Operator` yourself? Pass a pre-built instance: + +```csharp +using Delibera.Core.Council; +using Delibera.Core.Interfaces; +using Delibera.Core.Models; +using Delibera.Core.Providers.Mcp; + +var @operator = new Operator( + new CouncilMember("llama3.2:3b", ollama, "Operator"), + new IMcpClient[] { new McpClientAdapter(servers[0]), new McpClientAdapter(servers[1]) }, + compressor: null, // optional IContextCompressor + compressionOptions: null); // optional CompressionOptions + +var council = new CouncilBuilder() + /* …members… */ + .WithOperator(@operator) + .Build(); +``` + +### Direct usage (without a council) + +```csharp +await using var @operator = new Operator( + new CouncilMember("llama3.2:3b", ollama, "Operator"), + new IMcpClient[] { new McpClientAdapter(servers[0]), new McpClientAdapter(servers[1]) }); + +await @operator.InitializeAsync(); + +var result = await @operator.ExecuteTaskAsync( + "Open https://modelcontextprotocol.io and briefly summarize what MCP is."); + +Console.WriteLine(result.FinalAnswer); +``` + +### Dependency Injection + +Configure the Operator declaratively in `appsettings.json` under `Delibera:Operator`: + +```json +{ + "Delibera": { + "Operator": { + "Enabled": true, + "ModelName": "llama3.2:3b", + "ReuseCompression": true, + "McpServers": [ + { + "Name": "browser", + "Transport": "Stdio", + "Command": "npx", + "Arguments": [ "-y", "@playwright/mcp@latest", "--headless" ] + }, + { + "Name": "remote", + "Transport": "Http", + "Endpoint": "https://my-mcp-host.example.com/mcp", + "AdditionalHeaders": { "Authorization": "Bearer " } + } + ] + } + } +} +``` + +> ▶️ A complete, runnable demo lives in +> [`OperatorMcpToolsExample.cs`](src/Delibera.ConsoleApp/Examples/OperatorMcpToolsExample.cs). +> Run it with `dotnet run --project src/Delibera.ConsoleApp -- --operator-mcp`. +> For the full technical write-up, see [docs/NET10-Upgrade.md](docs/NET10-Upgrade.md). + +--- + ## 🗜️ Context Compression Automatically compress context between deliberation rounds — save **30–70% of tokens** without losing meaning. @@ -401,6 +559,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 -- --operator # Operator role basics (MCP tools) +dotnet run -- --operator-mcp # 🆕 Operator with browser + Marp MCP servers # Or run the full default demo (reads appsettings.json) dotnet run @@ -476,6 +636,7 @@ docker exec -it psql -U postgres -d council_vectors -c "CREATE EXTEN | `Qdrant.Client` | Qdrant vector DB gRPC client | | `Npgsql` | PostgreSQL ADO.NET provider | | `Pgvector` | pgvector type support for Npgsql | +| `ModelContextProtocol` | MCP client for the Operator role | | `Microsoft.Extensions.*` | Configuration, DI and Options | --- @@ -484,16 +645,17 @@ docker exec -it psql -U postgres -d council_vectors -c "CREATE EXTEN ``` Delibera.Core -├── Council/ ← CouncilBuilder, CouncilExecutor, Chairman, KnowledgeKeeper +├── Council/ ← CouncilBuilder, CouncilExecutor, Chairman, KnowledgeKeeper, Operator ├── Debate/ ← StandardDebate, CritiqueDebate, ConsensusDebate ├── Compression/ ← Semantic / Deduplication / Summarization / Hybrid ├── Providers/ │ ├── LLM/ ← OllamaProvider, OllamaEmbeddingProvider -│ └── RAG/ ← QdrantRagProvider, PgVectorRagProvider +│ ├── RAG/ ← QdrantRagProvider, PgVectorRagProvider +│ └── Mcp/ ← McpClientAdapter (Operator ↔ MCP servers) ├── DependencyInjection/ ← AddDelibera() + CouncilOptions ├── Knowledge/ ← MarkdownKnowledgeBase ├── Models/ ← CouncilMember, DebateResult, DebateRound, TokenStatistics, ... -└── Interfaces/ ← ILLMProvider, IRagProvider, IContextCompressor, ... +└── Interfaces/ ← ILLMProvider, IRagProvider, IContextCompressor, IOperator, IMcpClient, ... ``` --- diff --git a/docs/NET10-Upgrade-RU.docx b/docs/NET10-Upgrade-RU.docx new file mode 100644 index 0000000000000000000000000000000000000000..6daba5ddac0f7c453d4fbdeb5e0637953e021937 GIT binary patch literal 20175 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 literal 0 HcmV?d00001 diff --git a/docs/NET10-Upgrade-RU.md b/docs/NET10-Upgrade-RU.md new file mode 100644 index 0000000..aaa2a59 --- /dev/null +++ b/docs/NET10-Upgrade-RU.md @@ -0,0 +1,301 @@ +# Delibera — обновление до .NET 10 и C# 15 + +Документ описывает изменения, внесённые при переводе проекта **Delibera** на платформу +**.NET 10** и язык **C# 15 (preview)**, оптимизации производительности и новые примеры работы +роли **Operator** с MCP-инструментами (браузер и генерация Marp-презентаций). + +> Все изменения выполнены в ветке `feature/operator-mcp-role`. Проект собирается командой +> `dotnet build` без предупреждений и ошибок. + +--- + +## Содержание + +1. [Целевая платформа и версия языка](#1-целевая-платформа-и-версия-языка) +2. [Обновление зависимостей](#2-обновление-зависимостей) +3. [Оптимизации производительности (high performance)](#3-оптимизации-производительности-high-performance) +4. [Примеры Operator + MCP-инструменты](#4-примеры-operator--mcp-инструменты) +5. [Сборка и запуск](#5-сборка-и-запуск) +6. [Краткая сводка изменённых файлов](#6-краткая-сводка-изменённых-файлов) + +--- + +## 1. Целевая платформа и версия языка + +Оба проекта переведены на единый стек: + +| Свойство | Было | Стало | +|---------------------|-------------|----------------| +| `TargetFramework` | `net10.0` | `net10.0` | +| `LangVersion` | `14.0` | `preview` *(C# 15)* | +| `Nullable` | `enable` | `enable` | +| `ImplicitUsings` | `enable` | `enable` | + +Изменения внесены в файлы: + +- `src/Delibera.Core/Delibera.Core.csproj` +- `src/Delibera.ConsoleApp/Delibera.ConsoleApp.csproj` + +### ⚠️ Важно про «C# 15» + +На момент обновления установлен GA-релиз **.NET SDK 10.0.301**, в котором компилятор Roslyn +поддерживает версии языка до **14.0** включительно, а следующая (будущая C# 15) доступна +**только** через значение `preview`. Прямое указание `15.0` приводит +к ошибке компиляции: + +``` +error CS1617: Invalid option '15.0' for /langversion +``` + +Поэтому используется `preview` — это включает максимально доступный +набор языковых возможностей следующей версии (C# 15). Когда выйдет SDK с официальной поддержкой +`15.0`, значение можно будет заменить на `15.0` без других изменений в коде. + +--- + +## 2. Обновление зависимостей + +Все NuGet-пакеты приведены к версиям, совместимым с .NET 10. Большинство пакетов уже находились +на актуальных версиях для .NET 10; обновлён `Microsoft.SourceLink.GitHub`. + +### Delibera.Core + +| Пакет | Версия | Назначение | +|--------------------------------------------------------|-----------|---------------------------------------| +| `Microsoft.Extensions.Configuration.Abstractions` | 10.0.9 | Конфигурация (DI) | +| `Microsoft.Extensions.Configuration.Json` | 10.0.9 | Чтение `appsettings.json` | +| `Microsoft.Extensions.DependencyInjection` | 10.0.9 | Контейнер DI | +| `Microsoft.Extensions.DependencyInjection.Abstractions`| 10.0.9 | Абстракции DI | +| `Microsoft.Extensions.Options` | 10.0.9 | Паттерн Options | +| `Microsoft.Extensions.Options.ConfigurationExtensions` | 10.0.9 | Привязка Options к конфигурации | +| `ModelContextProtocol` | 1.4.0 | SDK для MCP-серверов (роль Operator) | +| `Npgsql` | 10.0.3 | PostgreSQL / pgvector | +| `OllamaSharp` | 5.4.25 | LLM-провайдер Ollama | +| `Pgvector` | 0.3.2 | Векторный поиск в PostgreSQL | +| `Qdrant.Client` | 1.18.1 | Векторное хранилище Qdrant | +| `Microsoft.SourceLink.GitHub` | **10.0.300** *(было 10.0.102)* | Source Link для отладки | + +### Delibera.ConsoleApp + +| Пакет | Версия | Назначение | +|------------------------------------------------------|--------|-----------------------------| +| `Microsoft.Extensions.Configuration` | 10.0.9 | Конфигурация | +| `Microsoft.Extensions.Configuration.Binder` | 10.0.9 | Привязка конфигурации | +| `Microsoft.Extensions.Configuration.Json` | 10.0.9 | JSON-конфигурация | +| `Microsoft.Extensions.Configuration.UserSecrets` | 10.0.9 | User Secrets | +| `Microsoft.Extensions.DependencyInjection` | 10.0.9 | Контейнер DI | + +> Проверка актуальности выполнена через `dotnet list package --outdated` и NuGet flat-container +> API — на момент обновления более свежих стабильных версий для перечисленных пакетов нет. + +--- + +## 3. Оптимизации производительности (high performance) + +Внесены целенаправленные оптимизации «горячих» путей с применением `Span`/`ReadOnlySpan`, +SIMD (`System.Numerics.Vector`) и `ArrayPool`. Все оптимизации сохраняют прежнее поведение +(проверено численно). + +### 3.1. SIMD-векторизация косинусной близости + +**Файл:** `src/Delibera.Core/Compression/SemanticCompressor.cs` → `CosineSimilarity` + +Косинусная близость — самый «горячий» цикл при семантическом сжатии и дедупликации (вызывается +для каждой пары предложений по всем измерениям эмбеддинга, обычно 768–1536). Реализация переписана: + +- Сигнатура изменена с `float[]` на `ReadOnlySpan` — это zero-copy и позволяет передавать + массивы, срезы и `stackalloc`-буферы без аллокаций (вызывающий код с `float[]` работает без изменений). +- Основной цикл векторизован через `Vector`: за одну итерацию обрабатывается + `Vector.Count` элементов (на современных x64/ARM64 — 8–16 значений за такт). +- Скалярный «хвост» обрабатывает остаток, а также служит запасным путём при отсутствии аппаратного + ускорения (`Vector.IsHardwareAccelerated == false`). + +```csharp +internal static double CosineSimilarity(ReadOnlySpan a, ReadOnlySpan b) +{ + if (a.Length != b.Length || a.IsEmpty) return 0; + + float dot = 0, magA = 0, magB = 0; + var i = 0; + + if (Vector.IsHardwareAccelerated && a.Length >= Vector.Count) + { + var dotAcc = Vector.Zero; + var magAAcc = Vector.Zero; + var magBAcc = Vector.Zero; + var width = Vector.Count; + + for (; i <= a.Length - width; i += width) + { + var va = new Vector(a.Slice(i, width)); + var vb = new Vector(b.Slice(i, width)); + dotAcc += va * vb; + magAAcc += va * va; + magBAcc += vb * vb; + } + + dot = Vector.Dot(dotAcc, Vector.One); + magA = Vector.Dot(magAAcc, Vector.One); + magB = Vector.Dot(magBAcc, Vector.One); + } + + for (; i < a.Length; i++) { dot += a[i]*b[i]; magA += a[i]*a[i]; magB += b[i]*b[i]; } + + var denom = Math.Sqrt(magA) * Math.Sqrt(magB); + return denom > 0 ? dot / denom : 0; +} +``` + +**Корректность.** Численное сравнение с эталонной (скалярной) реализацией на длинах +`1, 3, 7, 8, 16, 17, 100, 1536` дало максимальную абсолютную погрешность ≈ `6·10⁻⁸` +(на уровне точности `float`), результат идентичен. + +### 3.2. Подсчёт токенов без аллокаций (`ReadOnlySpan`) + +**Файл:** `src/Delibera.Core/Compression/TokenCounter.cs` + +- Добавлена перегрузка `EstimateTokens(ReadOnlySpan text)` — позволяет оценивать токены + для срезов строк без выделения подстрок. +- Внутренний `CountWords` принимает `ReadOnlySpan` и перебирает символы по `ref struct` + enumerator'у — без боксинга и без копий. +- Существующий `EstimateTokens(string?)` сохранён и теперь делегирует в span-перегрузку + (через `text.AsSpan()`), поэтому публичный API полностью обратно совместим. + +### 3.3. Хеширование ключа кэша через `ArrayPool` + `stackalloc` + +**Файл:** `src/Delibera.Core/Compression/CompressionCache.cs` → `ComputeKey` + +Раньше построение ключа кэша создавало две лишние кучи-аллокации на каждый вызов: +интерполированную строку `"{strategy}:{text}"` и массив байтов из `Encoding.UTF8.GetBytes`. +Новая реализация: + +- Арендует буфер символов из `ArrayPool.Shared`, склеивает в него `strategy + ':' + text`. +- Арендует буфер байтов из `ArrayPool.Shared` и кодирует UTF-8 напрямую в него + (`Encoding.UTF8.GetBytes(span, span)`). +- Считает SHA-256 в стек-буфер `stackalloc byte[SHA256.HashSizeInBytes]` и форматирует hex. +- Гарантированно возвращает арендованные буферы в `finally`. + +Итог: на «тёплом» пути кэширования сжатия исключены промежуточные аллокации больших строк/массивов, +что снижает нагрузку на GC при больших объёмах контекста дебатов. + +### 3.4. `ValueTask` в роли Operator + +Метод освобождения ресурсов Operator реализован как `public async ValueTask DisposeAsync()` +(`src/Delibera.Core/Council/Operator.cs`) — стандартный для .NET 10 паттерн `IAsyncDisposable`, +позволяющий избежать аллокации `Task` на пути освобождения MCP-клиентов. Новый пример +(`OperatorMcpToolsExample`) использует `await using`, корректно задействуя этот путь. + +### Сводка оптимизаций + +| Приём | Где применён | Эффект | +|---------------------|------------------------------------------------|---------------------------------------------| +| `ReadOnlySpan` + SIMD | `SemanticCompressor.CosineSimilarity` | 8–16× ширина обработки, без копий | +| `ReadOnlySpan`| `TokenCounter` (подсчёт слов/токенов) | Нет аллокаций подстрок | +| `ArrayPool` + `stackalloc` | `CompressionCache.ComputeKey` | −2 кучи-аллокации на вызов, меньше нагрузки на GC | +| `ValueTask` | `Operator.DisposeAsync` | Освобождение без аллокации `Task` | + +--- + +## 4. Примеры Operator + MCP-инструменты + +Добавлен новый пример **`OperatorMcpToolsExample`** +(`src/Delibera.ConsoleApp/Examples/OperatorMcpToolsExample.cs`), демонстрирующий работу роли +**Operator** с реальными MCP-серверами. + +### Подключаемые MCP-серверы + +| Сервер | Транспорт | Команда запуска | Что даёт | +|------------|-----------|------------------------------------------------------------|-----------------------------------------------------| +| 🌐 `browser` | stdio | `npx -y @playwright/mcp@latest --headless` | Навигация по сайтам, чтение страниц, клики, скриншоты | +| 🎯 `marp` | stdio | `npx -y @marp-team/marp-cli --server ` | Генерация презентаций (HTML/PDF/PPTX) из Markdown | + +> Также в примере показан (закомментирован) HTTP/SSE-вариант через `McpServerConfig.Http(...)` +> для удалённых MCP-серверов с заголовками авторизации. + +### Что демонстрирует пример + +**Раздел A — прямой вызов Operator** (без совета): + +- Ручное создание MCP-клиентов (`McpClientAdapter`) и объекта `Operator`. +- `await using` для корректного освобождения через `ValueTask DisposeAsync`. +- `InitializeAsync()` — подключение к серверам и обнаружение инструментов. +- Две задачи через `ExecuteTaskAsync`: + 1. **Браузер:** открыть `https://modelcontextprotocol.io` и кратко пересказать, что такое MCP. + 2. **Marp:** сгенерировать презентацию из 3 слайдов и сохранить как `deck.html`. + +**Раздел B — Operator внутри совета** (делегирование маркером `[[OPERATOR: ...]]`): + +- Построение совета через `CouncilBuilder` с участниками, председателем (`Chairman`) и Operator'ом + (удобная перегрузка `WithOperator(modelName, provider, servers)`). +- Подписка на `OnRoundCompleted` для вывода активности Operator по раундам. +- Сохранение Markdown-отчёта дебатов и готовых презентаций. + +### Запуск примера + +```bash +# из каталога проекта +dotnet run --project src/Delibera.ConsoleApp -- --operator-mcp +``` + +#### Предварительные требования + +- **Node.js + npx** — нужны для запуска npx-MCP-серверов (`@playwright/mcp`, `@marp-team/marp-cli`). +- Для браузера при первом запуске Playwright скачает движки: `npx playwright install`. +- **Ollama** запущен локально (`ollama serve`) с моделями `llama3.2`, `qwen2.5` + (модель Operator можно заменить на любую «дешёвую»). + +> Пример устойчив к отсутствию окружения: при ошибке выводятся понятные подсказки на русском, +> процесс не падает аварийно. + +--- + +## 5. Сборка и запуск + +```bash +# Сборка обоих проектов +dotnet build + +# Сборка в Release +dotnet build -c Release + +# Запуск консольного приложения +dotnet run --project src/Delibera.ConsoleApp + +# Доступные демонстрации (флаги): +dotnet run --project src/Delibera.ConsoleApp -- --operator # базовый пример Operator +dotnet run --project src/Delibera.ConsoleApp -- --operator-mcp # 🆕 браузер + Marp +dotnet run --project src/Delibera.ConsoleApp -- --compression # сжатие контекста +dotnet run --project src/Delibera.ConsoleApp -- --rag # RAG +dotnet run --project src/Delibera.ConsoleApp -- --di # Dependency Injection +``` + +Результат сборки: + +``` +Build succeeded. + 0 Warning(s) + 0 Error(s) +``` + +> **Примечание о localhost.** В примерах используется адрес `http://localhost:11434` (Ollama) и +> локальные пути для вывода презентаций. Это localhost той машины, где запускается приложение +> (среда агента Abacus AI), а не вашего компьютера. Чтобы запустить локально, скачайте файлы через +> иконку «Files», перейдите в скачанную папку, разверните приложение на своей системе и запустите его. + +--- + +## 6. Краткая сводка изменённых файлов + +| Файл | Изменение | +|-----------------------------------------------------------------|--------------------------------------------------------------| +| `src/Delibera.Core/Delibera.Core.csproj` | `LangVersion=preview`, `SourceLink → 10.0.300` | +| `src/Delibera.ConsoleApp/Delibera.ConsoleApp.csproj` | `LangVersion=preview` | +| `src/Delibera.Core/Compression/SemanticCompressor.cs` | SIMD-векторизация `CosineSimilarity` + `ReadOnlySpan` | +| `src/Delibera.Core/Compression/TokenCounter.cs` | Перегрузка `ReadOnlySpan`, span-подсчёт слов | +| `src/Delibera.Core/Compression/CompressionCache.cs` | `ArrayPool` + `stackalloc` в `ComputeKey` | +| `src/Delibera.ConsoleApp/Examples/OperatorMcpToolsExample.cs` | 🆕 Пример Operator с браузером и Marp | +| `src/Delibera.ConsoleApp/Program.cs` | Флаг `--operator-mcp`, исправлено nullable-предупреждение | + +--- + +*Документ подготовлен в рамках перевода Delibera на .NET 10 / C# 15.* diff --git a/docs/NET10-Upgrade-RU.pdf b/docs/NET10-Upgrade-RU.pdf new file mode 100644 index 0000000000000000000000000000000000000000..26f7eec79bf3337024c6a60f368f788f499f7af3 GIT binary patch literal 112771 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

GxNp>Uitq1L~|A}WFO!K z=ol}g4cPcxEKXIQx5*0W|B5wAX{CzZ5r&J=ld8Pri)V`-z7u^byWIG(P*|)BMF0Iq z_h!bk%5Q7|=vv@04D?px@A`BYuYPY1>x(tOOUMVN^?ipmwXjkH{o<)c68mXPy|l@*2~LYP~(ARk`^N7=J@$I$Q8tV0(jKhg|$7eVz4 zKs4P?Hz}P~exw=2;Q#P3bFKiq{9`4NGCz+gM18+f|3f^ za&)UGbNJNGrRz=IWO-~$A2v24bSO@D(9W~>eX2|%y*KNKz}{%*QkuW(%}$UR?n9+K z*3BEOe^n*{tj8i=_EEVmi&M@%I`l4R! z9IcsCFf#`P`st8dXCuJqJu^fX8_fkgMSj;ju-w= zs(%OUo$fQ(f4r?PN;10J_Q}AeC6|87Qp`Txa`?%CJIcvXt{Jw{Y0p#gC7?liuP}>k zgLAv^C_7S+usi84t!@NX?F;>kp{!BNDF09iukSVXfmCN5ENfxY<0l9#Va)U&-|x>P z_g&LxwCp&?TJqK8mIoUJiv7f$|7>7G=l|kQu8i^W-fSVSv2*WRld;OVs{;oKkPdXF zV=Ph~mWP?mwJkNRR6Bsd945rdYJ-LPGy6@|hjtohB^aGQ=}*2j`AxyEAdIECn#y*X zu!7DE_+<2rDyRq`+yEF;0J{~5y)ujFTp?_J`vTYm_-+Kj7k1Km&`md%vticIF>JFt z&O4)u#*MVcf#sk-i+hv0lT*%{sBY)ACZ$7^Dbz||`unwZNSmR(ofvfMi!oX65H1Qc zATq@k#A-3EK(;$6>)S!Fa7240&m$|=N5x{!SVS=y(AEjlUeRO+VjbpF*m?Lik4yO% zYgye$+gF|fu($~EkAZ&j&+HjtfTDG zPE2TNp3tB|#dnjt7mc5Bezi6oS0!)BOV$nss*Y`yuh`_UiBT|zIJ|Rz;k<}^TwOe= zne92RcjpOpNhj0Z(07zfqVeSlA{ihzWT7Pc z@8H|F{BeUVf$4Q1hfd4;9?IcV4u6bibErn$HF3`+h5dkO+0*oVCv)4=)PJsLa~%A! zaxVCaejm59={6ejE#XkaF1nUUI)M0cUixVHhV}^MsSDw){+S3HJGEsa=gcBG#Ib|=RG()WCRy?&QgXwA=9rU8-&2Z9!oeL&0|c2D2H zT0gM1P3^@7*KTmYw|Seqq->Syk4q!MtcE)`_vC=z?zCyo{}gF)&78rW&XUYs4ke-E z*JVU*Wi&*PVmzPU$3^5;&aq+N(n(36vtei7w)*OFZ$gdwK@vuvyg~bFGx)lBw~fTr zqD)8Z^I*o9X~p;9r^)zToZZCj)6kXyH_dUhY3_-*vE`SZ+W~udSy#D>SQX{M)t8Q- zO~oximlT0RlX+#yZ?ymLnmbg>naqQDs5ocgdq<)>$TYS!+y-IZ?I7 z!*#14=tTC)z{l=b?W{DKvA`RQ{sRhw1(_?4LhmXlC8$iIJb>_!Ktct87^;CffDEb} zfO0W{*2H4@q9qgtUm{}f$?sk>C=49i1rhCrlkMAB9`76;(1KDH+o+H@(PWbJEaL>r zYZc;GU7PE3TiJKcnif|wJM%qA&5X3+H2{-G8I0OP5Gqxu*$e$8mZ1nx7V7K+5gjZs zfpT-}%B23_FiPF9R`-aqLLtGopJbFJ)L)ulvUwU^4F}ubnalXlV3m}c7!6UV%zY7D zsLE6!$@yu}kg6GO5vWvZweywgilP>1b%rT*h7hV$b4d1u#sx7@l&TLzs$q>V8IyQR z+|pmT-=njpW3$LYu5!$j34HC$b>jmhI>WnHh>3D`M)faHYwwVt3_NQNGMek|6KgpG zpfinWT{nK0+q}FktXYimBwb$q)h#R7ip`INi@^%|>mbAzcHS-Sj{{5m-(M@wfpJmb z`V$P=`0PDi6H9WAP3+pyo~_WWwhPx;)UBSaG}>@Gnffm-gT}AB8xUOYthTKzat413pdJ7HW*j88ITT{(`Ilwc|MZ`tj6|{BR>!ssw zXU9m}n{pe2d{3hCjYfmVhVqSTonOwaU$rP8fYLPdQYZxj6M2b_wfn|VesHnJ+ySU* zc8M{w%%_KYeSggxqN2z&@Nc?h3#WCNF$fpp;)rOKx3$Bp7ISL=T^%S^Ag@LX;-HV( zb~Qj+g%M(a-Qb{&D8|8sVn5sig>%r)2R79V@=Zvd z=}pJWKZ09j8+Y~@f&S>4s2kL-#B}X7p-X@N+V6np@6ODu?1LlTjc~QAeY#CAb2wx; zejv2$wu+f$&>7-c-h2`ufL+ZKZ;hGj4h5w(gUEQSo$#@nMf%Vcw)|b6o8Wy5x691N zR|AbmQ-Sf^zmbZVFXu=HdSX!W(wYJRw#?>TGU#XaUc4x+ZfA~%at$kk52Grqdg@j{ zmzAm$2O5o75YEjLS9=WEj9Cwu&r`s|2b}({lTy6>yIuZuV`ZPl{(SYV>PObo}|Fo={S z0vuoLfHzxe7p!eR$`3lC7y!y%IkaNX{`8It zt(QTwQn{(F*zmmy=DHx-32$S99tPIyxab&X(G2kO!7C@}RSJ>bJ2B1uiIf|knFoZ# zgh>s+pERk#MbnvmB;{P#8DW|1uh)t&^XMM@z%=+CMd4XTb_w>jrY{yF@ z?D$){Iy2bOVQcqY1WQYzi6Y~bPGBqf^H}1~Y2nm-wjjz%9{KZjAc^ZR0GtR|utrNt z^==Wv%H>M+eLKAq%zV8gHDAf##6mj*uHWf+Sx*dkPcmkQ*=~d|t3Pxw>Hp9%;P($5 z7KoQnKA5BV9@X)+W8-PT+m zVSe0|Kp&*>cHypVa@w%#o>siTuZP-Db^C0@Ffr~{IP>rXnPz0M&N~?|7im;>;6e)B z`jSn6Fb2)Zf7y6!uaP$sa%|u)_wA)ZQ~MJ#*avjJhwCU9E8h#C+;C;|gB0HWifm@# z9z%#*cNta>wh|f2EjU0m%w0IK8)I*}tcbfkhW(pxAqd6I>GAgLEL0wPh(H|+p~q5w zyDsn$++P%@BpK!+wYz4!MffK876V;hxiqWQ>&M2jEoyRIe9%iGpE@$pw*=_?mzcV9zvM+PmZetff3f(R7_EAm+xl(G}Im~UCa zgy!UFtj+iRb@Ms5`?~6p@;9owr!uwJueJIS1`^$R4&;&Ai&?Wwdrrr$Mt{z*w)xWG zY)TO+sTLd62jR$s`uq(|^n?i=DMon1CVg~iZL(ngVh|Hsi#8cvK*>^sb>SQed@qbD zG#QQ|9gHhBRm!6*Mg}mV9?&)e&75&sV=Zggz*NU_I_nYGWNdZ6FoicVhk;huw$zfj z)%l+U$fA8?9$6n~hqTYna&2IP#buR!kqWbI*{xwm?&Os@<>)Epi7z~@zUN@6?eLtp zYJf&i)LpRHUXJF)wLfNCde)`97p#HP-RZrZGPpcn;n_>w)n_iP1&;3PVa_)8O$5fK zB|}eGH7}mp)~$&bnF6Kk??~@u^#GhiuzT*f`0;QA*VD^o8szG6qG}b2`+wHyNo~^= zQYtQMEo#xcK&@(K)Si=x>ZC9@+>uz&xt((ZFwgDfIqI!7-9r$)B- z$}%dRJDJw=d+_SPAh@#dfw;*NPIS)a)9L$O$Q=DFNN1shv*=u(TbYZe`rG z`o;wif1^qt*_qdO37cn1hux;c?F3Nu2<;D&WE5OUUP{(IzmwG!h$M)dN2$B4N!^;{ zzIM^VbyX)UC4cDx?L67Xr(&Q#JESgXNmi0RTJ<{=?Vy;)?n+KZ;Y z5q9^vL~}w9MGwX%5x!OkD6zgc#i+_s)XAoPEw(m%Umu$Ymb&(_O_@&FK40Lg6+nys zr)bLb{|6Pu%*pw`0jO)l9t^|!T>lfzMYQ4#5CW*j;MF_ov7$~ch-bpeECwJ8#0~%Y zZg5KBle9=&-hsT4DOuTLf*MJa@L2A2(oo>*JOAK2c1l)^6Uf~{U#cfx5#IgXg!gds zKyR^R_EV(6^pCbt)17Xvp4=E4-}4B7D^N2`q%MH(_uOf2X)L}|;dIYOW=Mkd>vbp3 z%O&~=*V`#Y+N@i)@SQ1grrC54ul0Db;?-;60yYyvn^-jQa-IDaHCN+$9J_Kbxx`a$o?fzY zMbM{acZD%|HXzYR<&P!8`E}VkK^?JiTf73`hHY1YkPUW_!FZ2B8hl9MDjt%_#DEJU z^hCC#5T`WwG>~$1*=~#kB=p-J(V9Sv)acnn%GzRpRiE^@i>~L}{Jk6uH)yL0)F5Z{ zEUf3SjM?M8984ItJ@j+&ghtOAQkE_|?2Blv!9uG)?Dh}Aq55AfRJ$7BZ*(TOH{6q4 zSU@k{olQ7(X^$JU@#Ne#^MH2KvT$d7R&pl-A10&>=ZmS>ZwU?4FP9B;FoGX+TbeQI z({EELI}au1y&UJ=SqE62_-y1(06rZ*^j@D9S)52ua_zXLIkSM=yt`R)8Zw?&ruYFP zF;4B8u1+jgW|E?d6SFC@d;BHku!{3Lt54_nh_9a6&VFdTp4|MsqQ0NpaO_H78td!v z{m6dJ&wc(^q{3-Dcbhl&>nU7`Yav%vO5--2b<@K$#_7E~hmXU+b*z6k@|VPj zb#>2kPgCr}%Dim`AO6XLLI;D_(+J7q3&T@lsLuvDm#UHP?LtrPw)aGcv9c?2mPM8{ zm9+HuYtExj{h$v**R+S@_=>W}oR0ZxEz{GRhL0zP$NL%Xly9}?Cf5s@n58pC+n!?~ z&yb!#C-+(&mctrMOJA*%Rij3Md4jckn0Wsw2dp=94$9ifepx(`MbbamM+KZPuBBF) z7e@>-kd?r3BVwOSlO;|-)h>ZdE^`&R8;p=D<@k(k_ji7GfXRy`hsOP&=PboZw)L!3 za+Gc&KE7m1w~Ny+>Nv=m2o0FPFPYcfFHfcP(@s*BMsj0vycyVvB>;p<9ECLSBpyInXzzk-DH-F%iZu#1Rk#wwId*{{<%q#stOYityF{18>VDZP*iV%XB@jm`UW5_kr=)RlJR&Fljr z`bE@kA_d4$b3Z&xb_$A-rxy|)spd*>K>_T6kyN)<>BBd!msg&zj3KI+ptn!Iakp}V zeijes{GxY<&fCmh=mqJ@j^^rF*Hgx?Z`Obx2HuL`fWJOmB8I7%io3U2E+Q7@n1z&F zj4L{Z1e<8(DU?N3Ge!A)OKzuh?SaFJgpXhRgFX|1VBD$|`W$%Kp4sl~#_z__*jDaR zZof=wsSAB6`wE;78TckXJo1{ue_`Q2;9xi~e?$kq+9wX5BmOm%xU1qen-dN|B+db2s~+oJ2@2 zQYg+-e;E?-LTN7W!4Bvb#70!_0|CG?r;O*R7sOw2kQZ3Xt|9p$3d~r0IE}OP17r2v z2yDNjXA@*<{~0B1`?hkz#WRe*mBxn3UsOzjI-rM(-Ulrr4%7b4mTpZ01kCaFnf-=f`w;pOAA~JN3nJ{d%Vc*^hDeE#4Wp@otp@z}%fE z*2vwx{RykWm)XGGQ{Ectv%V<{4MF`mJvlS5~V6j2QDQeWoXTMYctFo1!fx%NrxDBAiNvgqk`Wc)bC0^&1fx@s! z&D$$O_-;vvnCUOg@D<>_m0I@*p*`WgPsKGaoWM`>q=i%s&$g@tXYE>-VNy#|oY&?V z6mDpi6G_$jCyCZN`@Z!;bB7E2MT||7=zXJKAY=oZT%Gtz-(D zE7d)Vavfnj-6aE*M;mjSX70{_zH9OSx|K%YyL({8;4h5Moa1th$|DVx!To|VPKIv` zvrIKi66HHlkEjd^oK|U0;R0x*K_WLzi!>wO(XaMTfu+g9i> zYb#q<&)1180m&xPFw*o+XhEDWg?boerA8-VGod>~ukFqQ zGNKgsqe9Q1B$=4jeaVm+OuCM`!*=fMz?jnIA=vC1#`U+JZ9+AvWM23L{EosEm%pPm zNVI~OfmZ=aAa4QMz%s;BeBMI_-r4}Rh zo|^UGMb)SY39a(brDP0l<*u8a03jz^H3BvwmC$BBt+@Q*43(PA6T0G6cz_94xU!^J z8#(*0#znyrDngFHk@o29oem~5!WrHw=q zPs6Tl7bN@Kvu0-fj^j+-!Uf2l(xvPTRCr46yaPD!ht<=P?bEdagx!GXx6vHp*k(ba zLAT*AV4fmg;1zs~leYqAl`NJZDEruUh}%G>=NiNwKLhZ-r**Q1xGjG0JmQ+yJMQ1l zd|kco7?SC>F)Z^QdU+<9bI&q07Ap^=SRBc>@Qv`#!kJZlKy)Z{!pQ~E9E#1`BZ2P- zN#qIfuK~>{s|5gln5c!nfD6L4x2Mkq0s3dvq^&t9Cr(V6wgoLx|8-IRsz~ehc1PiaeZhG+0%kc_vgQvph$*mOIp;BAc`? zq?J|{g_UNJslSA@uNWULHUL-HsgrCQQ6#>NQTzcgD}&Gq`FD05y5OH_p=_g4GS}r% z(L(9FG^TqO&1hmNJr|wnwy=~%*(hDsjW+Jdwr+~~dZ^d)eHMD1T4lVx>6zS|wE1#+ zmG(w+TKoPr<|6c{^jq3KPXI7yDBIU@&4o4@qBl}IQ)Bh}<=aF1Qa%0OwHvS|S zxNJ841b1%Y>2Pu5@Z+TH?Kc-ucxuU7^&i%RZr2M3?B;e?jw8@=Cx z#c%61v+Uhvu9W4ZM(IVl`bH!m8>@Xoyj)7bzw9h^p9{o!b3u|nJ*21(0@#;=U;t+> z6P^|7foY)3_N&~?i_&R4J9asw_35wxcreg*PvEH5QRg7?ok}=~Hm3mdy$+S8*~ zW`qaX3C3n|fYWlC^&*ORv&yfl@Z~!?TQ${+P=(gb}$W1IgLEwwU+GRz4lp@mp0WjRt>fUuZ!d)cj`@|Mx2h*cdtf*W4{$bM;>% z^*W~5AiqLX3W10xB%&e+^kuY>lJgV^Em#zPAz*^8p(r3Cc9GdjCQ-ygTuCLB+3zMC zQZb%IYsf5GwSqu0@-0#ntXLxambe+C1s2cR^zn_x;`PpnfDi_XJ9e0*2o*>83=E2Z@%mv(sT*S1>aU6{8Mjyw1q6Z z#0H<@$svEL74bRd;^n3bI)#y3p>@0FYUVxEX7ta*^JB!5A{MNiLlD7#%aoDP+4 z*GSTnI-8_Kl}(Q?Pl;;u`~VSQV$_GGIOX~HwPB7#$BAUSzPU3)X2=0_f{o_KlVG?O zMEad?FC>ZkN}l&X79%CDM7e^Ls1&i%R#g9}!UW%(>s=CZ_^AoZl(=RV2`|2FPR z8tlMk?`3vzH7M}~V2tc8$tUq75&FuiDo08m-ZnV8acCTCmzIn+9omFD9ItcHmQ^!% z`?@Li){|g->p34Diz(>kv>=g$BjQ!N8UKL}kb3S`8MQe^cDD^s#4mMm;|rI@yWXS^ z&b`_P=kl@*g3)u)V)@go9q0j~0Y7lp9|7mYozZ!UFQ(bv_g3Y}!mh`O)6u{iy%V?m zD(wiLVSHXF*3WG_$ljg`w-`$Zb?7Ccq0{8~Gf9|E13##}ptUYoGIq|mZA1z?@slHT zxJpdlbl`VkF@|4!Ip}}r*{)@bHuH&fvng~JA^a7{q4UhbXF2muzv3NZ&Ce7!;rDT& z`k&<02I5})7fmR*pd!S6U+DBtv0Cos-WsIu$Gy^U`gjS`s!s`oQ#p?WyZKlc(t*)? zl8`9UF_J*zcz0uVPLeiB+@g`9>IB6z!e9wSrN)PV8c%6x!mM^|rDuXZiBFhPJjvlW zw*(=RM9wTtdUrx+^fCPMz+%4ndo1UPV*XyEFw1Ym8Xb>^=hAPj8z<`SsJm%G&@gPp ztIeW}zkqsaAkaI!Wh)APp|j!`c%~)jJ*YTK!(dH{c1fg7PK0)!cmqHYux$l3I(12dFlT6P!^0WcSIiS(4V@D! zS``cX6^Z1m|2900fQPpnI##?88XEd0g?AMphe29p^Hr&jf!4<4*`NOETX5fp8Gx4K zC)e7Ty|dQ<9ZEur#R*i<{#^F$^Ysj#hL+Nzoq%Qc*T!xC^mLaFvmFfUF~_Rf%BzP# zdLSjONj2=2&T&zn*(t%>j?&ttxoC}Qx26VL?)h-XE`iIzou zqZ}>5bB6eW-YKn}|E|oHY^xBElIxu6OyHOp&)>0uy~4%gq1Yu(`A!@d4~UZM1^oN# z!ynKCU!(J7_TgV8qbiM85sq3O_tVzvv)mvaqGEJ2ISo~w@JUqooG)XrX24&gwUv9j zHkhan{$9q$nEe_SV2FE^i=;$pNXQ1$N%d*G;@Ua1>FVIPr>2eJ%d>@Xu)?MI$aHjd zes&4V&nI7!zpu9*reC!h(NctwCPT8jCxOmuu}FvVozEIEnW_Rw-q@&>aHa~mS+qp4 zm7>ZZaV;5Hfy5Ofe!0rzy9*(Bk%G+eG{HplfT4RxSGRBLKp==UK&WzwB>Nyr`Q^E29<}Jbz@-Scv5TJ3%+q!qCO`1qVxm_Qupw;Ujo| zNcHHrE2$lc<6Isrb`r~i=KE{xt*)uQQ`w~H4O(0GqZD=pmrv0%61TjiJr!Pq8e{S6 z2+SZzGk@+hO*IP>*DPirR#{gVG%8TlD?$RKJS;s|thGO)e92aGIqB7H?u?}lP4+z&cf)sQwGNm8cv;b@ zqcKe`)bNytbw#h#B(8cT5JcArOGi5Lgk=?Ai~*U=ta9>lDZZ64Jxx{$jGrv^cOP}I z*9lP9skujZJ#rP5(boseuZgDf3J*OlnbIP;%#{8k)v)GOU{Y2l%iVX(=a$seA<(x% z`1nS`=;7YpTE4Fk1j#q5=k+Mmn$9d8k85sC*&aPnUrGvqQc!5U%vP(*nbJP=^}_&? z>%3gC2VBJahaI?(xEku~FIpa$kQfY3mtO}Ew!$~AhfOU&7{1z)oaxu=s?hdH`(-&t z%IGGtQ-wti%kYQFAX5nfTCQL6EYMK0r5kgQMTV8QRsP2WNLxr(l~4*)5YNFDXTa!+ zru6xt{B(yzBLG_bXlQ<+ z^7SAjaoZk_ei!msu)xQ6(#H!!P& zOffmVCn!YX>GPoNHnS(Em~PzrYnuxV<3qU2O4xMe`$1*R+4z~<4OR_rg$T}eM;+Kp z_e$EQNv$xIXz|C_7IXXXjjcXSDv=y`$^|ApVmLE<$JQx7nZMBD$47@#Q@XVT*gDG! z9%0M#xhHx%oQ$r|%YQmF0EK$;9>eqm^z6hhCYD2E63O>hy`DiEq0HNeBDQFdw;`uOnXMPp=9$dtI5qd38!*z$~rkj?eA?3v4L)e5e;&kTv5ZfLN zRTUi=lmpx1wHe91AmYyd+1Z(`z2ePYf3aTsJci=uw>w=Z-A?T5TIX`J^(^&@WlzNY z3|6PI-Sf=Iq@yDK6{|h4+Pn;%&k%_Q6ZRRF^RX-7-qsn8XGzGS|4Rg#^9J}Aus@&` z$|UMx42BL>N-F>iABL*CKe69Q<14Vg)zRea9l*vzOLD=H@>EW{b;R%n|6K{ z7Jlc8z@e=Kn5W}`jehZ3*s8XDMxWqY0k4G3O7DkshEYgk4TlPsX%L5{PhUW#L5xO7 zSx`w`*lZUnM8u)+v0e?WX*?*Tbq$OpK(ceAg&tFD!EYY<#b+pU=I0r#T7su@p7Vn{cxjDWD|_n~2U@UWYemIFT=kXxovE@`G{{KfBh^Vji|VvK z2G^+txhGFGCaGU37k6+obnGa)(vsy=TixErbFncOT1|#)6~p;~eH@tkS@FvcOtatl z5B08UZj;nurK#}ZHCCl&eKmh2pc(COVao&Hp{q4^DnRQ}D7cGREmnkRv(ZfC{h*w4 z88W*?zpW-SP#kN)r7<{>#^@o26NbBZ9(zOaO$T~(5)PgMl{9HDDT-nnXHt~fXEa(3lX6W*|W zR|5M$wI_Rh=U^*S#7)rdQOJ^Vf>P`*^@H~!@|sy97|41qI1nelX*;WwYobZ4R=3*h zp49HI*co`w8HCb00-J%oPZy0S^|?4aKyGk*GZq)H_*t#vkNhC!9m4>hNJJp5z|3i; z+R1niCIV*{Dy>7N9nZ`0&_as((5@ao=mZ zb(oBhB3D{w;3UPhP(!LYj0wAo3Cn#7MzJ5x?vC?{F_$eKBpw7FB<@C8U!d$1m3laM zZsdtja=QMUslfnV9m<2r95B6&o4!<18;|>RNimt0n96QC9RMnHHr*G+?>@mrXfrW(RKpKm~ZpRPsc}SFcC3gDauetMG^AyLu zjAm)rXeq8(`5-NiHvaI6QVnT59v#;}_nQ44Iyj@@q#2=Mh&zl3Fyt@7Q}BDVrWo%N z5+|fRuamQDmah6R^hc~AT2OfAO->x#nK}j9Oz+y=2eF$FlQ$5WK<$V7wpy5tDJRrT zHvbfvyp1439s#IT!O%lVXRa&^c{QOUH>_!~oDk}h*iz;NSUu<|k~BG|3}_+vK|PF* z-j+r?lh4~%{47?zKi$%{a~b!j{5|a^ke}{}c6PJK3!woR+BZ+8`JeRNE$C{74o;)z z89gaB1sCw{Xs2Yf`L+w}3Ogi6oxycycs%h>5JvV-Swgoiuqg6Kdj-f+qva`%DXlbO z-1yeKa5(@9ehpVtqa5dZA}% zEia(7jPTyTVZKijH@l&3BqN}+29afJSSl;4S!WUyZ{bn5U=5gT^N;v?{F7-F+Yo&m z$=X=Z#b=dhQswO`d#c7rKQUo6I_3gPX(3mYh@42}X0uYX|&Fp#Um3Y#YfE z9XY*YWOF6?Dq0CVVHLs;L$-iriRRF>oIWNkgwzO}j-EL<;9+j?{r=LI()}ZvQ<-QE z+3XJ2%B5izYHMmpZ%-G=5gO>;S{v^H-yl{vaI$Y_lE%u2<6_2<9MTX&QAw&Rq(N?x zQQ&;1Dg9C8yQ5ItWI`{y3%*9)f#GIfT<#oogt{mdZ-TBGdzKFx?!lPWt=-Hk|JO}^gWB^zt3OoU?X{->QKhW&VB+C8!^TcLzQG&tS+1$tG>O=a>JdI~| zy{Y)Ce`5w+9`qaVLHZHq(*Xd41f|RDHAyJ7|K3i|W+uNiwkr1;8#wwRkrX>O zMyz|ds|*79X4$L6?2jj*<|T;q4<-fw&1i*+>b1_z5hB-)VEX_EoXFJV@zzFS?s6*~ z#wUPTIM8+<(FYc<{q0`(Tx%qduD08gZGe9YO(*?qnc%FC-z&YshNtV@5o~Ku#HrKy zm%}fQLRZedPH4#^UlnW#tc#UDh~t0ke`iehoCYY!QNH{5#mMMyXT_z#Lm#~dH2J#; z)fUqtfQL0Uita%d(pZm`&?dsgnV3@#V(StIN^*@6a2m5o5-p6uBm`$%iXllL=n6|P z7*aHs7{cq_(L9Y2rK_7HiXh1=jUm4{VMq>q@zy=UND4lP))?SYCvKDOvBvwt6UWO8 zwz_`(i>7+MWy9M z_dUz8hKXVTbP=a_A}`)t7tpj}a&*)#ED8ox(O%46NC8Lb;WiyOqCCtjFU%~WM4*I{ z2uKqxK0X6@H?G7d#oTV@=#TewSJ?Xd+530Tv(NQscLSQ~mgQyBimoT@aepuXVG(4y zJP1?0?{~g!QP0|XV%%V|TgzePr|xObU1f6S4HtM#Y|oslnf^oh5;=qhmMnxwCufn- zaX2wf=#D!IA3P>2CC{5l|GK`AZu5Yri_w5lK{#GKNq6kb<*-~7t}>U8H~?XMF7n=& z=;F!Es9d4%YVeHB?h$@4dzK~(e^es{@NHBEi}w?GU*Pl(Yx6ZWi~V@njcqq4e^{K) zR2-kB(#wTe=<}$V7hc%qCV8kl*5$n;eUV<&-2nTXvSGY((-ZQ0^+@g4L0W;yJofHFi-SEo=Gt}_G zea?)?gtmB*zjXU40a{cx@vX(X%|IM@Bj zBmj6dYw?J9oF+&>;CbFS&WVJ?`y$!3PcP@Heb-%mG1XW&!+a~}ZNN{3gMDA>H_5`X z%nAU*vb<~qrS+g#YX0-%3+BvH;P2ps`a;*J`CBM&sGeHgh z`FSiAQYFsktq;%ZOUs^s=k;7>F+T*{6hW9lZuk`VF3qAt;_!Jb{i8 z&r|K9pIhT{>>k{_a{1>p5q;HXk-1xsU(pG8BG4z3RZI56GHkzRXRZX3ifb4S>P*~e z1k7r#2_%+ToCO5>x2K)1H=d~lflM1D-ie%H+xCW z%tHI2HRD^%M)%X{9X-gj`zv>^!iiVKg{Xgf__sxE_Mgs$o$Z(29`8OsPlXsF_pp(x zLEIV5oh^y=M7$z;_edDxeF>IyVV4B?F^xF_He{_Zyn@mb;%-Vf2Uk1xR(4n0^Sbsw z${W4yTtX=8E`#XidSdH#O#U(rvs@I7wDHSY7e%vN>_vYn-+*~acGgF3OR1pA?7i-^ zsJ{5~OnJKlJu(FO2+S$o1(igu0&yRL)@sor$|N?;Fl386%nX1#DO#aEgU9-j3<__W zZW3<<8qqhxb%qe^>swW563UDd3W`OQN&-f5g&M9nHyJjSbo{S8y&b*bKe5A%9v2=p z-jF^&#d5;v*)vwm91Hu!OtC-LS7%oj-Fkh)uPM`R$Y18ZiP1js2cckD7jN`}Tv--x zD82L-hKEOnx5@eg91&IsZJO6??(3!)GnF!HIM>+(AL=jPQMye!=AVMBgJ~NU*1GiB z=dQdvl3h4mNL^Mg&MqFGXfHO_`7O66zEI!cFBdN1*L^L0u`!)(<~t-s5)Wl#SlfbM z6oh}?`EREIUt23G%9F)LSzZ?pD7__x0bihjuPOWfZvr&)7hf!T_O46){hiC%Awu>4 zxOJc>$_VoCTUzj#43`YZTo}R)0xw`^*9wV8n zzC(Q9Np292-f3}Y=fLEl#78F!$n0-Ia3or3X_Zcc+_6*gW4FtX6TcghoK7cbzlGyB zn39|#Wwva!935B%`y2PGb3Ud-BO9srZGk9PsaDnTkaX4ilm$h*3v7&iyOdCoV}gX$ zZ9n43k(tvVBPUdcId~h7G+ik!i6Y(15&2ppaH&M4V}MZq%Qr40Lt7r+Rf;JM#-zjQ z@ePqL9wfRskj|j*@en4a*u3e1MLPv>Zq~p^N4$~(MfCCb1g7#Avec$Di(19bb03J< zI)#=`8m-C)AEYT!UA2@p;il;8-y3tSvy&a;vx|ch0dFp3ad1PciDwo{=LNElwZ?KZ ztOoF=rVBuplz=sJ7pRtn(M1rQ-lS>6RiuCe7wnDHD2iO(<4l8`H5296R*$J zo5>T^3$i=;p}Ap#Zx;?#H8u3Wley)Mk(s5XMUB&;6H`v})$Cc*CGiWG4m?#JpOZ1k z(8zmcSA6x%r@2sJIQd9Q_hYJ6ZvwX_&hEQ@UizATbgt4WH{Z+u#@9CmXBxC^PMnEt z+qP{?Y}>YN+qUgwVw-Pl+s@AJ9_;?RU;R~GU478cU47Ek^<22GR*iW^hurOtt*yR^ zn4NJyVM?UYlmN~A$fJV(EF6bG6fFejXy6AUR8&&O^h1+cB)KeE4H{fAw91t~q+&!S z)Gn>K9m+$)0(j^+M?C~AX^VN-&)Tr=jdp7}^#kQ|v*edlv|Fs^lYc6RvTAJClefOl zZZ3Pfya3QSaK)u~w~t~TssukMCf~eopBLINXt}%lG2RvWS97N`wEaa*)YIyn9uJC} z`3`d<`Twq!*P&cH3{sa@$oM52=peHob_gDrR-5xb?VbQm98{@6>tX^e-fy#=yyailcs`WF=&U2Ekk5GFd^iWMs71!kq(UPj}B( zXMS+)CS5WnYwObx_e`jGWq~9$GqYOlX6iT-dTr&$9+6gy{*(A+e-Ol_pxADuD0EU1 zI-RCy6dMVd?OHpDgK)Ul^{0>7%w<;I#rE@1+$bz->#q_?5sOTR z7%&Q_-NWj|J*m43A(_h5bY!WQ%C zUQ{I^`@$0)=>2StCBsR7_WgS)r5dELhp9T81k|jH!`5uhhxaQj+Un9Kh*MQ1WDk!A zuCeqa^r7ROe*e@O7h8$oM0D-1F7>bExqGl=Bfv|9`r_NHKO%C2_FAddXbxIexYwBa zey+>7wyE~YNgqN-Ncw5XjMi@}mCD9!Kfi68Xu)3plj48oZb-=Jq#Opl+s2}AlF_=J z;_n7ZwPBSUG9RxGa1C4sg2accjzs^w`Lsq&2aqxtkIyQK42LtQPW2k4RC8F1c<2*Y zhc+O9W$6t!9GDmLrq1uG(=eYN72qkn>h;5~*H2bQ_XIn4#19mzGeDQWB<<_fI#o`mlGin7fM4gQ>wjoB?CJW-pZ?#&6EfUYg&yb zgT@KO)t~|*B#!xngR2@|97~JM&?IZ`{?R&{x5}MHr8kPgTXK4``gxIDTqC;o3^Qi% z!wHc3md7bv6F|{55Vb)hJS9*augX+S0}oUP+8rF2d98~krVC_DU^QYUsoa&C?w>A4 zqhaH{g#2DKtRE~9a1V=}+Ei=bnChl9DOD1CpH_vOR=ut2ay@{`86`c5r&(f*!(Yc?RI>U4Th;3qTi*lFJ`qmF}-$n@z&kppI=PfTlAwpy(%4bxVeF;a++Mdk34pEu0r%-ygaZg^AY3EPj}+RTOfN1`yH3h+hl&I;+1zVRUBlfz0>EgbN0$M z!R6iX+!e96y<%l&VRNuOE(DUEEk?Ks9tT zO}e7vZRm{96>?PpM4P8DPls-xB#-wlc|Oo`kACdoU;Wg)nx%kvIo4UfY085!(iO%x zDJzTz0o*%E=-Mo(OKNNx_zN5!4RevRf;g7tK^S3G(^UrAPtf)(?|1Q~plWfUm(N1|TWGz_t~*)84mA(y*^ z-e$RtnR+o+Hiek$X;FEcynCwQ^68vdW#3pt_7q0854;wmW4}igZh(dmwY`QyeJTeu zQnN^~1=o0ZY`u)6Eib1G-=su2{L;|ifZY29`|+i!YRvH%Bo_WPkP&47C$qy@*cTZK&9l8-I&Eu~ z4??FO9QSMTa^TM8!>!C=OaE8|e;=d0&3-S_L4g)IhcVCO-F~*5J)ii4%5OGzEAI^E zBB)5LhQSS`SCjEpi8L%{LTKnu^1SMJF7{MT7KlxKCKlDm^el<>w00L6v(xnY6wip| zfD<^Z6!z!%w{6FrUi!4&(2470QIA|MzhC3)=Udh#RM&jvQ}0Rc*X3AO5z$-$B!SRN zxI!ev=%aXavqYRw)z(3TUI?}05~gpTNrL7Ml*mdf)xqKpgyud{%>@ z)_1eUWZTin3jmqhtgN2K_xOw2TS80mE#7>6B}SYt5qUVFYgBA#)e7RnLyDWuuMpR` zKcz@>7skYO3vad_zY4@?{#J?XN<+drk4-`cu*{yCOi+HR>x` z3;=60`%C97QoL$hpm$>8t`dE`aS7iEi1F?1i8{x%h3<#Oqiu$)t6wuLYbB&8S_{K} z)8qK~Ybbv!7|2G~i_V4Hkyick(_7l0FUbgCz=OMU=i zMnwWU1*OwUV{NQCachRpa%ezV^Giedi1D;R0owTsju`i7v9_Jd~n@TS#o(|?^Tz)D-p$x_S4Yc^HhC6tQAJ$DhUTuD#ris6MSgb|F|xW~FL@$iSRj4ek?4boKJ zcP-XTp3LkB0vfCzBhjs;^WM@0k6Z2S#VbW3fhhV!%ce8l5O0>!OCP>LB4WORQ3V)T zPr;BR>lYxU+8DzLmZigf&vHZYoa`n-`|&>x`5rM0h!tT4F4@ACtWDtGLO?vFPzOf@ojW{f z*J7MUVC^P64(~WWs zI)<`pt?8q=f?oc;9xGC!S<6h~Q>;`kwp4@P1wr^lP}2B|B4hY7eMg34ymKa)i2^z3 zmWu#wB3+LrSwMs4A*j(aFT9oHmjCEvdlGYN)&3g(j_kZ%-ICsU5lnO2i_&1ccuoCI zX5+R^SZsZ^SyHQoN*4X~{Q@LVwOU*9{Jh$wBq>&HELBFO23==qY_A_$)J*q+V!Sde ztmw2fIZT~QE*V#!Kz01m%`~-cu)kQO8g97S=5qMPul=@1IpCA_GoIg+HqPuv>i$NZ z$U0$zIs)yPFB3OmhO#3a+pj7Z76&xv5P$dZwY=OR*s(K|7x`-BneGk_-PFu%^*cU? zmhLqa&?Nu<_M8E$Q_kL8UZGDnmxddV6}Jkuc>-((o_N;I^RI1`oRa(9pfqp`PkpnV zq8`}Y1h`s|;<`6%pm%UA2mNY2cXz9WY~5JLu{zKi>~Upmni@UFwp?)eD$mKFmEO_1 zqQkC>*+RYJVpZbZoh0v4BCIsFo(zADrgp789lEVM3t}y}yfOE(8OcA7akGe7rG1*rgtSBsG z3M!~BKTYnx+y(EQ!)OT7QcT3I(tiPfsP3g!NyI&<)SQn8T_oFa$STII!@HklFLu7I+ZxlB;K?0%vt)G0IhfZSZ2yUgcf>dOT-x(se_{ka+aq+?d1SwykQ8+Xz( z@5K8RI{f&Ejg~VHQW{{p{c$sE#QIXW?Z+P(@;9K+jx1LGb)&^jgPp1ol~_o4Fh4j2 z4wGSb`Y8k#ncO;zGwaoKY*A>y}*P-#lAVo!$v>1klwVf-BxU}i4~ii zyiDfPdFpNWW&VLm)7hZ1hz$es8#UR0hPHY0y07%bC$}6XTT#G|CJSf+e0Q+1{9jEr<5oBJ;!*w`-h-7O5vbne3nUC%w)z{5Tf(B=;c9uv%da` z_T(z0FoT68Vy_#O07$m8FD&8b(6L3z+ay1 z2o$?X_qV}-)QFO$nzXVgvEv*!WvJz#vOU~lk!WAFzr;2w)Vj~X=p$Zq>mEO6fYb_P z{m#?@jnx=+^|^R!0NilMP3#gCWkdxTE!g+LY^X87z_H;^gzS-)#;~@fG+u~eUzH_{ zT8ZGYyD&Z_9fR9}l)Ri6GPA>ULw~Bg3S_IRvQKf`Ims4{E>rxKI=`vAE_xq@c^-tB zc<`a1w5y+39NsN`Jj0TaIlN6cB^LJ?Kgwpxo|9Qaj- zcjv#RhQ;0_g`zT#tm&Ilv(#*355Y5)R!X|gQg~?a6f$4WSntiX*0fKcydSua4`{T7 z7km>F69{tTP5#Eq%Ni@G%?i;@w}gG+>VBN3!IP0?eur8)*v7=Co;)c` zgQ>C`0%4R}oT@Jxj-q2TH%X&9Fym)&P2X;=ak$XR_YIv?I`N8OEx!8?7=GkQtTb;J zDHCcq`Vn;Z$|pNFnjrHx|9I6SUaE<;hCz=B6|0beOAL#$vc39nk;)ECd#l0 z?%~;sBjEuGr-S?x4E<^YhV#O2OUv(f2z601UB|;WQyJXsVJky7o0$CUsn zQ97t~SHOJUL_WaI{njg&*eyLn1K3SxqXnNtA0yV^5p-7sIYnkQjw({kq4Q0E@_8xy z_?h2*hH&#iPW#t`qgh5i$qr6O=KJF}{GL=f*lqOQ_mq?_Az_AEi&v@!H#gQ0`i9BHC=w{qM_>rP01{ zGjcOzM1RD?DP`!_QfH=@6@ zF2N7mW%f=7FtI3^9)r?@4;vqusTZFneVNP}+cE-TLe+NJp3t~{9*aqQ;ef%ClA4l= zpmlN)CZ^6dpcXm^W|n>%GM}&e%ffV@4@qnNGer06!V5Q&cY3IIgm~K6din@bJkzgs)YqL2?@Z{=!}XdHO0JV_ z5o)BpiOBVs3Db2^gDmJhZrIgkf-lrFUM-JT;w(alG>jArm;>fC_S@NGpkHGys1iEkS8t>8b61fVyRs1*PF3-r7wM;+h2sp$! zs)k5%Sx7(X!w5)q4;Pi~%*Vez{8PvNFd`BD-J;urUnC*~PKW{3;-G{|YXKiR19Y2y zG~Ur4Jp(|SC(v*0Kgt}zA4GfNn|i3VNcC^S$LO0srVqbfOtG1Pw8mA5^j9ZpQLotn zHUmB#I-EcsKF=PYIN;p3p8;|Gq1r^Nk_=}S;*aRb{WK&zWdx^T%XYvxZ$s=Lnze)z zj^8VJekQ+J1bdl(2HdiOD{WFb8gkip@7BKNz*iUXau#cYmumm?k`F^&g@cGWMIPA1 za2^@+C{E%Ogtq%B2-N=sx7kEH+4SLCtUJgX^~I@S#V9S+i8MD)R@u4L_ihEeyHW(| zP@i=V>Ss9%eIqkU>D~SZ_L%MeH?A?Wb1?n4#x+)UPR9Sj9_##8R~B!rReEt{-xXue zBBRrXKHWvr`b%pW%_yRv5HX%FC?KFsNI)etL>kF6*q&bwnt-usAF(l3=nzAcAAw0S zqHrJ--_9*51M^6`UE$RQ#F{kT{nGo=!|wER(9?F^<06;C<#N7M@m-wtk3r`A#~>py z8B6TmW+&KiBQ!cv^4Wa7@2-XhUqS=?Upn0NxIR+^`UL7;v4g842|>l;a29JumCxX( z>v2MEM&+{cu$VWgTVV--sBbkcBnuoEMZ!c>$@V@vzSK~ZmKooAv0c*6XGe8 zfzLrcun+DIy7{W_y@;MWL(F~A;}aTlT*9Xt5*eMY?ve?t|GN9+-Sa8w2OHJIgp)U) zzhSg66XvLOoAr#+vy0KW`J+ah;iA#0;%+5Oq2TY`c~%YUr>v%=Ry3JN${IKb^bI=8 zJ0FWD;(g333f#b1O@JlDLN@J*0#NOMLYE|A)lVVQ6s$-5U;HtQ^$-rin6_-Q!iG;Ba^CVV#GlT@22e=S-BShS$*i(}Q>F?1ClTLV6Cqhd1&A-Hh znh=w-StH;G6QzWFC$lFoH!Lj6|F&9=<4Y8OzA4wby z-FrDB3B}`06Vu2}Ix8)kHUrd;mG& zyX`0uXtDjM8`srIKAHFZJEk{!0g^*QpN?~ntZ${pO04(%eLaX6AEgi|R4V22*S=p< zdXXyB<$ntHUjlDHj(E2PknX%ZUhx|Ejz1^9<&#AC-j-XLB%BX;^IzkK!r$8CE+Rg< zN{p#?AkMqT5$;eM)r|(s+{01bZG=BtTHUX#03iFzoqi~7Nxt5FWj*1I_BUk_!zZF7 z!t9y=@`?A@0kgM>o@WM5x?FPRye+rNKLtYn`!N4$vA+ZV`LoZ~+eRqAQ}ur~+|_|5 z#PiDIv&87BO#VCkf3EQ3SI|Jb1)0$2BL8;~ah5<&bXRX_HLo2hp?SC|!I_9Roc&B# zm(VW$7m@^7A|z9WNRoSF5DpS-VbPkLHN9t~jtKv}9u8%!V~d+hb%~2h@YpA=q#5*2 zl`#jVuuuYjqzrMx?428WSOhWF>jjlxk{m&eNA9)qc7=7v3&0Nc)2>jD_Y3i3+JaF- zv~gU{G34`?SjNbb(yI~+{(V7aYkqDseoWEiDHk+uaUQg?eHx`q_)k}}bUW2L(NoG9 zE~92Ci9_6L355k0G3+AEy!CSOxU-to3-*#&DbSBU**CUAwiH#xBfp5nzqzUz{Oq-E z0W70-X)y_W!DC`cmR-6{$xY&=<~lbfYT7)DlC8;F72s{?G*dcp+)LZg($Lbzy6y>> zY0@$5>VK}k`uFWdghbC38IfpJapJ$y|7Uu%laoY~QjsK0QC2+jOJUpi7pb&)9lMo8 z7W;p1D_-d1cuM?1H)JE!M4v_^igyk6&)Ow}w1p$BPx3ysw`+6uzM)sB8DI2CZ8E4W5wi%Mz2}Sx@q)Jg=*$=t=eC4ZpqRA)_4J9 zpUCiiG1N;&ez{z9zX?_ZkaL9rzkk`Yk0DzW{q!^8(qA1si&8|@Nsh9kJGj^yqa4c~ znLIj*9`BRYW)Ht!RK7jY2PSSAPs%;Cpz_U#b(O8CLP@j2OCTEp+i5NH^a`p76SR}( z^+M?3Wo$h$>!h(71pjO^5T;#g%(Jor%<5PD)<0R(;F&4-gZv1p1Q%r_tqv6x#3JF} z5g%%^Lm&b((0cWUoV{mcW2;!a1QW*Og0K)JG}Gx}h@%gaAm13hs8Ct|$kn&i0nSwqeQ9tzUY>8Hutl+y21*ytdqOS#k~0)ClK# zD`3SZgq@2LV{Vbd1n$X$my-67fDi*?wT4_6Ap}d&2I-HDkKk-d%sYkm$1PP|)a%Ej zg2lihq_ks6YE9<=Y}^`Xf_ksdFvOcpL|!{V>8A8lQ_tS*Vs9$uAlhvH6#R_g^#B%| z&or#x>S-dSN&}fnb#+Q!vnhkOGNreObYVH!1fQlfq1w;+vWrnG_DXi^@Km$)dP=Wj zbZA+{lE(o@b1x<-ENni-3yAITqIg-;E}JzsVHx@Y(hKxi08Ld~uH%PqWB7Og38U$- zm#o@Qh=JNvuSvR^!(FH&58pai5&;ZbZ?xCQj>U_nr@uEHCE?n30yX6avAY1btAApR z;BW&1lO__Z{*P?iI$#c|Q^?F7FtF12W{o!~;M3ufTF_0F>d=BiI)HZurb+WuDnIr0 zRu4O#MD;-P6r;Y=s^DW3fj}}m_sc?_vhF;M{7sXl0fsgJ@1S;ct#CC`6(pQ4MwU3& z7@~qOcWc5ROPz-%XjdgaDwdJn2!74S{@37=4Qjn(R5H)8tUCOn0eJNbwxfd(1WqZsdMb+WILCRL00a4`**?!oYfaRvO@#MH>UI=t1AyT-Qp z#$Hxwci5>J1>~GYH0d1Jc98L@V!{ICoswwuKWK3FVPDXg~ai|J((lQqZmnVD1 ze6FeY2%e-;d|7Lbd(nt=x57s3sHts{G%RB<_7I6Uy!8=kbhD_`b1L=YfZJy1R$S znqKW9yeLco-BS}LP!>3CIt|!*F)56x!1AI}MeB|5t73M0sL92}4Eg1mlBRfTqOxl8 zHos_47+~-WdS-9_%7zm8?AunWSU&RIY-w$mM4H;%HiT5*ITY&uRVIG}zs6wrdhAB> zjQU>jJS|)J@()#-a5|yt>AvT3_Wz#4ftec}dlKs~WSVdxp?4g&op}<{p~Wjt(prod zKlYKGJ(8z{%X0Un`ba<(CV!BfL!FzJW`@pM+|4H*=JmS%7B)KtbLCLbGg0H$^3r!> zTkU}qr@`qW{K!(M!+?M$^Ek3c8-z|wT)Cv7!D;6IeznM?jwu6RH^m)FQEG})auamCF3)lu-J)E2G7Er0PY0s&@8yEVCttYPLI!WZ!3U&f@<5#p8L$_JZ`> z({z!Pd~!33v@`n`J&)HFC%l9$K7i=y@MCN=tetE{ZwB^6rGfK685ToLQ~e^nK6zff zQM{$u)h(9$+l}_S@=*&Ta^a2Z@J^;oC%E8h`!u@+wFK>-u3%l0L;oKVo$zdsMoezW zGKL$|s}sH1ha~+f2Z6{9ey#5I^)Lpq z@4to14Gs?HJy`RGzfxw3%Yqf>?)BraRR8M*4R?iRY8!y2^(5N%uA5X7jS5e)=JiM! z4TC`>RB{|u{J&Gr6$;4<#iC37eJpMZkr&)Zp)J>jcn!QDkkSma;v_M-a{q`vv-j~%L-h} zkUr~=F;gnEO{zK+N!Ra-=JlIPkepGZlMFWu_*gtl(d?OyM*KU?!8$3S53H7Bjm|Po zX)SG%Vg3=ZDruqY4I*<>eJ2S4COs%8BkQb<3T52j4cNoh&t`k#D_8>RV_k-&nxqY8|lBYFKO_!L5{-%keMeeFeb8RD`ELkRLJ&q==D|T!31>ciu)dVgsD^qj% zM;BnNi2bpabdy!l(6fc}f|p=5BcrSNb`lhif8^Af(2ca$wn^~{2C}=m*X{1_hrrO) znV(PkbwQ2=6V*t$<$QT}`7SZieZ6eYfT^(57 zj4${3wzcj18jT`qv!{*(`VW5r-b&^dpZ>fR#78FuV9~*()^r<_czXVXc|sRq9`FUz zWvG6J1=hS;qe5!=(==8zmYnLX8Zdg&&AsVIEowU*QXv+GYw*bA1iLWsE>nccfM72-nEIM1Ii3N|#j+)f4? zse6cJy@qXN<4JN*FfifY)VR#fr(=((QRJk}<{flw#H&azArEV4*K9{Q8NCwrseEX{ z3)5uHlgg?k7D09`Lx6K>E@x8w9yek&uHePD9*xTj2i$+wjz{N*sYmC6SqPfXNcmCr zNrMltrihqObPd!?u}Ljeo96DkOMGDld7p6FTlLU_$swa~xDrpQ1`&*S__jVn=3N@H z$gq3(hGZrC)ah3!TO*#gTsE_!;)JEL;F7^dq>AM05c%R8g>%E`NwXo61;i36?7}iO zcgJiGsvsfm1!T#e{vGa3yvr?L@uXphR1CQ@Km6?JAt=i1i3;l}~ z?#(Tu!0p`5>o{TXSigell~h{x;-03zouT*U|BT!SyB@&dzuy+xUhdDLyi=j0rfwn# zkBtq0@;V%SYKr=WImE+zVoejLI-AfqsZ+|&=f=oH-;~{wz=V!YXs(reOMZw;6qY_< zq))Csi1o6OWM#Kg6__TBN}u@GtQdT>|03F#)L>vE*TO=L%z0+ILDo7ZASWY1os zMdo83YlGxV5>iv^mxQd92k;53@FtS?7{k>+dkXfnKKUvrOYG3lB)by8KZGQ%<4{SN z8s7Q413<)*Mm}TqBijWG4!JUA4I$f762S;oF$@z1nxwk%iKNfM6ifg0D4^+Z;lCqg zJDs(Tznn5RJZ!9HA7j=1?pncMQ{|TYb}l4lEWqGv@Oj##7nnV$y>|$2iXz=>A{+GN zyqG&XDlXcX7^+`es~<|S?|CU?)Uw;z<}y{d83-lPZd1+Qx<<1rirt8``k3GDC$@jG z7w`n|r)QC^<>d@PI^?CqK&D29NUN)Kw$-Ft*=U$3TsTAy+pBU>p}8GAA46#BXzO23 z$K>Y5(wXk38WqvzRUlN?s1endAqDEtFs{Yf=;3qr=9jslI8*sYA&;O5SUq#}+UdPk zu!&>hUU8Msx>;$thhx238OVMJwJWF7We*-n+bY~yOotg3_K?2`4rMRvQg6l+TMNrL z{c**#<*D3DRujsYmrd`21i=7r6kYf$Wd5zf3a`71j}pBno!;-wzuiikR+sORqUpyu zY5n1x`kN0tiPhc<>j*~&$1zP>&Z7iB&Acj(;R@sa&Y z$kI_3U*&Xw^wi?{$GE0f_FakhND`&Dv+o0Qk9~X1WZ~~8JLjI+cE4dmV4aa@UGZ zkq!d~-h(VF4QF_qO)JW&jR;}s4+^86=$zZaD&H&YUpnqx!Gpff+}DLbP-U3)98E_y zrC+XpC}godo{f6qG_jD>!E{nQIrSH=Kg=G!75bo^ZtImiygkWrKM(1F!k=!x7!)-6 zox?HRCs8Ps-+{x~NDu8A(3Ex4%n|{lfSi`q^;?e)-@zQ>$IBM?_rBj9-M#v@PAqCS z%n;^HG7~8q!;m5x7B4z>3TrWGbbWHm5F*id6-nk$JSdVhS&}3PRL>Nu(X7Z6x3(nw z0`7Ql2}AXs6u3c71_@l8I+CE!xbp&LUaz33{#UKKXNZij<1mM13M%=I=q@X;hv^tkZvzRQmCR6QL1Qyy2C?#W0<}~| zzzIe*a;PJ@5wF@|+{lXXk=yJ9Cb4vca7AupoW%j2H2=igDv*^bg->}IMVlw|y z-3ONV^J)OG)N30OywD5w_=MomOZM>%_uN>}LtYYuRw%8z zZk@H*6OaKQo_~0_J0A_s$iweF&|LZ7YIoka#B`7;18wfTq?<(gFF{-I?%#lGGvMiG zc4~s%`W=J5&uO@Bde=j)w^-14U)hN3%FT+9;5u~BRZ-0DO^g+<@W$kw&r!X&U(*WL z)jM~*P=ZoA9?{iDVobT7cuS{ztpze(h>Bg>-*Vv8T#3gR!ya*Z0y{u7l*FWd(=9Ls zfIJUgEu0%uh{?xKHwJkwC-$fO)h3|Cl!;J0lar zf9IHD;b7qSFPJxvg*wV;t4pUB+Hgn!5KB8lY~T=>3+T{MXAy!jiUvv`K~z3byYw6+ zmdNJcvO9iN08!!IS zSQVXRm6N54CKXf`B!b{(((z#O-rQUz@(Q1r0gp>-*LcWn=79T@&bo%L-*R~j?0iJpt+;523oWpO zSKy-|^wNKv$phB-AxF8VpyQ&>AEP$pgv7eTZqP`F=(U&JV?dFa?lCXOt!=c+dwq>x zYM9sIYlwAHf=jZ#96fijJY(}5-$xs@0Uq3n+6Ec?p3=LcR2_%?CA|Y#NuK|h{J<| zO0;E{(%w#SC+r%bgBWf^kp`;S%11iro^@tr~D?d+O41&tz|Oh09Vu z<%F{F7MysC=J$P?MVc;C?tm7Y9HQJXVe&phg54|9KR?^plmO^setQ8TC&^6$eY3`xBt6Vl{LyTu`$)h>WN)T6nfKGUS;wypJm zIt4q6YkX&RsQXDqxdvz){44zbj>q>8s`{Ovh8^nr^kwzHSCp}~R~!MHdee}SRE{Lw zp^!&%ka2yc#ArfYW8e&tPYGZRAtbOCeR6Y9`F`>ALw&%SM0ofjvco$;tlSW$>_`NM zk_3mwT=b&6`(*H<`v?EPExP^A{_@V+cgW#94A9s-aQ90cIe;Y3JJdcl8wAYR4(OUb z0AC2|Fv=dx_gGVMH+YwR0PDJH2O{_O>lMKlL~m*pQYRR3U}M11AndlnUX3j6DQQ~3 zT}_qbpTcpB7V&-jYx*|Lpciv$4){51uf=`@=0`qX4)nlIknls;yb4m;`p+Si6e?>jMN>*w@<;M$N?G!VYNKjSmVtGZ29a@QKa43u5ayJAC|bQz zH)#`n$n0cf1n-#?;}FPdKUTzbPxX%XHCR-R88C$ zL8zjAuGB?8DR{Dr(uK(|3l3lY9_OPzTh4E8jhWC0&f9>7WIU2zA&+^Vh8?IDQ4iys z*exkJD+x}x=0`L1aymU+tacwOH5*5&Zm02HZfCn*{6gc?SR7A~5W*mlG<g&y392kWOvYYRRq!}U0f-)AEUyf*Cw?f zO>DIjGk)Z@28y$};~ z(6CRBAY1t9kTGNmgd}B9a_z1G67&=j>>xq=!jvTt(xHaB+K?}zua&8FmcsP8h4yr+ z<|z4#{A$PC23y+_87q`mkXbK5Dp|t?^|*!8e#QMq_M*%o{VgagT zkM1$Ao;!WJkdy(33*shsR|muXeW_wm@S|HhCx;LkNgvOcn_CSATabS4;Sv9FAlzU~ zlIxxY%tH|gRS|!;w9$FVTxF${lZEQF`p#N|_;*zBFAU+hMIp_MonHrgHKCy;Oz52{ zWQ!`!*!#?1J80eslQVupB~llah~DIB^aXCGlo&uc!Y?0kblDE?j}gp8McH)%atm6> z`rJdzraD_xWbX4pRF3W$9y-hL4ZrrosYXDd=;p3RnHlCa(+Pb#YbZIJ>P90+> zJ?)A`5J@_iv1!r=8Fxlv2dl|zk+jEX_w@-CMHZz1Oh^F)Z5XDcR<551lLa*9}4_L5KR{?Fmd)D3M#2%BiZRFu>2+ZyJr zktA0rzh>Yr)E~J*naN*HlDC^Ho6TPGInn=H5|Mn8unzCAD;m9bquauZ%uNwDPu_^Y zVRd7~Sz|dSN-NG`F`BUTzoZP%LT;xO;0FN zi244Yb-MSJ)Wb?X?a8jtnr zPdBsMC=dk|mLtVmkrV8XCzMrdurDT#4~_ZPnL#XEwym#_@<2(^4Btrd=NKlr8~#V- zL#9YufG2qwr4qQcfg*2&6U6v32Yb@F!00g`K-Q?l(9_9z03-jPX{EGQqs>+ z`$jzPtDu#KsjE(RwnMwuv*tYOLaoC@DRAzOUm^V2nLuBR1d54f;G@^>y6(SZRcKUC z_n#LO%ly@R2*@NSGEWPmFCgKl1%5{CXztlcNoloCURGyP&9TVOr+JXfH%J=R}bcELDYdiR-_hO22e8S~i}}urfPEAK9+Xxtp1z>be4FFAtsjr!>~@?F ziH$O~Z8nk79HMG=2^KJdSTrNJ<(!;>H(mV&Scfo=1#x=7be=p3r*;z)`s4P+nx;c> zN`Sga4O>kdw8*YcEjuiXjI+?tgRup-3gFLz&dh&Ve%p!-h9*jPP#@y2Fm2xn5aOU* z<%%k7hB1s#DzA$V+kcSZLbS(B$+xbm9P}xC#UG8UDP|oh(VjR6k#vip?noy8eMuP{0*iYMD2B~GVPCI* zgI`A&fJ$fLxSL|sZ6-tO$&!f}=B7koK;AcGGd8tdOV8+Nc((WoT7UQKJw89Gv|6pC z)0-|qKYO9*ke-9+6hc3ZVu`ZwXF#=x^j$B?z@@D3MzK#tLuKUe`56S9TDvEjLa|w3 z$C%u&VEP6;!ua?%l+YlY7rO^RBm>%ymnCp%f8pq9)aTTDj&bXLz!NVcSB=s*93X%6 zzFl!1kP=?5FQjaB77bO}_c#vu%66xJ2 ziD#t%a!I-AgMG$gRCHLbi*s|ssBKhG89S4%3w2D<9+PIaJk6oN&!?*2CTz{rRB`w;PuGfRZCj(_YF+=$BY>(-vv#y%1_0k%hw)Cr|N*kERYt zA0|I->PIF&4$eg)!)m>E%Hmxa8o%+XMiA4{y<78%fe+3tKjQaFG+jU0=xdANvwq~q zIdKbU*9l{vzN#J5RqNnB&iVY&f!*eEYW#r-B+LWCxp`-! zU~q=Dfb#P6lE~@UZE{rF4b|ahE}|1Ctpv>=;M?FTRr7K)yF-a@dvs%ioR}HeM^rc} zME*8-F}FLQmni4r_K%TGiJ}FHIR#G`TIC|-bwo($VhHj(r^{Yh>Y_r5|nbUuBPUS@X`Z${~LvtXW4z zO||faZj|HNjT7}eIOO^-?keu^k6uYPZ>5qjv8B9$!$B;G9}BVMpKQOayF4ydf3g0N zfjTph;t`eHVniJIHj-C4s1QZXJ$_GT0aZC6Z>g?WEA$f-hA$ww7v>ofW>KXWSd_C4 z@=uMREQHjMg@}?JXvVdjSZ8-hHPzF98JElEXO_4>O8-($Dly}`|h2xoUk;ER8vy)!F3g3biET=L=8cAkMT1248>o7AJ+sJgQbTz=c=9t~Lm zbaI*~&1t@VzY3EZLK({80DqS)h&}$&OFq{=5SY1F>V!7+w)7sz394B_Ka=j&r##_U zXb$QNc+_ejZHmd-+I-DuF4Bd6dGg!_{E6w$&vdgL6Sj6}_UK5mO!Mm_+^ximpY)>le=y5!>}f^3&(os;AWxLZqjVmfdy@0dgl6Q$ctmgQs~W- zudE%{yi?Fz)=dH{ihP1ZhhNT4sXLVD$})71yh@*A-*82#*ndk!K|)nJm<{VDC_m{g zE*8w6l`>pk+#aq@xSs}vxE4F`5RP*d@kJs=cP+{mYdz^Qsw^_;9t}pMxf3quCby{rzGfR7PXlfq}d<+YD#$NIn~x z9?H;CWO*w>3r)#~$%kg;(vEv~Uz32v(lBsL3u&y=&d)X?SgkYfQbYD%3|BicW$k`8 z8oRzH&-q~R{R=I(o@oC9V2hL6;9kPoe+`0{H|Vsq5tI?a9|Vr#f0Kmghn6+fNrOJ( z-PtwDy8Inerdj4ZJPcJn(dtImTrw%Y^HS7_EhCW|p& zg){zj7M4G_E`zftAhwq-YD{lP8YLNcsOp=mui4? zGS~T5`kguIHrxlWd;dWmBEPU$jGhM)Z>kGEg!F&j0iSe*^e7dCF#{#o z`pwPHwLgw%?{*nOx(Kz41+IT4wONl7vImzP||uey9mgUgQy% zFK=qrt1KwWGn5zb4|qhpQ}TZR$2k52IL67y%=X`bW324#|0TEZiU(R*Sv*zcN!KmN z{E!-pb%AA$3QB0hTQq`*3Yds1Tmll3$QJ5UM zxde_#5E+&b&Qdw!IaefpXH?IVIYMGKV9UvMCUYarh~GUFoK7bLX)~4*!=rBFz z-4fWX88DA-QhI!I=><6OyDwWcenUq>c;^3L?45%{d6%xy*tTt(JKC{r+qSb~+qP|I z$F^)!YL?m2bpt(uyung3>HJ=6VkuU;#v|4G~1^`_9&ED_M)a2WCsdgWC{ ztPJz_kBa~e9%u_#Xzmq|Tf7?=5@C1RW*=gKkMNx;;8Bek(};A)ZH51b6Q8RK^SGNF$voNjN9)QCz6@iCjziRnHYhs5s7wbE160AG!nd)9y~y|u=77g z8@H2zA#wiiaIn*xyKRiWR0W|!-l@#;?6g5>!PaJv%u*HCqro&zru&&K*3jt`UHq?Ik)R;{mOL(&#>Q zZ3&u|UeEl7j!tGHvH3eV~s5{bS z;-^e$AK~4xc`@`PtKNFPSbh-42^o>6Q$eAThCBQ)jkm+VBf18CQ7Q(=2UG`Q)RIC; z1@od87db{{MlnYffw==P!7^aVVb5S~Fp2v0g8_ppU|g}XnZ0=RM*P}<prG6^IpBe_y`Ub)QxI|BtVf^OEl~t{MJP!7o`=Q6UKU^$#+6Xe2_xz}mjR z1bZ?lXv|$~S{F?gws4Kcic`8yAx%L*1aL)$ z=W3U3od=94;c9>oZLLJ)Lx-TCHZ=pvgor^V%fif)JquHC(V%+-Noa2Fw5F9luTY_h zXed*3RdjS!qJC5dZn*2)ABhmi%pcqFmWQdMU&oNgyx!dN%Z~jIr9$v2H^RrRQLy;z zS|$^R<{4QMdzD_Kcbnf3UAWih0pQ+Qo_s&5&zPj|AsTqe>vT{z6K3g;B{#Uo!5VPQ zI3SUThBDF?L9Y-EgiY=448@BNkSR25!Z31DG!xRa#x6pfvkOU*Q)3eo# z5guD@(mYqc2AuhtcbVZaOgQw$oKwRIi_wmRh#VP}Wk@)*{+0uH)tv%!m0gl$J?zl& zuKPge2xBNC@@?T2ngF>G#SNfwAdOmEYYUS>A!}}BC-17cdJu%+*$vk=Yp)Y4e+YC^$bRe1q|rwOcbIi`o?WR6GudGQLkw7Nb&IDIgBIJvFevqwVS29Z z_N={VsY{qCVTjxH#DaCnm;NhmwXC$IOl}C8Y){#g9zrfeOTWJ>j&>5pH?abtPK5aY zeFk7b)X>b_Kt?uH3}vc_Y)FJ2&9nbwR<1K;L z4hGuR4*Hhh;1R|)1O0%M?WbK!oh>J@JPXqvSKZI~0-PD_=vM<5=%%}43GlmF0Xc3? zrS~9k`!AjoA6&biHp}s%SG{l4b_E*iyU4JNUkYFiF-B*Q(y6~znhx`15{0&(efDXK zT4chgkMtemFD1-<&BX2#ue~`(dG^}bK8zN$CJpTvT(X*NMpMDw1#?|)&h2-qyMy$W zYk)_s*g$GId{{wPK*SpYpf(4H22i$Osj`j}ZVXd7B^h6z=7+Pnm4=84IJ^yYm1I?& zRtufVxYD0NCb_pMw_idl%26H(Xn$c}FjV4}RO6P}@0u>eZZmCUR7c6MUABR5# z0o5N?0>eJKQ&8h9<4gL(SDPd3n$@h>o<=vA5W3URu;7c~ulaIgsw(NYxN%D> zIm|4_e~HDIzzDN3WbP1zGC(ksr=gkw#dE>;E^+w+2H0CGLIKHSXXWZVUee2ImK{MR z>pS)stfk9qW!6!Wi#`m>YjRo_@C{bxPJiD9y6hNfg)Z-Z$gd@dK3{OYt(Ls6q9@4V zjRc-nsHI{e#2wkcH$K-O09Pq2Al*?38o{S$Kz@KirBe$=`z?hauH*F#JqWZUv)v3# zd^D+vX}QckRc7X2KKvHhO^}?Fg<_WAEq+*{U3;|W_ZWM7Xk18iQs^(rgS$}zkH!i^6Cwr+j4!MR4`7VC9GV3~#k69%ecz$@I41AS$+U$jB#5x;~x zl%(8>v_yp>dPk{c{Cwii#t(g11k>0M1+yVq428B3lAnge91nzmK9R*GL|kq*pnoyA z^hH_e$2jstYXob?ghu6KY~gRFUR%4PR=cKp&dO(1Z)oDR(>sVrz8Bpr69JKBqq@Bk z3O$Z?3`YL| zo_hu<-*+N5!-cLYc~W=Q&DgZFEv8#XL~J-1{y?i~%^cvW+g8tQmN5CJ4{j~|v$kwp z;u%s=f#*j`6iz3EoVAB&E>gRLiS24l_?k@W*4^Rb1zER-@~~+qCwivesjE%+sW$J0 zY0IB|w`VpQ|BomGginrM(M|;eIqF7fVnSRiTBt$}Pe1HrMdP#zTZEBD_WJi2p6RU8 z(@{6njS6r!pb!c*8o1Nh@T1m>#5+ZqQn)G~m)xEFV?j=fu?dPIpVz}v#Kc6ttQoks zxVLyfGb^jFCS6!j0y2ALbQ#yX@s;S2U^*3621o^zxc> zQpAb0Mo7s{Tm%*H#{%J_>C{5VRaOxWGg!)wP$yJMH0J!lAuQYn(Ig>b4O+J>@ZVTmughbIfHb#Y+vpj zLnZh`pxv>PrtEr^>8_eAS*XVAIOO!E3F_DKrb`Py@Qyt}z^z*N&I`PhTv&3+#ttmZ z6RD;p0WnwG0nld+Q6M8gy5(q#)MrY-#?wjv_{9s=o;KLltEC5DP4)@_D}-CqIRrthZGm(T=26CP%OCbi#qRfG$H- z)_KTGY~tdQVOu!8OQGe9#`m>OmbJ0idspelv80~*?!XxeN}W{goA4k)lNyPW&ELl^ zZiAEFxKvd9#q;YI`7*FKgMyE#YqkD(Cznbr$A=Ab z)@S6DjY`CYK#Hc*HW}IyAkAMPeUqUty@H|9Gvzbtccn*bax8j&iOth~b<6H!OWjpR zOV&1+8k`#_&rfv8wInrPOAfP-_RGe+@%8UnX4JcWMU;y{lU)JD}NG>qDl za;j&r*={8!6rGNfgZnyeo399PmF`-6U=hu z+Cn?NZ*nX%%Wo}R_v@ACvDeZb9VdfMQF$EC|Fl|!ue{bbx9~dj9ZPKthqFA^_lG5= z*?Gu;x6AmKQGyxJUQD93W0hJ6;d{LmMCmGvhw@m8Z?`_7CC5&7GAk=PJ1Z-3s?(%A zUkQA-fG!}*k|15Q`J<|JzP~b(v6thbeJF0M31?bj&p4V+(tt-q=DP5_N{c@SzGf0` z$IsAaWjt^-dAi>^gXt$m*G#2|`UXO#JKFGr&07tbe<66z<$v>CVb3vncMn zDhX%)Ig*D98uFb#iI!pcEF`jR#LNM~;^h~dS6grv)f#I(PH>{tf19q%k-argU}5nh z7F8fIC`~%A_7)vIhcJb~0A6=OjnqeH6&Mtib0b2AGFNPx^PRQ@4-Cq1Y*WG2vY<`5 zP4IQkGxQFO@tm9c{Aq9uwl+tcP>5Yh%m-q}eo()u64706n3*-h-9}Ixpd( zPUk2kORnkx_O8<9Ipgqalzi@l@=LNcgt>%$p6ya2{#H`q%-R}j)Gc#l%FF1Wkg}Vn z0&xGsgq7wiVR47JV$}daUzSI4hHU(cyRrKkS!^S@O0(s_b~D}g+I{%b6Bk!&{jq|U z-KJ5iOGk!&z9-@6K86TSTr^3%jx;I6{Sz+ z=A~8reG}{+V7WW=iMj{Amt;TCht#nx#xP4NJ}7sg&Gq8&iM?-Bh*B>fH#IdoJ2iDi zCug^lcz;(1`d?36&fgc9tPCEPNAA#d)ExdlMW5bZi{o(3y}EErlOR{C*km2LkPGqC z4_ZI6tAsmmLdDbXbXwv4E4DpLI=jTSmC_B(e^AW<-NOOjT@9LAZ7W?!c)0bh_A}~eg*`;#ZHs@SKv5&JW;UY~O#A2>@zcH?^D5!ikQg>mO?scFb(R{++^5sOkUTGd^`Jl3=R*_ zr@^GGIezG(602s7x$_FFgSLZNx7i%2PT!!<>rb|mjwUt|-ME95e#43vaOhR|RNV}g zldm`;#gth6cO8%q?tuyTMtXH!0#ZLLY==t=6Z0k(jyb;ymn{T+cp5fG;CMmb_g4Yj zFlkOM{VyQkaiXc4Kj{WGq!|Ll>)!Vf@5eBg8*Z0i@nxJMOik;rgNy0ck_(x%{4H|< z8;UpFYs!tS5V5)gNr_PL%hqZj_3XP*w&;j{_l$MoCpbL+0-_-IuU5byoTYgL0) zF)o$zU8;AjO=3;CJA)yh{lVyoJ#s+=Qc|a43eYvAW zw4C7A#)G7m&JysEl4~vG#k!{tQWmN2y1MRFm3lI@u+zWuJM~^_{o!>FLdwH6@KZWV zG#d8hE$eP<5;)@5wT!l1GqgxNTV7?iLLF8Ttac@Y;AgQ%0NokCi4ik2{rB4HQ+8b{ zsmR;?GC$Fx@G{M7QSYDf*Ri+|N)=!;aT2b#GHQPO+ zTvKj^>D$3g^f{>3 z^0rYQDL1lK{%%N;tc2)iGWdJ-q!cK>311^7hw*+bfM+LHb@Lr~fkjN+dfrV|8_#!A z-Nrlp%DYU&g0eZQ9ns6KzyeE}E0pNJB{)8#fD*C<@L+}l>mGpWAcCArwPV4^uLih1 zp34Wh=4l2Q?hnBRmp%2n0$>ZUdP{^lPa|(fM?<`C6N!T!F!OmA12`5q7G8@M9Ey+v zlOP|E4XTs|m7r=TeYORNs$wS0Kq?SRdI^-pZk(sNCe;F;?$ThauM(=#N3ps!|K8}bInXt=KFclAl*)j`Xd4`d=p;>Q0uCE^i9@HF2m)m@RDt!2kFc0b*z8iW|ir?epMKiX9 zZ8p$-GX4GJd$;@hK287 z{td3LP?1?xT0FU)YEnd&rc( z9c0sr+q!pT7%yJ|VfKrU!98E(V(^F1uH3u-C~i3D2xU#*f?=r%W1_O8W&Ms%&QCFH zl-UxA>!L2r^Ap!1UM`HPm}RRH_(m#!xKcdkY8ZDL2^}0k@`SC7t8wg9$|>|ozXUxy zc0V{-{59z~v9+A`OqDi-5`!Ye)IechDSk*2gKDI-u~!O1OlJ7Q6Tx6cHDt7LSVFU8 zHz%ENYr~wtofymsG%3j-P(F+uJnug z`rK5C@$eUA0tE#KqANtoi)t^LJ6Z}WI7FYwEoVGVSFTYKdo4^x6I{cnBvak32YuDy zLepX)A(MS$;w)pR>;BKJ6yec!bDJZRtIzBE>dv^H<-_Z{q=sjK6sLNaJGSqY{?V zIlKXNe`zXZI>_DK!xQPM{&phWe>^NQ){(j@Z;oOZ8(yjD9+fAmjtpZaNTu@0ACy-c zeF31k(I4$cNOT3EACu^sN>v-u=tY+NP2#h3eSTiyj$zvl2)cEZEXy_h>lIv5S$tK8 zPW}aUk3K^sNxjrzW&R{9@Qv{llhk%bm6k9$umvKCZ z7!Tx|!a1j`{dS`uik5!)0F>@4Iw(s2?0$8F+^49!$LRvzM2h2pPMdftT0aw(@9#Z5 zvMc`;opX5hN=y!TQ<5{yScv00ugU?PPvk4(k?H+<%<02!i&HS_oj8JJrmtUN0%wZt z@DznsHvB3&bIlY1%)*7Phh__p?SL+qN6H2C+u8 zZT5}sNFsNi>frM%&xIy;;L-s${?n!D+tQ}ld+_tfo@NEV z=bT;B&NJV43_xa9v0H&H>snB|Q0F?jy8`0_fW!})U!W&@nZviNeq7z~c7eVG_LRTJ z5}Sdtp&YG})*Vy6*O+LN@+J8IiAYy9uOo%@Ai4q0N%hX_b)o6ffcD zz%(a#uxPTuKZq$7C1dx!HrZ*Hl$B@ggxx{=lrUmN_{B|t!~$&GQv`)Cm!zOx@!x6m)0@k51 z42lYoI@-}|*ve0#iQn2IFg?y0rPcUarTUPeR_;6%Y4j2+JL&+hSHfI|dmPR9sPsm>M)zhzT^*Qm-=9@@~-QmfpUpCue9r z(5JWTk4qTWEJbrT*IJQ-*}XVhFkwd=-omB5ie%s_2{vr~YCijX#}-cp+a;pRT0{j5 z%IBUDr%j<|VSAU(GyHLlHg}CvS)xm+J%9vQu$CAhVP30@W#OWM{Phru0n3R^dAXt({gvm1YXV;_ZXh& zBV*%gUp@6d<%#L`2O1|QzTy2i!BfM06lBg!rQ|`zXH{{8@VT@a{^|_WUT2 zz|D@H9j2qqChr~cB$GQq@i6jM(VSd1K^Xyu=;LE~|JlK(7cF#Y-Q_zTl~9;suDIUHSzXxJXgq=!hn$^l z^n-z@2vlzvTznCxZ)(98S1VoP15ZRhg&A?2SCVCVSK4{Y6|Y6W^cn&{r%W^ny=U}< z*5PwLy38b-RY*@!)yYm#rRwTc)%||U9hbIeNh_Y+a3-p~Jeg%{j58}8w#Hmpke^#O zNZT@7vcjb|*LWGHhL*KPLm$LU_qpT(r3PH+?AZ|-h3{;y&$ZF|s8n|%mo5@-L@Dmj z=nLq4c9BHA0^bIbhGxZzG$}AK=)g3oU5zA|e5cTCjBwn80&Vi0^(CYf@tNP` zUZG%psP?)J+iJ_TgFrFxLh-9Iw_5N5eTsmMGZcEhOTy&L&!EwNXZy7}G)=+m9I0&EiyQA+FbBck6_lA5LgDfdxHOqi<`k=#c2dL;j&N*rtY>ITZyVQo zqQ{F1C+KgFMfL&D=iBwlARL>p6k+9|jf3nvj(+SE+zVf(b3i5T)v0I{<>m%uM{YJ3 zKzd=qOq6Ms!O^d3(oP?StW7^F8sFf$({y>*g%3=Q%(1SQVz*<)br!en;Ts_Sw5`^OU!@%~Q!TGUf7UVBcWlCJ$N~d7!i(`XfO~56URX;b38} zt$iEZ&UMI`?(mA8PDJP9sIHah((~~!PhTP3C{I9(XiJFy6wzW9CgF}>-mf9IL`1Mx zte5a0fRiU>-$E3rBVaX|+Axg{cz_q?hjv(MW7RQEyM?YBMnABGv>Yv)+sY zEj*fu8-d@qI8sWODxJVAGf_)wU1&IawQ~L98!~RXx_r;yQA#`C+{lN0zSLRV@;X)8 z(mfy*q?yXDB_LK|Qp@qaXgj7sGq{CDC#O-v;T>ITuDBFS9cux?nz*;TRi8Pzr(>Nq z?;heV6JV)Un}7e*D|x|Dwu^9UP<=h>9@ygZ={wZXD4#Q1WJ*;mLabb-;9tl%9a@t> z96ddSFSSQ(ZfSj#ZA{2fuJ6p==ACGPZN$@3b+c$yi0|MB-Zt!_t?Dxs(;-Pr%L3NvAEft#OC(CQyxT(crRnZN(RIV(VG zfX%7nuIxgkODPJ;{h`CF?ldQX>Kiwbj1TTZVBtMtSyy3BjR%Njb!1EL;c?&6#Hl_@ z*!Kr!q`QE_c04d$n6eO*~(Xlf=ZGTV1Hkusw^7v8!-BP2xt3F_D6_h(m?lZ z=&rO5KSSzgb}w3;GMqNRnko(5*@Th$_nJQJmVSw6x>1&S=F5LABi}Z zxxhM!Z4j!%lSQmm*!DpVQy?GEm-fc4_0!6w(o&Q;yK)>_$SM?$9;Yd%`$EBlMyv;F zmaDqhWw7&@Vg`RcfA3>yEAv((yf?M?m^;U$HVe||a9ym`>oza_`IGOAHO);--=Eel zG%x~{D)SpAfPYl}OvMx7h?$^mNTLO7ydN(nf9Zm1Zcof+j1OrcSu>b&efs$_(;GVv z{?hh;NJ$z0JtbvgV)?JeV~i~SG#;x`c9&8T#rQtzYUv0s)i!pm0S}Rs*3?n)hftEDx>fvyf|Km5vA|mY2+EWn5X(u&iSXmq4A5QbDo~ z6Cj~z`0N6w7L!`%=q*&t4?bM1$(UO_AciG2 z&bOS0@9=U^@<+2qki?{rbUnUUW!uZI$@3)69N}kb_3?&$J*1G+;Vw7}Eu%LFEXQbC zsxb^->|C!0%qy`!`Iv+_$j}?{B^M@x*nJ~riBQM_vL40M#3i~VXb*9lt|mdNB6jA+ zNA&X8QdESeFicLkgSFw#*}WUN#g;|blSd(|9QOhNskEh6W`F0efN`#wMA8|GEO3TQ zoK$Yw-@0i7Im0#uoqeK+TjdP=4Ffd5>-eb=CzMwi9zBl;L1}f;5Pux zy8C+BKDehqMKj+ka{BB^a-?UKc|=^n-O?hk+SN1w#EK@6ZbZL$({e5!4Pc%L1=8DcuT{g~r z$hAfCLBeQ4p4=G7tc?l>X-@&*RErV^KTJeFw^sTxVAL#kL~$-tu>ZW>$_0dK%MdX zqnFW>8rfY_6W-SJUhO12oxT2}#gwJpFdYj!!-vE9DSW2e36jY3MVBUrwDz|OGR8_; z=2Xr2cP}GB6ENzY8uJIWNS3%|qRRuz1**=OdNws%1yhG>MIZJ`#!JNEFIOseEHkhN zZ*w3pdw|W3(c&EhOQD0xT87B340<0$Wfc|DXUaiMwv7q)m-_B6f`;1&3j86_O=wvHIEgstD_!3+T)wcXc2bT6cwe!WR=!H zV96AQ?+(1;L(mV!>|s?fVe)??S+%8kO08J!vO)RLjs!ZoJ6BaNndRl-* zYVSr4{vZ02a<1*uti_xn`DBTdg95L0;bWqNd18R3Art8w%7n>AGV>^q1ul!f8J?2^ z1-3UaX+jhZ3+&urBH-s~?om9hE<%;Z8MWqaJR^C(3Gxt$+1o6T-02O1XxoffU^T3c z0_r5eO#L^3+vwkuz$va5ZTK#N6;q;WX?5%i(dp<)4>n7ci7c4HJ|TElOlhwi@)t!hM&)<9^Xy?d9&k&;dhYV z&3BFShdU0QzsgRHlunnwr@mkXg!dKxLl9v44?%#F@xRkW+1UOuO0CRjJI{~uHC4A? zC?ssbrbGxY?371qu3&K@2PvoLC+5e+z%PwO?uRRC9=ZlwhRX$nB#3VE1&9m`Q6}&= zc!AF^6@%X&PRQc*%4#q937p}DyX&Rvy6gFMYIaiqU}Tq3A+=^Blg6p(jg6o>R=e^| z2AWt`2GAqs)@@VMIWJb{IL2%tv*_dEobwIeoEa9$g!%ptW!9})|8?a->Ol9;-V zoO7SgCwH4^cf(wtryZCBdG@b#m;Hcd1Vrg6kD=k)T(??Pq*hhLVl zOW?ycTXO6wl$-&;aBan?cl0K0Ex01ePD9Mpo@6XZ0`uNehy#2cnQxyxHCu~wK+gmc ziG;(?%oZYBU+|1&yv)t1R&_Auc+9Wr!1A+&!9L?Vcjk~&y)dYI=TtS)3%0)%^|j2q zvwNET2I!fHFG?P-039(mNV(A--(jq!F_SF>AbIl2iFiJ0Aq&ED1vaIlrX>VE(t^S=X;gPHxm0+ErO^PfZg z>fUxri>Q3Z-c|+#Wb~^fJ_APV&|z@tm8(|%kw8lbyrdBJ(p3~0v83XwB(;zu0|=EW zC6V)U`XG?GapI_AiSZ^>RK|(PXqQGT2IUZ?xFRKr0w4V*G_~xXo6l^F0$I&6=Th9) zA4i*>(_P2sJ{K3<*GS0nMAigJXh})R8(ZqxtYIUE!nQ{jbLz%v*v#zyx&YnXUg84! zEAw|mh^qeZN+AK#>VGa+g}|IjErl88gzN#@Ncijr$DH%rH$!@LUm4<>^P0(8y(GTN za`Y}pviCHcNNhqyGqQHb7lSjdYigugfj;QN;?q^xRKUN}KSrTe_#{wV8E`s##CfD6 z7#OYX2{dAh$s8WPi$b!9WoC#5) z)2aL7tRzdQM)>g_*!^b9MyZGJu-CVTjVnm0E(Y=*X=DjrP^JzWcJA_zSw40BgyuR&5f=pm{uBZnV|hh}aWxXGn6uQjGq1 z@sa&|T5uTwT%UlZrtU?sg-(BG)ztKUTGDaYPZAx@ z{8v7EOh%8&>N(WUhu(@@k!;oo(}JnX-Ujz zvt4_ZSM;~)qQb3zC=fl3vgFgR$A#?8tgMd1iL<(8<(pV`ml|fK#s`*?qp8tJ<~c*S zyV~F{qBa)|kptQ1pz>pIQ@b<5+aQi@x8}5fs&Sd5&ebM!7A^*Qz_zqKMh@vUHMz7$#edRsV z`D;#sx9zR%^$I>pIQWFr&RV0?r=CgjRJZmh{LN-fh7E@YBRUnWz9-2;gun;3l{)3DQJ?KpABKs$W%wQa zowG&!90wQgtirMsWI^1ngJgK=?nL$`nfJA=hVZ$ucT!Ss$32&5Ytw`dn$c#nOlV8< zJO+ztr|rzv{8e}KzrT*%XYoCHrfs!P0!?r>X_>L5r`9cxxaubp;ifF8QKpUSnkDFT zFf1(%*SNHMVe6U$>R%AsVcTSos|psCM5$xnS%~4=(bQ+0OQA=hUuMev10%Y@(@es5 z)TLQRlKPx@JdPEB5AJ8%QPbun79vEgg{7*&oMD}oa<$+_1ATifZ%db<+%$l7A7{+{ z8Y77zvV5lvDK#Evpyi-RjFQn=suokR1RO=^)Xe!;LDHyJssb{h-=c2tScd7E8U`AX z1cahub-!Q>s%LNgFqJ`?v7}{#LMKH@_a8(WZ7>$=nKWl^I_|DMEXCtm_Bm|NMx-CW zP5CS^JyUF!9E|o#MUV=Oo2>vMF(l-Z0j2H%iyi#x(j(-5rUor?)jfbx@paehrK+%>&5Vot!5P*z1B?!~ATmqjQVG8bJ3^$CdFE5c7&-{h) z!Vy2d`;K>|Kc+vnR~xt*_KHa94E4d7&%ADV<}^XPJ7-{%h+r8gQh3F$@v(3@KN{cf zJ~(&3u${c6aN%3t@lM0vJ9TUi`Y2oV&QZ(`xmEu4>C;x}<(^Z__nJN|JaXiOMEQI! zp!@kSsoSuUWIcV{I23Yt+j;fcvbStF_8VOV1=*Hzn;ifCVt9E2=kSd?%&_N6MjCJ4 z_qP9!bzY3E&sOrejW%6TQ%TfCcdTU|UbtK!oE-G&Z`uWcbA8fcC~-xx@hGpJ!+C`y zFS4Xm);2k(Sfb)Qp~?UjMo3TtJc~S#%RFEqUkQ21QF2%#Lh0+|Em%kMb6&1|rm~X} zu_w+a%=RFbLXL#7LYRT4iAY5*8FTp9fL{xBF{74bwk)q6Dhj5eS+tC*24oDf+Q;fF z^(ss$f3{{uzDKcjme#E zy2;3A?u}O4Wl^_F>dM67q7Vd7(UzY~fP_&R6jKfTx{Vlp&}##PN>POe>#Vr3(W5Ju z!Mpw$-8@;!H;FjMskR*5S{%Dk&rf#O3T+-UzwqDTb&CRRx@xnWOZm7%4aG12c^{hXEzny!^X5G=;(-RmQnfo6Q z{x3ss{{`V3j12#}Z_!R3x4;%a5Pp6OSLcE*@QDHajgT3I=8iy$KT~55GAkB&5cI{% z>^&wE+8ZqCy1c)o$*RM5&38GxU#01rG4(2EyJ?3ru2gfpOu0)h#V(X{Sd1m~DLeS1fmZP8*gjrVNK%eQqEbDz<}?z2JP#0+a0K6!8sV^;|0R(Cw5S_d;wznB(teIkrRVNXWU>vjaC0X0t2Fs=##6Kkx zF4?vEu>=Uy>s;W`?lNpuQZ&gG+rY%YzY#3YGoM&c6jJs?g4Y64mLMM_zZK$4K$ZZ0 z%?~IE6QSI#9oFf*j8u$Q4XTwculOj7G~Ddmv)%bY^%Pbg$;zVr14) zy^-89VXczcU2C?}k-VH@nIkdt{Lls#FD5{V0{kS>rcUND247U28)uw1mmv{#JSVlf zIw2`C(>l$NBCO?!#$vPx=o4*skCWLEKOsg*DSGEU_f=5D%_ar`-eNWL)D(9Hf|o|f zYFI5%wg3}0+KYPDL;>j|ZF3y+fjGM*aJRaf{56wgSgSr?XmxY2i7~%ftz1q~;6Gq6 zD$a5B1cQPSJ2Var;6=d>4Xv}-h`87dbo&St3PVvt>PutDMdUdpQ{9Vc>mmIZR;VFT zD0ygDYO&)&E>J|k9u1ZzopUl zHim!D7qk=qzlcKre%;OYTTTt+~&q=D>xgQ|C;qvNew2ru?5$h>wlS>g!!=&8bIru%_d$ z=8o&M&YJN8?>7uv@3)zYZaPNV{f9N)Pp>v3FZYb&?bGz)Ce+E(g&~#${+!Q`L0!cT zYMbff)K`1&U#2;}Xnc5%7RtRyjJs&_-cN+IJ|5DO@(0spJaDg$7IP@y`_E7j6Ne#Ni;Iu|R|V@r3<;_^Yg|34Z6d z{R)PdtQ3E07Z&3wf+_U@ql4%S17=5rm=ul zp((3VG$X~wre^V=Ne-td7C8;=gCXv)rt$0rE5l0QTr4xEY7P#UtCFiK8&QbMS7bFY z$gxruA&03UsAW#i3cHBZH$}?OAtxqAHqIF2ck!s2?bBTQl9=A1dDWU6#{S?2KXY6# zEz2ta6LiJJUT9!0@3z$WlZOmv++FentRVrLA8_uTgZESTNBP{yKLA#ypLHp3j(GC{ z?_aM!KvtWwY4cuR#4dU8NjqNebhqB-Q$W(Z`VRp87h9bF0#H^K&VK}i{{iSv%F|o# zM8k;yWZXWQkDmSn=oQBtJj53L|K>dWK=?lhjhB)6a`|$=3sFAgOD^jQRDSGVq21-> zNoFf0(+Xjvjywx?kw)=d*W@vMP4;r%TDIek`Ao!(q55tMdvcj zid22Pd%vu|J!G8r?W2vXm|Hz9Y@e|W>R!5Uc|%jeo7b7%EQUY7cOHL5zdh#!Hzq<) zH7}vtIBtiP1%SiLMii?5X&V}I3;%f z6Z#;OOlm}gN)~@QOOP^xR2H@%ZFd%?-mWS3CA*lmJAvEX2;DnILnJEs9J5mrJ ziwmRyycio}WKYJ?aFBG6w2^xZ2AeK4i9RV}!e{b}mKT;+TF@xSa)hL>q_?EFIE=L_ z#uQys0Pb!Eu$;IJFMQJ)wIc$9$g&aaEEfQi;t^U7US6^E6tt>Px093+6w4?tT#RsT zfAEefnVrghs6wnajH=Tvr#Zj45RO)vs}Zb9n0gim%M@YiFN_4C*+yVt27iNDc6xF; zDl6$Ci9%{G(N?Yu`!)5Q8{ase-<1ZlX)q`A$1q16n^YEqb1CJrSf z9Ebk)F67OE9AE%W@z~I8(P;AI_T_^AjKA)lG<1ZN+%7b$ z&3ox-Q&w!&jG&)>N3uE2!*1AT-1gQDcsBMqqQ_sagTt@iTyBGPQO`se(HJo;#c2f?J0McRP8B74{9iZRmYY$Cvu=8q933#z-t8OWIEkD zB?<|(60am^0G%D2AB3!n&IVLMX3j;36G+RSpeO~1qcoBL_e74%A0(wEy@1jM?*BtK zkvp-Ycf-KZ?$%D>g~l6#j7Ls$L3)7(E{TKkLgR%-ORAAb7flDI3!)2R%+@BECC%LE zR2I4$FHM&xJWEu%bU^L}l`}U#JI_$XRHbh?HU#r?Evz0^?=MBDxY}WSot#a|6C@>B zypa1D+_@Ty0(K0V#n#2l^qGyJL8F0l<*}iR)5uY3HD6@C7e7oYIUS!ZF@cEuWV)K; zul2iAW!SPw>O8gCPj(JwfMsw8`31^_K>1MlV6TPeJ@tuf$U3m$_ni>pC2PuXB9}z=!Wne7Sn$i!8 z`0QJwt7Xo)1@%j{c>rJ8QO;mgmzWc6;gi32b@si)u zb3G$re(3An{fX@Mo8;3udxno7&b^Dq7>BVY^r)cbq6Sf7v%d=z8zX7H#<#V#0#tOF zj2;;)&I3@=g&bJ&kwOM``m26xhxKKe85+$(IDZ)5IO)D=Q6ELWGOTl1KoVLjRI93t z^kYk||LMA4P1=UN0Ygl#@n@_eP%3p)=YHV%Q0L{c6pY> zz4xJXNo=>Kc#5n{w%cl4LeVmqv&=Ht$IV`Sa|lk>U6%lnedz+8;GdF_0^9oKv6$CKP74E2vAOM!`W#3f4Glon+fvoDqk z{1*!(=BMm2V-@~_>+zGF{oU@*%=M&9Jpe2LW+@dABogRJ(!dB-^)0tMwNf_jQ=<|4b7&V?7m`@ z{%ypWM@tpcBZ5PRM32@n>V)){gokydwO{@f5D`pXjB2l;R3oQNRKKt*zQ2AM({a7y z?YJ<>*~6kQ-`{-U$%NH@U7vON^RwF2y z=$?-L4|8u79!Iln36_|dN?byVEVjU621^!`#VpxkW@ct)W@faQnJs1}i+Sq5J>7G< z`+T?WThBv0WPBMFnL8q~R;(R+ZK__Mc#et+L38|z(dLXmLtfm6Q%swSaWjoMz(+(6 z)guna)F|p>|6{w_QDds!jpuRxOS-)PS3ZJ#O%qI@;peuCPzl2dE+idZfEY6Vm&Ik6Pa zY-q!$Oer`;*{^ZX$D#z1rg4-Dt?lllrBe-)LzsILFI?Umt|YvX6)5~!0~*O?vkypJ z&VgzF#*+UvfW^xCKXXbk|5y?|;L-V{Q`4JB`|Sd9MGjH@Fr1h-6j8RvIuy3B2_Of0#~ zW}z=AM-LX0QmV_Dw8$R$x8>;JHxy_5OrDExn7f>EXIMnatB|!5Xng692Gp?HwVo0y z&gSc;2Ta$s;z3FaLz}F?kVCVWfyY=C`{6lZ!<&XMzPB6BpEZxh1qIjq#mC#YpZcHjel z;MlQ&WQb9KX*DI3XdBWs=-l*WNls7zT>;Fr%C(Ui&$DV`aELnz>JK&sy*~2h5bn7nUfo0irMMRan@2Rn)c@K@dw`(q{g)Ie1-nC zqEO5QV#UPViOa9q6Cl@{9G+=dr1-j#V3~R&(t*2bR-(`KAdGRpZlD-p5vsV(OIA)Z$l5SL`_47Wh#ncy%mo}M@PuiCYBS5ZWm z^LIdYL2s~`^e0{+{!r5}u~@wVYf7l_%uwQjZ(I*o6PCFSjB&Ad90e%Drpe`Hl8s!E+Jj01<9+&DJR zqyuw~#Scce9R&7i{eH}r7>$eOmYBLp!3`kAtqk0-<$Vvsqr=}_j`F61W^11L#THng zK>bg*N5?4jg++wF7OnR@`8&XMF>UCozU)7tv2^fy>0-nq^~k|#ay&n$vK=T1mJl|(`e>^J6Eho=L{0E7u8dv2UUAq*KxZNS!W_K$)$xhyA6{_Ai4 zs^7xHLfS_Dr$(5D6}rEIT3x$f(6%qPwzpk#3`=AO3k?4qt3jgb4* zXW}?qobg#nRO$rAo4Kl?ix!q%i8qF^NwkF$IsGJYJLXzPgy?7%A9aG%p8h#1I(}nV z^lubwK!`T?l7(%M%|hztd(i*fz%j0d_mQ@3U_>+T@QK5m5;|TBe2sx-F)ImA zoy+(tNELacr>>IBoak>ew{UCa%~Mi%IxYdL6F=jB;ZaAK; zBUz%q>(pkTsQDx8+;m{rkaX8oz_A{+Xc9Y-zwIfj07{{Kq;BQUUWgQjuf#2~j7s0_ z;A1|a4C&Fdjbm?L?%H@EPRjL!<|F)iuk3>mPATGUVL8CdY&eq$V(;*~u3%h19G9z4 zn|cTyDMY4}>Z4EbIf+Yo&JM=#cQJ3Npiig4hDh_n2WnjOsH>z1Oo-yia$2>u-H(w% zq(>eQ`*4Y{o(E%f+((47IBFBE{w#n5h$B7+>sTme)QSoS`Pj(;>^HItFIPe#t(Dlr3MQBD1z0Lm2yE5u_6KaX*YTV0<{hd>NWT zJvj#X{)V6l1B8;UXLP)yE0%)%*}X@T0JgI3elSJSgXe=tK!3E|b?`S3c_DD9!|qDR zRXUy>*t!s?j4{Vv%ID3>{u`|-k=mTu&XG(dU?D&q`B5sK2AC1a5_=U-t-uT{3rDjE zPU_sElzr7>J_2`Pr-8vXoly`r|rAKc%@T#XA+kE!; z_EgU9OuKE1Om-aH5E2k&Y1z8*4o*0pZLkjOCR$PcAyRumOh&anq}RT_&sC5n-sIf42|WRH?|+KT=3SATX^-EoVHECK32en9$AbSGwep3W`@ zcO=O^^+~6}_kb!G4clcg5h@B4yVoCfsZL1mw^7QavB-cHZA94)T>@Xx==B{O9b2t7 zpLS-2rFuhIVyP{d;T`cmTtnB$SXv?=e54EiF5!16u_~uXVsU`m0e;^;?SCXv!qN>< zQ?Wz%1hVJ)36*@~nZRQ?rc9<*2DkQYT9BH+tNq$xn(Qe_BWuHK=19AS1CpSQ$6!+v zr^;pT>Diix0W@h@Z`yuqo`S9Bs})~l{UMBL4(TUUjs!4kyWaRVcm;aPzB*d}mDQ?t z1ZTD?MJ_7{{vooxx~p>@J|=;URide@oUVt+GtH^cbebEoCU!4FV=jroKd$d&r-8xl zxYAck_`%4r+)ew-eg}zfp?q!!rpR{MCoup!jS@7ipKZQmIKsTOV6GePBqvYZiCZeyLid}5w!Y(U`(7XiFJ`FGtZ#JSCslaF7R_EEyau*4w2ltSuptdfRcXa{P;Jx^Z&3r`cJXwe-S5w z;o*M~wMy9OyZ+Z;>wiB9!1=GdPsaJ`ge4&hdXPSK*F)mNDi0_C9k;9E)QvmFen`Lb;_u0y zo>R{(s^Bl{JBvaZL(d1ey{;jIQV=^5iheG!${-!eFCl1BQ2oILFgrrC(AOYy|1Bve z!7EC4kXf8II{OGee9|5SxM0A??U8{C3w+cb0=VGAhwagV3padF0Ti^zER-MU7*Z(a z)^|DH!EZVK)=xd&!CyW8)_*w;*=IQ}!bd$0*;hR-!go0q*>5>E!cRRG*rfI?BOU^YQ0hPvQ}cb89f8(SCcZ{>m=dAXQ6{cNXBZoS)=4I| z#$*^8q0~txrp78HB_M&PpR$6OoRaJgl?>rDI7D(dM?|z2mkbh<^%y;-Ms}DP0pAfh zzD8vjgplfp99N?~jE;csgdAIAJPbfcbwZA*v6SRQz;|Ys^5U2@;LNlcQ_OxVi9RwS z#*AWGP!OR^b8e9Qi2;xdFTf4;iGr1)f`l-SQ6|TjRfta@2gRfnV29>Jg%zOt`YKZf zN(MM4P`1LO7f_Cfk=NvdMnrQ!FjCZ*xy0O3bdazkQi`$g7sHa67+jL#aUr5E64G{f zwg4%q3-Xy<3bGYZEkVpUS;{ttfPE)?MAxi-2GFE?WaE|)mdhg-cEVb3nzcWr9lH4FY+1m5?3Vy9@|(d9q>jJV;+ z)60MCZvTP?xgz>+PP}mcONOZbbglgtSJ3~ZecwL}rC3FHyGahzOIX_{bT4IOpPA|vF~hUY_S(7)?xk--jT{c z$5>rvIluTLvRzdB(s@#K(L{J9N28b{PHQmW#h>F6mH|Bih6R7X zFD1f2NJd)e9*^5yOV-bF%pqdXfu1uO000C4YygM%n^YBm z-#KSQXBSVj_eWtX74y4AHBi#RDq@vYVAHts5SzY!Jdna@r2a~ zllNer7yr7iRO9;ermYJ~bB2MxrdRqgu zKP8mt0~uO{syzNmY8#|!MhdMEBZVQ3cXhe_boS9$G>HlI5Cyf$jO&kmo;xuI{KjWSC9EW9z+R;gf4EH49mtoq>^u8~=iNn4pxQEB z$k^9&p(AZSt4wK9(M5wj71@Y2288AG;kLMAKdhbgv;e=^HDS|Cnd4BN;ax*wJ&}oB z%j85?laoQJXQtOd&KXBD!SyPK?_OwZ8B=|Ab*6e@lkc%xPgu>3mjA@Vj00ZeiGt^i z=fna8pdzcuEzFK$&-`wl@(~IE;HPN^TT`l09;9!8>hxfNWIS-Gb+@<=o;L^W(9p&N z-e6TEfNAeu&fEL`@xt(qs+^{`VXR5$s%r1XkGTHn&ulNx&xB(q=34&IQ^6_EI##KJ zF$k9NA~@^Ag`w-)@zko+MqCc z*9h=`1l1bc-zl!5SBzVnukRMkbJ*}q!F{WeFWVW#w}}U^D5?h7e`&()Df8j3c-3F5 zfOlD|$VzJ|+5x|I);)b=B-ozUoxQWEhcqKPG%}tz&;vm#HT}h(Yun3%H;vMdEw%v zC1JL24Dl98vVyTG*J~t+K0BZKm>~SAZg+NC^^sSQJ!+UsNWhS=7v}5ggT!~shM&4w z+I()`G^mXw1Y?12Rv$-|$EJbu2IKkzQ{v+47|Ui0VcE2~*AI8$OHA!JFE1ZI9&b|8 zgbfjA-e*G#v{Q0WBHi>+cfam-*EzEkoIaCy1_}NnO#8oFs3n!BY#_cUi;Cb5L`~ z8L&%=PnssX&70S)dM0?I!^=7Dbv4|jWe@q*aP{L8Hp|5M;CyL9`k=m0 z>|5fNW{B&Z2bpS5v9Fl1*1l^gu}8R7{f%i)_U4*ZzzQu|;V8T^NgFLQP1GT$E#G!0M^(*9RKy@zzM25aKaOZn@*?0tCU%8*x6yP zO5Q$=xeWOiR$r2jq;u3JWQBKq7K`5^zbs)NUVJAUIUy9_0BEbMjd z>~Zj(XUlZ%ju;9@AUG^mI8Zb$M_}eR zz5JDThQl5rpBX`Dmm*U;x*$E0bq+LlG%`qtM_-%a_9ibO5o?lzWG>h#Xx~PQs>1;d=BpSoRYOeu#L)-!o_5lJ813@~&UY z&b;7J?#WI_fx|1piDCm0PV5(lUtjrt|G)o%wIJ$zb@X@z(GXQCgOWG+@k!jjI$vbE zXUscYODJ0UHqz5kpc;JbFDj}A&9(jKCfek19jK;?V!Q$J3kD-F1pG!Ab|dNM6) zH`L~Y^q{2G39ji&g>BQrkw)c4!rY1SVyBgpG|P9f%z>hq%Z^##w;3t^MDSV(Eq}Co ze20=16(S~-*pvB6miWpIW9jrs2ps!%DQr*9YQ6(l`N{GXH$jPSQ%Zaxu4c>JWsj)1 z(Lc1E=wzh*5Rn%FXepFXmb%Qbq$2ONCNOmy5c(n)+&>`&6zEC|IH#wN#5;gCzqN->Sqy*n|N3QZ@ z4uKtrq}`lPl^tfn(H4C!VLc%y&`O4A-w$ICR3&$ZoQwUleHr;|1QYU{mHCOIBiu#} z9HTUJ*O&RWpM(#bji$09)p&hfv3Y)w+K>Bq^r(A@$SsNy_JR~eZc$sa-+4av2aM~N z7gH~hf3gnYtpI-~LKE>DkiP3Ncd!`wBF3B&A!SWL)?h!5>(5mnf3w=Uj#!u@GM#>7 z@pHtGkA0ZLYJa3%Ue>CL?BZ?ICKe5(zl57ZNd84Jl+wZVM{XJ~f`N094Wf$|g|Ptu z@Hjaog6e|BzfY{sU@?j)hn6pqkp;i&CVh9308lpz7D(8>Y>@Fvb>k zeaQCL9wjs1hS@^F-8saTo^y38RhggyfrrjojIkX603L*uwV!%0;a0VZwY!I@GA2`- z<<@fc6!U2^xf8zW%te9ApKsM6^snN%ex|822Mta92^C9FX#V`5_kdG6tv9$PsWIZ* z;J?l%Q}9~hb5dbUASFHmZxunN$u|)jKJ8IM8MN`*ieS4Itk6^I>G>9!$VDIH@)#FQ z7{7MC*X2F)OL0Gl8OYB}SgpZQYnBe8j}g~eIcQINOi)}^=k7~_72Zh#W2UrKvc7Lz zPO{f)Em_sfJ_Q=S36SsoikPN)ckHP@;&=Y6ewTMZb-o{JQ-(3o)#a%bB|#qYu^RZE zfg{O;X`iohs%kORqVS7l7Y5dt^L^GwUbm@mG7KGiszmEO#zJgGqO-X z=0LdAGT{%bswX!Q7l7!jYt;EmqegGf1&NNi3Tl0IvcTFr(}6Hc!#oMKL>q>b0sEWI zQzDxl(SN{Ob>AbKEq|&*J!huPg}yoans(0b-Q~TA_RDr-w;V%6P`4QUE2{AR8UbNo z7&CD${*;LTP?f89`%_aeln)2ZcOVZXxTNqOP(4&iYFqfBSjFxRdts?kEf^tI77+~J z#;on1)l*TvS;@Eo03)KGvB4*6hXWD-KIqTvXC5FE0xjC4^rVgS-+~O^H2Exz@Hdk( z-^20=T=QK3F#eLON5y+ToXPgPlzL^T2K}>1(E8$j;jICY2mB;|KCP>*vt2S58JQ^V z40(iLpx?H@9yRlQ>5(s(&-#hJZ8~7D-JF_@mO@Vjc@u&4Aak@m`JVr=zq}lVgaZcv z6iLhuOH+?de|z{!Mg*Aw!K}yldVan}gE1@$=;orOQG^juJe9>ccps&Ab>D9|BL-v;hjefCdl=_^^zy(#+GcZTrxU*YIj)lqQ zs|jJg8mSN^@O7lNb|V_03ZmsV;OoZ4rN)|RkuL-0?$GV+#KF|M%H67Owy4 zlxfC!I$zx?2f7a@{)6u?JYUm{XEMjI;0%z3Vqu6@Nz-gWq#Y#Hrx-qpB{zQGBdm}M zHjyGp*=J}OvNMD0U++iPD{R(;G35h0eUJfy=m3`li6`N`a(vfBc}Od006>{E*Szhg zEqbMOLD*4y*|WTCZz>?~u5wZ{;d0ClaU4i{Zz&)dMQ`hk?M4Grw}up`zFsR-1QQjrp>=$xY4r)XlhOx3f&`&T z&AnHc*kOzc6`<1l-jaqCFoVE~d-!CX=8|Ux;CCg~aZs~cLE5Q&C z%J!=oeP*#61HvI!h-@4MDx_a5(RvesM4ch`67M{}1Y|!M?b=W^u&3wQToa(q2_b

Fd*=zXk=MC>;<)UBooqcF_tRltRA{hxq*`jwsl+CMOF9 zKx9vZkFI))@Ef0$(_he{$FxVv_hZ&9(_W* zrccW^75Qx$@_)~By@!Yrjn|O9egTUSX+9W0K?|vKaLem)TM96RtR=Kl3pPYB7X1+JmOA@%if4 z{A$DK=Dfjz6Jj4^xT5LvO%b$!!4J}#p1yu6LIj*RU0^cR&ez*CHywK?eLOQ+-tdJv^ z@bJ?4?Rb4RLnWnw=I9gA_CCiUdixv4!>7oNz{%FO$752-nj|k$S7uk}>wS#=WC`?y z$pSP~#nju-V(&jQg@d#vP7*LbxW%2cZ^(JKBNcX{Mim1#bp(3kV>xsDU(U!&?XqS$JMMv{T&sInJl;`9ws<$-474BpoS1; zBEUfBs7|BaYe00kofHMh!u$E|wMmg*<<-+2V~+`r8*#ACG_jqR=3V)P6&ln;Xpdc7;`j}Wi=w|(GRHPUBHXfrfS$7&a`Ey__9-`MGGaG-ar-^ zmptFKsfj+1LpD6YiyDYWDj8^I481p5U~x`4>uc((T)_HkO%?{Dv~wogQ!RnTeTf>X z4sC7AZGD5F_Jcs{JDsGGqIs$T|L2rdM&Iqc51vhIz!@vUi>NK)h`%B~eyj}{@zQTi zlL7MC1nS)|gUeXZ;SAnMRVl!fjsm--V%c={eD1?AwN;{HxPG{5?sJ z^xG9C>eXL{tH0IJ0DyyFt}7N0s^f7#PMinN(yhhlO=aipl3zmRtf~?T^DR>1W|HXu8~>3iVfy zQsvUs8?<+!JfBn@BMbFYkLu#GD>c}=k^;j+Zh+m8NLzzh?A`tX+QCpM zr3kF}BH$-#*<7Mi2>8LSzDj8gGe6$pITIQhxZeRrVTW$IoA8!&BP&Du2Sn*A3ZH7Lth^ z3y;@y<;g3J0LJ8h(6-fnGI?y2^*<&nKk|s!xn|EXWe5$e88?TF^yBvECxIU!^>kST zq8eI+aI1Uk&>{(QP@{bkq3(m$u(3Fn`ohw8z=wj3hrz&Cp(^($$kSDwP}{ z*|{hi+h5>9l7U>{q7#D{y7Dk*8W?yW{=r@<`IY4k#cARLNtJ7!bNLjtnfirxM8$Z- zt$Q4IoK>iL=+y%-eXQRx!{zMQ>xuxFT;0C@Z6X$ol}W6~r(3CaPi!Sy_9LFkoF%AV zT&y^lo74p9K$Dz1M?&or(PuMW#B>$9xHwMDZ-^{$wRv;-Iq2cIhWfeCoq>J&L+_o- z-FPIScLIZH$q9@WMNk_KC4Gbvt1}u8&PxYE4*~z~y zS1c=J!t=%1Q(RZbP|5k=)Wh)ns;%c(s0a}Fx&*3%8Y$*hl#Y1pIliT#mr$rxrjUZ? zDN_I^SylaRWsz!a#(I+wCIcWdkuMt7tA+zsPo1*6>w=u9@L6^=XleS3MhjLkg>06u zrSXaUx@Vf-l#;R-pv;w1B~=~Vl+S-R5g!jA0`d_^i3LaKggA+r zdF5|Uu`ntEQiMKy!e558r&8-;x;YVuWMV6TvL(-GYia3^fVjXqJMa}vzWK}QOOpq0 ze#)5PBb=7bX5p5+=|>JzM=FO1MRp95U^Rsi7A1%p+fT;Kq_6~Nygv&seXA_p+bW{K zVv<@@6fm@YoSEb4woNK`uvhQ>45!tZh5PY)KPB=7&J$<>mE+JXIPkD18d_>ygEZ-k zgJY;vIDF{nxHCVZzm)0i^oyNr39xH1J|!1Imyf{n%e4i;wD9nmGwYorO~*4$>#8#1 z+xTO+g29gZ@rkG*)cqxneoU}}e2Cw`4xef;A2sL4*KUvEV_L8^uhI^QsOSMnG@HBD zF){t{ss`lhwHgZTNJG&G?eqjg(J0P}$`N@6J*?Q@tWbd@Tk5SI=SIAD15la=gnSn! z_a7$_BilFZ*O(OQS^;%CHHN1L(Ee1mWQ<)k-b|c$N0~X9F9C^;@<(5Q0=)y0dkz{l z!hVjbG~2fMc5GQL3>p=H8jac_=wH&)3$$BRc?K1qjX&BgH(-+z`7gz!)I)tF4FX{L zBd8%NmMb1thveJ8J>~@TnGF1usLlWJ`}0(l|BBt)fyMVw?aMduxLP9?sBQ1CS{BD( zO&EsM`QvMXM#F4DE>TKet9j)flYu6JKSEaJt_KVHU%V#lqYzN9In~OoW=uA!l#R+% zR;~}=IZqG8`_22&DP*)3tH92w+LK=&IvBgL01o;jhW^PPX}KJCCN(#^=xoFJ{p$P; zW!bjAtN>QA!3oZFEH5=&wwUTaDAs%DQaV(IP4X}tyjB&o7y>v+&}TpnHOzRsbOFM9 ziwSXqx~y6R`2ahkW!1DfC#2<_vFC?dl!$0tuy#>t7XDH`s1vKcRoqa`=PK zy@F&{N1Bqt{e^_yb0fSei>Xy08d}wZaTC4Xn3c+8fUZnlJ*}oUHxJp(lecxn%kB{m z2@kLFBuT1m<);dVTs64W+XiW#C-KMD0);xpxiun%32Eob)R&w+TDy8gmKjGqBiA5X zj=!IOM_#i86O(X)(^xOhI3N;#@5xL~9%$gy>HgTQrvuNM>C<+2L9%R{xdL};lkfY- z6;tjro8HvqY52YrzLHh}7!-Pe%YPLAQWiTU1Apl%M~H>jn%Sj?M^qL?q2pfg%IEK?$(-kYzfFCs}>xt%Uh1y(5%zDECXx4u$J4elE)C4nprA zMFve&|KT&H`^$lQI*8kvrvqkQ9YO;XndVa@uXQTFIQUQuz1N2s_ZNCq3<8(61lK72 zl+R|5L{o`jrZ9pEECl>mF;;qg4h(y&lV?yZRHqv*6}PTTZB-t2cYkPY{d$D}EDdfl z#h*+sf0e;7w9?ZLRXt$kQ4-Os8BPiPQStw>(8rRlchnz>ro zk_)#2v7?BfmA<8DL6Id{)hiA}#7`iGbFD!e<;vNUz8XlMDYe z2*F2{rfOYi~e!3d%9*4SWs?;un=r@A7 z?Vm1+IXt}ae7;!SBSCg`sYII^X@q%~N)hL)TUt#-^6((a$j?MJe_0ysF)L_Pksz zLMLtvFrVo}!Udivxgp5j5j}Iatk?-R$+-lB9y(CFHSroOdkVD%^{~>}WLB044m!e5 zNOf(|IO)ygoY9@j8&2D&_NY}85_qtwdOqFRYO91Fg`F4?Fx&mmB7mfIV|AxweN%+A zjCZ+3E3?`CYZ?*2H|@jj@iso)#&qv|B7R>PC)$A@V@#Cx+g|7Fk{KwmpCR82z7<9M~1N5-qThh0aCoDeT&iN}DR5Xu# zt-@7__(fxrL=ck--NIT0aM3uc`6XRxA+`b#fE-B1^vYx`Ie7C4L%Bad7&|gVA4nd; zKYhs=y2`qe{AJ)ix|?`fBFaWjZ;R7@!8?(yjH3ZRZQTD~1+oG0_IVpe@}=e7BMBtUudKbM+oCbgnS((Fawi57|KGX-S4%f-(hhO9mf zP&?4wQBbjjBC%Gp8hwWc9Z2kUSSP;`qfilfu?%rJ{$U>=ZKfchFNMKjQMT%~brOam zqpB8m_y!fAn5S5JFrL8=z9&!-BBlUWP|frwhi0Vb@qj+MH%46I$yl0!_fF~Lsv-2D zM#=d6Jb&_=xoAWRpJAz(?qb4+tDA_7kV5Nvb5?hZ zq4jg>E;}G~B`@Xr=9tZk@vl_rAI3Be%=qQmx~_~hGgW1)hjqvH`c=(DsM$gp#rOzG z3IojYNXCJTIN&juVx7bq)TbHP)pm-b}g@_qbhhO7O=a+u6I} zm0UeSCQSi5FV3S7y4&5>U!8BXb1z6zPYImYhiHf{(%BJB(iZa-SphN0c-2^dD;G?n zV&xb{%07XiVzQv6YE=fxg2@CZ{@p@>;QC(Q!l4yTTTqTqDyL!phuifK^`w%yAvj5c zSOj#Ez$J$rq1R8O5pLPUxRE(gOEX~4f>7das0uL|K2}IKYPdJ@YgbyqEOlo8m8NwX zZP6X&W^t?Q=ui^YOG?G{faVA8=p-V8_MXGn^!K&-nHh|9l3rX#9o-4CLeID;VBQ_| zAWf)#dNN%?tIJl$R_(DlB@Esp)}aFg;k^YRc?p5Sy_m~-NWTLYg2B*Y z7MUxiofz|vT$m+3{O52Ua>lQjllGqvt6IdJFfl^6KM>=rsB@nO%*RS@x>;YV36XYw zqU1|G-NTJ)1YAuSMOxbp4(BOVnp_Pm^!P2;8)Z*9=T=s>wzhVjL+ZoG`K=UT$iJt= zQICdTia4Z4lTwt6<|DLksPVUFbvJ88s^#nXA&rld>Lh4-^=uYS9iF-tbrgy$o~sR6 z_GfDsB4i^8A%;ST9W3t-wzm(X%^qKCmDX+B?+=E#%*L~iNO-bz5PMvrmV z4wRif{sht7pxtuTCVVE;&(cca5at?S-+kecdH=*NU79IaEPSbesyH zq*zZ3@D_kk79TcHk;UF*&xnCY=yYj=Pj~Pop>T-9dL*&iE9R1AD9~8V9CY^%49=9# zEZn0l8+MgVR2~4bE}Vd!XDk}+Z28Y3257n6x^B6Z6F+$tW)?=E^KnTP7>8j&1&B77 z9E=OsjH%PdBZLqd9UTe;R(q`eJ?NFwM(5uL=v9tB89}oqZ%)aQ2CdJ+yFGSSs~2Pd z0aS7`u<5!RG>PBC<;_M#BEt^pLn{Q!Z3T3&Z6gbdJ3H6Ms!)OU24({__5N2yr?)P( ze)pwcRMhV%zhY`<#if?&UGHhRe{U&|Zrk}k7&&|Pj2p*Fj8P7|m^Hj2_ zTfNuO5-RKnmcCH11tLXIl6~GZU|E?KCS6hXv>j6DINIBYb^38j-dTOQqQ>c~gT^d40FDS&x6M$k_}=i|&8j zJIM))B)CNT(0B;hwGM)^EVWQm~)!T#mQbPx4G_L?81$~|tg0T5&DuJ)c9`Iuwl z79bq~++_mIX(@;=Du>Pw;lfx}1fU=hsQN8do2&PPoFfGGXE0I(0U@G~&jhK&S_AV8 zJRCrY5=`J{h{ZRn3<|+b>2A6$4XCyJHs3n)=1tS%H~`L{b4M`~ifaHL0b*4c2GT8E0X=D+!xM z^?I0ex|u6(X;rGO-g{VVY`fz5Yf2cd;pprHwQi;z?wtyxaM39mERS2rUy`B~sm{x` zJHRMX*+D6)3Dc=Zk6KwbUbO5G?q?mLo?3WG8<&g}B_j?M>VhJR#Tk(VjG+)gt3 zNk`o3eiee40wyaI&a;dQ@L-O>WvBL*Hqf%=OY)c<9oiQ70&u9txPxnc0H=5=Z(a zkl|m$3DxH*DNvuiQXp*G8cKXg5=b+(#+8qJ6Q^Ot4o$~Joh2pAKC+z_8*s7mJV9l= zYf*SX-B=x%IDL$Vyv`Yx?|9_04ixl_@v^VTR-kmS<0ZUhpy{vM@6Xr5gw%ZJ{)x{+ zD3U*7XBU%!Ln&eam?ySU8No;3fIlFRuK|h;C|d0l@hH|0@P*oyr6Q}wP#UO9y7u#F zPOex8y>}WkX#K;Wa2XF==O?^$(ct`uQx{SU^=(+Q0rN3L*L%B`K_@rA^@CRyojHBi5H$JL^*{Vvlu^+|L`LiVS`+4un_`vAIVq zVl>d+^0B-;q*$d@UHxST&#>;#OM<&s!*MJzceS+P17(f=cZZ2}dSB<*f=-+nP7`aN zpY`5c|6tzH3ef}zhLnxEE3(`N?k#77Jl8T=6trf5?gr{M(uOj?zbvIq=b{BjEJR#E zU|wl~CdFt9W7Y{X+o|6M-aC++hcc=-q;qgZ=3)Oz@V$}}&}Dx5-)YzXMp#-K{6qvt zvHm;7lmC>9{lD*z<^J;JUsNr?(G3D%^=csORd5)Z&HA!iL?Y12w1oZ+%0)j1$;9|X zf(8>Zl*&NZfJG((fhTSqf(4tl3ecEdUn?5c-7_|wus7*s@N%I8Hxi9Ka!Avvdl9|M zWvVwEL-t{Qq0ym=K%1N{7F2Nrg;_#TI-a?5M)&QUtCD-* zL|g546_tt<+0%+K#>}bv z)$t+2c9ZMYtj^p#Bo#R|SKCRwB$vF9RD3DiVs;TE6@A$xZAiJ}04m9DR|12>yrt&5 zH0}H%5&>^VD^y>*C0;}4Er>D@SvwTI52mP;6I_>?klRt}eyb0QdU4pg!Wk5^W`W1~ zy!S>vBPH+cJ87OT1`ryV+gtVgnoU7A*>;2hkWW2@o@9AdK;+OH5xxPe^ zU>cp_>^nbLj0)6FDE489aziLl_aN>s0=*h}j)K|ftvW|od4*HQ<^L2h{{lWTA8ld^ zPZJ+Ni#pTU0)Jn|`dL~;^Mylaju7Japjdvntj4xG7S>${>>pCWMjpuZti-)vz3)aH zBu9^!fAqI7^tf8yY+H015lt)d56Rf4#4vfpC^OwJkq?@3RM-F2ZvVs7koQ#h5H-d! zLrgUsFjDKuKFF)Kg=f-PcoQpE#sBv}W}JBc_nZfL{kn3}-8 z!?4h#aHRyMp+Ymb>&7(WsvloHCbnxD`=TjNwytV*a?|_XzRpIW3R1Rj`^?$0HroOy zOi_*Z0eOK~5~sWB+>964Ik8`$7;%TDpzu(aH)Mq=wud1?6r9ELGFSkD)ayXkqXYw1zAc9mb+Yku;!ov6DmYdR@_a+Bk}ACRgTQU^~qXx7SYc?xSzkCXBG$*$3ZH9}_=Dgj+|-Ei{k_S6o3IDCRmcC7!(b#CqZS%0#PH{?JUy@pUTq(x@v~RS~|)E9$ z6Z8aRL&|Jtl#AvcaoW6r+cvrT`@INU%QK9wmQxsPAn70sGk0B;RJl5HS@_sVldKnC z3*7a`-*o}#mgUBcmc8618YN7__ z26c2ZxpKxCU6P=v6sR!+6-ju8hyv9EWN~=o{u;V~7=*bBfud(RadgDKMPZ~!W;i`r zLM#jd{blXpZ57rqVG#0REf)yA$Z|v#n{^w69&}pE1^x+oTU(Ow&=QP3R|aZGq`7kF zW`s=!v@?7ycR1M!+ugR~)rr`aN?KK|f~$(Ai}CkqexhbCeh{nqla86ROJO#1iCrSGQdcrwKMC9a)81JH z#no+j9Cr;6BoG`zu*O{)m&P@?Td+Xm8k`^EtJXPnetWOA|4Z!EjF3{n0&FitQ;z^&=(vA0^fUy3Ms`ioc9dur??)ewLpyC5R2fJ*`Q)rAV^rOt` zL9r^@j2&Lk*Vh5c;>yk#vmEfxG0@x>0mq2pUy%9D%kM<;>LpELw(+eP8vBDIOG@dZ zkKOUIbg+1A;<_aW9QTSk#;aS}Xt%i%UPAkf&V? zZdw!^J2$hW=b?z+C?~*da7_5m0guX+7>$o!`>gqLUT36)e)m6H_K|-g{>h=d;LX$i zzvf^)w~q5U`h3D>4L_D&u<&)bN5x!kA;d4mF2u(d;2$rfh|m88ElJ?yFF>e4QUXKd zD!)Ic7U$UtzNPX;doE=BvoBhJl}bSiPk~6^R%+QW@ZrfW&wzoIz>1h~r zFXymdnUNLfHcGsHoMJwoU2=lN*6TFMCOJ5tBcpCv+9^<;%Ap)_W=7UC;?%HOzu4p` zX!2@tR#^G~e$#eC%nG6d{lIDv$b>~JCn_|fRV(a*i*e4H;H(2yl2G&Lj(aPuDfEiP1Jz~iXt}kSb0L4h zDIh3fF}{E>qxsqZy259Nx597qiW2ah{e?3OgN*n7D>!$fbS zEW3ytoF_9QlxCgLA}vLJ5>Ct`z{B3S(p4$uF1`@(r5quwjQ!l)z}Ig94;0FT!MI_} ze0uO}&3(c#fA-=@02cmYy!DFgH}IRtj0{Ad4S+$?vR!3|&6@wLO}z;9#vt(l@^-c4 z;2nh-Vz>7dJm>KpB0gy4t(y}09Pnj#53ep{a-g1FaT`wB4lm}VAg%%=Pl(RV zOB!_nwbm1LA0KKK+XDYc*6~3RHy*>cF#r5nI|+`@U&DVJ+e8xK`W!_XYsMB5+TX0g zYkxqKQFRx1)?bdA<$o3y#&ykK`YmG%utMq)F5xpFAWnSz;ArQLAW@gQ?x@E^(FS}S zL{#wMaY-}Q`JSKD)@U>2eIs$Vn)O(#CEYjLc5Yz|QBg?oNbO%qLP5=bn?4Ak%u@V+_hXHoFG zo}L#txJ55G;tT5m|K6MMqFL;p5k$Cr!dbw3Qzs3}ky%NNcYwE8{^AmmuU*Tu2P9;4xj8*CyKJ&IF+ph*axPK{tya}uHyZ# zUqEUIvci4p=|7{S|3L-qUy{@R{`!qq;P0ZXkL$OO_&oqpq{;Ve<(AlAS(w>*k{Zw) zDK~W%qI8Jp=*9X?&v8|QVJrR=W5K%~kf;(zlK7Yyu%Q>+PEJmDxEA#LPRi|S^t)&7 zfVhdjaMW~anFoU@!PcG<4pXb}OFDDJ{*BARrJr)PppU=`NlyfT9)$Jpzc>fHZ~1a> zyM)qm@ix`vU~m(>|E-)B!oKBxfF#1@R|)YF5e#AR^lBF@NvEA&3!WD;K17a8pHcZ| z5&l~*oqrk4|Kl9|mrUg!4*tWze>nIL2mj&VKOFprga2^wzmbFg2NmppNlt$_`2Pb3 z|4oPMAJKf2f7JoIaH~IdyL!xBd3b{91o(OXX%_3hw6OSu`TxHAx;Sh6+Vfd6+1;x7 zTgU}iY2yyM=c|yuDc5K%mafCP?Jlb7Varz{msTu#(skT|l#WKL`p#)9o7!V~v5bxoI6`KBYdRF!vzxEG5Ix33IGM)k)q9y-?u^ zN1nvy8z>;@jUV(K;U(el8mXQs|2^hJRMB1Cuj6Sa(M%DsfV2myVlfZ~inj);QlWd??*EJSAN6Opo#+k2ptCe(rekw*Frl|_>ZCE%<`9D3qVi(+_D zpZPS&z=rhRpEn8F+J^Bk-y0GP4!xgX<-o1Y$FO8tlu-v@aiX7D=WH_JdH|taw});W zgyycV=K>6ZD#<$xQcl9oIZeJxn1tW=#GKGi%a$n?62;1~JfT#^cvNg(K_o}of0_G` zXE5p{dXtJ_8Mf>1cAP#vjBjSi=pSOb5@@BdpIz1=G%N6K@>0ITZ~*jjPl{OM*?0f0 z+iQ(aGV>V9S#vMJ;ms3xwDqh{Y=UVsy-Ht$WlKf#So0)(To0~hor6tR;+^25VXgwY zBiN#d-RjLc* z8JN4;H|)V04E2jb45s&-y3PL;=lx11aLh?0ySYigq_Ae( z<-8KO;}2ePbYV_Cz$t1}Mc`pIh&eY5c+FRH*u=Wl9pwm_5>vH?G#V|3t%RvJd6Y*U ziLz43$VUWxK<%DJ$FdV$QDI$|Gh*JnAB88!615Vn&^yO^%n+BVJzaMu-_44btSw8S z*IjF=$e{Tw7EL9(*=osbJng~X_G+xu%PU}MzaZPlOeklN=-Xg6zxp~Pi6FQC0SmWu zww_Ng=bZRJ7Uuwx$2xkw;*9cX*V981I|5x4+`%7O4m_3s6xiJnXr{hT6`%x*Dy<+x zmsJ{C&*7fri6Ds`sv6rrHlrSu^Q1hzWBnNoAqQLv>#VQTkBhTUI8kSt}I*?kE^;5 zsP_3KDI2jmI!w)3JidgrTvEMKNvQFQhhx%~#FiYpvWk`e$mxxo8R4(X{c2x=>0zsxZ7zbbDF~m>8x~n8` z%-2mC_05W9rRYG%YVDCAc8_I&JC_%C^@Z;|vs^IM3zEF(Aba_JkyVY>a+2LC%C}6* zF4Sc&>lxMujh>U(Dj$oF3UMr^sZ-4*+WrDPr-@1pOryc%>%bkBoScNoR%m&@%o`b6 zXQ-~F`EkiJYbR`1w^%0c1&Wi8QAx4q%998ue=+)-&N)rCPMyje5wYd9xvaqzcaCuG zn&Ss$@K02S7Y_L1^g|Vtt>L+^YCNMv0+ky?U#Ihnj$LV~&zRM~{m*{7s#d`CDz2g0 zUc|ZHh*Z8|J&@g^ZRshDo~=^ry56wf)WtGwC`_?kl*7WW=M!_j6WIi{QCk|F37LKu zcXxCzUWQ_{G2Ve`)bCe7HYS|mQCWMbY#SUyfT3_YeCQzvsRQE=`%8t~ z-fwI!n48aZ={FMiWxE*dH8$e7-rMiEv)`*P~1rr0AG zW;`5r7QWgK%Tiac7s|81S`{;nkMWv$k7Jd)8?ZBbNO-?hSg$(#wkA2NUvN+N`mHY*B z@jq$>iqXz{wkG*{@L+VMY-f~F9A0=EnUmb))Ut7z1ww;DPBsSj)6c0YR~6?BF3avh zU;XeS%ay6`9H^vrlolFqA)Pgvn3bZPKsH~S^18j}&sVdjtrg2B+7&Cy46mdqb*bCc zK^CARJ5Ng)lEVp)x4$F8Q=&eN3a7Tguz?0oViZ3m{rYyg8GQ_mEN{+F;WgEW*mW_G zT?Msu;dmN1$83P=fJO^{m#V97a~?Cw5pzL2)_RDXzk#Y6OP!et4wg^z01~q^VI=EK zs38Sj?D65|t>!CnTX9wLDg!l?o|pn(@L@wY+AyunC7(e6sp zyEnA9%=nbt6hRm=!!qj8WG@vIaEVKb?TZ*U3&fKXBt<9BXfP(3c4+&iBzvBt4l6yU zPU_Dpcdn1BNhp%Vf_m1IrJq?20u{@6g9!;A05?~5yjcmad4&sle1v=%qs>~JcA}{h zHgPNki+YzjW0Ax@k-Ogh+AquFTFa1?!r~PUVHyGuhQ&*0OGp~UaKgL$-m*dKCQz0% znb@aNB&ovok5XdvwE)D2e=ud{~xY}PTNB-vo_I8@F^B!fNW z^H5A&Nn#e%>n$vaY&;flu_CPqWo`wMyF#frihl9~TP9mAUTwy%(Zg!`Y zHmTLwo^HPT*$HKe3Gm%}{Ng@pAaI5uNJI{0a3UVNexscZ@HUKQ-4l(X8-II3HuY(H za_SjVY(WJcU{%{Vg0)`Y;wuuaGF~~LTl{Al9jZ2_bnM$;PRB6qJ~V0EjmazyfJ8%6 zyavn<52%5wRM-ZQ4kG@B!dD-zvANsb&O%`Di2>9{=D}j6J2hw>#Gz;vIw=;V8q6ZW zza+F}`p$JXr8BjdlEpq~Zi7oSa(cp113=cjrGdmUdQ1q)km?9Hy|zLnStBS*d03tu zL>jrSd#8B;#PX7Emm;YERlCSmAg@%N=|s&Dmsw~V4LiTpG8(pFT13u64uV4qd$iuy z=3`pBeI7R(S|H>Gr_X8>5bj-5ssnf_nB9rw$Sr#W!EhX9x?gGC4BS9oUA&E7r6Hso zS?5?qAMp2Hf0^p5oDO>k&8r1=kLXBC=hcf|ZC~B|b`w%~c;n3{7KIicqwh}Sy^iD* z$i(kXXk15~bUH~-f1QTV2t%9F^q*9#Fik6^`XQyAkcuvWEIU?S+J=Rv)|hVG;gB~B zn*N}L-f{lyj8GZ-*@Zq@=HQt^wMRntYOM#??V1VocYwPw7L8mH?z;Tkjw@3eN!bab zq<%z$1ar>aUGLS#?A2O`OTyJnl$FwUKjFw70#C^}#7{;qSg8-2wZ zJh^mrBn=xXOgMUe^;~(HoA^q}Do!ZwPD? z)3xs=G;HUsq9h;@CV`mh^nEtUo-^KhF?yF!F}+%`G)} z-ugB+dm*T>hNeFc7I)72!)m(D(OcU0n5)T!cSU3d<|v3?lc;lzh+3jPd6X}y6Jgn8 z)TpTP1J5g}FSF2)6#vZ7X;QwAaG8R1<#q)=c z^skO%B{(w%`>VE5TwjTFlti-KZHNco5tn&fCy8`0LreA0I!Z!|mv%43(qGWu`3IIj zeNGoHU3I@Kq(HnHFMqkJcQwLG*>h_Yw;S-C1G-g;=7;lC?xvfXOEw}oRdKGP%IX@*91n)r)nXYW%(sFqP6rxJEd~x zHuU7!uii2j_s-2S<7O_+K&}wyW?5G*XP*i6MR`+r_QizRqT&iX&%Zo_U;?;bBnyTn zPH63yS%ayyYaWoc4pr~)%DYxu-3Ro#6loqbdu;{0s|UJf!JliP`N*Q!HsNz+yZj%J z1zl^tOwoNn71WuN3@M8P?qL;ieM_0pm#s literal 0 HcmV?d00001 diff --git a/docs/NET10-Upgrade.docx b/docs/NET10-Upgrade.docx new file mode 100644 index 0000000000000000000000000000000000000000..70a657774037bf0c3a2b40af44677bf4172f8b35 GIT binary patch literal 18738 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 All changes were made in the `feature/operator-mcp-role` branch. The project builds with +> `dotnet build` without warnings or errors. + +--- + +## Table of Contents + +1. [Target Platform and Language Version](#1-target-platform-and-language-version) +2. [Dependency Updates](#2-dependency-updates) +3. [Performance Optimizations (high performance)](#3-performance-optimizations-high-performance) +4. [Operator + MCP Tools Examples](#4-operator--mcp-tools-examples) +5. [Build and Run](#5-build-and-run) +6. [Summary of Changed Files](#6-summary-of-changed-files) + +--- + +## 1. Target Platform and Language Version + +Both projects were migrated to a single stack: + +| Property | Before | After | +|---------------------|-------------|----------------| +| `TargetFramework` | `net10.0` | `net10.0` | +| `LangVersion` | `14.0` | `preview` *(C# 15)* | +| `Nullable` | `enable` | `enable` | +| `ImplicitUsings` | `enable` | `enable` | + +The changes were made in the files: + +- `src/Delibera.Core/Delibera.Core.csproj` +- `src/Delibera.ConsoleApp/Delibera.ConsoleApp.csproj` + +### ⚠️ Important note about "C# 15" + +At the time of the upgrade, the GA release **.NET SDK 10.0.301** is installed, in which the Roslyn +compiler supports language versions up to and including **14.0**, while the next one (the future +C# 15) is available **only** through the `preview` value. Specifying `15.0` +directly results in a compilation error: + +``` +error CS1617: Invalid option '15.0' for /langversion +``` + +Therefore `preview` is used — this enables the maximum available set of +language features of the next version (C# 15). When an SDK with official `15.0` support is released, +the value can be replaced with `15.0` without any other changes to the code. + +--- + +## 2. Dependency Updates + +All NuGet packages were brought to versions compatible with .NET 10. Most packages were already on +their current versions for .NET 10; `Microsoft.SourceLink.GitHub` was updated. + +### Delibera.Core + +| Package | Version | Purpose | +|--------------------------------------------------------|-----------|---------------------------------------| +| `Microsoft.Extensions.Configuration.Abstractions` | 10.0.9 | Configuration (DI) | +| `Microsoft.Extensions.Configuration.Json` | 10.0.9 | Reading `appsettings.json` | +| `Microsoft.Extensions.DependencyInjection` | 10.0.9 | DI container | +| `Microsoft.Extensions.DependencyInjection.Abstractions`| 10.0.9 | DI abstractions | +| `Microsoft.Extensions.Options` | 10.0.9 | Options pattern | +| `Microsoft.Extensions.Options.ConfigurationExtensions` | 10.0.9 | Binding Options to configuration | +| `ModelContextProtocol` | 1.4.0 | SDK for MCP servers (Operator role) | +| `Npgsql` | 10.0.3 | PostgreSQL / pgvector | +| `OllamaSharp` | 5.4.25 | Ollama LLM provider | +| `Pgvector` | 0.3.2 | Vector search in PostgreSQL | +| `Qdrant.Client` | 1.18.1 | Qdrant vector store | +| `Microsoft.SourceLink.GitHub` | **10.0.300** *(was 10.0.102)* | Source Link for debugging | + +### Delibera.ConsoleApp + +| Package | Version | Purpose | +|------------------------------------------------------|---------|-----------------------------| +| `Microsoft.Extensions.Configuration` | 10.0.9 | Configuration | +| `Microsoft.Extensions.Configuration.Binder` | 10.0.9 | Configuration binding | +| `Microsoft.Extensions.Configuration.Json` | 10.0.9 | JSON configuration | +| `Microsoft.Extensions.Configuration.UserSecrets` | 10.0.9 | User Secrets | +| `Microsoft.Extensions.DependencyInjection` | 10.0.9 | DI container | + +> The currency check was performed via `dotnet list package --outdated` and the NuGet flat-container +> API — at the time of the upgrade there are no newer stable versions for the listed packages. + +--- + +## 3. Performance Optimizations (high performance) + +Targeted optimizations of the "hot" paths were made using `Span`/`ReadOnlySpan`, +SIMD (`System.Numerics.Vector`), and `ArrayPool`. All optimizations preserve the previous +behavior (verified numerically). + +### 3.1. SIMD vectorization of cosine similarity + +**File:** `src/Delibera.Core/Compression/SemanticCompressor.cs` → `CosineSimilarity` + +Cosine similarity is the hottest loop during semantic compression and deduplication (it is called +for every pair of sentences across all embedding dimensions, typically 768–1536). The implementation +was rewritten: + +- The signature was changed from `float[]` to `ReadOnlySpan` — this is zero-copy and allows + passing arrays, slices, and `stackalloc` buffers without allocations (calling code using `float[]` + works without changes). +- The main loop is vectorized via `Vector`: each iteration processes + `Vector.Count` elements (on modern x64/ARM64 — 8–16 values per cycle). +- The scalar "tail" handles the remainder and also serves as a fallback path when there is no + hardware acceleration (`Vector.IsHardwareAccelerated == false`). + +```csharp +internal static double CosineSimilarity(ReadOnlySpan a, ReadOnlySpan b) +{ + if (a.Length != b.Length || a.IsEmpty) return 0; + + float dot = 0, magA = 0, magB = 0; + var i = 0; + + if (Vector.IsHardwareAccelerated && a.Length >= Vector.Count) + { + var dotAcc = Vector.Zero; + var magAAcc = Vector.Zero; + var magBAcc = Vector.Zero; + var width = Vector.Count; + + for (; i <= a.Length - width; i += width) + { + var va = new Vector(a.Slice(i, width)); + var vb = new Vector(b.Slice(i, width)); + dotAcc += va * vb; + magAAcc += va * va; + magBAcc += vb * vb; + } + + dot = Vector.Dot(dotAcc, Vector.One); + magA = Vector.Dot(magAAcc, Vector.One); + magB = Vector.Dot(magBAcc, Vector.One); + } + + for (; i < a.Length; i++) { dot += a[i]*b[i]; magA += a[i]*a[i]; magB += b[i]*b[i]; } + + var denom = Math.Sqrt(magA) * Math.Sqrt(magB); + return denom > 0 ? dot / denom : 0; +} +``` + +**Correctness.** A numerical comparison with the reference (scalar) implementation on lengths +`1, 3, 7, 8, 16, 17, 100, 1536` yielded a maximum absolute error of ≈ `6·10⁻⁸` +(at the precision level of `float`); the result is identical. + +### 3.2. Allocation-free token counting (`ReadOnlySpan`) + +**File:** `src/Delibera.Core/Compression/TokenCounter.cs` + +- Added an `EstimateTokens(ReadOnlySpan text)` overload — it allows estimating tokens for + string slices without allocating substrings. +- The internal `CountWords` accepts `ReadOnlySpan` and iterates over characters using a + `ref struct` enumerator — without boxing and without copies. +- The existing `EstimateTokens(string?)` is preserved and now delegates to the span overload + (via `text.AsSpan()`), so the public API is fully backward compatible. + +### 3.3. Cache key hashing via `ArrayPool` + `stackalloc` + +**File:** `src/Delibera.Core/Compression/CompressionCache.cs` → `ComputeKey` + +Previously, building the cache key created two extra heap allocations on every call: +the interpolated string `"{strategy}:{text}"` and a byte array from `Encoding.UTF8.GetBytes`. +The new implementation: + +- Rents a character buffer from `ArrayPool.Shared` and concatenates `strategy + ':' + text` + into it. +- Rents a byte buffer from `ArrayPool.Shared` and encodes UTF-8 directly into it + (`Encoding.UTF8.GetBytes(span, span)`). +- Computes SHA-256 into a stack buffer `stackalloc byte[SHA256.HashSizeInBytes]` and formats hex. +- Guarantees that the rented buffers are returned in `finally`. + +Result: on the "warm" compression-cache path, intermediate allocations of large strings/arrays are +eliminated, which reduces GC pressure for large volumes of debate context. + +### 3.4. `ValueTask` in the Operator role + +The Operator resource-disposal method is implemented as `public async ValueTask DisposeAsync()` +(`src/Delibera.Core/Council/Operator.cs`) — the standard .NET 10 `IAsyncDisposable` pattern, which +avoids a `Task` allocation on the MCP-client disposal path. The new example +(`OperatorMcpToolsExample`) uses `await using`, correctly exercising this path. + +### Optimization summary + +| Technique | Where applied | Effect | +|---------------------|------------------------------------------------|---------------------------------------------| +| `ReadOnlySpan` + SIMD | `SemanticCompressor.CosineSimilarity` | 8–16× processing width, no copies | +| `ReadOnlySpan`| `TokenCounter` (word/token counting) | No substring allocations | +| `ArrayPool` + `stackalloc` | `CompressionCache.ComputeKey` | −2 heap allocations per call, less GC pressure | +| `ValueTask` | `Operator.DisposeAsync` | Disposal without a `Task` allocation | + +--- + +## 4. Operator + MCP Tools Examples + +A new example **`OperatorMcpToolsExample`** was added +(`src/Delibera.ConsoleApp/Examples/OperatorMcpToolsExample.cs`), demonstrating the **Operator** role +working with real MCP servers. + +### Connected MCP servers + +| Server | Transport | Launch command | What it provides | +|------------|-----------|------------------------------------------------------------|-----------------------------------------------------| +| 🌐 `browser` | stdio | `npx -y @playwright/mcp@latest --headless` | Site navigation, page reading, clicks, screenshots | +| 🎯 `marp` | stdio | `npx -y @marp-team/marp-cli --server

` | Generating presentations (HTML/PDF/PPTX) from Markdown | + +> The example also shows (commented out) an HTTP/SSE variant via `McpServerConfig.Http(...)` +> for remote MCP servers with authorization headers. + +### What the example demonstrates + +**Section A — direct Operator call** (without a council): + +- Manual creation of MCP clients (`McpClientAdapter`) and an `Operator` object. +- `await using` for correct disposal via `ValueTask DisposeAsync`. +- `InitializeAsync()` — connecting to the servers and discovering tools. +- Two tasks via `ExecuteTaskAsync`: + 1. **Browser:** open `https://modelcontextprotocol.io` and briefly summarize what MCP is. + 2. **Marp:** generate a 3-slide presentation and save it as `deck.html`. + +**Section B — Operator inside a council** (delegation via the `[[OPERATOR: ...]]` marker): + +- Building a council via `CouncilBuilder` with participants, a chairman (`Chairman`), and an Operator + (the convenient `WithOperator(modelName, provider, servers)` overload). +- Subscribing to `OnRoundCompleted` to print Operator activity per round. +- Saving the debate Markdown report and the generated presentations. + +### Running the example + +```bash +# from the project directory +dotnet run --project src/Delibera.ConsoleApp -- --operator-mcp +``` + +#### Prerequisites + +- **Node.js + npx** — required to launch the npx MCP servers (`@playwright/mcp`, `@marp-team/marp-cli`). +- For the browser, on first run Playwright will download the engines: `npx playwright install`. +- **Ollama** running locally (`ollama serve`) with the models `llama3.2`, `qwen2.5` + (the Operator model can be replaced with any "cheap" one). + +> The example is resilient to a missing environment: on error it prints clear hints, and the +> process does not crash. + +--- + +## 5. Build and Run + +```bash +# Build both projects +dotnet build + +# Build in Release +dotnet build -c Release + +# Run the console application +dotnet run --project src/Delibera.ConsoleApp + +# Available demos (flags): +dotnet run --project src/Delibera.ConsoleApp -- --operator # basic Operator example +dotnet run --project src/Delibera.ConsoleApp -- --operator-mcp # 🆕 browser + Marp +dotnet run --project src/Delibera.ConsoleApp -- --compression # context compression +dotnet run --project src/Delibera.ConsoleApp -- --rag # RAG +dotnet run --project src/Delibera.ConsoleApp -- --di # Dependency Injection +``` + +Build result: + +``` +Build succeeded. + 0 Warning(s) + 0 Error(s) +``` + +> **Note about localhost.** The examples use the address `http://localhost:11434` (Ollama) and +> local paths for presentation output. This is the localhost of the machine where the application +> runs (the Abacus AI Agent environment), not your computer. To run it locally, download the files +> via the "Files" icon, go into the downloaded folder, deploy the application on your own system, +> and run it. + +--- + +## 6. Summary of Changed Files + +| File | Change | +|-----------------------------------------------------------------|--------------------------------------------------------------| +| `src/Delibera.Core/Delibera.Core.csproj` | `LangVersion=preview`, `SourceLink → 10.0.300` | +| `src/Delibera.ConsoleApp/Delibera.ConsoleApp.csproj` | `LangVersion=preview` | +| `src/Delibera.Core/Compression/SemanticCompressor.cs` | SIMD vectorization of `CosineSimilarity` + `ReadOnlySpan` | +| `src/Delibera.Core/Compression/TokenCounter.cs` | `ReadOnlySpan` overload, span-based word counting | +| `src/Delibera.Core/Compression/CompressionCache.cs` | `ArrayPool` + `stackalloc` in `ComputeKey` | +| `src/Delibera.ConsoleApp/Examples/OperatorMcpToolsExample.cs` | 🆕 Operator example with browser and Marp | +| `src/Delibera.ConsoleApp/Program.cs` | `--operator-mcp` flag, fixed nullable warning | + +--- + +*Document prepared as part of the Delibera upgrade to .NET 10 / C# 15.* diff --git a/docs/NET10-Upgrade.pdf b/docs/NET10-Upgrade.pdf new file mode 100644 index 0000000000000000000000000000000000000000..82103f111ebf0b86705af90ad913449c16647b07 GIT binary patch literal 104010 zcma%jWl&_@vL#LfjYH$!K;!P#xVyW%yHhmo?(Qy)H(I#6ySux~(BHdnV(!e`i1(wS zYDb+@JJ-s!GtbW4QKWK0A~X!NY_O!4*+~VkEQIugwgwik+}v~`=2lL|4uo_fR{BoH zLdJ%+M#gkf#x|x-W`xYF46MAou#Qd+#`@NVEpk94+>6-!0GcJ(x*mf^TRtMM2F)D*`OUtc6xs-ATJ-X z@!p*uU)5cb$FVnsn z*`phnp7rt?mtt_P94zSB7!|ua(r^_@GzlIkTkDb@lKG*6H_S5kSGGyjUXt zzp=q&l_pnP7MUM#9gvfNUKaY{^(a&MHVGm-KOeBa$*qWvN5tDu@nMBQcJ<}{)^HNw zbNHd6vHG2z2`vdfVkQUVQK2hrs|>x<76;bSy7)FIR}IN3xZBCE7%EdB2}~kj9M&{b zqAp-sVGKcxS-!;Zh3C#iH=nEbX0SvIRp3_PSoGf0w%l{qaiTIN6Ai8VK9*-=@YTV;QdqHs`0X1PFTJoa>HSbE8d~SR@Z=r$+ z-;r#kJ+%~1Y$V%4GDpzBB$0qCD7$nTey{B-DBsJ*@q9{)w3m+bm%Tv%@}h}R=gQr$ zf!y*}7?;qO81SVIdn9cvXZC#?FEpiYut^|3K+?{QVU*2_x1`y-U*nu0MGes`T|5E6 zM1# znbk&S=oc+du+fgo`|tT_QzpsKkiBk%8HW;3OV*1{mMxWxF=$kDOQ?@KFOB%(8fRc$QhcuSfL9 z2_J}f7$RM+$H#$TyjpbjW3JRB&ScVnY;ykjj8IJXA~95KPw0cE%!v=O`weHY1e#MQ zo;7wpLes>2rgpZT8DY7sagU|dqiOF~m_NKx`K@e^?|5JL9H>&zwN?ac`__udE^~d* z#I~Tz8+)ET?iRhBTn6^savE)~zEhKDuA5$g8Ej3~wc(GD8?VFL^%0@;D=qVxE+z|R zir_v>#`^^_ty#?Vs5`poZsHL%Qm8ZoQM&K`FHp4~9^m~52OK{^XzcLi{9{jgvX!6= z^DOJ>5P&L?bnv36Pj%+J%JLeL=qvlxD0u!4S`$Y8*xkJ2qe>GbtQxE2SPb+kC!Cj# zD@%rLyZ~7||08rV~#^u~alB1-GK75l2Q|8Ys zW4OZ>&xa_#bP@W?Xv0eUMPCKjrRf^`A{(fsgH|YxYfq1S|J2Q93|oVrbCJ$Y!df~J zNg94&@+jZa`w($Em-i`8Qfo7-l=L-XnR?Q)=v0VCo|d`;vip-c@<<#WWB$zfh+X`U zmvvY1m{a}j$4DrR+@GkLu+leSt=u|jhwgQdf$OCx|C+4Z;dQe=AzGF$7&0Cpb;azS z22!WF*-M6Q8uX+dW7I2V%Qkq&rPXYU4R+FRS@O_xU?^X&$iz@P&6?`_l zBXpOsM?h#gFJFq-ykZqImXXGl=SbjDfSzVc8C(*(kXll92*#{#_XvdEJVas71mOKo z=E4UCsQ&4JO`dAy^ zUWpDwzT8RQTwUe7x zsX=?^XL*Uz&dXP@t^SrR@o{)REUoq=m7PCBGGagG|cvJS4Sc&93)WfO0;^mls&Onk*aIiW$cr9RAJKT^Czgv-nq!3AK{n~ov`dB&8e4c z;Kgu%GZs09c5qDDz}ud;g12WsiS0M^78Ot*SHMJC2&<4D$!m^mmb)O^eJi)uO{5vg zk8ldq;<>#_=&FZg;ynvjZRU<_R1;B7y3rU>%A`Dwv;SoPX0L$*vnIZ@9cGb2 ze8!nuR5DRpe-6e?!#BvUZ-#TZNU^e0!VpFs`DS<aDfn+03#X2IiP-G|NmU3Fh0`1X6!1J!`>O z8vg)7J5;>_vhG02oCtA9JVBj2JTWP0DhxvnTyluX;F5txEKvs)b{qukF3~!l_5KM7 zS@)#jt9n6(o(Z1om8Vk3N<8TV4O`M5Rhic~bqNWiHYMt%!=qsk^)fThbnF^p%3tfp zT{Ig1NMo?oI$?#uLZT2yQCD0Fz|9V#b-xoKPvL(j=E#@4;IzQKfi)9C3khLIsp($w znEaBJcK5(atm46nWFP(NLabrVX; z8RKY?7UiEs_~Aw2Pk`wqTT?%UBa{gj{<`HY`apte_q{a3Dt5Pahp zyF%^%26cWLK~v;uRO;0cm$}euD>05^zBJ+g?jcZB0u580hhHGtNtEb!Lxoln%&t(~ z4Tmg85)btfJS*rK;}L^jgD#_9f-Ikdzb)1dYkI;@;$|_w(Td90_=l9JdUzuthAqza z>9C+d&4<^w|*UzrHsO4M=f~325AK&J{1(&uPeIs`^zK zsh%_d!cR!pImj0PhxHTd28uIiSCA{lE5$k&dC)fpQJ#=sTdV`tw1=PM(QJO=<>uwv z^;s;C2FMrU6@xhqN^r0p<4ZzAP{1@!BqPo^ZP3&3rvR%(tk{)Ik=aCDqSJR^u@v+3 z8mwX+MU)f!NlY^ttioJ?uK^eoCMtbrM%hoiUAlA4MBk+ZO3=Es7~iG83d{8E+G9q9 zG2Z$5a6h<_5MX$ipsH`(>}{b_mes!?vvUQDS5#fTNotgt5u2X!8k3$vi8W9^Y^apm zDcu{J?hCH|ljIWiS=Ao(v~&=r6h(9EHV~Vrl!y;m)K7(76LARZAEJg*v)7U`7ItkV z$5+H!s5nSqh@gp~iFEzcGt}L`B0+$GMZ6kf4t5UvvaQyHdFhD?^NJ-dekrD#g%*b& z3TBV1?q0GJ8{f=V=ev*{*BopTeQ(_L;(IcQB7wl;Dj(f{5!?E3XVg4KEbH)(iT?K0 zRM32KAfM(}iIlq;J5E{YprhasH2aw5xZJ$0z4V@8l1pfkqLQ(C8Y<{RE!^@!Bj|z> z({nER{GoJG|2u<6*{MEvCUT*{L_It1DQF}*Wv+GSQkG;S#)&+Mr^WilN;k~iWnIE^ z>(7GuA`XfcK`&2w@w^7y@h~XaOt`>%j+EuPJXYOY$DX6p2F(!j)`kqP0L3*no@B%a zj&*q)nK$ZypJIw#m|*>bGE*&m*u8WCRB1sPtC&60Wj7XKY1E#agfhky3>;1Hf27+8 zH1s2toTLB7ixVWhHU!ZASkj6b0hW4r_7%lo5Y1@pCB>Wfbpo(gD~}liV)%*<6fmmo zra+Ly+jZ|{BurWNZSQiYjNz!@CH(bukHL`obDS#PCW3FWq$0vHB;PnzJdaO+Z;xZL zeE5YY!;SSA{(cTQ@IDLK=oJ~xI7I`&|X_)#UA*F-_tBzQZ3%hngijYmlwy2nVeDMAGD68p&b;3 zN}}(SZ?|~XAJ-zY$9Bh^Jg|h7-&Bf|k4NSb)tpOiz9L-O!K0V6w!xflc3SLgI>@O< zMsXvGMpok{M@A`hj3wPd+GKsbW(CP&YqXoo#>tCSl7zx3NJzopKi zpw&cKx}3_rGoEn~bN(nMoj$8vMjfe0bH5oG<4Ao!wJ129Vr~+Py%{$CU_CeTPW{tc zx9!ilr{LOJ62jfHWO4Od3Q+@$GW0eDz{TF<%!E)4nopLjv1~a&{8SATQnS>-jWT#} z-`}mBCT{P^^J}y|Kc?CDiu_Xj7O;QbI}NWYEe&OXmDy-%2J{MvbIZyG=Tc14ovXeZJAd@ANMsXvA{1BZ=~eRjSkRr- zv6+dnbN)-f{~fz3Uv%yFmz;`OwGZH=+y7{L46G*?#Oc7YkRBQ||G8TOusp6b6L3)7 zWgyz2_FftPY9qJWW=GH5Ctv0zn;ydvwH#UO4aisabJloK8xZ15E-?x(uFDW^`JE>~ zM20kwQgYcXH#=z6|E@2U!-I+1-x|fax=FT%jaR5{7zp(lNFzdF>E`oA?q}P8O?^Uz z#oXo7_84a`sPW2jr$`(4==}qtH82EkI zG=gyu>R9F}-`3fANSG=+ zaTov{={7}tSE0qwU5~FmM$m)7G9FfT@fo3U)Zvu=&$VtB#`hIcX*b~cq(JTqIfzS>ih2WKz!N~lw$}QY0 zFNe~BZj+QzQe8eJt#mV<1W1mgr(fkvSqKM^ehncW-;B;&2+t9#>$4gNJ2DiG{JqyP z%~_RD|J7MUTj`yBSn?Vh2dkzWO3vM0mY2Tq_{&~(3w+)sNyJ#o>JaQtQ@O}!<#PNlF z+IP2mbm3uUX2Djs{pk``(Ek@Ss6hv>43K9B7gzM!Yv}Uf>Eh-t{iyY1aw)uYhgdw4 z*^>ozm?2bxqgj>3oL$uorgP)~2%VReF~Txji~km)jC((f0`Q`#3!J% zB8M7%BSS;89B8W)Pp{LZhMb|C80a?%8=7Yqxx?axi*@gh9g(;vI;kr9lLE`xF3MGD zIEN<1fbX>rK|$SFpsSR2ttUea!qy>r?7Kf`S^6_zlIudHJg|Q#ed}EQ@t)kIs>%n< zu9>or?#ZFsRt_0lA---#YwnH3E5TG%4wQ}mBHo#ak*fehJw{w``c^&0@FZOeeo58lW2 zomtZ4(oTvsg@Y87foV4e@7T(PuyRmX#S}aH<)<0}3Lg>OUaCOWY%DE=s}ZJZYN5F0 zS#3&;F{?pv{@8;AE6Q_8+F+&hf#22_zPPR{K-L(Qms`>h6CYuvgE?f#b(B|aRLdcm zpfctv|8ZpOSEs9f6Jtm9$y}IUr!Qirb$JE5N+ia4Ys~DIt`mXYNfA}Be7~!=c!AO+ zs1uA5(7TeyexQob3iy!rdd<-j5((rNwWESb@y=2$aLzcV85>kA=fP_Ea%Rb)+k-Opnr^7LDGRLj<|vUAvr51q z+DsmJ(RE7ZPUL}l>se`HVf4MVOOuJxf%H2e;;Y#r;6vh!jEwg;xK>rYdaSY|pX0Ya zrJbEk>+7#$tv7x3I2S?G=d5jp4|4Kw9OyU7dy@NFbvVM&QQt)4Zt9WnConL`Q&8*w zRD^+Gy&L9IK=$9lz@>2u!bK7%{;nR!5tl|$H4y9^-UV&y-tHwI!R?ji#_<%j&rtpY z*MV=>=bhi5WVuj6Str-mJ3Qt!|bmN zB>pcMTu83U$lSc8_O8jc^Vt_Vo5hP~fTPLRiBT; zd~dROA>)%F+ghqD!pn-3UL%tVsT+#EuOajktUv7;L#(#d8SQ2|xl$@B&v&w1nU1TV zy@@BaoT*R@9B*X!u5PbGa**ogbsfKF+{++|GBj~bFQpPTo7u`dILTl7eDj;PI5AD- zcWrVEG=??X{r0b}5}8bQ!1irPh-p%!xV_;a3>WfgB5fwaiErVcU0>bT8^q_lTaaZt z!ZRC;iGv-QS;@)?mUOcp;=bH9MMv?hXf?Jdl?V@&{=CPXtu-)DcC*5PJ*@=tCZb;; zXT?c)`eZ|PutXx+1E!Tqroat^7EZ!E0XxyRJpx8qoBPDe^Owucsy+cvSpV^}W=u;v`c%Zm`$CriK-h5i2CAji*$VSb2b#a_z8eR^ z{ZG@*m82{P7vj^4zIfSwZh?)sc6WCNrtAb(7_~yMuu3BHzVr+bz<3KhjT~++INYcr zNY)?n!x(4#4zN3jMO-@#uoVI^>lR2=hQ{efmyiW z^Q5bwHyPQU zqWm;|9W_>3J!G)xG#nYU-IKVMq9a0#{#L$K8+j=qFvM!O0~2|&m7F+zdn80iHvwkP zK966B)tHDly_QY{;?^%5@!(wg%2)`6RHMHCB6P5bTF6N8zAwagod*|AJ5Uq)%0F{%moMGO<)+cngzEs7?g#M>{k6HD)Hk1-@ zFv8zn(OG+mTNb`tCaX5Z0V5u6RN0AD9!-TTISht2Y-Ay1yHI;udHCP+$Ok`%yH!D! zteE#i6ldmvPOyL}sLi*}X?cavMYR2ZzS*6+P7s%lpF7W*Om8CxKusC%zt$)!jN}|3(wq*mmW0!mk0QE^E-(Jrigf7vzLEAP^ zH>)op9#<}4Q05vy;ocRvr9%%!f!$EsYa+hDK%3hV$?N2+&)*V7;v>}?R#2BUk(3f8==o zFHgA2w9grDpB!J=kaX%!?}=#tizmNFiYbjKwdj_dIxe+$GC^C%A)vf3YN#k(h=2sSC(YS0Zym%7R=xKs-`$gTCK;5>Et6 z*MZtG4?}v&d{9{tu3JMq;vBi=(+=tOzdTXl^1mjS(TGxi$|rP_!tLMGq}=qWCWNOR zw?P0HV5lZMWs%~Jf+E)BzWRZ-r}Y_)S&I`xx0kD(>tNv9?SU=ed4Ehum=CD?eiJ{>YJSG#-9EFB z*wOvyShCSR&rEAg@@7R&4S26^BKUB4N+vjQTVGzV0s)U52&T`F?=sQO!2G2=)rxf9 zV|QK`nJp5ZHTslYJfXX|tL@2CU?to8<<7t8n&VOSR463h&>!5Sh+=kyiwC4;cK0x> zs81R@?@L#8GRk=tlm-FhfmDH}g_QR!(~=w#kf$>`M>Us3J@F93y(;~Rk;px3aM)izis77Lm;=Sz%qL37`18^29E!SW= z^rv!0A>zzV2z2iT2Fx=(Q5@rXkWx>aGHjm=%+;AI;CW=@UFboOqfC2g4OIZKUV5J@ z4xTuxWL!R4tSL@Hkz#(g&2I0fk;@SmF@TV@g2hy!@J+<(7=q#ZUP8=WE8Q)R^o_Fg za`nzc53}C>Oy^@=%F1YoVG1~~zE?MzXH6XO=K;U>%S$G38qRr%ztP-RL!tAXf2mX(&Ki^;eBp_aiM@3 z&;#d!(hQnM;ixu6<)|wJSQa>afAKqWZP0c?cwo7N_WZJ2L%`9a@3*P$e=_UnD)fXs zDSD&|Ol;BGEA>C2s$g{!R!rq6|9w8=D)e6X+5SYYTL4~kTPVt<^|Oa-4%q$6Ns_8ZOLcj!TeFOoY`@+4ItM;*nx= za_Mtvmv>V*L?N;ISYKrm1{B)IRc7ew&`|kEA8J=a5?IO@BR5TyE_F$X?KUMSLO+q) zSjibnVOZvG3{ZReJQtAY%wSS!kf)sR_d|I`<_-f_QpW=HFS07g5XZA-@mHOaN#X@& zT!MMwTWC1L9ITO~Pjb=InKjYO1lA%9?a?0BJnIKzHKHFn>XBV?UUE7@6;|l3_d{Oa zdtvGk9{LtAo?Vg#i5ypN(ThuY`B|lVtrXjJhf35D4}RE|WW(R!BV4Y4zkZ?gU^lH} zyft6%`6Rs$U#4Wg5mqm&DkG9|1rqa90DI84n^=#MJ!ZL&K^uB#Jx4ocz0Dc(su|B&sd&2Lm|MtlFJH0Pq3H#^Rq zPPQOskQquj)+BRmv!PipY|opbo}4tIm;$Y~PQRHkfWIoR!ndmzJDC^99vCPQ6AQBa zOLq%?(F$i90e8lsmpSFEIQk3sR8y}mF;ikoiQ~H9{>dLMq#_BXdhu|Ym;vuqD;bXR z=y;12`QhJ@TScIaRuYQ33mIw^zHZM&%A)q`)WhF*nxG$%l@i3$dQLbJQ z_+0&)>yRQGT>kP7tiL{3C4LloMcni&;X^Q3P7BU z@JwS?a$(e!Kg6~5oHa{x+J>FxoHA(QYy&6vN7^lu-kLeWZ0c#pSkS4?csMiKd38J>9fZNqLIrSHx4Tj7*oOD5+;gDw*`# zz-6Lpd;}Y6h^{NLkF}-Cr2<*RCL(ZYgqFj;XW0J|D%txch0{Fkyy_=D`)!OecUmaS z>2djz8L-iuWc_pw+%E&1o`ZMj|H!oYOY(W@BU_(t>Z5;nJ#J{`N%^KA&le7B?*`co zL?q0kL1UsPd!b0B-w=UZJ8I@4g-Y_ z63_yTEd%df&4ccG_Px;nT4ttolj?ygWq28Izd4%Oz1HPJWwNl+Cf z@s^hHFDeQ|wF$&rW@b`oOFYWWRYzMn`V@@7gZSn`A*mYH_(`uo%; zJrqY%Zs;fCA1>kq66kH;^bb6rVitP1z$!Vu}7t z_RAbk?!#l<7xrJ>D@6~=P_ZYT%LRoy1g5g)tQ>zOqi)Q!MMbqlx47-K1nS5yc{|AHpr;XjO=oDXcA>w%kX6fDQp{&laL{4jHdd5}WUB4~Ei#b~G_THT16 zIt*y{-l#prurps*HfvfmeY;3{C8ek@4teEKp))d!OP~X8KArl7Q*_87zEbo)0;hXu z?`RO;uH5Xj2RzA&0qeigr3z?-IwbuJ;waJI6?PgnNO_M_8zxx)88+1WlBXrcXMUg6 zskke?CRUVH3$&cF>S|fcJiaL5-afXY%mXriZ$-8Yq_Q&I(a&60a0Z3(j5C+sFX>s5 z&5f4nHe5E0c59$7GZOOfOv@_0#*NR!+vdJoQ(*^pQh*EOaOg!XJWQNV$A+{BDaIF` zf~}InCT3-M3AX*vguPz_RRqSfdyE8!@DuDGQ^hae9!!+`{2Zw!}BxQ@9&!9DbuOw#?c$JNM@ zdK7_ZFTu~XX;za(TmZ9o#Xxx-r62416(JXPL(>SY^K_}!_jH)quX||SSg408|61zXw z)0Uc^fV)?OLDHvdq3CCjM~Te-x^`H))Ae7biPn9kWhs0|kAG})Urhp#PSlbfp}BTs zHo1FkE@w%t1f_(9dk3G5WEa1_GU_{1?Vh+r0u*RW1-);{spc2lXAXoNaZp)QQj=O7 z@A$kvEEtPU}M^%lu5K&^@XcDPr`+q2*1=zh5!z43ax26(-}k#AT__@RXO z@lMeC$^!EL7AP1Q{znB078VBj|FgzFrY#x0E8@_xP+f8ZHZt}Aj`@|aGBQvl=q%QR z2rCG2jpev~mOAII3$TIwo#`h0`;xHA-12k|%W1VA7FO)luxcbP)$@&~^oOJjBE=r& z>eKbz?TI-2RCM-yFfuJ54o694{iIIC{BoYkgZZ_@4{_gWN3VkQ0QlkXe00m^v4u$I zA$I_LXJg6GR@q41AVtGYrteD=vn3anjN=~#-`>U`{>T%Z` zh-&f(=z6RN4PkPzXUBVy_7fT5Jfh2uN^2YrOD`7+436jX(h&rp3$%7UcAenHU99l@ z?3fLSG#8^Sea7K`Mp{T=8q3ur$TxsXCW(`6+i^1U7vOnf*>Bq>e5ys0WA zl6JJKYRmGi_xRs+Se>4f?EJR#022mIFU%+>?`5KE>aSeUOJ==LE<7Fwhb z1AE+w^L>LA@BAD?z|SQh+1s<5M3y*@tbtJ1kr%ejG!;p%BA%iL3}nq z0VMfUKEc1Xzj&*m=ob^hY$Hng-G|5Jyzx?pW<)M_=XZ?{@N=XQ@$~t`LT^Shnctsy zVZP2L{mb?*n-f$2(R7x-rpH+#Otl=0Za^S*5|~b(Ca*9mYIgg80#kqwi`1ibENgItu0M`+5yH(U!f>3!|fSR zKy)Fku&Fgb+X%v}o`5&k{KE%pG2hl$?L$Nm`k?gFU>ZR-IrQ=tIO80^jF zFgWY&m3taBlL*`P>0k$t$@lSeJCWU-eQ-VFZ1uHhMj&X;pH1AD%EKgRv*Mw_bKZw} zt7jHnv|^3!<_4}VTWx;6d6KSNBJZiD@O7#cQJVc*0Av=#iN-aq!M^ZkgPq#Oyla1#HF*R0R7(|>c zjz>!VH0U)2YwnH>rw}f)anhg#QT4;pD8lE-*Uwm{`!2S_pUYWnR4!ODWtHzEYzQ8y z$e(PMB_JriCqFJyT#vNpYvR)28u0FGqft&FuQm0(W#7?-dM_^3kgeSWFaaS-I zmk%!EKJo{XTvSvGks_Mij(Pc;#UVk?VGM}w!smmav9|QW-$(exsg?31vN=j}%k{c_ z&D{sgy4+QRufkn8{ih~^Ys2y>XGW)wPcO3BKCk+zQY;6KTuZKRFV`~1sAExc0(Y?1 zy6_&oF2Su%Vu{RSP=e<(M4JonpZm4x+$#$^hCN<9u+ROP(~mS8ELc5E_hjhnR{V%b z8-Hu7bmiYHJf`TTl+1>vK#Py;Ts*2g zktb#=kwv4_(JfAO1p@_HA6pPEpy^I5TB<(x$URZ(I?}(sD)+g*y+Yk-7fI0=pN(x9 zv8NYiGq8>r^!sqhyU6dUhbpjoa?6)7#5C)qT))<)iP1Qxn{0H*7WnG2U6PcD6(p63 zq#Zr+#{LB=M44vO48Li@GCv71O8pb@j5S57z6^ZJJW}Yp0+OflgyfZQMq@ zSf%|f=Bi=ntcIX+Sk#0>!OJq206_M(W;_<#;L(~~;baUcF_mgx&rMkAz?YrFkWgMJ z`eYNQA>VuxN% zgLBx}z4KUh2>T>6%(y`$PPdodN-~{4?5AmSyQ{z^jzv)gru2m+!2``P_1o$*<4dbI`__^+e|o zhZ@Yp2;qF4j~fKcfhm2eH{aqIBn5U6_rD{=V$*WDXWXImb!(j2sDBeHcZ!hWt{}>j zxww<6r|98D7$*m!(nImb$af7htNhAx6A$HwEV^+c7S)koLGe=AxG;7)W_#fCJfEFg zN@x5ZUiT&Qfk_zlIJ`kFOsXRxirf)@p>O|3=%lVAoawH3Z)8tA{iiG}!kH_Eqedx3 z6;?jKM(Z4_>%u8yB@^?O92mp6C=W6nMe(R7hEbH@x#ebLtLe5`PpA&jO7cV!e{-xG6RU(jq}pLz{WOB85xFi-F*eJQJ^OKWuhsASAA8aQuM&T-GLGzJ=NxnIz3i+*)Bd^@ z|7#(&@-kEgetVjRIj%q5>>@BoCx9Csm{l>xjdN&cshD4d3u*Cr+$Nn{HB|K|)^KLF zrQ<;uVRvSoWf+lFo*DDReCJqtAAC1_u;vIRNrc3M=}6>H_XHo{r3dlDrRYdbB6Zpj z9?zb2?28Utx-m!VpHE3Hb30X{FYRqhRhCGaDA{JTJUcRN7&Y_QIRm)AL3wwFit!KZ9f>&bjQcPc+kfbfuRV9 zZAh9WCt|Q@r!R;(q_jv6Kx zmN%V!H|<6eHXeA}%!@#+2qm5ID>w5HhK_goj+z&zJyF$-@o4ZxhP<~rptb7=pKXcl zn0|5wOgix1L10yAx{cBrTqY}LZdPK)OgXpzo#mEz*ZeWjZhiUG-rB0r1{V^$<}8TK zy$Ypznq;7;)Y<#O5xuLp0dwq+NbYm!66p0?Rh~K#r*sRyr6=E^D+{eE`Of?xcO8Wz z2w%1xjwz!fvPGR1I3?s}kt7TgiuS4#MOI^5rF6d?Opgd9k3yKbM_B%~dT@f)0mXO> znN#%5yjfvYBeC2iTJMx1gwEMF4py9j_uS$f?n8?CxWHxnCa+kRqJJ5dU;L#_A{sZn zX#TF<6{e@ME&Wl03f@bnLhrP2{erxWIzKa=xo$+ZKRgaUs!VG?J)lUtWVPO1AF0)< zWAq~Bj2+(F(@w##bx&kbh+1)P&fNP$C+L1=xavm%7o|m_TJVo#MNz0K%h9G#vhlSw zV_wPtrdHSOIPkzo%hF^Ye4j@Yn}}U``M@*HKgJ?ec%!4L4y_wsk}e|k_(&^9#Q3Ye zBtjzBhsh%&xY19H)Ch+Pk!D8E>8(iXn*SyPmpqPoCmzNWh9ul$^^8)YJ5=&x0sw^f zGqrswe4d$w7P`K>78c?!L1My@Lhi$omxW}~*kkE+(DFfU4pU#TCTDi3QBb+@bATM2 zS?L&^*{}bpnoZ3930}Wu7qdrsMagalfmeCrZu;csb=*3y-r=1ha5|FwwktY}p@R_} zZxgU&9cZj8WBwaD&HrKRV50v4wn$>s(!N%g*XY^d5Nx42?7%_Sra^V2p!RHLK)zl% zH*}10s4V<_nhuK;+n}SutYBvf*Z#6!y$W^eX7B(S&vVsut)v_V7VYyu;g&a8^*IP` zmvauF8_?^~|c28%BAXTnti*#XJhwiUYe#$CB!k&c`Bd_f)t9 zxD`Xq1DD~p2F&!Pi-fYaYVkA6IC)@MaudC3f_W9aBZcV!Y#rOIBvT3_dmg}kpuq{V zO$^nUj+OYO4%#e{F4MFtCqd))p1W}Tz!Q$m;gng;k9k>sundfOX$W9r_4M8EtUNpG zM>{fxG?KKS<7D*5EUZX@JZ`BjzEqB1%U5C`-q^^+6t`}^IRj^Ii+Um?2d$84ur$&W zI9VLleT;Xa+=x?B@vr?1H=z~m`3(SH9Xb5YQtS6_n2nvJZtq;Es1PzMO%`p^NtkWv z=YC|ChO&#y5!k`vj@E{Q+U)1fTEG1Z|FC$I$tt#)12;?`8N$BbG}*3lZ^)FmUQ@SR zkouWM66sks>QwOs(w^VcsX9i#TP@Ehu?6BkFoT!QIx9iKvcmuQZT?FY4m?UPa+1${ zMjGFIhT<#P!=F8-943}U<~*|*_+-{dx4NT#WNQ!Q4K8c!r&-sA1}_+*Lmw3v-&bE{ za!O~%45zx0vWq2v`c1&zI7!-w4qeHCwuNypI=qUH?ygNc8v&qtCiwg@DzLVOu9-eR zor5CCHI4vau7yT1-w4sj{gn`rcT=IDghfF0ko4(kAG;;gW)oWq#{qN0Bz(ud`e%%A zMn>n=x@`2y)yuUhbHY>HPsMaBA{&GBDH!hLW*mq5GP%!(R}bWC=2r>!?3EMyv@zx5 ze)$~a6&ttsgU^M=<6xDhvGCB*N%%PHTFQ^AVE5K>`gw>-{})i!5B61@N&mq>T8m>5 zns(>gg4HBa_jqDVjn9MliYiU?uQ}o?uwduSuUlytw$e$xOC>%AvoF>hFM!?8K6)nN zh4%C`!4nQc|uLj`Q^u$4Jyz*WgRzK6o8F(m;lgKJq)9UET+nmoDs{+3dqoNnx@fvRsp*)Xj|&43T8U&={`p4HmkWW2D-QJvF2C{Y63^-mDh%Us;B| zVN92>(lZ0-15{v(@@;&#UxBy1)*@XmfR4&3RrbE1ZjP1pPPTnkm$lEY{^P1h@Z%3U zBv?M#SdO$U#_B~z_oA{IoFOtbn`;TM9=$GYQnw3`x9YCGwovcz1X(`U(jBa-#v-oM zmN^_P$Cy{cY`U|Uid1Y7QLTH3&nClWp<}9(S30Rs2+cmRYk?l>hX1NX2PXR7|GT*{ z;9r^Wt^hq-_Rv1UIWqqzNj?0(`Fb2)cy~r)HLV4okneY{m`Sa-)PF?D3;JD?e{rZd3OhjfnCA&;|pN3PA6x%{8HDgut zPNuKMOH#90bDlj(vZ5*~UE!JrMt|*!a?%cmcN?o<_CnorURy}cdgL2H&F~sJ60J*~ z4fU4<8r>RaP*GbuF0kwo?1mH#Hx7S|R)Cyj<$?(#o9&j~6w`W#yRDnlanf12#z;fN zmys(f3?D7Cs}6);09#F1Rm#V34(WYTil~O)LE=X#V|*isMR0Ikbi9{JKbW7Dk{KW~ z^zl&nHr2(o2YV6CP!v@dV=GylDNtLNHp9^>yninBE56uY{kpvBxt{B{M zij1?8q`jX-Tbr~xyv&bYe3YVU4|7h3KO=4^MH#-}(RKX#IhX1BG`gW^l-Xdudwa&l z=lwzO;bhX`XpN6C26)z;SJfB&{}gkX{)fd}`hPoZcJ{ZJ%kI!|P(4@&YWd^?5(({6C!nq{kINmIHvS$q^aX0Ck5Kri&W{gJ zy#*LLA}Yl7=hE%S_TecddcJD9>-mz*H-x}Tb#ce>d`*!;@0KZeZF;f2sR%oj@p13) zym!lYnYh7o#Gm=`44ib1>TmVZ@p)X)ZF*=sf~zjh-a`ERX9L}5pRi#>4Jt!${|%5R zL0CN8hBE7|g(~QHN*X$HDCkTmygG(&Tqb`&iBgbFcl~M#S#~0GF<4QcfSGZ0+zcr~ zeS-`!Zmsk+(_-s?exKxoqKTVIcO;_=f1xCE)$wtQA*NSR@cr0!e*#s{oeFTahN)~^ z#E+s}Xf*!~_j~G_pQg1wyHs|hvZfXPSXpC=vo(X1rodKc*O0w+&EE@}%4pCF`B5q2 z#?!p$K5X-|4f3l2t^&mu=xXp#P;wu9UOY!A6b_i(vr1Gf?4}6_2h|(0z?TJrkJkgh zb8_9h|A-8i_xrQQb2XhrbGQQj^7EH$)Yr3O02ef0HQ(#Q(@D0>$0PbqWZ%d52qG%< z;y#q_GWjJQNAq{%(cn}Hwh#D-mfw*?36PgeLE<~gGCZ14x4orSa{@Yi@3#YdZ+p>H z*I+7bzX)OyZ+?&@tf+8g-uax`YK{qbW(FRb;)>eov$L(>LyAs$eU7nM=LJo)OLVb0f1?J9cJ!p8pD3G!7!P6lmTIy_{&3%lZxQ(!cvi|f9{B(clDvyNBSx-6245 zcXxMp_u%gC5G=U6yEN|5xVy{k?6dbB=l{ojxDWlXdUf~cHD^`LlCQoJW2f=h&VHwT z2uuGY9?n$l_(P<~H{EdSMr5PPB5vku29*7au_h)Z)t64ot6m@*#gOBY&9Pm3@@*a@ha$n~A&#*Md+`@hZ5)~h&&Zeb zLSP5+`hW*4f{6L?>@PA@RcF%RIk%5g#{QNsiO(Io7Cox_$cpz!ISTMcDhiC@>qS;F zl`v#b&YulV_?Zy%)i<+OwUS@kpgzTd_or9|B*M5ZPuwY9#>H*OmN<iwc`;w@kH-18phC9!tab-vdvBMW^NVtB_Y7jN2ek&2snmW953C*ssi&|){o`uR~eu0adq`iB|~ zoXWOJm4&yVN)-<6j7k;CY*9^Xhd_8-OynH120_B?eFcd%@73AEp5}_Ilxv4!m8#f# zn2Ltvv*5x;9O|()@_#Cpa?uk$_@hAPonbSV;URVmMk&?Fci zE-CU?P6^q~P~5n^8DUtIS$Ce6<(&Q)gV{G2S|Pbf!Q92@<=&fqlZ+YHjQ0G!vM+kU zUdxP?;l`0WM_JgVOkx`(rDA#dOPAM>HRtVKwoUqz(F-vd(lIf5_nSI_YbcXO#w=H| z&F8i*=E{N&zOu>TS+4yoHFgj}pq<7d9@&eyjNy{+bXvu`ndPJa0=O)Pn=MVc7^0~ALI_ZUr!`fHV7M8q@ z^P}0H5zE-nV%n9BIW5#c>!l>p3Qp0D-`&CbsW17w_{G0oNtXuD#u0&JP7cIW zw8_yXF;yk&Dd6L^Lf~YJ-}hzRVk0!0B4Z3IG(|ikoOP#^V`?vBBK6CI$IR9(w`(`} zZMFrYR;{)Lk9fv=6A0oF8SZi}wTPPRaEOM~nj`PmG0%zgw~282RNzx)S{HkbJABuA zgK?XddxK{=7kZD>(f-^Bcy^rB{q=hqVPV-lKmwQAi%B}MIZ+op)5Mn zMQpAT;Zkl(!CZ3uz}t5og81nqxdE+MOMjyihZ^j}RY*zzWS3`9@|Y&JpgmZ3+<0K| zY}_QD2MS@<(TV&i(QALf_s$;*MpC4(eMLraJ8!P$s7@Fcj^KCWLK z{rqhb8G>^Dzzl7CU@K}h)~Ft{^Gc#ud;YT**iWpIJwpAcha^iF-i;sI-V%*k+|7m} zWyHY7KgE{>X>Z@(@aQ71Py!0Bkn{L2da;R-=oAb%O<<4x+WLapPu1H?OFDz2BWj}a)s)T_xkLwH|3 zp@oMdW?=9=(PwR{?4Gf$x$Y*F{y8=f!OWT`? zHWSJ#2W}mjIi&bCT<2I8a<5Z6op#@WB{{zD55Ai5eH<(y`rMY&UP!hbx)t~;&Wfz~ zt1Z3x1}SMNmd|4sw`&Jz+ME1@G!3`z6%wnA-ptQqJabxRXl#hwaxaWDA<5OfeQtL1 zv*nN??-FOSjFLwiGt-yjd~MXuLb9S2J_A@;iQ@New1hO9vrjK0{X86xu(KwQt0Sbm zrPDxi9E&VyEKD=GqO-lZEgY>7I&ZY-#cWI7J3(~f?#W6{-yoewJ6OWaws>tM<~MSO zFQglf5yDGyj~T*C%{YvxGru#(;Qb0O-t=|naqu%g$5sgJ@MB{o)4#b_-|a;4tMbZ* zGvqTw@d@y$NY`-?1}_Rjr~C%uY#K9^LH;o)dP6w3PkXkm1l&x?&>(^D$ZA(lj>@g$M4)4|X*FY$AxZCYQ|vVqIeo zxMNC+#DM3}Z`v&-t5-Gw1!T2!Sxrs~2;$kX-|^}O`Zvl69~k9Mes|*UnXGP&TXD*` z^i#!;*uOU~Utig^L1Dq3JP|%L=c#F+35PfUtnEzN@J$O$(bEc5_GpSd84@6k?%~6+92?qT;qce@JbYp=_hc_JmYAWc=Ji%!=6?9MAUUx% z8#65~Sf#dL#dD`wKIEg3rBVs=5m8HKK@AJc}BD)HB#yvO?bzOFP&SAu%2_o6IP(RfGexZA-y(oGlmw z9LZj3owbx3dm+ypyk6LG-pc$oVx~RGlrlYCwwd+Yl2N0*&*N7M2bB9sktVXnC$*cc zSDMm6NQt}|gc19KjeEMvBk< zC*pyHNdUJ#r+&(~eFpexWN*0dcKEmALVx6W&+R%TEcHg|W|UU)t56Ze<7*tHtS-N_ z{w`4yK<}XYM@Q1Hkl$TqaW~hOfw&%2m0<>fwRP!nie#@5CPsd($o?U-e5W78VJMy;&pxS>qqaK@~LUo{q zoPv>HlVJ7RbQplUvcNlBF^di?QZu{oSnG+;YYb6hq@OO8(EKA9ZHQB?M%;%c)tGS_ zB_Cw`yq10=dRT;mw5>;F-~PL#uO?hgQ#Bl)>^;{unmYdTkoj%qH*pK?4MFN%r(;*? zPy9$Td~$>&qa54WReb|z6gQv=ub(v!Af6=SOEotAGYGC?lxq;B&qyDW!V zA!EAdni1=nJELca!5X@d)pQr5rC7_}q9yGE54ij%o)9dxkiB$$W*%4Oh zworC4pW#_=(I#`y_S?v8d*0e((TlymggVy@b*}*jDJ&3pR-k@6o5M@mga~2Fk~Nvx z1WxWU3yCto6b<*mBf7@ciZu#y}2D~mS^TPz+jaik4&ws}K; z7os3=oENIZGnC4vqes_ZkF;MLTnnrEWh_amhp(oBw8{uBYKO(h=80~x!=|J&p2>;J zR=-AXkV&}UdJ>wHwNpQknP4R^52!HSUlzr7Tf(}NkBB}A-t9_Db-!`tT zzCNZCjHb2aszh{P_=Bc?)!CO~du9XDd}U4$=MNh3s=u)J9NP<@P)bvBd24V5KQ?Q$ zPT^4~jVGS>iqU71BRYlZ4zJ&Xt#sEZaA>C0YS<+FU<;+ql}>+=1l;7 z)GU5ngdC^LE_Zu;qW)0wZk~1WZc!MjDwr%q}AP@9|TM^>t0(NKd~9o z^gP$NR5d+m!06-^R|GE&I3g*BP%$_Wjf$Zo{Winlw?Im%SocV6ce7Hz%vcC@>asd1^E++8Ufae3_IK@&4|Sr$8gdzXLtBF z@B*RD2~4a{J#=dU&3Dk%ei7eNC`Rmf+p-aySZiy(os%bB)Y;#)8=OTcu#nnXo24*o z?0uS>2M{{4{!+~u&8}$xSejtrt*5c|j`ijnAv(Xk^y&&rV%(OC{vcWXLFuVUv`RA- z8@0ow@JF3+$;4eRaO*S7<|`cp-B|o=BYl&$BiDjHF&>g~HX7f4`hm1vyZ+}XSllUl zwLH)1`6b|^;Nl9dwRPCwET+mwkSQ;&?NP?I%deYzM)~oT6i}cWqVwp6jTnYV2)@W` z(*rfPkYo^uyTHC}^*`p8lIibWsX=Xlw33x3=t^FaXl26A{iKb(k5Lvf7#C{(_hZ9^ z9dIemj$IX8;#Zcer~%n0=ES|@oTH&OOjg1Nx1;*?ZFenhKBNUR?nmTHD@=M`v-)+R zaq*fVUNskM?SnRfv_|IknS1E!)*(tJUgKBRmB~SX8TUq|fkKQ>Mec%;EErIThyysfE3t)%zGioaVT#WeCP0qThz|>i(0c{Z! zOP`xg24Kwez7up`1_0-aDGWCD05n2{ZOH!?Vb7bRFZbG(e-<)3CKhE)+ZSJ}061CC zZwoS}R_0yFy4t*zGiBA7$^&%InnZqr{e_>Zf1A@x3FQE(EEhGhL%$MA#{gHiEKh!W zB8<|+0N1W@+gHF!#Mt_tfXPEceY8`-en>^m&G6A036+j4mz|#vk{7pjU9pAt3qU^9 zmmirjZ|4o+)$I*y=lF!zuz6x4dam$qrn2d&3TG{satyPxKM+E)BbmF0pRNgf033dm zrb*eNh+;SP{Y}5Z+NT?`KBf!=m98`l4Ge>p+(Wt2!V?OgX^#OBOcL|Cq|8M3ErvHCLu51j9EdLvoJD?3}8*YyBuGW>G0$V7s z9qHZzPdK%4iP!TNCr_mb@IJin$VzF2m%7*gcvom7B^-50TAvRb*(z?8oyiwV_$EAK z#XzSE9_}s%ujnq;rUJX{ zKDT&>)C;G-ruVvahaG=0#4FIbAg1&atCBlEF0?)u^tzaXjr9CUf;3VtV)lKrp{(%7 zM+E3gzVC0sx*r$uG-ZVkFV%(LCA~bWTeaV>2?^CNPI@{gxe%#g+$hkh=LYq3>E+7> zERC%mKao2mIVHiQRyGKUIr(NQU{;t$U%T66%qRw+bvgMI~-kSOsY3i+m%A%x5L38R_m{CX5Hnm#4Mcg}6C)Mr4DI@k` z{ObOK5%5dx31vlNN^Ru=@DIQi*;nIVY_GYvkxwo)Qi!=5s(5)!jqWbk;(ga@8zTajB8PW_WnJ-ouQO$#B;+3 z3MuZDZ!G8xt5!k3%a7j{gwBmi^9^cTV8*@hZbgFv!J75s&B}ysoyLiLvewN|N34}YSu;hdF3LR z)Q(yaf9KknAxvxAWL)+)xg9H`Fw?*`++XBFi;f@{ki*ESqw85XVRH%yLWrL3sGU{!A)0 zQ&M}|rrSB@^u&!d#+uHji;0_4vuHg2l+F`Po%750$;f+;myvdoN|!w*1yje_>!Wtu zv6CD_U1!YIr^V*|9AmHs%F+1Tb`0CuhcDpOBhGhU)koJ2gSRLcX%cT}K8OG9;ckfE zYW-{FY^+m<(3438x(d1mR1Qv3*!Y*mMARQ{_Cm>Fud6XFf27DU@!dU|qwVd;n;BzE z)NDiJPDwQUY#|kuZV03n*)(crmje46<7Ke?0r8m91Znn_FqY#?62OuXJ$P0r#Ocq* zV6P(0c!M=VQ8iNQ9N$K7#tb2<)JXj5F1usr>&uSHXj1A;ATbi!s(go?J@jVj<`)pj zBL#-a?*r}gl#h@U=}-3J2KuK3h06!N@!_7ZZ+=|4`aYgqz)qAfBsMJW{WOMun^h;) zI2zmezFcs7AgEc{)@=PQ^E7E41Qk|xT4^=kXA1M{)l;*B++^L%TN`2QV!m8dCu(2$ zhcjZ5VR?9=IX(V&iUJ8uq7J)KcX|vi4!3!;3~4Y*e2J|sxnFhe5d3yfshM_^vBDgQ z1?HRpU^J=OTog(Ep&U3&D0pTtOh`qsv^0?;rBN^4j^h@>vR$DNK?&cv9H+U{0?iJQ z17$)uRA}v~t9`NphH=;+s?D&(-AO=N%8nAwMa6pPWJ?e$fdgITLmw^~Eti2>owi!W zL3Z-edT*wN;41#Y>~)(Y@;lqk_4h^#;V|X2V2biO1I+gXOLY;P29f(ly1DWGE8MMs zFWs4MW-q_FUuU@D9{7n5x5NOuiAF!h1O+0+d`H|uIi7-%C{2_#(5$1Wi}z7N$6WJ9xX4`clPEvpnKDLX$CEEcyS$p0&j2LxJC33ukQ~9G^L)0yCGA)D*d0z^S$JTSMfF-@)xxH9 zimJ0Npvd~lkK!Tj9<~sw4in0AB#)ROt>#UYLodl!u0u-axXQi#oqV*O$OFX&cD1VM z=e@F6$aYv_{BOXF9kry7>n_Pln9XhSZH`E50_*kOEdx}REj185%Yn(Q|5o)wn9r-S=BdB;DV6JkSHNe@mufCvspeQ@RoA>cW1_j=!LDCp zP-k0F*EOEJ6v?O+5U3^v%^6->{TZM6;M(T+X2aN?soFhGlgC#F;}Be$1s(prV`HS& zuDr{)1#7D3g0@IoMsxIjU2t>6ZN~qA9FoA%so!Gv;$3NC86h1jm?Tf?EP1XKiugo%i?!RE~>vbRkx57kv0XuMNn?Cr0TLr z%P!~>aH5_2r4nOXAK*|eTIVHlj zp|^NppX(>asi{iAQ=1Z=9`66Gk+77KzZI6WUY%Q2k$dy-r}ltS4}3GV5>Hb_r-sWB zojZ3pj(v-8%B9gpENRMi&8!rQzWMZ_R7a?spVQ5n``CBMI<=-H+Q5AKvd;GXu4L2q z1K_H5)rvQDM2IMfqq$)c>W5&@Atn`@aRwUzjX4uf$wmBZGI$4juQhiJF_`w_6j750Q^^6X^?&TbZ*9c?p?`7%!epK z>rA$Pc({*lkxG<{_6y?jOt1zfP<3G+=a7-2F4Hi<=%eqm>AUpRliH_$lz4+gau0CV#kfE9T@2 zT>PJULq6st?`@qtm}BRLKV%9Y4(nRgi;~)#Dm3NY_xjfO=FZ0iXD3<`gq5yYx*@OO zeseu6A0n$2XaaT;A;GfW*1Uq!{MoPigMooNb{98DoiW~5PXVf*B6a*3s-effU`EsO5?OIVlAq1~ z3^B`8_I7bkeUvKbz&Xs}Jw>adO+l+$384N`lZAbU$5iiU$o9UF<@5%1kT(S}@U8-Bvx29-HOIJ*s>dAW8x?hi7o$F{we-Fc_XJL(zEo*eKm~*g5 zL{r^4)1sF;XEt!^u$gn0U4qSTZn*_L4yC8Pzh}W)(e)wGgoXNk>7U5P(^Au<~ePL!nv$)jWU{Q zd7mOXr(=q$p>paqv0>CjIu+EuYIA+s_BBWwcA?!#0&BpOai6cI>=bVv!4K9SEF za6ND!Vw%yIU?&bv0>u~N6lPBrm6f%=LAS#8#M$ZkTTnm9J`r;?J{1)8-_xV$iNR;J zXDSyEU1h`lh;!R+e%+9M%e-IE(bo9%>pjx`x|FdAmHMHZqhn92py9QW*J8CvkS|(g>Ej#N<;R$Ce+5M&@2EN4)1Fc!A z{yx3CNRpwJMs>MtqJmTzu*f6y?o5|Y+>iv3V+;`vXm$yBsWstjcV28+0g_Y&Q`KhE zJ)7bsgn%xJEVw7P{-aCyLdBDkj;0$T{qmNvfX0#*TjeE-^AWkcA=B>9L#Cin;{TmLyLPO$jjVK$H@``^gPn?~CrN$ui8Y1HaheKqi0`L_ZLm*5aB@sJhAY+->OiwFB9UUc6 z%|>tV`^wzSQ7$C#?CHV&*cGZURk!eH4&s9EdFyUJTW85%@4-zrhV1=yU-=;NSZZ|| zQ3PhC4cdH#eE$Leu9;YrbTgx^R}BG7VCoiaM(k1=M;9zgYci4vWXmR>mD zV+u_^HGggANq^C|HE390HEnIG`rX8>IJrIJ0Wq?cp|tjr9kC)+N4Z+u5vURF@{}dD zP_jj^lL!~LPCXBNvX+hFLXfDQshL$shiV?Fc3_g!N3hS(ePaO*&!qn~Fz5gpKgJ30 zR5VmQn6Fa^02<^y@-1?9215+%<0q?hs1y;_{@8jZ*XxSV-3#&howaaGLgnLVzB>R6 zk$&(bU_5@}A{Sldus!chKZg4vwRIiAKLF!fw8}4z3@8U9_}-^HjC>xm5Mwj-z#(x`s~}v6=*3if}+!R{X1_RV|-4l*VYs7?_u`E7B}D* zOHT|nS?{e~CiYSN$aELQJrswllSqQgNISx=6-L!Pc!Ij;CbA*nHb7x4+K$!gaNOIc zgV2qj>s)rIjPjqngcSxB`>>Rtdxz5CrbqrHW)_N@L+#?1`XA%A^HZWj9rU0e_FP=w zrmbPLxG&qO+EyjCmzdtK{&;UPYy|AdRjriZJgueEvSQ3k@nsddw~9q=;_vcJ|a&Wick#SqguFKvf6o=>{&ej0b4 zVsy01&^^ma>M%Ik&KSU~=ibeywM72KYLFUa`1Z`}!w*E83?t9X>q8FcdayyCNHWC! zFN}1nx;c6+H3>Zk*D3hhPGn~_sGgnC|KF+xT0ahlimsh3gF?uB6#hNaZa_Yse(@d7 zm2Rb_YU&g*k_GY72#oT6$V)R|1=>fVHCHo%kgcagOx}nld(R=XG<*^+Gait*X|)QB zUShQ{I9M<%txtRe&!!Bmt%*i(!&p)6q%ZBveH6x{1WMKkA7T10%>7duZ^w8J%38|u2f-l_vTod5Nfv5Me{5b% zyJiz%VA3c#H<5ivcTO6cg#JFS4!tlHk5qxdccYZINW(}8CL>mjVPk+ zJ??TY9#MOf>`ZTLsx1K~%j2sPwcgbh`Rasv>@MaPx)w+gI}k5ZvitK~Bdn$K-xym4 z?Fc&!F_e9d1>M$@son2p@eP5Qg0npZ`CS`2YXt zv)S0#|Bod8nYL#2?x4L-jxOUAwBX$vI1OejOM zbW+m8^KWI`_Pg+|XNINJx*1hG7{i8^_qV>igE8M(2;Fu-VC(x?SK0PyEz{!rMc2of zRRM^sl5P`_vq?8t)+4-^?>^y^V}19<`>16?UrcBDRHyF1RFhXo*W+vFq+9!#hI@i&5g+1qz z=q*(hazA#QK?AP%b#Cu`=|&KJ_Qr@P8upV)*68?s;E)V&L=1_Jy!(5qBA?OHF2}Ul z@9jO}?2UbMhWkD1W|oE|=Paf*a6C|^n9V~~mEAs__UeJ-^ptePxqMZ6T!DXQL1Umj z2<2#+Pfy;GZFk1TN=i-MnzDpH#Uc}U@z#j9$y74yf&!)Dolp}Yvv$qJ^GAh{O-uht zb03CLe)iY_#OIDZ&2Go6r)C~7K4I{nxI5XOagy(ZA^*gcg?t6Q0d*8`p0I){Yefns z>!lMv2>cOGM>Z^5Zn+K|M9WDjyyEzug#EuCKy~6&(F)h~j2*?&N0&Vxt__UbO)jfMw${olX0mIxUcZ=}MtzuW@3 zV8h@ZV4H$Y)H6GLc71bUh6^ZCim9*=XyA06LggW1hYIS z$Q(k5*z##Uc^>$ci~4mW{tGi37vcq((qTxfWx~PUo^nHALO^S!H-uMir zD4gaBg(iw7&-y0rg|VEN2f5CHv`k92n%!`IeS6C+A{17YB#sLIg}(o}Jcq88#y$5N zbU`Hi`!ir@49oZF2}G>(x~GhmaUx5f;_F$E_*ueTh=|u$D<8?w{z=A{n)}T*zrp3b z3F&64qeM33V*9L1*Y_E)hs1C1TfGNxX2{TJs`a=eFSE)Zj2GrU@jfeFH)5~*U5T4w zfl_AfVnN(F|Gg_M!_Foym8>BGy7_2{RXkg+oU@#KVr*7H!)YkXi~nQ@qvQ9JB1da> zAX2M}`a+fudlLt5NBd{?(TB1NpY6nhoR+bEKog_vY;4<2GdzFy)>{MW#8v z?0#A7$}zZ(W42QKz%!6S%u0u7)$NF1#9|yPFm^A4@Exbp$#Qx15y#1D<;TtH;8d<5 z2~y*k(pSq9dwhd|W)$HQ5&Vg^o^o#z(KsK?&4JzvJeNLEtwAo_epWJ3Y5?C|w>{7$ z@ys;*bcLYdoNB|qUF}=q{xHkr<^-ywR+t%Z2h1moN)q1yZK3k0cZ|eWda}-GP=P^g zC8i*|*n_tmNmYDznLB4BqhBoDu zGDM~iJb0$(y95_rrdD@+Vdqd*lwei}vPU9xo1o-56vxzOt;o8QZ&jg*6!;vt;iMoK z5z_RaGg#Nzmg%&;sA?0z>lY6nwOCWC9Fn`#m%v_#Ygs0Bp@Kk07CSyzH+sGFZ5aa( znqg`2XG%H@ z60y3tFh|RcU>?(*Nr2YUqaTBDb~O2AYanZpTyS#sC^ZA7#Hblmaj}+^`(z^6WFQ;b zm+|)R_0x=Cj1Vx`%yw4!CZ)q)x__OisP#9q{`o~S{)1EV`EBmRMJ-JMj3-Ya) zQhu_J^NLp+NM}hzn+M19suokn^T7}*UZ69*t)pfCzxyBfcmJg)*Xt2*7`Cv#_h;g< z7_0^q2(Y^~nC()|+Mc{P*$GoH+KY+D*>1vD#}GXhX1={jHmwW&GFc-<7Tr&SKL#@*NN*D)*>rHXQgSkJ>J;D>A&5q+T;%nKV z#ms?pg+BSq)OOl1;@P__oQp1w}heRiJv%u31O%$=Bl-Ju(;)% zpgX=M&>ewF%$m5~qI6;9Ok zk;3o$fY1e(G(zr}^+`BwR#scm*4c|0>BE?#+BjJdxbOR7+>=XJ)ou$PCtOw@4CUqh zM~``?uxOV7uG+lTcx)`~g}rQ!j-GOyNRiwQXO5$HRy-(&-i(&p3>T>OwiDsGz=?jr72eG{(-$s z;CjT>wY(v}Z3>uwN!tFlA48OM@hQ($0^m%|;k;xgyZXT7X* zNtw2GyU4zsrwh({qzJ_7*niG+yC7Ji8&!-XGP^{jGKT->bNJCAQ-j!+68`Yu=u5Kk zG2N#i8>kLIO&YX#cnwNxdm;Gani0GSN=?L(nXF=G8zl1LVznEIflM6NysNH4SmO)X z;H}AgKdR=S$1maLPkfW>+o=V0MD{b3WV&=Ibjq%DGh|_UNs6jls|pOhrX{Nm z#l@E}@{0M?Kk3sAkm!oUo4reWjZv z5uE`Q>0*SdLu9>^sV&hXdU?o#51j*EACQUfeI{7ooWz^=JM7Vt`|A=m*s8y0z0_kNTfLVPWZjKGw(M>r%mJ8tH<_fZf1Q>C+{$Z?{bc!w2CneT{H3TfncaVR@(e%7YseNDY=m zlqVyFPBcNnSYM4V;$D&ad3PBGH3d@smI%3XZfvw(*=yCmH7bvOAe{$cmg%i=^G{dv z{iL*a9mo<*s+wnk@@l&W%U%WY7Wva#D_fTHY?r88C}#h%b-uJs4(AC6>yx1!KNA+s zGGDN%KxC00V?lBrYwk`^uzY3lMfb|iIamVnXjaW7Kevl`=Vcwd!=ESN3I2BU?e5dF zrn|WbE}-zHnVaVSAqwJjxz;~U4(F|^!#p0X5g=%+6js%70->n>$wVzY^jv{GiA8ui z2ZL(IBZt&f&3ueWv0lXcF0T{+a=d@aB+$|}6ah^O*`-q2*Ko=hxUoIM#yv;5K+7ZSR0iU(0EI6eiGTG93yW)K;l-6n!c&iXwuvx#S_WkI%O=+J<})PG=JF`b)nP^ z`95!R){|IF=k81`TrTn9_O+Y)8fG(6_-^s*7#C5|qU8JdqrTk-U-|5Hw?s!BMQf(Wdl!B)w#Y?T;_) zWcgP1Vk-CA3FVv@wp&!I;28C305>;>vg!rcL*g6V$!%|(>vkzr()(vFwrf}!2uA63 z!$bRnS~=Bd1PjmzKH+T52oR-GftiKoCQSLrUn}y>Hd8HnU!V1f;V#UAi3s)pYJZM< z?7S3*>}8wA6nB0UwvNyJ7$g&68GSdB1D#oB1R98h+n)pvu3{FgUgoCu ziOu19spAM~iP&c-*Ew%$(kbt51o2tGp?s|4qg6fl1L;@8DA|j&WNsQ!x<_hCcwupSIp`CxVlq5xRxGStklWT$VyNS)GGL%VSxb=73z_n62HV9z+(+L%S+mm4A&h zZS%0BnnC#0L}BpPXktd@@9uT0uk zm+Dg*EQ|dfCuPU?t5E619NF6s9Gf(%Wz<8^%1bT-0@<2@5x3na{}`;gue*vl zMNPcT;{vTY&;%6xj7Sb%&=ccaKO)xYE!G$(q|r84NM}+N`tGavZBA>bOVAIvg|=uw zqm%m1gy}71sS{G&KNf4hmaIs(P9&4^Jp&DjaqLfwUt@lZL>#q8t~oQ54J{vjcWz+* zIWzXjw!NaKd;&uB66sJEX#7d6_Vc6%s zd+y7~yGcQpffXYnCa-_cV6NVd89j*pp9r7_mTLCgHWQ#K{TSV`3`!T4U;B;tb+l5l9`ounPnXqhX$8P9@1Zu}dID9I9jsMQkn;je?gG2jH z+(HGAHvTTM?*HG~D2lZ|J6Pw6xsRBTb7?OOVI)&$GyUzt)?w}jzjnl!WVdO(1)6hy z{-D+jWL4WbL#ZztwW;`>&%2$2%W3*lX%97dOjomJAZ9L}J+`EqJF0=+-#T>JYdR@y z3*>x0oVEtdv-Xp_vkcs(J_LF;24v|6yL!tqyQ#D~J--i)mH*b1Ij8b^2tst3GZDH- zVA?RMXu2-q_r@93r%wN_@I6D@6+aTXaQuMiw?7OI@(Llewl%xI+tLMWrz=BAdbitQ zx+F>u8Ln$)R1G)bTDV*#L%{q`Jq+3pS8d-k&dm!%-N1Z`KK>OVU4*UqXJmvN?Ls%Z zdZfEAALrDbm7O@EhQ;}X7nd*BWB}M3kBXjA*Ac$%n-ay)Jnn20qh|KK%usLIw8squ zRw8ijq6<}^?QE=;m_&}xCeluU^tuCx8awi$e{LRRtmDdyd+ecWq^9SU5*cc0ErI?&zHf6 z=7UXe)lyz_)2NWY9{)i(nb~MNh~OlXd_s}BSH9#Qj~~(;GE0^&4uzHSLP497n!cL{ zREUe)2{B7jd;De7cBd)8n8U&GU~h%JF?Pf1I=~LH3UFwY=R1+1bFU$taEyufZ`D^yTKYf zpXFn$;iEA(wsz*bQAGSeB~cm}5+a6;aq_n8KiOaT<&kAr5L{ah_e&dyTJtO-5ZQT! zegQj_EEtt1M*JH$)cb9*Chjh$fnK~GMJ@oaQ9^`Zm-eqVZ%#ap=6IJ31pAp9DA8;n z=FObki5{qKTTlViX>DfS8iqeGn-3jq>Y7kSXgPM7AMMKTxj)%-E)sZ?74t<|=Ki4w z(rZ~-@j->^T$wZ;$qUoW+~hi*r+S9{!Rr>yaC4r&m0+~tRl{}A>}5ktLUHPIZ;%%4 z12I9EaC0>0CI&G;tHPtgqQ*1fwFp)Y9$fss*d^uDRj_=aJ}Ao}O)awPX zDi(6;`0TQKQ3m6ffgto`M$L>$4D1}}v}6N%1Qvpc4A3J8{PPHZ51$)MVPnrsc4jP# z?IjQ~(f=8m-J*VNyE;AB?*^Sb0qDYR*U9Sahvsdan^WkopHsTu9EzqJ-IF8?xulyA zZISyKgKR(D#+0~=WHwYCHFv7Z)>wDK|F*ohA$Szfua1V8cwCQNtx6S2j;3{E_65hadIpGwc0nBkVka@pSf^&(0Z?i+&t(Gu@Qc{eLJs2O!y=FVDZWZR@rBwQbwBZQFcp z+qP}nwryM6zyHqc&hAXa&fbd1sEVvOReAHCn|U+O`F@I4RQ)$suB6oubf7HMzi&Jr z6pjr&&+#~GhGzCTw^Yu|31A?<{8-<>_Vg=93IeP3wk;b-zr_n{1`MuLwT0X9z-p{%RSKb$Jo1kd1 zg}$}saByfCuYYyH2HGWC+usT(UpknDAmjnsQESrEo8?Ee!telhiE+s1aTH*|{?NiA zToOc-V>+AMSjzA;sS=4(ScUA7lNaS&_;BN>i08w&1Y$n& z<2uK-czL6yfZ?W&TB3^)_va*NAGn6>A~mljs2%alk!9$Fpd53AY45nE2)}Vn~S0i zcP}05)zR$?TZ!{`J7p8gy)V=-cRfB|AX?ZgpKiYIJ5zz)E1`l_;xq((u09u!KVh9H zg}WWyJ5Hm6JqKjIJXB4~+IIBc-Uw%Kk*uSv0$;`<2hw|ue_Rjv-#KiHwb2<{oQ}D zvGm`A%F)v^v;4>GkV}no8|-F;Z*QMLZ`c_oJOLl&3%;>Ow%BZ79Q*sP=A|D{_nQ@n z;_jmp^OEJstk}tOmgVusuJn!_qq&h*3$w*{^Ot;1=9+cQ{Jid6)fe zg6wq}7bdnlpIP5xjRBMcX}(+P|Jk;T&^|eU*7Gnqa=aoubvHm&BzD;L<52Wiwort$ z;0cJ~YWzF#!e`4JB}m1^V5eJ#Od*8w1Z%vYU)EPT6DAA9VlI~pxtM3OJ0MwazO6@$ z8c_q=X0#tZ2#7R<@n+I)Lfk=1A#zdg;+vpbVgJqSS*-4?atDJ;#q27u8!l0~3~6D45?cS_NhANgG#&zG#SK-?04&{SgNqX-CAx>(QM$Y$oS*Qd{P7oIM)FpPmweL({dAhn^fqs99UI(zOv zQNaA*^5VSpoKOy2Q)&rrW9TAK=^|_v=#}yPWb8*V-XXLMxt)`IVaiCsfUBHv-;7}n zV_eZlLB`@hxMuWbNW?8b6UX}1rolkFjiyeL8t9g=s)bI~6%Eo%AeOxif%p(o{(^M2 zQjmCT;%KplJzXJz?Ci54x0#~N`pMj#+wW)tSJ(P_q1{;B7U*D~iR-N)mdkkisSdCyAvlOud zUy&8=0c-V3%`T>Bnbj#W`Sf)vzBrKxP5!=&i-_)& zi%{E{Wlx3Evd;yinl+bo|m zS;&tWBMTG+^YUIPw+Gcj2N0y`mE&p*R_7OhdF_-n#qk;e4G{4kG0NoY#JbQ_4M!JZN060xBiNZ`Tat zJ$(*)edR~Umf$v!2u;(;lYbT~F;HU&CI?IC?=xfzeGDZ2(qy&I3LpAhpSJ0GzuBBC zRIj51yZB|>7Ph0h>LCiA01K4Qa5csTUWK74_msojV4NNP(ErHu^|jnA5t}!9Y?5>( zzLso&(zP*8HB5Ul#@&CmN?PzzLn$}z%)R#o1V3Z}C>SQQkOo~I^tjm%n+cbWpsw`r zjzg?){gDx+ow#NAOI2R#isOPcbK*Q+cOFD9ajd2Ool+spCK_5& z6bat1NJISf;2`%bGQpR5bRg@p-MKt^ZUAuZs761<$W_!g0r$RRS&bDn&?){hIv}8{ z6HRMu@ui?`{gmyZv;ZAH0w6BlBXlS2SxOzpv?}zC#01#lvX2iS6_O0j+#wgVW#l;z z=tR|)Q(nx83_mPy7Bby`YG5|uc`)2RMTYHluTJ?tz+rZ zP_XsmXcITB-d~@mJv)53(fC#Uie9k9i}!`_cC1$wwyi2ks;uo%?o@MzrN_fOn>)!a zMER?V$kZnD8K_?=fh5G0rC-bXK@)=4b@hn}P;4trkM1uU+K!)nM>-OyJKJKeW$+v^-D868?7ZJ~ z(qdDm4o@>;gEnku9 zPCp8U{c4oUYLy+!$8g1l9N8fjz8(M?!9p~?-Jd59(euMHZ3@RaUqS+-O&@GDG~VwZ zZBbp?AV}=h4x(R{`e>7{&ClufW^o>0Cl6d0DVoJa2*!8V;RTaNwv7=556x9Fm2H5M>E9Lmd?@G&$HUmBg?Trsvidp5`4_J3o^7- zjDA-vM#;kRe?`2swg0~Qc?aAwi5h?6!t1D;k<%+RSVt$O*RiNWd|uVsD|ng}=GVnJ z(0f#DBy`S-pSb)~?k$RAj*(iCk;a|XakP&ld_C;{VGpB8 z&h&yeda@=~P2f*1hxP5ec7#0Q=1_nAh4Xj25}0J5ojSs66hiMq7)S1qT5&jl;4jaB z#yG;bTK76)hPDdWhn_M(zbXJ8?RklT^5wNcUp?$k3;_EMT#6GT@XfI653QE&{8__) zzWm&Guwpn*syt+(ZN~3qQ(SjRAQh;Stjl+s=e!So0B`Syx_Vf#Z3Q7o^H^98I|}qM z0)+dI*&(;9f6NXD{a0qO&VJ02Zj+zS3m9vi@P1em)1EUr^E?roWZu4;XrcSR2z2~V zZ!;d-|MZ?ZIrZQHV=-r9t=<3`<2ZpI`BWojIptjIF9G=UROA!*@(lg7EnKa88?klO zP6@1s&4sOGxP~nk=(atvr$Rx;`a)ugegAp7jTZR!2cOQS&vx$e)3)?8U%REw$jqrX zIuz}bUmxDYoabA?KW8pf4eSG;J}UUv7rM<48rIlr5z&@+CKHDzxL&f;UDe)+aSz@p ze^|?Z9{{3+IwW)-?)2?pQf%45l4okR+i{m>|9A^NY1l%b?rQV5z~v^=r>q8A@wC{K9LF=~ z9(Tvr`s?H}=Zxi=nV&)?Q7V;)`jf-bJG1=ieg#Xh=XzcERd7La7n19WSPtS%pp|e2khARh zjD>Jks>6(KS^ldsr@O;9RHdqYjRk4h-0iKIxR0;~H3}5T$X^)z+ocPLU1G){Hm3GB2bw^f5;#hr zQ^GFu6)k<*e=?T86DADEemzY7E5^qjLvGs0uPqLM+>y9>2>RxL^DYkRuR_Ax%pv1& zrwo;r={(}3QWE{I8~$L+8v{^UY3mbkDppfQaTPOkyc|NHUuYP=SOdXL2YPmIN1=5+ z3_)ui36Il?mp;8Xfm$k`f|pg(qXK}f2kwYk!U~IgRZD&Aklym=S?ckgiN^d z*cQJqew90tUpP}8TyE29tt)-$L!(GYKrQs>AcOdfRSAOcf1-SVyCZnXa^<$6&3D)g zMdys@2rf&K|2lHonzM`#og=AUaTgQ9wp9PXCXR{k0kn+XvwyTHyfA`ITJMdt^KlI+Ct_%8~qse{6$ z{BecEQU~%{`W+ZQ7PbHrPg(=Q8cWC}pZXth`k$cuIKKlx*b7kccwV4nf`QhHM?@PY zL?(N-pFS58_NXBtD%Hgn1F=9h?dif8Zln75E`O&j^=*1LeRK{;7ag2B`!H4KB1LK- zhGaX9mHaJ|FToBr@J{)H@Id%B;s+>u%6R?5DK5cBT4AcVh8BRyHD(-OGtJcrjyH0_ zw;aRFB~I0?SNZg8*|S#=C|RUyK^q+?`j9g8Z-X2w{a(})9XukyNz&6fxEyF)Pc<&R zv75sX^jRJOGdmqyvKIxx8PVhk#N6T9t*SSVO3rkNXU+_jSWsRahp@A1M}BbiCb+qS z-t^5E#wq)UiV3ae`~D9ENzFx}`-cn^T?EJ&*`%OHgv2~(lZ$&uK5qDVRJtQRvxINr zZuj}uBgtktid}poeY_?S+un+`KRf9!pHSci1>6070-oGa4Ci~u#|^c!@F9AgCMBTU zyrqJ?S``X`srt~dYmTkeT%|GOg1whu#qplMcYXu7nrrAe14bUt!y1j{^R?_VWeqz0a4`FT8!#;u(q;s zMS0$-VeXxO4)9YXY;}y;e)}7$z`~5)f4#-#s-pXHg^Po0V%hE-*3g0@qk<0D>A|~d zpvw{^n`7gl+BSfxfbOjM3J?HB0yeI&>|4sv-2R(Cztgr43%Q}SZ(G|UwQskIub2gq z$!S@aua#mai_$b3UN!(!CJ6IH6T81`*pjojCvIw_o5B?h^;=8a$|qp?J*(Uhod`Yb zj^`s7bH->7|9Hri(6VF;K(NtTI&~XJ$xqLdo1}64DssJX_vOP#?>sy1PY6!O#LSd4 zBi8;2Pw2ZOhYZC5dHx=|B!yL&ngV0<%Ir^0r&JpT2{F;McO8oLLUQ?Q?H4s|Jpd9C z9f*a6hRr?4j|_&*B&<^Sk1zE6U%t@eFt3H}E%8C}^K`x6=iz)82aGk=Ftv+T03cD_ zTopbFzaC%M%vo}9flV_l%5dh@FANZA z?2t1clx~cw(iWCe``}tZg+$^nVAoevfHjGz4+z`xk~&yc^M*F@AL{8~49Nd+t1+eE zows*M*?TKouhy7rt|3+F!u8)X1M|RK@@so2z^Q~%0mST>D6{k6o~ZIXB%QdIIrzlb=Q^JDOK{p zGm({9m~`pq#!2TN=oy}I?Pq57jJz}CHvRDM<{`4iVh5ufLqU#aI6;>;ZQKlOod~Z zOUfzU_~+*nfT;5{cPzOq1Z67UJdXL;&&;gG`-=zKm773C4&bm0&LtZ;N_64qqQQ-x zKOH}z7d*Y^#ehezUaB(G2VC(5w8GfkF!l)$Lge}SxOyAz-Z8N2%0Z)z-7cIcor|I7#m{cD)=()}5w{)TwG4#B1T1mp{^y9<@+B=QucPv&n zfL2xY%-zpO^@Ubk6{&rhI(6x=gOh;)xE-rD)jTJA`makZw7619$C2dmjZS}`i|@F| zNG9wA4ok#1!YYU{%PtNBT_H2=%s9D`T`%NuZ5Ko{4C01qP=lsHMZR_E78R;WSJ?_c4}L_Ra}~@#s63+yLFE~K}E##J>IP2`Ft4{ zt65avk48SOJOUvZ?J?RFx}nDQt7 zv@y(rsHcS)aAFtc48CE8rr%PWX2+O?%mA(O?X~VM)3dz{@Ig=0ErhjdvzV38XPod9 z27%wk=XzP3iaYrm7^AW&I#qDSV=tQ-w7d@5z51_ADw{TSx?{ShAO8$d6d%nEYEVfm z5bAIUU-XmiLVxa$EAjH?^Zt37WEJk}Nq;Pfl$RP(5lxkqtp@S15Lcv6g&8)-}{NM+;`dbRuZnDm$J2fZ+%k)^CSuiNOvAv zYDeEMWA`*(|BJa~{UOziXwlwOH+0CNv5?rV-lmkUxM`!VW}yjeBlT2r`bkhO@??k%|8Q!VmapDk(K>pMUHHr)YbNf9+@^go+Bm*JvkT5Msu~5`YkvXaXFD z0Adp6?LP?APlzJb3+g8zONE|>qa~pOQcSahzLogDt@ix5JXhIgTiUa0sZ2>vjvGk}S}uCGIj3?p&m^Vb zF-Fyz9MKH-@?x6>YFXC%H!R%yVrhpdSpbrW+iQC?~%hlph-9y-D~INaTFV6DzGdb8P> zcv{Qfg*;B)Iut*PI(6?b5@{DiOr5}i!KhlrZ6^X{xC14Ie zz-L%uv4lG|H!w`%Z0ZFlpk{Qj`P-+gwy_q{Sc{uAUJR})#gwm5QzS+`Aq{o1CJ}Me z=XX?9=`oaKu#qEgEzE{r>idFdh@DWoH+C{-s5eV=)HbErWZ$Y;ZiH`> z@sw6k%TyK!I?82cTLUb)#HUn(Dyc>^&*zpJRt0`%$yrnNZwpbPe2;L5OB+NyAUv{J zuuyGmh)$kjXPVMx8LYSZ=u+2D-uJvSisKofKQyS|X-q8NoftMl4Oa$Afm`fMq(~Y7 z{%;@F0im@QK8saRM#Ciw5E2mJ|BvItho4ga%W>~pEBd>c(}msNPI-kg;yargYVlt$=Zut0Er?)w=$7GnDCR0kO%J0gG)K;&G~xz7w|vJ>#%;5s=-X~1mbVe7<@i|;Q#YYj&)f_By5T|@_(*<@X6=si0uC5 z8SArN-WL66!X4pf=>!#9iL0Fd(uP62V2+%Ves_jUTgY7K57xy43%XO4P=stLVJ6+H zL%^+2-E~cGahy{=00Dtlq-Ki zwrpEY;?~kW2I;NxWPa)nuG-cBOrsOf85wE2(+u^3Cne>|Bc{A(R60q0ih6{}E`HbS z-savSA)P1z0bkSgut?wGiNI}R?mtca5e&z~yhpVB^8A>kD`};OvJ_4|!6;B)ooZ~H z6fHjWwiy1VG^(cbKw0^QGFqr)zl+EwOPZOK_NpV+rZ%~RO|GLu%w&LM!y$gu4wvN8 zh!fIqp@ay%Oq)j*zWi-oj& zMMKeNj$tPJqkQlDFxn>onjaJ})hpuL{M=KBfLeHEf!%6Xh_^n$s%MP`enc_2hUzS^0$X%5C4QopGPF^!;2GZlCO8+7YF%9O2T1`?EP*u~!W9K`F`|{ZIo;leveCu& z_o&U76~L_*wkAC!Twg$RmUeCS#tnc|+9p`2j{@rh(GL4PMCN|T5#QIE#Xhc@AlO=Q zNVnp6uiy-Id?1};A+cWl4gF`OJA%neMrdBt!n8jC zvj+Cx=*_4C1CphvGqE{N?LHKz)9DzPP@ssf2_9*@zAM*cU4EBGEKLZbN)hVhj~o`8 zb!i#j(_f4lfUUAChj0#y>k*oG-g=PlQ6@!K4$wSk|~Xvf`$} zhgz9T>uMYTTZ5#9I%m@%bs0=XJ%Y)vkK<}FrUfC_);+qfgM6~6f+^_E^+Biq*~i!r zN+?}$oZwjg*jKxenw05ps=VH5Z1DUNI_oewM1I^jKVr8nQ+PYMFI;s`Rb1T?^(g2^rg@2gDJt5qA;q$FNk7e8Wv$Q9Gg*=Q)@IDxz`gL~WFla=T=b%q#iwUH zc1Fhqc#E1mYzp?|uDfU%6h-ZCEatu1Ig06k|DPZf_eBIa`F6 zsn^{A-+FcoraLp9U*p|O?)88lf}2u8W8;?$K{%^v*7eh!OZMvFz&Kx5ZgzEXX>YDK zEXqdZN7Fc7S==IZRK9X+jfmu8MokTU+|6spsH$sdX{t*~O!cjL5p0LbMIg;d_dshh zODSlo-A(3;Tnd6Q=5K%&Lb+3z^Znb1*Kgc{<~lk&-k_sXk8o!dAu?s(R@y2xmZjXh zCK7NIZpp0kCpiiA+7bOo0|K_Z?k8OWMD+8c;essT@@(<4!o{9B%woENhaF64p$FTc zA)XREq>Z6`q|;OtY=Nu&?_)onDPA;>&``&mURgbNXi&a zucT$lpgAkDn#ry8%DlJ~kE5bJY&4m!`RyQH_gG%2IUxgjfe~KD`Vt;^9uoO2C6YK1 zLo4}+2#`B^P_BmGNi#oTgXBC3h&&X|nYv%?pZ+mvccyJ<)Xp9hs z+(NcsQmbH)5w8Wme*1so{41^QI?;t&JnmcUC5u)hPS7y zzWLXRhL3sG%$~HmzBtAnkkZ<f3J-~eBbj=B*g88?hsZ{3y3JKRIyuh+uEk)hi5Zpp_$D*3UL$J(=f`(42&s$#j{$lZ|Zw5M-xF9;+Y4J~GI zNDkj>2=A5%od(TnyTgrxrst-5Mf^bp9p*|f|FuoOBTdo^t9_|jgHNQy?wl^=V2tS! z!HRjz`cnr5z81{H~c%@;;`Y(%f0K&Yx`SlVJ=S`+w;*%_NiVPR3^DyYU zv?>kF1tgHWu8SkOO#057QhVp2avIPfTFd5)w*C2EGa}`x!?tLT>XpZ=_Ctg$-1hGR z7cz1@H9p%ulSITAx8<`__v8l<0F%Uf>&-0e+syM_5eb4^!#u8yTE>H6m%MtMSym}hxu z+`QR%ZXqX@wAS_k(w)^rQyh$P?72dY$*ld+;}=-Bk7>2XkR0W*DX9Ud{%oCiEk zbZj2RQa_Z_VH*K3Y!Fjs9(_(bXtoVp%`^2Td;d!U*zv&GE5wd$IY@0tE* zbzsfw=nwTKqSxPaA4oMKz%>YJ$BuY#GLF>K6X9E!6<$JJ0aGjArFU(1`%J$e`mgj} zQRYa$d!}Ox$BTpXQLB)3*e~+qZ$JcGJK6tY3FtpamH)3LAlCoK5)czBBmI92ytw3o zP*oI})B4;>-<4+rof!bahK0e5B-%q@3TYcCAqXxZ5avU;<-Xw;?Z_LMMnpzd5&8`o zEM$lh0idsmLL-O-qX)yc-&_5cR7_GaW%*vEJGEEt-l%@N`ThIb`@QUhWwW}x?B`Yf z9{GaL3vMQsgyc^>)19a!Mh+ivwuYkCs{H_szEQ6XaRT@uPbXncMBGAqQ-zof_EVJ|pgnJ4tyOnb#SjgyPn+-}Kd z6`_tSJ!Yvqk5ov|Iot6S?4muWP=#^J)~g6-4)BcEGtXo~!?B{=FFMF*fMwo9DuQ<7 zLP2CCy%XX#o0~{~*0LFK4)49yTbqH%Sn{rW^ZOTpg`Cfe@l!>LTM;H=@IKO(OiQJs zW-anF7tZ$CV73?-hj+H-Sr%QIk2Y2!-C6=Q7LL=JqU9Qb_=KUK~%WpX%U4; zCgCXP2KS-M%^Ts{Y~L-g?+W4)d?@@VzSV%S$9=JFl%Sl7;;duK;%`$&NwD+=hMcEa@I5m`qrw8Q=-~Ma;#8S3d`JBDT0@Pu@>uTpr8v=# zirk=*R8p}XdkhKoBxtHwRr=Zj-O<6gKqHJfW?eUNZZPsBR*CC)d-BWyQ}%rm33+O3 z!b41;CLww0;X8ftWYE3OSJ!S>vxpbAh=IR}M>6Cf*QcWXsut!6=As12rIn-s1+3i5 z-7xw%p#ob!tiy@kX|}yT^dVWqo32{+#DxuTY=ZA8<^xtoZB70Ockg>jL0t*+5CXM0 zyv7jjh=E=pd){+8@q)s;COq0`Fow{taDbEArUHtxd>|Xq^zHOxH`1RNRv&7W&~Z@w6TeZmK{^bH}E|D$a}*g||%K87ROpZeZ# z{}5Wi6!Hp0W+)vfmcVy?sM`1y4Q%2m zwDvXSHH5X*wZpYPYts!P4R9B0D={*UiMtZjSR91%wmAAuabW z3%4%&^8aS-qq)B`3N54OREhs}J8jWb9PBSIJ3Qc)e>1*oI|i_gi%sFlW&+|#CGu>% z7C6W@-|P+pu?78uF%OToa-YB|Knfypg<%jsV7PB3l|B;X#s~>7 z2@RIs0LjYO_t2H~6H(-ROC0LTjI2v$dGS^>=#wK%o?UrTwQ6c&m-i-U_PF2)n zyE{^pRvWm8wcV8EL59(hBZF&8+j)ud_Bc}w>7;PcHhQTlSz+|=^*%mO zmj?j>qo=x9s%)IVWLKZxngtRkkYDc!)?~DC+>|Z>YO3sI_!VsK%I&T zy}w;QKXhuM^(OtkTo!Gh#OmZt9O?IEY1siLuf;b-2SFVvD;qnsi>t0xX0>8GsG|a> zVGt3hL=VlloT$I-Yb3m4ziZhmxdzFN%vdy%OcuV;+pcNfAIs~9m0owcJE&seflVyC zQ#CJFzL<){Tbb6@Mxk-sPL8de9)^1q<9j_F`*^>3U2Zw2qVL^=JCyHb&)EtW@pplP zOY3w$Us_b{@m@L7Bc;%Iq|9)}_`PHsGPx_Pv-nkVA!Bz}tK$FdCUFKzouam`c zl|htZg@q<&H+KjV5jzKdfU6B+S$N!uYwA7&56$X#1WX)}*u`-;9Qh)reNAE^>do+uoX?K*R3 z&yAO{9o-ekcmm9(F90hMi-Jv5iy72$=r{Lt9=TT!cyOE*BjsLgzAk=IlUb@jA<@k= zRDKIr+&151*e1U}MtC)j3&|d&5Ql8)$`yqPQ0<2%Cw8rHUb@&lXa_Nzt<2N>)gta5 zSngh}@Fdek1b0{Q8H}tluywBf)j$&j@{bfKGi&Al+1#fQqhYdCjt&0E2dv_QsvLpub=Lm~Avt7=b7?{mp1k8y8m7d-`+ z`a7qPOdMj}JZ|z=f5S#G80+YK*9~(+81JbA9z;WsBLQ6J6Q=SKaO;1YL=sOYWpiqA zc(& z8Eeh1lb*8eyCy`ehqda=1H5OpM9ck7^B)C0uoo<&UxR?d1S)_P zCX5>zSLQ*lxDUA9%_9SzV*CWRh$$wNQ2@KOH}G|BEd|n1@=PkjyDae(haS^Dv&q6e zqD9QlRK|F-Lz?2QblX?C=+hD>03&SK3d0e8iWTyK za3NXfs;tBEXgdXo&@)nb*gMT%B_Vh{Yyl`P|7K@2n|+P91^jCllrbW|TrE@h&b8#( zlcy*42FxZ`_O{+UTKE9-D%}@`LTPvOvTBUAc4RY(El&UR;OV~q{CvFr7tWeU-*%$( zmQ4uCg{sRwN=w&C;hH+KoyleSD^SW#McV&(TA?exE*kVvy-jzo0M-hy>{&_YZ0mAj zhoGD%1!FtcX*qx+`m-4?`b9V+-Ms;(zCoAm!EB)Y?nh}Cw(59@BD5Mx{%`CYMyX~( z3DdPIx{k3Anlf?%4t0qx+H9uEs!+tlYsvst;O~7^=d4edETAqumSfuuKDVzSJa$EH zWr)R_&E-40h@X8rZM7gnc!D*YB&HnrJ4d)2JV(gmDw#js8OEW|qz|+L+$y)QG%ctZ zSg%b2Z|g@uLD>%db>@HlUKc0AO9&{e z`6VB>ruq+z+~g2Kn!N00oW$ntr;lIXv0hyZS+$#lL)Pw2>aGS@f7`Bp_=X$2-CxSJ zyos5gk#>yWJ#Heoj2W_{G#A^5L0l@!si^n`MMXsDzOMoed?LiQk|B4}4J-a6Q*c>X z8jS0%CbFwL6X#kFB*o+z42SVIMWAQhXGP2}p)lImAOYP20h-HXSHXaf+~4{E%phv! zR%e|@+7HrV_o>aMlWGle9XYgutzcUOS`7|*JrAg)NTS?&9PFF&T2o^ZT;%WLZjD7o z`10w7u#)+=YCzYH>G->!kJM{A*hCu_c2ZgBl&j{WB%?!RO8YQBz66Gv6xDiMusSkp z16a{bs&$qY5aXaZ@3>B9flNF3ZSDAKjFn&X#lm<%(YM;%L#i@Xm0$Z#$}=YzHxn>#s!U|Ot^gN!6H zGt&NE@|u-w|C}eFk;EvX{kXLOV<2QUXJ6E480V!P|0=2ANAog+cchFU19btO+-Q2OUOxn;>(IUX3C+shW0iSb_`H5W z7P3uF+js6lM9PZh0pNVfz^teCIeu&S_-FvNHFCl9)^+I>YEf0u)^_YIVBb{c&F zZlu;aee%@Mt1g!q1ZaiV!eo+*i}64`s@Wg;q*;aX^5hz5PMm%_ zOntqyTfo=E=MhgmQr!s!*z`w;=J+K8PhA7Qw=%YD(DjmMuEFo0FbZ2dq83WTZ1tHI zT-@`ks|}JCb%<;>uykc|(D=OJf>^d^-%Q2sw%p?gj~zgVk9?Cr#B$ReKYfVF=ukw; z65f821M{fQ_V9GR1Luh*z>c;mBGL-EKQfo!TLJa?j*OEFlrXc-p^cp9CFl2v2#QX- zy4R&SU3ux(?y&iRC|c3oTmo?cR4@hF0GWB`w|4>J1DLuI}#m+giE ztrKWZpmYn9aC>`Hs^Z196H^*fuoUto9zy~71sVF{KCP%q*$DB zP=+ceUK=@SgQ%qYa%#m(w`w-e)Z{rqHQ&i3czV%=+K|1_>vWGE!lH!cfMo;Sa5 zp?gJgrJR)8tCaJ}`k?fJjZ;3&uMZQ3z9NNGocB>Bs}@HtukLpVvQf3^RvIBM@oi4L zmNm+U&y#AL2(3>qPxk{~0MQ?-6L&GBGgv`fbro3orDEy=e~`UG@fvm8lnSTc5W1vg z>Y|p)e4C7Z-(1I@kWwP*cntW635Xut4X302TD@KccCCigL97)Oq z;fP9xuDPkHwPAJ_M{7K>CYLsdVwYb9FQGxN#@)Y$RlI@E8;{Z@_{B5*YQ3NZE~i*4 zouq*{`{JT!UfVn;qox2Vu9wgPsuEi4)vAv{A;D zknkq(_u+2T#r*)p=uyIeLSB-~O>lw<7d>62y*b4S*X%YNF1NF19d0hO^BFQ%EM6fQ zo(Oi>ehcOCLNOTct7)M&ws}%}c}ZaOeN90io$nGdog^c4>>60Z1;>8$KMLKS%I1nly3AsdF1c8=F@Pp9Y zK+KL}(Wx9peO&+gNG+O}=mwr$(C zZQHhO+qP}nnDdf(nS0;+ax;}m{RKOzs=a=DEv!wyQ!q^$%2>S|wZ&Gp#$FhD;c-^7O&Kags)(|LF$sh@Em<4v&4ZUHrPOZKP8CFR1!cnYfT!^@gy3!0_M4Sm#DTGw zd^GWwL_u9=LQSYhe^U-CAV;(0ja9-{t@}4Z%p||Bdw(M!&B7f}zhYeVgVN2BeKjsw z7;rjqO%`)L%Vaze7m^CPR0kol?}oN9s6n0%;Ii2JI@W8?$d9w}u!2x>9W*?%a#1|v zOA%-1sUzMT^SwFnS)7KaopPBMuPsXik+U|$PqRfi_WW;r0ody8F#bcEoaH}}l5}+c zGbzc)^nZ|&o@z*mm@&;N7$|0$5d`bl% z_Naz61oTxkWHrF?ZF*`#DD!A~(3SY`$!8g8Ec*qkpsL}sajVh(=Na6~jCdJW(>J)U zvE7=zg3hY0|8i57$`wmHdSlZ8{{Yz3K@f#yGLf5D+|S}d0k~Cz-16Ktf+Lbj-{sPw z)$;aV8Y|{Tg-3y_djRTvBLOg6pDqdxp15!`0bG)=@etxKw~SfKwt(Y#5bCH-)fdzL z;YR3E@n8Kur5Y2%xm9b1+B;?daBWQ@(mWW{N9`GExl|iaybIC%%yU=@W#QwKxR9j% z+=klo#{Lt=(T+#m??Ro&4Y9XQ577UpU`C@~Bo&rpcA+#*D%xy? zJA&vro;+Dq{}8boxG<5FWB_q*Qy3YMG5(J?g?Z<8px2*-PWJPUO@#>`vW`vQanOpo zq^ASg0_(W2Qx%Qq6n8^jbkYX$C>y7Uc<0{i+5Wlf8F(om6T%#Q)!{ zUOwCD=6!(n;SHweeu7l1n8#QH7M24v-Lo`UG4p^W-r=u| zs~9sa3cNT&!w|HlnJCaSN9-OKhln5f*47}oPT)x?=MspCe>`eE7I`nQT76!QUY=g) zE~ym$kh+%>A!AJbD-rVmdR*op^B*9||AkGGuWCPiXBV-a3;z9I1m)Z>K6hbmxvzMy zylzn~;(wth=YB0ME(=$GDC@xZ?pKxrfq4LiWFHcVcwr-tPR~n`8$(s9d^F;_*VoYy z*zkjar>JuTMH*O32}sB}jUClcti z#FzbP2>hL~LH>*|p%B^Yb*z$;O`PAv5~?PQf@gBPzEE97I<;NzQl{52Io#M>H%zUG zCp?{vf30;KM@W)#l<-jNipn9WU=78rCNu;8XHrY6^LSAianFJ2c*BM1eU0#**PY!p zdK`fzvH%eEt6>*3j3~Nn-*J>|UKN0S&&vQ*;U0YCGC{z1bYyH4@R^^WZgfTs4)DlgEO`3R3eC*(1ByD8s$nJ1T_-fR2 z0_ewQ)EFWtbTw*_`LVLGUaG5eKRz`ZZhin6 zxW;;Xd^|aBD_Uyf?cx5Tc*yI;boKbEL#54>roHI3NkUq{9GKn1XqUWa{)hKBPp>jH zmAI<2v2g*f*==n~Kby$Ud!Q$6IS#?o>?h}N^p)$=B&w!8%Nm0%ZoBJ)_;Y2o^?ZIG z6ao?!7&xDmb1oFR0iCv85HX=?0Qk+s@(B z0<;e1Kt|8m^Pvm#xBsOM*@!y5=Ou)u$NhzBWsJv=y2^g#YB4_E2sB+#VJs#$pLuky zhN7tw7Of66aJZadZr1&U(0vz<@?IZR4bcUqw`Hs+=byEH z7%2S=ttCnCcCm@HZ;TaTxe@+43j6uuy8RW=XPEbt3`oEt&9-5Uz1|rH6-cAMrc2h( z>iuB_FGdNuWv zqP@GKU65+S1IC3zlvASLG)x36sWtsRuP1etsHE@feqTn~pVm z?Xr(l>2*uiYYR(mPkHLW`FpG;#A;}l;g;S5^M{9*mj?l2*Q%+#y*#|jFaXK4RIK!$ z{i_AipP%xZqP@OMy2s#`&Jm zV=1kAE+VGOHtrR$!1ajASzK#~*0*OFMV+P}tzk;@O$$p49NxB?0`Y5tr|N2ZP=kaE z!`_>`l%uJ)r<8BO(z4nl(oL9Kuh%$w%WuNiBv8;zcVFi<-Ef0}^+7H%kv6OVZG@Uk zQqwEOG*Z;_1-osmSV!4=f|AX5AI);R&K_R-FY;WJop9zASOu4Hwlo2x=;yR>#&bSn`sdi!5Cr8TYr4l zne0~1Yvjpp>_h8jF;2-=D;nzUuHvSUdKt~(##t0;PtV`7i)7O4mqf}+(dD^%(b+8a z4Y!jiW|w$v^sl`hec0~LQ&fZl`(+6slEOp8iS}n_wApZ)%Xu@?SG9UG&g|4IwMK{I zl!%Ru(iduOub`AN+Jypa;b&rt76XOI1ccr_Lbj*|^oa5H(0i^sV=%FI81Z zpxW|eC!f#_waTu3|mjr_Ep-W!ZcA3Bc>T-v62_ZOV|>wHg+ zP#&j){AJ&&TI~j>!I}-?Nk`Y^6&{`BdV|)aMu+uQ)ONxhs-u`ZVb0yR$J=Eq_8JG7 zuL+!7176;{@>AJ2BxSo)jzgDn6vUnGN6?eTomsW>OiCBq@yfEXOFR*m#zV=Mzw~0K z8?o4yzh7pD$pS7(+NP{@Nuv5g8DnBOq;xA1a|leh{HG$L_MsnWqtApI}=6H zHOikTZO4&y^fd9U5Y^`bQnk>xMfrD`r|tCF8W&WjQV< zP6db&=iYKNGatUvaC;?v1uxBOS6Gk6d>~}3D{IAP&Eem3TT_Whr(l6X#A*)YK8&!$ zsT-*PdDbh&G>(cPYY%gEnCvO~Bfq~X`VR|!4uT4da}}OE6sJt4TBuo0rs_853H30= zrq&pc@AAY-@`P|3tiX>DM9bb5rS^Srb z8?mquxj1{)GKjYdv3GcZ%Nb(Z_7{1(mSo)C_+_Pwf&9+b+fG*Necn~f@c6dKBWA}Uzh9gsSj#hqpfdHlW?1Uu+PjkImuH&-KM2;y*iV#UcCX*OM zr$;NXifcGR`6U6Em*taN3I>g^ zWy#ZY_asUrDMj*>7WuR_*^XO98#!$xrlWwhvB(SQ14z5f4_DqB~5|ifAv^{Z!kU6`CRrGF69+S?jo)N-)hcTb^-8HFOHH-28pysC8Ru@CC zHvd$XgHO90@Rcl`<(>h>K&CzGnN`$NlRlp#+=0e9Kku*bHrpR;FJ^A9+owD1v;Dwc zz)~J1vH#Fc`7haO|C>6bXJ%&jAKNL6EOh^!I>fY8QyO)i>2|$6$PE6QSfRg)42KJ+ zh6-fC6hS8Br5{g*j|bb|7C?Yeq!JGs4;KicD`x@53=H0!BBZ@!0L2qv1~+XXjM&5@ zZ@9pc7r&ivN(0GnxOjUR>!aIyn=`nT-tMU5crumV>8buFD2~U7dVhSXua6ynm{A8! zU`&m92b+yp&p{9N7sqGK7yfgcQJ~uk0r$0&A1ug_mudtpo9lj}J3go@uis~Q;4I0g zK#0ijx^CBV|Dfp&XwgFzjsfA*8-;jCcofF-^nx6#Ghmn%(Do^3t`T*@@FRo>Ew$Gf zw+ZCxR`_{5GSQ&G_UqAhhwPUL-}^gy-j*@!KsH|QyYcz&eKpZD#CXvmj+M>8iY zsiEteAzgMXjngWtuH8Pq#9lh%i1j1d8lPs0N}#4kjVX1xbwEwJ6au;c(&ZXVlmp;B zNrWQ|F5lnz9^wdG4kpPABtid-6?0S(Kmf>!073xCLm1FS_pIzak|@WKuhAo%+l|xz zFsND9el{u>OOgaGdPGM_kr|01+U}ba+DuY_)Hu{x+xo^FaW24t3clsuZnImBYI-fU zb=$UinLYb82fsA^3zH8=m8w|HAwA0G_0XEZGPeM>ak+f^=9u2+K^Ne`Sf4$j7(}w8 zddaDhvL0*50aub9rSO`kv3i50S9p!ZaJ(scm|@o-ePw%>VGJ)2>VG)yexa_k@l~$6 z2|OpuM<~Ub`CRn4)6D9I_mxGt#_SG+yT-{5WdDf4f#WlZk%^O-V|>222oPoDi!maY zKZ!j;q!Y|fk<1&R7n=9}NV;{dGVav)*`xitePwp6(v2i=Z1P{eQ>3uis~pZ2KF#DqCS02~Zd$e%~ebbJvbIC31El zOyo*lGQ^o1w`%-cf?C!aP&fA}y|o0HjFq$(ufM`wnxJMsCE%6DNfSkdIa8NxA*8u6 zpf&)fI=SC@@LkU{Q8f|3@e{6B#Tnm69#6cZ0_W3eyM2f}OyT=d)znng!L??K6)LvP z9DS>e!mlQ5-iZYwR%h%GoPX90i%fT8U}A>ig%>tdAzN5nTSWXndO>!SMZ3`OcGn+q z)wU1(3kq8%1;Si_NB`HcdV#}SnFD!7Y#2MFcTU`x2>=H2ohdJid19Y#5eZVdD}K^w z7XW~Pk7{-BBW`kQcqC)qnzdgOW3kd{&Z^#zwOW+>{u(iO9>Q@H?1Qp;<6$ps;HwaR zRAYYDpKT=(PPJM>-)@;vGLQCck(=%j=}n`D3{{~tJpeaza{ZV4wCP`H!ER#yn!SsQ z0%S+O%?*gDX?JX`ep054X&p02q(oqC?T)COLS&b=4}AA(!`;BZ8zQ6xC8aA8$_a|K z&aZ~Ez=m!B27mluXIt*p$1!&+eshTYCQA(^nYOW%I4;Lio`|En?gvd&uv-%Yc=Ztx zF3^a-#BEXH*D$j@Y+G5k=Yvr-vnKtRLoBc1{B40SoF_2$!aj08_G!)4UF5_GW~8uP zDfAL7_Yk~lk}m%E?RbGSWji=wv?QIO)>r#q3(OQX&3}_EPAdMpk8W&6^G+|^QsfWR z!aSS>1Q;z5E$ffj(5lt29O6n6H47H{=dPZu*HoW3JD%z2nr3uZUGHR!jK72%623+L zTGh{&Ptmu>cK1)mN6V6ilCoEm>*X#rv^ABcIkUpEo`t8mqsa)u*&$_D;1ajDMF4o!R53JkeAh0Js~Ak(zq;54wDK38k^cia3Ru&`?&<8{CgTwE8U(s`!m=pV$~ zocdTZ9H6K4n=G&g7l)M%u8S8+G1weU6mL!6O4gj=5y5m|d7e`cp@i#b;pZ90%hy08!BUH6zuutVxO}o`f=`i`?{skJAKPU~+0 z=V?S45&Tzi?i{pn`^+1aHMq^I&Tz=#XP3wImm4O-UD5(*Ru9@{<8p5%2tv< z#Jdh$t!9UKWIb)cI?!q4QETNP97QqiH|yq{NL`U~Qs8{O{e7&yxw$zpL1PxWJ)HW5 zlSfWhwb<%7+cJHMC%&&_Rh%J0N-DL{@p_?qGKZ*CNyrOhH_q3|j*HQGHU8BsDfU( zB=kY`y^wL#w(>6>A9B|=>$3W}kRW_rxw3;=x;^9e@Ul4*L5kq1=6|nYYc;*unhA%f z>F>>zrhZ~*3!tmC^N{t)=tC1&+7IB!EL-jlduQm%N4HOD#;^9(mYy&8K3{yUYX#+chOFEZ!l zyPq$<)`ed=kQ13jit~0r+zZ48&1)C5L+9_~*k$y(Lom*YHh;qgDq7<2K3IN>sH_Mqd@q*AwUfTdi9DW z->_=qYy|ukh;Y@%YqfJC^9j0kCECcjBv~4TR50LIAuP%0AWd@HkQD3c_*vb*^>clT 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 literal 0 HcmV?d00001 diff --git a/docs/QuickStart-RU.md b/docs/QuickStart-RU.md new file mode 100644 index 0000000..70918c1 --- /dev/null +++ b/docs/QuickStart-RU.md @@ -0,0 +1,451 @@ +
+ +Delibera + +# Delibera — Быстрый старт + +### ⚖️ Продуманные решения с помощью ИИ + +🇬🇧 [English version (QuickStart.md)](QuickStart.md) + +
+ +Это руководство за несколько минут проведёт вас через ваше первое обсуждение с помощью ИИ в +**Delibera**. По его завершении у вас будут запущенные локально многомодельные дебаты и +Markdown-стенограмма на диске. + +--- + +## 1. Требования + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) (≥ 10.0.301) — проект нацелен на + `net10.0` и собирается с `LangVersion=preview` для включения возможностей **C# 15**. См. + [NET10-Upgrade-RU.md](NET10-Upgrade-RU.md) для полных заметок о миграции. +- Запущенный экземпляр [Ollama](https://ollama.com) — либо: + - **Локально:** [установите Ollama](https://ollama.com/download) и запустите `ollama serve`, либо + - **Cloud:** зарегистрируйтесь в [Ollama Cloud](https://ollama.com/cloud) и получите API-ключ + (без установки, без GPU). +- **Опционально — для роли Operator:** [Node.js + npx](https://nodejs.org) для запуска MCP-серверов + (например, `@playwright/mcp`, `@marp-team/marp-cli`). См. [раздел 7](#7-использование-operator-mcp-инструменты). + +### Модели для установки + +Выберите **один** из наборов моделей ниже в зависимости от вашего железа. Набор 🟡 «Стандартный» +рекомендуется для большинства случаев. + +#### 🟢 Минимальный — слабое железо / smoke-тесты (≈ 2 ГБ всего) + +```bash +ollama pull llama3.2:1b +ollama pull qwen2.5:1.5b +ollama pull nomic-embed-text +``` + +#### 🟡 Стандартный — рекомендуется (≈ 7 ГБ всего) + +```bash +ollama pull llama3.2:3b +ollama pull qwen2.5:7b +ollama pull nomic-embed-text +``` + +#### 🔴 Высокопроизводительный — GPU с ≥ 24 ГБ VRAM или Ollama Cloud (≈ 30+ ГБ) + +```bash +ollama pull llama3.1:8b +ollama pull qwen2.5:14b +ollama pull mistral:7b +ollama pull nomic-embed-text +``` + +> ℹ️ Ollama Cloud использует те же имена моделей — локальная загрузка не нужна, только API-ключ. + +--- + +## 2. Установка пакета + +```bash +dotnet add package Delibera.Core +``` + +Или клонируйте репозиторий и сошлитесь на `Delibera.Core` напрямую: + +```bash +git clone https://github.com/delibera/Delibera.git +cd Delibera +``` + +--- + +## 3. Ваше первое обсуждение + +Создайте консольный проект и вставьте следующее: + +```csharp +// Program.cs +using Delibera.Core.Council; +using Delibera.Core.Providers; + +using var factory = new ProviderFactory(); +var ollama = factory.CreateOllama("http://localhost:11434"); + +var result = await new CouncilBuilder() + .AddMember("llama3.2:3b", ollama, "Analyst") + .AddMember("qwen2.5:7b", ollama, "Strategist") + .SetChairman(Chairman.CreateStandard("qwen2.5:7b", ollama)) + .WithStandardDebate() + .WithSystemPrompt("You are a software architecture expert.") + .WithUserPrompt("Microservices vs Monolith for a 5-person startup?") + .WithMaxRounds(4) + .SaveResultTo("./deliberation.md") + .Build() + .ExecuteAsync(); + +Console.WriteLine(result.FinalVerdict); +``` + +Запустите: + +```bash +dotnet run +``` + +Delibera проведёт структурированные многораундовые дебаты и запишет полную стенограмму и вердикт +председателя в файл `deliberation.md`. + +> ☁️ **Используете Ollama Cloud?** Замените endpoint на `https://api.ollama.com` и передайте API-ключ: +> `factory.CreateOllama("https://api.ollama.com", "YOUR_API_KEY")`. + +--- + +## 4. Использование Dependency Injection + +```csharp +using Delibera.Core.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; + +var services = new ServiceCollection(); + +// Привязывает секцию "Delibera" из appsettings.json +services.AddDelibera(configuration, "Delibera"); + +var provider = services.BuildServiceProvider(); +var builder = provider.GetRequiredService(); +``` + +`appsettings.json`: + +```json +{ + "Delibera": { + "Strategy": "Standard", + "MaxRounds": 4, + "Temperature": 0.7, + "SystemPrompt": "You are a knowledgeable AI expert participating in a council debate.", + "Providers": { + "DefaultType": "Ollama", + "DefaultEndpoint": "http://localhost:11434", + "ApiKey": "", + "EmbeddingModel": "nomic-embed-text" + }, + "Compression": { + "Enabled": true, + "Strategy": "Hybrid", + "TargetRatio": 0.5, + "EnableCache": true, + "MaxCacheEntries": 256 + }, + "Rag": { + "Enabled": false, + "ProviderType": "Qdrant", + "Host": "localhost", + "Port": 6334, + "CollectionName": "council_knowledge", + "ConnectionString": null + }, + "Output": { + "Directory": "./debate_results", + "SeparateFiles": true, + "FilePrefix": null + } + } +} +``` + +> Для **Ollama Cloud** задайте `Providers:DefaultEndpoint` равным `https://api.ollama.com` и +> `Providers:ApiKey` равным вашему ключу. + +--- + +## 5. Добавление сжатия контекста + +```csharp +using Delibera.Core.Compression; +using Delibera.Core.Providers.LLM; + +var ollama = new OllamaProvider("http://localhost:11434"); +var embeddings = new OllamaEmbeddingProvider(ollama, "nomic-embed-text"); + +var result = await new CouncilBuilder() + .AddMember("llama3.2:3b", ollama, "Analyst") + .AddMember("qwen2.5:7b", ollama, "Strategist") + .SetChairman(Chairman.CreateStandard("qwen2.5:7b", ollama)) + .WithCompression(CompressionStrategy.Hybrid, + llmProvider: ollama, + modelName: "llama3.2:3b", + embeddingProvider: embeddings) + .WithCompressionCache() + .WithUserPrompt("Analyze our architecture options...") + .WithMaxRounds(4) + .Build() + .ExecuteAsync(); + +Console.WriteLine(result.TokenStats?.ToSummary()); +``` + +Сжатие экономит примерно **30–70% токенов** без потери смысла контекста дебатов. + +--- + +## 6. Добавление RAG с Qdrant + +```bash +docker run -d -p 6333:6333 -p 6334:6334 qdrant/qdrant +``` + +```csharp +using Delibera.Core.Council; +using Delibera.Core.Models; +using Delibera.Core.Providers.LLM; +using Delibera.Core.Providers.RAG; + +var ollama = new OllamaProvider("http://localhost:11434"); +var embeddings = new OllamaEmbeddingProvider(ollama, "nomic-embed-text"); + +var ragFactory = new RagProviderFactory(); +var rag = ragFactory.CreateQdrant(embeddings, "localhost", 6334); + +var kkMember = new CouncilMember("llama3.2:3b", ollama, "Knowledge Keeper"); +var keeper = new KnowledgeKeeper(rag, kkMember, "architecture_kb"); +await keeper.IndexFileAsync("./docs/architecture.md"); + +var result = await new CouncilBuilder() + .AddMember("llama3.2:3b", ollama, "Backend Expert") + .AddMember("qwen2.5:7b", ollama, "DevOps Expert") + .SetChairman(Chairman.CreateStandard("qwen2.5:7b", ollama)) + .WithKnowledgeKeeper(keeper) + .WithStandardDebate() + .WithUserPrompt("Our startup has 4 developers. Microservices or monolith?") + .WithMaxRounds(4) + .Build() + .ExecuteAsync(); +``` + +> 💡 Замените `CreateQdrant(...)` на `CreatePgVector(embeddings, connectionString)`, чтобы +> использовать PostgreSQL/pgvector. + +--- + +## 7. Использование Operator (MCP-инструменты) + +**Operator** — это микроагент, который соединяет совет с внешними инструментами через серверы +[**MCP (Model Context Protocol)**](https://modelcontextprotocol.io) — веб-навигация, доступ к +файловой системе, генерация Marp-презентаций и многое другое. Участники делегируют задачу в любой +момент, написав маркер `[[OPERATOR: ...]]`, и Operator запускает нужные инструменты и возвращает +результат в следующий раунд. + +### 7.1. Установка требований + +Серверы из примеров ниже запускаются через `npx`, поэтому вам нужны **Node.js + npx**: + +```bash +# Проверьте, что Node.js / npx доступны +node --version +npx --version + +# Для сервера браузера один раз установите движки Playwright: +npx playwright install +``` + +Также нужна запущенная **Ollama** с дешёвой моделью для Operator (например, `llama3.2`) — Operator +использует свою собственную модель, отдельную от участников совета. + +### 7.2. Настройка MCP-серверов + +Каждый MCP-сервер описывается через `McpServerConfig`. Используйте `Stdio(...)` для локальных +процессов или `Http(...)` для удалённых серверов: + +```csharp +using Delibera.Core.Models; + +var servers = new[] +{ + // 🌐 Браузер — навигация, чтение страниц, клики, скриншоты + McpServerConfig.Stdio( + name: "browser", + command: "npx", + arguments: new[] { "-y", "@playwright/mcp@latest", "--headless" }), + + // 🎯 Marp — генерация презентаций HTML/PDF/PPTX из Markdown + McpServerConfig.Stdio( + name: "marp", + command: "npx", + arguments: new[] { "-y", "@marp-team/marp-cli", "--server", "./out" }), + + // …или удалённый HTTP/SSE MCP-сервер с заголовками авторизации: + // McpServerConfig.Http( + // name: "remote", + // endpoint: "https://my-mcp-host.example.com/mcp", + // additionalHeaders: new Dictionary { ["Authorization"] = "Bearer " }), +}; +``` + +| Сервер | Транспорт | Команда запуска | Что даёт | +| ------------ | --------- | ----------------------------------------------- | ---------------------------------------------------- | +| 🌐 `browser` | stdio | `npx -y @playwright/mcp@latest --headless` | Навигация по сайтам, чтение страниц, клики, скриншоты | +| 🎯 `marp` | stdio | `npx -y @marp-team/marp-cli --server ` | Генерация презентаций (HTML/PDF/PPTX) из Markdown | + +### 7.3. Прямое использование (без совета) + +```csharp +using Delibera.Core.Council; +using Delibera.Core.Interfaces; +using Delibera.Core.Models; +using Delibera.Core.Providers.LLM; +using Delibera.Core.Providers.Mcp; + +var ollama = new OllamaProvider("http://localhost:11434"); + +await using var @operator = new Operator( + new CouncilMember("llama3.2", ollama, "Operator"), + new IMcpClient[] { new McpClientAdapter(servers[0]), new McpClientAdapter(servers[1]) }); + +await @operator.InitializeAsync(); // подключается к серверам и обнаруживает инструменты + +// 1) Открыть сайт и пересказать его +var browse = await @operator.ExecuteTaskAsync( + "Open https://modelcontextprotocol.io and briefly summarize what MCP is."); +Console.WriteLine(browse.FinalAnswer); + +// 2) Сгенерировать презентацию из 3 слайдов и сохранить как deck.html +var deck = await @operator.ExecuteTaskAsync( + "Generate a 3-slide Marp presentation about .NET 10 and save it as deck.html."); +Console.WriteLine(deck.FinalAnswer); +``` + +> `await using` освобождает MCP-клиентов через `ValueTask DisposeAsync` у Operator. + +### 7.4. Operator внутри дебатов + +Добавьте Operator в совет с помощью удобной перегрузки `WithOperator(...)`. Участники затем +делегируют работу через маркер `[[OPERATOR: ...]]`, и их результаты внедряются в последующие раунды: + +```csharp +var result = await new CouncilBuilder() + .AddMember("llama3.2:3b", ollama, "Researcher") + .AddMember("qwen2.5:7b", ollama, "Reviewer") + .SetChairman(Chairman.CreateStandard("qwen2.5:7b", ollama)) + // Operator использует свою более дешёвую модель; reuseCompression разделяет компрессор совета + .WithOperator("llama3.2", ollama, servers, reuseCompression: true) + .WithStandardDebate() + .WithSystemPrompt( + "You may delegate research or presentation tasks to the Operator by writing " + + "[[OPERATOR: ]] in your message.") + .WithUserPrompt("Research the latest .NET 10 features and prepare a short summary deck.") + .WithMaxRounds(4) + .SaveResultTo("./deliberation.md") + .Build() + .ExecuteAsync(); + +Console.WriteLine(result.FinalVerdict); +``` + +Сообщение участника может выглядеть так: + +``` +I think we should highlight the runtime improvements. +[[OPERATOR: open https://learn.microsoft.com/dotnet/core/whats-new/dotnet-10 and list the top 5 features]] +``` + +Все взаимодействия с Operator записываются по раундам и отображаются в финальном Markdown-отчёте в +блоке **🛠️ Operator Interactions**. + +### 7.5. Настройка через `appsettings.json` + +```json +{ + "Delibera": { + "Operator": { + "Enabled": true, + "ModelName": "llama3.2", + "ReuseCompression": true, + "McpServers": [ + { + "Name": "browser", + "Transport": "Stdio", + "Command": "npx", + "Arguments": [ "-y", "@playwright/mcp@latest", "--headless" ] + }, + { + "Name": "marp", + "Transport": "Stdio", + "Command": "npx", + "Arguments": [ "-y", "@marp-team/marp-cli", "--server", "./out" ] + } + ] + } + } +} +``` + +> ▶️ Запустите полное демо: `dotnet run --project src/Delibera.ConsoleApp -- --operator-mcp`. +> См. [`OperatorMcpToolsExample.cs`](../src/Delibera.ConsoleApp/Examples/OperatorMcpToolsExample.cs) +> и [NET10-Upgrade-RU.md](NET10-Upgrade-RU.md) для деталей. + +--- + +## 8. Сохранение раздельных выходных файлов + +```csharp +var (resultPath, statsPath, logsPath) = await result.SaveAllAsync("./output"); +// → debate__result.md +// → debate__statistics.md +// → debate__logs.md +``` + +Каждый файл — это обычный Markdown, идеальный для коммита в git, публикации в чат-инструменты или +передачи обратно в другую LLM. + +--- + +## 9. Изучите ConsoleApp + +В репозитории есть запускаемое демо в +[`src/Delibera.ConsoleApp/`](../src/Delibera.ConsoleApp/), которое задействует каждую возможность: + +| Пример | Команда | Что демонстрирует | +| -------------------- | -------------------------------- | ---------------------------------------------- | +| Быстрый старт | `dotnet run` | Дебаты по умолчанию из `appsettings.json` | +| Dependency injection | `dotnet run -- --di` | `AddDelibera()` + резолвинг `ICouncilBuilder` | +| Раздельный вывод | `dotnet run -- --separate-files` | `SaveAllAsync()` — три Markdown-файла | +| Сжатие контекста | `dotnet run -- --compression` | Все 4 стратегии + кэш + статистика токенов | +| Несколько провайдеров| `dotnet run -- --multiprovider` | Cloud + локальные модели в одном совете | +| RAG (Qdrant) | `dotnet run -- --rag` | Индексирование документов + Knowledge Keeper | +| RAG (pgvector) | `dotnet run -- --pgvector` | Векторный поиск на базе PostgreSQL | +| Operator (основы) | `dotnet run -- --operator` | Роль Operator с MCP-инструментами | +| Operator + MCP | `dotnet run -- --operator-mcp` | 🆕 MCP-серверы browser + Marp в совете | + +--- + +## Дальнейшие шаги + +- Прочитайте [README](../README-RU.md) для полного обзора возможностей, архитектуры и паттернов проектирования. +- См. [CONTRIBUTING.md](../CONTRIBUTING.md), если хотите внести вклад. + +--- + +
+ +**⚖️ Delibera — Продуманные решения с помощью ИИ** + +
diff --git a/docs/QuickStart.md b/docs/QuickStart.md index 56fbdb0..0fa09b1 100644 --- a/docs/QuickStart.md +++ b/docs/QuickStart.md @@ -6,6 +6,8 @@ ### ⚖️ Thoughtful AI Decisions +🇷🇺 [Русская версия (QuickStart-RU.md)](QuickStart-RU.md) + This guide walks you through your first AI deliberation with **Delibera** in a few minutes. @@ -15,11 +17,15 @@ By the end you'll have a multi-model debate running locally and a Markdown trans ## 1. Prerequisites -- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) (≥ 10.0.301) — the project targets + `net10.0` and builds with `LangVersion=preview` to enable **C# 15** features. See + [NET10-Upgrade.md](NET10-Upgrade.md) for the full migration notes. - A running [Ollama](https://ollama.com) instance — either: - **Local:** [install Ollama](https://ollama.com/download) and run `ollama serve`, or - **Cloud:** sign up for [Ollama Cloud](https://ollama.com/cloud) and grab an API key (no install, no GPU required). +- **Optional — for the Operator role:** [Node.js + npx](https://nodejs.org) to launch MCP servers + (e.g. `@playwright/mcp`, `@marp-team/marp-cli`). See [section 7](#7-using-the-operator-mcp-tools). ### Models to install @@ -239,7 +245,165 @@ var result = await new CouncilBuilder() --- -## 7. Saving Separate Output Files +## 7. Using the Operator (MCP Tools) + +The **Operator** is a micro-agent that connects the council to external tools through +[**MCP (Model Context Protocol)**](https://modelcontextprotocol.io) servers — web browsing, +file system access, Marp presentation generation, and more. Participants delegate a task at any +moment by writing a `[[OPERATOR: ...]]` marker, and the Operator runs the right tools and feeds the +result back into the next round. + +### 7.1. Install the prerequisites + +The example servers below are launched via `npx`, so you need **Node.js + npx**: + +```bash +# Verify Node.js / npx are available +node --version +npx --version + +# For the browser server, install the Playwright engines once: +npx playwright install +``` + +You also need **Ollama** running with a cheap model for the Operator (e.g. `llama3.2`) — the +Operator uses its own model, separate from the council members. + +### 7.2. Configure MCP servers + +Each MCP server is described by an `McpServerConfig`. Use `Stdio(...)` for local processes or +`Http(...)` for remote servers: + +```csharp +using Delibera.Core.Models; + +var servers = new[] +{ + // 🌐 Browser — navigation, page reading, clicks, screenshots + McpServerConfig.Stdio( + name: "browser", + command: "npx", + arguments: new[] { "-y", "@playwright/mcp@latest", "--headless" }), + + // 🎯 Marp — generate HTML/PDF/PPTX presentations from Markdown + McpServerConfig.Stdio( + name: "marp", + command: "npx", + arguments: new[] { "-y", "@marp-team/marp-cli", "--server", "./out" }), + + // …or a remote HTTP/SSE MCP server with auth headers: + // McpServerConfig.Http( + // name: "remote", + // endpoint: "https://my-mcp-host.example.com/mcp", + // additionalHeaders: new Dictionary { ["Authorization"] = "Bearer " }), +}; +``` + +| Server | Transport | Launch command | What it provides | +| ------------ | --------- | ----------------------------------------------- | ---------------------------------------------------- | +| 🌐 `browser` | stdio | `npx -y @playwright/mcp@latest --headless` | Site navigation, page reading, clicks, screenshots | +| 🎯 `marp` | stdio | `npx -y @marp-team/marp-cli --server ` | Generating presentations (HTML/PDF/PPTX) from Markdown | + +### 7.3. Direct usage (without a council) + +```csharp +using Delibera.Core.Council; +using Delibera.Core.Interfaces; +using Delibera.Core.Models; +using Delibera.Core.Providers.LLM; +using Delibera.Core.Providers.Mcp; + +var ollama = new OllamaProvider("http://localhost:11434"); + +await using var @operator = new Operator( + new CouncilMember("llama3.2", ollama, "Operator"), + new IMcpClient[] { new McpClientAdapter(servers[0]), new McpClientAdapter(servers[1]) }); + +await @operator.InitializeAsync(); // connects to servers and discovers tools + +// 1) Browse a website and summarize it +var browse = await @operator.ExecuteTaskAsync( + "Open https://modelcontextprotocol.io and briefly summarize what MCP is."); +Console.WriteLine(browse.FinalAnswer); + +// 2) Generate a 3-slide presentation and save it as deck.html +var deck = await @operator.ExecuteTaskAsync( + "Generate a 3-slide Marp presentation about .NET 10 and save it as deck.html."); +Console.WriteLine(deck.FinalAnswer); +``` + +> `await using` releases the MCP clients through the Operator's `ValueTask DisposeAsync`. + +### 7.4. Operator inside a debate + +Add the Operator to a council with the convenience `WithOperator(...)` overload. Participants then +delegate work via the `[[OPERATOR: ...]]` marker, and their results are injected into later rounds: + +```csharp +var result = await new CouncilBuilder() + .AddMember("llama3.2:3b", ollama, "Researcher") + .AddMember("qwen2.5:7b", ollama, "Reviewer") + .SetChairman(Chairman.CreateStandard("qwen2.5:7b", ollama)) + // Operator uses its own cheaper model; reuseCompression shares the council's compressor + .WithOperator("llama3.2", ollama, servers, reuseCompression: true) + .WithStandardDebate() + .WithSystemPrompt( + "You may delegate research or presentation tasks to the Operator by writing " + + "[[OPERATOR: ]] in your message.") + .WithUserPrompt("Research the latest .NET 10 features and prepare a short summary deck.") + .WithMaxRounds(4) + .SaveResultTo("./deliberation.md") + .Build() + .ExecuteAsync(); + +Console.WriteLine(result.FinalVerdict); +``` + +A participant message might look like: + +``` +I think we should highlight the runtime improvements. +[[OPERATOR: open https://learn.microsoft.com/dotnet/core/whats-new/dotnet-10 and list the top 5 features]] +``` + +All Operator interactions are recorded per round and rendered in the final Markdown report under a +**🛠️ Operator Interactions** block. + +### 7.5. Configure via `appsettings.json` + +```json +{ + "Delibera": { + "Operator": { + "Enabled": true, + "ModelName": "llama3.2", + "ReuseCompression": true, + "McpServers": [ + { + "Name": "browser", + "Transport": "Stdio", + "Command": "npx", + "Arguments": [ "-y", "@playwright/mcp@latest", "--headless" ] + }, + { + "Name": "marp", + "Transport": "Stdio", + "Command": "npx", + "Arguments": [ "-y", "@marp-team/marp-cli", "--server", "./out" ] + } + ] + } + } +} +``` + +> ▶️ Run the full demo: `dotnet run --project src/Delibera.ConsoleApp -- --operator-mcp`. +> See [`OperatorMcpToolsExample.cs`](../src/Delibera.ConsoleApp/Examples/OperatorMcpToolsExample.cs) +> and [NET10-Upgrade.md](NET10-Upgrade.md) for details. + +--- + +## 8. Saving Separate Output Files ```csharp var (resultPath, statsPath, logsPath) = await result.SaveAllAsync("./output"); @@ -253,7 +417,7 @@ back into another LLM. --- -## 8. Explore the ConsoleApp +## 9. Explore the ConsoleApp The repository ships with a runnable demo under [`src/Delibera.ConsoleApp/`](../src/Delibera.ConsoleApp/) that exercises every feature: @@ -267,6 +431,8 @@ The repository ships with a runnable demo under | Multi-provider | `dotnet run -- --multiprovider` | Cloud + local models in one council | | RAG (Qdrant) | `dotnet run -- --rag` | Document indexing + Knowledge Keeper | | RAG (pgvector) | `dotnet run -- --pgvector` | PostgreSQL-backed vector search | +| Operator (basics) | `dotnet run -- --operator` | Operator role with MCP tools | +| Operator + MCP | `dotnet run -- --operator-mcp` | 🆕 Browser + Marp MCP servers in a council | --- diff --git a/src/Delibera.ConsoleApp/Delibera.ConsoleApp.csproj b/src/Delibera.ConsoleApp/Delibera.ConsoleApp.csproj index 07e29a6..7474cda 100644 --- a/src/Delibera.ConsoleApp/Delibera.ConsoleApp.csproj +++ b/src/Delibera.ConsoleApp/Delibera.ConsoleApp.csproj @@ -5,7 +5,7 @@ net10.0 enable enable - 14.0 + preview Delibera Delibera.ConsoleApp $(NoWarn);CS9057 diff --git a/src/Delibera.ConsoleApp/Examples/OperatorExample.cs b/src/Delibera.ConsoleApp/Examples/OperatorExample.cs new file mode 100644 index 0000000..e1d4dfa --- /dev/null +++ b/src/Delibera.ConsoleApp/Examples/OperatorExample.cs @@ -0,0 +1,111 @@ +using Delibera.Core.Council; +using Delibera.Core.Models; +using Delibera.Core.Providers; + +namespace Delibera.ConsoleApp.Examples; + +/// +/// Operator example — demonstrates the 🛠️ Operator role: a micro-agent that connects to one or +/// more MCP (Model Context Protocol) servers and exposes their tools to the debate participants. +/// During the debate, participants delegate natural-language tasks to the Operator using the +/// [[OPERATOR: ...]] marker (e.g., web search, database lookups, writing to Notion, etc.). +/// The Operator uses its own (cheaper) model to plan tool calls, execute them, and synthesise an +/// answer that is fed back into the next round. +/// +public static class OperatorExample +{ + public static async Task RunAsync() + { + Console.WriteLine("🛠️ Operator (MCP) Example\n"); + + // ── 1. LLM providers ── + // Participants typically use stronger (more expensive) models; the Operator uses a cheaper one. + using var factory = new ProviderFactory(); + var ollama = factory.CreateOllama("http://localhost:11434"); + + // ── 2. Configure MCP servers ── + // (a) Stdio transport — a local MCP server launched as a child process. + // The "everything" reference server ships a handful of demo tools (echo, add, etc.). + var everythingServer = McpServerConfig.Stdio( + name: "demo", + command: "npx", + arguments: ["-y", "@modelcontextprotocol/server-everything"]); + + // (b) A filesystem MCP server scoped to a working directory (also stdio). + var filesystemServer = McpServerConfig.Stdio( + name: "files", + command: "npx", + arguments: ["-y", "@modelcontextprotocol/server-filesystem", Directory.GetCurrentDirectory()]); + + // (c) HTTP/SSE transport — a remote MCP server (e.g., a hosted Notion or web-search server). + // Supply auth headers as needed. Commented out: requires a real endpoint + token. + // var notionServer = McpServerConfig.Http( + // name: "notion", + // endpoint: new Uri("https://mcp.notion.com/sse"), + // additionalHeaders: new Dictionary { ["Authorization"] = "Bearer " }); + + var servers = new[] { everythingServer, filesystemServer }; + + // ── 3. Build the council with an Operator ── + // The convenience overload creates the Operator from a (cheap) model + MCP servers and wires + // it through the whole pipeline. The Operator is DI-friendly and initialised automatically + // (connects to the servers and discovers their tools) before the debate starts. + var executor = new CouncilBuilder() + .AddMember("qwen2.5", ollama, "Researcher") + .AddMember("llama2", ollama, "Analyst") + .SetChairman(Chairman.CreateStandard("qwen2.5", ollama)) + .WithOperator( + modelName: "llama3.2", // cheaper model for tool orchestration + provider: ollama, + servers: servers) + .WithStandardDebate() + .WithSystemPrompt("You are a meticulous research council. Delegate factual lookups to the Operator.") + .WithUserPrompt( + "Summarise the key risks of adopting microservices for a 4-developer startup. " + + "If you need an external lookup or calculation, delegate it to the Operator.") + .WithMaxRounds(4) + .SaveResultTo("./results/operator_debate.md") + .Build(); + + Console.WriteLine(executor.GetInfo()); + + // ── 4. Observe Operator activity per round ── + executor.OnRoundCompleted += round => + { + Console.WriteLine($"✅ Round {round.RoundNumber}: {round.RoundName}"); + foreach (var oi in round.OperatorInteractions) + { + Console.WriteLine($" 🛠️ {oi.RequesterName} → \"{oi.Task}\""); + Console.WriteLine($" tools: {oi.ToolCallCount}, answer: {Preview(oi.Answer, 120)}"); + } + }; + + // ── 5. Run the debate ── + try + { + var result = await executor.ExecuteAsync(); + + Console.WriteLine($"\n🏆 Debate completed in {result.TotalDuration.TotalSeconds:F1}s"); + if (result.OperatorName is not null) + Console.WriteLine($" Operator: {result.OperatorName}"); + Console.WriteLine("📁 Saved to: ./results/operator_debate.md"); + + if (!string.IsNullOrWhiteSpace(result.FinalVerdict)) + { + Console.WriteLine("\n══ FINAL VERDICT ══\n"); + Console.WriteLine(result.FinalVerdict); + } + } + catch (Exception ex) + { + Console.WriteLine($"\n❌ Operator example failed: {ex.Message}"); + Console.WriteLine("\n💡 Tips:"); + Console.WriteLine(" • Node.js + npx must be installed for the npx-based MCP servers."); + Console.WriteLine(" • Ensure a local Ollama server is running: ollama serve"); + Console.WriteLine(" • Swap in your own MCP servers via McpServerConfig.Stdio / .Http."); + } + } + + private static string Preview(string text, int max) => + string.IsNullOrEmpty(text) ? "(empty)" : text.Length <= max ? text : text[..max] + "…"; +} diff --git a/src/Delibera.ConsoleApp/Examples/OperatorMcpToolsExample.cs b/src/Delibera.ConsoleApp/Examples/OperatorMcpToolsExample.cs new file mode 100644 index 0000000..2df7d14 --- /dev/null +++ b/src/Delibera.ConsoleApp/Examples/OperatorMcpToolsExample.cs @@ -0,0 +1,237 @@ +using Delibera.Core.Council; +using Delibera.Core.Interfaces; +using Delibera.Core.Models; +using Delibera.Core.Providers; +using Delibera.Core.Providers.Mcp; + +namespace Delibera.ConsoleApp.Examples; + +/// +/// Расширенный пример работы 🛠️ Operator с реальными MCP-инструментами. +/// +/// Демонстрирует два практических MCP-сервера: +/// +/// +/// +/// +/// Браузер — официальный сервер @playwright/mcp, который даёт Operator'у +/// инструменты для навигации по сайтам, чтения содержимого страниц, кликов и снятия +/// скриншотов (browser_navigate, browser_snapshot, browser_click и т. д.). +/// +/// +/// +/// +/// Marp-презентации — сервер на базе Marp CLI (@marp-team/marp-cli), +/// запускаемый как stdio-MCP, который превращает Markdown в готовые презентации +/// (HTML/PDF/PPTX). Operator формирует Markdown по запросу участника и собирает слайды. +/// +/// +/// +/// +/// Пример показывает оба способа использования Operator: +/// +/// +/// Прямой вызов (раздел A). +/// Делегирование внутри совета через маркер [[OPERATOR: ...]] (раздел B). +/// +/// +public static class OperatorMcpToolsExample +{ + public static async Task RunAsync() + { + Console.OutputEncoding = System.Text.Encoding.UTF8; + Console.WriteLine("🛠️ Operator + MCP-инструменты (браузер · Marp-презентации)\n"); + + // ── LLM-провайдер ── + // Для оркестрации инструментов достаточно «дешёвой» модели — её задача планировать + // и интерпретировать вызовы, а не вести глубокие рассуждения. + using var factory = new ProviderFactory(); + var ollama = factory.CreateOllama("http://localhost:11434"); + + // ════════════════════════════════════════════════════════════════ + // Конфигурация MCP-серверов + // ════════════════════════════════════════════════════════════════ + + // (1) 🌐 Браузер — Playwright MCP. + // Предоставляет инструменты управления реальным браузером. + // Запускается как дочерний процесс через npx (нужен Node.js). + var browserServer = McpServerConfig.Stdio( + name: "browser", + command: "npx", + arguments: ["-y", "@playwright/mcp@latest", "--headless"]); + + // (2) 🎯 Marp — генерация презентаций из Markdown. + // Каталог вывода монтируется через рабочую директорию; готовые файлы + // (например, deck.html / deck.pdf) появятся в ./results/presentations. + var presentationsDir = Path.Combine(Directory.GetCurrentDirectory(), "results", "presentations"); + Directory.CreateDirectory(presentationsDir); + + var marpServer = McpServerConfig.Stdio( + name: "marp", + command: "npx", + arguments: ["-y", "@marp-team/marp-cli", "--server", presentationsDir], + workingDirectory: presentationsDir); + + // (3) 🌐 HTTP-вариант (закомментирован): удалённый MCP-сервер браузера/поиска. + // Передавайте заголовки авторизации при необходимости. + // var remoteBrowser = McpServerConfig.Http( + // name: "browser-remote", + // endpoint: new Uri("https://mcp.example.com/sse"), + // additionalHeaders: new Dictionary { ["Authorization"] = "Bearer " }); + + var servers = new[] { browserServer, marpServer }; + + // ════════════════════════════════════════════════════════════════ + // Раздел A. Прямое использование Operator (без совета) + // ════════════════════════════════════════════════════════════════ + await RunDirectOperatorAsync(ollama, servers); + + // ════════════════════════════════════════════════════════════════ + // Раздел B. Operator внутри совета (делегирование [[OPERATOR: ...]]) + // ════════════════════════════════════════════════════════════════ + await RunCouncilWithOperatorAsync(ollama, servers); + } + + // ────────────────────────────────────────────────────────────────── + // A. Прямой Operator: формируем MCP-клиентов вручную и вызываем задачи + // ────────────────────────────────────────────────────────────────── + private static async Task RunDirectOperatorAsync( + ILLMProvider ollama, + IReadOnlyList servers) + { + Console.WriteLine("══ Раздел A. Прямой вызов Operator ══\n"); + + // Operator использует «дешёвую» модель для планирования инструментов. + var operatorModel = new CouncilMember("llama3.2", ollama, "Operator"); + + // Создаём по одному MCP-клиенту на каждый сервер. + var clients = servers.Select(IMcpClientFor).ToArray(); + + await using var @operator = new Operator(operatorModel, clients); + + try + { + await @operator.InitializeAsync(); + Console.WriteLine($"✅ Operator инициализирован: {@operator.DisplayName}"); + Console.WriteLine($" Обнаружено инструментов: {@operator.AvailableTools.Count}"); + foreach (var tool in @operator.AvailableTools.Take(8)) + Console.WriteLine($" • [{tool.ServerName}] {tool.Name} — {Preview(tool.Description, 60)}"); + + // A.1 🌐 Браузерная задача — Operator сам выберет browser_navigate + browser_snapshot. + Console.WriteLine("\n▶ Задача 1 (браузер): прочитать заголовок и краткое содержание страницы."); + var browseResult = await @operator.ExecuteTaskAsync( + requesterName: "Аналитик", + task: "Открой https://modelcontextprotocol.io и кратко перескажи, что такое MCP, в 3 предложениях."); + PrintResult(browseResult); + + // A.2 🎯 Marp-задача — Operator сформирует Markdown и соберёт презентацию. + Console.WriteLine("\n▶ Задача 2 (Marp): сгенерировать презентацию из Markdown."); + var deckResult = await @operator.ExecuteTaskAsync( + requesterName: "Докладчик", + task: "Создай Marp-презентацию из 3 слайдов о преимуществах .NET 10 и сохрани её как deck.html."); + PrintResult(deckResult); + } + catch (Exception ex) + { + PrintFailureTips(ex); + } + } + + // ────────────────────────────────────────────────────────────────── + // B. Совет с Operator: участники делегируют задачи маркером [[OPERATOR: ...]] + // ────────────────────────────────────────────────────────────────── + private static async Task RunCouncilWithOperatorAsync( + ILLMProvider ollama, + IReadOnlyList servers) + { + Console.WriteLine("\n══ Раздел B. Operator внутри совета ══\n"); + + var executor = new CouncilBuilder() + .AddMember("qwen2.5", ollama, "Исследователь") + .AddMember("llama2", ollama, "Архитектор") + .SetChairman(Chairman.CreateStandard("qwen2.5", ollama)) + .WithOperator( + modelName: "llama3.2", // дешёвая модель для оркестрации инструментов + provider: ollama, + servers: servers) + .WithStandardDebate() + .WithSystemPrompt( + "Вы — совет по технологической стратегии. Когда нужны свежие факты из интернета " + + "или сборка презентации — делегируйте задачу Operator'у маркером [[OPERATOR: задача]].") + .WithUserPrompt( + "Подготовьте краткий обзор «Что нового в .NET 10 для high-performance кода». " + + "Сначала проверьте актуальную информацию через браузер, затем соберите итоговую " + + "Marp-презентацию из 4 слайдов.") + .WithMaxRounds(4) + .SaveResultTo("./results/operator_mcp_debate.md") + .Build(); + + Console.WriteLine(executor.GetInfo()); + + // Наблюдаем за активностью Operator по раундам. + executor.OnRoundCompleted += round => + { + Console.WriteLine($"✅ Раунд {round.RoundNumber}: {round.RoundName}"); + foreach (var oi in round.OperatorInteractions) + { + Console.WriteLine($" 🛠️ {oi.RequesterName} → \"{Preview(oi.Task, 70)}\""); + Console.WriteLine($" инструментов: {oi.ToolCallCount}, ответ: {Preview(oi.Answer, 100)}"); + } + }; + + try + { + var result = await executor.ExecuteAsync(); + + Console.WriteLine($"\n🏆 Дебаты завершены за {result.TotalDuration.TotalSeconds:F1} c"); + if (result.OperatorName is not null) + Console.WriteLine($" Operator: {result.OperatorName}"); + Console.WriteLine("📁 Отчёт: ./results/operator_mcp_debate.md"); + Console.WriteLine("📁 Презентации: ./results/presentations/"); + + if (!string.IsNullOrWhiteSpace(result.FinalVerdict)) + { + Console.WriteLine("\n══ ИТОГОВОЕ РЕШЕНИЕ ══\n"); + Console.WriteLine(result.FinalVerdict); + } + } + catch (Exception ex) + { + PrintFailureTips(ex); + } + } + + // ────────────────────────────────────────────────────────────────── + // Вспомогательные методы + // ────────────────────────────────────────────────────────────────── + + /// Создаёт MCP-клиент для конфигурации сервера. + private static IMcpClient IMcpClientFor(McpServerConfig config) => new McpClientAdapter(config); + + private static void PrintResult(OperatorResult result) + { + Console.WriteLine($" Вызвано инструментов: {result.ToolCalls.Count}" + + (result.Compressed ? " (ответ сжат)" : string.Empty)); + foreach (var call in result.ToolCalls) + { + var status = call.IsError ? "❌" : "✓"; + Console.WriteLine($" {status} [{call.ServerName}] {call.ToolName}"); + } + + Console.WriteLine($" 💬 Ответ: {Preview(result.Answer, 240)}"); + } + + private static void PrintFailureTips(Exception ex) + { + Console.WriteLine($"\n❌ Пример завершился ошибкой: {ex.Message}"); + Console.WriteLine("\n💡 Подсказки:"); + Console.WriteLine(" • Установите Node.js + npx (нужны для npx-MCP-серверов)."); + Console.WriteLine(" • Для браузера: первый запуск Playwright скачает движки (npx playwright install)."); + Console.WriteLine(" • Запустите локальный Ollama: ollama serve (модели llama3.2, qwen2.5)."); + Console.WriteLine(" • Свои MCP-серверы подключаются через McpServerConfig.Stdio / .Http."); + } + + private static string Preview(string? text, int max) => + string.IsNullOrEmpty(text) ? "(пусто)" : + text.Length <= max ? text : text[..max] + "…"; +} diff --git a/src/Delibera.ConsoleApp/Program.cs b/src/Delibera.ConsoleApp/Program.cs index c608f45..f6444bc 100644 --- a/src/Delibera.ConsoleApp/Program.cs +++ b/src/Delibera.ConsoleApp/Program.cs @@ -65,6 +65,18 @@ public static async Task Main(string[] args) return; } + if (args.Contains("--operator")) + { + await OperatorExample.RunAsync(); + return; + } + + if (args.Contains("--operator-mcp")) + { + await OperatorMcpToolsExample.RunAsync(); + return; + } + // Quick DI showcase before main demo Console.WriteLine("🆕 v3.1 DI Quick Demo:"); Console.WriteLine(" Run with --di for full DI example"); @@ -78,7 +90,7 @@ public static async Task Main(string[] args) var configuration = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) .AddJsonFile("appsettings.json", false, true) - .AddUserSecrets(Assembly.GetEntryAssembly()) + .AddUserSecrets(Assembly.GetEntryAssembly()!) .Build(); var cfg = configuration.GetSection("DeliberaApp"); @@ -394,6 +406,13 @@ public static async Task Main(string[] args) Console.WriteLine($" Q: {ki.Query[..Math.Min(80, ki.Query.Length)]}… → {ki.SourceChunks} chunks"); } + if (round.OperatorInteractions.Count > 0) + { + Console.WriteLine(" 🛠️ 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)"); + } + foreach (var (member, response) in round.Responses) { var preview = response.Length > 300 @@ -421,6 +440,8 @@ public static async Task Main(string[] args) Console.WriteLine($" Participants: {string.Join(", ", result.Participants)}"); if (result.KnowledgeKeeperName is not null) Console.WriteLine($" Knowledge Keeper: {result.KnowledgeKeeperName}"); + if (result.OperatorName is not null) + Console.WriteLine($" Operator: {result.OperatorName}"); // Token statistics if (result.TokenStats is not null) Console.WriteLine($"\n{result.TokenStats.ToSummary()}"); diff --git a/src/Delibera.Core/Compression/CompressionCache.cs b/src/Delibera.Core/Compression/CompressionCache.cs index 8ebecf7..a619f81 100644 --- a/src/Delibera.Core/Compression/CompressionCache.cs +++ b/src/Delibera.Core/Compression/CompressionCache.cs @@ -1,3 +1,4 @@ +using System.Buffers; using System.Collections.Concurrent; using System.Security.Cryptography; @@ -110,9 +111,33 @@ public void Clear() private static string ComputeKey(string text, string strategyName) { - var input = $"{strategyName}:{text}"; - var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(input)); - return Convert.ToHexString(hashBytes); + // Compose "{strategy}:{text}" into a pooled char buffer, then UTF-8 encode + // into a pooled byte buffer — avoids two intermediate heap allocations + // (the interpolated string and the GetBytes array) on this hot path. + var charCount = strategyName.Length + 1 + text.Length; + var chars = ArrayPool.Shared.Rent(charCount); + byte[]? bytes = null; + try + { + strategyName.AsSpan().CopyTo(chars); + chars[strategyName.Length] = ':'; + text.AsSpan().CopyTo(chars.AsSpan(strategyName.Length + 1)); + var charSpan = chars.AsSpan(0, charCount); + + var maxBytes = Encoding.UTF8.GetMaxByteCount(charCount); + bytes = ArrayPool.Shared.Rent(maxBytes); + var byteCount = Encoding.UTF8.GetBytes(charSpan, bytes); + + Span hash = stackalloc byte[SHA256.HashSizeInBytes]; + SHA256.HashData(bytes.AsSpan(0, byteCount), hash); + return Convert.ToHexString(hash); + } + finally + { + ArrayPool.Shared.Return(chars); + if (bytes is not null) + ArrayPool.Shared.Return(bytes); + } } private sealed class CacheEntry diff --git a/src/Delibera.Core/Compression/SemanticCompressor.cs b/src/Delibera.Core/Compression/SemanticCompressor.cs index 8000cdf..cbbc3fb 100644 --- a/src/Delibera.Core/Compression/SemanticCompressor.cs +++ b/src/Delibera.Core/Compression/SemanticCompressor.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Numerics; namespace Delibera.Core.Compression; @@ -151,11 +152,38 @@ internal static List SplitSentences(string text) return sentences; } - internal static double CosineSimilarity(float[] a, float[] b) + internal static double CosineSimilarity(ReadOnlySpan a, ReadOnlySpan b) { - if (a.Length != b.Length) return 0; - double dot = 0, magA = 0, magB = 0; - for (var i = 0; i < a.Length; i++) + if (a.Length != b.Length || a.IsEmpty) return 0; + + float dot = 0, magA = 0, magB = 0; + var i = 0; + + // SIMD fast-path: process Vector.Count lanes at a time. + // On modern x64/ARM64 this is 8–16 floats per iteration. + if (Vector.IsHardwareAccelerated && a.Length >= Vector.Count) + { + var dotAcc = Vector.Zero; + var magAAcc = Vector.Zero; + var magBAcc = Vector.Zero; + var width = Vector.Count; + + for (; i <= a.Length - width; i += width) + { + var va = new Vector(a.Slice(i, width)); + var vb = new Vector(b.Slice(i, width)); + dotAcc += va * vb; + magAAcc += va * va; + magBAcc += vb * vb; + } + + dot = Vector.Dot(dotAcc, Vector.One); + magA = Vector.Dot(magAAcc, Vector.One); + magB = Vector.Dot(magBAcc, Vector.One); + } + + // Scalar tail (and full loop when SIMD is unavailable). + for (; i < a.Length; i++) { dot += a[i] * b[i]; magA += a[i] * a[i]; diff --git a/src/Delibera.Core/Compression/TokenCounter.cs b/src/Delibera.Core/Compression/TokenCounter.cs index 7d90948..6f7a1dc 100644 --- a/src/Delibera.Core/Compression/TokenCounter.cs +++ b/src/Delibera.Core/Compression/TokenCounter.cs @@ -44,6 +44,19 @@ public int EstimateTokens(string? text) if (TokenizerFunc is not null) return TokenizerFunc(text); + return EstimateTokens(text.AsSpan()); + } + + /// + /// Estimates the token count for the given text span without allocating. + /// Note: a custom is ignored on this allocation-free path. + /// + /// Input text span. + /// Estimated token count. + public int EstimateTokens(ReadOnlySpan text) + { + if (text.IsEmpty) return 0; + // Heuristic: count words + account for sub-word tokenization // Blend word-count and char-count estimates for better accuracy var wordCount = CountWords(text); @@ -95,7 +108,7 @@ public string TruncateToTokenLimit(string text, int maxTokens) // ────────────────────────────────────────────── - private static int CountWords(string text) + private static int CountWords(ReadOnlySpan text) { var count = 0; var inWord = false; diff --git a/src/Delibera.Core/Council/CouncilBuilder.cs b/src/Delibera.Core/Council/CouncilBuilder.cs index 72f6f2d..37fa7bc 100644 --- a/src/Delibera.Core/Council/CouncilBuilder.cs +++ b/src/Delibera.Core/Council/CouncilBuilder.cs @@ -1,5 +1,6 @@ using Delibera.Core.Compression; using Delibera.Core.Debate; +using Delibera.Core.Providers.Mcp; namespace Delibera.Core.Council; @@ -15,6 +16,10 @@ public sealed class CouncilBuilder : ICouncilBuilder private IContextCompressor? _compressor; private IKnowledgeBase? _knowledgeBase; private KnowledgeKeeper? _knowledgeKeeper; + private Operator? _operator; + private bool _operatorReuseCompression; + private CouncilMember? _operatorModel; + private IReadOnlyList? _operatorServers; private int _maxRounds = 4; private string? _outputPath; private IDebateStrategy _strategy = new StandardDebate(); @@ -89,6 +94,37 @@ public ICouncilBuilder WithKnowledgeKeeper( return this; } + // ── Operator (MCP tool micro-agent) ── + + /// + public ICouncilBuilder WithOperator(Operator @operator) + { + _operator = @operator ?? throw new ArgumentNullException(nameof(@operator)); + return this; + } + + /// + public ICouncilBuilder WithOperator( + string modelName, + ILLMProvider provider, + IEnumerable servers, + bool reuseCompression = true) + { + ArgumentException.ThrowIfNullOrWhiteSpace(modelName); + ArgumentNullException.ThrowIfNull(provider); + ArgumentNullException.ThrowIfNull(servers); + + var serverList = servers.ToList(); + if (serverList.Count == 0) + throw new ArgumentException("At least one MCP server configuration is required.", nameof(servers)); + + // Operator construction is deferred to Build() so it can reuse the council's compressor. + _operatorModel = new CouncilMember(modelName, provider, "Operator"); + _operatorServers = serverList; + _operatorReuseCompression = reuseCompression; + return this; + } + // ── Strategy ── /// @@ -202,6 +238,19 @@ public CouncilExecutor Build() KnowledgeFiles = _knowledgeBase?.GetLoadedSources().ToList() ?? [] }; + // Build the deferred Operator (if configured via the convenience overload) so it can reuse + // the council's compressor when requested. + var @operator = _operator; + if (@operator is null && _operatorModel is not null && _operatorServers is not null) + { + var clients = _operatorServers.Select(s => (IMcpClient)new McpClientAdapter(s)).ToList(); + @operator = new Operator( + _operatorModel, + clients, + _operatorReuseCompression ? _compressor : null, + _operatorReuseCompression ? _compressionOptions : null); + } + return new CouncilExecutor( _members.AsReadOnly(), _chairman, @@ -213,7 +262,8 @@ public CouncilExecutor Build() _outputPath, _compressor, _compressionOptions, - _compressionCache); + _compressionCache, + @operator); } /// diff --git a/src/Delibera.Core/Council/CouncilExecutor.cs b/src/Delibera.Core/Council/CouncilExecutor.cs index bc91974..cc2823a 100644 --- a/src/Delibera.Core/Council/CouncilExecutor.cs +++ b/src/Delibera.Core/Council/CouncilExecutor.cs @@ -26,11 +26,13 @@ internal CouncilExecutor( string? outputPath, IContextCompressor? compressor = null, CompressionOptions? compressionOptions = null, - CompressionCache? compressionCache = null) + CompressionCache? compressionCache = null, + Operator? @operator = null) { Members = members; Chairman = chairman; KnowledgeKeeper = knowledgeKeeper; + Operator = @operator; Strategy = strategy; _context = context; _maxRounds = maxRounds; @@ -53,6 +55,9 @@ internal CouncilExecutor( /// Knowledge Keeper (may be null). public KnowledgeKeeper? KnowledgeKeeper { get; } + /// Operator (may be null). + public Operator? Operator { get; } + /// Debate strategy. public IDebateStrategy Strategy { get; } @@ -80,6 +85,25 @@ public async Task ExecuteAsync(CancellationToken ct = default) if (KnowledgeKeeper is not null) Log(ExecutionLog.Info("KnowledgeKeeper", $"Knowledge Keeper ready: {KnowledgeKeeper.DisplayName} (collection: {KnowledgeKeeper.CollectionName})")); + // Initialise the Operator (connect to MCP servers, discover tools) before the debate begins. + if (Operator is not null) + { + if (!Operator.IsInitialized) + { + Log(ExecutionLog.Info("Operator", $"Initialising Operator: {Operator.DisplayName}…")); + try + { + await Operator.InitializeAsync(ct); + } + catch (Exception ex) + { + Log(ExecutionLog.Error("Operator", $"Operator initialisation failed: {ex.Message}")); + } + } + + Log(ExecutionLog.Info("Operator", $"Operator ready: {Operator.DisplayName} ({Operator.AvailableTools.Count} tool(s) available)")); + } + if (Compressor is not null) Log(ExecutionLog.Info("Compression", $"Compression enabled: {Compressor.StrategyName}")); @@ -91,6 +115,7 @@ public async Task ExecuteAsync(CancellationToken ct = default) _context, Chairman, KnowledgeKeeper, + Operator, _maxRounds, _temperature, round => @@ -101,6 +126,10 @@ public async Task ExecuteAsync(CancellationToken ct = default) foreach (var ki in round.KnowledgeInteractions) Log(ExecutionLog.Info("KnowledgeKeeper", $"Query: \"{Truncate(ki.Query, 100)}\" → {ki.SourceChunks} chunks")); + // Log operator interactions + foreach (var oi in round.OperatorInteractions) + Log(ExecutionLog.Info("Operator", $"{oi.RequesterName} → \"{Truncate(oi.Task, 100)}\" ({oi.ToolCallCount} tool call(s))")); + // Log participant responses foreach (var (member, response) in round.Responses) Log(ExecutionLog.Trace("Participant", $"{member} responded ({response.Length} chars)")); @@ -186,6 +215,13 @@ public string GetInfo() sb.AppendLine($" 📚 {KnowledgeKeeper.DisplayName} (collection: {KnowledgeKeeper.CollectionName})"); } + if (Operator is not null) + { + sb.AppendLine(); + sb.AppendLine(" ── Operator ──"); + sb.AppendLine($" 🛠️ {Operator.DisplayName} ({Operator.AvailableTools.Count} tool(s))"); + } + if (Compressor is not null) { sb.AppendLine(); diff --git a/src/Delibera.Core/Council/Operator.cs b/src/Delibera.Core/Council/Operator.cs new file mode 100644 index 0000000..d9385d5 --- /dev/null +++ b/src/Delibera.Core/Council/Operator.cs @@ -0,0 +1,354 @@ +using System.Text.Json; + +namespace Delibera.Core.Council; + +/// +/// The Operator — a special council role implemented as a self-contained micro-agent. +/// It connects to one or more MCP (Model Context Protocol) servers, exposes their tools +/// to debate participants, and fulfils natural-language tasks delegated by participants: +/// it interprets the task with its own (typically cheaper) LLM model, selects and invokes +/// the appropriate MCP tools, interprets the raw results, optionally compresses them using +/// the debate's compression strategy, and returns a concise answer to the requester. +/// +/// +/// The Operator deliberately uses a less expensive model than the debate participants — +/// its job is orchestration and interpretation of tool output, not deep reasoning, so a +/// cheaper model keeps tool usage economical. +/// +public sealed class Operator : IOperator +{ + private readonly CompressionOptions? _compressionOptions; + private readonly IContextCompressor? _compressor; + private readonly List _interactions = []; + private readonly Dictionary _mcpClients; + private readonly CouncilMember _model; + private readonly List _tools = []; + + /// + /// Creates an Operator. + /// + /// The (cheaper) LLM model the Operator uses to plan and interpret tool output. + /// MCP server clients the Operator can use. + /// Optional compressor used to shrink large tool results before returning them. + /// Optional compression options. + public Operator( + CouncilMember model, + IEnumerable mcpClients, + IContextCompressor? compressor = null, + CompressionOptions? compressionOptions = null) + { + _model = model ?? throw new ArgumentNullException(nameof(model)); + ArgumentNullException.ThrowIfNull(mcpClients); + + _mcpClients = mcpClients.ToDictionary(c => c.ServerName, StringComparer.OrdinalIgnoreCase); + _compressor = compressor; + _compressionOptions = compressionOptions; + + if (_model.Role is null or "Expert") + _model.Role = "Operator"; + } + + /// + public string DisplayName => $"🛠️ Operator ({_model.ModelName})"; + + /// + public bool IsInitialized { get; private set; } + + /// + public IReadOnlyList AvailableTools => _tools.AsReadOnly(); + + /// + public IReadOnlyList Interactions => _interactions.AsReadOnly(); + + /// + public async Task InitializeAsync(CancellationToken ct = default) + { + if (IsInitialized) return; + + _tools.Clear(); + foreach (var client in _mcpClients.Values) + try + { + await client.ConnectAsync(ct); + var tools = await client.ListToolsAsync(ct); + _tools.AddRange(tools); + } + catch (Exception ex) + { + // A failing server should not abort the whole debate — skip it but keep going. + _interactions.Add(new OperatorInteraction( + "System", + $"Connect to MCP server '{client.ServerName}'", + $"[Failed to initialise server '{client.ServerName}': {ex.Message}]", + [], + false, + DateTime.UtcNow)); + } + + IsInitialized = true; + } + + /// + public string GetToolCatalog() + { + if (_tools.Count == 0) + return "The Operator currently has no tools available."; + + var sb = new StringBuilder(); + sb.AppendLine("The Operator can perform the following actions via connected MCP servers:"); + foreach (var byServer in _tools.GroupBy(t => t.ServerName)) + { + sb.AppendLine($"\n• Server \"{byServer.Key}\":"); + foreach (var tool in byServer) + { + var desc = string.IsNullOrWhiteSpace(tool.Description) ? "(no description)" : tool.Description.Trim(); + sb.AppendLine($" - {tool.QualifiedName}: {desc}"); + } + } + + return sb.ToString(); + } + + /// + public async Task ExecuteTaskAsync( + string requesterName, + string task, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(requesterName); + ArgumentException.ThrowIfNullOrWhiteSpace(task); + + if (!IsInitialized) + await InitializeAsync(ct); + + // 1. Plan: ask the model which tool(s) to call. + var plannedCalls = _tools.Count == 0 + ? [] + : await PlanToolCallsAsync(task, ct); + + // 2. Execute the planned tool calls. + var executed = new List(); + foreach (var plan in plannedCalls) + { + if (!_mcpClients.TryGetValue(plan.ServerName, out var client)) + { + executed.Add(new OperatorToolCall(plan.ServerName, plan.ToolName, plan.Arguments, + $"[Unknown MCP server '{plan.ServerName}']", true)); + continue; + } + + var toolResult = await client.CallToolAsync(plan.ToolName, plan.Arguments, ct); + executed.Add(new OperatorToolCall(plan.ServerName, plan.ToolName, plan.Arguments, + toolResult.Text, toolResult.IsError)); + } + + // 3. Interpret the results (or answer directly if no tools were used). + var answer = await InterpretAsync(requesterName, task, executed, ct); + + // 4. Optionally compress the answer using the debate's compression strategy. + var compressed = false; + if (_compressor is not null && !string.IsNullOrWhiteSpace(answer)) + try + { + var result = await _compressor.CompressAsync(answer, _compressionOptions, ct); + if (!string.IsNullOrWhiteSpace(result.Text)) + { + answer = result.Text; + compressed = true; + } + } + catch + { + // Compression is best-effort; keep the uncompressed answer on failure. + } + + var operatorResult = new OperatorResult(requesterName, task, answer, executed.AsReadOnly(), compressed); + _interactions.Add(operatorResult.ToInteraction()); + return operatorResult; + } + + // ────────────────────────────────────────────── + // Micro-agent internals + // ────────────────────────────────────────────── + + private async Task> PlanToolCallsAsync(string task, CancellationToken ct) + { + const string SystemPrompt = """ + You are the Operator's planner — a tool-routing micro-agent. + Given a task and a list of available MCP tools, decide which tools to call. + Respond with STRICT JSON only, no prose, in exactly this shape: + {"tool_calls":[{"server":"","tool":"","arguments":{ ... }}]} + Rules: + - Use only tools from the provided list (match server and tool names exactly). + - Provide arguments that satisfy each tool's input schema. + - If no tool is appropriate, return {"tool_calls":[]}. + - Do not wrap the JSON in markdown fences. + """; + + var toolsText = new StringBuilder(); + foreach (var tool in _tools) + toolsText.AppendLine($"- server=\"{tool.ServerName}\" tool=\"{tool.Name}\" description=\"{tool.Description}\" input_schema={tool.InputSchemaJson}"); + + var userPrompt = $""" + ### Available tools: + {toolsText} + + ### Task: + {task} + + Return the JSON plan now. + """; + + string raw; + try + { + raw = await _model.AskAsync(SystemPrompt, userPrompt, 0.1f, ct); + } + catch + { + return []; + } + + return ParsePlan(raw); + } + + private static IReadOnlyList ParsePlan(string raw) + { + var json = ExtractJson(raw); + if (string.IsNullOrWhiteSpace(json)) return []; + + try + { + using var doc = JsonDocument.Parse(json); + if (!doc.RootElement.TryGetProperty("tool_calls", out var callsEl) || + callsEl.ValueKind != JsonValueKind.Array) + return []; + + var calls = new List(); + foreach (var callEl in callsEl.EnumerateArray()) + { + var server = callEl.TryGetProperty("server", out var s) ? s.GetString() : null; + var tool = callEl.TryGetProperty("tool", out var t) ? t.GetString() : null; + if (string.IsNullOrWhiteSpace(server) || string.IsNullOrWhiteSpace(tool)) + continue; + + var args = new Dictionary(); + if (callEl.TryGetProperty("arguments", out var argsEl) && argsEl.ValueKind == JsonValueKind.Object) + foreach (var prop in argsEl.EnumerateObject()) + args[prop.Name] = JsonElementToObject(prop.Value); + + // ResultText is filled in after execution; placeholder here. + calls.Add(new OperatorToolCall(server, tool, args, string.Empty, false)); + } + + return calls; + } + catch + { + return []; + } + } + + private async Task InterpretAsync( + string requesterName, + string task, + IReadOnlyList toolCalls, + CancellationToken ct) + { + string systemPrompt; + string userPrompt; + + if (toolCalls.Count == 0) + { + systemPrompt = """ + You are the Operator — an assistant micro-agent serving an AI council debate. + No MCP tool was applicable to this task. Answer the requester as best you can + from general knowledge, and clearly state that the answer is not tool-sourced. + Be concise and factual. + """; + userPrompt = $""" + Requester: {requesterName} + Task: {task} + + Provide a concise answer. + """; + } + else + { + var resultsText = new StringBuilder(); + for (var i = 0; i < toolCalls.Count; i++) + { + var c = toolCalls[i]; + resultsText.AppendLine($"[Tool {i + 1}] {c.ServerName}.{c.ToolName}{(c.IsError ? " (ERROR)" : "")}"); + resultsText.AppendLine(c.ResultText); + resultsText.AppendLine(); + } + + systemPrompt = """ + You are the Operator — an assistant micro-agent serving an AI council debate. + You delegated the requester's task to MCP tools and received their raw output below. + Interpret and synthesise the results into a clear, concise answer for the requester. + Cite which tool(s) produced the information. Do not invent facts beyond the tool output. + If a tool errored or returned nothing useful, say so plainly. + """; + userPrompt = $""" + Requester: {requesterName} + Task: {task} + + ### Raw tool output: + {resultsText} + + Synthesise the final answer for the requester now. + """; + } + + try + { + return await _model.AskAsync(systemPrompt, userPrompt, 0.3f, ct); + } + catch (Exception ex) + { + return $"[Operator failed to interpret results: {ex.Message}]"; + } + } + + // ────────────────────────────────────────────── + // JSON helpers + // ────────────────────────────────────────────── + + /// Extracts the first JSON object from a model response, tolerating markdown fences/prose. + private static string ExtractJson(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) return string.Empty; + + var start = raw.IndexOf('{'); + var end = raw.LastIndexOf('}'); + return start >= 0 && end > start ? raw[start..(end + 1)] : string.Empty; + } + + private static object? JsonElementToObject(JsonElement el) => el.ValueKind switch + { + JsonValueKind.String => el.GetString(), + JsonValueKind.Number => el.TryGetInt64(out var l) ? l : el.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + JsonValueKind.Array => el.EnumerateArray().Select(JsonElementToObject).ToList(), + JsonValueKind.Object => el.EnumerateObject().ToDictionary(p => p.Name, p => JsonElementToObject(p.Value)), + _ => el.GetRawText() + }; + + /// + public async ValueTask DisposeAsync() + { + foreach (var client in _mcpClients.Values) + try + { + await client.DisposeAsync(); + } + catch + { + // ignore disposal errors + } + } +} diff --git a/src/Delibera.Core/Debate/ConsensusDebate.cs b/src/Delibera.Core/Debate/ConsensusDebate.cs index de994fa..65e8069 100644 --- a/src/Delibera.Core/Debate/ConsensusDebate.cs +++ b/src/Delibera.Core/Debate/ConsensusDebate.cs @@ -26,6 +26,7 @@ public override async Task ExecuteAsync( PromptContext context, CouncilMember? chairman, KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, int maxRounds = 4, float temperature = 0.7f, Action? onRoundCompleted = null, @@ -33,9 +34,13 @@ public override async Task ExecuteAsync( { ArgumentNullException.ThrowIfNull(members); ArgumentNullException.ThrowIfNull(context); - var builder = new DebateResultBuilder(this, members, context, chairman, knowledgeKeeper); + var builder = new DebateResultBuilder(this, members, context, chairman, knowledgeKeeper, @operator); var fullUserPrompt = context.GetFullUserPrompt(); + // Operator briefing appended to participant system prompts. + var operatorBriefing = BuildOperatorBriefing(@operator); + var baseSystemPrompt = context.SystemPrompt + operatorBriefing; + if (chairman is not null) builder.SetOpeningStatement(await Chairman.OpenDebateAsync(chairman, context, members, StrategyName, maxRounds, temperature, ct)); @@ -54,8 +59,9 @@ public override async Task ExecuteAsync( : $"{fullUserPrompt}\n\n📚 Knowledge:\n{knowledgeCtx}"; // Round 1: Initial Perspectives - var r1 = await CollectResponsesAsync(members, context.SystemPrompt, enrichedPrompt, temperature, ct); - var round1 = CreateRound(1, "Initial Perspectives", "Each model shares their perspective.", r1, knowledgeInteractions: r1Ki); + var r1 = await CollectResponsesAsync(members, baseSystemPrompt, enrichedPrompt, temperature, ct); + var r1Op = await ProcessOperatorRequestsAsync(@operator, r1, ct); + var round1 = CreateRound(1, "Initial Perspectives", "Each model shares their perspective.", r1, knowledgeInteractions: r1Ki, operatorInteractions: r1Op); builder.AddRound(round1); onRoundCompleted?.Invoke(round1); if (maxRounds < 2) return BuildAndComplete(builder); @@ -70,8 +76,9 @@ public override async Task ExecuteAsync( // Round 2: Finding Common Ground var r1Text = FormatRoundResponses(round1); + var r1OpText = FormatOperatorInteractions(r1Op); var r2Sys = $""" - {context.SystemPrompt} + {baseSystemPrompt} Work collaboratively. Find COMMON GROUND. Identify agreements, disagreements, and propose bridges. Be open to changing your position. """; @@ -79,10 +86,12 @@ and propose bridges. Be open to changing your position. Original question: {fullUserPrompt} All perspectives: {r1Text} + {(string.IsNullOrWhiteSpace(r1OpText) ? "" : $"\n{r1OpText}")} Identify: 1) Points of Agreement 2) Points of Disagreement 3) Bridge Proposals 4) Your Updated Position """; var r2 = await CollectResponsesAsync(members, r2Sys, r2Prompt, temperature, ct); - var round2 = CreateRound(2, "Finding Common Ground", "Models identify agreements and disagreements.", r2, knowledgeInteractions: r2Ki); + var r2Op = await ProcessOperatorRequestsAsync(@operator, r2, ct); + var round2 = CreateRound(2, "Finding Common Ground", "Models identify agreements and disagreements.", r2, knowledgeInteractions: r2Ki, operatorInteractions: r2Op); builder.AddRound(round2); onRoundCompleted?.Invoke(round2); if (maxRounds < 3) return BuildAndComplete(builder); @@ -97,8 +106,9 @@ and propose bridges. Be open to changing your position. // Round 3: Consensus Building var r2Text = FormatRoundResponses(round2); + var r2OpText = FormatOperatorInteractions(r2Op); var r3Sys = $""" - {context.SystemPrompt} + {baseSystemPrompt} This is the CONSENSUS round. Formulate a single, unified answer. If full consensus is impossible, state what can be agreed upon. """; @@ -106,10 +116,12 @@ and propose bridges. Be open to changing your position. Original question: {fullUserPrompt} Perspectives: {r1Text} Common ground: {r2Text} + {(string.IsNullOrWhiteSpace(r1OpText) && string.IsNullOrWhiteSpace(r2OpText) ? "" : $"\n{r1OpText}\n{r2OpText}")} Formulate: 1) Agreed points 2) Proposed unified answer 3) Remaining disagreements 4) Confidence (Low/Medium/High) """; var r3 = await CollectResponsesAsync(members, r3Sys, r3Prompt, temperature, ct); - var round3 = CreateRound(3, "Consensus Building", "Models attempt a unified answer.", r3, knowledgeInteractions: r3Ki); + var r3Op = await ProcessOperatorRequestsAsync(@operator, r3, ct); + var round3 = CreateRound(3, "Consensus Building", "Models attempt a unified answer.", r3, knowledgeInteractions: r3Ki, operatorInteractions: r3Op); builder.AddRound(round3); onRoundCompleted?.Invoke(round3); diff --git a/src/Delibera.Core/Debate/CritiqueDebate.cs b/src/Delibera.Core/Debate/CritiqueDebate.cs index 1fb3ed0..3f39690 100644 --- a/src/Delibera.Core/Debate/CritiqueDebate.cs +++ b/src/Delibera.Core/Debate/CritiqueDebate.cs @@ -26,6 +26,7 @@ public override async Task ExecuteAsync( PromptContext context, CouncilMember? chairman, KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, int maxRounds = 4, float temperature = 0.7f, Action? onRoundCompleted = null, @@ -33,9 +34,13 @@ public override async Task ExecuteAsync( { ArgumentNullException.ThrowIfNull(members); ArgumentNullException.ThrowIfNull(context); - var builder = new DebateResultBuilder(this, members, context, chairman, knowledgeKeeper); + var builder = new DebateResultBuilder(this, members, context, chairman, knowledgeKeeper, @operator); var fullUserPrompt = context.GetFullUserPrompt(); + // Operator briefing appended to participant system prompts. + var operatorBriefing = BuildOperatorBriefing(@operator); + var baseSystemPrompt = context.SystemPrompt + operatorBriefing; + if (chairman is not null) builder.SetOpeningStatement(await Chairman.OpenDebateAsync(chairman, context, members, StrategyName, maxRounds, temperature, ct)); @@ -54,8 +59,9 @@ public override async Task ExecuteAsync( : $"{fullUserPrompt}\n\n📚 Knowledge:\n{knowledgeCtx}"; // Round 1: Initial Positions - var r1 = await CollectResponsesAsync(members, context.SystemPrompt, enrichedPrompt, temperature, ct); - var round1 = CreateRound(1, "Initial Positions", "Each model states their initial position.", r1, knowledgeInteractions: r1Ki); + var r1 = await CollectResponsesAsync(members, baseSystemPrompt, enrichedPrompt, temperature, ct); + var r1Op = await ProcessOperatorRequestsAsync(@operator, r1, ct); + var round1 = CreateRound(1, "Initial Positions", "Each model states their initial position.", r1, knowledgeInteractions: r1Ki, operatorInteractions: r1Op); builder.AddRound(round1); onRoundCompleted?.Invoke(round1); if (maxRounds < 2) return BuildAndComplete(builder); @@ -70,8 +76,9 @@ public override async Task ExecuteAsync( // Round 2: Directed Critique var r1Text = FormatRoundResponses(round1); + var r1OpText = FormatOperatorInteractions(r1Op); var r2Sys = $""" - {context.SystemPrompt} + {baseSystemPrompt} You are a sharp analytical critic. Find the WEAKEST points in other participants' arguments. Challenge assumptions, find logical flaws, identify missing evidence, propose counter-examples. """; @@ -79,10 +86,12 @@ You are a sharp analytical critic. Find the WEAKEST points in other participants Original question: {fullUserPrompt} Responses to critique: {r1Text} + {(string.IsNullOrWhiteSpace(r1OpText) ? "" : $"\n{r1OpText}")} For each response: 1) Weakest argument 2) Logical fallacies 3) Counter-example 4) Quality (1-10) """; var r2 = await CollectResponsesAsync(members, r2Sys, r2Prompt, temperature, ct); - var round2 = CreateRound(2, "Directed Critique", "Models attack weaknesses in each other's positions.", r2, knowledgeInteractions: r2Ki); + var r2Op = await ProcessOperatorRequestsAsync(@operator, r2, ct); + var round2 = CreateRound(2, "Directed Critique", "Models attack weaknesses in each other's positions.", r2, knowledgeInteractions: r2Ki, operatorInteractions: r2Op); builder.AddRound(round2); onRoundCompleted?.Invoke(round2); if (maxRounds < 3) return BuildAndComplete(builder); @@ -97,8 +106,9 @@ You are a sharp analytical critic. Find the WEAKEST points in other participants // Round 3: Defence var r2Text = FormatRoundResponses(round2); + var r2OpText = FormatOperatorInteractions(r2Op); var r3Sys = $""" - {context.SystemPrompt} + {baseSystemPrompt} Defend your position. Address every critique, strengthen weak arguments, provide additional evidence, and concede where critics are right. """; @@ -106,10 +116,12 @@ You are a sharp analytical critic. Find the WEAKEST points in other participants Original question: {fullUserPrompt} Initial responses: {r1Text} Critiques: {r2Text} + {(string.IsNullOrWhiteSpace(r1OpText) && string.IsNullOrWhiteSpace(r2OpText) ? "" : $"\n{r1OpText}\n{r2OpText}")} Defend your position: 1) Address each criticism 2) Strengthen weak points 3) Concede where right 4) Final answer """; var r3 = await CollectResponsesAsync(members, r3Sys, r3Prompt, temperature, ct); - var round3 = CreateRound(3, "Defence & Counter-Arguments", "Models defend their positions.", r3, knowledgeInteractions: r3Ki); + var r3Op = await ProcessOperatorRequestsAsync(@operator, r3, ct); + var round3 = CreateRound(3, "Defence & Counter-Arguments", "Models defend their positions.", r3, knowledgeInteractions: r3Ki, operatorInteractions: r3Op); builder.AddRound(round3); onRoundCompleted?.Invoke(round3); diff --git a/src/Delibera.Core/Debate/DebateResultBuilder.cs b/src/Delibera.Core/Debate/DebateResultBuilder.cs index 1b81201..cf518ec 100644 --- a/src/Delibera.Core/Debate/DebateResultBuilder.cs +++ b/src/Delibera.Core/Debate/DebateResultBuilder.cs @@ -12,7 +12,8 @@ internal sealed class DebateResultBuilder( IReadOnlyList members, PromptContext context, CouncilMember? chairman, - KnowledgeKeeper? knowledgeKeeper) + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator = null) { private readonly List _rounds = []; private string? _openingStatement; @@ -37,6 +38,7 @@ internal sealed class DebateResultBuilder( Participants = members.Select(m => m.DisplayName).ToList(), ChairmanName = chairman?.DisplayName, KnowledgeKeeperName = knowledgeKeeper?.DisplayName, + OperatorName = @operator?.DisplayName, OpeningStatement = _openingStatement, Rounds = _rounds, FinalVerdict = _finalVerdict, diff --git a/src/Delibera.Core/Debate/DebateScenario.cs b/src/Delibera.Core/Debate/DebateScenario.cs index 286d22b..c0f7fa5 100644 --- a/src/Delibera.Core/Debate/DebateScenario.cs +++ b/src/Delibera.Core/Debate/DebateScenario.cs @@ -21,6 +21,7 @@ public abstract Task ExecuteAsync( PromptContext context, CouncilMember? chairman, KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, int maxRounds = 4, float temperature = 0.7f, Action? onRoundCompleted = null, @@ -81,7 +82,8 @@ protected static DebateRound CreateRound( string? description, Dictionary responses, string? prompt = null, - IReadOnlyList? knowledgeInteractions = null) => + IReadOnlyList? knowledgeInteractions = null, + IReadOnlyList? operatorInteractions = null) => new() { RoundNumber = number, @@ -90,6 +92,7 @@ protected static DebateRound CreateRound( Responses = responses, RoundPrompt = prompt, KnowledgeInteractions = knowledgeInteractions ?? [], + OperatorInteractions = operatorInteractions ?? [], CompletedAt = DateTime.UtcNow }; @@ -155,4 +158,105 @@ protected static DebateRound CreateRound( return (string.Empty, null); } } + + // ────────────────────────────────────────────── + // Operator helpers + // ────────────────────────────────────────────── + + /// + /// Marker participants use to delegate a task to the Operator, e.g.: + /// [[OPERATOR: search the web for the latest .NET 10 release notes]]. + /// + private static readonly System.Text.RegularExpressions.Regex OperatorRequestRegex = + new(@"\[\[\s*OPERATOR\s*:\s*(?.+?)\]\]", + System.Text.RegularExpressions.RegexOptions.Singleline | + System.Text.RegularExpressions.RegexOptions.IgnoreCase | + System.Text.RegularExpressions.RegexOptions.Compiled); + + /// + /// Builds an "Operator briefing" describing the Operator's tools and how to delegate + /// tasks to it. Appended to the participants' system prompt so they know what is available. + /// Returns an empty string when no Operator is configured. + /// + protected static string BuildOperatorBriefing(Operator? @operator) + { + if (@operator is null) return string.Empty; + + return $""" + + ── OPERATOR (tools available) ── + A shared Operator agent is available to all participants. + {@operator.GetToolCatalog()} + + To delegate a task to the Operator, include a line in your response using this exact marker: + [[OPERATOR: ]] + For example: [[OPERATOR: search the web for recent benchmarks comparing PostgreSQL and MySQL]] + The Operator's answer will be provided to all participants in the next round. + Only delegate when external information or actions (web search, database lookup, writing to Notion, etc.) are genuinely needed. + """; + } + + /// + /// Scans participant responses for Operator request markers, executes each delegated + /// task via the Operator, and returns the recorded interactions. + /// + /// Operator instance (may be null). + /// Participant responses keyed by display name. + /// Cancellation token. + /// Operator interactions produced during this round. + protected static async Task> ProcessOperatorRequestsAsync( + Operator? @operator, + IReadOnlyDictionary responses, + CancellationToken ct = default) + { + if (@operator is null || responses.Count == 0) return []; + + var interactions = new List(); + foreach (var (member, response) in responses) + { + if (string.IsNullOrWhiteSpace(response)) continue; + + foreach (System.Text.RegularExpressions.Match match in OperatorRequestRegex.Matches(response)) + { + var task = match.Groups["task"].Value.Trim(); + if (string.IsNullOrWhiteSpace(task)) continue; + + try + { + var result = await @operator.ExecuteTaskAsync(member, task, ct); + interactions.Add(result.ToInteraction()); + } + catch (Exception ex) + { + interactions.Add(new OperatorInteraction( + member, task, $"[Operator error: {ex.Message}]", [], false, DateTime.UtcNow)); + } + } + } + + return interactions; + } + + /// + /// Formats Operator interactions into a context block that can be injected into the + /// next round's prompt so participants can use the Operator's findings. + /// + protected static string FormatOperatorInteractions(IReadOnlyList interactions) + { + if (interactions is not { Count: > 0 }) return string.Empty; + + var sb = new StringBuilder(); + sb.AppendLine("🛠️ Operator results (requested by participants):"); + foreach (var i in interactions) + { + var tools = i.ToolCalls.Count > 0 + ? string.Join(", ", i.ToolCalls.Select(c => $"{c.ServerName}.{c.ToolName}")) + : "no tools"; + sb.AppendLine($"\n• {i.RequesterName} asked: {i.Task}"); + sb.AppendLine($" Tools used: {tools}"); + sb.AppendLine($" Answer: {i.Answer}"); + } + + return sb.ToString(); + } } diff --git a/src/Delibera.Core/Debate/StandardDebate.cs b/src/Delibera.Core/Debate/StandardDebate.cs index 1694070..83e9289 100644 --- a/src/Delibera.Core/Debate/StandardDebate.cs +++ b/src/Delibera.Core/Debate/StandardDebate.cs @@ -30,6 +30,7 @@ public override async Task ExecuteAsync( PromptContext context, CouncilMember? chairman, KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, int maxRounds = 4, float temperature = 0.7f, Action? onRoundCompleted = null, @@ -37,9 +38,13 @@ public override async Task ExecuteAsync( { ArgumentNullException.ThrowIfNull(members); ArgumentNullException.ThrowIfNull(context); - var builder = new DebateResultBuilder(this, members, context, chairman, knowledgeKeeper); + var builder = new DebateResultBuilder(this, members, context, chairman, knowledgeKeeper, @operator); var fullUserPrompt = context.GetFullUserPrompt(); + // Operator briefing is appended to participant system prompts so they know what tools exist. + var operatorBriefing = BuildOperatorBriefing(@operator); + var baseSystemPrompt = context.SystemPrompt + operatorBriefing; + // ── Chairman opening ── if (chairman is not null) builder.SetOpeningStatement(await Chairman.OpenDebateAsync( @@ -60,9 +65,11 @@ public override async Task ExecuteAsync( ? fullUserPrompt : $"{fullUserPrompt}\n\n📚 Knowledge Keeper context:\n{knowledgeContext}"; - var r1Responses = await CollectResponsesAsync(members, context.SystemPrompt, r1Prompt, temperature, ct); + var r1Responses = await CollectResponsesAsync(members, baseSystemPrompt, r1Prompt, temperature, ct); + // Operator: fulfil any [[OPERATOR: ...]] requests raised in round 1. + var r1Op = await ProcessOperatorRequestsAsync(@operator, r1Responses, ct); var round1 = CreateRound(1, "Initial Responses", - "All models provide their initial answers.", r1Responses, r1Prompt, r1Ki); + "All models provide their initial answers.", r1Responses, r1Prompt, r1Ki, r1Op); builder.AddRound(round1); onRoundCompleted?.Invoke(round1); @@ -79,8 +86,9 @@ public override async Task ExecuteAsync( // ══════════ Round 2: Critique ══════════ var r1Text = FormatRoundResponses(round1); + var r1OpText = FormatOperatorInteractions(r1Op); var r2System = $""" - {context.SystemPrompt} + {baseSystemPrompt} You are now in critique mode. Critically analyse the responses from other council members. Identify strengths, weaknesses, factual errors, logical gaps, and areas for improvement. @@ -91,13 +99,14 @@ Be constructive but thorough. Initial responses: {r1Text} - + {(string.IsNullOrWhiteSpace(r1OpText) ? "" : $"\n{r1OpText}")} Provide your detailed critique of each response. """; var r2Responses = await CollectResponsesAsync(members, r2System, r2Prompt, temperature, ct); + var r2Op = await ProcessOperatorRequestsAsync(@operator, r2Responses, ct); var round2 = CreateRound(2, "Critique", - "Models critically analyse each other's responses.", r2Responses, r2Prompt, r2Ki); + "Models critically analyse each other's responses.", r2Responses, r2Prompt, r2Ki, r2Op); builder.AddRound(round2); onRoundCompleted?.Invoke(round2); @@ -114,8 +123,9 @@ Provide your detailed critique of each response. // ══════════ Round 3: Improved Responses ══════════ var r2Text = FormatRoundResponses(round2); + var r2OpText = FormatOperatorInteractions(r2Op); var r3System = $""" - {context.SystemPrompt} + {baseSystemPrompt} You have received critiques. Provide your FINAL, IMPROVED answer. Take the best ideas from all participants, address the critiques, @@ -129,13 +139,14 @@ and synthesise the most comprehensive response possible. Critiques: {r2Text} - + {(string.IsNullOrWhiteSpace(r1OpText) && string.IsNullOrWhiteSpace(r2OpText) ? "" : $"\n{r1OpText}\n{r2OpText}")} Provide your final, improved answer. """; var r3Responses = await CollectResponsesAsync(members, r3System, r3Prompt, temperature, ct); + var r3Op = await ProcessOperatorRequestsAsync(@operator, r3Responses, ct); var round3 = CreateRound(3, "Final Improved Responses", - "Models provide refined answers incorporating critiques.", r3Responses, r3Prompt, r3Ki); + "Models provide refined answers incorporating critiques.", r3Responses, r3Prompt, r3Ki, r3Op); builder.AddRound(round3); onRoundCompleted?.Invoke(round3); diff --git a/src/Delibera.Core/Delibera.Core.csproj b/src/Delibera.Core/Delibera.Core.csproj index a216279..998588b 100644 --- a/src/Delibera.Core/Delibera.Core.csproj +++ b/src/Delibera.Core/Delibera.Core.csproj @@ -4,7 +4,7 @@ net10.0 enable enable - 14.0 + preview true true true @@ -44,11 +44,12 @@ + - + \ No newline at end of file diff --git a/src/Delibera.Core/DependencyInjection/CouncilOptions.cs b/src/Delibera.Core/DependencyInjection/CouncilOptions.cs index 202144f..d24f156 100644 --- a/src/Delibera.Core/DependencyInjection/CouncilOptions.cs +++ b/src/Delibera.Core/DependencyInjection/CouncilOptions.cs @@ -30,6 +30,9 @@ public sealed class CouncilOptions /// RAG configuration options. public RagOptions Rag { get; set; } = new(); + /// Operator (MCP tool micro-agent) configuration options. + public OperatorConfig Operator { get; set; } = new(); + /// Output configuration options. public OutputOptions Output { get; set; } = new(); } @@ -101,6 +104,81 @@ public sealed class RagOptions public string? ConnectionString { get; set; } } +/// +/// Configuration options for the Operator (MCP tool micro-agent). +/// +/// +/// Bind this from configuration (e.g., Delibera:Operator) and use +/// to materialise instances +/// for ICouncilBuilder.WithOperator(...). +/// +public sealed class OperatorConfig +{ + /// Whether the Operator is enabled. + public bool Enabled { get; set; } + + /// Model name used by the Operator (typically a cheaper model than the participants). + public string ModelName { get; set; } = string.Empty; + + /// Whether the Operator reuses the council's compressor for large tool results. + public bool ReuseCompression { get; set; } = true; + + /// MCP servers the Operator connects to. + public List McpServers { get; set; } = []; + + /// Materialises the configured MCP servers into instances. + public IReadOnlyList ToServerConfigs() => + McpServers.Select(s => s.ToConfig()).ToList(); +} + +/// +/// Configuration for a single MCP server bound from configuration. +/// +public sealed class McpServerOptions +{ + /// Logical server name surfaced to participants (e.g., "web", "notion", "postgres"). + public string Name { get; set; } = string.Empty; + + /// Transport type: "Stdio" (default) or "Http". + public string Transport { get; set; } = "Stdio"; + + // ── Stdio ── + + /// Executable command for stdio transport (e.g., "npx", "uvx", "dotnet"). + public string? Command { get; set; } + + /// Command-line arguments for stdio transport. + public List Arguments { get; set; } = []; + + /// Working directory for the spawned stdio process. + public string? WorkingDirectory { get; set; } + + /// Environment variables passed to the stdio process. + public Dictionary EnvironmentVariables { get; set; } = []; + + // ── Http ── + + /// Endpoint URL for HTTP/SSE transport. + public string? Endpoint { get; set; } + + /// Additional HTTP headers (e.g., authorization) for HTTP transport. + public Dictionary AdditionalHeaders { get; set; } = []; + + /// Converts these options into a . + public Models.McpServerConfig ToConfig() => + string.Equals(Transport, "Http", StringComparison.OrdinalIgnoreCase) + ? Models.McpServerConfig.Http( + Name, + new Uri(Endpoint ?? throw new InvalidOperationException($"MCP server '{Name}' uses Http transport but has no Endpoint.")), + AdditionalHeaders) + : Models.McpServerConfig.Stdio( + Name, + Command ?? throw new InvalidOperationException($"MCP server '{Name}' uses Stdio transport but has no Command."), + Arguments, + EnvironmentVariables, + WorkingDirectory); +} + /// /// Configuration options for debate output files. /// diff --git a/src/Delibera.Core/Interfaces/ICouncilBuilder.cs b/src/Delibera.Core/Interfaces/ICouncilBuilder.cs index a5c9fd4..6f8cf80 100644 --- a/src/Delibera.Core/Interfaces/ICouncilBuilder.cs +++ b/src/Delibera.Core/Interfaces/ICouncilBuilder.cs @@ -43,6 +43,30 @@ public interface ICouncilBuilder /// This builder for fluent chaining. ICouncilBuilder WithKnowledgeKeeper(KnowledgeKeeper knowledgeKeeper); + /// Attaches a pre-configured Operator (MCP tool micro-agent). + /// Configured Operator instance. + /// This builder for fluent chaining. + ICouncilBuilder WithOperator(Operator @operator); + + /// + /// Creates and attaches an Operator from a (cheaper) model and one or more MCP server + /// configurations. The Operator connects to the servers, discovers their tools, and lets + /// participants delegate natural-language tasks to it during the debate. + /// + /// Model name used by the Operator (typically a cheaper model). + /// LLM provider for the Operator model. + /// MCP server configurations the Operator connects to. + /// + /// When true (default), the Operator reuses the council's configured compressor + /// (if any) to compress large tool results before returning them. + /// + /// This builder for fluent chaining. + ICouncilBuilder WithOperator( + string modelName, + ILLMProvider provider, + IEnumerable servers, + bool reuseCompression = true); + /// Sets the debate strategy. /// Strategy implementation. /// This builder for fluent chaining. diff --git a/src/Delibera.Core/Interfaces/ICouncilExecutor.cs b/src/Delibera.Core/Interfaces/ICouncilExecutor.cs index 0d0e29a..74a1362 100644 --- a/src/Delibera.Core/Interfaces/ICouncilExecutor.cs +++ b/src/Delibera.Core/Interfaces/ICouncilExecutor.cs @@ -17,6 +17,9 @@ public interface ICouncilExecutor /// Knowledge Keeper (may be null). KnowledgeKeeper? KnowledgeKeeper { get; } + /// Operator (may be null). + Operator? Operator { get; } + /// Debate strategy. IDebateStrategy Strategy { get; } diff --git a/src/Delibera.Core/Interfaces/IDebateStrategy.cs b/src/Delibera.Core/Interfaces/IDebateStrategy.cs index f79c518..9116976 100644 --- a/src/Delibera.Core/Interfaces/IDebateStrategy.cs +++ b/src/Delibera.Core/Interfaces/IDebateStrategy.cs @@ -21,6 +21,7 @@ public interface IDebateStrategy /// Prompt context (system / user prompt, knowledge). /// Chairman for moderation and verdict synthesis (may be null). /// Knowledge Keeper for RAG queries (may be null). + /// Operator micro-agent for tool/MCP delegation (may be null). /// Maximum number of rounds. /// Generation temperature. /// Callback invoked after each round. @@ -31,6 +32,7 @@ Task ExecuteAsync( PromptContext context, CouncilMember? chairman, KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, int maxRounds = 4, float temperature = 0.7f, Action? onRoundCompleted = null, diff --git a/src/Delibera.Core/Interfaces/IMcpClient.cs b/src/Delibera.Core/Interfaces/IMcpClient.cs new file mode 100644 index 0000000..bd4649b --- /dev/null +++ b/src/Delibera.Core/Interfaces/IMcpClient.cs @@ -0,0 +1,41 @@ +namespace Delibera.Core.Interfaces; + +/// +/// Thin abstraction over a connection to a single MCP (Model Context Protocol) server. +/// Allows the micro-agent to discover and invoke tools +/// without coupling the rest of the framework to a specific MCP SDK, and keeps the +/// Operator unit-testable via fakes. +/// +public interface IMcpClient : IAsyncDisposable +{ + /// Logical name of the server (e.g., "web", "notion", "postgres"). + string ServerName { get; } + + /// Whether the client has been connected via . + bool IsConnected { get; } + + /// + /// Establishes the connection to the MCP server and performs the initial handshake. + /// + /// Cancellation token. + Task ConnectAsync(CancellationToken ct = default); + + /// + /// Lists the tools exposed by the connected MCP server. + /// + /// Cancellation token. + /// Discovered tools, tagged with this client's . + Task> ListToolsAsync(CancellationToken ct = default); + + /// + /// Invokes a tool on the MCP server. + /// + /// Name of the tool to call. + /// Arguments passed to the tool. + /// Cancellation token. + /// The tool's textual result and error flag. + Task CallToolAsync( + string toolName, + IReadOnlyDictionary arguments, + CancellationToken ct = default); +} diff --git a/src/Delibera.Core/Interfaces/IOperator.cs b/src/Delibera.Core/Interfaces/IOperator.cs new file mode 100644 index 0000000..5340091 --- /dev/null +++ b/src/Delibera.Core/Interfaces/IOperator.cs @@ -0,0 +1,51 @@ +namespace Delibera.Core.Interfaces; + +/// +/// The Operator — a special council role implemented as a self-contained micro-agent. +/// During a debate, participants can delegate tasks to the Operator in natural language +/// (e.g., "search the web for …", "save the debate summary to Notion", "query the +/// PostgreSQL employee table for …"). The Operator interprets the task with its own +/// (typically cheaper) LLM model, selects and invokes the appropriate MCP tools, +/// interprets the raw tool output, optionally compresses it, and returns a concise +/// answer to the requesting participant. +/// +public interface IOperator : IAsyncDisposable +{ + /// Display name shown in debate logs (e.g., "🛠️ Operator (llama3.2)"). + string DisplayName { get; } + + /// Whether has completed successfully. + bool IsInitialized { get; } + + /// All tools discovered across the configured MCP servers. + IReadOnlyList AvailableTools { get; } + + /// All interactions recorded during this session. + IReadOnlyList Interactions { get; } + + /// + /// Connects to every configured MCP server and discovers their tools. + /// Safe to call multiple times — subsequent calls are no-ops once initialised. + /// + /// Cancellation token. + Task InitializeAsync(CancellationToken ct = default); + + /// + /// Returns a human-readable catalogue of the Operator's capabilities, suitable for + /// injection into participant prompts so they understand what they can delegate. + /// + string GetToolCatalog(); + + /// + /// Executes a natural-language task on behalf of a participant: selects MCP tools, + /// invokes them, interprets the results, optionally compresses, and returns an answer. + /// + /// Display name of the requesting participant. + /// The natural-language task to fulfil. + /// Cancellation token. + /// The Operator's result, including the answer and the tool calls performed. + Task ExecuteTaskAsync( + string requesterName, + string task, + CancellationToken ct = default); +} diff --git a/src/Delibera.Core/Models/DebateResult.cs b/src/Delibera.Core/Models/DebateResult.cs index 4e76932..927aefd 100644 --- a/src/Delibera.Core/Models/DebateResult.cs +++ b/src/Delibera.Core/Models/DebateResult.cs @@ -25,6 +25,9 @@ public sealed record DebateResult /// Knowledge Keeper name (if assigned). public string? KnowledgeKeeperName { get; init; } + /// Operator name (if assigned). + public string? OperatorName { get; init; } + /// All debate rounds. public IReadOnlyList Rounds { get; init; } = []; @@ -97,6 +100,8 @@ public string ToMarkdown() sb.AppendLine($"**Chairman:** {ChairmanName}"); if (!string.IsNullOrEmpty(KnowledgeKeeperName)) sb.AppendLine($"**Knowledge Keeper:** {KnowledgeKeeperName}"); + if (!string.IsNullOrEmpty(OperatorName)) + sb.AppendLine($"**Operator:** {OperatorName}"); sb.AppendLine(); // Opening statement @@ -129,6 +134,23 @@ public string ToMarkdown() } } + // Operator interactions + if (round.OperatorInteractions is { Count: > 0 }) + { + sb.AppendLine("### 🛠️ Operator Interactions"); + sb.AppendLine(); + foreach (var oi in round.OperatorInteractions) + { + var tools = oi.ToolCalls.Count > 0 + ? string.Join(", ", oi.ToolCalls.Select(c => $"`{c.ServerName}.{c.ToolName}`")) + : "none"; + sb.AppendLine($"> **{oi.RequesterName}** requested: {oi.Task}"); + sb.AppendLine($"> **Tools used:** {tools}"); + sb.AppendLine($"> **Answer:** {oi.Answer}{(oi.Compressed ? " *(compressed)*" : "")}"); + sb.AppendLine(); + } + } + foreach (var (member, response) in round.Responses) { sb.AppendLine($"### {member}"); diff --git a/src/Delibera.Core/Models/DebateRound.cs b/src/Delibera.Core/Models/DebateRound.cs index 5a2495d..3a075c5 100644 --- a/src/Delibera.Core/Models/DebateRound.cs +++ b/src/Delibera.Core/Models/DebateRound.cs @@ -55,6 +55,9 @@ public sealed record DebateRound /// Knowledge Keeper queries & answers that occurred during this round. public IReadOnlyList KnowledgeInteractions { get; init; } = []; + /// Operator tasks delegated by participants that occurred during this round. + public IReadOnlyList OperatorInteractions { get; init; } = []; + /// Timestamp when the round started. public DateTime StartedAt { get; init; } = DateTime.UtcNow; diff --git a/src/Delibera.Core/Models/OperatorModels.cs b/src/Delibera.Core/Models/OperatorModels.cs new file mode 100644 index 0000000..fa163f8 --- /dev/null +++ b/src/Delibera.Core/Models/OperatorModels.cs @@ -0,0 +1,176 @@ +namespace Delibera.Core.Models; + +/// +/// Transport type used to connect to an MCP (Model Context Protocol) server. +/// +public enum McpTransportType +{ + /// Local server launched as a child process and communicating over standard I/O. + Stdio = 0, + + /// Remote server reached over HTTP / Server-Sent Events. + Http = 1 +} + +/// +/// Configuration describing how the connects to a single +/// MCP server. Supports both local (stdio) and remote (HTTP/SSE) servers. +/// +/// +/// Examples: +/// +/// +/// +/// Stdio: McpServerConfig.Stdio("web", "npx", ["-y", "@modelcontextprotocol/server-everything"]) +/// +/// +/// +/// +/// HTTP: McpServerConfig.Http("notion", new Uri("https://mcp.notion.com/sse")) +/// +/// +/// +/// +public sealed record McpServerConfig +{ + /// Logical server name surfaced to participants (e.g., "web", "notion", "postgres"). + public required string Name { get; init; } + + /// Transport used to reach this server. + public McpTransportType TransportType { get; init; } = McpTransportType.Stdio; + + // ── Stdio transport ── + + /// Executable command for stdio transport (e.g., "npx", "dotnet", "uvx"). + public string? Command { get; init; } + + /// Command-line arguments for stdio transport. + public IReadOnlyList Arguments { get; init; } = []; + + /// Working directory for the spawned stdio process. + public string? WorkingDirectory { get; init; } + + /// Environment variables passed to the stdio process (e.g., API keys). + public IReadOnlyDictionary EnvironmentVariables { get; init; } = + new Dictionary(); + + // ── HTTP transport ── + + /// Endpoint URI for HTTP/SSE transport. + public Uri? Endpoint { get; init; } + + /// Additional HTTP headers (e.g., authorization) for HTTP transport. + public IReadOnlyDictionary AdditionalHeaders { get; init; } = + new Dictionary(); + + /// Creates a stdio MCP server configuration. + public static McpServerConfig Stdio( + string name, + string command, + IReadOnlyList? arguments = null, + IReadOnlyDictionary? environmentVariables = null, + string? workingDirectory = null) => + new() + { + Name = name, + TransportType = McpTransportType.Stdio, + Command = command, + Arguments = arguments ?? [], + EnvironmentVariables = environmentVariables ?? new Dictionary(), + WorkingDirectory = workingDirectory + }; + + /// Creates an HTTP/SSE MCP server configuration. + public static McpServerConfig Http( + string name, + Uri endpoint, + IReadOnlyDictionary? additionalHeaders = null) => + new() + { + Name = name, + TransportType = McpTransportType.Http, + Endpoint = endpoint, + AdditionalHeaders = additionalHeaders ?? new Dictionary() + }; +} + +/// +/// Describes a single tool exposed by an MCP server, as discovered by the Operator. +/// +/// Logical name of the server that owns this tool. +/// Tool name (as defined by the MCP server). +/// Human-readable tool description. +/// JSON schema for the tool's input arguments (may be empty). +public sealed record OperatorTool( + string ServerName, + string Name, + string Description, + string InputSchemaJson) +{ + /// Fully-qualified tool reference used when selecting tools (e.g., "web.search"). + public string QualifiedName => $"{ServerName}.{Name}"; +} + +/// +/// A single tool invocation performed by the Operator while fulfilling a request. +/// +/// Server that owns the tool. +/// Name of the invoked tool. +/// Arguments passed to the tool. +/// Textual result returned by the tool. +/// Whether the tool reported an error. +public sealed record OperatorToolCall( + string ServerName, + string ToolName, + IReadOnlyDictionary Arguments, + string ResultText, + bool IsError); + +/// +/// Result of a single MCP tool call at the transport level. +/// +/// Concatenated text content blocks returned by the tool. +/// Whether the tool reported an error. +public sealed record McpToolResult(string Text, bool IsError); + +/// +/// Records one complete Operator interaction during a debate — a participant's task, +/// the tools used to fulfil it, and the Operator's synthesised answer. +/// +/// Display name of the participant who requested the Operator. +/// The natural-language task delegated to the Operator. +/// The Operator's synthesised answer returned to the requester. +/// All MCP tool calls performed while fulfilling the task. +/// Whether the answer was compressed before being returned. +/// UTC timestamp when the interaction completed. +public sealed record OperatorInteraction( + string RequesterName, + string Task, + string Answer, + IReadOnlyList ToolCalls, + bool Compressed, + DateTime Timestamp) +{ + /// Number of tools invoked for this interaction. + public int ToolCallCount => ToolCalls.Count; +} + +/// +/// The Operator's response to a delegated task. +/// +/// Display name of the requester. +/// The original task. +/// The synthesised answer. +/// Tool calls performed. +/// Whether the answer was compressed. +public sealed record OperatorResult( + string RequesterName, + string Task, + string Answer, + IReadOnlyList ToolCalls, + bool Compressed) +{ + /// Converts this result into a loggable . + public OperatorInteraction ToInteraction() => + new(RequesterName, Task, Answer, ToolCalls, Compressed, DateTime.UtcNow); +} diff --git a/src/Delibera.Core/Providers/Mcp/McpClientAdapter.cs b/src/Delibera.Core/Providers/Mcp/McpClientAdapter.cs new file mode 100644 index 0000000..a4484e1 --- /dev/null +++ b/src/Delibera.Core/Providers/Mcp/McpClientAdapter.cs @@ -0,0 +1,162 @@ +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +namespace Delibera.Core.Providers.Mcp; + +/// +/// Default implementation backed by the official +/// ModelContextProtocol C# SDK. Supports both stdio (local child process) and +/// HTTP/SSE (remote) MCP servers, selected via . +/// +public sealed class McpClientAdapter : IMcpClient +{ + private readonly McpServerConfig _config; + private McpClient? _client; + private bool _disposed; + + /// Creates an adapter for the given MCP server configuration. + /// Server connection configuration. + public McpClientAdapter(McpServerConfig config) + { + _config = config ?? throw new ArgumentNullException(nameof(config)); + ArgumentException.ThrowIfNullOrWhiteSpace(config.Name); + } + + /// + public string ServerName => _config.Name; + + /// + public bool IsConnected => _client is not null; + + /// + public async Task ConnectAsync(CancellationToken ct = default) + { + if (_client is not null) return; + + var transport = CreateTransport(_config); + _client = await McpClient.CreateAsync(transport, cancellationToken: ct); + } + + /// + public async Task> ListToolsAsync(CancellationToken ct = default) + { + EnsureConnected(); + var tools = await _client!.ListToolsAsync(cancellationToken: ct); + + return tools + .Select(t => new OperatorTool( + ServerName, + t.Name, + t.Description ?? string.Empty, + SchemaToJson(t))) + .ToList() + .AsReadOnly(); + } + + /// + public async Task CallToolAsync( + string toolName, + IReadOnlyDictionary arguments, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(toolName); + ArgumentNullException.ThrowIfNull(arguments); + EnsureConnected(); + + CallToolResult result; + try + { + result = await _client!.CallToolAsync(toolName, arguments, cancellationToken: ct); + } + catch (Exception ex) + { + return new McpToolResult($"[MCP tool '{toolName}' failed: {ex.Message}]", true); + } + + var text = string.Join( + "\n", + result.Content.OfType().Select(b => b.Text)); + + if (string.IsNullOrWhiteSpace(text)) + text = "[Tool returned no textual content]"; + + return new McpToolResult(text, result.IsError ?? false); + } + + /// + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + + if (_client is not null) + await _client.DisposeAsync(); + } + + private void EnsureConnected() + { + if (_client is null) + throw new InvalidOperationException( + $"MCP client for server '{ServerName}' is not connected. Call ConnectAsync() first."); + } + + private static string SchemaToJson(McpClientTool tool) + { + try + { + return tool.JsonSchema.GetRawText(); + } + catch + { + return "{}"; + } + } + + private static IClientTransport CreateTransport(McpServerConfig config) => + config.TransportType switch + { + McpTransportType.Stdio => CreateStdioTransport(config), + McpTransportType.Http => CreateHttpTransport(config), + _ => throw new NotSupportedException($"Unsupported MCP transport: {config.TransportType}") + }; + + private static StdioClientTransport CreateStdioTransport(McpServerConfig config) + { + if (string.IsNullOrWhiteSpace(config.Command)) + throw new InvalidOperationException( + $"MCP server '{config.Name}' uses stdio transport but no Command was provided."); + + var options = new StdioClientTransportOptions + { + Name = config.Name, + Command = config.Command, + Arguments = config.Arguments.ToList(), + WorkingDirectory = config.WorkingDirectory + }; + + if (config.EnvironmentVariables.Count > 0) + options.EnvironmentVariables = config.EnvironmentVariables + .ToDictionary(kv => kv.Key, kv => (string?)kv.Value); + + return new StdioClientTransport(options); + } + + private static HttpClientTransport CreateHttpTransport(McpServerConfig config) + { + if (config.Endpoint is null) + throw new InvalidOperationException( + $"MCP server '{config.Name}' uses HTTP transport but no Endpoint was provided."); + + var options = new HttpClientTransportOptions + { + Name = config.Name, + Endpoint = config.Endpoint + }; + + if (config.AdditionalHeaders.Count > 0) + options.AdditionalHeaders = config.AdditionalHeaders + .ToDictionary(kv => kv.Key, kv => kv.Value); + + return new HttpClientTransport(options); + } +} diff --git a/src/Delibera.Core/README.md b/src/Delibera.Core/README.md index b9b9d6b..eb4f5f2 100644 --- a/src/Delibera.Core/README.md +++ b/src/Delibera.Core/README.md @@ -4,7 +4,7 @@ ### ⚖️ Thoughtful AI Decisions -**Collective decision making through structured AI deliberation — with RAG, pgvector, Knowledge Keeper, Chairman, Context Compression, Dependency Injection & Execution Logging.** +**Collective decision making through structured AI deliberation — with RAG, pgvector, Knowledge Keeper, Operator (MCP tools), Chairman, Context Compression, Dependency Injection & Execution Logging.** [![NuGet](https://img.shields.io/nuget/v/Delibera.Core.svg)](https://www.nuget.org/packages/Delibera.Core) [![License: MIT](https://img.shields.io/badge/License-MIT-10B981.svg)](../LICENSE) @@ -28,6 +28,7 @@ backed by **Qdrant** or **PostgreSQL/pgvector** (RAG), with **context compressio - 🏛️ **Multi-Model Councils** — orchestrate any number of LLM participants across structured debate rounds - ⚖️ **Chairman Synthesis** — a dedicated moderator opens, regulates and synthesises the final verdict - 📚 **Knowledge Keeper (RAG)** — per-round semantic retrieval with Qdrant or pgvector +- 🛠️ **Operator (MCP Tools)** — a micro-agent that delegates tasks to MCP servers (web search, file system, Notion, PostgreSQL…) on demand during the debate - 🗜️ **Context Compression** — 4 strategies (Semantic, Deduplication, Summarization, Hybrid) save 30–70% of tokens - 💉 **Dependency Injection** — `AddDelibera()` extension for `IServiceCollection` - 📋 **Execution Logging** — `ExecutionLog` with `LogLevel` for Chairman, KK, Compression & participants @@ -171,6 +172,95 @@ await keeper.IndexFileAsync("./docs/architecture.md"); --- +## 🛠️ Operator (MCP Tools) + +The **Operator** is a lightweight micro-agent that connects the council to the outside world through +[**MCP (Model Context Protocol)**](https://modelcontextprotocol.io) servers. It exposes whatever tools +those servers provide — web search, file system access, Notion, PostgreSQL, a debate-history store, etc. — +to the debate participants. + +**How it works** + +1. The Operator connects to one or more MCP servers and discovers their tools on `InitializeAsync`. +2. Participants are told (in their system prompt) what the Operator can do, and can delegate a task at + **any moment** during the debate by writing a marker in their message: + + ``` + [[OPERATOR: search the web for the latest EU AI Act timeline and save it to Notion]] + ``` + +3. The Operator interprets the request with its **own (cheaper) LLM model**, picks and calls the right + MCP tools, interprets the results, and returns a concise answer that is injected into the next round. +4. If the council uses **context compression**, the Operator can reuse the same strategy to compress + large tool outputs before they re-enter the debate. + +All Operator interactions are recorded per round and rendered in the final Markdown report under a +**🛠️ Operator Interactions** block. + +### Quick usage + +```csharp +using Delibera.Core.Council; +using Delibera.Core.Models; +using Delibera.Core.Providers.LLM; + +var ollama = new OllamaProvider("http://localhost:11434"); + +// Define the MCP servers the Operator may use +var servers = new[] +{ + // stdio transport — launches a local MCP server process + McpServerConfig.Stdio( + name: "everything", + command: "npx", + arguments: new[] { "-y", "@modelcontextprotocol/server-everything" }), + + McpServerConfig.Stdio( + name: "filesystem", + command: "npx", + arguments: new[] { "-y", "@modelcontextprotocol/server-filesystem", "/data" }), + + // …or an HTTP/SSE transport for a remote MCP server + // McpServerConfig.Http( + // name: "remote", + // endpoint: "https://my-mcp-host.example.com/mcp", + // additionalHeaders: new Dictionary { ["Authorization"] = "Bearer " }), +}; + +var council = new CouncilBuilder() + .AddMember("gpt-oss:20b", ollama, "Optimist") + .AddMember("llama3.1:8b", ollama, "Skeptic") + .WithChairman("gpt-oss:20b", ollama) + // Operator uses its own cheaper model; reuseCompression shares the council's compressor + .WithOperator("llama3.2:3b", ollama, servers, reuseCompression: true) + .WithTopic("Should we migrate the data pipeline to Kafka?") + .Build(); + +var result = await council.ExecuteAsync(); +``` + +Prefer to build the `Operator` yourself? Pass a pre-built instance: + +```csharp +var @operator = new Operator( + new CouncilMember("llama3.2:3b", ollama, "Operator"), + new IMcpClient[] { new McpClientAdapter(servers[0]), new McpClientAdapter(servers[1]) }, + compressor: null, // optional IContextCompressor + compressionOptions: null); // optional CompressionOptions + +var council = new CouncilBuilder() + /* …members… */ + .WithOperator(@operator) + .Build(); +``` + +### Dependency Injection + +Configure the Operator declaratively in `appsettings.json` under `Delibera:Operator` (see the +configuration section below), then build the council from `CouncilOptions` as usual. + +--- + ## 🗣️ Debate Strategies | Strategy | Flow | Use Case | @@ -214,6 +304,25 @@ var (resultPath, statsPath, logsPath) = await result.SaveAllAsync("./output"); }, "Compression": { "Enabled": true, "Strategy": "Hybrid", "TargetRatio": 0.5 }, "Rag": { "Enabled": false, "ProviderType": "Qdrant", "Host": "localhost", "Port": 6334 }, + "Operator": { + "Enabled": true, + "ModelName": "llama3.2:3b", + "ReuseCompression": true, + "McpServers": [ + { + "Name": "filesystem", + "Transport": "Stdio", + "Command": "npx", + "Arguments": [ "-y", "@modelcontextprotocol/server-filesystem", "/data" ] + }, + { + "Name": "remote", + "Transport": "Http", + "Endpoint": "https://my-mcp-host.example.com/mcp", + "AdditionalHeaders": { "Authorization": "Bearer " } + } + ] + }, "Output": { "Directory": "./debate_results", "SeparateFiles": true } } } @@ -231,6 +340,7 @@ in `Providers:ApiKey`. | `OllamaSharp` | Ollama API client | | `Qdrant.Client` | Qdrant vector DB gRPC client | | `Npgsql` / `Pgvector` | PostgreSQL/pgvector support | +| `ModelContextProtocol` | MCP client for the Operator role | | `Microsoft.Extensions.*` | Configuration, DI and Options | --- From eb557784beabb7e18ca8f46cd2bdae15be0bc7fd Mon Sep 17 00:00:00 2001 From: Victor Buzin Date: Tue, 16 Jun 2026 11:13:14 +0300 Subject: [PATCH 02/14] feat: integrate Microsoft.Extensions.AI (IChatClient/IEmbeddingGenerator) [v10.1.1] (#2) Add first-class interop with the standard Microsoft.Extensions.AI v10.7.0 abstractions while preserving the existing public API (additive only). - Delibera.Core: add Microsoft.Extensions.AI 10.7.0; bump 10.1.0 -> 10.1.1 - ChatClientLLMProvider: ILLMProvider implemented over IChatClient (+ streaming) - EmbeddingGeneratorProvider: IEmbeddingProvider over IEmbeddingGenerator - MicrosoftAIExtensions: AsLLMProvider/AsEmbeddingProvider/AsChatClient bridges + WithMiddleware (logging + function invocation) - ILLMProvider: additive default ChatStreamAsync (non-breaking) - OllamaProvider: expose native AsChatClient()/AsEmbeddingGenerator() - ProviderFactory.CreateFromChatClient + DI AddDeliberaChatClient/EmbeddingGenerator - ConsoleApp: --msai example - tests: new Delibera.Core.Tests (20 tests, fakes; no live network) - docs: README/README-RU/QuickStart/CHANGELOG + solution file --- CHANGELOG.md | 50 ++++++ Delibera.slnx | 9 + README-RU.md | 88 +++++++++- README.md | 88 +++++++++- docs/QuickStart-RU.md | 1 + docs/QuickStart.md | 1 + .../Examples/MicrosoftExtensionsAiExample.cs | 130 ++++++++++++++ src/Delibera.ConsoleApp/Program.cs | 6 + src/Delibera.Core/Delibera.Core.csproj | 5 +- .../ServiceCollectionExtensions.cs | 76 +++++++++ .../Extensions/MicrosoftAIExtensions.cs | 143 ++++++++++++++++ src/Delibera.Core/Interfaces/ILLMProvider.cs | 26 +++ .../Providers/LLM/ChatClientLLMProvider.cs | 160 ++++++++++++++++++ .../LLM/EmbeddingGeneratorProvider.cs | 104 ++++++++++++ .../Providers/LLM/OllamaProvider.cs | 15 ++ .../Providers/ProviderFactory.cs | 30 ++++ src/Delibera.Core/README.md | 2 + .../ChatClientLLMProviderTests.cs | 119 +++++++++++++ .../Delibera.Core.Tests.csproj | 26 +++ .../EmbeddingAndBridgeTests.cs | 142 ++++++++++++++++ .../Fakes/FakeChatClient.cs | 55 ++++++ .../Fakes/FakeEmbeddingGenerator.cs | 38 +++++ 22 files changed, 1308 insertions(+), 6 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 Delibera.slnx create mode 100644 src/Delibera.ConsoleApp/Examples/MicrosoftExtensionsAiExample.cs create mode 100644 src/Delibera.Core/Extensions/MicrosoftAIExtensions.cs create mode 100644 src/Delibera.Core/Providers/LLM/ChatClientLLMProvider.cs create mode 100644 src/Delibera.Core/Providers/LLM/EmbeddingGeneratorProvider.cs create mode 100644 tests/Delibera.Core.Tests/ChatClientLLMProviderTests.cs create mode 100644 tests/Delibera.Core.Tests/Delibera.Core.Tests.csproj create mode 100644 tests/Delibera.Core.Tests/EmbeddingAndBridgeTests.cs create mode 100644 tests/Delibera.Core.Tests/Fakes/FakeChatClient.cs create mode 100644 tests/Delibera.Core.Tests/Fakes/FakeEmbeddingGenerator.cs diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f84b6b6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,50 @@ +# Changelog + +All notable changes to **Delibera** are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [10.1.1] - 2026 + +### Added — Microsoft.Extensions.AI integration + +- **`Microsoft.Extensions.AI` 10.7.0** dependency in `Delibera.Core`. +- **`ChatClientLLMProvider`** — adapts any Microsoft.Extensions.AI `IChatClient` to Delibera's + `ILLMProvider`. Works with OpenAI, Azure OpenAI, Ollama, Anthropic and local OpenAI-compatible + servers (LM Studio, LocalAI, vLLM) without a bespoke provider per vendor. +- **`EmbeddingGeneratorProvider`** — adapts any `IEmbeddingGenerator>` + to Delibera's `IEmbeddingProvider` for RAG indexing/querying. +- **`ILLMProvider.ChatStreamAsync(...)`** — additive default interface method for token-by-token + streaming. `ChatClientLLMProvider` overrides it for true streaming; existing providers fall back + to a single `ChatAsync` call, so nothing breaks. +- **`MicrosoftAIExtensions`** bridge helpers: + - `IChatClient.AsLLMProvider(...)`, `IEmbeddingGenerator.AsEmbeddingProvider(...)` + - `ILLMProvider.AsChatClient(...)` (reverse bridge so Delibera providers can join a + Microsoft.Extensions.AI middleware pipeline) + - `IChatClient.WithMiddleware(...)` to compose function invocation + logging. +- **`ProviderFactory.CreateFromChatClient(...)`** — build a cached provider directly from an `IChatClient`. +- **`OllamaProvider.AsChatClient()` / `AsEmbeddingGenerator()`** — expose the underlying + OllamaSharp client (which natively implements the Microsoft.Extensions.AI interfaces). +- **DI helpers** `AddDeliberaChatClient(...)` and `AddDeliberaEmbeddingGenerator(...)` in + `ServiceCollectionExtensions`. +- **Console example** `MicrosoftExtensionsAiExample` (run with `--msai`). +- **Unit test project** `tests/Delibera.Core.Tests` (xUnit) covering the new providers, the bridge + adapters and the factory — plus a solution file `Delibera.slnx`. + +### Changed + +- Bumped `Delibera.Core` package version `10.1.0` → `10.1.1`. +- Documentation: `README.md` / `README-RU.md` and `docs/QuickStart*.md` updated with a + Microsoft.Extensions.AI section. + +### Compatibility + +- **No breaking changes.** The public API is fully backward compatible; all existing + `ILLMProvider` / `IEmbeddingProvider` consumers continue to work unchanged. + +## [10.1.0] - 2026 + +- Operator (MCP tools) role, .NET 10 / C# 15 (preview) upgrade, high-performance hot-path + optimizations (SIMD cosine similarity, allocation-free token counting, pooled cache hashing). +- Dependency injection, context compression, and RAG (Qdrant, pgvector) support. diff --git a/Delibera.slnx b/Delibera.slnx new file mode 100644 index 0000000..0534774 --- /dev/null +++ b/Delibera.slnx @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/README-RU.md b/README-RU.md index d4811dd..02d0b84 100644 --- a/README-RU.md +++ b/README-RU.md @@ -48,6 +48,7 @@ | **📋 Журналирование выполнения** | Модель `ExecutionLog` с `LogLevel` — события Chairman, KK, сжатия и участников | | **📁 Раздельный вывод файлов** | Экспорт `result.md`, `statistics.md` и `logs.md` по отдельности | | **🔌 Interface-First** | Чистые абстракции для провайдеров, фабрик, билдеров и исполнителей | +| **🤝 Microsoft.Extensions.AI** | Поддержка `IChatClient` / `IEmbeddingGenerator` — подключайте OpenAI, Azure OpenAI, Ollama и любые совместимые бэкенды, с middleware (function calling, логирование) | | **🧱 Современный C# 15 (preview)** | Построено на .NET 10 с `LangVersion=preview`, file-scoped namespaces, records, span/SIMD горячие пути | --- @@ -642,10 +643,92 @@ docker exec -it psql -U postgres -d council_vectors -c "CREATE EXTEN | `Npgsql` | ADO.NET-провайдер PostgreSQL | | `Pgvector` | Поддержка типов pgvector для Npgsql | | `ModelContextProtocol` | MCP-клиент для роли Operator | +| `Microsoft.Extensions.AI`| Унифицированные AI-абстракции `IChatClient` / `IEmbeddingGenerator` и middleware | | `Microsoft.Extensions.*` | Конфигурация, DI и Options | --- +## 🤝 Microsoft.Extensions.AI + +Delibera интегрирована с [**Microsoft.Extensions.AI**](https://learn.microsoft.com/dotnet/ai/microsoft-extensions-ai) +(v10.7.0) — стандартным слоем абстракций .NET для генеративного AI. Это позволяет запускать совет +с **любым** бэкендом, реализующим `IChatClient` (OpenAI, Azure OpenAI, Ollama, Anthropic, +LM Studio / LocalAI / vLLM, …) и собирать **конвейер middleware** (вызов функций/инструментов, +логирование, кэширование, телеметрия) — без написания отдельного провайдера под каждого поставщика. + +### Что вы получаете + +| Тип | Роль | +| ---- | ---- | +| `ChatClientLLMProvider` | Адаптирует любой `IChatClient` к `ILLMProvider` Delibera (со **стримингом** через `ChatStreamAsync`) | +| `EmbeddingGeneratorProvider` | Адаптирует любой `IEmbeddingGenerator>` к `IEmbeddingProvider` для RAG | +| `MicrosoftAIExtensions` | Мосты: `AsLLMProvider()`, `AsEmbeddingProvider()`, `AsChatClient()`, `WithMiddleware()` | +| `ProviderFactory.CreateFromChatClient(...)` | Создаёт кэшируемый провайдер прямо из `IChatClient` | +| `AddDeliberaChatClient(...)` / `AddDeliberaEmbeddingGenerator(...)` | Регистрация стандартных AI-сервисов в DI | + +### Использование любого `IChatClient` как провайдера Delibera + +```csharp +using Delibera.Core.Extensions; +using Microsoft.Extensions.AI; + +// 1) Подключите свой IChatClient. Для OpenAI: +// dotnet add package Microsoft.Extensions.AI.OpenAI +IChatClient client = new OpenAI.Chat.ChatClient("gpt-4o-mini", apiKey).AsIChatClient(); + +// 2) (опционально) middleware — вызов функций + логирование +client = client.WithMiddleware(enableFunctionInvocation: true, loggerFactory); + +// 3) отдайте его Delibera +ILLMProvider provider = client.AsLLMProvider("OpenAI"); + +var executor = new CouncilBuilder() + .AddMember("gpt-4o-mini", provider, "Архитектор") + .AddMember("gpt-4o-mini", provider, "Скептик") + .SetChairman(Chairman.CreateStandard("gpt-4o-mini", provider)) + .WithStandardDebate() + .WithUserPrompt("Модульный монолит или микросервисы для команды из 5 человек?") + .Build(); +``` + +Ollama работает «из коробки», так как `OllamaApiClient` из OllamaSharp нативно реализует +`IChatClient` и `IEmbeddingGenerator`: + +```csharp +using var ollama = new OllamaProvider("http://localhost:11434"); +IChatClient chat = ollama.AsChatClient(); // готов к middleware +ILLMProvider provider = chat.AsLLMProvider("Ollama"); +IEmbeddingProvider embeddings = ollama.AsEmbeddingGenerator().AsEmbeddingProvider("nomic-embed-text"); +``` + +### Стриминг + +```csharp +await foreach (var chunk in provider.ChatStreamAsync("gpt-4o-mini", systemPrompt, userPrompt)) + Console.Write(chunk); +``` + +`ChatStreamAsync` — это additive-метод по умолчанию в `ILLMProvider`: провайдеры на базе +`IChatClient` стримят токен за токеном, а старые провайдеры прозрачно используют один вызов. + +### Внедрение зависимостей (DI) + +```csharp +services.AddDeliberaChatClient( + sp => new OpenAI.Chat.ChatClient("gpt-4o-mini", apiKey) + .AsIChatClient() + .WithMiddleware(enableFunctionInvocation: true), + providerName: "OpenAI"); + +services.AddDeliberaEmbeddingGenerator( + sp => /* ваш IEmbeddingGenerator */, + modelName: "text-embedding-3-small"); +``` + +> Попробуйте: `dotnet run --project src/Delibera.ConsoleApp -- --msai` + +--- + ## 🏛️ Архитектура ``` @@ -654,10 +737,11 @@ Delibera.Core ├── Debate/ ← StandardDebate, CritiqueDebate, ConsensusDebate ├── Compression/ ← Semantic / Deduplication / Summarization / Hybrid ├── Providers/ -│ ├── LLM/ ← OllamaProvider, OllamaEmbeddingProvider +│ ├── LLM/ ← OllamaProvider, ChatClientLLMProvider, EmbeddingGeneratorProvider │ ├── RAG/ ← QdrantRagProvider, PgVectorRagProvider │ └── Mcp/ ← McpClientAdapter (Operator ↔ MCP-серверы) -├── DependencyInjection/ ← AddDelibera() + CouncilOptions +├── Extensions/ ← MicrosoftAIExtensions (мосты IChatClient ↔ ILLMProvider) +├── DependencyInjection/ ← AddDelibera() / AddDeliberaChatClient() + CouncilOptions ├── Knowledge/ ← MarkdownKnowledgeBase ├── Models/ ← CouncilMember, DebateResult, DebateRound, TokenStatistics, ... └── Interfaces/ ← ILLMProvider, IRagProvider, IContextCompressor, IOperator, IMcpClient, ... diff --git a/README.md b/README.md index 86caed7..15c220d 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ well-reasoned outcomes** rather than single-model guesses. | **📋 Execution Logging** | `ExecutionLog` model with `LogLevel` — Chairman, KK, Compression & participant events | | **📁 Separate File Output** | Export `result.md`, `statistics.md`, and `logs.md` independently | | **🔌 Interface-First** | Clean abstractions for providers, factories, builders and executors | +| **🤝 Microsoft.Extensions.AI**| First-class support for `IChatClient` / `IEmbeddingGenerator` — plug in OpenAI, Azure OpenAI, Ollama or any compatible backend, with middleware (function calling, logging) | | **🧱 Modern C# 15 (preview)** | Built on .NET 10 with `LangVersion=preview`, file-scoped namespaces, records, span/SIMD hot paths | --- @@ -637,10 +638,92 @@ docker exec -it psql -U postgres -d council_vectors -c "CREATE EXTEN | `Npgsql` | PostgreSQL ADO.NET provider | | `Pgvector` | pgvector type support for Npgsql | | `ModelContextProtocol` | MCP client for the Operator role | +| `Microsoft.Extensions.AI`| Unified `IChatClient` / `IEmbeddingGenerator` AI abstractions & middleware | | `Microsoft.Extensions.*` | Configuration, DI and Options | --- +## 🤝 Microsoft.Extensions.AI + +Delibera integrates with [**Microsoft.Extensions.AI**](https://learn.microsoft.com/dotnet/ai/microsoft-extensions-ai) +(v10.7.0) — the standard .NET abstraction layer for generative-AI services. This lets you drive a +council with **any** backend that ships an `IChatClient` (OpenAI, Azure OpenAI, Ollama, Anthropic, +LM Studio / LocalAI / vLLM, …) and compose a **middleware pipeline** (function/tool invocation, +logging, caching, telemetry) — all without writing a provider per vendor. + +### What you get + +| Type | Role | +| ---- | ---- | +| `ChatClientLLMProvider` | Adapts any `IChatClient` to Delibera's `ILLMProvider` (with **streaming** via `ChatStreamAsync`) | +| `EmbeddingGeneratorProvider` | Adapts any `IEmbeddingGenerator>` to `IEmbeddingProvider` for RAG | +| `MicrosoftAIExtensions` | Bridge helpers: `AsLLMProvider()`, `AsEmbeddingProvider()`, `AsChatClient()`, `WithMiddleware()` | +| `ProviderFactory.CreateFromChatClient(...)` | Build a cached provider straight from an `IChatClient` | +| `AddDeliberaChatClient(...)` / `AddDeliberaEmbeddingGenerator(...)` | Register the standard AI services in DI | + +### Use any `IChatClient` as a Delibera provider + +```csharp +using Delibera.Core.Extensions; +using Microsoft.Extensions.AI; + +// 1) Bring your own IChatClient. For OpenAI: +// dotnet add package Microsoft.Extensions.AI.OpenAI +IChatClient client = new OpenAI.Chat.ChatClient("gpt-4o-mini", apiKey).AsIChatClient(); + +// 2) (optional) compose middleware — function calling + logging +client = client.WithMiddleware(enableFunctionInvocation: true, loggerFactory); + +// 3) expose it to Delibera +ILLMProvider provider = client.AsLLMProvider("OpenAI"); + +var executor = new CouncilBuilder() + .AddMember("gpt-4o-mini", provider, "Architect") + .AddMember("gpt-4o-mini", provider, "Skeptic") + .SetChairman(Chairman.CreateStandard("gpt-4o-mini", provider)) + .WithStandardDebate() + .WithUserPrompt("Modular monolith or microservices for a 5-person team?") + .Build(); +``` + +Ollama works out of the box because OllamaSharp's `OllamaApiClient` natively implements +`IChatClient` and `IEmbeddingGenerator`: + +```csharp +using var ollama = new OllamaProvider("http://localhost:11434"); +IChatClient chat = ollama.AsChatClient(); // ready for middleware +ILLMProvider provider = chat.AsLLMProvider("Ollama"); +IEmbeddingProvider embeddings = ollama.AsEmbeddingGenerator().AsEmbeddingProvider("nomic-embed-text"); +``` + +### Streaming + +```csharp +await foreach (var chunk in provider.ChatStreamAsync("gpt-4o-mini", systemPrompt, userPrompt)) + Console.Write(chunk); +``` + +`ChatStreamAsync` is an additive default method on `ILLMProvider` — providers backed by an +`IChatClient` stream token-by-token, while older providers transparently fall back to a single call. + +### Dependency Injection + +```csharp +services.AddDeliberaChatClient( + sp => new OpenAI.Chat.ChatClient("gpt-4o-mini", apiKey) + .AsIChatClient() + .WithMiddleware(enableFunctionInvocation: true), + providerName: "OpenAI"); + +services.AddDeliberaEmbeddingGenerator( + sp => /* your IEmbeddingGenerator */, + modelName: "text-embedding-3-small"); +``` + +> Try it: `dotnet run --project src/Delibera.ConsoleApp -- --msai` + +--- + ## 🏛️ Architecture ``` @@ -649,10 +732,11 @@ Delibera.Core ├── Debate/ ← StandardDebate, CritiqueDebate, ConsensusDebate ├── Compression/ ← Semantic / Deduplication / Summarization / Hybrid ├── Providers/ -│ ├── LLM/ ← OllamaProvider, OllamaEmbeddingProvider +│ ├── LLM/ ← OllamaProvider, ChatClientLLMProvider, EmbeddingGeneratorProvider │ ├── RAG/ ← QdrantRagProvider, PgVectorRagProvider │ └── Mcp/ ← McpClientAdapter (Operator ↔ MCP servers) -├── DependencyInjection/ ← AddDelibera() + CouncilOptions +├── Extensions/ ← MicrosoftAIExtensions (IChatClient ↔ ILLMProvider bridges) +├── DependencyInjection/ ← AddDelibera() / AddDeliberaChatClient() + CouncilOptions ├── Knowledge/ ← MarkdownKnowledgeBase ├── Models/ ← CouncilMember, DebateResult, DebateRound, TokenStatistics, ... └── Interfaces/ ← ILLMProvider, IRagProvider, IContextCompressor, IOperator, IMcpClient, ... diff --git a/docs/QuickStart-RU.md b/docs/QuickStart-RU.md index 70918c1..c68ac59 100644 --- a/docs/QuickStart-RU.md +++ b/docs/QuickStart-RU.md @@ -434,6 +434,7 @@ var (resultPath, statsPath, logsPath) = await result.SaveAllAsync("./output"); | RAG (pgvector) | `dotnet run -- --pgvector` | Векторный поиск на базе PostgreSQL | | Operator (основы) | `dotnet run -- --operator` | Роль Operator с MCP-инструментами | | Operator + MCP | `dotnet run -- --operator-mcp` | 🆕 MCP-серверы browser + Marp в совете | +| Microsoft.Extensions.AI | `dotnet run -- --msai` | 🆕 `IChatClient`/`IEmbeddingGenerator` + middleware | --- diff --git a/docs/QuickStart.md b/docs/QuickStart.md index 0fa09b1..f1fda84 100644 --- a/docs/QuickStart.md +++ b/docs/QuickStart.md @@ -433,6 +433,7 @@ The repository ships with a runnable demo under | RAG (pgvector) | `dotnet run -- --pgvector` | PostgreSQL-backed vector search | | Operator (basics) | `dotnet run -- --operator` | Operator role with MCP tools | | Operator + MCP | `dotnet run -- --operator-mcp` | 🆕 Browser + Marp MCP servers in a council | +| Microsoft.Extensions.AI | `dotnet run -- --msai` | 🆕 `IChatClient`/`IEmbeddingGenerator` + middleware | --- diff --git a/src/Delibera.ConsoleApp/Examples/MicrosoftExtensionsAiExample.cs b/src/Delibera.ConsoleApp/Examples/MicrosoftExtensionsAiExample.cs new file mode 100644 index 0000000..fc6e05c --- /dev/null +++ b/src/Delibera.ConsoleApp/Examples/MicrosoftExtensionsAiExample.cs @@ -0,0 +1,130 @@ +using Delibera.Core.Council; +using Delibera.Core.Extensions; +using Delibera.Core.Interfaces; +using Delibera.Core.Providers; +using Delibera.Core.Providers.LLM; +using Microsoft.Extensions.AI; + +namespace Delibera.ConsoleApp.Examples; + +/// +/// Demonstrates the Microsoft.Extensions.AI integration: using the standard +/// / abstractions +/// inside Delibera, composing middleware, and bridging providers in both directions. +/// +/// +/// Run with: dotnet run --project src/Delibera.ConsoleApp -- --msai +/// +public static class MicrosoftExtensionsAiExample +{ + public static async Task RunAsync() + { + Console.WriteLine("🤝 Microsoft.Extensions.AI integration demo\n"); + + // ────────────────────────────────────────────────────────────── + // A. Use ANY IChatClient as a Delibera ILLMProvider + // ────────────────────────────────────────────────────────────── + // OllamaSharp's OllamaApiClient natively implements IChatClient, so we use it here. + // The same pattern applies to OpenAI / Azure OpenAI clients from Microsoft.Extensions.AI.OpenAI: + // + // IChatClient openAi = new OpenAI.Chat.ChatClient("gpt-4o-mini", apiKey).AsIChatClient(); + // ILLMProvider provider = openAi.AsLLMProvider("OpenAI"); + // + const string endpoint = "http://localhost:11434"; // local Ollama (agent VM) + const string model = "llama3.2"; + + using var ollama = new OllamaProvider(endpoint); + + // Grab the underlying IChatClient and wrap it with the standard middleware pipeline + // (function invocation + logging). This is the heart of "maximising the package potential". + IChatClient pipeline = ollama + .AsChatClient() + .WithMiddleware(enableFunctionInvocation: true); + + // Expose the decorated client back to Delibera as a normal provider. + ILLMProvider msaiProvider = pipeline.AsLLMProvider("Ollama (via Microsoft.Extensions.AI)"); + Console.WriteLine($" ✦ Provider name: {msaiProvider.ProviderName}"); + + // ────────────────────────────────────────────────────────────── + // B. Streaming via the unified abstraction + // ────────────────────────────────────────────────────────────── + Console.WriteLine("\n 💬 Streaming response (token-by-token):\n"); + try + { + await foreach (var chunk in msaiProvider.ChatStreamAsync( + model, + systemPrompt: "You are a concise assistant.", + userPrompt: "In one sentence, what is Microsoft.Extensions.AI?")) + Console.Write(chunk); + Console.WriteLine(); + } + catch (Exception ex) + { + PrintTips(ex); + return; + } + + // ────────────────────────────────────────────────────────────── + // C. Embeddings through IEmbeddingGenerator + // ────────────────────────────────────────────────────────────── + Console.WriteLine("\n 🧮 Embedding via IEmbeddingGenerator:"); + try + { + IEmbeddingProvider embeddings = ollama + .AsEmbeddingGenerator() + .AsEmbeddingProvider("nomic-embed-text"); + + var vector = await embeddings.EmbedAsync("Delibera deliberates."); + Console.WriteLine($" dimensions = {vector.Length}"); + } + catch (Exception ex) + { + Console.WriteLine($" ⚠️ {ex.Message} (embedding model may not be pulled)"); + } + + // ────────────────────────────────────────────────────────────── + // D. Plug the standard client into a full council via the factory + // ────────────────────────────────────────────────────────────── + Console.WriteLine("\n 🏛️ Building a council from an IChatClient..."); + using var factory = new ProviderFactory(); + var councilProvider = factory.CreateFromChatClient( + name: "msai", + chatClient: ollama.AsChatClient(), + providerName: "Ollama"); + + var executor = new CouncilBuilder() + .AddMember(model, councilProvider, "Architect") + .AddMember(model, councilProvider, "Skeptic") + .SetChairman(Chairman.CreateStandard(model, councilProvider)) + .WithStandardDebate() + .WithSystemPrompt("You are a software architecture expert.") + .WithUserPrompt("Is Microsoft.Extensions.AI a good abstraction for multi-provider apps?") + .WithMaxRounds(2) + .SaveResultTo("./results/msai_debate.md") + .Build(); + + Console.WriteLine(executor.GetInfo()); + + try + { + var result = await executor.ExecuteAsync(); + Console.WriteLine($"\n🏆 Completed in {result.TotalDuration.TotalSeconds:F1}s"); + Console.WriteLine("📁 Saved to: ./results/msai_debate.md"); + } + catch (Exception ex) + { + PrintTips(ex); + } + } + + 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(" and build an IChatClient from your OpenAI key, then call .AsLLMProvider()."); + } +} diff --git a/src/Delibera.ConsoleApp/Program.cs b/src/Delibera.ConsoleApp/Program.cs index f6444bc..1fcb618 100644 --- a/src/Delibera.ConsoleApp/Program.cs +++ b/src/Delibera.ConsoleApp/Program.cs @@ -77,6 +77,12 @@ public static async Task Main(string[] args) return; } + if (args.Contains("--msai")) + { + await MicrosoftExtensionsAiExample.RunAsync(); + return; + } + // Quick DI showcase before main demo Console.WriteLine("🆕 v3.1 DI Quick Demo:"); Console.WriteLine(" Run with --di for full DI example"); diff --git a/src/Delibera.Core/Delibera.Core.csproj b/src/Delibera.Core/Delibera.Core.csproj index 998588b..2471c5b 100644 --- a/src/Delibera.Core/Delibera.Core.csproj +++ b/src/Delibera.Core/Delibera.Core.csproj @@ -12,7 +12,7 @@ Delibera.Core - 10.1.0 + 10.1.1 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, and context compression. Victor Buzin @@ -25,7 +25,7 @@ https://github.com/techbuzzz/Delibera git https://github.com/techbuzzz/Delibera - v10.1.0 — Major version update with improved dependency injection, context compression, and RAG support. + v10.1.1 — Microsoft.Extensions.AI (10.7.0) integration: ChatClientLLMProvider and EmbeddingGeneratorProvider adapt any IChatClient / IEmbeddingGenerator (OpenAI, Azure OpenAI, Ollama, …), streaming via ChatStreamAsync, middleware composition, and AddDeliberaChatClient DI helpers. Fully backward compatible. true snupkg true @@ -38,6 +38,7 @@ + diff --git a/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs b/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs index b9e37be..6bd81d5 100644 --- a/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs @@ -1,7 +1,10 @@ using Delibera.Core.Compression; using Delibera.Core.Council; +using Delibera.Core.Interfaces; using Delibera.Core.Providers; +using Delibera.Core.Providers.LLM; using Delibera.Core.Providers.RAG; +using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -73,4 +76,77 @@ public static IServiceCollection AddDelibera( return services; } + + /// + /// Registers a Microsoft.Extensions.AI and exposes it as a Delibera + /// (). + /// + /// + /// Lets you wire any Microsoft.Extensions.AI backend (OpenAI, Azure OpenAI, Ollama, local + /// OpenAI-compatible servers) into the container and consume it through Delibera's provider + /// abstraction. The factory delegate may compose a middleware pipeline (function invocation, + /// logging, caching) before returning the client. + /// + /// The service collection. + /// Factory that builds the chat client (optionally with middleware). + /// Optional friendly provider name surfaced by . + public static IServiceCollection AddDeliberaChatClient( + this IServiceCollection services, + Func chatClientFactory, + string? providerName = null) + { + ArgumentNullException.ThrowIfNull(chatClientFactory); + + services.AddDelibera(); + services.TryAddSingleton(chatClientFactory); + // The DI container owns the IChatClient lifetime, so the provider must not dispose it. + services.TryAddSingleton(sp => + new ChatClientLLMProvider(sp.GetRequiredService(), providerName, ownsClient: false)); + + return services; + } + + /// + /// Registers an already-constructed Microsoft.Extensions.AI and exposes it + /// as a Delibera . + /// + /// The service collection. + /// The chat client instance. + /// Optional friendly provider name. + public static IServiceCollection AddDeliberaChatClient( + this IServiceCollection services, + IChatClient chatClient, + string? providerName = null) + { + ArgumentNullException.ThrowIfNull(chatClient); + return services.AddDeliberaChatClient(_ => chatClient, providerName); + } + + /// + /// Registers a Microsoft.Extensions.AI and exposes + /// it as a Delibera (). + /// + /// The service collection. + /// Factory that builds the embedding generator. + /// Optional friendly model name. + /// Optional known vector dimensionality. + public static IServiceCollection AddDeliberaEmbeddingGenerator( + this IServiceCollection services, + Func>> generatorFactory, + string? modelName = null, + int? vectorSize = null) + { + ArgumentNullException.ThrowIfNull(generatorFactory); + + services.AddDelibera(); + services.TryAddSingleton(generatorFactory); + services.TryAddSingleton(sp => + new EmbeddingGeneratorProvider( + sp.GetRequiredService>>(), + modelName, + vectorSize, + ownsGenerator: false)); + + return services; + } } diff --git a/src/Delibera.Core/Extensions/MicrosoftAIExtensions.cs b/src/Delibera.Core/Extensions/MicrosoftAIExtensions.cs new file mode 100644 index 0000000..66cfe27 --- /dev/null +++ b/src/Delibera.Core/Extensions/MicrosoftAIExtensions.cs @@ -0,0 +1,143 @@ +using System.Runtime.CompilerServices; +using System.Text; +using Delibera.Core.Interfaces; +using Delibera.Core.Providers.LLM; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace Delibera.Core.Extensions; + +/// +/// Bridging helpers between Delibera's provider abstractions (, +/// ) and the Microsoft.Extensions.AI abstractions +/// (, ). +/// +/// +/// These extensions let the two worlds interoperate in either direction: +/// +/// Adopt any Microsoft.Extensions.AI client as a Delibera provider via / . +/// Expose a Delibera provider as a Microsoft.Extensions.AI via , so it can participate in the standard middleware pipeline. +/// Compose middleware (function invocation, logging) with . +/// +/// +public static class MicrosoftAIExtensions +{ + /// + /// Adopts a Microsoft.Extensions.AI as a Delibera . + /// + /// The chat client to wrap. + /// Optional friendly provider name (defaults to client metadata). + /// Whether disposing the provider also disposes the client. + public static ILLMProvider AsLLMProvider(this IChatClient chatClient, string? providerName = null, bool ownsClient = true) + => new ChatClientLLMProvider(chatClient, providerName, ownsClient); + + /// + /// Adopts a Microsoft.Extensions.AI as a + /// Delibera . + /// + public static IEmbeddingProvider AsEmbeddingProvider( + this IEmbeddingGenerator> generator, + string? modelName = null, + int? vectorSize = null, + bool ownsGenerator = true) + => new EmbeddingGeneratorProvider(generator, modelName, vectorSize, ownsGenerator); + + /// + /// Exposes a Delibera as a Microsoft.Extensions.AI . + /// + /// + /// Use this to drop an existing Delibera provider into a Microsoft.Extensions.AI middleware pipeline + /// (caching, telemetry, function invocation). If the provider already is a + /// , its underlying client is returned directly to avoid a needless layer. + /// + /// The Delibera provider to expose. + /// Model id used when a request does not specify one. + public static IChatClient AsChatClient(this ILLMProvider provider, string? defaultModel = null) + { + ArgumentNullException.ThrowIfNull(provider); + if (provider is ChatClientLLMProvider ccp) return ccp.ChatClient; + return new LLMProviderChatClient(provider, defaultModel); + } + + /// + /// Composes a standard Microsoft.Extensions.AI middleware pipeline around an . + /// + /// The inner client. + /// Add automatic function (tool) invocation middleware. + /// When supplied, adds logging middleware. + /// The decorated client. + public static IChatClient WithMiddleware( + this IChatClient chatClient, + bool enableFunctionInvocation = false, + ILoggerFactory? loggerFactory = null) + { + ArgumentNullException.ThrowIfNull(chatClient); + + var builder = chatClient.AsBuilder(); + if (loggerFactory is not null) builder = builder.UseLogging(loggerFactory); + if (enableFunctionInvocation) builder = builder.UseFunctionInvocation(loggerFactory); + return builder.Build(); + } + + /// + /// Minimal adapter over a Delibera . + /// + private sealed class LLMProviderChatClient(ILLMProvider provider, string? defaultModel) : IChatClient + { + private readonly ChatClientMetadata _metadata = new(provider.ProviderName, defaultModelId: defaultModel); + + public async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + var (system, user) = SplitMessages(messages); + var text = await provider.ChatAsync( + ResolveModel(options), + system, + user, + options?.Temperature ?? 0.7f, + cancellationToken); + + return new ChatResponse(new ChatMessage(ChatRole.Assistant, text)) { ModelId = ResolveModel(options) }; + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var (system, user) = SplitMessages(messages); + await foreach (var chunk in provider.ChatStreamAsync( + ResolveModel(options), system, user, options?.Temperature ?? 0.7f, cancellationToken)) + yield return new ChatResponseUpdate(ChatRole.Assistant, chunk); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + ArgumentNullException.ThrowIfNull(serviceType); + if (serviceKey is null && serviceType.IsInstanceOfType(_metadata)) return _metadata; + if (serviceKey is null && serviceType.IsInstanceOfType(provider)) return provider; + return null; + } + + public void Dispose() => provider.Dispose(); + + private string ResolveModel(ChatOptions? options) => + options?.ModelId is { Length: > 0 } m ? m : defaultModel ?? string.Empty; + + private static (string System, string User) SplitMessages(IEnumerable messages) + { + var system = new StringBuilder(); + var user = new StringBuilder(); + foreach (var message in messages) + { + var target = message.Role == ChatRole.System ? system : user; + if (target.Length > 0) target.Append('\n'); + target.Append(message.Text); + } + + return (system.ToString(), user.ToString()); + } + } +} diff --git a/src/Delibera.Core/Interfaces/ILLMProvider.cs b/src/Delibera.Core/Interfaces/ILLMProvider.cs index fc08380..0c9e300 100644 --- a/src/Delibera.Core/Interfaces/ILLMProvider.cs +++ b/src/Delibera.Core/Interfaces/ILLMProvider.cs @@ -30,4 +30,30 @@ Task ChatAsync( string userPrompt, float temperature = 0.7f, CancellationToken ct = default); + + /// + /// Streams a chat-completion response incrementally as text chunks. + /// + /// + /// This is an additive capability introduced alongside the Microsoft.Extensions.AI integration. + /// The default implementation falls back to a single call and yields the + /// whole answer at once, so existing providers keep working unchanged. Providers backed by an + /// IChatClient (see ) override it to deliver + /// true token-by-token streaming. + /// + /// Model name (e.g., "llama3.2", "gpt-4o-mini"). + /// System prompt that defines the model's role / context. + /// User prompt with the question or task. + /// Generation temperature (0.0 = deterministic, 1.0+ = creative). + /// Cancellation token. + /// An asynchronous stream of text chunks that together form the full response. + async IAsyncEnumerable ChatStreamAsync( + string model, + string systemPrompt, + string userPrompt, + float temperature = 0.7f, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) + { + yield return await ChatAsync(model, systemPrompt, userPrompt, temperature, ct); + } } \ No newline at end of file diff --git a/src/Delibera.Core/Providers/LLM/ChatClientLLMProvider.cs b/src/Delibera.Core/Providers/LLM/ChatClientLLMProvider.cs new file mode 100644 index 0000000..76c5629 --- /dev/null +++ b/src/Delibera.Core/Providers/LLM/ChatClientLLMProvider.cs @@ -0,0 +1,160 @@ +using Delibera.Core.Interfaces; +using Microsoft.Extensions.AI; + +namespace Delibera.Core.Providers.LLM; + +/// +/// A universal implemented on top of the Microsoft.Extensions.AI +/// abstraction. +/// +/// +/// +/// This provider lets Delibera talk to any chat backend that ships an +/// implementation — Ollama (via OllamaSharp), OpenAI / Azure OpenAI +/// (via Microsoft.Extensions.AI.OpenAI), Anthropic, local OpenAI-compatible servers +/// (LM Studio, LocalAI, vLLM), and so on — without writing a bespoke provider for each one. +/// +/// +/// Because it wraps an , it also benefits from the +/// Microsoft.Extensions.AI middleware pipeline (function invocation, caching, telemetry, +/// logging). Build a decorated client with helpers and +/// hand it to this provider. +/// +/// +public sealed class ChatClientLLMProvider : ILLMProvider +{ + private readonly IChatClient _chatClient; + private readonly bool _ownsClient; + private bool _disposed; + + /// + /// Wraps an existing as an . + /// + /// The Microsoft.Extensions.AI chat client to wrap. + /// + /// Friendly provider name. When null the name is taken from the client metadata + /// (), falling back to "ChatClient". + /// + /// + /// When true (default) the wrapped client is disposed together with this provider. + /// Set to false when the client lifetime is owned elsewhere (e.g., a DI container). + /// + public ChatClientLLMProvider(IChatClient chatClient, string? providerName = null, bool ownsClient = true) + { + _chatClient = chatClient ?? throw new ArgumentNullException(nameof(chatClient)); + _ownsClient = ownsClient; + + var metadata = chatClient.GetService(typeof(ChatClientMetadata)) as ChatClientMetadata; + ProviderName = providerName + ?? (string.IsNullOrWhiteSpace(metadata?.ProviderName) ? "ChatClient" : metadata!.ProviderName!); + DefaultModelId = metadata?.DefaultModelId; + } + + /// The default model id reported by the underlying client metadata (may be null). + public string? DefaultModelId { get; } + + /// Exposes the wrapped for advanced scenarios and middleware composition. + public IChatClient ChatClient => _chatClient; + + /// + public string ProviderName { get; } + + /// + public Task IsAvailableAsync(CancellationToken ct = default) + { + // A generic IChatClient has no universal health-check. We treat a live client as available; + // concrete transports surface failures on the first ChatAsync call. + return Task.FromResult(!_disposed); + } + + /// + /// + /// The Microsoft.Extensions.AI abstraction does not define a model-enumeration contract, + /// so this returns the default model id when known, otherwise an empty list. + /// + public Task> ListModelsAsync(CancellationToken ct = default) + { + IReadOnlyList models = string.IsNullOrWhiteSpace(DefaultModelId) + ? [] + : [DefaultModelId!]; + return Task.FromResult(models); + } + + /// + public async Task ChatAsync( + string model, + string systemPrompt, + string userPrompt, + float temperature = 0.7f, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(userPrompt); + + var messages = BuildMessages(systemPrompt, userPrompt); + var options = BuildOptions(model, temperature); + + try + { + var response = await _chatClient.GetResponseAsync(messages, options, ct); + var text = response.Text?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(text)) + throw new InvalidOperationException($"Empty response from model '{model}' ({ProviderName})."); + return text; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) when (ex is not InvalidOperationException) + { + throw new InvalidOperationException( + $"Error talking to {ProviderName} (model: {model}): {ex.Message}", ex); + } + } + + /// + public async IAsyncEnumerable ChatStreamAsync( + string model, + string systemPrompt, + string userPrompt, + float temperature = 0.7f, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(userPrompt); + + var messages = BuildMessages(systemPrompt, userPrompt); + var options = BuildOptions(model, temperature); + + await foreach (var update in _chatClient.GetStreamingResponseAsync(messages, options, ct)) + { + var text = update.Text; + if (!string.IsNullOrEmpty(text)) + yield return text; + } + } + + private static List BuildMessages(string systemPrompt, string userPrompt) + { + var messages = new List(2); + if (!string.IsNullOrWhiteSpace(systemPrompt)) + messages.Add(new ChatMessage(ChatRole.System, systemPrompt)); + messages.Add(new ChatMessage(ChatRole.User, userPrompt)); + return messages; + } + + private ChatOptions BuildOptions(string model, float temperature) => new() + { + // Prefer the explicitly requested model; fall back to the client's default. + ModelId = string.IsNullOrWhiteSpace(model) ? DefaultModelId : model, + Temperature = temperature + }; + + /// + public void Dispose() + { + if (_disposed) return; + _disposed = true; + if (_ownsClient) _chatClient.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/src/Delibera.Core/Providers/LLM/EmbeddingGeneratorProvider.cs b/src/Delibera.Core/Providers/LLM/EmbeddingGeneratorProvider.cs new file mode 100644 index 0000000..4de303f --- /dev/null +++ b/src/Delibera.Core/Providers/LLM/EmbeddingGeneratorProvider.cs @@ -0,0 +1,104 @@ +using Delibera.Core.Interfaces; +using Microsoft.Extensions.AI; + +namespace Delibera.Core.Providers.LLM; + +/// +/// A universal implemented on top of the Microsoft.Extensions.AI +/// abstraction. +/// +/// +/// Wraps any IEmbeddingGenerator<string, Embedding<float>> — Ollama (OllamaSharp), +/// OpenAI / Azure OpenAI, local OpenAI-compatible servers, etc. — so RAG indexing and querying +/// work with the standard .NET AI embedding contract instead of a provider-specific SDK call. +/// +public sealed class EmbeddingGeneratorProvider : IEmbeddingProvider, IDisposable +{ + private readonly IEmbeddingGenerator> _generator; + private readonly bool _ownsGenerator; + private int? _cachedVectorSize; + private bool _disposed; + + /// + /// Wraps an existing . + /// + /// The Microsoft.Extensions.AI embedding generator to wrap. + /// + /// Friendly model name. When null it is read from the generator metadata + /// (), falling back to "embedding". + /// + /// + /// Known vector dimensionality. When null it is auto-detected from metadata or on the + /// first call. + /// + /// + /// When true (default) the wrapped generator is disposed together with this provider. + /// + public EmbeddingGeneratorProvider( + IEmbeddingGenerator> generator, + string? modelName = null, + int? vectorSize = null, + bool ownsGenerator = true) + { + _generator = generator ?? throw new ArgumentNullException(nameof(generator)); + _ownsGenerator = ownsGenerator; + + var metadata = generator.GetService(typeof(EmbeddingGeneratorMetadata)) as EmbeddingGeneratorMetadata; + EmbeddingModelName = modelName + ?? (string.IsNullOrWhiteSpace(metadata?.DefaultModelId) ? "embedding" : metadata!.DefaultModelId!); + _cachedVectorSize = vectorSize ?? metadata?.DefaultModelDimensions; + } + + /// Exposes the wrapped generator for advanced scenarios and middleware composition. + public IEmbeddingGenerator> Generator => _generator; + + /// + public string EmbeddingModelName { get; } + + /// + public int VectorSize => _cachedVectorSize ?? + throw new InvalidOperationException( + "Vector size unknown — call EmbedAsync at least once so the provider can probe the model."); + + /// + public async Task EmbedAsync(string text, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(text); + + var result = await _generator.GenerateAsync([text], cancellationToken: ct); + var embedding = result.FirstOrDefault() + ?? throw new InvalidOperationException($"No embedding returned by model '{EmbeddingModelName}'."); + + var vector = embedding.Vector.ToArray(); + _cachedVectorSize ??= vector.Length; + return vector; + } + + /// + public async Task> EmbedBatchAsync(IReadOnlyList texts, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(texts); + if (texts.Count == 0) return []; + + var result = await _generator.GenerateAsync(texts, cancellationToken: ct); + + var vectors = new List(texts.Count); + foreach (var embedding in result) + { + var vector = embedding.Vector.ToArray(); + _cachedVectorSize ??= vector.Length; + vectors.Add(vector); + } + + return vectors.AsReadOnly(); + } + + /// + public void Dispose() + { + if (_disposed) return; + _disposed = true; + if (_ownsGenerator) _generator.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/src/Delibera.Core/Providers/LLM/OllamaProvider.cs b/src/Delibera.Core/Providers/LLM/OllamaProvider.cs index b9489cd..726dde9 100644 --- a/src/Delibera.Core/Providers/LLM/OllamaProvider.cs +++ b/src/Delibera.Core/Providers/LLM/OllamaProvider.cs @@ -41,6 +41,21 @@ public OllamaProvider(string endpoint, string apiKey = "") /// public string ProviderName => "Ollama"; + /// + /// Exposes the underlying OllamaSharp client as a Microsoft.Extensions.AI . + /// + /// + /// natively implements , so this lets the + /// Ollama provider plug into the standard Microsoft.Extensions.AI middleware pipeline + /// (function invocation, caching, telemetry). + /// + public Microsoft.Extensions.AI.IChatClient AsChatClient() => Client; + + /// + /// Exposes the underlying OllamaSharp client as a Microsoft.Extensions.AI embedding generator. + /// + public Microsoft.Extensions.AI.IEmbeddingGenerator> AsEmbeddingGenerator() => Client; + /// public async Task IsAvailableAsync(CancellationToken ct = default) { diff --git a/src/Delibera.Core/Providers/ProviderFactory.cs b/src/Delibera.Core/Providers/ProviderFactory.cs index e88bc5c..28a1455 100644 --- a/src/Delibera.Core/Providers/ProviderFactory.cs +++ b/src/Delibera.Core/Providers/ProviderFactory.cs @@ -1,4 +1,5 @@ using Delibera.Core.Providers.LLM; +using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; namespace Delibera.Core.Providers; @@ -126,6 +127,35 @@ public OllamaProvider CreateOllama(string endpoint, string apiKey = "") return provider; } + /// + /// Creates (or returns a cached) from any Microsoft.Extensions.AI + /// . + /// + /// + /// This is the entry point for plugging arbitrary Microsoft.Extensions.AI backends — OpenAI, Azure + /// OpenAI, Anthropic, local OpenAI-compatible servers — into a Delibera council through the standard + /// .NET AI abstractions. + /// + /// Unique instance name used for caching. + /// The chat client to wrap. + /// Optional friendly provider name (defaults to client metadata). + /// Whether disposing the factory also disposes the client. + public ChatClientLLMProvider CreateFromChatClient( + string name, + IChatClient chatClient, + string? providerName = null, + bool ownsClient = true) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(chatClient); + + if (GetInstance(name) is ChatClientLLMProvider existing) return existing; + + var provider = new ChatClientLLMProvider(chatClient, providerName, ownsClient); + CacheInstance(name, provider); + return provider; + } + /// protected override void DisposeInstances() { diff --git a/src/Delibera.Core/README.md b/src/Delibera.Core/README.md index eb4f5f2..b515425 100644 --- a/src/Delibera.Core/README.md +++ b/src/Delibera.Core/README.md @@ -33,6 +33,7 @@ backed by **Qdrant** or **PostgreSQL/pgvector** (RAG), with **context compressio - 💉 **Dependency Injection** — `AddDelibera()` extension for `IServiceCollection` - 📋 **Execution Logging** — `ExecutionLog` with `LogLevel` for Chairman, KK, Compression & participants - 📁 **Separate File Output** — export `result.md`, `statistics.md`, `logs.md` independently +- 🤝 **Microsoft.Extensions.AI** — first-class `IChatClient` / `IEmbeddingGenerator` interop with logging & function-invocation middleware - 🔌 **Interface-First** — clean abstractions for providers, factories, builders and executors - 🧱 **Modern C# 12** — file-scoped namespaces, records, init-only properties, global usings @@ -341,6 +342,7 @@ in `Providers:ApiKey`. | `Qdrant.Client` | Qdrant vector DB gRPC client | | `Npgsql` / `Pgvector` | PostgreSQL/pgvector support | | `ModelContextProtocol` | MCP client for the Operator role | +| `Microsoft.Extensions.AI`| `IChatClient` / `IEmbeddingGenerator` abstractions & middleware | | `Microsoft.Extensions.*` | Configuration, DI and Options | --- diff --git a/tests/Delibera.Core.Tests/ChatClientLLMProviderTests.cs b/tests/Delibera.Core.Tests/ChatClientLLMProviderTests.cs new file mode 100644 index 0000000..98a34c3 --- /dev/null +++ b/tests/Delibera.Core.Tests/ChatClientLLMProviderTests.cs @@ -0,0 +1,119 @@ +using Delibera.Core.Providers.LLM; +using Delibera.Core.Tests.Fakes; +using Microsoft.Extensions.AI; + +namespace Delibera.Core.Tests; + +public class ChatClientLLMProviderTests +{ + [Fact] + public void ProviderName_TakenFromClientMetadata_WhenNotSpecified() + { + using var client = new FakeChatClient(providerName: "Contoso"); + using var provider = new ChatClientLLMProvider(client); + + Assert.Equal("Contoso", provider.ProviderName); + } + + [Fact] + public void ProviderName_ExplicitOverridesMetadata() + { + using var client = new FakeChatClient(providerName: "Contoso"); + using var provider = new ChatClientLLMProvider(client, "MyName"); + + Assert.Equal("MyName", provider.ProviderName); + } + + [Fact] + public async Task ChatAsync_ReturnsClientText_AndForwardsModelAndTemperature() + { + var client = new FakeChatClient(reply: "hello world"); + using var provider = new ChatClientLLMProvider(client); + + var answer = await provider.ChatAsync("gpt-test", "system", "user", temperature: 0.42f); + + Assert.Equal("hello world", answer); + Assert.Equal("gpt-test", client.LastOptions?.ModelId); + Assert.Equal(0.42f, client.LastOptions?.Temperature); + } + + [Fact] + public async Task ChatAsync_PutsSystemAndUserMessagesInOrder() + { + var client = new FakeChatClient(); + using var provider = new ChatClientLLMProvider(client); + + await provider.ChatAsync("m", "the-system", "the-user"); + + Assert.NotNull(client.LastMessages); + Assert.Equal(2, client.LastMessages!.Count); + Assert.Equal(ChatRole.System, client.LastMessages[0].Role); + Assert.Equal("the-system", client.LastMessages[0].Text); + Assert.Equal(ChatRole.User, client.LastMessages[1].Role); + Assert.Equal("the-user", client.LastMessages[1].Text); + } + + [Fact] + public async Task ChatAsync_OmitsSystemMessage_WhenSystemPromptEmpty() + { + var client = new FakeChatClient(); + using var provider = new ChatClientLLMProvider(client); + + await provider.ChatAsync("m", "", "only-user"); + + Assert.Single(client.LastMessages!); + Assert.Equal(ChatRole.User, client.LastMessages![0].Role); + } + + [Fact] + public async Task ChatStreamAsync_YieldsChunks_ThatConcatenateToFullReply() + { + var client = new FakeChatClient(reply: "alpha beta gamma"); + using var provider = new ChatClientLLMProvider(client); + + var chunks = new List(); + await foreach (var chunk in provider.ChatStreamAsync("m", "s", "u")) + chunks.Add(chunk); + + Assert.True(chunks.Count > 1, "Streaming should produce multiple chunks."); + Assert.Equal("alpha beta gamma", string.Concat(chunks).Trim()); + } + + [Fact] + public async Task ChatAsync_Throws_OnEmptyReply() + { + var client = new FakeChatClient(reply: " "); + using var provider = new ChatClientLLMProvider(client); + + await Assert.ThrowsAsync(() => provider.ChatAsync("m", "s", "u")); + } + + [Fact] + public void Dispose_DisposesClient_WhenOwned() + { + var client = new FakeChatClient(); + var provider = new ChatClientLLMProvider(client, ownsClient: true); + provider.Dispose(); + Assert.True(client.Disposed); + } + + [Fact] + public void Dispose_LeavesClient_WhenNotOwned() + { + var client = new FakeChatClient(); + var provider = new ChatClientLLMProvider(client, ownsClient: false); + provider.Dispose(); + Assert.False(client.Disposed); + } + + [Fact] + public async Task ListModelsAsync_ReturnsDefaultModel() + { + using var client = new FakeChatClient(defaultModel: "default-x"); + using var provider = new ChatClientLLMProvider(client); + + var models = await provider.ListModelsAsync(); + + Assert.Contains("default-x", models); + } +} diff --git a/tests/Delibera.Core.Tests/Delibera.Core.Tests.csproj b/tests/Delibera.Core.Tests/Delibera.Core.Tests.csproj new file mode 100644 index 0000000..edddd78 --- /dev/null +++ b/tests/Delibera.Core.Tests/Delibera.Core.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Delibera.Core.Tests/EmbeddingAndBridgeTests.cs b/tests/Delibera.Core.Tests/EmbeddingAndBridgeTests.cs new file mode 100644 index 0000000..8f5305d --- /dev/null +++ b/tests/Delibera.Core.Tests/EmbeddingAndBridgeTests.cs @@ -0,0 +1,142 @@ +using Delibera.Core.Extensions; +using Delibera.Core.Interfaces; +using Delibera.Core.Providers; +using Delibera.Core.Providers.LLM; +using Delibera.Core.Tests.Fakes; +using Microsoft.Extensions.AI; + +namespace Delibera.Core.Tests; + +public class EmbeddingGeneratorProviderTests +{ + [Fact] + public void VectorSize_ReadFromMetadata() + { + using var gen = new FakeEmbeddingGenerator(dimensions: 8); + using var provider = new EmbeddingGeneratorProvider(gen); + + Assert.Equal(8, provider.VectorSize); + Assert.Equal("fake-embed", provider.EmbeddingModelName); + } + + [Fact] + public async Task EmbedAsync_ReturnsVectorOfCorrectSize() + { + using var gen = new FakeEmbeddingGenerator(dimensions: 5); + using var provider = new EmbeddingGeneratorProvider(gen); + + var vector = await provider.EmbedAsync("hello"); + + Assert.Equal(5, vector.Length); + } + + [Fact] + public async Task EmbedBatchAsync_ReturnsOneVectorPerInput() + { + using var gen = new FakeEmbeddingGenerator(dimensions: 3); + using var provider = new EmbeddingGeneratorProvider(gen); + + var vectors = await provider.EmbedBatchAsync(["a", "bb", "ccc"]); + + Assert.Equal(3, vectors.Count); + Assert.All(vectors, v => Assert.Equal(3, v.Length)); + } + + [Fact] + public async Task EmbedBatchAsync_EmptyInput_ReturnsEmpty() + { + using var gen = new FakeEmbeddingGenerator(); + using var provider = new EmbeddingGeneratorProvider(gen); + + var vectors = await provider.EmbedBatchAsync([]); + + Assert.Empty(vectors); + } +} + +public class MicrosoftAIExtensionsTests +{ + [Fact] + public void AsLLMProvider_WrapsChatClient() + { + using var client = new FakeChatClient(providerName: "Z"); + using var provider = client.AsLLMProvider(); + + Assert.IsType(provider); + Assert.Equal("Z", provider.ProviderName); + } + + [Fact] + public void AsChatClient_OnChatClientProvider_ReturnsUnderlyingClient() + { + using var client = new FakeChatClient(); + using var provider = new ChatClientLLMProvider(client); + + var roundTrip = provider.AsChatClient(); + + Assert.Same(client, roundTrip); + } + + [Fact] + public async Task AsChatClient_OnArbitraryProvider_BridgesCalls() + { + // Use a custom provider that is NOT a ChatClientLLMProvider to force the adapter path. + ILLMProvider custom = new EchoProvider(); + var chatClient = custom.AsChatClient("echo-model"); + + var response = await chatClient.GetResponseAsync([new ChatMessage(ChatRole.User, "ping")]); + + Assert.Equal("echo:ping", response.Text); + Assert.Equal("echo-model", response.ModelId); + } + + [Fact] + public async Task AsChatClient_Streaming_BridgesChunks() + { + ILLMProvider custom = new EchoProvider(); + var chatClient = custom.AsChatClient("echo-model"); + + var text = ""; + await foreach (var update in chatClient.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "ping")])) + text += update.Text; + + Assert.Equal("echo:ping", text); + } + + /// Minimal provider that echoes the user prompt, to exercise the bridge adapter. + private sealed class EchoProvider : ILLMProvider + { + public string ProviderName => "Echo"; + public Task IsAvailableAsync(CancellationToken ct = default) => Task.FromResult(true); + public Task> ListModelsAsync(CancellationToken ct = default) => Task.FromResult>([]); + public Task ChatAsync(string model, string systemPrompt, string userPrompt, float temperature = 0.7f, CancellationToken ct = default) + => Task.FromResult($"echo:{userPrompt}"); + public void Dispose() { } + } +} + +public class ProviderFactoryChatClientTests +{ + [Fact] + public void CreateFromChatClient_CachesByName() + { + using var factory = new ProviderFactory(); + using var client = new FakeChatClient(); + + var a = factory.CreateFromChatClient("x", client); + var b = factory.CreateFromChatClient("x", client); + + Assert.Same(a, b); + } + + [Fact] + public void CreateFromChatClient_ReturnsChatClientProvider() + { + using var factory = new ProviderFactory(); + using var client = new FakeChatClient(providerName: "P"); + + var provider = factory.CreateFromChatClient("y", client, "Custom"); + + Assert.Equal("Custom", provider.ProviderName); + } +} diff --git a/tests/Delibera.Core.Tests/Fakes/FakeChatClient.cs b/tests/Delibera.Core.Tests/Fakes/FakeChatClient.cs new file mode 100644 index 0000000..7aaddb6 --- /dev/null +++ b/tests/Delibera.Core.Tests/Fakes/FakeChatClient.cs @@ -0,0 +1,55 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace Delibera.Core.Tests.Fakes; + +/// +/// In-memory used to test the Microsoft.Extensions.AI integration +/// without any network access. +/// +public sealed class FakeChatClient(string reply = "fake-reply", string providerName = "Fake", string? defaultModel = "fake-model") + : IChatClient +{ + private readonly ChatClientMetadata _metadata = new(providerName, defaultModelId: defaultModel); + + public bool Disposed { get; private set; } + + /// Records the options of the most recent request for assertions. + public ChatOptions? LastOptions { get; private set; } + + /// Records the messages of the most recent request for assertions. + public IList? LastMessages { get; private set; } + + public Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + LastMessages = messages.ToList(); + LastOptions = options; + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, reply))); + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + LastMessages = messages.ToList(); + LastOptions = options; + // Emit one update per word to simulate token streaming. + foreach (var word in reply.Split(' ')) + { + await Task.Yield(); + yield return new ChatResponseUpdate(ChatRole.Assistant, word + " "); + } + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + ArgumentNullException.ThrowIfNull(serviceType); + return serviceKey is null && serviceType.IsInstanceOfType(_metadata) ? _metadata : null; + } + + public void Dispose() => Disposed = true; +} diff --git a/tests/Delibera.Core.Tests/Fakes/FakeEmbeddingGenerator.cs b/tests/Delibera.Core.Tests/Fakes/FakeEmbeddingGenerator.cs new file mode 100644 index 0000000..7fe6b70 --- /dev/null +++ b/tests/Delibera.Core.Tests/Fakes/FakeEmbeddingGenerator.cs @@ -0,0 +1,38 @@ +using Microsoft.Extensions.AI; + +namespace Delibera.Core.Tests.Fakes; + +/// +/// In-memory producing deterministic vectors. +/// +public sealed class FakeEmbeddingGenerator(int dimensions = 4, string modelName = "fake-embed") : IEmbeddingGenerator> +{ + private readonly EmbeddingGeneratorMetadata _metadata = new("Fake", defaultModelId: modelName, defaultModelDimensions: dimensions); + + public bool Disposed { get; private set; } + + public Task>> GenerateAsync( + IEnumerable values, + EmbeddingGenerationOptions? options = null, + CancellationToken cancellationToken = default) + { + var embeddings = new GeneratedEmbeddings>(); + foreach (var value in values) + { + var vector = new float[dimensions]; + for (var i = 0; i < dimensions; i++) + vector[i] = (value.Length + i) % 7 / 7f; + embeddings.Add(new Embedding(vector)); + } + + return Task.FromResult(embeddings); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + ArgumentNullException.ThrowIfNull(serviceType); + return serviceKey is null && serviceType.IsInstanceOfType(_metadata) ? _metadata : null; + } + + public void Dispose() => Disposed = true; +} From 749df13f5f84ce128be8b548338b328a062a5ff6 Mon Sep 17 00:00:00 2001 From: Victor Buzin Date: Thu, 18 Jun 2026 18:31:16 +0200 Subject: [PATCH 03/14] Refactor: expand expression-bodied members to block bodies Refactored expression-bodied members to full block-bodied methods for improved consistency and maintainability. Updated method/property implementations, reordered some fields, and enhanced property clarity. Restored or moved obsolete/compatibility methods. Improved disposal patterns in factories and clarified null/empty handling. No changes to core business logic; focus is on code style and API consistency. --- .../Compression/CompressedContextFactory.cs | 16 ++-- .../Compression/CompressionCache.cs | 7 +- .../Compression/CompressionFactory.cs | 33 +++++---- .../Compression/CompressionService.cs | 14 ++-- .../Compression/DeduplicationCompressor.cs | 9 ++- .../Compression/HybridCompressor.cs | 2 +- .../Compression/SemanticCompressor.cs | 2 +- .../Compression/SummarizationCompressor.cs | 2 +- src/Delibera.Core/Compression/TokenCounter.cs | 7 +- src/Delibera.Core/Council/Chairman.cs | 46 ++++++++---- src/Delibera.Core/Council/CouncilBuilder.cs | 74 +++++++++++-------- src/Delibera.Core/Council/CouncilExecutor.cs | 13 +++- src/Delibera.Core/Council/KnowledgeKeeper.cs | 24 +++--- src/Delibera.Core/Council/Operator.cs | 69 ++++++++++------- src/Delibera.Core/Debate/ConsensusDebate.cs | 2 +- src/Delibera.Core/Debate/CritiqueDebate.cs | 2 +- .../Debate/DebateResultBuilder.cs | 53 ++++++++----- src/Delibera.Core/Debate/DebateScenario.cs | 45 ++++++----- src/Delibera.Core/Debate/StandardDebate.cs | 2 +- .../DependencyInjection/CouncilOptions.cs | 16 ++-- .../ServiceCollectionExtensions.cs | 7 +- .../Extensions/MicrosoftAIExtensions.cs | 39 +++++++--- .../Interfaces/IContextCompressor.cs | 2 +- .../Interfaces/ICouncilBuilder.cs | 18 ++++- src/Delibera.Core/Interfaces/ILLMProvider.cs | 4 +- src/Delibera.Core/Interfaces/IMcpClient.cs | 2 +- src/Delibera.Core/Interfaces/IOperator.cs | 2 +- src/Delibera.Core/Interfaces/IVectorStore.cs | 2 +- .../Knowledge/MarkdownKnowledgeBase.cs | 7 +- src/Delibera.Core/Models/CompressionLog.cs | 2 +- src/Delibera.Core/Models/CouncilMember.cs | 21 +++--- src/Delibera.Core/Models/DebateResult.cs | 19 +++-- src/Delibera.Core/Models/DebateRound.cs | 2 +- src/Delibera.Core/Models/ExecutionLog.cs | 27 +++++-- src/Delibera.Core/Models/OperatorModels.cs | 20 +++-- src/Delibera.Core/Models/PromptContext.cs | 12 +-- src/Delibera.Core/Models/TokenStatistics.cs | 23 +++--- .../Providers/LLM/ChatClientLLMProvider.cs | 52 +++++++------ .../LLM/EmbeddingGeneratorProvider.cs | 39 +++++----- .../Providers/LLM/OllamaEmbeddingProvider.cs | 2 +- .../Providers/LLM/OllamaProvider.cs | 42 +++++++---- .../Providers/Mcp/McpClientAdapter.cs | 8 +- .../Providers/ProviderFactory.cs | 56 +++++++++----- .../Providers/RAG/BaseRagProvider.cs | 25 ++++--- .../Providers/RAG/PgVectorRagProvider.cs | 2 +- .../Providers/RAG/PgVectorStore.cs | 14 +++- .../Providers/RAG/QdrantRagProvider.cs | 2 +- .../Providers/RAG/QdrantVectorStore.cs | 20 +++-- .../Providers/RAG/RagProviderFactory.cs | 33 +++++---- .../Providers/RAG/TextChunker.cs | 2 +- 50 files changed, 584 insertions(+), 360 deletions(-) diff --git a/src/Delibera.Core/Compression/CompressedContextFactory.cs b/src/Delibera.Core/Compression/CompressedContextFactory.cs index 702b85b..fb79287 100644 --- a/src/Delibera.Core/Compression/CompressedContextFactory.cs +++ b/src/Delibera.Core/Compression/CompressedContextFactory.cs @@ -1,5 +1,3 @@ -using System.Diagnostics; - namespace Delibera.Core.Compression; /// @@ -12,8 +10,9 @@ internal static class CompressedContextFactory /// /// Builds a result describing text that was passed through unchanged. /// - public static CompressedContext PassThrough(string text, int tokens, string strategyName, TimeSpan duration) => - new() + public static CompressedContext PassThrough(string text, int tokens, string strategyName, TimeSpan duration) + { + return new CompressedContext { Text = text, OriginalLength = text.Length, @@ -23,6 +22,7 @@ public static CompressedContext PassThrough(string text, int tokens, string stra StrategyUsed = strategyName, Duration = duration }; + } /// /// Builds a result describing text that was actually compressed. @@ -33,8 +33,9 @@ public static CompressedContext Compressed( int originalTokens, int compressedTokens, string strategyName, - TimeSpan duration) => - new() + TimeSpan duration) + { + return new CompressedContext { Text = compressed, OriginalLength = original.Length, @@ -44,4 +45,5 @@ public static CompressedContext Compressed( StrategyUsed = strategyName, Duration = duration }; -} + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Compression/CompressionCache.cs b/src/Delibera.Core/Compression/CompressionCache.cs index a619f81..52e112b 100644 --- a/src/Delibera.Core/Compression/CompressionCache.cs +++ b/src/Delibera.Core/Compression/CompressionCache.cs @@ -105,7 +105,10 @@ public void Clear() /// /// Returns a formatted summary of cache performance. /// - public string GetSummary() => $"Cache: {Count} entries, {HitCount} hits / {MissCount} misses ({HitRate:P1} hit rate)"; + public string GetSummary() + { + return $"Cache: {Count} entries, {HitCount} hits / {MissCount} misses ({HitRate:P1} hit rate)"; + } // ────────────────────────────────────────────── @@ -145,4 +148,4 @@ private sealed class CacheEntry public required CompressedContext Context { get; init; } public DateTime LastAccessed { get; set; } } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Compression/CompressionFactory.cs b/src/Delibera.Core/Compression/CompressionFactory.cs index 2e61113..c7773d8 100644 --- a/src/Delibera.Core/Compression/CompressionFactory.cs +++ b/src/Delibera.Core/Compression/CompressionFactory.cs @@ -24,18 +24,21 @@ public static IContextCompressor Create( CompressionStrategy strategy, ILLMProvider? llmProvider = null, string? modelName = null, - IEmbeddingProvider? embeddingProvider = null) => strategy switch + IEmbeddingProvider? embeddingProvider = null) { - CompressionStrategy.None => PassThroughCompressor.Instance, - CompressionStrategy.Semantic => new SemanticCompressor( - embeddingProvider ?? throw new ArgumentException("Semantic compression requires an IEmbeddingProvider.", nameof(embeddingProvider))), - CompressionStrategy.Deduplication => new DeduplicationCompressor(embeddingProvider), - CompressionStrategy.Summarization => new SummarizationCompressor( - llmProvider ?? throw new ArgumentException("Summarization compression requires an ILLMProvider.", nameof(llmProvider)), - modelName ?? throw new ArgumentException("Summarization compression requires a modelName.", nameof(modelName))), - CompressionStrategy.Hybrid => new HybridCompressor(llmProvider, modelName, embeddingProvider), - _ => throw new ArgumentOutOfRangeException(nameof(strategy), $"Unknown compression strategy: {strategy}") - }; + return strategy switch + { + CompressionStrategy.None => PassThroughCompressor.Instance, + CompressionStrategy.Semantic => new SemanticCompressor( + embeddingProvider ?? throw new ArgumentException("Semantic compression requires an IEmbeddingProvider.", nameof(embeddingProvider))), + CompressionStrategy.Deduplication => new DeduplicationCompressor(embeddingProvider), + CompressionStrategy.Summarization => new SummarizationCompressor( + llmProvider ?? throw new ArgumentException("Summarization compression requires an ILLMProvider.", nameof(llmProvider)), + modelName ?? throw new ArgumentException("Summarization compression requires a modelName.", nameof(modelName))), + CompressionStrategy.Hybrid => new HybridCompressor(llmProvider, modelName, embeddingProvider), + _ => throw new ArgumentOutOfRangeException(nameof(strategy), $"Unknown compression strategy: {strategy}") + }; + } /// /// Creates a compressor from a strategy name string (case-insensitive). @@ -55,12 +58,14 @@ public static IContextCompressor Create( /// /// No-op compressor — passes text through unchanged. Used when compression is disabled. -/// +/// internal sealed class PassThroughCompressor : IContextCompressor { public static readonly PassThroughCompressor Instance = new(); - private PassThroughCompressor() { } + private PassThroughCompressor() + { + } /// public string StrategyName => "None"; @@ -82,4 +87,4 @@ public Task CompressBatchAsync(IReadOnlyList texts, C var merged = string.Join("\n\n", texts); return CompressAsync(merged, options, ct); } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Compression/CompressionService.cs b/src/Delibera.Core/Compression/CompressionService.cs index 873fc34..f68d2d9 100644 --- a/src/Delibera.Core/Compression/CompressionService.cs +++ b/src/Delibera.Core/Compression/CompressionService.cs @@ -15,14 +15,18 @@ public IContextCompressor Create( CompressionStrategy strategy, ILLMProvider? llmProvider = null, string? modelName = null, - IEmbeddingProvider? embeddingProvider = null) => - CompressionFactory.Create(strategy, llmProvider, modelName, embeddingProvider); + IEmbeddingProvider? embeddingProvider = null) + { + return CompressionFactory.Create(strategy, llmProvider, modelName, embeddingProvider); + } /// public IContextCompressor Create( string strategyName, ILLMProvider? llmProvider = null, string? modelName = null, - IEmbeddingProvider? embeddingProvider = null) => - CompressionFactory.Create(strategyName, llmProvider, modelName, embeddingProvider); -} + IEmbeddingProvider? embeddingProvider = null) + { + return CompressionFactory.Create(strategyName, llmProvider, modelName, embeddingProvider); + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Compression/DeduplicationCompressor.cs b/src/Delibera.Core/Compression/DeduplicationCompressor.cs index 84c3bdd..bf56bb0 100644 --- a/src/Delibera.Core/Compression/DeduplicationCompressor.cs +++ b/src/Delibera.Core/Compression/DeduplicationCompressor.cs @@ -35,7 +35,7 @@ public async Task CompressAsync(string text, CompressionOptio if (sentences.Count <= 1) return CompressedContextFactory.PassThrough(text, originalTokens, StrategyName, sw.Elapsed); - List unique = _embeddingProvider is not null + var unique = _embeddingProvider is not null ? await DeduplicateWithEmbeddingsAsync(sentences, options.DeduplicationThreshold, ct) : DeduplicateWithHeuristics(sentences, options.DeduplicationThreshold); @@ -140,5 +140,8 @@ private static double ComputeTextSimilarity(string a, string b) : 0; } - private static string NormalizeText(string text) => text.Trim().ToLowerInvariant(); -} + private static string NormalizeText(string text) + { + return text.Trim().ToLowerInvariant(); + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Compression/HybridCompressor.cs b/src/Delibera.Core/Compression/HybridCompressor.cs index 641b08a..d49b8de 100644 --- a/src/Delibera.Core/Compression/HybridCompressor.cs +++ b/src/Delibera.Core/Compression/HybridCompressor.cs @@ -109,4 +109,4 @@ public async Task CompressBatchAsync(IReadOnlyList te var merged = string.Join("\n\n", texts); return await CompressAsync(merged, options, ct); } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Compression/SemanticCompressor.cs b/src/Delibera.Core/Compression/SemanticCompressor.cs index cbbc3fb..082c66a 100644 --- a/src/Delibera.Core/Compression/SemanticCompressor.cs +++ b/src/Delibera.Core/Compression/SemanticCompressor.cs @@ -201,4 +201,4 @@ internal static double CosineSimilarity(ReadOnlySpan a, ReadOnlySpanSentence with computed relevance score. private readonly record struct ScoredSentence(int Index, double Score, string Text); -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Compression/SummarizationCompressor.cs b/src/Delibera.Core/Compression/SummarizationCompressor.cs index 3575b12..cd8e4fe 100644 --- a/src/Delibera.Core/Compression/SummarizationCompressor.cs +++ b/src/Delibera.Core/Compression/SummarizationCompressor.cs @@ -114,4 +114,4 @@ 5. Output ONLY the compressed text sw.Stop(); return CompressedContextFactory.Compressed(merged, summary, originalTokens, compressedTokens, StrategyName, sw.Elapsed); } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Compression/TokenCounter.cs b/src/Delibera.Core/Compression/TokenCounter.cs index 6f7a1dc..b31eb64 100644 --- a/src/Delibera.Core/Compression/TokenCounter.cs +++ b/src/Delibera.Core/Compression/TokenCounter.cs @@ -82,7 +82,10 @@ public int EstimateTokens(IEnumerable texts) /// /// Returns true if the text exceeds the specified token limit. /// - public bool ExceedsLimit(string text, int tokenLimit) => EstimateTokens(text) > tokenLimit; + public bool ExceedsLimit(string text, int tokenLimit) + { + return EstimateTokens(text) > tokenLimit; + } /// /// Truncates text to approximately fit within the specified token limit. @@ -125,4 +128,4 @@ private static int CountWords(ReadOnlySpan text) return count; } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Council/Chairman.cs b/src/Delibera.Core/Council/Chairman.cs index d7e49f8..41432a7 100644 --- a/src/Delibera.Core/Council/Chairman.cs +++ b/src/Delibera.Core/Council/Chairman.cs @@ -12,36 +12,44 @@ public static class Chairman // ────────────────────────────────────────────── /// Creates a standard, neutral Chairman. - public static CouncilMember CreateStandard(string modelName, ILLMProvider provider) => - new(modelName, provider, "Chairman", + public static CouncilMember CreateStandard(string modelName, ILLMProvider provider) + { + return new CouncilMember(modelName, provider, "Chairman", """ You are the Chairman of an AI Council debate — neutral, objective and fair. You organise the debate process, ensure every participant is heard, synthesise diverse viewpoints and produce clear, actionable verdicts. Prioritise accuracy, completeness and practical usefulness. """); + } /// Creates a strict, evidence-focused Chairman. - public static CouncilMember CreateStrict(string modelName, ILLMProvider provider) => - new(modelName, provider, "Strict Chairman", + public static CouncilMember CreateStrict(string modelName, ILLMProvider provider) + { + return new CouncilMember(modelName, provider, "Strict Chairman", """ You are a strict, evidence-focused Chairman. You demand factual accuracy, reject unsupported claims and penalise logical fallacies. If no participant provides a satisfactory answer, say so clearly. """); + } /// Creates a creative Chairman who encourages novel thinking. - public static CouncilMember CreateCreative(string modelName, ILLMProvider provider) => - new(modelName, provider, "Creative Chairman", + public static CouncilMember CreateCreative(string modelName, ILLMProvider provider) + { + return new CouncilMember(modelName, provider, "Creative Chairman", """ You are a creative Chairman who looks beyond conventional answers. While maintaining accuracy, you seek innovative connections between ideas, encourage thinking outside the box, and surface unexpected insights. """); + } /// Creates a Chairman with a custom persona prompt. - public static CouncilMember CreateCustom(string modelName, ILLMProvider provider, string personaPrompt) => - new(modelName, provider, "Chairman", personaPrompt); + public static CouncilMember CreateCustom(string modelName, ILLMProvider provider, string personaPrompt) + { + return new CouncilMember(modelName, provider, "Chairman", personaPrompt); + } // ────────────────────────────────────────────── // Specialised Chairman actions (each has its own prompt) @@ -278,14 +286,26 @@ public static async Task SynthesizeVerdictAsync( public static class Moderator { /// - public static CouncilMember CreateStandard(string modelName, ILLMProvider provider) => Chairman.CreateStandard(modelName, provider); + public static CouncilMember CreateStandard(string modelName, ILLMProvider provider) + { + return Chairman.CreateStandard(modelName, provider); + } /// - public static CouncilMember CreateStrict(string modelName, ILLMProvider provider) => Chairman.CreateStrict(modelName, provider); + public static CouncilMember CreateStrict(string modelName, ILLMProvider provider) + { + return Chairman.CreateStrict(modelName, provider); + } /// - public static CouncilMember CreateCreative(string modelName, ILLMProvider provider) => Chairman.CreateCreative(modelName, provider); + public static CouncilMember CreateCreative(string modelName, ILLMProvider provider) + { + return Chairman.CreateCreative(modelName, provider); + } /// - public static CouncilMember CreateCustom(string modelName, ILLMProvider provider, string personaPrompt) => Chairman.CreateCustom(modelName, provider, personaPrompt); -} + public static CouncilMember CreateCustom(string modelName, ILLMProvider provider, string personaPrompt) + { + return Chairman.CreateCustom(modelName, provider, personaPrompt); + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Council/CouncilBuilder.cs b/src/Delibera.Core/Council/CouncilBuilder.cs index 37fa7bc..aefb2ce 100644 --- a/src/Delibera.Core/Council/CouncilBuilder.cs +++ b/src/Delibera.Core/Council/CouncilBuilder.cs @@ -16,11 +16,11 @@ public sealed class CouncilBuilder : ICouncilBuilder private IContextCompressor? _compressor; private IKnowledgeBase? _knowledgeBase; private KnowledgeKeeper? _knowledgeKeeper; + private int _maxRounds = 4; private Operator? _operator; - private bool _operatorReuseCompression; private CouncilMember? _operatorModel; + private bool _operatorReuseCompression; private IReadOnlyList? _operatorServers; - private int _maxRounds = 4; private string? _outputPath; private IDebateStrategy _strategy = new StandardDebate(); private string _systemPrompt = "You are a helpful AI assistant participating in a council debate."; @@ -62,15 +62,6 @@ public ICouncilBuilder SetChairman(string modelName, ILLMProvider provider, stri return this; } - /// Backward-compatible alias for . - [Obsolete("Use SetChairman instead.")] - public ICouncilBuilder SetModerator(CouncilMember moderator) => SetChairman(moderator); - - /// Backward-compatible alias for . - [Obsolete("Use SetChairman instead.")] - public ICouncilBuilder SetModerator(string modelName, ILLMProvider provider, string? persona = null) - => SetChairman(modelName, provider, persona); - // ── Knowledge Keeper ── /// @@ -80,20 +71,6 @@ public ICouncilBuilder WithKnowledgeKeeper(KnowledgeKeeper knowledgeKeeper) return this; } - /// Creates and attaches a Knowledge Keeper from a RAG provider, model and collection. - public ICouncilBuilder WithKnowledgeKeeper( - IRagProvider ragProvider, - string modelName, - ILLMProvider llmProvider, - string collectionName = "council_knowledge") - { - ArgumentNullException.ThrowIfNull(ragProvider); - ArgumentNullException.ThrowIfNull(llmProvider); - var member = new CouncilMember(modelName, llmProvider, "Knowledge Keeper"); - _knowledgeKeeper = new KnowledgeKeeper(ragProvider, member, collectionName); - return this; - } - // ── Operator (MCP tool micro-agent) ── /// @@ -217,6 +194,40 @@ public ICouncilBuilder SaveResultTo(string outputPath) return this; } + /// + ICouncilExecutor ICouncilBuilder.Build() + { + return Build(); + } + + /// Backward-compatible alias for . + [Obsolete("Use SetChairman instead.")] + public ICouncilBuilder SetModerator(CouncilMember moderator) + { + return SetChairman(moderator); + } + + /// Backward-compatible alias for . + [Obsolete("Use SetChairman instead.")] + public ICouncilBuilder SetModerator(string modelName, ILLMProvider provider, string? persona = null) + { + return SetChairman(modelName, provider, persona); + } + + /// Creates and attaches a Knowledge Keeper from a RAG provider, model and collection. + public ICouncilBuilder WithKnowledgeKeeper( + IRagProvider ragProvider, + string modelName, + ILLMProvider llmProvider, + string collectionName = "council_knowledge") + { + ArgumentNullException.ThrowIfNull(ragProvider); + ArgumentNullException.ThrowIfNull(llmProvider); + var member = new CouncilMember(modelName, llmProvider, "Knowledge Keeper"); + _knowledgeKeeper = new KnowledgeKeeper(ragProvider, member, collectionName); + return this; + } + // ── Build ── /// @@ -247,8 +258,12 @@ public CouncilExecutor Build() @operator = new Operator( _operatorModel, clients, - _operatorReuseCompression ? _compressor : null, - _operatorReuseCompression ? _compressionOptions : null); + _operatorReuseCompression + ? _compressor + : null, + _operatorReuseCompression + ? _compressionOptions + : null); } return new CouncilExecutor( @@ -265,7 +280,4 @@ public CouncilExecutor Build() _compressionCache, @operator); } - - /// - ICouncilExecutor ICouncilBuilder.Build() => Build(); -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Council/CouncilExecutor.cs b/src/Delibera.Core/Council/CouncilExecutor.cs index cc2823a..05e8240 100644 --- a/src/Delibera.Core/Council/CouncilExecutor.cs +++ b/src/Delibera.Core/Council/CouncilExecutor.cs @@ -250,8 +250,13 @@ public string GetInfo() return sb.ToString(); } - private void Log(ExecutionLog entry) => _executionLogs.Add(entry); + private void Log(ExecutionLog entry) + { + _executionLogs.Add(entry); + } - private static string Truncate(string text, int max) => - string.IsNullOrEmpty(text) ? "(empty)" : text.Length <= max ? text : text[..max] + "…"; -} + private static string Truncate(string text, int max) + { + return string.IsNullOrEmpty(text) ? "(empty)" : text.Length <= max ? text : text[..max] + "…"; + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Council/KnowledgeKeeper.cs b/src/Delibera.Core/Council/KnowledgeKeeper.cs index 23ac08c..280c886 100644 --- a/src/Delibera.Core/Council/KnowledgeKeeper.cs +++ b/src/Delibera.Core/Council/KnowledgeKeeper.cs @@ -7,9 +7,9 @@ namespace Delibera.Core.Council; /// public sealed class KnowledgeKeeper(IRagProvider ragProvider, CouncilMember model, string collectionName) { - private readonly IRagProvider _ragProvider = ragProvider ?? throw new ArgumentNullException(nameof(ragProvider)); - private readonly CouncilMember _model = model ?? throw new ArgumentNullException(nameof(model)); private readonly List _interactions = []; + private readonly CouncilMember _model = model ?? throw new ArgumentNullException(nameof(model)); + private readonly IRagProvider _ragProvider = ragProvider ?? throw new ArgumentNullException(nameof(ragProvider)); /// RAG collection used for searching. public string CollectionName { get; } = collectionName ?? throw new ArgumentNullException(nameof(collectionName)); @@ -28,8 +28,10 @@ public sealed class KnowledgeKeeper(IRagProvider ragProvider, CouncilMember mode /// Cancellation token. /// Scored search results from the vector store. public Task> SearchKnowledgeAsync( - string query, int limit = 5, CancellationToken ct = default) => - _ragProvider.SearchAsync(CollectionName, query, limit, ct: ct); + string query, int limit = 5, CancellationToken ct = default) + { + return _ragProvider.SearchAsync(CollectionName, query, limit, ct: ct); + } /// /// Generates an answer to a question using RAG context. @@ -221,8 +223,10 @@ public Task IndexDocumentAsync( Dictionary? metadata = null, int chunkSize = 500, int chunkOverlap = 50, - CancellationToken ct = default) => - _ragProvider.IndexDocumentAsync(CollectionName, documentText, metadata, chunkSize, chunkOverlap, ct); + CancellationToken ct = default) + { + return _ragProvider.IndexDocumentAsync(CollectionName, documentText, metadata, chunkSize, chunkOverlap, ct); + } /// /// Indexes a file into the Knowledge Keeper's collection. @@ -231,6 +235,8 @@ public Task IndexFileAsync( string filePath, int chunkSize = 500, int chunkOverlap = 50, - CancellationToken ct = default) => - _ragProvider.IndexFileAsync(CollectionName, filePath, chunkSize, chunkOverlap, ct); -} + CancellationToken ct = default) + { + return _ragProvider.IndexFileAsync(CollectionName, filePath, chunkSize, chunkOverlap, ct); + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Council/Operator.cs b/src/Delibera.Core/Council/Operator.cs index d9385d5..883f80e 100644 --- a/src/Delibera.Core/Council/Operator.cs +++ b/src/Delibera.Core/Council/Operator.cs @@ -101,7 +101,9 @@ public string GetToolCatalog() sb.AppendLine($"\n• Server \"{byServer.Key}\":"); foreach (var tool in byServer) { - var desc = string.IsNullOrWhiteSpace(tool.Description) ? "(no description)" : tool.Description.Trim(); + var desc = string.IsNullOrWhiteSpace(tool.Description) + ? "(no description)" + : tool.Description.Trim(); sb.AppendLine($" - {tool.QualifiedName}: {desc}"); } } @@ -167,6 +169,20 @@ public async Task ExecuteTaskAsync( return operatorResult; } + /// + public async ValueTask DisposeAsync() + { + foreach (var client in _mcpClients.Values) + try + { + await client.DisposeAsync(); + } + catch + { + // ignore disposal errors + } + } + // ────────────────────────────────────────────── // Micro-agent internals // ────────────────────────────────────────────── @@ -227,8 +243,12 @@ private static IReadOnlyList ParsePlan(string raw) var calls = new List(); foreach (var callEl in callsEl.EnumerateArray()) { - var server = callEl.TryGetProperty("server", out var s) ? s.GetString() : null; - var tool = callEl.TryGetProperty("tool", out var t) ? t.GetString() : null; + var server = callEl.TryGetProperty("server", out var s) + ? s.GetString() + : null; + var tool = callEl.TryGetProperty("tool", out var t) + ? t.GetString() + : null; if (string.IsNullOrWhiteSpace(server) || string.IsNullOrWhiteSpace(tool)) continue; @@ -323,32 +343,25 @@ private static string ExtractJson(string raw) var start = raw.IndexOf('{'); var end = raw.LastIndexOf('}'); - return start >= 0 && end > start ? raw[start..(end + 1)] : string.Empty; + return start >= 0 && end > start + ? raw[start..(end + 1)] + : string.Empty; } - private static object? JsonElementToObject(JsonElement el) => el.ValueKind switch + private static object? JsonElementToObject(JsonElement el) { - JsonValueKind.String => el.GetString(), - JsonValueKind.Number => el.TryGetInt64(out var l) ? l : el.GetDouble(), - JsonValueKind.True => true, - JsonValueKind.False => false, - JsonValueKind.Null => null, - JsonValueKind.Array => el.EnumerateArray().Select(JsonElementToObject).ToList(), - JsonValueKind.Object => el.EnumerateObject().ToDictionary(p => p.Name, p => JsonElementToObject(p.Value)), - _ => el.GetRawText() - }; - - /// - public async ValueTask DisposeAsync() - { - foreach (var client in _mcpClients.Values) - try - { - await client.DisposeAsync(); - } - catch - { - // ignore disposal errors - } + return el.ValueKind switch + { + JsonValueKind.String => el.GetString(), + JsonValueKind.Number => el.TryGetInt64(out var l) + ? l + : el.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + JsonValueKind.Array => el.EnumerateArray().Select(JsonElementToObject).ToList(), + JsonValueKind.Object => el.EnumerateObject().ToDictionary(p => p.Name, p => JsonElementToObject(p.Value)), + _ => el.GetRawText() + }; } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Debate/ConsensusDebate.cs b/src/Delibera.Core/Debate/ConsensusDebate.cs index 65e8069..7dbde2e 100644 --- a/src/Delibera.Core/Debate/ConsensusDebate.cs +++ b/src/Delibera.Core/Debate/ConsensusDebate.cs @@ -150,4 +150,4 @@ private static DebateResult BuildAndComplete(DebateResultBuilder builder) builder.MarkCompleted(); return builder.Build(); } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Debate/CritiqueDebate.cs b/src/Delibera.Core/Debate/CritiqueDebate.cs index 3f39690..335b463 100644 --- a/src/Delibera.Core/Debate/CritiqueDebate.cs +++ b/src/Delibera.Core/Debate/CritiqueDebate.cs @@ -150,4 +150,4 @@ private static DebateResult BuildAndComplete(DebateResultBuilder builder) builder.MarkCompleted(); return builder.Build(); } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Debate/DebateResultBuilder.cs b/src/Delibera.Core/Debate/DebateResultBuilder.cs index cf518ec..c50cc68 100644 --- a/src/Delibera.Core/Debate/DebateResultBuilder.cs +++ b/src/Delibera.Core/Debate/DebateResultBuilder.cs @@ -16,32 +16,47 @@ internal sealed class DebateResultBuilder( Operator? @operator = null) { private readonly List _rounds = []; - private string? _openingStatement; - private string? _finalVerdict; private DateTime? _completedAt; + private string? _finalVerdict; + private string? _openingStatement; public string StrategyName => strategy.StrategyName; public IReadOnlyList Rounds => _rounds; - public void SetOpeningStatement(string? statement) => _openingStatement = statement; + public void SetOpeningStatement(string? statement) + { + _openingStatement = statement; + } - public void AddRound(DebateRound round) => _rounds.Add(round); + public void AddRound(DebateRound round) + { + _rounds.Add(round); + } - public void SetFinalVerdict(string? verdict) => _finalVerdict = verdict; + public void SetFinalVerdict(string? verdict) + { + _finalVerdict = verdict; + } - public void MarkCompleted() => _completedAt = DateTime.UtcNow; + public void MarkCompleted() + { + _completedAt = DateTime.UtcNow; + } - public DebateResult Build() => new() + public DebateResult Build() { - StrategyName = strategy.StrategyName, - Context = context, - Participants = members.Select(m => m.DisplayName).ToList(), - ChairmanName = chairman?.DisplayName, - KnowledgeKeeperName = knowledgeKeeper?.DisplayName, - OperatorName = @operator?.DisplayName, - OpeningStatement = _openingStatement, - Rounds = _rounds, - FinalVerdict = _finalVerdict, - CompletedAt = _completedAt - }; -} + return new DebateResult + { + StrategyName = strategy.StrategyName, + Context = context, + Participants = members.Select(m => m.DisplayName).ToList(), + ChairmanName = chairman?.DisplayName, + KnowledgeKeeperName = knowledgeKeeper?.DisplayName, + OperatorName = @operator?.DisplayName, + OpeningStatement = _openingStatement, + Rounds = _rounds, + FinalVerdict = _finalVerdict, + CompletedAt = _completedAt + }; + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Debate/DebateScenario.cs b/src/Delibera.Core/Debate/DebateScenario.cs index c0f7fa5..bf09067 100644 --- a/src/Delibera.Core/Debate/DebateScenario.cs +++ b/src/Delibera.Core/Debate/DebateScenario.cs @@ -1,3 +1,4 @@ +using System.Text.RegularExpressions; using Delibera.Core.Council; namespace Delibera.Core.Debate; @@ -9,6 +10,20 @@ namespace Delibera.Core.Debate; /// public abstract class DebateScenario : IDebateStrategy { + // ────────────────────────────────────────────── + // Operator helpers + // ────────────────────────────────────────────── + + /// + /// Marker participants use to delegate a task to the Operator, e.g.: + /// [[OPERATOR: search the web for the latest .NET 10 release notes]]. + /// + private static readonly Regex OperatorRequestRegex = + new(@"\[\[\s*OPERATOR\s*:\s*(?.+?)\]\]", + RegexOptions.Singleline | + RegexOptions.IgnoreCase | + RegexOptions.Compiled); + /// public abstract string StrategyName { get; } @@ -72,8 +87,10 @@ protected static string FormatRoundResponses(DebateRound round) } /// Formats all rounds into a single text block. - protected static string FormatAllRounds(IReadOnlyList rounds) => - string.Join("\n\n", rounds.Select(FormatRoundResponses)); + protected static string FormatAllRounds(IReadOnlyList rounds) + { + return string.Join("\n\n", rounds.Select(FormatRoundResponses)); + } /// Creates a completed debate round. protected static DebateRound CreateRound( @@ -83,8 +100,9 @@ protected static DebateRound CreateRound( Dictionary responses, string? prompt = null, IReadOnlyList? knowledgeInteractions = null, - IReadOnlyList? operatorInteractions = null) => - new() + IReadOnlyList? operatorInteractions = null) + { + return new DebateRound { RoundNumber = number, RoundName = name, @@ -95,6 +113,7 @@ protected static DebateRound CreateRound( OperatorInteractions = operatorInteractions ?? [], CompletedAt = DateTime.UtcNow }; + } /// /// Optionally queries the Knowledge Keeper for context relevant to the debate topic. @@ -159,20 +178,6 @@ protected static DebateRound CreateRound( } } - // ────────────────────────────────────────────── - // Operator helpers - // ────────────────────────────────────────────── - - /// - /// Marker participants use to delegate a task to the Operator, e.g.: - /// [[OPERATOR: search the web for the latest .NET 10 release notes]]. - /// - private static readonly System.Text.RegularExpressions.Regex OperatorRequestRegex = - new(@"\[\[\s*OPERATOR\s*:\s*(?.+?)\]\]", - System.Text.RegularExpressions.RegexOptions.Singleline | - System.Text.RegularExpressions.RegexOptions.IgnoreCase | - System.Text.RegularExpressions.RegexOptions.Compiled); - /// /// Builds an "Operator briefing" describing the Operator's tools and how to delegate /// tasks to it. Appended to the participants' system prompt so they know what is available. @@ -216,7 +221,7 @@ protected static async Task> ProcessOperatorR { if (string.IsNullOrWhiteSpace(response)) continue; - foreach (System.Text.RegularExpressions.Match match in OperatorRequestRegex.Matches(response)) + foreach (Match match in OperatorRequestRegex.Matches(response)) { var task = match.Groups["task"].Value.Trim(); if (string.IsNullOrWhiteSpace(task)) continue; @@ -259,4 +264,4 @@ protected static string FormatOperatorInteractions(IReadOnlyList McpServers { get; set; } = []; /// Materialises the configured MCP servers into instances. - public IReadOnlyList ToServerConfigs() => - McpServers.Select(s => s.ToConfig()).ToList(); + public IReadOnlyList ToServerConfigs() + { + return McpServers.Select(s => s.ToConfig()).ToList(); + } } /// @@ -165,18 +167,20 @@ public sealed class McpServerOptions public Dictionary AdditionalHeaders { get; set; } = []; /// Converts these options into a . - public Models.McpServerConfig ToConfig() => - string.Equals(Transport, "Http", StringComparison.OrdinalIgnoreCase) - ? Models.McpServerConfig.Http( + public McpServerConfig ToConfig() + { + return string.Equals(Transport, "Http", StringComparison.OrdinalIgnoreCase) + ? McpServerConfig.Http( Name, new Uri(Endpoint ?? throw new InvalidOperationException($"MCP server '{Name}' uses Http transport but has no Endpoint.")), AdditionalHeaders) - : Models.McpServerConfig.Stdio( + : McpServerConfig.Stdio( Name, Command ?? throw new InvalidOperationException($"MCP server '{Name}' uses Stdio transport but has no Command."), Arguments, EnvironmentVariables, WorkingDirectory); + } } /// diff --git a/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs b/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs index 6bd81d5..ec5fbe0 100644 --- a/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs @@ -1,6 +1,5 @@ using Delibera.Core.Compression; using Delibera.Core.Council; -using Delibera.Core.Interfaces; using Delibera.Core.Providers; using Delibera.Core.Providers.LLM; using Delibera.Core.Providers.RAG; @@ -101,7 +100,7 @@ public static IServiceCollection AddDeliberaChatClient( services.TryAddSingleton(chatClientFactory); // The DI container owns the IChatClient lifetime, so the provider must not dispose it. services.TryAddSingleton(sp => - new ChatClientLLMProvider(sp.GetRequiredService(), providerName, ownsClient: false)); + new ChatClientLLMProvider(sp.GetRequiredService(), providerName, false)); return services; } @@ -145,8 +144,8 @@ public static IServiceCollection AddDeliberaEmbeddingGenerator( sp.GetRequiredService>>(), modelName, vectorSize, - ownsGenerator: false)); + false)); return services; } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Extensions/MicrosoftAIExtensions.cs b/src/Delibera.Core/Extensions/MicrosoftAIExtensions.cs index 66cfe27..906b577 100644 --- a/src/Delibera.Core/Extensions/MicrosoftAIExtensions.cs +++ b/src/Delibera.Core/Extensions/MicrosoftAIExtensions.cs @@ -1,6 +1,4 @@ using System.Runtime.CompilerServices; -using System.Text; -using Delibera.Core.Interfaces; using Delibera.Core.Providers.LLM; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; @@ -15,8 +13,14 @@ namespace Delibera.Core.Extensions; /// /// These extensions let the two worlds interoperate in either direction: /// -/// Adopt any Microsoft.Extensions.AI client as a Delibera provider via / . -/// Expose a Delibera provider as a Microsoft.Extensions.AI via , so it can participate in the standard middleware pipeline. +/// +/// Adopt any Microsoft.Extensions.AI client as a Delibera provider via / +/// . +/// +/// +/// Expose a Delibera provider as a Microsoft.Extensions.AI via +/// , so it can participate in the standard middleware pipeline. +/// /// Compose middleware (function invocation, logging) with . /// /// @@ -29,7 +33,9 @@ public static class MicrosoftAIExtensions /// Optional friendly provider name (defaults to client metadata). /// Whether disposing the provider also disposes the client. public static ILLMProvider AsLLMProvider(this IChatClient chatClient, string? providerName = null, bool ownsClient = true) - => new ChatClientLLMProvider(chatClient, providerName, ownsClient); + { + return new ChatClientLLMProvider(chatClient, providerName, ownsClient); + } /// /// Adopts a Microsoft.Extensions.AI as a @@ -40,7 +46,9 @@ public static IEmbeddingProvider AsEmbeddingProvider( string? modelName = null, int? vectorSize = null, bool ownsGenerator = true) - => new EmbeddingGeneratorProvider(generator, modelName, vectorSize, ownsGenerator); + { + return new EmbeddingGeneratorProvider(generator, modelName, vectorSize, ownsGenerator); + } /// /// Exposes a Delibera as a Microsoft.Extensions.AI . @@ -121,10 +129,17 @@ public async IAsyncEnumerable GetStreamingResponseAsync( return null; } - public void Dispose() => provider.Dispose(); + public void Dispose() + { + provider.Dispose(); + } - private string ResolveModel(ChatOptions? options) => - options?.ModelId is { Length: > 0 } m ? m : defaultModel ?? string.Empty; + private string ResolveModel(ChatOptions? options) + { + return options?.ModelId is { Length: > 0 } m + ? m + : defaultModel ?? string.Empty; + } private static (string System, string User) SplitMessages(IEnumerable messages) { @@ -132,7 +147,9 @@ private static (string System, string User) SplitMessages(IEnumerable 0) target.Append('\n'); target.Append(message.Text); } @@ -140,4 +157,4 @@ private static (string System, string User) SplitMessages(IEnumerablePercentage of tokens saved. public double TokensSavedPercent => (1.0 - CompressionRatio) * 100.0; -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Interfaces/ICouncilBuilder.cs b/src/Delibera.Core/Interfaces/ICouncilBuilder.cs index 6f8cf80..c3cb4a6 100644 --- a/src/Delibera.Core/Interfaces/ICouncilBuilder.cs +++ b/src/Delibera.Core/Interfaces/ICouncilBuilder.cs @@ -1,4 +1,5 @@ using Delibera.Core.Council; +using Delibera.Core.Debate; namespace Delibera.Core.Interfaces; @@ -73,13 +74,22 @@ ICouncilBuilder WithOperator( ICouncilBuilder WithStrategy(IDebateStrategy strategy); /// Uses the standard 4-round debate strategy. - ICouncilBuilder WithStandardDebate() => WithStrategy(new Debate.StandardDebate()); + ICouncilBuilder WithStandardDebate() + { + return WithStrategy(new StandardDebate()); + } /// Uses the adversarial critique debate strategy. - ICouncilBuilder WithCritiqueDebate() => WithStrategy(new Debate.CritiqueDebate()); + ICouncilBuilder WithCritiqueDebate() + { + return WithStrategy(new CritiqueDebate()); + } /// Uses the consensus-building debate strategy. - ICouncilBuilder WithConsensusDebate() => WithStrategy(new Debate.ConsensusDebate()); + ICouncilBuilder WithConsensusDebate() + { + return WithStrategy(new ConsensusDebate()); + } /// Attaches a legacy knowledge base for prompt injection. /// Knowledge base instance. @@ -147,4 +157,4 @@ ICouncilBuilder WithCompression( /// Configured council executor ready for debate execution. /// When required configuration is missing. ICouncilExecutor Build(); -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Interfaces/ILLMProvider.cs b/src/Delibera.Core/Interfaces/ILLMProvider.cs index 0c9e300..d58e65c 100644 --- a/src/Delibera.Core/Interfaces/ILLMProvider.cs +++ b/src/Delibera.Core/Interfaces/ILLMProvider.cs @@ -1,3 +1,5 @@ +using System.Runtime.CompilerServices; + namespace Delibera.Core.Interfaces; /// @@ -52,7 +54,7 @@ async IAsyncEnumerable ChatStreamAsync( string systemPrompt, string userPrompt, float temperature = 0.7f, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) + [EnumeratorCancellation] CancellationToken ct = default) { yield return await ChatAsync(model, systemPrompt, userPrompt, temperature, ct); } diff --git a/src/Delibera.Core/Interfaces/IMcpClient.cs b/src/Delibera.Core/Interfaces/IMcpClient.cs index bd4649b..791ee24 100644 --- a/src/Delibera.Core/Interfaces/IMcpClient.cs +++ b/src/Delibera.Core/Interfaces/IMcpClient.cs @@ -38,4 +38,4 @@ Task CallToolAsync( string toolName, IReadOnlyDictionary arguments, CancellationToken ct = default); -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Interfaces/IOperator.cs b/src/Delibera.Core/Interfaces/IOperator.cs index 5340091..dd8a23a 100644 --- a/src/Delibera.Core/Interfaces/IOperator.cs +++ b/src/Delibera.Core/Interfaces/IOperator.cs @@ -48,4 +48,4 @@ Task ExecuteTaskAsync( string requesterName, string task, CancellationToken ct = default); -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Interfaces/IVectorStore.cs b/src/Delibera.Core/Interfaces/IVectorStore.cs index 9101205..527d677 100644 --- a/src/Delibera.Core/Interfaces/IVectorStore.cs +++ b/src/Delibera.Core/Interfaces/IVectorStore.cs @@ -70,4 +70,4 @@ Task> SearchAsync( /// Returns the total number of points in a collection. Task CountAsync(string collectionName, CancellationToken ct = default); -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Knowledge/MarkdownKnowledgeBase.cs b/src/Delibera.Core/Knowledge/MarkdownKnowledgeBase.cs index f3efd7a..8d4fecc 100644 --- a/src/Delibera.Core/Knowledge/MarkdownKnowledgeBase.cs +++ b/src/Delibera.Core/Knowledge/MarkdownKnowledgeBase.cs @@ -86,7 +86,10 @@ public IReadOnlyList Search(string query, int maxResults = 5) } /// - public IReadOnlyList GetLoadedSources() => _documents.Keys.ToList().AsReadOnly(); + public IReadOnlyList GetLoadedSources() + { + return _documents.Keys.ToList().AsReadOnly(); + } /// Loads all matching files from a directory. public async Task LoadDirectoryAsync(string directoryPath, string pattern = "*.md") @@ -97,4 +100,4 @@ public async Task LoadDirectoryAsync(string directoryPath, string pattern = "*.m await LoadManyAsync(Directory.GetFiles(full, pattern, SearchOption.AllDirectories)); } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Models/CompressionLog.cs b/src/Delibera.Core/Models/CompressionLog.cs index 5361d06..42531c7 100644 --- a/src/Delibera.Core/Models/CompressionLog.cs +++ b/src/Delibera.Core/Models/CompressionLog.cs @@ -33,4 +33,4 @@ public sealed record CompressionLog public double Ratio => OriginalTokens > 0 ? (double)CompressedTokens / OriginalTokens : 1.0; -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Models/CouncilMember.cs b/src/Delibera.Core/Models/CouncilMember.cs index 03b9398..297cc63 100644 --- a/src/Delibera.Core/Models/CouncilMember.cs +++ b/src/Delibera.Core/Models/CouncilMember.cs @@ -5,6 +5,10 @@ namespace Delibera.Core.Models; /// public sealed class CouncilMember(string modelName, ILLMProvider provider, string? role = null, string? personaPrompt = null) { + private readonly string? _personaPrompt = personaPrompt; + + private string _role = role ?? "Expert"; + /// Unique participant identifier. public string Id { get; } = $"{provider.ProviderName}:{modelName}:{Guid.NewGuid():N}".ToLowerInvariant(); @@ -17,8 +21,6 @@ public sealed class CouncilMember(string modelName, ILLMProvider provider, strin /// LLM provider that serves this model. public ILLMProvider Provider { get; } = provider ?? throw new ArgumentNullException(nameof(provider)); - private string _role = role ?? "Expert"; - /// Role in the debate (Expert, Critic, Chairman, etc.). public string Role { @@ -26,14 +28,8 @@ public string Role set => field = value ?? "Expert"; } - private string? _personaPrompt = personaPrompt; - /// Optional persona system-prompt that personalises the model's behaviour. - public string? PersonaPrompt - { - get => field; - set => field = value; - } + public string? PersonaPrompt { get; set; } /// Sends a request to the underlying model. public Task AskAsync( @@ -53,5 +49,8 @@ public Task AskAsync( } /// - public override string ToString() => DisplayName; -} + public override string ToString() + { + return DisplayName; + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Models/DebateResult.cs b/src/Delibera.Core/Models/DebateResult.cs index 927aefd..fa429bc 100644 --- a/src/Delibera.Core/Models/DebateResult.cs +++ b/src/Delibera.Core/Models/DebateResult.cs @@ -1,5 +1,3 @@ -using System.Text; - namespace Delibera.Core.Models; /// @@ -272,19 +270,28 @@ public string ToLogsMarkdown() /// Saves the debate result (rounds and verdict) to a Markdown file. /// /// Path for the result Markdown file. - public Task SaveToMarkdownAsync(string filePath) => WriteAllTextAsync(filePath, ToMarkdown()); + public Task SaveToMarkdownAsync(string filePath) + { + return WriteAllTextAsync(filePath, ToMarkdown()); + } /// /// Saves token statistics and compression logs to a Markdown file. /// /// Path for the statistics Markdown file. - public Task SaveStatisticsAsync(string filePath) => WriteAllTextAsync(filePath, ToStatisticsMarkdown()); + public Task SaveStatisticsAsync(string filePath) + { + return WriteAllTextAsync(filePath, ToStatisticsMarkdown()); + } /// /// Saves execution logs to a Markdown file. /// /// Path for the logs Markdown file. - public Task SaveLogsAsync(string filePath) => WriteAllTextAsync(filePath, ToLogsMarkdown()); + public Task SaveLogsAsync(string filePath) + { + return WriteAllTextAsync(filePath, ToLogsMarkdown()); + } /// /// Saves all three files (result.md, statistics.md, logs.md) to the specified directory. @@ -385,4 +392,4 @@ public static void WriteTokenStatistics(StringBuilder sb, TokenStatistics stats) sb.AppendLine(); } } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Models/DebateRound.cs b/src/Delibera.Core/Models/DebateRound.cs index 3a075c5..33ca3ef 100644 --- a/src/Delibera.Core/Models/DebateRound.cs +++ b/src/Delibera.Core/Models/DebateRound.cs @@ -66,4 +66,4 @@ public sealed record DebateRound /// Round duration. public TimeSpan Duration => (CompletedAt ?? DateTime.UtcNow) - StartedAt; -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Models/ExecutionLog.cs b/src/Delibera.Core/Models/ExecutionLog.cs index ebf8a0f..700bc4f 100644 --- a/src/Delibera.Core/Models/ExecutionLog.cs +++ b/src/Delibera.Core/Models/ExecutionLog.cs @@ -39,25 +39,40 @@ public sealed record ExecutionLog( /// /// Creates an with and the current UTC time. /// - public static ExecutionLog Info(string source, string message) => new(LogLevel.Info, source, message, DateTime.UtcNow); + public static ExecutionLog Info(string source, string message) + { + return new ExecutionLog(LogLevel.Info, source, message, DateTime.UtcNow); + } /// /// Creates an with and the current UTC time. /// - public static ExecutionLog Trace(string source, string message) => new(LogLevel.Trace, source, message, DateTime.UtcNow); + public static ExecutionLog Trace(string source, string message) + { + return new ExecutionLog(LogLevel.Trace, source, message, DateTime.UtcNow); + } /// /// Creates an with and the current UTC time. /// - public static ExecutionLog Warn(string source, string message) => new(LogLevel.Warning, source, message, DateTime.UtcNow); + public static ExecutionLog Warn(string source, string message) + { + return new ExecutionLog(LogLevel.Warning, source, message, DateTime.UtcNow); + } /// /// Creates an with and the current UTC time. /// - public static ExecutionLog Error(string source, string message) => new(LogLevel.Error, source, message, DateTime.UtcNow); + public static ExecutionLog Error(string source, string message) + { + return new ExecutionLog(LogLevel.Error, source, message, DateTime.UtcNow); + } /// /// Formats the log entry as a single-line string. /// - public override string ToString() => $"[{Timestamp:HH:mm:ss.fff}] [{Level,-7}] [{Source}] {Message}"; -} + public override string ToString() + { + return $"[{Timestamp:HH:mm:ss.fff}] [{Level,-7}] [{Source}] {Message}"; + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Models/OperatorModels.cs b/src/Delibera.Core/Models/OperatorModels.cs index fa163f8..e9a71ea 100644 --- a/src/Delibera.Core/Models/OperatorModels.cs +++ b/src/Delibera.Core/Models/OperatorModels.cs @@ -69,8 +69,9 @@ public static McpServerConfig Stdio( string command, IReadOnlyList? arguments = null, IReadOnlyDictionary? environmentVariables = null, - string? workingDirectory = null) => - new() + string? workingDirectory = null) + { + return new McpServerConfig { Name = name, TransportType = McpTransportType.Stdio, @@ -79,19 +80,22 @@ public static McpServerConfig Stdio( EnvironmentVariables = environmentVariables ?? new Dictionary(), WorkingDirectory = workingDirectory }; + } /// Creates an HTTP/SSE MCP server configuration. public static McpServerConfig Http( string name, Uri endpoint, - IReadOnlyDictionary? additionalHeaders = null) => - new() + IReadOnlyDictionary? additionalHeaders = null) + { + return new McpServerConfig { Name = name, TransportType = McpTransportType.Http, Endpoint = endpoint, AdditionalHeaders = additionalHeaders ?? new Dictionary() }; + } } /// @@ -171,6 +175,8 @@ public sealed record OperatorResult( bool Compressed) { /// Converts this result into a loggable . - public OperatorInteraction ToInteraction() => - new(RequesterName, Task, Answer, ToolCalls, Compressed, DateTime.UtcNow); -} + public OperatorInteraction ToInteraction() + { + return new OperatorInteraction(RequesterName, Task, Answer, ToolCalls, Compressed, DateTime.UtcNow); + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Models/PromptContext.cs b/src/Delibera.Core/Models/PromptContext.cs index cd5d385..adba2b6 100644 --- a/src/Delibera.Core/Models/PromptContext.cs +++ b/src/Delibera.Core/Models/PromptContext.cs @@ -31,11 +31,11 @@ public string GetFullUserPrompt() return UserPrompt; return $$""" - ### Context (Knowledge Base): - {{KnowledgeContent}} + ### Context (Knowledge Base): + {{KnowledgeContent}} - ### Question: - {{UserPrompt}} - """; + ### Question: + {{UserPrompt}} + """; } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Models/TokenStatistics.cs b/src/Delibera.Core/Models/TokenStatistics.cs index cec8871..d7413b9 100644 --- a/src/Delibera.Core/Models/TokenStatistics.cs +++ b/src/Delibera.Core/Models/TokenStatistics.cs @@ -52,13 +52,16 @@ public sealed record TokenStatistics /// /// Formats the statistics as a human-readable summary. /// - public string ToSummary() => $""" - 📊 Token Statistics: - Original tokens: {TotalOriginalTokens:N0} - Compressed tokens: {TotalCompressedTokens:N0} - Response tokens: {TotalResponseTokens:N0} - Tokens saved: {TokensSaved:N0} ({SavedPercent:F1}%) - Compression ratio: {OverallCompressionRatio:P1} - Grand total: {GrandTotal:N0} - """; -} + public string ToSummary() + { + return $""" + 📊 Token Statistics: + Original tokens: {TotalOriginalTokens:N0} + Compressed tokens: {TotalCompressedTokens:N0} + Response tokens: {TotalResponseTokens:N0} + Tokens saved: {TokensSaved:N0} ({SavedPercent:F1}%) + Compression ratio: {OverallCompressionRatio:P1} + Grand total: {GrandTotal:N0} + """; + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Providers/LLM/ChatClientLLMProvider.cs b/src/Delibera.Core/Providers/LLM/ChatClientLLMProvider.cs index 76c5629..6287fd8 100644 --- a/src/Delibera.Core/Providers/LLM/ChatClientLLMProvider.cs +++ b/src/Delibera.Core/Providers/LLM/ChatClientLLMProvider.cs @@ -1,4 +1,4 @@ -using Delibera.Core.Interfaces; +using System.Runtime.CompilerServices; using Microsoft.Extensions.AI; namespace Delibera.Core.Providers.LLM; @@ -23,7 +23,6 @@ namespace Delibera.Core.Providers.LLM; /// public sealed class ChatClientLLMProvider : ILLMProvider { - private readonly IChatClient _chatClient; private readonly bool _ownsClient; private bool _disposed; @@ -41,12 +40,14 @@ public sealed class ChatClientLLMProvider : ILLMProvider /// public ChatClientLLMProvider(IChatClient chatClient, string? providerName = null, bool ownsClient = true) { - _chatClient = chatClient ?? throw new ArgumentNullException(nameof(chatClient)); + ChatClient = chatClient ?? throw new ArgumentNullException(nameof(chatClient)); _ownsClient = ownsClient; var metadata = chatClient.GetService(typeof(ChatClientMetadata)) as ChatClientMetadata; - ProviderName = providerName - ?? (string.IsNullOrWhiteSpace(metadata?.ProviderName) ? "ChatClient" : metadata!.ProviderName!); + ProviderName = providerName ?? + (string.IsNullOrWhiteSpace(metadata?.ProviderName) + ? "ChatClient" + : metadata!.ProviderName!); DefaultModelId = metadata?.DefaultModelId; } @@ -54,7 +55,7 @@ public ChatClientLLMProvider(IChatClient chatClient, string? providerName = null public string? DefaultModelId { get; } /// Exposes the wrapped for advanced scenarios and middleware composition. - public IChatClient ChatClient => _chatClient; + public IChatClient ChatClient { get; } /// public string ProviderName { get; } @@ -95,7 +96,7 @@ public async Task ChatAsync( try { - var response = await _chatClient.GetResponseAsync(messages, options, ct); + var response = await ChatClient.GetResponseAsync(messages, options, ct); var text = response.Text?.Trim() ?? string.Empty; if (string.IsNullOrWhiteSpace(text)) throw new InvalidOperationException($"Empty response from model '{model}' ({ProviderName})."); @@ -118,14 +119,14 @@ public async IAsyncEnumerable ChatStreamAsync( string systemPrompt, string userPrompt, float temperature = 0.7f, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) + [EnumeratorCancellation] CancellationToken ct = default) { ArgumentException.ThrowIfNullOrWhiteSpace(userPrompt); var messages = BuildMessages(systemPrompt, userPrompt); var options = BuildOptions(model, temperature); - await foreach (var update in _chatClient.GetStreamingResponseAsync(messages, options, ct)) + await foreach (var update in ChatClient.GetStreamingResponseAsync(messages, options, ct)) { var text = update.Text; if (!string.IsNullOrEmpty(text)) @@ -133,6 +134,15 @@ public async IAsyncEnumerable ChatStreamAsync( } } + /// + public void Dispose() + { + if (_disposed) return; + _disposed = true; + if (_ownsClient) ChatClient.Dispose(); + GC.SuppressFinalize(this); + } + private static List BuildMessages(string systemPrompt, string userPrompt) { var messages = new List(2); @@ -142,19 +152,15 @@ private static List BuildMessages(string systemPrompt, string userP return messages; } - private ChatOptions BuildOptions(string model, float temperature) => new() + private ChatOptions BuildOptions(string model, float temperature) { - // Prefer the explicitly requested model; fall back to the client's default. - ModelId = string.IsNullOrWhiteSpace(model) ? DefaultModelId : model, - Temperature = temperature - }; - - /// - public void Dispose() - { - if (_disposed) return; - _disposed = true; - if (_ownsClient) _chatClient.Dispose(); - GC.SuppressFinalize(this); + return new ChatOptions + { + // Prefer the explicitly requested model; fall back to the client's default. + ModelId = string.IsNullOrWhiteSpace(model) + ? DefaultModelId + : model, + Temperature = temperature + }; } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Providers/LLM/EmbeddingGeneratorProvider.cs b/src/Delibera.Core/Providers/LLM/EmbeddingGeneratorProvider.cs index 4de303f..dba685f 100644 --- a/src/Delibera.Core/Providers/LLM/EmbeddingGeneratorProvider.cs +++ b/src/Delibera.Core/Providers/LLM/EmbeddingGeneratorProvider.cs @@ -1,4 +1,3 @@ -using Delibera.Core.Interfaces; using Microsoft.Extensions.AI; namespace Delibera.Core.Providers.LLM; @@ -14,7 +13,6 @@ namespace Delibera.Core.Providers.LLM; /// public sealed class EmbeddingGeneratorProvider : IEmbeddingProvider, IDisposable { - private readonly IEmbeddingGenerator> _generator; private readonly bool _ownsGenerator; private int? _cachedVectorSize; private bool _disposed; @@ -40,17 +38,28 @@ public EmbeddingGeneratorProvider( int? vectorSize = null, bool ownsGenerator = true) { - _generator = generator ?? throw new ArgumentNullException(nameof(generator)); + Generator = generator ?? throw new ArgumentNullException(nameof(generator)); _ownsGenerator = ownsGenerator; var metadata = generator.GetService(typeof(EmbeddingGeneratorMetadata)) as EmbeddingGeneratorMetadata; - EmbeddingModelName = modelName - ?? (string.IsNullOrWhiteSpace(metadata?.DefaultModelId) ? "embedding" : metadata!.DefaultModelId!); + EmbeddingModelName = modelName ?? + (string.IsNullOrWhiteSpace(metadata?.DefaultModelId) + ? "embedding" + : metadata!.DefaultModelId!); _cachedVectorSize = vectorSize ?? metadata?.DefaultModelDimensions; } /// Exposes the wrapped generator for advanced scenarios and middleware composition. - public IEmbeddingGenerator> Generator => _generator; + public IEmbeddingGenerator> Generator { get; } + + /// + public void Dispose() + { + if (_disposed) return; + _disposed = true; + if (_ownsGenerator) Generator.Dispose(); + GC.SuppressFinalize(this); + } /// public string EmbeddingModelName { get; } @@ -65,9 +74,8 @@ public async Task EmbedAsync(string text, CancellationToken ct = defaul { ArgumentException.ThrowIfNullOrWhiteSpace(text); - var result = await _generator.GenerateAsync([text], cancellationToken: ct); - var embedding = result.FirstOrDefault() - ?? throw new InvalidOperationException($"No embedding returned by model '{EmbeddingModelName}'."); + var result = await Generator.GenerateAsync([text], cancellationToken: ct); + var embedding = result.FirstOrDefault() ?? throw new InvalidOperationException($"No embedding returned by model '{EmbeddingModelName}'."); var vector = embedding.Vector.ToArray(); _cachedVectorSize ??= vector.Length; @@ -80,7 +88,7 @@ public async Task> EmbedBatchAsync(IReadOnlyList ArgumentNullException.ThrowIfNull(texts); if (texts.Count == 0) return []; - var result = await _generator.GenerateAsync(texts, cancellationToken: ct); + var result = await Generator.GenerateAsync(texts, cancellationToken: ct); var vectors = new List(texts.Count); foreach (var embedding in result) @@ -92,13 +100,4 @@ public async Task> EmbedBatchAsync(IReadOnlyList return vectors.AsReadOnly(); } - - /// - public void Dispose() - { - if (_disposed) return; - _disposed = true; - if (_ownsGenerator) _generator.Dispose(); - GC.SuppressFinalize(this); - } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Providers/LLM/OllamaEmbeddingProvider.cs b/src/Delibera.Core/Providers/LLM/OllamaEmbeddingProvider.cs index 9f1c74e..d16acff 100644 --- a/src/Delibera.Core/Providers/LLM/OllamaEmbeddingProvider.cs +++ b/src/Delibera.Core/Providers/LLM/OllamaEmbeddingProvider.cs @@ -81,4 +81,4 @@ public async Task> EmbedBatchAsync(IReadOnlyList return result.AsReadOnly(); } -} +} \ 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 726dde9..22a2d55 100644 --- a/src/Delibera.Core/Providers/LLM/OllamaProvider.cs +++ b/src/Delibera.Core/Providers/LLM/OllamaProvider.cs @@ -1,6 +1,8 @@ +using Microsoft.Extensions.AI; using OllamaSharp; using OllamaSharp.Models; using OllamaSharp.Models.Chat; +using ChatRole = OllamaSharp.Models.Chat.ChatRole; namespace Delibera.Core.Providers.LLM; @@ -41,21 +43,6 @@ public OllamaProvider(string endpoint, string apiKey = "") /// public string ProviderName => "Ollama"; - /// - /// Exposes the underlying OllamaSharp client as a Microsoft.Extensions.AI . - /// - /// - /// natively implements , so this lets the - /// Ollama provider plug into the standard Microsoft.Extensions.AI middleware pipeline - /// (function invocation, caching, telemetry). - /// - public Microsoft.Extensions.AI.IChatClient AsChatClient() => Client; - - /// - /// Exposes the underlying OllamaSharp client as a Microsoft.Extensions.AI embedding generator. - /// - public Microsoft.Extensions.AI.IEmbeddingGenerator> AsEmbeddingGenerator() => Client; - /// public async Task IsAvailableAsync(CancellationToken ct = default) { @@ -141,4 +128,27 @@ public void Dispose() _disposed = true; GC.SuppressFinalize(this); } -} + + /// + /// Exposes the underlying OllamaSharp client as a Microsoft.Extensions.AI + /// . + /// + /// + /// natively implements , so this lets + /// the + /// Ollama provider plug into the standard Microsoft.Extensions.AI middleware pipeline + /// (function invocation, caching, telemetry). + /// + public IChatClient AsChatClient() + { + return Client; + } + + /// + /// Exposes the underlying OllamaSharp client as a Microsoft.Extensions.AI embedding generator. + /// + public IEmbeddingGenerator> AsEmbeddingGenerator() + { + return Client; + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Providers/Mcp/McpClientAdapter.cs b/src/Delibera.Core/Providers/Mcp/McpClientAdapter.cs index a4484e1..9190dce 100644 --- a/src/Delibera.Core/Providers/Mcp/McpClientAdapter.cs +++ b/src/Delibera.Core/Providers/Mcp/McpClientAdapter.cs @@ -112,13 +112,15 @@ private static string SchemaToJson(McpClientTool tool) } } - private static IClientTransport CreateTransport(McpServerConfig config) => - config.TransportType switch + private static IClientTransport CreateTransport(McpServerConfig config) + { + return config.TransportType switch { McpTransportType.Stdio => CreateStdioTransport(config), McpTransportType.Http => CreateHttpTransport(config), _ => throw new NotSupportedException($"Unsupported MCP transport: {config.TransportType}") }; + } private static StdioClientTransport CreateStdioTransport(McpServerConfig config) { @@ -159,4 +161,4 @@ private static HttpClientTransport CreateHttpTransport(McpServerConfig config) return new HttpClientTransport(options); } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Providers/ProviderFactory.cs b/src/Delibera.Core/Providers/ProviderFactory.cs index 28a1455..4a46425 100644 --- a/src/Delibera.Core/Providers/ProviderFactory.cs +++ b/src/Delibera.Core/Providers/ProviderFactory.cs @@ -20,11 +20,29 @@ public abstract class CachingFactory /// Registered provider type names (read-only view). public IReadOnlyCollection RegisteredTypes => _builders.Keys.ToList().AsReadOnly(); + /// + public void Dispose() + { + if (_disposed) return; + _disposed = true; + DisposeInstances(); + ClearInstances(); + GC.SuppressFinalize(this); + } + /// All currently cached provider instances. - public IReadOnlyDictionary GetAllInstances() => _instances; + public IReadOnlyDictionary GetAllInstances() + { + return _instances; + } /// Returns a cached instance by name, or null. - public TInstance? GetInstance(string name) => _instances.TryGetValue(name, out var p) ? p : null; + public TInstance? GetInstance(string name) + { + return _instances.TryGetValue(name, out var p) + ? p + : null; + } /// Registers a builder for a new provider type (e.g., "OpenAI", "YandexGPT"). public CachingFactory RegisterBuilder(string providerType, TBuilder builder) @@ -61,19 +79,15 @@ protected TInstance CacheInstance(string name, TInstance instance) } /// Iterates every cached instance — used by the derived Dispose methods. - protected IEnumerable EnumerateInstances() => _instances.Values; + protected IEnumerable EnumerateInstances() + { + return _instances.Values; + } /// Clears the cache without disposing anything. - protected void ClearInstances() => _instances.Clear(); - - /// - public void Dispose() + protected void ClearInstances() { - if (_disposed) return; - _disposed = true; - DisposeInstances(); - ClearInstances(); - GC.SuppressFinalize(this); + _instances.Clear(); } /// Disposes every cached instance. Subclasses override to call the correct dispose method. @@ -107,14 +121,22 @@ ILLMProviderFactory ILLMProviderFactory.RegisterBuilder(string providerType, Fun /// /// Creates (or returns a cached) provider from configuration. /// - public ILLMProvider Create(string name, string providerType, IConfigurationSection config) => - GetOrCreate(name, providerType, b => b(config)); + public ILLMProvider Create(string name, string providerType, IConfigurationSection config) + { + return GetOrCreate(name, providerType, b => b(config)); + } /// Returns a cached provider by name, or null. - public ILLMProvider? GetProvider(string name) => GetInstance(name); + public ILLMProvider? GetProvider(string name) + { + return GetInstance(name); + } /// All created provider instances. - public IReadOnlyDictionary GetAllProviders() => GetAllInstances(); + public IReadOnlyDictionary GetAllProviders() + { + return GetAllInstances(); + } /// Creates an Ollama provider with direct parameters. public OllamaProvider CreateOllama(string endpoint, string apiKey = "") @@ -161,4 +183,4 @@ protected override void DisposeInstances() { foreach (var p in EnumerateInstances()) p.Dispose(); } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Providers/RAG/BaseRagProvider.cs b/src/Delibera.Core/Providers/RAG/BaseRagProvider.cs index 9c613b2..f21ec07 100644 --- a/src/Delibera.Core/Providers/RAG/BaseRagProvider.cs +++ b/src/Delibera.Core/Providers/RAG/BaseRagProvider.cs @@ -8,15 +8,6 @@ namespace Delibera.Core.Providers.RAG; /// public abstract class BaseRagProvider : IRagProvider { - /// Abstract implementations describe themselves. - public abstract string ProviderName { get; } - - /// - public IVectorStore VectorStore { get; } - - /// - public IEmbeddingProvider EmbeddingProvider { get; } - /// /// Initialises a base RAG provider with the supplied vector store and embedding provider. /// @@ -26,6 +17,15 @@ protected BaseRagProvider(IVectorStore vectorStore, IEmbeddingProvider embedding EmbeddingProvider = embeddingProvider ?? throw new ArgumentNullException(nameof(embeddingProvider)); } + /// Abstract implementations describe themselves. + public abstract string ProviderName { get; } + + /// + public IVectorStore VectorStore { get; } + + /// + public IEmbeddingProvider EmbeddingProvider { get; } + /// public virtual async Task IndexDocumentAsync( string collectionName, @@ -124,5 +124,8 @@ public virtual async Task GetContextAsync( } /// - public virtual ValueTask DisposeAsync() => VectorStore.DisposeAsync(); -} + public virtual ValueTask DisposeAsync() + { + return VectorStore.DisposeAsync(); + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Providers/RAG/PgVectorRagProvider.cs b/src/Delibera.Core/Providers/RAG/PgVectorRagProvider.cs index 903e8e3..ef10cfc 100644 --- a/src/Delibera.Core/Providers/RAG/PgVectorRagProvider.cs +++ b/src/Delibera.Core/Providers/RAG/PgVectorRagProvider.cs @@ -36,4 +36,4 @@ public PgVectorRagProvider(IEmbeddingProvider embeddingProvider, string connecti /// public override string ProviderName => "PgVectorRAG"; -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Providers/RAG/PgVectorStore.cs b/src/Delibera.Core/Providers/RAG/PgVectorStore.cs index b118fb5..40cba43 100644 --- a/src/Delibera.Core/Providers/RAG/PgVectorStore.cs +++ b/src/Delibera.Core/Providers/RAG/PgVectorStore.cs @@ -110,8 +110,12 @@ public async Task UpsertAsync(string collectionName, IReadOnlyList foreach (var p in points) { - var id = Guid.TryParse(p.Id, out var guid) ? guid : Guid.NewGuid(); - var metadataJson = p.Metadata is { Count: > 0 } ? JsonSerializer.Serialize(p.Metadata) : null; + var id = Guid.TryParse(p.Id, out var guid) + ? guid + : Guid.NewGuid(); + var metadataJson = p.Metadata is { Count: > 0 } + ? JsonSerializer.Serialize(p.Metadata) + : null; var cmd = new NpgsqlBatchCommand($""" INSERT INTO {tableName} (id, text, metadata, embedding) @@ -164,7 +168,9 @@ public async Task> SearchAsync( { var id = reader.GetGuid(0).ToString(); var text = reader.GetString(1); - var metaJson = reader.IsDBNull(2) ? null : reader.GetString(2); + var metaJson = reader.IsDBNull(2) + ? null + : reader.GetString(2); var score = (float)reader.GetDouble(3); Dictionary? metadata = null; @@ -229,4 +235,4 @@ private static string SanitizeTableName(string collectionName) collectionName.ToLowerInvariant(), @"[^a-z0-9_]", "_"); return $"vc_{safe}"; } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Providers/RAG/QdrantRagProvider.cs b/src/Delibera.Core/Providers/RAG/QdrantRagProvider.cs index 17c20b1..bb7e832 100644 --- a/src/Delibera.Core/Providers/RAG/QdrantRagProvider.cs +++ b/src/Delibera.Core/Providers/RAG/QdrantRagProvider.cs @@ -32,4 +32,4 @@ public QdrantRagProvider( /// public override string ProviderName => "QdrantRAG"; -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Providers/RAG/QdrantVectorStore.cs b/src/Delibera.Core/Providers/RAG/QdrantVectorStore.cs index aee80ac..f6be109 100644 --- a/src/Delibera.Core/Providers/RAG/QdrantVectorStore.cs +++ b/src/Delibera.Core/Providers/RAG/QdrantVectorStore.cs @@ -83,14 +83,18 @@ public async Task> SearchAsync( collectionName, queryVector, limit: (ulong)limit, - scoreThreshold: scoreThreshold > 0 ? scoreThreshold : null, + scoreThreshold: scoreThreshold > 0 + ? scoreThreshold + : null, cancellationToken: ct); var results = new List(scored.Count); foreach (var s in scored) { - var text = s.Payload.TryGetValue("text", out var tv) ? tv.StringValue : string.Empty; + var text = s.Payload.TryGetValue("text", out var tv) + ? tv.StringValue + : string.Empty; var meta = new Dictionary(); foreach (var (k, v) in s.Payload) @@ -101,15 +105,19 @@ public async Task> SearchAsync( s.Id.Uuid, text, s.Score, - meta.Count > 0 ? meta : null)); + meta.Count > 0 + ? meta + : null)); } return results.AsReadOnly(); } /// - public Task DeleteCollectionAsync(string collectionName, CancellationToken ct = default) => - _client.DeleteCollectionAsync(collectionName, cancellationToken: ct); + public Task DeleteCollectionAsync(string collectionName, CancellationToken ct = default) + { + return _client.DeleteCollectionAsync(collectionName, cancellationToken: ct); + } /// public async Task CountAsync(string collectionName, CancellationToken ct = default) @@ -124,4 +132,4 @@ public ValueTask DisposeAsync() _client.Dispose(); return ValueTask.CompletedTask; } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Providers/RAG/RagProviderFactory.cs b/src/Delibera.Core/Providers/RAG/RagProviderFactory.cs index 90df63b..cb5e060 100644 --- a/src/Delibera.Core/Providers/RAG/RagProviderFactory.cs +++ b/src/Delibera.Core/Providers/RAG/RagProviderFactory.cs @@ -16,7 +16,9 @@ public RagProviderFactory() RegisterBuilder("Qdrant", (config, embeddings) => { var host = config["Host"] ?? "localhost"; - var port = int.TryParse(config["Port"], out var p) ? p : 6334; + var port = int.TryParse(config["Port"], out var p) + ? p + : 6334; var https = bool.TryParse(config["Https"], out var h) && h; var apiKey = config["ApiKey"]; @@ -42,11 +44,24 @@ IRagProviderFactory IRagProviderFactory.RegisterBuilder( /// /// Creates (or returns cached) a RAG provider instance. /// - public IRagProvider Create(string name, string providerType, IConfigurationSection config, IEmbeddingProvider embeddingProvider) => - GetOrCreate(name, providerType, b => b(config, embeddingProvider)); + public IRagProvider Create(string name, string providerType, IConfigurationSection config, IEmbeddingProvider embeddingProvider) + { + return GetOrCreate(name, providerType, b => b(config, embeddingProvider)); + } /// Returns a cached provider by name. - public IRagProvider? GetProvider(string name) => GetInstance(name); + public IRagProvider? GetProvider(string name) + { + return GetInstance(name); + } + + /// + public async ValueTask DisposeAsync() + { + foreach (var p in EnumerateInstances()) + await p.DisposeAsync(); + ClearInstances(); + } /// /// Creates a Qdrant RAG provider with direct parameters. @@ -85,12 +100,4 @@ protected override void DisposeInstances() foreach (var p in EnumerateInstances()) p.DisposeAsync().AsTask().GetAwaiter().GetResult(); } - - /// - public async ValueTask DisposeAsync() - { - foreach (var p in EnumerateInstances()) - await p.DisposeAsync(); - ClearInstances(); - } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Providers/RAG/TextChunker.cs b/src/Delibera.Core/Providers/RAG/TextChunker.cs index 0b6cda5..761b165 100644 --- a/src/Delibera.Core/Providers/RAG/TextChunker.cs +++ b/src/Delibera.Core/Providers/RAG/TextChunker.cs @@ -59,4 +59,4 @@ public static List SplitIntoChunks(string text, int chunkSize, int chunk return chunks; } -} +} \ No newline at end of file From 6613a3ad9856231304bd23a1f00b7402368318c8 Mon Sep 17 00:00:00 2001 From: Victor Buzin Date: Thu, 18 Jun 2026 19:04:17 +0200 Subject: [PATCH 04/14] Revamp README: expand docs, update samples, clarify config Major overhaul of README.md for clarity and completeness: - Expanded intro, features, and quick start with prerequisites - Improved code samples and formatting throughout - Added detailed sections for DI, Microsoft.Extensions.AI interop, context compression, RAG, Operator, and output files - Provided a more comprehensive appsettings.json example - Updated NuGet dependencies table and all repo links - README.md no longer included in NuGet package in .csproj - Numerous minor language, formatting, and consistency fixes --- README.md | 2 +- src/Delibera.Core/Delibera.Core.csproj | 1 - src/Delibera.Core/README.md | 364 +++++++++++++++---------- 3 files changed, 225 insertions(+), 142 deletions(-) diff --git a/README.md b/README.md index 15c220d..d180f50 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ [![.NET 10](https://img.shields.io/badge/.NET-10.0-1F2937.svg)](https://dotnet.microsoft.com) [![C# 15](https://img.shields.io/badge/C%23-15.0--preview-239120.svg)](https://learn.microsoft.com/dotnet/csharp/) -🇷🇺 [Русская версия (README-RU.md)](README-RU.md) +[EN](README.md) / [RU](README-RU.md) diff --git a/src/Delibera.Core/Delibera.Core.csproj b/src/Delibera.Core/Delibera.Core.csproj index 2471c5b..727d501 100644 --- a/src/Delibera.Core/Delibera.Core.csproj +++ b/src/Delibera.Core/Delibera.Core.csproj @@ -33,7 +33,6 @@ - diff --git a/src/Delibera.Core/README.md b/src/Delibera.Core/README.md index b515425..5c2d0fd 100644 --- a/src/Delibera.Core/README.md +++ b/src/Delibera.Core/README.md @@ -1,25 +1,27 @@ -
- -# Delibera +# [Delibera](https://www.nuget.org/packages/Delibera.Core) ### ⚖️ Thoughtful AI Decisions **Collective decision making through structured AI deliberation — with RAG, pgvector, Knowledge Keeper, Operator (MCP tools), Chairman, Context Compression, Dependency Injection & Execution Logging.** [![NuGet](https://img.shields.io/nuget/v/Delibera.Core.svg)](https://www.nuget.org/packages/Delibera.Core) -[![License: MIT](https://img.shields.io/badge/License-MIT-10B981.svg)](../LICENSE) +[![License: MIT](https://img.shields.io/badge/License-MIT-10B981.svg)](https://github.com/techbuzzz/Delibera/blob/develop/LICENSE) [![.NET 10](https://img.shields.io/badge/.NET-10.0-1F2937.svg)](https://dotnet.microsoft.com) - -
+[![C# 15](https://img.shields.io/badge/C%23-15.0--preview-239120.svg)](https://learn.microsoft.com/dotnet/csharp/) --- ## What is Delibera? **Delibera** is a C# / .NET 10 framework that orchestrates **multi-model deliberations** between LLMs. -Multiple models reason through a question across structured rounds, critique each other, and a -**Chairman** synthesises a balanced final verdict — optionally enriched by a **Knowledge Keeper** -backed by **Qdrant** or **PostgreSQL/pgvector** (RAG), with **context compression** to minimise tokens. +Multiple AI models reason through a question across structured rounds, critique each other's answers, +and a **Chairman** weighs the arguments to synthesise a balanced final verdict — enriched by a +**Knowledge Keeper** backed by **Qdrant** or **PostgreSQL/pgvector** (RAG), with **intelligent context +compression** to minimise token usage. + +The name comes from *deliberation* — the careful weighing of evidence and viewpoints before reaching +a decision. Delibera brings that discipline to AI, helping teams reach **thoughtful, well-reasoned +outcomes** rather than single-model guesses. --- @@ -28,14 +30,15 @@ backed by **Qdrant** or **PostgreSQL/pgvector** (RAG), with **context compressio - 🏛️ **Multi-Model Councils** — orchestrate any number of LLM participants across structured debate rounds - ⚖️ **Chairman Synthesis** — a dedicated moderator opens, regulates and synthesises the final verdict - 📚 **Knowledge Keeper (RAG)** — per-round semantic retrieval with Qdrant or pgvector +- 🐘 **Qdrant + pgvector** — pluggable vector stores (dedicated DB or your existing PostgreSQL) - 🛠️ **Operator (MCP Tools)** — a micro-agent that delegates tasks to MCP servers (web search, file system, Notion, PostgreSQL…) on demand during the debate - 🗜️ **Context Compression** — 4 strategies (Semantic, Deduplication, Summarization, Hybrid) save 30–70% of tokens -- 💉 **Dependency Injection** — `AddDelibera()` extension for `IServiceCollection` -- 📋 **Execution Logging** — `ExecutionLog` with `LogLevel` for Chairman, KK, Compression & participants +- 💉 **Dependency Injection** — `AddDelibera()` extension for `IServiceCollection` with full options binding +- 📋 **Execution Logging** — `ExecutionLog` model with `LogLevel` for Chairman, KK, Compression & participants - 📁 **Separate File Output** — export `result.md`, `statistics.md`, `logs.md` independently - 🤝 **Microsoft.Extensions.AI** — first-class `IChatClient` / `IEmbeddingGenerator` interop with logging & function-invocation middleware - 🔌 **Interface-First** — clean abstractions for providers, factories, builders and executors -- 🧱 **Modern C# 12** — file-scoped namespaces, records, init-only properties, global usings +- 🧱 **Modern C# 15** — file-scoped namespaces, records, init-only properties, global usings --- @@ -47,7 +50,12 @@ backed by **Qdrant** or **PostgreSQL/pgvector** (RAG), with **context compressio dotnet add package Delibera.Core ``` -### 2. Pull the recommended models +### 2. Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- A running [Ollama](https://ollama.com) instance — local (`ollama serve`) or [Ollama Cloud](https://ollama.com/cloud) (just an API key, no install). + +### 3. Pull the recommended models ```bash ollama pull llama3.2:3b # council member @@ -56,9 +64,9 @@ ollama pull nomic-embed-text # embeddings (RAG) ``` > ☁️ To use **Ollama Cloud** instead, just pass an API key — no local models required. -> See the [Configuration section](#-dependency-injection) below. +> See the [Configuration section](#-configuration-appsettingsjson) below. -### 3. Run your first deliberation +### 4. Run your first deliberation ```csharp using Delibera.Core.Council; @@ -68,16 +76,16 @@ using var factory = new ProviderFactory(); var ollama = factory.CreateOllama("http://localhost:11434"); var result = await new CouncilBuilder() - .AddMember("llama3.2:3b", ollama, "Analyst") - .AddMember("qwen2.5:7b", ollama, "Strategist") - .SetChairman(Chairman.CreateStandard("qwen2.5:7b", ollama)) - .WithStandardDebate() - .WithSystemPrompt("You are a software architecture expert.") - .WithUserPrompt("Microservices vs Monolith for a 5-person startup?") - .WithMaxRounds(4) - .SaveResultTo("./deliberation.md") - .Build() - .ExecuteAsync(); + .AddMember("llama3.2:3b", ollama, "Analyst") + .AddMember("qwen2.5:7b", ollama, "Strategist") + .SetChairman(Chairman.CreateStandard("qwen2.5:7b", ollama)) + .WithStandardDebate() + .WithSystemPrompt("You are a software architecture expert.") + .WithUserPrompt("Microservices vs Monolith for a 5-person startup?") + .WithMaxRounds(4) + .SaveResultTo("./deliberation.md") + .Build() + .ExecuteAsync(); Console.WriteLine(result.FinalVerdict); ``` @@ -86,19 +94,23 @@ Console.WriteLine(result.FinalVerdict); ## 💉 Dependency Injection +Register all Delibera services in one line: + ```csharp using Delibera.Core.DependencyInjection; -// A: with configuration binding +// A: with configuration binding (binds the "Delibera" section) services.AddDelibera(configuration, "Delibera"); // B: with options delegate services.AddDelibera(options => { - options.Strategy = "Standard"; - options.MaxRounds = 4; - options.Compression.Enabled = true; - options.Compression.Strategy = "Hybrid"; + options.Strategy = "Standard"; + options.MaxRounds = 4; + options.Temperature = 0.7f; + options.Compression.Enabled = true; + options.Compression.Strategy = "Hybrid"; + options.Compression.TargetRatio = 0.5; }); // C: defaults only @@ -116,8 +128,54 @@ Resolved services: --- +## 🤝 Microsoft.Extensions.AI Interop + +Delibera integrates with [`Microsoft.Extensions.AI`](https://learn.microsoft.com/dotnet/ai/microsoft-extensions-ai) +through `ChatClientLLMProvider` and `EmbeddingGeneratorProvider`. Adapt **any** `IChatClient` / +`IEmbeddingGenerator` (OpenAI, Azure OpenAI, Ollama, custom…) and use the full middleware pipeline +(logging, function invocation, telemetry). + +```csharp +using Delibera.Core.Providers.LLM; +using Microsoft.Extensions.AI; +using OpenAI; + +var openAi = new OpenAIClient(apiKey); + +// IChatClient — works with any MEAI-compatible client +var chatClient = openAi.GetChatClient("gpt-4o").AsIChatClient(); +var llm = new ChatClientLLMProvider(chatClient); + +// IEmbeddingGenerator — for Knowledge Keeper / compression +var embeddingGen = openAi.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator(); +var embeddings = new EmbeddingGeneratorProvider(embeddingGen); + +var result = await new CouncilBuilder() + .AddMember("gpt-4o", llm, "Analyst") + .SetChairman(Chairman.CreateStandard("gpt-4o", llm)) + .WithStandardDebate() + .WithUserPrompt("Should we adopt event sourcing?") + .Build() + .ExecuteAsync(); +``` + +Streaming via `ChatStreamAsync` and DI helpers (`AddDeliberaChatClient`) are also included. Fully +backward compatible with the existing `OllamaProvider` / `OllamaEmbeddingProvider` APIs. + +--- + ## 🗜️ Context Compression +Automatically compress context between deliberation rounds — save **30–70% of tokens** without losing meaning. + +| Strategy | How It Works | Best For | +| ----------------- | ---------------------------------------------------------- | -------------------------------- | +| **Semantic** | Embeds sentences, ranks by relevance to topic, keeps top-N | Large knowledge contexts | +| **Deduplication** | Removes semantically similar sentences across participants | Multi-model debates with overlap | +| **Summarization** | LLM produces a concise summary preserving key facts | Maximum compression ratio | +| **Hybrid** | Dedup → Semantic → Summarize pipeline | Best overall quality | +| **None** | Pass-through (when disabled) | Debugging | + ```csharp using Delibera.Core.Compression; using Delibera.Core.Providers.LLM; @@ -126,26 +184,27 @@ var ollama = new OllamaProvider("http://localhost:11434"); var embeddings = new OllamaEmbeddingProvider(ollama, "nomic-embed-text"); var result = await new CouncilBuilder() - .AddMember("llama3.2:3b", ollama) - .SetChairman(Chairman.CreateStandard("qwen2.5:7b", ollama)) - .WithCompression(CompressionStrategy.Hybrid, - llmProvider: ollama, - modelName: "llama3.2:3b", - embeddingProvider: embeddings) - .WithCompressionCache() - .WithUserPrompt("Analyze our architecture options...") - .Build() - .ExecuteAsync(); + .AddMember("llama3.2:3b", ollama) + .SetChairman(Chairman.CreateStandard("qwen2.5:7b", ollama)) + .WithCompression(CompressionStrategy.Hybrid, + llmProvider: ollama, + modelName: "llama3.2:3b", + embeddingProvider: embeddings) + .WithCompressionOptions(new CompressionOptions { TargetRatio = 0.5 }) + .WithCompressionCache() + .WithUserPrompt("Analyze our architecture options...") + .Build() + .ExecuteAsync(); Console.WriteLine(result.TokenStats?.ToSummary()); ``` -Strategies: **Semantic**, **Deduplication**, **Summarization**, **Hybrid** (Dedup → Semantic → Summarize). - --- ## 📚 RAG Integration +Use a dedicated **Qdrant** instance or your existing **PostgreSQL/pgvector** database as a vector store. + ```csharp using Delibera.Core.Council; using Delibera.Core.Models; @@ -155,22 +214,27 @@ using Delibera.Core.Providers.RAG; var ollama = new OllamaProvider("http://localhost:11434"); var embeddings = new OllamaEmbeddingProvider(ollama, "nomic-embed-text"); -// pgvector — point at your existing PostgreSQL -var rag = new RagProviderFactory().CreatePgVector( - embeddings, - "Host=localhost;Database=council_vectors;Username=postgres;Password=postgres"); - -// …or Qdrant -// var rag = new RagProviderFactory().CreateQdrant(embeddings, "localhost", 6334); +// pgvector — just add a connection string +var ragFactory = new RagProviderFactory(); +var rag = ragFactory.CreatePgVector( + embeddings, + "Host=localhost;Database=council_vectors;Username=postgres;Password=postgres"); -var keeper = new KnowledgeKeeper( - rag, - new CouncilMember("llama3.2:3b", ollama, "Knowledge Keeper"), - "my_knowledge"); +await rag.IndexDocumentAsync("my_collection", documentText); +var results = await rag.SearchAsync("my_collection", "query", limit: 5); +// Wire into a Knowledge Keeper +var kkMember = new CouncilMember("llama3.2:3b", ollama, "Knowledge Keeper"); +var keeper = new KnowledgeKeeper(rag, kkMember, "my_knowledge"); await keeper.IndexFileAsync("./docs/architecture.md"); ``` +…or use **Qdrant**: + +```csharp +var rag = ragFactory.CreateQdrant(embeddings, "localhost", 6334); +``` + --- ## 🛠️ Operator (MCP Tools) @@ -184,16 +248,16 @@ to the debate participants. 1. The Operator connects to one or more MCP servers and discovers their tools on `InitializeAsync`. 2. Participants are told (in their system prompt) what the Operator can do, and can delegate a task at - **any moment** during the debate by writing a marker in their message: + **any moment** during the debate by writing a marker in their message: - ``` - [[OPERATOR: search the web for the latest EU AI Act timeline and save it to Notion]] - ``` + ``` + [[OPERATOR: search the web for the latest EU AI Act timeline and save it to Notion]] + ``` 3. The Operator interprets the request with its **own (cheaper) LLM model**, picks and calls the right - MCP tools, interprets the results, and returns a concise answer that is injected into the next round. + MCP tools, interprets the results, and returns a concise answer that is injected into the next round. 4. If the council uses **context compression**, the Operator can reuse the same strategy to compress - large tool outputs before they re-enter the debate. + large tool outputs before they re-enter the debate. All Operator interactions are recorded per round and rendered in the final Markdown report under a **🛠️ Operator Interactions** block. @@ -210,32 +274,30 @@ var ollama = new OllamaProvider("http://localhost:11434"); // Define the MCP servers the Operator may use var servers = new[] { - // stdio transport — launches a local MCP server process - McpServerConfig.Stdio( - name: "everything", - command: "npx", - arguments: new[] { "-y", "@modelcontextprotocol/server-everything" }), - - McpServerConfig.Stdio( - name: "filesystem", - command: "npx", - arguments: new[] { "-y", "@modelcontextprotocol/server-filesystem", "/data" }), - - // …or an HTTP/SSE transport for a remote MCP server - // McpServerConfig.Http( - // name: "remote", - // endpoint: "https://my-mcp-host.example.com/mcp", - // additionalHeaders: new Dictionary { ["Authorization"] = "Bearer " }), + McpServerConfig.Stdio( + name: "everything", + command: "npx", + arguments: new[] { "-y", "@modelcontextprotocol/server-everything" }), + + McpServerConfig.Stdio( + name: "filesystem", + command: "npx", + arguments: new[] { "-y", "@modelcontextprotocol/server-filesystem", "/data" }), + + // McpServerConfig.Http( + // name: "remote", + // endpoint: "https://my-mcp-host.example.com/mcp", + // additionalHeaders: new Dictionary { ["Authorization"] = "Bearer " }), }; var council = new CouncilBuilder() - .AddMember("gpt-oss:20b", ollama, "Optimist") - .AddMember("llama3.1:8b", ollama, "Skeptic") - .WithChairman("gpt-oss:20b", ollama) - // Operator uses its own cheaper model; reuseCompression shares the council's compressor - .WithOperator("llama3.2:3b", ollama, servers, reuseCompression: true) - .WithTopic("Should we migrate the data pipeline to Kafka?") - .Build(); + .AddMember("gpt-oss:20b", ollama, "Optimist") + .AddMember("llama3.1:8b", ollama, "Skeptic") + .WithChairman("gpt-oss:20b", ollama) + // Operator uses its own cheaper model; reuseCompression shares the council's compressor + .WithOperator("llama3.2:3b", ollama, servers, reuseCompression: true) + .WithTopic("Should we migrate the data pipeline to Kafka?") + .Build(); var result = await council.ExecuteAsync(); ``` @@ -244,22 +306,17 @@ Prefer to build the `Operator` yourself? Pass a pre-built instance: ```csharp var @operator = new Operator( - new CouncilMember("llama3.2:3b", ollama, "Operator"), - new IMcpClient[] { new McpClientAdapter(servers[0]), new McpClientAdapter(servers[1]) }, - compressor: null, // optional IContextCompressor - compressionOptions: null); // optional CompressionOptions + new CouncilMember("llama3.2:3b", ollama, "Operator"), + new IMcpClient[] { new McpClientAdapter(servers[0]), new McpClientAdapter(servers[1]) }, + compressor: null, // optional IContextCompressor + compressionOptions: null); // optional CompressionOptions var council = new CouncilBuilder() - /* …members… */ - .WithOperator(@operator) - .Build(); + /* …members… */ + .WithOperator(@operator) + .Build(); ``` -### Dependency Injection - -Configure the Operator declaratively in `appsettings.json` under `Delibera:Operator` (see the -configuration section below), then build the council from `CouncilOptions` as usual. - --- ## 🗣️ Debate Strategies @@ -270,10 +327,16 @@ configuration section below), then build the council from `CouncilOptions` as us | **CritiqueDebate** | Position → Attack → Defence → Judge | Hypothesis testing | | **ConsensusDebate** | Perspectives → Common Ground → Consensus → Facilitator | Optimal solution search | +Each strategy is implemented as an `IDebateStrategy` — combine with `Builder`, `Template Method` +(`DebateScenario`) and `Factory` patterns (`ProviderFactory`, `RagProviderFactory`, `CompressionFactory`, +`Chairman`) to compose custom flows. + --- ## 📁 Output Files +Each deliberation can be exported as a single file or as three separate Markdown documents: + ```csharp var result = await executor.ExecuteAsync(); @@ -287,6 +350,12 @@ var (resultPath, statsPath, logsPath) = await result.SaveAllAsync("./output"); // → debate__logs.md (ExecutionLog) ``` +| File | Contents | +| ----------------- | ---------------------------------------------------------------------------- | +| `*_result.md` | Full deliberation transcript, rounds, and the Chairman's final verdict | +| `*_statistics.md` | Token usage statistics with per-round breakdown | +| `*_logs.md` | Execution logs (`ExecutionLog`) for Chairman, KK, compression & participants | + --- ## 📦 Configuration (`appsettings.json`) @@ -294,37 +363,53 @@ var (resultPath, statsPath, logsPath) = await result.SaveAllAsync("./output"); ```json { "Delibera": { - "Strategy": "Standard", - "MaxRounds": 4, - "Temperature": 0.7, - "Providers": { - "DefaultType": "Ollama", - "DefaultEndpoint": "http://localhost:11434", - "ApiKey": "", - "EmbeddingModel": "nomic-embed-text" - }, - "Compression": { "Enabled": true, "Strategy": "Hybrid", "TargetRatio": 0.5 }, - "Rag": { "Enabled": false, "ProviderType": "Qdrant", "Host": "localhost", "Port": 6334 }, - "Operator": { - "Enabled": true, - "ModelName": "llama3.2:3b", - "ReuseCompression": true, - "McpServers": [ - { - "Name": "filesystem", - "Transport": "Stdio", - "Command": "npx", - "Arguments": [ "-y", "@modelcontextprotocol/server-filesystem", "/data" ] - }, - { - "Name": "remote", - "Transport": "Http", - "Endpoint": "https://my-mcp-host.example.com/mcp", - "AdditionalHeaders": { "Authorization": "Bearer " } - } - ] - }, - "Output": { "Directory": "./debate_results", "SeparateFiles": true } + "Strategy": "Standard", + "MaxRounds": 4, + "Temperature": 0.7, + "SystemPrompt": "You are a knowledgeable AI expert participating in a council debate.", + "Providers": { + "DefaultType": "Ollama", + "DefaultEndpoint": "http://localhost:11434", + "ApiKey": "", + "EmbeddingModel": "nomic-embed-text" + }, + "Compression": { + "Enabled": true, + "Strategy": "Hybrid", + "TargetRatio": 0.5, + "EnableCache": true, + "MaxCacheEntries": 256 + }, + "Rag": { + "Enabled": false, + "ProviderType": "Qdrant", + "Host": "localhost", + "Port": 6334, + "CollectionName": "council_knowledge" + }, + "Operator": { + "Enabled": true, + "ModelName": "llama3.2:3b", + "ReuseCompression": true, + "McpServers": [ + { + "Name": "filesystem", + "Transport": "Stdio", + "Command": "npx", + "Arguments": [ "-y", "@modelcontextprotocol/server-filesystem", "/data" ] + }, + { + "Name": "remote", + "Transport": "Http", + "Endpoint": "https://my-mcp-host.example.com/mcp", + "AdditionalHeaders": { "Authorization": "Bearer " } + } + ] + }, + "Output": { + "Directory": "./debate_results", + "SeparateFiles": true + } } } ``` @@ -336,31 +421,30 @@ in `Providers:ApiKey`. ## 📦 NuGet Dependencies -| Package | Purpose | -| ------------------------ | -------------------------------- | -| `OllamaSharp` | Ollama API client | -| `Qdrant.Client` | Qdrant vector DB gRPC client | -| `Npgsql` / `Pgvector` | PostgreSQL/pgvector support | -| `ModelContextProtocol` | MCP client for the Operator role | -| `Microsoft.Extensions.AI`| `IChatClient` / `IEmbeddingGenerator` abstractions & middleware | -| `Microsoft.Extensions.*` | Configuration, DI and Options | +| Package | Purpose | +| ------------------------- | ----------------------------------------------------------- | +| `Microsoft.Extensions.AI` | `IChatClient` / `IEmbeddingGenerator` abstractions & middleware | +| `OllamaSharp` | Ollama API client | +| `Qdrant.Client` | Qdrant vector DB gRPC client | +| `Npgsql` / `Pgvector` | PostgreSQL/pgvector support | +| `ModelContextProtocol` | MCP client for the Operator role | +| `Microsoft.Extensions.*` | Configuration, DI and Options | --- ## 📚 Learn More -- 📖 Full README (architecture, design patterns, console app examples) → [github.com/delibera/Delibera](https://github.com/delibera/Delibera) -- 📄 Step-by-step walkthrough → [docs/QuickStart.md](https://github.com/delibera/Delibera/blob/main/docs/QuickStart.md) -- 🤝 [CONTRIBUTING.md](https://github.com/delibera/Delibera/blob/main/CONTRIBUTING.md) +- 📖 Full README (architecture, design patterns, console app examples) → [github.com/techbuzzz/Delibera](https://github.com/techbuzzz/Delibera) +- 📄 Step-by-step walkthrough → [docs/QuickStart.md](https://github.com/techbuzzz/Delibera/blob/develop/docs/QuickStart.md) +- 💻 Console app examples → [src/Delibera.ConsoleApp](https://github.com/techbuzzz/Delibera/tree/develop/src/Delibera.ConsoleApp) +- 🤝 [CONTRIBUTING.md](https://github.com/techbuzzz/Delibera/blob/develop/CONTRIBUTING.md) --- ## 📄 License -[MIT](../LICENSE) — Copyright © 2026 Delibera Project. - -
+[MIT](https://github.com/techbuzzz/Delibera/blob/develop/LICENSE) — Copyright © 2026 Delibera Project. **⚖️ Delibera — Thoughtful AI Decisions** -
+*Built with care for AI-powered collective intelligence* \ No newline at end of file From 2ce7385e0bc92ef51f12758b37e5bff7f78bebad Mon Sep 17 00:00:00 2001 From: Victor Buzin Date: Fri, 19 Jun 2026 11:04:17 +0200 Subject: [PATCH 05/14] Add NuGet icon and include README.md in package Added icon.png as the NuGet package icon via and ensured README.md is packed with the package for better documentation. --- src/Delibera.Core/Delibera.Core.csproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Delibera.Core/Delibera.Core.csproj b/src/Delibera.Core/Delibera.Core.csproj index 727d501..2d3a366 100644 --- a/src/Delibera.Core/Delibera.Core.csproj +++ b/src/Delibera.Core/Delibera.Core.csproj @@ -21,6 +21,7 @@ Copyright © 2026 Techbuzzz llm;ai;debate;council;deliberation;rag;qdrant;pgvector;ollama;openai;vector-search;embeddings;dependency-injection README.md + icon.png MIT https://github.com/techbuzzz/Delibera git @@ -34,6 +35,7 @@ + From adb1e034febafef7c4ac1b9a07381dd7f67aa4ae Mon Sep 17 00:00:00 2001 From: Victor Buzin Date: Wed, 24 Jun 2026 17:53:35 +0200 Subject: [PATCH 06/14] Add real-time logging, error events, and crash guard Introduce top-level crash guard and color-coded log streaming in Program.cs. Add OnLog and OnError events to CouncilExecutor and ICouncilExecutor for real-time observability. Refactor error handling to use new events. Update AskAllAsync to disambiguate responses by role and display name. Improve console output formatting and banner. --- src/Delibera.ConsoleApp/Program.cs | 179 ++++++++++++++++-- src/Delibera.Core/Council/CouncilExecutor.cs | 15 +- src/Delibera.Core/Debate/DebateScenario.cs | 23 ++- .../Interfaces/ICouncilExecutor.cs | 15 ++ 4 files changed, 214 insertions(+), 18 deletions(-) diff --git a/src/Delibera.ConsoleApp/Program.cs b/src/Delibera.ConsoleApp/Program.cs index 1fcb618..de1965d 100644 --- a/src/Delibera.ConsoleApp/Program.cs +++ b/src/Delibera.ConsoleApp/Program.cs @@ -26,6 +26,30 @@ 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; + try + { + await RunAsync(args, () => alreadyReported = true).ConfigureAwait(false); + } + catch (Exception ex) + { + if (!alreadyReported) + PrintFatalError(ex); + + WaitForKeyOnExit("Press any key to exit…", isError: true); + return; + } + + WaitForKeyOnExit("\n🏁 Delibera session complete. Press any key to exit…", isError: false); + } + + private static async Task RunAsync(string[] args, Action onDebateFailed) + { + // ═══════════════════════════════════════════════ // 🆕 v3.1: DI & Separate Files Examples // ═══════════════════════════════════════════════ @@ -400,6 +424,21 @@ public static async Task Main(string[] args) Console.WriteLine("🎯 Starting debate...\n"); Console.WriteLine(new string('═', 60)); + // Stream every ExecutionLog entry live so the user can watch progress in real time. + // Stored separately so we can also dump the full transcript at the end. + var liveLogs = new List(); + executor.OnLog += entry => + { + liveLogs.Add(entry); + WriteLogEntry(entry); + }; + + // Surface non-fatal internal errors (e.g. failed MCP tool call) without aborting the debate. + executor.OnError += (ex, context) => + { + WriteErrorEntry(ex, context); + }; + executor.OnRoundCompleted += round => { Console.WriteLine($"\n✅ Round {round.RoundNumber}: {round.RoundName} ({round.Duration.TotalSeconds:F1}s)"); @@ -503,16 +542,15 @@ public static async Task Main(string[] args) } catch (Exception ex) { - Console.WriteLine($"\n❌ Debate failed: {ex.Message}"); + PrintFatalError(ex, header: "❌ 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;"); - Console.WriteLine($"\n{ex.StackTrace}"); + onDebateFailed(); + throw; } - - Console.WriteLine("\n🏁 Delibera session complete."); } private static void PrintBanner() @@ -520,19 +558,132 @@ private static void PrintBanner() Console.ForegroundColor = ConsoleColor.Green; Console.WriteLine(""" - ██████╗ ███████╗██╗ ██╗██████╗ ███████╗██████╗ █████╗ - ██╔══██╗██╔════╝██║ ██║██╔══██╗██╔════╝██╔══██╗██╔══██╗ - ██║ ██║█████╗ ██║ ██║██████╔╝█████╗ ██████╔╝███████║ - ██║ ██║██╔══╝ ██║ ██║██╔══██╗██╔══╝ ██╔══██╗██╔══██║ - ██████╔╝███████╗███████╗██║██████╔╝███████╗██║ ██║██║ ██║ - ╚═════╝ ╚══════╝╚══════╝╚═╝╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ + ██████╗ ███████╗██╗ ██╗██████╗ ███████╗██████╗ █████╗ + ██╔══██╗██╔════╝██║ ██║██╔══██╗██╔════╝██╔══██╗██╔══██╗ + ██║ ██║█████╗ ██║ ██║██████╔╝█████╗ ██████╔╝███████║ + ██║ ██║██╔══╝ ██║ ██║██╔══██╗██╔══╝ ██╔══██╗██╔══██║ + ██████╔╝███████╗███████╗██║██████╔╝███████╗██║ ██║██║ ██║ + ╚═════╝ ╚══════╝╚══════╝╚═╝╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ - ⚖️ Thoughtful AI Decisions · v3.1 + ⚖️ Thoughtful AI Decisions · v3.1 - RAG • pgvector • Knowledge Keeper • Chairman - Context Compression • DI • Execution Logging + RAG • pgvector • Knowledge Keeper • Chairman + Context Compression • DI • Execution Logging - """); + """); Console.ResetColor(); } + + // ─── 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. + /// + private static void WriteLogEntry(ExecutionLog entry) + { + var prev = Console.ForegroundColor; + Console.ForegroundColor = entry.Level switch + { + LogLevel.Trace => ConsoleColor.DarkGray, + LogLevel.Info => ConsoleColor.Cyan, + LogLevel.Warning => ConsoleColor.Yellow, + LogLevel.Error => ConsoleColor.Red, + _ => prev + }; + + Console.WriteLine($" ┊ {entry}"); + Console.ForegroundColor = prev; + } + + /// + /// Writes a non-fatal internal error to the console with a small stack-trace + /// excerpt. The debate continues; this is purely informational. + /// + private static void WriteErrorEntry(Exception ex, string context) + { + var prev = Console.ForegroundColor; + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($" ┊ ⚠ {context} error: {ex.Message}"); + if (ex.StackTrace is not null) + { + var firstFrame = ex.StackTrace + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault(); + if (firstLineIsMeaningful(firstFrame)) + Console.WriteLine($" ┊ at {firstFrame.Trim()}"); + } + + Console.ForegroundColor = prev; + } + + /// + /// Prints a full diagnostic panel for a fatal exception: type, message, + /// full stack trace and the live log transcript captured so far. + /// + /// 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); + } + + Console.ForegroundColor = prev; + } + + /// + /// 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. + /// + private static void WaitForKeyOnExit(string prompt, bool isError) + { + if (isError) + Console.ForegroundColor = ConsoleColor.Red; + + Console.WriteLine(); + Console.WriteLine(prompt); + Console.ResetColor(); + + try + { + Console.ReadKey(intercept: true); + } + catch (InvalidOperationException) + { + // No interactive console (e.g. redirected stdin in CI) — fall back gracefully. + Console.WriteLine("(no interactive console available; exiting.)"); + } + + Environment.ExitCode = isError ? 1 : 0; + } + + private static bool firstLineIsMeaningful(string? frame) + { + if (string.IsNullOrWhiteSpace(frame)) + return false; + + var trimmed = frame.Trim(); + // Filter out noise from runtime/compiler-emitted frames. + return !trimmed.StartsWith("at System.", StringComparison.Ordinal) + || trimmed.Contains("Delibera", StringComparison.Ordinal); + } } \ No newline at end of file diff --git a/src/Delibera.Core/Council/CouncilExecutor.cs b/src/Delibera.Core/Council/CouncilExecutor.cs index 05e8240..9e59fd8 100644 --- a/src/Delibera.Core/Council/CouncilExecutor.cs +++ b/src/Delibera.Core/Council/CouncilExecutor.cs @@ -70,6 +70,12 @@ internal CouncilExecutor( /// Invoked after each round completes. public event Action? OnRoundCompleted; + /// + public event Action? OnLog; + + /// + public event Action? OnError; + /// /// Runs the debate and returns the full result. /// @@ -97,7 +103,7 @@ public async Task ExecuteAsync(CancellationToken ct = default) } catch (Exception ex) { - Log(ExecutionLog.Error("Operator", $"Operator initialisation failed: {ex.Message}")); + ReportError(ex, "Operator"); } } @@ -253,6 +259,13 @@ public string GetInfo() private void Log(ExecutionLog entry) { _executionLogs.Add(entry); + OnLog?.Invoke(entry); + } + + private void ReportError(Exception ex, string context) + { + Log(ExecutionLog.Error(context, ex.Message)); + OnError?.Invoke(ex, context); } private static string Truncate(string text, int max) diff --git a/src/Delibera.Core/Debate/DebateScenario.cs b/src/Delibera.Core/Debate/DebateScenario.cs index bf09067..8df173b 100644 --- a/src/Delibera.Core/Debate/DebateScenario.cs +++ b/src/Delibera.Core/Debate/DebateScenario.cs @@ -59,16 +59,33 @@ protected static async Task> CollectResponsesAsync( try { var response = await member.AskAsync(systemPrompt, userPrompt, temperature, ct); - return (member.DisplayName, Response: response); + return (member.Role, member.DisplayName, Response: response); } catch (Exception ex) { - return (member.DisplayName, Response: $"[ERROR: {ex.Message}]"); + return (member.Role, member.DisplayName, Response: $"[ERROR: {ex.Message}]"); } }); var results = await Task.WhenAll(tasks); - return results.ToDictionary(r => r.DisplayName, r => r.Response); + //return results.ToDictionary(r => r.DisplayName, r => r.Response); + // Disambiguate by appending a counter while preserving the original label for unique names. + var seen = new HashSet(); + var responses = new Dictionary(results.Length); + foreach (var (role, displayName, response) in results) + { + var key = $"{role}: {displayName}"; + if (!seen.Add(key)) + { + var index = 2; + while (!seen.Add($"{key} #{index}")) + index++; + + key = $"{key} #{index}"; + } + + responses[key] = response; + } } /// Formats a single round's responses into readable text. diff --git a/src/Delibera.Core/Interfaces/ICouncilExecutor.cs b/src/Delibera.Core/Interfaces/ICouncilExecutor.cs index 74a1362..e97efc1 100644 --- a/src/Delibera.Core/Interfaces/ICouncilExecutor.cs +++ b/src/Delibera.Core/Interfaces/ICouncilExecutor.cs @@ -1,4 +1,5 @@ using Delibera.Core.Council; +using Delibera.Core.Models; namespace Delibera.Core.Interfaces; @@ -35,6 +36,20 @@ public interface ICouncilExecutor /// Invoked after each round completes. event Action? OnRoundCompleted; + /// + /// Invoked for every entry produced during + /// . Useful for streaming progress to a console + /// or another observer without having to wait until the debate finishes. + /// + event Action? OnLog; + + /// + /// Invoked when a non-fatal error is caught internally (e.g. failed MCP + /// tool call, failed knowledge index). The debate continues. Fatal errors + /// are surfaced via the exception path. + /// + event Action? OnError; + /// /// Runs the debate and returns the full result. /// From 0e3769638f4bb9c5b6f9eb03ec78c76a06fcb7ff Mon Sep 17 00:00:00 2001 From: Victor Buzin Date: Wed, 24 Jun 2026 18:20:08 +0200 Subject: [PATCH 07/14] Add logging, language enforcement, and parallel Operator - Integrate Microsoft.Extensions.Logging throughout debate pipeline; add WithLogger, DI logger injection, and ICouncilExecutor.Logger - Enforce response language via WithResponseLanguage and config; inject strict directive into all prompts - Support parallel Operator requests per round, bounded by MaxDegreeOfParallelism (builder/config) - Introduce DebateExecutionOptions to bundle logger, language, and parallelism for strategies - Rename LogLevel to ExecutionLogLevel to avoid naming clash; add mapping to Microsoft log levels - Update DI, configuration, README, and CHANGELOG for new features - Minor refactoring and improved comments throughout --- CHANGELOG.md | 34 ++++ src/Delibera.ConsoleApp/Program.cs | 54 ++--- src/Delibera.ConsoleApp/appsettings.json | 6 +- src/Delibera.Core/Council/CouncilBuilder.cs | 71 +++++-- src/Delibera.Core/Council/CouncilExecutor.cs | 191 +++++++++++------- src/Delibera.Core/Council/KnowledgeKeeper.cs | 8 +- src/Delibera.Core/Council/Operator.cs | 4 +- src/Delibera.Core/Debate/ConsensusDebate.cs | 69 ++++--- src/Delibera.Core/Debate/CritiqueDebate.cs | 69 ++++--- src/Delibera.Core/Debate/DebateScenario.cs | 190 ++++++++++------- src/Delibera.Core/Debate/StandardDebate.cs | 71 ++++--- src/Delibera.Core/Delibera.Core.csproj | 22 +- .../DependencyInjection/CouncilOptions.cs | 30 ++- .../ServiceCollectionExtensions.cs | 146 ++++++++----- src/Delibera.Core/GlobalUsings.cs | 1 + .../Interfaces/ICouncilBuilder.cs | 38 +++- .../Interfaces/ICouncilExecutor.cs | 32 +-- .../Interfaces/IDebateStrategy.cs | 108 +++++++--- .../Models/DebateExecutionOptions.cs | 84 ++++++++ src/Delibera.Core/Models/DebateResult.cs | 10 +- src/Delibera.Core/Models/ExecutionLog.cs | 42 +++- src/Delibera.Core/README.md | 109 +++++++++- src/README.md | 102 +++++++++- 23 files changed, 1077 insertions(+), 414 deletions(-) create mode 100644 src/Delibera.Core/Models/DebateExecutionOptions.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index f84b6b6..fedf229 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,40 @@ All notable changes to **Delibera** are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [10.2.0] - 2026 + +### Added — Microsoft.Extensions.Logging, response-language enforcement, parallel Operator + +- **Microsoft.Extensions.Logging support** — inject your own `ILogger` / `ILoggerFactory` and every + debate event (Chairman opening, Knowledge Keeper queries, compression, Operator interactions, + participant responses, errors) is forwarded to the host's logging pipeline. New APIs: + - `ICouncilBuilder.WithLogger(ILogger?)` and `CouncilBuilder.WithLogger(...)`. + - `ICouncilExecutor.Logger` property exposing the configured `ILogger`. + - `AddDelibera(IServiceCollection, IConfiguration, ILoggerFactory, string?)` DI overload that + auto-decorates every resolved `ICouncilBuilder` with a logger. + - `DebateExecutionOptions` record bundles the logger (plus response language + parallelism) + threaded through `IDebateStrategy.ExecuteAsync(...)` via a new + `IDebateStrategyWithOptions` interface (default method on `IDebateStrategy` keeps custom + strategies working unchanged). +- **Response-language enforcement** — `ICouncilBuilder.WithResponseLanguage(string?)` and + `CouncilOptions.ResponseLanguage`. When set, Delibera injects a strict directive into every + system and user prompt so all models (participants, Chairman, Knowledge Keeper, Operator) answer + exclusively in the chosen language, regardless of the prompt or retrieved RAG context. +- **Parallel Operator requests** — `[[OPERATOR: …]]` tasks delegated by participants within a round + now run concurrently via `Parallel.ForEachAsync`, bounded by + `ICouncilBuilder.WithMaxDegreeOfParallelism(int)` / `CouncilOptions.MaxDegreeOfParallelism` + (0 = unbounded, default). Delibera-shipped strategies (`StandardDebate`, `CritiqueDebate`, + `ConsensusDebate`) opt in via the new `ExecuteAsync(..., DebateExecutionOptions, ...)` overload. + +### Changed + +- **Renamed `LogLevel` enum to `ExecutionLogLevel`** (in `Delibera.Core.Models`) to avoid a name + clash with `Microsoft.Extensions.Logging.LogLevel`, which is now referenced throughout the + framework. The `ExecutionLog.Level` field, `DebateResult.ToLogsMarkdown()`, and the console + demo all use the renamed enum. `ExecutionLog.ToMicrosoftLogLevel()` maps to the M.E.Logging + severity. This is a source-breaking change for consumers that referenced `LogLevel.Info` etc. + directly; replace with `ExecutionLogLevel.Info`. + ## [10.1.1] - 2026 ### Added — Microsoft.Extensions.AI integration diff --git a/src/Delibera.ConsoleApp/Program.cs b/src/Delibera.ConsoleApp/Program.cs index de1965d..a3741a5 100644 --- a/src/Delibera.ConsoleApp/Program.cs +++ b/src/Delibera.ConsoleApp/Program.cs @@ -340,22 +340,26 @@ private static async Task RunAsync(string[] args, Action onDebateFailed) var maxRounds = debateCfg.GetValue("MaxRounds") ?? 4; var temperature = debateCfg.GetValue("Temperature") ?? 0.7f; - var systemPrompt = cfg["Prompts:SystemPrompt"] ?? "You are a helpful AI assistant participating in a council debate."; - var userPrompt = cfg["Prompts:UserPrompt"] ?? "What is the different between Microservices vs Monolith?"; - - IDebateStrategy strategy = stratName.ToLowerInvariant() switch - { - "critique" => new CritiqueDebate(), - "consensus" => new ConsensusDebate(), - _ => new StandardDebate() - }; - - var builder = new CouncilBuilder() - .WithStrategy(strategy) - .WithSystemPrompt(systemPrompt) - .WithUserPrompt(userPrompt) - .WithMaxRounds(maxRounds) - .WithTemperature(temperature); + var systemPrompt = cfg["Prompts:SystemPrompt"] ?? "You are a helpful AI assistant participating in a council debate."; + var userPrompt = cfg["Prompts:UserPrompt"] ?? "What is the different between Microservices vs Monolith?"; + var responseLanguage = debateCfg["ResponseLanguage"]; + var maxDegreeOfParallelism = debateCfg.GetValue("MaxDegreeOfParallelism") ?? 0; + + IDebateStrategy strategy = stratName.ToLowerInvariant() switch + { + "critique" => new CritiqueDebate(), + "consensus" => new ConsensusDebate(), + _ => new StandardDebate() + }; + + var builder = new CouncilBuilder() + .WithStrategy(strategy) + .WithSystemPrompt(systemPrompt) + .WithUserPrompt(userPrompt) + .WithMaxRounds(maxRounds) + .WithTemperature(temperature) + .WithResponseLanguage(responseLanguage) + .WithMaxDegreeOfParallelism(maxDegreeOfParallelism); // Add members foreach (var mc in cfg.GetSection("Models").GetChildren()) @@ -536,7 +540,7 @@ private static async Task RunAsync(string[] args, Action onDebateFailed) if (result.ExecutionLogs.Count > 0) { Console.WriteLine($"\n 📋 Execution Logs ({result.ExecutionLogs.Count} entries):"); - foreach (var log in result.ExecutionLogs.Where(l => l.Level >= LogLevel.Info)) + foreach (var log in result.ExecutionLogs.Where(l => l.Level >= ExecutionLogLevel.Info)) Console.WriteLine($" {log}"); } } @@ -584,14 +588,14 @@ Context Compression • DI • Execution Logging private static void WriteLogEntry(ExecutionLog entry) { var prev = Console.ForegroundColor; - Console.ForegroundColor = entry.Level switch - { - LogLevel.Trace => ConsoleColor.DarkGray, - LogLevel.Info => ConsoleColor.Cyan, - LogLevel.Warning => ConsoleColor.Yellow, - LogLevel.Error => ConsoleColor.Red, - _ => prev - }; + Console.ForegroundColor = entry.Level switch + { + ExecutionLogLevel.Trace => ConsoleColor.DarkGray, + ExecutionLogLevel.Info => ConsoleColor.Cyan, + ExecutionLogLevel.Warning => ConsoleColor.Yellow, + ExecutionLogLevel.Error => ConsoleColor.Red, + _ => prev + }; Console.WriteLine($" ┊ {entry}"); Console.ForegroundColor = prev; diff --git a/src/Delibera.ConsoleApp/appsettings.json b/src/Delibera.ConsoleApp/appsettings.json index 0db4444..9f0e1a7 100644 --- a/src/Delibera.ConsoleApp/appsettings.json +++ b/src/Delibera.ConsoleApp/appsettings.json @@ -8,6 +8,8 @@ "MaxRounds": 4, "Temperature": 0.7, "SystemPrompt": "You are a knowledgeable AI expert participating in a council debate. Provide thorough, well-reasoned answers.", + "ResponseLanguage": "Russian", + "MaxDegreeOfParallelism": 0, "Providers": { "DefaultType": "Ollama", @@ -92,7 +94,9 @@ "Strategy": "Standard", "MaxRounds": 4, "Temperature": 0.7, - "OutputDirectory": "./debate_results" + "OutputDirectory": "./debate_results", + "ResponseLanguage": "Russian", + "MaxDegreeOfParallelism": 0 }, "ContextCompression": { diff --git a/src/Delibera.Core/Council/CouncilBuilder.cs b/src/Delibera.Core/Council/CouncilBuilder.cs index aefb2ce..08f2de9 100644 --- a/src/Delibera.Core/Council/CouncilBuilder.cs +++ b/src/Delibera.Core/Council/CouncilBuilder.cs @@ -1,6 +1,7 @@ using Delibera.Core.Compression; using Delibera.Core.Debate; using Delibera.Core.Providers.Mcp; +using Microsoft.Extensions.Logging; namespace Delibera.Core.Council; @@ -16,12 +17,15 @@ public sealed class CouncilBuilder : ICouncilBuilder private IContextCompressor? _compressor; private IKnowledgeBase? _knowledgeBase; private KnowledgeKeeper? _knowledgeKeeper; + private ILogger? _logger; + private int _maxDegreeOfParallelism; private int _maxRounds = 4; private Operator? _operator; private CouncilMember? _operatorModel; private bool _operatorReuseCompression; private IReadOnlyList? _operatorServers; private string? _outputPath; + private string? _responseLanguage; private IDebateStrategy _strategy = new StandardDebate(); private string _systemPrompt = "You are a helpful AI assistant participating in a council debate."; private float _temperature = 0.7f; @@ -187,12 +191,33 @@ public ICouncilBuilder WithTemperature(float temperature) return this; } - /// - public ICouncilBuilder SaveResultTo(string outputPath) - { - _outputPath = outputPath; - return this; - } + /// + public ICouncilBuilder SaveResultTo(string outputPath) + { + _outputPath = outputPath; + return this; + } + + /// + public ICouncilBuilder WithResponseLanguage(string? language) + { + _responseLanguage = string.IsNullOrWhiteSpace(language) ? null : language.Trim(); + return this; + } + + /// + public ICouncilBuilder WithMaxDegreeOfParallelism(int maxDegreeOfParallelism) + { + _maxDegreeOfParallelism = Math.Max(0, maxDegreeOfParallelism); + return this; + } + + /// + public ICouncilBuilder WithLogger(ILogger? logger) + { + _logger = logger; + return this; + } /// ICouncilExecutor ICouncilBuilder.Build() @@ -266,18 +291,24 @@ public CouncilExecutor Build() : null); } - return new CouncilExecutor( - _members.AsReadOnly(), - _chairman, - _knowledgeKeeper, - _strategy, - context, - _maxRounds, - _temperature, - _outputPath, - _compressor, - _compressionOptions, - _compressionCache, - @operator); - } + var executionOptions = new DebateExecutionOptions( + ResponseLanguage: _responseLanguage, + MaxDegreeOfParallelism: _maxDegreeOfParallelism, + Logger: _logger); + + return new CouncilExecutor( + _members.AsReadOnly(), + _chairman, + _knowledgeKeeper, + _strategy, + context, + _maxRounds, + _temperature, + _outputPath, + _compressor, + _compressionOptions, + _compressionCache, + @operator, + executionOptions); + } } \ No newline at end of file diff --git a/src/Delibera.Core/Council/CouncilExecutor.cs b/src/Delibera.Core/Council/CouncilExecutor.cs index 9e59fd8..1a9b14e 100644 --- a/src/Delibera.Core/Council/CouncilExecutor.cs +++ b/src/Delibera.Core/Council/CouncilExecutor.cs @@ -1,4 +1,5 @@ using Delibera.Core.Compression; +using Microsoft.Extensions.Logging; namespace Delibera.Core.Council; @@ -27,7 +28,8 @@ internal CouncilExecutor( IContextCompressor? compressor = null, CompressionOptions? compressionOptions = null, CompressionCache? compressionCache = null, - Operator? @operator = null) + Operator? @operator = null, + DebateExecutionOptions? executionOptions = null) { Members = members; Chairman = chairman; @@ -41,6 +43,7 @@ internal CouncilExecutor( Compressor = compressor; _compressionOptions = compressionOptions; CompressionCache = compressionCache; + ExecutionOptions = executionOptions ?? DebateExecutionOptions.Default; } /// Compression cache (may be null). @@ -64,6 +67,15 @@ internal CouncilExecutor( /// Context compressor (may be null if compression is disabled). public IContextCompressor? Compressor { get; } + /// + /// Per-execution options (response language, parallelism budget, logger). + /// Populated from . + /// + public DebateExecutionOptions ExecutionOptions { get; } + + /// + public ILogger? Logger => ExecutionOptions.Logger; + /// public IReadOnlyList ExecutionLogs => _executionLogs.AsReadOnly(); @@ -76,73 +88,87 @@ internal CouncilExecutor( /// public event Action? OnError; - /// - /// Runs the debate and returns the full result. - /// - public async Task ExecuteAsync(CancellationToken ct = default) - { - _executionLogs.Clear(); - - Log(ExecutionLog.Info("Council", $"Starting debate — strategy: {Strategy.StrategyName}, members: {Members.Count}, maxRounds: {_maxRounds}")); - - if (Chairman is not null) - Log(ExecutionLog.Info("Chairman", $"Chairman assigned: {Chairman.DisplayName}")); - - if (KnowledgeKeeper is not null) - Log(ExecutionLog.Info("KnowledgeKeeper", $"Knowledge Keeper ready: {KnowledgeKeeper.DisplayName} (collection: {KnowledgeKeeper.CollectionName})")); - - // Initialise the Operator (connect to MCP servers, discover tools) before the debate begins. - if (Operator is not null) - { - if (!Operator.IsInitialized) - { - Log(ExecutionLog.Info("Operator", $"Initialising Operator: {Operator.DisplayName}…")); - try - { - await Operator.InitializeAsync(ct); - } - catch (Exception ex) - { - ReportError(ex, "Operator"); - } - } - - Log(ExecutionLog.Info("Operator", $"Operator ready: {Operator.DisplayName} ({Operator.AvailableTools.Count} tool(s) available)")); - } - - if (Compressor is not null) - Log(ExecutionLog.Info("Compression", $"Compression enabled: {Compressor.StrategyName}")); - - foreach (var m in Members) - Log(ExecutionLog.Trace("Council", $"Participant registered: {m.DisplayName} [{m.Role}]")); - - var result = await Strategy.ExecuteAsync( - Members, - _context, - Chairman, - KnowledgeKeeper, - Operator, - _maxRounds, - _temperature, - round => - { - Log(ExecutionLog.Info("Council", $"Round {round.RoundNumber} completed: {round.RoundName} ({round.Duration.TotalSeconds:F1}s, {round.Responses.Count} responses)")); - - // Log knowledge interactions - foreach (var ki in round.KnowledgeInteractions) - Log(ExecutionLog.Info("KnowledgeKeeper", $"Query: \"{Truncate(ki.Query, 100)}\" → {ki.SourceChunks} chunks")); - - // Log operator interactions - foreach (var oi in round.OperatorInteractions) - Log(ExecutionLog.Info("Operator", $"{oi.RequesterName} → \"{Truncate(oi.Task, 100)}\" ({oi.ToolCallCount} tool call(s))")); - - // Log participant responses - foreach (var (member, response) in round.Responses) - Log(ExecutionLog.Trace("Participant", $"{member} responded ({response.Length} chars)")); - - OnRoundCompleted?.Invoke(round); - }, - ct); + /// + /// Runs the debate and returns the full result. + /// + public async Task ExecuteAsync(CancellationToken ct = default) + { + _executionLogs.Clear(); + + Log(ExecutionLog.Info("Council", $"Starting debate — strategy: {Strategy.StrategyName}, members: {Members.Count}, maxRounds: {_maxRounds}")); + + if (ExecutionOptions.HasResponseLanguage) + Log(ExecutionLog.Info("Council", $"Response language enforced: {ExecutionOptions.ResponseLanguage}")); + + if (ExecutionOptions.MaxDegreeOfParallelism > 0) + Log(ExecutionLog.Info("Council", $"Parallelism cap: {ExecutionOptions.MaxDegreeOfParallelism}")); + + if (Chairman is not null) + Log(ExecutionLog.Info("Chairman", $"Chairman assigned: {Chairman.DisplayName}")); + + if (KnowledgeKeeper is not null) + Log(ExecutionLog.Info("KnowledgeKeeper", $"Knowledge Keeper ready: {KnowledgeKeeper.DisplayName} (collection: {KnowledgeKeeper.CollectionName})")); + + // Initialise the Operator (connect to MCP servers, discover tools) before the debate begins. + if (Operator is not null) + { + if (!Operator.IsInitialized) + { + Log(ExecutionLog.Info("Operator", $"Initialising Operator: {Operator.DisplayName}…")); + try + { + await Operator.InitializeAsync(ct); + } + catch (Exception ex) + { + ReportError(ex, "Operator"); + } + } + + Log(ExecutionLog.Info("Operator", $"Operator ready: {Operator.DisplayName} ({Operator.AvailableTools.Count} tool(s) available)")); + } + + if (Compressor is not null) + Log(ExecutionLog.Info("Compression", $"Compression enabled: {Compressor.StrategyName}")); + + foreach (var m in Members) + Log(ExecutionLog.Trace("Council", $"Participant registered: {m.DisplayName} [{m.Role}]")); + + // Inject the response-language directive into the system prompt so every downstream + // call (participants, Chairman.OpenDebateAsync / SynthesizeVerdictAsync, Knowledge Keeper, + // Operator) inherits it. + var effectiveContext = ExecutionOptions.HasResponseLanguage + ? _context with { SystemPrompt = _context.SystemPrompt + ExecutionOptions.BuildLanguageDirective() } + : _context; + + var result = await Strategy.ExecuteAsync( + Members, + effectiveContext, + Chairman, + KnowledgeKeeper, + Operator, + ExecutionOptions, + _maxRounds, + _temperature, + round => + { + Log(ExecutionLog.Info("Council", $"Round {round.RoundNumber} completed: {round.RoundName} ({round.Duration.TotalSeconds:F1}s, {round.Responses.Count} responses)")); + + // Log knowledge interactions + foreach (var ki in round.KnowledgeInteractions) + Log(ExecutionLog.Info("KnowledgeKeeper", $"Query: \"{Truncate(ki.Query, 100)}\" → {ki.SourceChunks} chunks")); + + // Log operator interactions + foreach (var oi in round.OperatorInteractions) + Log(ExecutionLog.Info("Operator", $"{oi.RequesterName} → \"{Truncate(oi.Task, 100)}\" ({oi.ToolCallCount} tool call(s))")); + + // Log participant responses + foreach (var (member, response) in round.Responses) + Log(ExecutionLog.Trace("Participant", $"{member} responded ({response.Length} chars)")); + + OnRoundCompleted?.Invoke(round); + }, + ct); Log(ExecutionLog.Info("Council", $"Debate completed — {result.Rounds.Count} rounds, duration: {result.TotalDuration.TotalSeconds:F1}s")); @@ -199,10 +225,16 @@ public string GetInfo() sb.AppendLine("║ LLM COUNCIL v3.1 CONFIGURATION ║"); sb.AppendLine("╚══════════════════════════════════════════╝"); sb.AppendLine(); - sb.AppendLine($" Strategy: {Strategy.StrategyName}"); - sb.AppendLine($" Max Rounds: {_maxRounds}"); - sb.AppendLine($" Temperature: {_temperature:F2}"); - sb.AppendLine(); + sb.AppendLine($" Strategy: {Strategy.StrategyName}"); + sb.AppendLine($" Max Rounds: {_maxRounds}"); + sb.AppendLine($" Temperature: {_temperature:F2}"); + if (ExecutionOptions.HasResponseLanguage) + sb.AppendLine($" Language: {ExecutionOptions.ResponseLanguage}"); + if (ExecutionOptions.MaxDegreeOfParallelism > 0) + sb.AppendLine($" Parallelism: {ExecutionOptions.MaxDegreeOfParallelism}"); + if (ExecutionOptions.Logger is not null) + sb.AppendLine($" Logger: {ExecutionOptions.Logger.GetType().Name}"); + sb.AppendLine(); sb.AppendLine(" ── Members ──"); foreach (var m in Members) sb.AppendLine($" • {m.DisplayName} [{m.Role}]"); @@ -258,15 +290,18 @@ public string GetInfo() private void Log(ExecutionLog entry) { - _executionLogs.Add(entry); + _executionLogs.Add(ExecutionLogSink.Emit(ExecutionOptions.Logger, entry)); OnLog?.Invoke(entry); } - private void ReportError(Exception ex, string context) - { - Log(ExecutionLog.Error(context, ex.Message)); - OnError?.Invoke(ex, context); - } + private void ReportError(Exception ex, string context) + { + var entry = ExecutionLog.Error(context, ex.Message); + _executionLogs.Add(ExecutionLogSink.Emit(ExecutionOptions.Logger, entry)); + OnError?.Invoke(ex, context); + + ExecutionOptions.Logger?.LogError(ex, "[{Source}] {Message}", context, ex.Message); + } private static string Truncate(string text, int max) { diff --git a/src/Delibera.Core/Council/KnowledgeKeeper.cs b/src/Delibera.Core/Council/KnowledgeKeeper.cs index 280c886..b80d04b 100644 --- a/src/Delibera.Core/Council/KnowledgeKeeper.cs +++ b/src/Delibera.Core/Council/KnowledgeKeeper.cs @@ -51,7 +51,7 @@ public async Task AnswerQuestionAsync( // 1. Retrieve context from RAG var context = await _ragProvider.GetContextAsync(CollectionName, question, limit, ct); - const string SystemPrompt = """ + const string systemPrompt = """ You are the Knowledge Keeper — a librarian and fact-checker for an AI council debate. Your role is to provide accurate, well-sourced answers based ONLY on the context provided. If the context does not contain sufficient information, clearly state what you know @@ -90,7 +90,7 @@ Cite relevant source numbers in your answer. } // 2. Generate answer via dedicated LLM - var answer = await _model.AskAsync(SystemPrompt, userPrompt, temperature, ct); + var answer = await _model.AskAsync(systemPrompt, userPrompt, temperature, ct); // 3. Log the interaction _interactions.Add(new KnowledgeInteraction(question, answer, sourceChunks)); @@ -118,7 +118,7 @@ public async Task AnswerWithContextAsync( sb.AppendLine(); } - const string SystemPrompt = """ + const string systemPrompt = """ You are the Knowledge Keeper — a librarian and fact-checker for an AI council debate. Provide accurate answers based ONLY on the context provided. Cite source numbers. """; @@ -131,7 +131,7 @@ Provide accurate answers based ONLY on the context provided. Cite source numbers {question} """; - var answer = await _model.AskAsync(SystemPrompt, userPrompt, temperature, ct); + var answer = await _model.AskAsync(systemPrompt, userPrompt, temperature, ct); _interactions.Add(new KnowledgeInteraction(question, answer, searchResults.Count)); return answer; } diff --git a/src/Delibera.Core/Council/Operator.cs b/src/Delibera.Core/Council/Operator.cs index 883f80e..c7e3ed1 100644 --- a/src/Delibera.Core/Council/Operator.cs +++ b/src/Delibera.Core/Council/Operator.cs @@ -189,7 +189,7 @@ public async ValueTask DisposeAsync() private async Task> PlanToolCallsAsync(string task, CancellationToken ct) { - const string SystemPrompt = """ + const string systemPrompt = """ You are the Operator's planner — a tool-routing micro-agent. Given a task and a list of available MCP tools, decide which tools to call. Respond with STRICT JSON only, no prose, in exactly this shape: @@ -218,7 +218,7 @@ Return the JSON plan now. string raw; try { - raw = await _model.AskAsync(SystemPrompt, userPrompt, 0.1f, ct); + raw = await _model.AskAsync(systemPrompt, userPrompt, 0.1f, ct); } catch { diff --git a/src/Delibera.Core/Debate/ConsensusDebate.cs b/src/Delibera.Core/Debate/ConsensusDebate.cs index 7dbde2e..3d3fec5 100644 --- a/src/Delibera.Core/Debate/ConsensusDebate.cs +++ b/src/Delibera.Core/Debate/ConsensusDebate.cs @@ -20,26 +20,43 @@ public sealed class ConsensusDebate : DebateScenario /// public override string Description => "Collaborative debate: Perspectives → Common Ground → Consensus → Facilitator"; - /// - public override async Task ExecuteAsync( - IReadOnlyList members, - PromptContext context, - CouncilMember? chairman, - KnowledgeKeeper? knowledgeKeeper, - Operator? @operator, - int maxRounds = 4, - float temperature = 0.7f, - Action? onRoundCompleted = null, - CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(members); - ArgumentNullException.ThrowIfNull(context); - var builder = new DebateResultBuilder(this, members, context, chairman, knowledgeKeeper, @operator); - var fullUserPrompt = context.GetFullUserPrompt(); - - // Operator briefing appended to participant system prompts. - var operatorBriefing = BuildOperatorBriefing(@operator); - var baseSystemPrompt = context.SystemPrompt + operatorBriefing; + /// + public override async Task ExecuteAsync( + IReadOnlyList members, + PromptContext context, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, + int maxRounds = 4, + float temperature = 0.7f, + Action? onRoundCompleted = null, + CancellationToken ct = default) + { + return await ExecuteAsync(members, context, chairman, knowledgeKeeper, @operator, + DebateExecutionOptions.Default, maxRounds, temperature, onRoundCompleted, ct); + } + + /// + public override async Task ExecuteAsync( + IReadOnlyList members, + PromptContext context, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, + DebateExecutionOptions executionOptions, + int maxRounds = 4, + float temperature = 0.7f, + Action? onRoundCompleted = null, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(members); + ArgumentNullException.ThrowIfNull(context); + var builder = new DebateResultBuilder(this, members, context, chairman, knowledgeKeeper, @operator); + var fullUserPrompt = context.GetFullUserPrompt(); + + // Operator briefing appended to participant system prompts. + var operatorBriefing = BuildOperatorBriefing(@operator); + var baseSystemPrompt = context.SystemPrompt + operatorBriefing; if (chairman is not null) builder.SetOpeningStatement(await Chairman.OpenDebateAsync(chairman, context, members, StrategyName, maxRounds, temperature, ct)); @@ -59,8 +76,8 @@ public override async Task ExecuteAsync( : $"{fullUserPrompt}\n\n📚 Knowledge:\n{knowledgeCtx}"; // Round 1: Initial Perspectives - var r1 = await CollectResponsesAsync(members, baseSystemPrompt, enrichedPrompt, temperature, ct); - var r1Op = await ProcessOperatorRequestsAsync(@operator, r1, ct); + var r1 = await CollectResponsesAsync(members, baseSystemPrompt, enrichedPrompt, temperature, ct); + var r1Op = await ProcessOperatorRequestsAsync(@operator, r1, executionOptions, ct); var round1 = CreateRound(1, "Initial Perspectives", "Each model shares their perspective.", r1, knowledgeInteractions: r1Ki, operatorInteractions: r1Op); builder.AddRound(round1); onRoundCompleted?.Invoke(round1); @@ -89,8 +106,8 @@ and propose bridges. Be open to changing your position. {(string.IsNullOrWhiteSpace(r1OpText) ? "" : $"\n{r1OpText}")} Identify: 1) Points of Agreement 2) Points of Disagreement 3) Bridge Proposals 4) Your Updated Position """; - var r2 = await CollectResponsesAsync(members, r2Sys, r2Prompt, temperature, ct); - var r2Op = await ProcessOperatorRequestsAsync(@operator, r2, ct); + var r2 = await CollectResponsesAsync(members, r2Sys, r2Prompt, temperature, ct); + var r2Op = await ProcessOperatorRequestsAsync(@operator, r2, executionOptions, ct); var round2 = CreateRound(2, "Finding Common Ground", "Models identify agreements and disagreements.", r2, knowledgeInteractions: r2Ki, operatorInteractions: r2Op); builder.AddRound(round2); onRoundCompleted?.Invoke(round2); @@ -119,8 +136,8 @@ and propose bridges. Be open to changing your position. {(string.IsNullOrWhiteSpace(r1OpText) && string.IsNullOrWhiteSpace(r2OpText) ? "" : $"\n{r1OpText}\n{r2OpText}")} Formulate: 1) Agreed points 2) Proposed unified answer 3) Remaining disagreements 4) Confidence (Low/Medium/High) """; - var r3 = await CollectResponsesAsync(members, r3Sys, r3Prompt, temperature, ct); - var r3Op = await ProcessOperatorRequestsAsync(@operator, r3, ct); + var r3 = await CollectResponsesAsync(members, r3Sys, r3Prompt, temperature, ct); + var r3Op = await ProcessOperatorRequestsAsync(@operator, r3, executionOptions, ct); var round3 = CreateRound(3, "Consensus Building", "Models attempt a unified answer.", r3, knowledgeInteractions: r3Ki, operatorInteractions: r3Op); builder.AddRound(round3); onRoundCompleted?.Invoke(round3); diff --git a/src/Delibera.Core/Debate/CritiqueDebate.cs b/src/Delibera.Core/Debate/CritiqueDebate.cs index 335b463..7d4fa61 100644 --- a/src/Delibera.Core/Debate/CritiqueDebate.cs +++ b/src/Delibera.Core/Debate/CritiqueDebate.cs @@ -20,26 +20,43 @@ public sealed class CritiqueDebate : DebateScenario /// public override string Description => "Adversarial debate: Position → Critique → Defence → Judge Verdict"; - /// - public override async Task ExecuteAsync( - IReadOnlyList members, - PromptContext context, - CouncilMember? chairman, - KnowledgeKeeper? knowledgeKeeper, - Operator? @operator, - int maxRounds = 4, - float temperature = 0.7f, - Action? onRoundCompleted = null, - CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(members); - ArgumentNullException.ThrowIfNull(context); - var builder = new DebateResultBuilder(this, members, context, chairman, knowledgeKeeper, @operator); - var fullUserPrompt = context.GetFullUserPrompt(); - - // Operator briefing appended to participant system prompts. - var operatorBriefing = BuildOperatorBriefing(@operator); - var baseSystemPrompt = context.SystemPrompt + operatorBriefing; + /// + public override async Task ExecuteAsync( + IReadOnlyList members, + PromptContext context, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, + int maxRounds = 4, + float temperature = 0.7f, + Action? onRoundCompleted = null, + CancellationToken ct = default) + { + return await ExecuteAsync(members, context, chairman, knowledgeKeeper, @operator, + DebateExecutionOptions.Default, maxRounds, temperature, onRoundCompleted, ct); + } + + /// + public override async Task ExecuteAsync( + IReadOnlyList members, + PromptContext context, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, + DebateExecutionOptions executionOptions, + int maxRounds = 4, + float temperature = 0.7f, + Action? onRoundCompleted = null, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(members); + ArgumentNullException.ThrowIfNull(context); + var builder = new DebateResultBuilder(this, members, context, chairman, knowledgeKeeper, @operator); + var fullUserPrompt = context.GetFullUserPrompt(); + + // Operator briefing appended to participant system prompts. + var operatorBriefing = BuildOperatorBriefing(@operator); + var baseSystemPrompt = context.SystemPrompt + operatorBriefing; if (chairman is not null) builder.SetOpeningStatement(await Chairman.OpenDebateAsync(chairman, context, members, StrategyName, maxRounds, temperature, ct)); @@ -59,8 +76,8 @@ public override async Task ExecuteAsync( : $"{fullUserPrompt}\n\n📚 Knowledge:\n{knowledgeCtx}"; // Round 1: Initial Positions - var r1 = await CollectResponsesAsync(members, baseSystemPrompt, enrichedPrompt, temperature, ct); - var r1Op = await ProcessOperatorRequestsAsync(@operator, r1, ct); + var r1 = await CollectResponsesAsync(members, baseSystemPrompt, enrichedPrompt, temperature, ct); + var r1Op = await ProcessOperatorRequestsAsync(@operator, r1, executionOptions, ct); var round1 = CreateRound(1, "Initial Positions", "Each model states their initial position.", r1, knowledgeInteractions: r1Ki, operatorInteractions: r1Op); builder.AddRound(round1); onRoundCompleted?.Invoke(round1); @@ -89,8 +106,8 @@ You are a sharp analytical critic. Find the WEAKEST points in other participants {(string.IsNullOrWhiteSpace(r1OpText) ? "" : $"\n{r1OpText}")} For each response: 1) Weakest argument 2) Logical fallacies 3) Counter-example 4) Quality (1-10) """; - var r2 = await CollectResponsesAsync(members, r2Sys, r2Prompt, temperature, ct); - var r2Op = await ProcessOperatorRequestsAsync(@operator, r2, ct); + var r2 = await CollectResponsesAsync(members, r2Sys, r2Prompt, temperature, ct); + var r2Op = await ProcessOperatorRequestsAsync(@operator, r2, executionOptions, ct); var round2 = CreateRound(2, "Directed Critique", "Models attack weaknesses in each other's positions.", r2, knowledgeInteractions: r2Ki, operatorInteractions: r2Op); builder.AddRound(round2); onRoundCompleted?.Invoke(round2); @@ -119,8 +136,8 @@ You are a sharp analytical critic. Find the WEAKEST points in other participants {(string.IsNullOrWhiteSpace(r1OpText) && string.IsNullOrWhiteSpace(r2OpText) ? "" : $"\n{r1OpText}\n{r2OpText}")} Defend your position: 1) Address each criticism 2) Strengthen weak points 3) Concede where right 4) Final answer """; - var r3 = await CollectResponsesAsync(members, r3Sys, r3Prompt, temperature, ct); - var r3Op = await ProcessOperatorRequestsAsync(@operator, r3, ct); + var r3 = await CollectResponsesAsync(members, r3Sys, r3Prompt, temperature, ct); + var r3Op = await ProcessOperatorRequestsAsync(@operator, r3, executionOptions, ct); var round3 = CreateRound(3, "Defence & Counter-Arguments", "Models defend their positions.", r3, knowledgeInteractions: r3Ki, operatorInteractions: r3Op); builder.AddRound(round3); onRoundCompleted?.Invoke(round3); diff --git a/src/Delibera.Core/Debate/DebateScenario.cs b/src/Delibera.Core/Debate/DebateScenario.cs index 8df173b..cf364da 100644 --- a/src/Delibera.Core/Debate/DebateScenario.cs +++ b/src/Delibera.Core/Debate/DebateScenario.cs @@ -8,39 +8,52 @@ namespace Delibera.Core.Debate; /// Provides shared utilities for collecting responses, formatting rounds, /// querying the Knowledge Keeper, and compressing context. ///
-public abstract class DebateScenario : IDebateStrategy +public abstract class DebateScenario : IDebateStrategyWithOptions { - // ────────────────────────────────────────────── - // Operator helpers - // ────────────────────────────────────────────── - - /// - /// Marker participants use to delegate a task to the Operator, e.g.: - /// [[OPERATOR: search the web for the latest .NET 10 release notes]]. - /// - private static readonly Regex OperatorRequestRegex = - new(@"\[\[\s*OPERATOR\s*:\s*(?.+?)\]\]", - RegexOptions.Singleline | - RegexOptions.IgnoreCase | - RegexOptions.Compiled); - - /// - public abstract string StrategyName { get; } - - /// - public abstract string Description { get; } - - /// - public abstract Task ExecuteAsync( - IReadOnlyList members, - PromptContext context, - CouncilMember? chairman, - KnowledgeKeeper? knowledgeKeeper, - Operator? @operator, - int maxRounds = 4, - float temperature = 0.7f, - Action? onRoundCompleted = null, - CancellationToken ct = default); + // ────────────────────────────────────────────── + // Operator helpers + // ────────────────────────────────────────────── + + /// + /// Marker participants use to delegate a task to the Operator, e.g.: + /// [[OPERATOR: search the web for the latest .NET 10 release notes]]. + /// + private static readonly Regex OperatorRequestRegex = + new(@"\[\[\s*OPERATOR\s*:\s*(?.+?)\]\]", + RegexOptions.Singleline | + RegexOptions.IgnoreCase | + RegexOptions.Compiled); + + /// + public abstract string StrategyName { get; } + + /// + public abstract string Description { get; } + + /// + public abstract Task ExecuteAsync( + IReadOnlyList members, + PromptContext context, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, + int maxRounds = 4, + float temperature = 0.7f, + Action? onRoundCompleted = null, + CancellationToken ct = default); + + /// + public abstract Task ExecuteAsync( + IReadOnlyList members, + PromptContext context, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, + DebateExecutionOptions executionOptions, + int maxRounds = 4, + float temperature = 0.7f, + Action? onRoundCompleted = null, + CancellationToken ct = default); // ────────────────────────────────────────────── // Shared helpers @@ -86,6 +99,8 @@ protected static async Task> CollectResponsesAsync( responses[key] = response; } + + return responses; } /// Formats a single round's responses into readable text. @@ -218,46 +233,77 @@ Only delegate when external information or actions (web search, database lookup, """; } - /// - /// Scans participant responses for Operator request markers, executes each delegated - /// task via the Operator, and returns the recorded interactions. - /// - /// Operator instance (may be null). - /// Participant responses keyed by display name. - /// Cancellation token. - /// Operator interactions produced during this round. - protected static async Task> ProcessOperatorRequestsAsync( - Operator? @operator, - IReadOnlyDictionary responses, - CancellationToken ct = default) - { - if (@operator is null || responses.Count == 0) return []; - - var interactions = new List(); - foreach (var (member, response) in responses) - { - if (string.IsNullOrWhiteSpace(response)) continue; - - foreach (Match match in OperatorRequestRegex.Matches(response)) - { - var task = match.Groups["task"].Value.Trim(); - if (string.IsNullOrWhiteSpace(task)) continue; - - try - { - var result = await @operator.ExecuteTaskAsync(member, task, ct); - interactions.Add(result.ToInteraction()); - } - catch (Exception ex) - { - interactions.Add(new OperatorInteraction( - member, task, $"[Operator error: {ex.Message}]", [], false, DateTime.UtcNow)); - } - } - } - - return interactions; - } + /// + /// Scans participant responses for Operator request markers, executes each delegated + /// task via the Operator, and returns the recorded interactions. + /// + /// Operator instance (may be null). + /// Participant responses keyed by display name. + /// Cancellation token. + /// Operator interactions produced during this round. + protected static async Task> ProcessOperatorRequestsAsync( + Operator? @operator, + IReadOnlyDictionary responses, + CancellationToken ct = default) + { + return await ProcessOperatorRequestsAsync(@operator, responses, DebateExecutionOptions.Default, ct); + } + + /// + /// Scans participant responses for Operator request markers, executes each delegated + /// task via the Operator (in parallel, bounded by + /// ), and returns the + /// recorded interactions. + /// + protected static async Task> ProcessOperatorRequestsAsync( + Operator? @operator, + IReadOnlyDictionary responses, + DebateExecutionOptions executionOptions, + CancellationToken ct = default) + { + if (@operator is null || responses.Count == 0) return []; + + // Collect every (member, task) pair across all responses first. + var pending = new List<(string Member, string Task)>(); + foreach (var (member, response) in responses) + { + if (string.IsNullOrWhiteSpace(response)) continue; + + foreach (Match match in OperatorRequestRegex.Matches(response)) + { + var task = match.Groups["task"].Value.Trim(); + if (string.IsNullOrWhiteSpace(task)) continue; + pending.Add((member, task)); + } + } + + if (pending.Count == 0) return []; + + var interactions = new List(pending.Count); + var parallelOpts = executionOptions.ToParallelOptions(ct); + + // Parallel.ForEachAsync gives us a concurrent, optionally-bounded execution of the + // delegated Operator tasks. Results are collected in a thread-safe list. + await Parallel.ForEachAsync( + pending, + parallelOpts, + async (item, token) => + { + try + { + var result = await @operator.ExecuteTaskAsync(item.Member, item.Task, token); + var interaction = result.ToInteraction(); + lock (interactions) interactions.Add(interaction); + } + catch (Exception ex) + { + lock (interactions) interactions.Add(new OperatorInteraction( + item.Member, item.Task, $"[Operator error: {ex.Message}]", [], false, DateTime.UtcNow)); + } + }); + + return interactions; + } /// /// Formats Operator interactions into a context block that can be injected into the diff --git a/src/Delibera.Core/Debate/StandardDebate.cs b/src/Delibera.Core/Debate/StandardDebate.cs index 2ed18a8..ea48952 100644 --- a/src/Delibera.Core/Debate/StandardDebate.cs +++ b/src/Delibera.Core/Debate/StandardDebate.cs @@ -24,26 +24,43 @@ public sealed class StandardDebate : DebateScenario /// public override string Description => "4-round debate: Initial → Critique → Improved → Chairman Verdict"; - /// - public override async Task ExecuteAsync( - IReadOnlyList members, - PromptContext context, - CouncilMember? chairman, - KnowledgeKeeper? knowledgeKeeper, - Operator? @operator, - int maxRounds = 4, - float temperature = 0.7f, - Action? onRoundCompleted = null, - CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(members); - ArgumentNullException.ThrowIfNull(context); - var builder = new DebateResultBuilder(this, members, context, chairman, knowledgeKeeper, @operator); - var fullUserPrompt = context.GetFullUserPrompt(); - - // Operator briefing is appended to participant system prompts so they know what tools exist. - var operatorBriefing = BuildOperatorBriefing(@operator); - var baseSystemPrompt = context.SystemPrompt + operatorBriefing; + /// + public override async Task ExecuteAsync( + IReadOnlyList members, + PromptContext context, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, + int maxRounds = 4, + float temperature = 0.7f, + Action? onRoundCompleted = null, + CancellationToken ct = default) + { + return await ExecuteAsync(members, context, chairman, knowledgeKeeper, @operator, + DebateExecutionOptions.Default, maxRounds, temperature, onRoundCompleted, ct); + } + + /// + public override async Task ExecuteAsync( + IReadOnlyList members, + PromptContext context, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, + DebateExecutionOptions executionOptions, + int maxRounds = 4, + float temperature = 0.7f, + Action? onRoundCompleted = null, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(members); + ArgumentNullException.ThrowIfNull(context); + var builder = new DebateResultBuilder(this, members, context, chairman, knowledgeKeeper, @operator); + var fullUserPrompt = context.GetFullUserPrompt(); + + // Operator briefing is appended to participant system prompts so they know what tools exist. + var operatorBriefing = BuildOperatorBriefing(@operator); + var baseSystemPrompt = context.SystemPrompt + operatorBriefing; // ── Chairman opening ── if (chairman is not null) @@ -65,9 +82,9 @@ public override async Task ExecuteAsync( ? fullUserPrompt : $"{fullUserPrompt}\n\n📚 Knowledge Keeper context:\n{knowledgeContext}"; - var r1Responses = await CollectResponsesAsync(members, baseSystemPrompt, r1Prompt, temperature, ct); - // Operator: fulfil any [[OPERATOR: ...]] requests raised in round 1. - var r1Op = await ProcessOperatorRequestsAsync(@operator, r1Responses, ct); + var r1Responses = await CollectResponsesAsync(members, baseSystemPrompt, r1Prompt, temperature, ct); + // Operator: fulfil any [[OPERATOR: ...]] requests raised in round 1 (parallel, bounded). + var r1Op = await ProcessOperatorRequestsAsync(@operator, r1Responses, executionOptions, ct); var round1 = CreateRound(1, "Initial Responses", "All models provide their initial answers.", r1Responses, r1Prompt, r1Ki, r1Op); builder.AddRound(round1); @@ -103,8 +120,8 @@ Be constructive but thorough. Provide your detailed critique of each response. """; - var r2Responses = await CollectResponsesAsync(members, r2System, r2Prompt, temperature, ct); - var r2Op = await ProcessOperatorRequestsAsync(@operator, r2Responses, ct); + var r2Responses = await CollectResponsesAsync(members, r2System, r2Prompt, temperature, ct); + var r2Op = await ProcessOperatorRequestsAsync(@operator, r2Responses, executionOptions, ct); var round2 = CreateRound(2, "Critique", "Models critically analyse each other's responses.", r2Responses, r2Prompt, r2Ki, r2Op); builder.AddRound(round2); @@ -143,8 +160,8 @@ and synthesise the most comprehensive response possible. Provide your final, improved answer. """; - var r3Responses = await CollectResponsesAsync(members, r3System, r3Prompt, temperature, ct); - var r3Op = await ProcessOperatorRequestsAsync(@operator, r3Responses, ct); + var r3Responses = await CollectResponsesAsync(members, r3System, r3Prompt, temperature, ct); + var r3Op = await ProcessOperatorRequestsAsync(@operator, r3Responses, executionOptions, ct); var round3 = CreateRound(3, "Final Improved Responses", "Models provide refined answers incorporating critiques.", r3Responses, r3Prompt, r3Ki, r3Op); builder.AddRound(round3); diff --git a/src/Delibera.Core/Delibera.Core.csproj b/src/Delibera.Core/Delibera.Core.csproj index 2d3a366..c74dbe6 100644 --- a/src/Delibera.Core/Delibera.Core.csproj +++ b/src/Delibera.Core/Delibera.Core.csproj @@ -12,9 +12,9 @@ Delibera.Core - 10.1.1 + 10.2.0 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, and context compression. + 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, Microsoft.Extensions.Logging, response-language enforcement, and parallel Operator requests. Victor Buzin Techbuzzz Delibera @@ -26,7 +26,7 @@ https://github.com/techbuzzz/Delibera git https://github.com/techbuzzz/Delibera - v10.1.1 — Microsoft.Extensions.AI (10.7.0) integration: ChatClientLLMProvider and EmbeddingGeneratorProvider adapt any IChatClient / IEmbeddingGenerator (OpenAI, Azure OpenAI, Ollama, …), streaming via ChatStreamAsync, middleware composition, and AddDeliberaChatClient DI helpers. Fully backward compatible. + v10.2.0 — Microsoft.Extensions.Logging support (inject ILogger/ILoggerFactory), response-language enforcement (WithResponseLanguage/ResponseLanguage), parallel Operator requests (WithMaxDegreeOfParallelism/MaxDegreeOfParallelism), renamed LogLevel enum to ExecutionLogLevel to avoid clash with Microsoft.Extensions.Logging.LogLevel. Fully backward compatible (legacy ExecuteAsync overloads still work). true snupkg true @@ -39,13 +39,15 @@ - - - - - - - + + + + + + + + + diff --git a/src/Delibera.Core/DependencyInjection/CouncilOptions.cs b/src/Delibera.Core/DependencyInjection/CouncilOptions.cs index 24322bb..a5431ae 100644 --- a/src/Delibera.Core/DependencyInjection/CouncilOptions.cs +++ b/src/Delibera.Core/DependencyInjection/CouncilOptions.cs @@ -18,8 +18,34 @@ public sealed class CouncilOptions /// Default generation temperature (0.0–2.0). public float Temperature { get; set; } = 0.7f; - /// Default system prompt for all participants. - public string SystemPrompt { get; set; } = "You are a helpful AI assistant participating in a council debate."; + /// Default system prompt for all participants. + public string SystemPrompt { get; set; } = "You are a helpful AI assistant participating in a council debate."; + + /// + /// Forces every model response (participants, Chairman, Knowledge Keeper, Operator) + /// to be written in the specified human language, regardless of the language used + /// in the prompt or retrieved RAG context. + /// + /// + /// + /// Set to a language name the model recognises (e.g. "Russian", "English", + /// "Spanish", "中文"). When non-empty, Delibera injects a strict directive into + /// every system and user prompt: "You MUST answer exclusively in {language}. + /// Never use any other language." + /// + /// + /// Leave null or empty to let the model pick a language from context (legacy + /// behaviour). + /// + /// + public string? ResponseLanguage { get; set; } + + /// + /// Optional maximum degree of parallelism for operations that can run concurrently + /// within a debate round (e.g. Operator task delegation, parallel Knowledge Keeper + /// queries). 0 means "unbounded" (default). + /// + public int MaxDegreeOfParallelism { get; set; } /// Provider configuration options. public ProviderOptions Providers { get; set; } = new(); diff --git a/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs b/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs index ec5fbe0..0574c87 100644 --- a/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; namespace Delibera.Core.DependencyInjection; @@ -15,66 +16,103 @@ namespace Delibera.Core.DependencyInjection; /// public static class ServiceCollectionExtensions { - /// - /// Registers core Delibera services with default options. - /// - /// The service collection. - /// The service collection for chaining. - /// - /// Registers: - /// - /// (singleton) - /// (singleton) - /// (singleton) - /// (transient) - /// - /// - public static IServiceCollection AddDelibera(this IServiceCollection services) - { - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddTransient(); + /// + /// Registers core Delibera services with default options. + /// + /// The service collection. + /// The service collection for chaining. + /// + /// Registers: + /// + /// (singleton) + /// (singleton) + /// (singleton) + /// (transient) + /// + /// + public static IServiceCollection AddDelibera(this IServiceCollection services) + { + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddTransient(); - return services; - } + return services; + } - /// - /// Registers core Delibera services and binds from configuration. - /// - /// The service collection. - /// Configuration root or section containing council settings. - /// Configuration section name (default: "Delibera"). - /// The service collection for chaining. - public static IServiceCollection AddDelibera( - this IServiceCollection services, - IConfiguration configuration, - string sectionName = CouncilOptions.SectionName) - { - services.AddDelibera(); + /// + /// Registers core Delibera services and binds from configuration. + /// + /// The service collection. + /// Configuration root or section containing council settings. + /// Configuration section name (default: "Delibera"). + /// The service collection for chaining. + public static IServiceCollection AddDelibera( + this IServiceCollection services, + IConfiguration configuration, + string sectionName = CouncilOptions.SectionName) + { + services.AddDelibera(); - var section = configuration.GetSection(sectionName); - if (section.Exists()) services.Configure(section); + var section = configuration.GetSection(sectionName); + if (section.Exists()) services.Configure(section); - return services; - } + return services; + } - /// - /// Registers core Delibera services with a custom options configuration delegate. - /// - /// The service collection. - /// Delegate to configure . - /// The service collection for chaining. - public static IServiceCollection AddDelibera( - this IServiceCollection services, - Action configureOptions) - { - ArgumentNullException.ThrowIfNull(configureOptions); - services.AddDelibera(); - services.Configure(configureOptions); + /// + /// Registers core Delibera services with a custom options configuration delegate. + /// + /// The service collection. + /// Delegate to configure . + /// The service collection for chaining. + public static IServiceCollection AddDelibera( + this IServiceCollection services, + Action configureOptions) + { + ArgumentNullException.ThrowIfNull(configureOptions); + services.AddDelibera(); + services.Configure(configureOptions); - return services; - } + return services; + } + + /// + /// Registers core Delibera services and wires the framework into the host's + /// . A resolved from the + /// container is automatically decorated with a logger, so any debate started via DI + /// logs to the host's pipeline (console, file, OpenTelemetry, …) in addition to the + /// in-memory collection. + /// + /// The service collection. + /// Configuration root or section. + /// Host logger factory. + /// Configuration section name (default: "Delibera"). + /// The service collection for chaining. + public static IServiceCollection AddDelibera( + this IServiceCollection services, + IConfiguration configuration, + ILoggerFactory loggerFactory, + string sectionName = CouncilOptions.SectionName) + { + ArgumentNullException.ThrowIfNull(loggerFactory); + services.AddDelibera(configuration, sectionName); + services.TryAddSingleton(loggerFactory); + + // Replace the transient builder registration so every resolved ICouncilBuilder + // gets a logger injected automatically. Consumers who build the executor themselves + // can still call WithLogger(...) explicitly to override. + services.Replace(ServiceDescriptor.Transient(sp => + { + var builder = new CouncilBuilder(); + var lf = sp.GetService(); + if (lf is not null) + builder.WithLogger(lf.CreateLogger("Delibera.Core.Council")); + return builder; + })); + + return services; + } /// /// Registers a Microsoft.Extensions.AI and exposes it as a Delibera diff --git a/src/Delibera.Core/GlobalUsings.cs b/src/Delibera.Core/GlobalUsings.cs index f7fc8b4..aa0bfcd 100644 --- a/src/Delibera.Core/GlobalUsings.cs +++ b/src/Delibera.Core/GlobalUsings.cs @@ -1,3 +1,4 @@ global using Delibera.Core.Interfaces; global using Delibera.Core.Models; +global using Microsoft.Extensions.Logging; global using System.Text; \ No newline at end of file diff --git a/src/Delibera.Core/Interfaces/ICouncilBuilder.cs b/src/Delibera.Core/Interfaces/ICouncilBuilder.cs index c3cb4a6..645d75d 100644 --- a/src/Delibera.Core/Interfaces/ICouncilBuilder.cs +++ b/src/Delibera.Core/Interfaces/ICouncilBuilder.cs @@ -146,10 +146,40 @@ ICouncilBuilder WithCompression( /// This builder for fluent chaining. ICouncilBuilder WithTemperature(float temperature); - /// Sets the output path for saving the debate result as Markdown. - /// File path for Markdown output. - /// This builder for fluent chaining. - ICouncilBuilder SaveResultTo(string outputPath); + /// Sets the output path for saving the debate result as Markdown. + /// File path for Markdown output. + /// This builder for fluent chaining. + ICouncilBuilder SaveResultTo(string outputPath); + + /// + /// Forces every model response (participants, Chairman, Knowledge Keeper, Operator) + /// to be in the specified language. Pass null or empty to disable language + /// enforcement and let the model pick a language from context. + /// + /// + /// Language name the model recognises (e.g. "Russian", "English", "Spanish"). + /// + /// This builder for fluent chaining. + ICouncilBuilder WithResponseLanguage(string? language); + + /// + /// Sets the maximum degree of parallelism for operations that can run concurrently + /// within a debate round (Operator task delegation, parallel Knowledge Keeper + /// queries). Pass 0 for unbounded parallelism (default). + /// + /// Max concurrent operations per round (0 = unbounded). + /// This builder for fluent chaining. + ICouncilBuilder WithMaxDegreeOfParallelism(int maxDegreeOfParallelism); + + /// + /// Attaches an used by the executor to surface progress + /// (Chairman actions, rounds, compression, errors, …) to a host's logging pipeline. + /// Pass null to disable structured logging (legacy behaviour — only the + /// OnLog event and the collection are populated). + /// + /// Logger instance, or null to clear. + /// This builder for fluent chaining. + ICouncilBuilder WithLogger(ILogger? logger); /// /// Validates configuration and builds an . diff --git a/src/Delibera.Core/Interfaces/ICouncilExecutor.cs b/src/Delibera.Core/Interfaces/ICouncilExecutor.cs index e97efc1..10e0add 100644 --- a/src/Delibera.Core/Interfaces/ICouncilExecutor.cs +++ b/src/Delibera.Core/Interfaces/ICouncilExecutor.cs @@ -9,23 +9,31 @@ namespace Delibera.Core.Interfaces; /// public interface ICouncilExecutor { - /// Council participants. - IReadOnlyList Members { get; } + /// Council participants. + IReadOnlyList Members { get; } - /// Chairman (may be null). - CouncilMember? Chairman { get; } + /// Chairman (may be null). + CouncilMember? Chairman { get; } - /// Knowledge Keeper (may be null). - KnowledgeKeeper? KnowledgeKeeper { get; } + /// Knowledge Keeper (may be null). + KnowledgeKeeper? KnowledgeKeeper { get; } - /// Operator (may be null). - Operator? Operator { get; } + /// Operator (may be null). + Operator? Operator { get; } - /// Debate strategy. - IDebateStrategy Strategy { get; } + /// Debate strategy. + IDebateStrategy Strategy { get; } - /// Context compressor (may be null if compression is disabled). - IContextCompressor? Compressor { get; } + /// Context compressor (may be null if compression is disabled). + IContextCompressor? Compressor { get; } + + /// + /// Optional used by the executor to surface progress + /// (Chairman actions, rounds, compression, errors, …) to a host's logging pipeline. + /// When null, only the event and the + /// collection are populated. + /// + ILogger? Logger { get; } /// /// Execution logs collected during the debate. diff --git a/src/Delibera.Core/Interfaces/IDebateStrategy.cs b/src/Delibera.Core/Interfaces/IDebateStrategy.cs index 9116976..1ce3c5f 100644 --- a/src/Delibera.Core/Interfaces/IDebateStrategy.cs +++ b/src/Delibera.Core/Interfaces/IDebateStrategy.cs @@ -1,4 +1,5 @@ using Delibera.Core.Council; +using Microsoft.Extensions.Logging; namespace Delibera.Core.Interfaces; @@ -8,33 +9,86 @@ namespace Delibera.Core.Interfaces; /// public interface IDebateStrategy { - /// Unique strategy name. - string StrategyName { get; } + /// Unique strategy name. + string StrategyName { get; } - /// Human-readable strategy description. - string Description { get; } + /// Human-readable strategy description. + string Description { get; } - /// - /// Executes the full debate cycle according to this strategy. - /// - /// Council participants. - /// Prompt context (system / user prompt, knowledge). - /// Chairman for moderation and verdict synthesis (may be null). - /// Knowledge Keeper for RAG queries (may be null). - /// Operator micro-agent for tool/MCP delegation (may be null). - /// Maximum number of rounds. - /// Generation temperature. - /// Callback invoked after each round. - /// Cancellation token. - /// Complete debate result. - Task ExecuteAsync( - IReadOnlyList members, - PromptContext context, - CouncilMember? chairman, - KnowledgeKeeper? knowledgeKeeper, - Operator? @operator, - int maxRounds = 4, - float temperature = 0.7f, - Action? onRoundCompleted = null, - CancellationToken ct = default); + /// + /// Executes the full debate cycle according to this strategy. + /// + /// Council participants. + /// Prompt context (system / user prompt, knowledge). + /// Chairman for moderation and verdict synthesis (may be null). + /// Knowledge Keeper for RAG queries (may be null). + /// Operator micro-agent for tool/MCP delegation (may be null). + /// Maximum number of rounds. + /// Generation temperature. + /// Callback invoked after each round. + /// Cancellation token. + /// Complete debate result. + Task ExecuteAsync( + IReadOnlyList members, + PromptContext context, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, + int maxRounds = 4, + float temperature = 0.7f, + Action? onRoundCompleted = null, + CancellationToken ct = default); + + /// + /// Executes the full debate cycle with an extra + /// bundle (response-language directive, parallelism budget, ). + /// + /// + /// The default implementation forwards to the legacy overload, ignoring + /// . Concrete strategies shipped with Delibera + /// (, , + /// ) override this to honour the options. + /// Custom strategies only need to override this overload to participate in + /// language enforcement and parallelism tuning. + /// + Task ExecuteAsync( + IReadOnlyList members, + PromptContext context, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, + DebateExecutionOptions executionOptions, + int maxRounds = 4, + float temperature = 0.7f, + Action? onRoundCompleted = null, + CancellationToken ct = default) + { + return ExecuteAsync(members, context, chairman, knowledgeKeeper, @operator, + maxRounds, temperature, onRoundCompleted, ct); + } +} + +/// +/// Convenience base interface used by Delibera strategies to declare an overload of +/// IDebateStrategy.ExecuteAsync that takes . +/// Concrete strategies derive from , which implements +/// this interface, so they can override the overload. +/// +public interface IDebateStrategyWithOptions : IDebateStrategy +{ + /// + /// Executes the debate with (response language, + /// parallelism budget, logger). Strategies override this to honour the options. + /// + new Task ExecuteAsync( + IReadOnlyList members, + PromptContext context, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, + DebateExecutionOptions executionOptions, + int maxRounds = 4, + float temperature = 0.7f, + Action? onRoundCompleted = null, + CancellationToken ct = default); } \ No newline at end of file diff --git a/src/Delibera.Core/Models/DebateExecutionOptions.cs b/src/Delibera.Core/Models/DebateExecutionOptions.cs new file mode 100644 index 0000000..764bf6c --- /dev/null +++ b/src/Delibera.Core/Models/DebateExecutionOptions.cs @@ -0,0 +1,84 @@ +using Microsoft.Extensions.Logging; + +namespace Delibera.Core.Models; + +/// +/// Per-execution options threaded through debate strategies alongside the +/// call. Bundles the response-language +/// directive, parallelism budget, and optional so every +/// strategy and helper can act on them without growing the public strategy signature +/// one parameter at a time. +/// +/// +/// When non-empty, every model response (participants, Chairman, Knowledge Keeper, +/// Operator) is forced into this language via a strict prompt directive. +/// +/// +/// Maximum number of concurrent operations within a debate round (Operator task +/// delegation, parallel Knowledge Keeper queries). 0 = unbounded. +/// +/// +/// Optional used by the executor and strategies to surface +/// progress to a host's logging pipeline. null disables structured logging. +/// +public sealed record DebateExecutionOptions( + string? ResponseLanguage = null, + int MaxDegreeOfParallelism = 0, + ILogger? Logger = null) +{ + /// Singleton representing "no extra execution options" (legacy behaviour). + public static DebateExecutionOptions Default { get; } = new(); + + /// Whether a response language directive is configured. + public bool HasResponseLanguage => !string.IsNullOrWhiteSpace(ResponseLanguage); + + /// + /// Builds the language-enforcement directive block that is appended to system and + /// user prompts. Returns an empty string when no language is configured. + /// + public string BuildLanguageDirective() + { + return HasResponseLanguage + ? $"\n\nIMPORTANT: You MUST answer exclusively in {ResponseLanguage}. Never use any other language, regardless of the language used in the question, retrieved context, or other participants' messages." + : string.Empty; + } + + /// + /// Returns a instance reflecting + /// (useful for Parallel.ForEachAsync). + /// + public ParallelOptions ToParallelOptions(CancellationToken ct) + { + var po = new ParallelOptions { CancellationToken = ct }; + if (MaxDegreeOfParallelism > 0) + po.MaxDegreeOfParallelism = MaxDegreeOfParallelism; + return po; + } +} + +/// +/// Internal helper that bridges entries and a host's +/// so the same event is recorded both in the in-memory log +/// collection and the host's logging pipeline. +/// +internal static class ExecutionLogSink +{ + /// + /// Forwards the to using the + /// appropriate , then returns the + /// same entry so the caller can also append it to its + /// collection. + /// + public static ExecutionLog Emit(ILogger? logger, ExecutionLog entry) + { + if (logger is null) return entry; + + var msLevel = entry.ToMicrosoftLogLevel(); + if (!logger.IsEnabled(msLevel)) return entry; + + // Use a structured log event id of 0 (free-form) with named placeholders so + // scopes/external sinks can correlate by Source + Message. + logger.Log(msLevel, new EventId(0, entry.Source), "[{Source}] {Message}", entry.Source, entry.Message); + return entry; + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Models/DebateResult.cs b/src/Delibera.Core/Models/DebateResult.cs index fa429bc..da9b175 100644 --- a/src/Delibera.Core/Models/DebateResult.cs +++ b/src/Delibera.Core/Models/DebateResult.cs @@ -232,10 +232,10 @@ public string ToLogsMarkdown() { var icon = log.Level switch { - LogLevel.Trace => "🔍", - LogLevel.Info => "ℹ️", - LogLevel.Warning => "⚠️", - LogLevel.Error => "❌", + ExecutionLogLevel.Trace => "🔍", + ExecutionLogLevel.Info => "ℹ️", + ExecutionLogLevel.Warning => "⚠️", + ExecutionLogLevel.Error => "❌", _ => "•" }; sb.AppendLine($"| {log.Timestamp:HH:mm:ss.fff} | {icon} {log.Level} | {log.Source} | {log.Message} |"); @@ -247,7 +247,7 @@ public string ToLogsMarkdown() sb.AppendLine("## Summary by Source"); sb.AppendLine(); foreach (var group in ExecutionLogs.GroupBy(l => l.Source).OrderBy(g => g.Key)) - sb.AppendLine($"- **{group.Key}**: {group.Count()} entries ({group.Count(l => l.Level == LogLevel.Error)} errors, {group.Count(l => l.Level == LogLevel.Warning)} warnings)"); + sb.AppendLine($"- **{group.Key}**: {group.Count()} entries ({group.Count(l => l.Level == ExecutionLogLevel.Error)} errors, {group.Count(l => l.Level == ExecutionLogLevel.Warning)} warnings)"); sb.AppendLine(); } else diff --git a/src/Delibera.Core/Models/ExecutionLog.cs b/src/Delibera.Core/Models/ExecutionLog.cs index 700bc4f..b45b1d5 100644 --- a/src/Delibera.Core/Models/ExecutionLog.cs +++ b/src/Delibera.Core/Models/ExecutionLog.cs @@ -3,7 +3,13 @@ namespace Delibera.Core.Models; /// /// Severity level for execution log entries. /// -public enum LogLevel +/// +/// Renamed from LogLevel in v10.2 to avoid a name clash with +/// , which is now the canonical +/// logging severity used throughout Delibera when +/// is wired up. +/// +public enum ExecutionLogLevel { /// Detailed trace-level messages for debugging. Trace = 0, @@ -31,41 +37,57 @@ public enum LogLevel /// Human-readable description of the event. /// UTC timestamp when the event occurred. public sealed record ExecutionLog( - LogLevel Level, + ExecutionLogLevel Level, string Source, string Message, DateTime Timestamp) { /// - /// Creates an with and the current UTC time. + /// Creates an with and the current UTC time. /// public static ExecutionLog Info(string source, string message) { - return new ExecutionLog(LogLevel.Info, source, message, DateTime.UtcNow); + return new ExecutionLog(ExecutionLogLevel.Info, source, message, DateTime.UtcNow); } /// - /// Creates an with and the current UTC time. + /// Creates an with and the current UTC time. /// public static ExecutionLog Trace(string source, string message) { - return new ExecutionLog(LogLevel.Trace, source, message, DateTime.UtcNow); + return new ExecutionLog(ExecutionLogLevel.Trace, source, message, DateTime.UtcNow); } /// - /// Creates an with and the current UTC time. + /// Creates an with and the current UTC time. /// public static ExecutionLog Warn(string source, string message) { - return new ExecutionLog(LogLevel.Warning, source, message, DateTime.UtcNow); + return new ExecutionLog(ExecutionLogLevel.Warning, source, message, DateTime.UtcNow); } /// - /// Creates an with and the current UTC time. + /// Creates an with and the current UTC time. /// public static ExecutionLog Error(string source, string message) { - return new ExecutionLog(LogLevel.Error, source, message, DateTime.UtcNow); + return new ExecutionLog(ExecutionLogLevel.Error, source, message, DateTime.UtcNow); + } + + /// + /// Maps this execution-log level to the equivalent + /// used by . + /// + public Microsoft.Extensions.Logging.LogLevel ToMicrosoftLogLevel() + { + return Level switch + { + ExecutionLogLevel.Trace => Microsoft.Extensions.Logging.LogLevel.Trace, + ExecutionLogLevel.Info => Microsoft.Extensions.Logging.LogLevel.Information, + ExecutionLogLevel.Warning => Microsoft.Extensions.Logging.LogLevel.Warning, + ExecutionLogLevel.Error => Microsoft.Extensions.Logging.LogLevel.Error, + _ => Microsoft.Extensions.Logging.LogLevel.None + }; } /// diff --git a/src/Delibera.Core/README.md b/src/Delibera.Core/README.md index 5c2d0fd..711f2b4 100644 --- a/src/Delibera.Core/README.md +++ b/src/Delibera.Core/README.md @@ -34,7 +34,10 @@ outcomes** rather than single-model guesses. - 🛠️ **Operator (MCP Tools)** — a micro-agent that delegates tasks to MCP servers (web search, file system, Notion, PostgreSQL…) on demand during the debate - 🗜️ **Context Compression** — 4 strategies (Semantic, Deduplication, Summarization, Hybrid) save 30–70% of tokens - 💉 **Dependency Injection** — `AddDelibera()` extension for `IServiceCollection` with full options binding -- 📋 **Execution Logging** — `ExecutionLog` model with `LogLevel` for Chairman, KK, Compression & participants +- 📋 **Execution Logging** — `ExecutionLog` model with `ExecutionLogLevel` for Chairman, KK, Compression & participants +- 📝 **Microsoft.Extensions.Logging** — inject your own `ILogger`/`ILoggerFactory`; every debate event is forwarded to the host's logging pipeline (in addition to the in-memory `ExecutionLog` collection) +- 🌐 **Response Language Enforcement** — force every model (participants, Chairman, Knowledge Keeper, Operator) to answer in a specific language, regardless of the prompt or retrieved context +- ⚡ **Parallel Operator Requests** — `[[OPERATOR: …]]` tasks delegated within a round run in parallel, bounded by `MaxDegreeOfParallelism` - 📁 **Separate File Output** — export `result.md`, `statistics.md`, `logs.md` independently - 🤝 **Microsoft.Extensions.AI** — first-class `IChatClient` / `IEmbeddingGenerator` interop with logging & function-invocation middleware - 🔌 **Interface-First** — clean abstractions for providers, factories, builders and executors @@ -333,6 +336,110 @@ Each strategy is implemented as an `IDebateStrategy` — combine with `Builder`, --- +## 📝 Logging (Microsoft.Extensions.Logging) + +Delibera integrates with the standard .NET logging framework. Every debate event — Chairman +opening, Knowledge Keeper queries, compression operations, Operator interactions, participant +responses, errors — is forwarded to an injected `ILogger` (category `Delibera.Core.Council`) +**in addition to** being recorded in the in-memory `ExecutionLog` collection and the `OnLog` event. + +### Inject your logger via the builder + +```csharp +using Microsoft.Extensions.Logging; + +var loggerFactory = LoggerFactory.Create(builder => +{ + builder.AddConsole(); + builder.SetMinimumLevel(LogLevel.Information); +}); + +var result = await new CouncilBuilder() + .AddMember("llama3.2:3b", ollama, "Analyst") + .SetChairman(Chairman.CreateStandard("qwen2.5:7b", ollama)) + .WithStandardDebate() + .WithUserPrompt("…") + .WithLogger(loggerFactory.CreateLogger("Delibera")) + .Build() + .ExecuteAsync(); +``` + +### Inject your logger factory via DI + +```csharp +services.AddDelibera(configuration, loggerFactory, "Delibera"); +// Any ICouncilBuilder resolved from the container is automatically decorated with a logger. +``` + +When no `ILogger` is configured, Delibera falls back to the legacy behaviour: events are only +captured in the `DebateResult.ExecutionLogs` collection and the `OnLog`/`OnError` events. + +--- + +## 🌐 Response Language Enforcement + +Force **every** model response (participants, Chairman, Knowledge Keeper, Operator) to be in a +specific language, regardless of the language used in the prompt or retrieved RAG context. + +```csharp +var result = await new CouncilBuilder() + .AddMember("llama3.2:3b", ollama, "Analyst") + .AddMember("qwen2.5:7b", ollama, "Strategist") + .SetChairman(Chairman.CreateStandard("qwen2.5:7b", ollama)) + .WithStandardDebate() + .WithUserPrompt("Microservices vs Monolith for a 5-person startup?") + .WithResponseLanguage("Russian") // ← force Russian answers + .Build() + .ExecuteAsync(); +``` + +Or via configuration: + +```json +{ + "Delibera": { + "ResponseLanguage": "Russian" + } +} +``` + +Delibera injects a strict directive into every system prompt: +> *IMPORTANT: You MUST answer exclusively in {language}. Never use any other language, regardless +> of the language used in the question, retrieved context, or other participants' messages.* + +Leave empty/null to let the model pick a language from context (legacy behaviour). + +--- + +## ⚡ Performance — Parallel Operator Requests + +When participants delegate multiple tasks to the Operator within a round (via `[[OPERATOR: …]]` +markers), Delibera now executes them **in parallel** using `Parallel.ForEachAsync`, bounded by +`MaxDegreeOfParallelism`. + +```csharp +var result = await new CouncilBuilder() + .AddMember("llama3.2:3b", ollama, "Analyst") + .WithOperator("llama3.2:3b", ollama, servers) + .WithMaxDegreeOfParallelism(4) // ← cap at 4 concurrent Operator tasks + .Build() + .ExecuteAsync(); +``` + +Or via configuration: + +```json +{ + "Delibera": { + "MaxDegreeOfParallelism": 4 + } +} +``` + +Set `0` (default) for unbounded parallelism — all delegated tasks in a round run concurrently. + +--- + ## 📁 Output Files Each deliberation can be exported as a single file or as three separate Markdown documents: diff --git a/src/README.md b/src/README.md index 5ec7ad6..befa566 100644 --- a/src/README.md +++ b/src/README.md @@ -41,7 +41,10 @@ well-reasoned outcomes** rather than single-model guesses. | **🐘 Qdrant + pgvector** | Pluggable vector stores — use a dedicated DB or your existing PostgreSQL | | **🗜️ Context Compression** | 4 strategies (Semantic, Deduplication, Summarization, Hybrid) save 30–70% of tokens | | **💉 Dependency Injection** | `AddDelibera()` extension for `IServiceCollection` with full options binding | -| **📋 Execution Logging** | `ExecutionLog` model with `LogLevel` — Chairman, KK, Compression & participant events | +| **📋 Execution Logging** | `ExecutionLog` model with `ExecutionLogLevel` — Chairman, KK, Compression & participant events | +| **📝 M.E.Logging** | Inject your own `ILogger`/`ILoggerFactory` — every debate event is forwarded to the host's logging pipeline | +| **🌐 Response Language** | Force every model (participants, Chairman, KK, Operator) to answer in a specific language | +| **⚡ Parallel Operator** | `[[OPERATOR: …]]` tasks within a round run in parallel, bounded by `MaxDegreeOfParallelism` | | **📁 Separate File Output** | Export `result.md`, `statistics.md`, and `logs.md` independently | | **🔌 Interface-First** | Clean abstractions for providers, factories, builders and executors | | **🧱 Modern C# 12** | File-scoped namespaces, records, init-only properties, global usings | @@ -198,6 +201,8 @@ Resolves these interfaces from DI: "MaxRounds": 4, "Temperature": 0.7, "SystemPrompt": "You are a knowledgeable AI expert participating in a council debate.", + "ResponseLanguage": "Russian", + "MaxDegreeOfParallelism": 0, "Providers": { "DefaultType": "Ollama", "DefaultEndpoint": "http://localhost:11434", @@ -480,20 +485,101 @@ docker exec -it psql -U postgres -d council_vectors -c "CREATE EXTEN --- +## 📝 Logging (Microsoft.Extensions.Logging) + +Delibera integrates with the standard .NET logging framework. Every debate event — Chairman +opening, Knowledge Keeper queries, compression operations, Operator interactions, participant +responses, errors — is forwarded to an injected `ILogger` (category `Delibera.Core.Council`) +**in addition to** the in-memory `ExecutionLog` collection and the `OnLog`/`OnError` events. + +### Inject your logger via the builder + +```csharp +using Microsoft.Extensions.Logging; + +var loggerFactory = LoggerFactory.Create(b => { b.AddConsole(); b.SetMinimumLevel(LogLevel.Information); }); + +var result = await new CouncilBuilder() + .AddMember("llama3.2:3b", ollama, "Analyst") + .SetChairman(Chairman.CreateStandard("qwen2.5:7b", ollama)) + .WithStandardDebate() + .WithUserPrompt("…") + .WithLogger(loggerFactory.CreateLogger("Delibera")) + .Build() + .ExecuteAsync(); +``` + +### Inject your logger factory via DI + +```csharp +services.AddDelibera(configuration, loggerFactory, "Delibera"); +// Any ICouncilBuilder resolved from the container is automatically decorated with a logger. +``` + +When no `ILogger` is configured, Delibera falls back to the legacy behaviour: events are only +captured in `DebateResult.ExecutionLogs` and the `OnLog`/`OnError` events. + +--- + +## 🌐 Response Language Enforcement + +Force **every** model response (participants, Chairman, Knowledge Keeper, Operator) to be in a +specific language, regardless of the language used in the prompt or retrieved RAG context. + +```csharp +var result = await new CouncilBuilder() + .AddMember("llama3.2:3b", ollama, "Analyst") + .AddMember("qwen2.5:7b", ollama, "Strategist") + .SetChairman(Chairman.CreateStandard("qwen2.5:7b", ollama)) + .WithStandardDebate() + .WithUserPrompt("Microservices vs Monolith for a 5-person startup?") + .WithResponseLanguage("Russian") // ← force Russian answers + .Build() + .ExecuteAsync(); +``` + +Or via configuration (`Delibera:ResponseLanguage`). Delibera injects a strict directive into every +system prompt: *“You MUST answer exclusively in {language}. Never use any other language…”*. +Leave empty/null to let the model pick a language from context (legacy behaviour). + +--- + +## ⚡ Performance — Parallel Operator Requests + +When participants delegate multiple tasks to the Operator within a round (via `[[OPERATOR: …]]` +markers), Delibera now executes them **in parallel** using `Parallel.ForEachAsync`, bounded by +`MaxDegreeOfParallelism`. + +```csharp +var result = await new CouncilBuilder() + .AddMember("llama3.2:3b", ollama, "Analyst") + .WithOperator("llama3.2:3b", ollama, servers) + .WithMaxDegreeOfParallelism(4) // ← cap at 4 concurrent Operator tasks + .Build() + .ExecuteAsync(); +``` + +Or via configuration (`Delibera:MaxDegreeOfParallelism`). Set `0` (default) for unbounded +parallelism — all delegated tasks in a round run concurrently. + +--- + ## 🏛️ Architecture ``` Delibera.Core -├── Council/ ← CouncilBuilder, CouncilExecutor, Chairman, KnowledgeKeeper -├── Debate/ ← StandardDebate, CritiqueDebate, ConsensusDebate +├── Council/ ← CouncilBuilder, CouncilExecutor, Chairman, KnowledgeKeeper, Operator +├── Debate/ ← StandardDebate, CritiqueDebate, ConsensusDebate, DebateScenario ├── Compression/ ← Semantic / Deduplication / Summarization / Hybrid ├── Providers/ -│ ├── LLM/ ← OllamaProvider, OllamaEmbeddingProvider -│ └── RAG/ ← QdrantRagProvider, PgVectorRagProvider -├── DependencyInjection/ ← AddDelibera() + CouncilOptions +│ ├── LLM/ ← OllamaProvider, ChatClientLLMProvider, EmbeddingGeneratorProvider +│ ├── RAG/ ← QdrantRagProvider, PgVectorRagProvider +│ └── Mcp/ ← McpClientAdapter (Operator ↔ MCP servers) +├── Extensions/ ← MicrosoftAIExtensions (IChatClient ↔ ILLMProvider bridges) +├── DependencyInjection/ ← AddDelibera() / AddDeliberaChatClient() + CouncilOptions ├── Knowledge/ ← MarkdownKnowledgeBase -├── Models/ ← CouncilMember, DebateResult, DebateRound, TokenStatistics, ... -└── Interfaces/ ← ILLMProvider, IRagProvider, IContextCompressor, ... +├── Models/ ← CouncilMember, DebateResult, DebateRound, TokenStatistics, DebateExecutionOptions, ... +└── Interfaces/ ← ILLMProvider, IRagProvider, IContextCompressor, IDebateStrategy, ... ``` --- From ef46ef103fcdb8fd2304e63c4635cbbe53ce9692 Mon Sep 17 00:00:00 2001 From: Victor Buzin Date: Wed, 24 Jun 2026 18:41:09 +0200 Subject: [PATCH 08/14] Fix merge conflicts: restore debate/builder files and re-add FluentAssertions - Restore DebateScenario, StandardDebate, CritiqueDebate, ConsensusDebate and CouncilBuilder to the pre-merge (v10.2.0) content; the merge commit had duplicated code blocks and garbled CollectResponsesAsync, breaking the build. - Re-add the FluentAssertions package reference to the test project (lost during the merge), required by the YandexGptProvider tests brought in from main. - Verified: dotnet build -> 0 errors, 0 warnings; dotnet test -> 31/31 passed. --- src/Delibera.Core/Council/CouncilBuilder.cs | 34 --------------- src/Delibera.Core/Debate/ConsensusDebate.cs | 4 -- src/Delibera.Core/Debate/CritiqueDebate.cs | 4 -- src/Delibera.Core/Debate/DebateScenario.cs | 42 +++++-------------- src/Delibera.Core/Debate/StandardDebate.cs | 4 -- .../Delibera.Core.Tests.csproj | 1 + 6 files changed, 12 insertions(+), 77 deletions(-) diff --git a/src/Delibera.Core/Council/CouncilBuilder.cs b/src/Delibera.Core/Council/CouncilBuilder.cs index bba58f8..08f2de9 100644 --- a/src/Delibera.Core/Council/CouncilBuilder.cs +++ b/src/Delibera.Core/Council/CouncilBuilder.cs @@ -253,40 +253,6 @@ public ICouncilBuilder WithKnowledgeKeeper( return this; } - /// - ICouncilExecutor ICouncilBuilder.Build() - { - return Build(); - } - - /// Backward-compatible alias for . - [Obsolete("Use SetChairman instead.")] - public ICouncilBuilder SetModerator(CouncilMember moderator) - { - return SetChairman(moderator); - } - - /// Backward-compatible alias for . - [Obsolete("Use SetChairman instead.")] - public ICouncilBuilder SetModerator(string modelName, ILLMProvider provider, string? persona = null) - { - return SetChairman(modelName, provider, persona); - } - - /// Creates and attaches a Knowledge Keeper from a RAG provider, model and collection. - public ICouncilBuilder WithKnowledgeKeeper( - IRagProvider ragProvider, - string modelName, - ILLMProvider llmProvider, - string collectionName = "council_knowledge") - { - ArgumentNullException.ThrowIfNull(ragProvider); - ArgumentNullException.ThrowIfNull(llmProvider); - var member = new CouncilMember(modelName, llmProvider, "Knowledge Keeper"); - _knowledgeKeeper = new KnowledgeKeeper(ragProvider, member, collectionName); - return this; - } - // ── Build ── /// diff --git a/src/Delibera.Core/Debate/ConsensusDebate.cs b/src/Delibera.Core/Debate/ConsensusDebate.cs index 2ce6aa2..3d3fec5 100644 --- a/src/Delibera.Core/Debate/ConsensusDebate.cs +++ b/src/Delibera.Core/Debate/ConsensusDebate.cs @@ -58,10 +58,6 @@ public override async Task ExecuteAsync( var operatorBriefing = BuildOperatorBriefing(@operator); var baseSystemPrompt = context.SystemPrompt + operatorBriefing; - // Operator briefing appended to participant system prompts. - var operatorBriefing = BuildOperatorBriefing(@operator); - var baseSystemPrompt = context.SystemPrompt + operatorBriefing; - if (chairman is not null) builder.SetOpeningStatement(await Chairman.OpenDebateAsync(chairman, context, members, StrategyName, maxRounds, temperature, ct)); diff --git a/src/Delibera.Core/Debate/CritiqueDebate.cs b/src/Delibera.Core/Debate/CritiqueDebate.cs index b0e02a8..7d4fa61 100644 --- a/src/Delibera.Core/Debate/CritiqueDebate.cs +++ b/src/Delibera.Core/Debate/CritiqueDebate.cs @@ -58,10 +58,6 @@ public override async Task ExecuteAsync( var operatorBriefing = BuildOperatorBriefing(@operator); var baseSystemPrompt = context.SystemPrompt + operatorBriefing; - // Operator briefing appended to participant system prompts. - var operatorBriefing = BuildOperatorBriefing(@operator); - var baseSystemPrompt = context.SystemPrompt + operatorBriefing; - if (chairman is not null) builder.SetOpeningStatement(await Chairman.OpenDebateAsync(chairman, context, members, StrategyName, maxRounds, temperature, ct)); diff --git a/src/Delibera.Core/Debate/DebateScenario.cs b/src/Delibera.Core/Debate/DebateScenario.cs index 1c37ca6..cf364da 100644 --- a/src/Delibera.Core/Debate/DebateScenario.cs +++ b/src/Delibera.Core/Debate/DebateScenario.cs @@ -59,38 +59,17 @@ public abstract Task ExecuteAsync( // Shared helpers // ────────────────────────────────────────────── - /// Collects responses from all members in parallel. - protected static async Task> CollectResponsesAsync( - IReadOnlyList members, - string systemPrompt, - string userPrompt, - float temperature, - CancellationToken ct) - { - var tasks = members.Select(async member => - { - try - { - var response = await member.AskAsync(systemPrompt, userPrompt, temperature, ct); - return (member.Role, member.DisplayName, Response: response); - } - catch (Exception ex) - { - return (member.Role, member.DisplayName, Response: $"[ERROR: {ex.Message}]"); - } - }); - - var results = await Task.WhenAll(tasks); - - //return results.ToDictionary(r => $"{r.Role}: {r.DisplayName}", r => r.Response); - // DisplayName can collide when the same model is registered multiple times. - // Disambiguate by appending a counter while preserving the original label for unique names. - var seen = new HashSet(); - var responses = new Dictionary(results.Length); - foreach (var (role, displayName, response) in results) + /// Collects responses from all members in parallel. + protected static async Task> CollectResponsesAsync( + IReadOnlyList members, + string systemPrompt, + string userPrompt, + float temperature, + CancellationToken ct) + { + var tasks = members.Select(async member => { - var key = $"{role}: {displayName}"; - if (!seen.Add(key)) + try { var response = await member.AskAsync(systemPrompt, userPrompt, temperature, ct); return (member.Role, member.DisplayName, Response: response); @@ -99,6 +78,7 @@ protected static async Task> CollectResponsesAsync( { return (member.Role, member.DisplayName, Response: $"[ERROR: {ex.Message}]"); } + }); var results = await Task.WhenAll(tasks); //return results.ToDictionary(r => r.DisplayName, r => r.Response); diff --git a/src/Delibera.Core/Debate/StandardDebate.cs b/src/Delibera.Core/Debate/StandardDebate.cs index 8c33aee..ea48952 100644 --- a/src/Delibera.Core/Debate/StandardDebate.cs +++ b/src/Delibera.Core/Debate/StandardDebate.cs @@ -62,10 +62,6 @@ public override async Task ExecuteAsync( var operatorBriefing = BuildOperatorBriefing(@operator); var baseSystemPrompt = context.SystemPrompt + operatorBriefing; - // Operator briefing is appended to participant system prompts so they know what tools exist. - var operatorBriefing = BuildOperatorBriefing(@operator); - var baseSystemPrompt = context.SystemPrompt + operatorBriefing; - // ── Chairman opening ── if (chairman is not null) builder.SetOpeningStatement(await Chairman.OpenDebateAsync( diff --git a/tests/Delibera.Core.Tests/Delibera.Core.Tests.csproj b/tests/Delibera.Core.Tests/Delibera.Core.Tests.csproj index edddd78..97b4a87 100644 --- a/tests/Delibera.Core.Tests/Delibera.Core.Tests.csproj +++ b/tests/Delibera.Core.Tests/Delibera.Core.Tests.csproj @@ -9,6 +9,7 @@ + From 2add1989f4602b98482073f9fe7236f01ae06331 Mon Sep 17 00:00:00 2001 From: Victor Buzin Date: Fri, 26 Jun 2026 15:02:41 +0200 Subject: [PATCH 09/14] Refactor debate execution options handling Standardize and refactor how debate execution options (response language, parallelism, logging) are passed and honored across the council framework. All debate strategies and their implementations now consistently support overloads with DebateExecutionOptions, enabling per-execution configuration. CouncilBuilder and CouncilExecutor thread these options through the debate lifecycle. Code style and formatting are unified, and minor cleanups applied. No breaking API changes; internal extensibility is improved. --- .../Examples/MicrosoftExtensionsAiExample.cs | 21 +- .../Examples/OperatorExample.cs | 26 +- .../Examples/OperatorMcpToolsExample.cs | 60 +++-- .../Examples/PgVectorExample.cs | 3 +- src/Delibera.ConsoleApp/Program.cs | 97 ++++--- src/Delibera.Core/Council/CouncilBuilder.cs | 97 +++---- src/Delibera.Core/Council/CouncilExecutor.cs | 209 ++++++++------- src/Delibera.Core/Debate/ConsensusDebate.cs | 86 +++---- src/Delibera.Core/Debate/CritiqueDebate.cs | 86 +++---- src/Delibera.Core/Debate/DebateScenario.cs | 238 +++++++++--------- src/Delibera.Core/Debate/StandardDebate.cs | 88 +++---- .../DependencyInjection/CouncilOptions.cs | 59 ++--- .../ServiceCollectionExtensions.cs | 195 +++++++------- .../Extensions/MicrosoftAIExtensions.cs | 1 - .../Interfaces/ICouncilBuilder.cs | 68 ++--- .../Interfaces/ICouncilExecutor.cs | 39 ++- .../Interfaces/IDebateStrategy.cs | 139 +++++----- .../Models/DebateExecutionOptions.cs | 2 - src/Delibera.Core/Models/ExecutionLog.cs | 12 +- 19 files changed, 770 insertions(+), 756 deletions(-) diff --git a/src/Delibera.ConsoleApp/Examples/MicrosoftExtensionsAiExample.cs b/src/Delibera.ConsoleApp/Examples/MicrosoftExtensionsAiExample.cs index fc6e05c..22e54cb 100644 --- a/src/Delibera.ConsoleApp/Examples/MicrosoftExtensionsAiExample.cs +++ b/src/Delibera.ConsoleApp/Examples/MicrosoftExtensionsAiExample.cs @@ -1,6 +1,5 @@ using Delibera.Core.Council; using Delibera.Core.Extensions; -using Delibera.Core.Interfaces; using Delibera.Core.Providers; using Delibera.Core.Providers.LLM; using Microsoft.Extensions.AI; @@ -37,12 +36,12 @@ public static async Task RunAsync() // Grab the underlying IChatClient and wrap it with the standard middleware pipeline // (function invocation + logging). This is the heart of "maximising the package potential". - IChatClient pipeline = ollama + var pipeline = ollama .AsChatClient() - .WithMiddleware(enableFunctionInvocation: true); + .WithMiddleware(true); // Expose the decorated client back to Delibera as a normal provider. - ILLMProvider msaiProvider = pipeline.AsLLMProvider("Ollama (via Microsoft.Extensions.AI)"); + var msaiProvider = pipeline.AsLLMProvider("Ollama (via Microsoft.Extensions.AI)"); Console.WriteLine($" ✦ Provider name: {msaiProvider.ProviderName}"); // ────────────────────────────────────────────────────────────── @@ -53,8 +52,8 @@ public static async Task RunAsync() { await foreach (var chunk in msaiProvider.ChatStreamAsync( model, - systemPrompt: "You are a concise assistant.", - userPrompt: "In one sentence, what is Microsoft.Extensions.AI?")) + "You are a concise assistant.", + "In one sentence, what is Microsoft.Extensions.AI?")) Console.Write(chunk); Console.WriteLine(); } @@ -70,7 +69,7 @@ public static async Task RunAsync() Console.WriteLine("\n 🧮 Embedding via IEmbeddingGenerator:"); try { - IEmbeddingProvider embeddings = ollama + var embeddings = ollama .AsEmbeddingGenerator() .AsEmbeddingProvider("nomic-embed-text"); @@ -88,9 +87,9 @@ public static async Task RunAsync() Console.WriteLine("\n 🏛️ Building a council from an IChatClient..."); using var factory = new ProviderFactory(); var councilProvider = factory.CreateFromChatClient( - name: "msai", - chatClient: ollama.AsChatClient(), - providerName: "Ollama"); + "msai", + ollama.AsChatClient(), + "Ollama"); var executor = new CouncilBuilder() .AddMember(model, councilProvider, "Architect") @@ -127,4 +126,4 @@ private static void PrintTips(Exception ex) Console.WriteLine(" • To use OpenAI instead, add the Microsoft.Extensions.AI.OpenAI package"); Console.WriteLine(" and build an IChatClient from your OpenAI key, then call .AsLLMProvider()."); } -} +} \ No newline at end of file diff --git a/src/Delibera.ConsoleApp/Examples/OperatorExample.cs b/src/Delibera.ConsoleApp/Examples/OperatorExample.cs index e1d4dfa..65a1c46 100644 --- a/src/Delibera.ConsoleApp/Examples/OperatorExample.cs +++ b/src/Delibera.ConsoleApp/Examples/OperatorExample.cs @@ -27,15 +27,15 @@ public static async Task RunAsync() // (a) Stdio transport — a local MCP server launched as a child process. // The "everything" reference server ships a handful of demo tools (echo, add, etc.). var everythingServer = McpServerConfig.Stdio( - name: "demo", - command: "npx", - arguments: ["-y", "@modelcontextprotocol/server-everything"]); + "demo", + "npx", + ["-y", "@modelcontextprotocol/server-everything"]); // (b) A filesystem MCP server scoped to a working directory (also stdio). var filesystemServer = McpServerConfig.Stdio( - name: "files", - command: "npx", - arguments: ["-y", "@modelcontextprotocol/server-filesystem", Directory.GetCurrentDirectory()]); + "files", + "npx", + ["-y", "@modelcontextprotocol/server-filesystem", Directory.GetCurrentDirectory()]); // (c) HTTP/SSE transport — a remote MCP server (e.g., a hosted Notion or web-search server). // Supply auth headers as needed. Commented out: requires a real endpoint + token. @@ -55,9 +55,9 @@ public static async Task RunAsync() .AddMember("llama2", ollama, "Analyst") .SetChairman(Chairman.CreateStandard("qwen2.5", ollama)) .WithOperator( - modelName: "llama3.2", // cheaper model for tool orchestration - provider: ollama, - servers: servers) + "llama3.2", // cheaper model for tool orchestration + ollama, + servers) .WithStandardDebate() .WithSystemPrompt("You are a meticulous research council. Delegate factual lookups to the Operator.") .WithUserPrompt( @@ -106,6 +106,8 @@ public static async Task RunAsync() } } - private static string Preview(string text, int max) => - string.IsNullOrEmpty(text) ? "(empty)" : text.Length <= max ? text : text[..max] + "…"; -} + private static string Preview(string text, int max) + { + return string.IsNullOrEmpty(text) ? "(empty)" : text.Length <= max ? text : text[..max] + "…"; + } +} \ No newline at end of file diff --git a/src/Delibera.ConsoleApp/Examples/OperatorMcpToolsExample.cs b/src/Delibera.ConsoleApp/Examples/OperatorMcpToolsExample.cs index 2df7d14..5510e57 100644 --- a/src/Delibera.ConsoleApp/Examples/OperatorMcpToolsExample.cs +++ b/src/Delibera.ConsoleApp/Examples/OperatorMcpToolsExample.cs @@ -1,3 +1,4 @@ +using System.Text; using Delibera.Core.Council; using Delibera.Core.Interfaces; using Delibera.Core.Models; @@ -31,15 +32,19 @@ namespace Delibera.ConsoleApp.Examples; /// Пример показывает оба способа использования Operator: /// /// -/// Прямой вызов (раздел A). -/// Делегирование внутри совета через маркер [[OPERATOR: ...]] (раздел B). +/// +/// Прямой вызов (раздел A). +/// +/// +/// Делегирование внутри совета через маркер [[OPERATOR: ...]] (раздел B). +/// /// /// public static class OperatorMcpToolsExample { public static async Task RunAsync() { - Console.OutputEncoding = System.Text.Encoding.UTF8; + Console.OutputEncoding = Encoding.UTF8; Console.WriteLine("🛠️ Operator + MCP-инструменты (браузер · Marp-презентации)\n"); // ── LLM-провайдер ── @@ -56,9 +61,9 @@ public static async Task RunAsync() // Предоставляет инструменты управления реальным браузером. // Запускается как дочерний процесс через npx (нужен Node.js). var browserServer = McpServerConfig.Stdio( - name: "browser", - command: "npx", - arguments: ["-y", "@playwright/mcp@latest", "--headless"]); + "browser", + "npx", + ["-y", "@playwright/mcp@latest", "--headless"]); // (2) 🎯 Marp — генерация презентаций из Markdown. // Каталог вывода монтируется через рабочую директорию; готовые файлы @@ -67,9 +72,9 @@ public static async Task RunAsync() Directory.CreateDirectory(presentationsDir); var marpServer = McpServerConfig.Stdio( - name: "marp", - command: "npx", - arguments: ["-y", "@marp-team/marp-cli", "--server", presentationsDir], + "marp", + "npx", + ["-y", "@marp-team/marp-cli", "--server", presentationsDir], workingDirectory: presentationsDir); // (3) 🌐 HTTP-вариант (закомментирован): удалённый MCP-сервер браузера/поиска. @@ -120,15 +125,15 @@ private static async Task RunDirectOperatorAsync( // A.1 🌐 Браузерная задача — Operator сам выберет browser_navigate + browser_snapshot. Console.WriteLine("\n▶ Задача 1 (браузер): прочитать заголовок и краткое содержание страницы."); var browseResult = await @operator.ExecuteTaskAsync( - requesterName: "Аналитик", - task: "Открой https://modelcontextprotocol.io и кратко перескажи, что такое MCP, в 3 предложениях."); + "Аналитик", + "Открой https://modelcontextprotocol.io и кратко перескажи, что такое MCP, в 3 предложениях."); PrintResult(browseResult); // A.2 🎯 Marp-задача — Operator сформирует Markdown и соберёт презентацию. Console.WriteLine("\n▶ Задача 2 (Marp): сгенерировать презентацию из Markdown."); var deckResult = await @operator.ExecuteTaskAsync( - requesterName: "Докладчик", - task: "Создай Marp-презентацию из 3 слайдов о преимуществах .NET 10 и сохрани её как deck.html."); + "Докладчик", + "Создай Marp-презентацию из 3 слайдов о преимуществах .NET 10 и сохрани её как deck.html."); PrintResult(deckResult); } catch (Exception ex) @@ -151,9 +156,9 @@ private static async Task RunCouncilWithOperatorAsync( .AddMember("llama2", ollama, "Архитектор") .SetChairman(Chairman.CreateStandard("qwen2.5", ollama)) .WithOperator( - modelName: "llama3.2", // дешёвая модель для оркестрации инструментов - provider: ollama, - servers: servers) + "llama3.2", // дешёвая модель для оркестрации инструментов + ollama, + servers) .WithStandardDebate() .WithSystemPrompt( "Вы — совет по технологической стратегии. Когда нужны свежие факты из интернета " + @@ -206,15 +211,22 @@ private static async Task RunCouncilWithOperatorAsync( // ────────────────────────────────────────────────────────────────── /// Создаёт MCP-клиент для конфигурации сервера. - private static IMcpClient IMcpClientFor(McpServerConfig config) => new McpClientAdapter(config); + private static IMcpClient IMcpClientFor(McpServerConfig config) + { + return new McpClientAdapter(config); + } private static void PrintResult(OperatorResult result) { Console.WriteLine($" Вызвано инструментов: {result.ToolCalls.Count}" + - (result.Compressed ? " (ответ сжат)" : string.Empty)); + (result.Compressed + ? " (ответ сжат)" + : string.Empty)); foreach (var call in result.ToolCalls) { - var status = call.IsError ? "❌" : "✓"; + var status = call.IsError + ? "❌" + : "✓"; Console.WriteLine($" {status} [{call.ServerName}] {call.ToolName}"); } @@ -231,7 +243,9 @@ private static void PrintFailureTips(Exception ex) Console.WriteLine(" • Свои MCP-серверы подключаются через McpServerConfig.Stdio / .Http."); } - private static string Preview(string? text, int max) => - string.IsNullOrEmpty(text) ? "(пусто)" : - text.Length <= max ? text : text[..max] + "…"; -} + private static string Preview(string? text, int max) + { + return string.IsNullOrEmpty(text) ? "(пусто)" : + text.Length <= max ? text : text[..max] + "…"; + } +} \ No newline at end of file diff --git a/src/Delibera.ConsoleApp/Examples/PgVectorExample.cs b/src/Delibera.ConsoleApp/Examples/PgVectorExample.cs index 73dbc2b..1ca4883 100644 --- a/src/Delibera.ConsoleApp/Examples/PgVectorExample.cs +++ b/src/Delibera.ConsoleApp/Examples/PgVectorExample.cs @@ -84,8 +84,7 @@ of loosely coupled services. Each service is fine-grained and implements a singl collectionName, sampleDocument, new Dictionary { ["source"] = "architecture_guide.md" }, - 300, - 50); + 300); Console.WriteLine($" ✅ Indexed {chunks} chunks into '{collectionName}'\n"); diff --git a/src/Delibera.ConsoleApp/Program.cs b/src/Delibera.ConsoleApp/Program.cs index a3741a5..ecd65b4 100644 --- a/src/Delibera.ConsoleApp/Program.cs +++ b/src/Delibera.ConsoleApp/Program.cs @@ -40,16 +40,15 @@ public static async Task Main(string[] args) if (!alreadyReported) PrintFatalError(ex); - WaitForKeyOnExit("Press any key to exit…", isError: true); + WaitForKeyOnExit("Press any key to exit…", true); return; } - WaitForKeyOnExit("\n🏁 Delibera session complete. Press any key to exit…", isError: false); + WaitForKeyOnExit("\n🏁 Delibera session complete. Press any key to exit…", false); } private static async Task RunAsync(string[] args, Action onDebateFailed) { - // ═══════════════════════════════════════════════ // 🆕 v3.1: DI & Separate Files Examples // ═══════════════════════════════════════════════ @@ -340,26 +339,26 @@ private static async Task RunAsync(string[] args, Action onDebateFailed) var maxRounds = debateCfg.GetValue("MaxRounds") ?? 4; var temperature = debateCfg.GetValue("Temperature") ?? 0.7f; - var systemPrompt = cfg["Prompts:SystemPrompt"] ?? "You are a helpful AI assistant participating in a council debate."; - var userPrompt = cfg["Prompts:UserPrompt"] ?? "What is the different between Microservices vs Monolith?"; - var responseLanguage = debateCfg["ResponseLanguage"]; - var maxDegreeOfParallelism = debateCfg.GetValue("MaxDegreeOfParallelism") ?? 0; - - IDebateStrategy strategy = stratName.ToLowerInvariant() switch - { - "critique" => new CritiqueDebate(), - "consensus" => new ConsensusDebate(), - _ => new StandardDebate() - }; - - var builder = new CouncilBuilder() - .WithStrategy(strategy) - .WithSystemPrompt(systemPrompt) - .WithUserPrompt(userPrompt) - .WithMaxRounds(maxRounds) - .WithTemperature(temperature) - .WithResponseLanguage(responseLanguage) - .WithMaxDegreeOfParallelism(maxDegreeOfParallelism); + var systemPrompt = cfg["Prompts:SystemPrompt"] ?? "You are a helpful AI assistant participating in a council debate."; + var userPrompt = cfg["Prompts:UserPrompt"] ?? "What is the different between Microservices vs Monolith?"; + var responseLanguage = debateCfg["ResponseLanguage"]; + var maxDegreeOfParallelism = debateCfg.GetValue("MaxDegreeOfParallelism") ?? 0; + + IDebateStrategy strategy = stratName.ToLowerInvariant() switch + { + "critique" => new CritiqueDebate(), + "consensus" => new ConsensusDebate(), + _ => new StandardDebate() + }; + + var builder = new CouncilBuilder() + .WithStrategy(strategy) + .WithSystemPrompt(systemPrompt) + .WithUserPrompt(userPrompt) + .WithMaxRounds(maxRounds) + .WithTemperature(temperature) + .WithResponseLanguage(responseLanguage) + .WithMaxDegreeOfParallelism(maxDegreeOfParallelism); // Add members foreach (var mc in cfg.GetSection("Models").GetChildren()) @@ -438,10 +437,7 @@ private static async Task RunAsync(string[] args, Action onDebateFailed) }; // Surface non-fatal internal errors (e.g. failed MCP tool call) without aborting the debate. - executor.OnError += (ex, context) => - { - WriteErrorEntry(ex, context); - }; + executor.OnError += (ex, context) => { WriteErrorEntry(ex, context); }; executor.OnRoundCompleted += round => { @@ -546,7 +542,7 @@ private static async Task RunAsync(string[] args, Action onDebateFailed) } catch (Exception ex) { - PrintFatalError(ex, header: "❌ Debate failed"); + 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"); @@ -562,19 +558,19 @@ private static void PrintBanner() Console.ForegroundColor = ConsoleColor.Green; Console.WriteLine(""" - ██████╗ ███████╗██╗ ██╗██████╗ ███████╗██████╗ █████╗ - ██╔══██╗██╔════╝██║ ██║██╔══██╗██╔════╝██╔══██╗██╔══██╗ - ██║ ██║█████╗ ██║ ██║██████╔╝█████╗ ██████╔╝███████║ - ██║ ██║██╔══╝ ██║ ██║██╔══██╗██╔══╝ ██╔══██╗██╔══██║ - ██████╔╝███████╗███████╗██║██████╔╝███████╗██║ ██║██║ ██║ - ╚═════╝ ╚══════╝╚══════╝╚═╝╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ + ██████╗ ███████╗██╗ ██╗██████╗ ███████╗██████╗ █████╗ + ██╔══██╗██╔════╝██║ ██║██╔══██╗██╔════╝██╔══██╗██╔══██╗ + ██║ ██║█████╗ ██║ ██║██████╔╝█████╗ ██████╔╝███████║ + ██║ ██║██╔══╝ ██║ ██║██╔══██╗██╔══╝ ██╔══██╗██╔══██║ + ██████╔╝███████╗███████╗██║██████╔╝███████╗██║ ██║██║ ██║ + ╚═════╝ ╚══════╝╚══════╝╚═╝╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ - ⚖️ Thoughtful AI Decisions · v3.1 + ⚖️ Thoughtful AI Decisions · v3.1 - RAG • pgvector • Knowledge Keeper • Chairman - Context Compression • DI • Execution Logging + RAG • pgvector • Knowledge Keeper • Chairman + Context Compression • DI • Execution Logging - """); + """); Console.ResetColor(); } @@ -588,14 +584,14 @@ Context Compression • DI • Execution Logging private static void WriteLogEntry(ExecutionLog entry) { var prev = Console.ForegroundColor; - Console.ForegroundColor = entry.Level switch - { - ExecutionLogLevel.Trace => ConsoleColor.DarkGray, - ExecutionLogLevel.Info => ConsoleColor.Cyan, - ExecutionLogLevel.Warning => ConsoleColor.Yellow, - ExecutionLogLevel.Error => ConsoleColor.Red, - _ => prev - }; + Console.ForegroundColor = entry.Level switch + { + ExecutionLogLevel.Trace => ConsoleColor.DarkGray, + ExecutionLogLevel.Info => ConsoleColor.Cyan, + ExecutionLogLevel.Warning => ConsoleColor.Yellow, + ExecutionLogLevel.Error => ConsoleColor.Red, + _ => prev + }; Console.WriteLine($" ┊ {entry}"); Console.ForegroundColor = prev; @@ -669,7 +665,7 @@ private static void WaitForKeyOnExit(string prompt, bool isError) try { - Console.ReadKey(intercept: true); + Console.ReadKey(true); } catch (InvalidOperationException) { @@ -677,7 +673,9 @@ private static void WaitForKeyOnExit(string prompt, bool isError) Console.WriteLine("(no interactive console available; exiting.)"); } - Environment.ExitCode = isError ? 1 : 0; + Environment.ExitCode = isError + ? 1 + : 0; } private static bool firstLineIsMeaningful(string? frame) @@ -687,7 +685,6 @@ private static bool firstLineIsMeaningful(string? frame) var trimmed = frame.Trim(); // Filter out noise from runtime/compiler-emitted frames. - return !trimmed.StartsWith("at System.", StringComparison.Ordinal) - || trimmed.Contains("Delibera", StringComparison.Ordinal); + return !trimmed.StartsWith("at System.", StringComparison.Ordinal) || trimmed.Contains("Delibera", StringComparison.Ordinal); } } \ No newline at end of file diff --git a/src/Delibera.Core/Council/CouncilBuilder.cs b/src/Delibera.Core/Council/CouncilBuilder.cs index 08f2de9..7cc6801 100644 --- a/src/Delibera.Core/Council/CouncilBuilder.cs +++ b/src/Delibera.Core/Council/CouncilBuilder.cs @@ -1,7 +1,6 @@ using Delibera.Core.Compression; using Delibera.Core.Debate; using Delibera.Core.Providers.Mcp; -using Microsoft.Extensions.Logging; namespace Delibera.Core.Council; @@ -191,33 +190,35 @@ public ICouncilBuilder WithTemperature(float temperature) return this; } - /// - public ICouncilBuilder SaveResultTo(string outputPath) - { - _outputPath = outputPath; - return this; - } - - /// - public ICouncilBuilder WithResponseLanguage(string? language) - { - _responseLanguage = string.IsNullOrWhiteSpace(language) ? null : language.Trim(); - return this; - } - - /// - public ICouncilBuilder WithMaxDegreeOfParallelism(int maxDegreeOfParallelism) - { - _maxDegreeOfParallelism = Math.Max(0, maxDegreeOfParallelism); - return this; - } - - /// - public ICouncilBuilder WithLogger(ILogger? logger) - { - _logger = logger; - return this; - } + /// + public ICouncilBuilder SaveResultTo(string outputPath) + { + _outputPath = outputPath; + return this; + } + + /// + public ICouncilBuilder WithResponseLanguage(string? language) + { + _responseLanguage = string.IsNullOrWhiteSpace(language) + ? null + : language.Trim(); + return this; + } + + /// + public ICouncilBuilder WithMaxDegreeOfParallelism(int maxDegreeOfParallelism) + { + _maxDegreeOfParallelism = Math.Max(0, maxDegreeOfParallelism); + return this; + } + + /// + public ICouncilBuilder WithLogger(ILogger? logger) + { + _logger = logger; + return this; + } /// ICouncilExecutor ICouncilBuilder.Build() @@ -291,24 +292,24 @@ public CouncilExecutor Build() : null); } - var executionOptions = new DebateExecutionOptions( - ResponseLanguage: _responseLanguage, - MaxDegreeOfParallelism: _maxDegreeOfParallelism, - Logger: _logger); - - return new CouncilExecutor( - _members.AsReadOnly(), - _chairman, - _knowledgeKeeper, - _strategy, - context, - _maxRounds, - _temperature, - _outputPath, - _compressor, - _compressionOptions, - _compressionCache, - @operator, - executionOptions); - } + var executionOptions = new DebateExecutionOptions( + _responseLanguage, + _maxDegreeOfParallelism, + _logger); + + return new CouncilExecutor( + _members.AsReadOnly(), + _chairman, + _knowledgeKeeper, + _strategy, + context, + _maxRounds, + _temperature, + _outputPath, + _compressor, + _compressionOptions, + _compressionCache, + @operator, + executionOptions); + } } \ No newline at end of file diff --git a/src/Delibera.Core/Council/CouncilExecutor.cs b/src/Delibera.Core/Council/CouncilExecutor.cs index 1a9b14e..2299b51 100644 --- a/src/Delibera.Core/Council/CouncilExecutor.cs +++ b/src/Delibera.Core/Council/CouncilExecutor.cs @@ -1,5 +1,4 @@ using Delibera.Core.Compression; -using Microsoft.Extensions.Logging; namespace Delibera.Core.Council; @@ -49,6 +48,12 @@ internal CouncilExecutor( /// Compression cache (may be null). public CompressionCache? CompressionCache { get; } + /// + /// Per-execution options (response language, parallelism budget, logger). + /// Populated from . + /// + public DebateExecutionOptions ExecutionOptions { get; } + /// Council participants. public IReadOnlyList Members { get; } @@ -67,12 +72,6 @@ internal CouncilExecutor( /// Context compressor (may be null if compression is disabled). public IContextCompressor? Compressor { get; } - /// - /// Per-execution options (response language, parallelism budget, logger). - /// Populated from . - /// - public DebateExecutionOptions ExecutionOptions { get; } - /// public ILogger? Logger => ExecutionOptions.Logger; @@ -88,87 +87,87 @@ internal CouncilExecutor( /// public event Action? OnError; - /// - /// Runs the debate and returns the full result. - /// - public async Task ExecuteAsync(CancellationToken ct = default) - { - _executionLogs.Clear(); - - Log(ExecutionLog.Info("Council", $"Starting debate — strategy: {Strategy.StrategyName}, members: {Members.Count}, maxRounds: {_maxRounds}")); - - if (ExecutionOptions.HasResponseLanguage) - Log(ExecutionLog.Info("Council", $"Response language enforced: {ExecutionOptions.ResponseLanguage}")); - - if (ExecutionOptions.MaxDegreeOfParallelism > 0) - Log(ExecutionLog.Info("Council", $"Parallelism cap: {ExecutionOptions.MaxDegreeOfParallelism}")); - - if (Chairman is not null) - Log(ExecutionLog.Info("Chairman", $"Chairman assigned: {Chairman.DisplayName}")); - - if (KnowledgeKeeper is not null) - Log(ExecutionLog.Info("KnowledgeKeeper", $"Knowledge Keeper ready: {KnowledgeKeeper.DisplayName} (collection: {KnowledgeKeeper.CollectionName})")); - - // Initialise the Operator (connect to MCP servers, discover tools) before the debate begins. - if (Operator is not null) - { - if (!Operator.IsInitialized) - { - Log(ExecutionLog.Info("Operator", $"Initialising Operator: {Operator.DisplayName}…")); - try - { - await Operator.InitializeAsync(ct); - } - catch (Exception ex) - { - ReportError(ex, "Operator"); - } - } - - Log(ExecutionLog.Info("Operator", $"Operator ready: {Operator.DisplayName} ({Operator.AvailableTools.Count} tool(s) available)")); - } - - if (Compressor is not null) - Log(ExecutionLog.Info("Compression", $"Compression enabled: {Compressor.StrategyName}")); - - foreach (var m in Members) - Log(ExecutionLog.Trace("Council", $"Participant registered: {m.DisplayName} [{m.Role}]")); - - // Inject the response-language directive into the system prompt so every downstream - // call (participants, Chairman.OpenDebateAsync / SynthesizeVerdictAsync, Knowledge Keeper, - // Operator) inherits it. - var effectiveContext = ExecutionOptions.HasResponseLanguage - ? _context with { SystemPrompt = _context.SystemPrompt + ExecutionOptions.BuildLanguageDirective() } - : _context; - - var result = await Strategy.ExecuteAsync( - Members, - effectiveContext, - Chairman, - KnowledgeKeeper, - Operator, - ExecutionOptions, - _maxRounds, - _temperature, - round => - { - Log(ExecutionLog.Info("Council", $"Round {round.RoundNumber} completed: {round.RoundName} ({round.Duration.TotalSeconds:F1}s, {round.Responses.Count} responses)")); - - // Log knowledge interactions - foreach (var ki in round.KnowledgeInteractions) - Log(ExecutionLog.Info("KnowledgeKeeper", $"Query: \"{Truncate(ki.Query, 100)}\" → {ki.SourceChunks} chunks")); - - // Log operator interactions - foreach (var oi in round.OperatorInteractions) - Log(ExecutionLog.Info("Operator", $"{oi.RequesterName} → \"{Truncate(oi.Task, 100)}\" ({oi.ToolCallCount} tool call(s))")); - - // Log participant responses - foreach (var (member, response) in round.Responses) - Log(ExecutionLog.Trace("Participant", $"{member} responded ({response.Length} chars)")); - - OnRoundCompleted?.Invoke(round); - }, - ct); + /// + /// Runs the debate and returns the full result. + /// + public async Task ExecuteAsync(CancellationToken ct = default) + { + _executionLogs.Clear(); + + Log(ExecutionLog.Info("Council", $"Starting debate — strategy: {Strategy.StrategyName}, members: {Members.Count}, maxRounds: {_maxRounds}")); + + if (ExecutionOptions.HasResponseLanguage) + Log(ExecutionLog.Info("Council", $"Response language enforced: {ExecutionOptions.ResponseLanguage}")); + + if (ExecutionOptions.MaxDegreeOfParallelism > 0) + Log(ExecutionLog.Info("Council", $"Parallelism cap: {ExecutionOptions.MaxDegreeOfParallelism}")); + + if (Chairman is not null) + Log(ExecutionLog.Info("Chairman", $"Chairman assigned: {Chairman.DisplayName}")); + + if (KnowledgeKeeper is not null) + Log(ExecutionLog.Info("KnowledgeKeeper", $"Knowledge Keeper ready: {KnowledgeKeeper.DisplayName} (collection: {KnowledgeKeeper.CollectionName})")); + + // Initialise the Operator (connect to MCP servers, discover tools) before the debate begins. + if (Operator is not null) + { + if (!Operator.IsInitialized) + { + Log(ExecutionLog.Info("Operator", $"Initialising Operator: {Operator.DisplayName}…")); + try + { + await Operator.InitializeAsync(ct); + } + catch (Exception ex) + { + ReportError(ex, "Operator"); + } + } + + Log(ExecutionLog.Info("Operator", $"Operator ready: {Operator.DisplayName} ({Operator.AvailableTools.Count} tool(s) available)")); + } + + if (Compressor is not null) + Log(ExecutionLog.Info("Compression", $"Compression enabled: {Compressor.StrategyName}")); + + foreach (var m in Members) + Log(ExecutionLog.Trace("Council", $"Participant registered: {m.DisplayName} [{m.Role}]")); + + // Inject the response-language directive into the system prompt so every downstream + // call (participants, Chairman.OpenDebateAsync / SynthesizeVerdictAsync, Knowledge Keeper, + // Operator) inherits it. + var effectiveContext = ExecutionOptions.HasResponseLanguage + ? _context with { SystemPrompt = _context.SystemPrompt + ExecutionOptions.BuildLanguageDirective() } + : _context; + + var result = await Strategy.ExecuteAsync( + Members, + effectiveContext, + Chairman, + KnowledgeKeeper, + Operator, + ExecutionOptions, + _maxRounds, + _temperature, + round => + { + Log(ExecutionLog.Info("Council", $"Round {round.RoundNumber} completed: {round.RoundName} ({round.Duration.TotalSeconds:F1}s, {round.Responses.Count} responses)")); + + // Log knowledge interactions + foreach (var ki in round.KnowledgeInteractions) + Log(ExecutionLog.Info("KnowledgeKeeper", $"Query: \"{Truncate(ki.Query, 100)}\" → {ki.SourceChunks} chunks")); + + // Log operator interactions + foreach (var oi in round.OperatorInteractions) + Log(ExecutionLog.Info("Operator", $"{oi.RequesterName} → \"{Truncate(oi.Task, 100)}\" ({oi.ToolCallCount} tool call(s))")); + + // Log participant responses + foreach (var (member, response) in round.Responses) + Log(ExecutionLog.Trace("Participant", $"{member} responded ({response.Length} chars)")); + + OnRoundCompleted?.Invoke(round); + }, + ct); Log(ExecutionLog.Info("Council", $"Debate completed — {result.Rounds.Count} rounds, duration: {result.TotalDuration.TotalSeconds:F1}s")); @@ -225,16 +224,16 @@ public string GetInfo() sb.AppendLine("║ LLM COUNCIL v3.1 CONFIGURATION ║"); sb.AppendLine("╚══════════════════════════════════════════╝"); sb.AppendLine(); - sb.AppendLine($" Strategy: {Strategy.StrategyName}"); - sb.AppendLine($" Max Rounds: {_maxRounds}"); - sb.AppendLine($" Temperature: {_temperature:F2}"); - if (ExecutionOptions.HasResponseLanguage) - sb.AppendLine($" Language: {ExecutionOptions.ResponseLanguage}"); - if (ExecutionOptions.MaxDegreeOfParallelism > 0) - sb.AppendLine($" Parallelism: {ExecutionOptions.MaxDegreeOfParallelism}"); - if (ExecutionOptions.Logger is not null) - sb.AppendLine($" Logger: {ExecutionOptions.Logger.GetType().Name}"); - sb.AppendLine(); + sb.AppendLine($" Strategy: {Strategy.StrategyName}"); + sb.AppendLine($" Max Rounds: {_maxRounds}"); + sb.AppendLine($" Temperature: {_temperature:F2}"); + if (ExecutionOptions.HasResponseLanguage) + sb.AppendLine($" Language: {ExecutionOptions.ResponseLanguage}"); + if (ExecutionOptions.MaxDegreeOfParallelism > 0) + sb.AppendLine($" Parallelism: {ExecutionOptions.MaxDegreeOfParallelism}"); + if (ExecutionOptions.Logger is not null) + sb.AppendLine($" Logger: {ExecutionOptions.Logger.GetType().Name}"); + sb.AppendLine(); sb.AppendLine(" ── Members ──"); foreach (var m in Members) sb.AppendLine($" • {m.DisplayName} [{m.Role}]"); @@ -294,14 +293,14 @@ private void Log(ExecutionLog entry) OnLog?.Invoke(entry); } - private void ReportError(Exception ex, string context) - { - var entry = ExecutionLog.Error(context, ex.Message); - _executionLogs.Add(ExecutionLogSink.Emit(ExecutionOptions.Logger, entry)); - OnError?.Invoke(ex, context); + private void ReportError(Exception ex, string context) + { + var entry = ExecutionLog.Error(context, ex.Message); + _executionLogs.Add(ExecutionLogSink.Emit(ExecutionOptions.Logger, entry)); + OnError?.Invoke(ex, context); - ExecutionOptions.Logger?.LogError(ex, "[{Source}] {Message}", context, ex.Message); - } + ExecutionOptions.Logger?.LogError(ex, "[{Source}] {Message}", context, ex.Message); + } private static string Truncate(string text, int max) { diff --git a/src/Delibera.Core/Debate/ConsensusDebate.cs b/src/Delibera.Core/Debate/ConsensusDebate.cs index 3d3fec5..2b905a4 100644 --- a/src/Delibera.Core/Debate/ConsensusDebate.cs +++ b/src/Delibera.Core/Debate/ConsensusDebate.cs @@ -20,43 +20,43 @@ public sealed class ConsensusDebate : DebateScenario /// public override string Description => "Collaborative debate: Perspectives → Common Ground → Consensus → Facilitator"; - /// - public override async Task ExecuteAsync( - IReadOnlyList members, - PromptContext context, - CouncilMember? chairman, - KnowledgeKeeper? knowledgeKeeper, - Operator? @operator, - int maxRounds = 4, - float temperature = 0.7f, - Action? onRoundCompleted = null, - CancellationToken ct = default) - { - return await ExecuteAsync(members, context, chairman, knowledgeKeeper, @operator, - DebateExecutionOptions.Default, maxRounds, temperature, onRoundCompleted, ct); - } - - /// - public override async Task ExecuteAsync( - IReadOnlyList members, - PromptContext context, - CouncilMember? chairman, - KnowledgeKeeper? knowledgeKeeper, - Operator? @operator, - DebateExecutionOptions executionOptions, - int maxRounds = 4, - float temperature = 0.7f, - Action? onRoundCompleted = null, - CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(members); - ArgumentNullException.ThrowIfNull(context); - var builder = new DebateResultBuilder(this, members, context, chairman, knowledgeKeeper, @operator); - var fullUserPrompt = context.GetFullUserPrompt(); - - // Operator briefing appended to participant system prompts. - var operatorBriefing = BuildOperatorBriefing(@operator); - var baseSystemPrompt = context.SystemPrompt + operatorBriefing; + /// + public override async Task ExecuteAsync( + IReadOnlyList members, + PromptContext context, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, + int maxRounds = 4, + float temperature = 0.7f, + Action? onRoundCompleted = null, + CancellationToken ct = default) + { + return await ExecuteAsync(members, context, chairman, knowledgeKeeper, @operator, + DebateExecutionOptions.Default, maxRounds, temperature, onRoundCompleted, ct); + } + + /// + public override async Task ExecuteAsync( + IReadOnlyList members, + PromptContext context, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, + DebateExecutionOptions executionOptions, + int maxRounds = 4, + float temperature = 0.7f, + Action? onRoundCompleted = null, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(members); + ArgumentNullException.ThrowIfNull(context); + var builder = new DebateResultBuilder(this, members, context, chairman, knowledgeKeeper, @operator); + var fullUserPrompt = context.GetFullUserPrompt(); + + // Operator briefing appended to participant system prompts. + var operatorBriefing = BuildOperatorBriefing(@operator); + var baseSystemPrompt = context.SystemPrompt + operatorBriefing; if (chairman is not null) builder.SetOpeningStatement(await Chairman.OpenDebateAsync(chairman, context, members, StrategyName, maxRounds, temperature, ct)); @@ -76,8 +76,8 @@ public override async Task ExecuteAsync( : $"{fullUserPrompt}\n\n📚 Knowledge:\n{knowledgeCtx}"; // Round 1: Initial Perspectives - var r1 = await CollectResponsesAsync(members, baseSystemPrompt, enrichedPrompt, temperature, ct); - var r1Op = await ProcessOperatorRequestsAsync(@operator, r1, executionOptions, ct); + var r1 = await CollectResponsesAsync(members, baseSystemPrompt, enrichedPrompt, temperature, ct); + var r1Op = await ProcessOperatorRequestsAsync(@operator, r1, executionOptions, ct); var round1 = CreateRound(1, "Initial Perspectives", "Each model shares their perspective.", r1, knowledgeInteractions: r1Ki, operatorInteractions: r1Op); builder.AddRound(round1); onRoundCompleted?.Invoke(round1); @@ -106,8 +106,8 @@ and propose bridges. Be open to changing your position. {(string.IsNullOrWhiteSpace(r1OpText) ? "" : $"\n{r1OpText}")} Identify: 1) Points of Agreement 2) Points of Disagreement 3) Bridge Proposals 4) Your Updated Position """; - var r2 = await CollectResponsesAsync(members, r2Sys, r2Prompt, temperature, ct); - var r2Op = await ProcessOperatorRequestsAsync(@operator, r2, executionOptions, ct); + var r2 = await CollectResponsesAsync(members, r2Sys, r2Prompt, temperature, ct); + var r2Op = await ProcessOperatorRequestsAsync(@operator, r2, executionOptions, ct); var round2 = CreateRound(2, "Finding Common Ground", "Models identify agreements and disagreements.", r2, knowledgeInteractions: r2Ki, operatorInteractions: r2Op); builder.AddRound(round2); onRoundCompleted?.Invoke(round2); @@ -136,8 +136,8 @@ and propose bridges. Be open to changing your position. {(string.IsNullOrWhiteSpace(r1OpText) && string.IsNullOrWhiteSpace(r2OpText) ? "" : $"\n{r1OpText}\n{r2OpText}")} Formulate: 1) Agreed points 2) Proposed unified answer 3) Remaining disagreements 4) Confidence (Low/Medium/High) """; - var r3 = await CollectResponsesAsync(members, r3Sys, r3Prompt, temperature, ct); - var r3Op = await ProcessOperatorRequestsAsync(@operator, r3, executionOptions, ct); + var r3 = await CollectResponsesAsync(members, r3Sys, r3Prompt, temperature, ct); + var r3Op = await ProcessOperatorRequestsAsync(@operator, r3, executionOptions, ct); var round3 = CreateRound(3, "Consensus Building", "Models attempt a unified answer.", r3, knowledgeInteractions: r3Ki, operatorInteractions: r3Op); builder.AddRound(round3); onRoundCompleted?.Invoke(round3); diff --git a/src/Delibera.Core/Debate/CritiqueDebate.cs b/src/Delibera.Core/Debate/CritiqueDebate.cs index 7d4fa61..e390f6f 100644 --- a/src/Delibera.Core/Debate/CritiqueDebate.cs +++ b/src/Delibera.Core/Debate/CritiqueDebate.cs @@ -20,43 +20,43 @@ public sealed class CritiqueDebate : DebateScenario /// public override string Description => "Adversarial debate: Position → Critique → Defence → Judge Verdict"; - /// - public override async Task ExecuteAsync( - IReadOnlyList members, - PromptContext context, - CouncilMember? chairman, - KnowledgeKeeper? knowledgeKeeper, - Operator? @operator, - int maxRounds = 4, - float temperature = 0.7f, - Action? onRoundCompleted = null, - CancellationToken ct = default) - { - return await ExecuteAsync(members, context, chairman, knowledgeKeeper, @operator, - DebateExecutionOptions.Default, maxRounds, temperature, onRoundCompleted, ct); - } - - /// - public override async Task ExecuteAsync( - IReadOnlyList members, - PromptContext context, - CouncilMember? chairman, - KnowledgeKeeper? knowledgeKeeper, - Operator? @operator, - DebateExecutionOptions executionOptions, - int maxRounds = 4, - float temperature = 0.7f, - Action? onRoundCompleted = null, - CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(members); - ArgumentNullException.ThrowIfNull(context); - var builder = new DebateResultBuilder(this, members, context, chairman, knowledgeKeeper, @operator); - var fullUserPrompt = context.GetFullUserPrompt(); - - // Operator briefing appended to participant system prompts. - var operatorBriefing = BuildOperatorBriefing(@operator); - var baseSystemPrompt = context.SystemPrompt + operatorBriefing; + /// + public override async Task ExecuteAsync( + IReadOnlyList members, + PromptContext context, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, + int maxRounds = 4, + float temperature = 0.7f, + Action? onRoundCompleted = null, + CancellationToken ct = default) + { + return await ExecuteAsync(members, context, chairman, knowledgeKeeper, @operator, + DebateExecutionOptions.Default, maxRounds, temperature, onRoundCompleted, ct); + } + + /// + public override async Task ExecuteAsync( + IReadOnlyList members, + PromptContext context, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, + DebateExecutionOptions executionOptions, + int maxRounds = 4, + float temperature = 0.7f, + Action? onRoundCompleted = null, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(members); + ArgumentNullException.ThrowIfNull(context); + var builder = new DebateResultBuilder(this, members, context, chairman, knowledgeKeeper, @operator); + var fullUserPrompt = context.GetFullUserPrompt(); + + // Operator briefing appended to participant system prompts. + var operatorBriefing = BuildOperatorBriefing(@operator); + var baseSystemPrompt = context.SystemPrompt + operatorBriefing; if (chairman is not null) builder.SetOpeningStatement(await Chairman.OpenDebateAsync(chairman, context, members, StrategyName, maxRounds, temperature, ct)); @@ -76,8 +76,8 @@ public override async Task ExecuteAsync( : $"{fullUserPrompt}\n\n📚 Knowledge:\n{knowledgeCtx}"; // Round 1: Initial Positions - var r1 = await CollectResponsesAsync(members, baseSystemPrompt, enrichedPrompt, temperature, ct); - var r1Op = await ProcessOperatorRequestsAsync(@operator, r1, executionOptions, ct); + var r1 = await CollectResponsesAsync(members, baseSystemPrompt, enrichedPrompt, temperature, ct); + var r1Op = await ProcessOperatorRequestsAsync(@operator, r1, executionOptions, ct); var round1 = CreateRound(1, "Initial Positions", "Each model states their initial position.", r1, knowledgeInteractions: r1Ki, operatorInteractions: r1Op); builder.AddRound(round1); onRoundCompleted?.Invoke(round1); @@ -106,8 +106,8 @@ You are a sharp analytical critic. Find the WEAKEST points in other participants {(string.IsNullOrWhiteSpace(r1OpText) ? "" : $"\n{r1OpText}")} For each response: 1) Weakest argument 2) Logical fallacies 3) Counter-example 4) Quality (1-10) """; - var r2 = await CollectResponsesAsync(members, r2Sys, r2Prompt, temperature, ct); - var r2Op = await ProcessOperatorRequestsAsync(@operator, r2, executionOptions, ct); + var r2 = await CollectResponsesAsync(members, r2Sys, r2Prompt, temperature, ct); + var r2Op = await ProcessOperatorRequestsAsync(@operator, r2, executionOptions, ct); var round2 = CreateRound(2, "Directed Critique", "Models attack weaknesses in each other's positions.", r2, knowledgeInteractions: r2Ki, operatorInteractions: r2Op); builder.AddRound(round2); onRoundCompleted?.Invoke(round2); @@ -136,8 +136,8 @@ You are a sharp analytical critic. Find the WEAKEST points in other participants {(string.IsNullOrWhiteSpace(r1OpText) && string.IsNullOrWhiteSpace(r2OpText) ? "" : $"\n{r1OpText}\n{r2OpText}")} Defend your position: 1) Address each criticism 2) Strengthen weak points 3) Concede where right 4) Final answer """; - var r3 = await CollectResponsesAsync(members, r3Sys, r3Prompt, temperature, ct); - var r3Op = await ProcessOperatorRequestsAsync(@operator, r3, executionOptions, ct); + var r3 = await CollectResponsesAsync(members, r3Sys, r3Prompt, temperature, ct); + var r3Op = await ProcessOperatorRequestsAsync(@operator, r3, executionOptions, ct); var round3 = CreateRound(3, "Defence & Counter-Arguments", "Models defend their positions.", r3, knowledgeInteractions: r3Ki, operatorInteractions: r3Op); builder.AddRound(round3); onRoundCompleted?.Invoke(round3); diff --git a/src/Delibera.Core/Debate/DebateScenario.cs b/src/Delibera.Core/Debate/DebateScenario.cs index cf364da..2262037 100644 --- a/src/Delibera.Core/Debate/DebateScenario.cs +++ b/src/Delibera.Core/Debate/DebateScenario.cs @@ -10,50 +10,50 @@ namespace Delibera.Core.Debate; /// public abstract class DebateScenario : IDebateStrategyWithOptions { - // ────────────────────────────────────────────── - // Operator helpers - // ────────────────────────────────────────────── - - /// - /// Marker participants use to delegate a task to the Operator, e.g.: - /// [[OPERATOR: search the web for the latest .NET 10 release notes]]. - /// - private static readonly Regex OperatorRequestRegex = - new(@"\[\[\s*OPERATOR\s*:\s*(?.+?)\]\]", - RegexOptions.Singleline | - RegexOptions.IgnoreCase | - RegexOptions.Compiled); - - /// - public abstract string StrategyName { get; } - - /// - public abstract string Description { get; } - - /// - public abstract Task ExecuteAsync( - IReadOnlyList members, - PromptContext context, - CouncilMember? chairman, - KnowledgeKeeper? knowledgeKeeper, - Operator? @operator, - int maxRounds = 4, - float temperature = 0.7f, - Action? onRoundCompleted = null, - CancellationToken ct = default); - - /// - public abstract Task ExecuteAsync( - IReadOnlyList members, - PromptContext context, - CouncilMember? chairman, - KnowledgeKeeper? knowledgeKeeper, - Operator? @operator, - DebateExecutionOptions executionOptions, - int maxRounds = 4, - float temperature = 0.7f, - Action? onRoundCompleted = null, - CancellationToken ct = default); + // ────────────────────────────────────────────── + // Operator helpers + // ────────────────────────────────────────────── + + /// + /// Marker participants use to delegate a task to the Operator, e.g.: + /// [[OPERATOR: search the web for the latest .NET 10 release notes]]. + /// + private static readonly Regex OperatorRequestRegex = + new(@"\[\[\s*OPERATOR\s*:\s*(?.+?)\]\]", + RegexOptions.Singleline | + RegexOptions.IgnoreCase | + RegexOptions.Compiled); + + /// + public abstract string StrategyName { get; } + + /// + public abstract string Description { get; } + + /// + public abstract Task ExecuteAsync( + IReadOnlyList members, + PromptContext context, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, + int maxRounds = 4, + float temperature = 0.7f, + Action? onRoundCompleted = null, + CancellationToken ct = default); + + /// + public abstract Task ExecuteAsync( + IReadOnlyList members, + PromptContext context, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, + DebateExecutionOptions executionOptions, + int maxRounds = 4, + float temperature = 0.7f, + Action? onRoundCompleted = null, + CancellationToken ct = default); // ────────────────────────────────────────────── // Shared helpers @@ -99,7 +99,7 @@ protected static async Task> CollectResponsesAsync( responses[key] = response; } - + return responses; } @@ -233,77 +233,83 @@ Only delegate when external information or actions (web search, database lookup, """; } - /// - /// Scans participant responses for Operator request markers, executes each delegated - /// task via the Operator, and returns the recorded interactions. - /// - /// Operator instance (may be null). - /// Participant responses keyed by display name. - /// Cancellation token. - /// Operator interactions produced during this round. - protected static async Task> ProcessOperatorRequestsAsync( - Operator? @operator, - IReadOnlyDictionary responses, - CancellationToken ct = default) - { - return await ProcessOperatorRequestsAsync(@operator, responses, DebateExecutionOptions.Default, ct); - } - - /// - /// Scans participant responses for Operator request markers, executes each delegated - /// task via the Operator (in parallel, bounded by - /// ), and returns the - /// recorded interactions. - /// - protected static async Task> ProcessOperatorRequestsAsync( - Operator? @operator, - IReadOnlyDictionary responses, - DebateExecutionOptions executionOptions, - CancellationToken ct = default) - { - if (@operator is null || responses.Count == 0) return []; - - // Collect every (member, task) pair across all responses first. - var pending = new List<(string Member, string Task)>(); - foreach (var (member, response) in responses) - { - if (string.IsNullOrWhiteSpace(response)) continue; - - foreach (Match match in OperatorRequestRegex.Matches(response)) - { - var task = match.Groups["task"].Value.Trim(); - if (string.IsNullOrWhiteSpace(task)) continue; - pending.Add((member, task)); - } - } - - if (pending.Count == 0) return []; - - var interactions = new List(pending.Count); - var parallelOpts = executionOptions.ToParallelOptions(ct); - - // Parallel.ForEachAsync gives us a concurrent, optionally-bounded execution of the - // delegated Operator tasks. Results are collected in a thread-safe list. - await Parallel.ForEachAsync( - pending, - parallelOpts, - async (item, token) => - { - try - { - var result = await @operator.ExecuteTaskAsync(item.Member, item.Task, token); - var interaction = result.ToInteraction(); - lock (interactions) interactions.Add(interaction); - } - catch (Exception ex) - { - lock (interactions) interactions.Add(new OperatorInteraction( - item.Member, item.Task, $"[Operator error: {ex.Message}]", [], false, DateTime.UtcNow)); - } - }); - - return interactions; - } + /// + /// Scans participant responses for Operator request markers, executes each delegated + /// task via the Operator, and returns the recorded interactions. + /// + /// Operator instance (may be null). + /// Participant responses keyed by display name. + /// Cancellation token. + /// Operator interactions produced during this round. + protected static async Task> ProcessOperatorRequestsAsync( + Operator? @operator, + IReadOnlyDictionary responses, + CancellationToken ct = default) + { + return await ProcessOperatorRequestsAsync(@operator, responses, DebateExecutionOptions.Default, ct); + } + + /// + /// Scans participant responses for Operator request markers, executes each delegated + /// task via the Operator (in parallel, bounded by + /// ), and returns the + /// recorded interactions. + /// + protected static async Task> ProcessOperatorRequestsAsync( + Operator? @operator, + IReadOnlyDictionary responses, + DebateExecutionOptions executionOptions, + CancellationToken ct = default) + { + if (@operator is null || responses.Count == 0) return []; + + // Collect every (member, task) pair across all responses first. + var pending = new List<(string Member, string Task)>(); + foreach (var (member, response) in responses) + { + if (string.IsNullOrWhiteSpace(response)) continue; + + foreach (Match match in OperatorRequestRegex.Matches(response)) + { + var task = match.Groups["task"].Value.Trim(); + if (string.IsNullOrWhiteSpace(task)) continue; + pending.Add((member, task)); + } + } + + if (pending.Count == 0) return []; + + var interactions = new List(pending.Count); + var parallelOpts = executionOptions.ToParallelOptions(ct); + + // Parallel.ForEachAsync gives us a concurrent, optionally-bounded execution of the + // delegated Operator tasks. Results are collected in a thread-safe list. + await Parallel.ForEachAsync( + pending, + parallelOpts, + async (item, token) => + { + try + { + var result = await @operator.ExecuteTaskAsync(item.Member, item.Task, token); + var interaction = result.ToInteraction(); + lock (interactions) + { + interactions.Add(interaction); + } + } + catch (Exception ex) + { + lock (interactions) + { + interactions.Add(new OperatorInteraction( + item.Member, item.Task, $"[Operator error: {ex.Message}]", [], false, DateTime.UtcNow)); + } + } + }); + + return interactions; + } /// /// Formats Operator interactions into a context block that can be injected into the diff --git a/src/Delibera.Core/Debate/StandardDebate.cs b/src/Delibera.Core/Debate/StandardDebate.cs index ea48952..b42a04d 100644 --- a/src/Delibera.Core/Debate/StandardDebate.cs +++ b/src/Delibera.Core/Debate/StandardDebate.cs @@ -24,43 +24,43 @@ public sealed class StandardDebate : DebateScenario /// public override string Description => "4-round debate: Initial → Critique → Improved → Chairman Verdict"; - /// - public override async Task ExecuteAsync( - IReadOnlyList members, - PromptContext context, - CouncilMember? chairman, - KnowledgeKeeper? knowledgeKeeper, - Operator? @operator, - int maxRounds = 4, - float temperature = 0.7f, - Action? onRoundCompleted = null, - CancellationToken ct = default) - { - return await ExecuteAsync(members, context, chairman, knowledgeKeeper, @operator, - DebateExecutionOptions.Default, maxRounds, temperature, onRoundCompleted, ct); - } - - /// - public override async Task ExecuteAsync( - IReadOnlyList members, - PromptContext context, - CouncilMember? chairman, - KnowledgeKeeper? knowledgeKeeper, - Operator? @operator, - DebateExecutionOptions executionOptions, - int maxRounds = 4, - float temperature = 0.7f, - Action? onRoundCompleted = null, - CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(members); - ArgumentNullException.ThrowIfNull(context); - var builder = new DebateResultBuilder(this, members, context, chairman, knowledgeKeeper, @operator); - var fullUserPrompt = context.GetFullUserPrompt(); - - // Operator briefing is appended to participant system prompts so they know what tools exist. - var operatorBriefing = BuildOperatorBriefing(@operator); - var baseSystemPrompt = context.SystemPrompt + operatorBriefing; + /// + public override async Task ExecuteAsync( + IReadOnlyList members, + PromptContext context, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, + int maxRounds = 4, + float temperature = 0.7f, + Action? onRoundCompleted = null, + CancellationToken ct = default) + { + return await ExecuteAsync(members, context, chairman, knowledgeKeeper, @operator, + DebateExecutionOptions.Default, maxRounds, temperature, onRoundCompleted, ct); + } + + /// + public override async Task ExecuteAsync( + IReadOnlyList members, + PromptContext context, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, + DebateExecutionOptions executionOptions, + int maxRounds = 4, + float temperature = 0.7f, + Action? onRoundCompleted = null, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(members); + ArgumentNullException.ThrowIfNull(context); + var builder = new DebateResultBuilder(this, members, context, chairman, knowledgeKeeper, @operator); + var fullUserPrompt = context.GetFullUserPrompt(); + + // Operator briefing is appended to participant system prompts so they know what tools exist. + var operatorBriefing = BuildOperatorBriefing(@operator); + var baseSystemPrompt = context.SystemPrompt + operatorBriefing; // ── Chairman opening ── if (chairman is not null) @@ -82,9 +82,9 @@ public override async Task ExecuteAsync( ? fullUserPrompt : $"{fullUserPrompt}\n\n📚 Knowledge Keeper context:\n{knowledgeContext}"; - var r1Responses = await CollectResponsesAsync(members, baseSystemPrompt, r1Prompt, temperature, ct); - // Operator: fulfil any [[OPERATOR: ...]] requests raised in round 1 (parallel, bounded). - var r1Op = await ProcessOperatorRequestsAsync(@operator, r1Responses, executionOptions, ct); + var r1Responses = await CollectResponsesAsync(members, baseSystemPrompt, r1Prompt, temperature, ct); + // Operator: fulfil any [[OPERATOR: ...]] requests raised in round 1 (parallel, bounded). + var r1Op = await ProcessOperatorRequestsAsync(@operator, r1Responses, executionOptions, ct); var round1 = CreateRound(1, "Initial Responses", "All models provide their initial answers.", r1Responses, r1Prompt, r1Ki, r1Op); builder.AddRound(round1); @@ -120,8 +120,8 @@ Be constructive but thorough. Provide your detailed critique of each response. """; - var r2Responses = await CollectResponsesAsync(members, r2System, r2Prompt, temperature, ct); - var r2Op = await ProcessOperatorRequestsAsync(@operator, r2Responses, executionOptions, ct); + var r2Responses = await CollectResponsesAsync(members, r2System, r2Prompt, temperature, ct); + var r2Op = await ProcessOperatorRequestsAsync(@operator, r2Responses, executionOptions, ct); var round2 = CreateRound(2, "Critique", "Models critically analyse each other's responses.", r2Responses, r2Prompt, r2Ki, r2Op); builder.AddRound(round2); @@ -160,8 +160,8 @@ and synthesise the most comprehensive response possible. Provide your final, improved answer. """; - var r3Responses = await CollectResponsesAsync(members, r3System, r3Prompt, temperature, ct); - var r3Op = await ProcessOperatorRequestsAsync(@operator, r3Responses, executionOptions, ct); + var r3Responses = await CollectResponsesAsync(members, r3System, r3Prompt, temperature, ct); + var r3Op = await ProcessOperatorRequestsAsync(@operator, r3Responses, executionOptions, ct); var round3 = CreateRound(3, "Final Improved Responses", "Models provide refined answers incorporating critiques.", r3Responses, r3Prompt, r3Ki, r3Op); builder.AddRound(round3); diff --git a/src/Delibera.Core/DependencyInjection/CouncilOptions.cs b/src/Delibera.Core/DependencyInjection/CouncilOptions.cs index a5431ae..ad9e5ad 100644 --- a/src/Delibera.Core/DependencyInjection/CouncilOptions.cs +++ b/src/Delibera.Core/DependencyInjection/CouncilOptions.cs @@ -18,34 +18,37 @@ public sealed class CouncilOptions /// Default generation temperature (0.0–2.0). public float Temperature { get; set; } = 0.7f; - /// Default system prompt for all participants. - public string SystemPrompt { get; set; } = "You are a helpful AI assistant participating in a council debate."; - - /// - /// Forces every model response (participants, Chairman, Knowledge Keeper, Operator) - /// to be written in the specified human language, regardless of the language used - /// in the prompt or retrieved RAG context. - /// - /// - /// - /// Set to a language name the model recognises (e.g. "Russian", "English", - /// "Spanish", "中文"). When non-empty, Delibera injects a strict directive into - /// every system and user prompt: "You MUST answer exclusively in {language}. - /// Never use any other language." - /// - /// - /// Leave null or empty to let the model pick a language from context (legacy - /// behaviour). - /// - /// - public string? ResponseLanguage { get; set; } - - /// - /// Optional maximum degree of parallelism for operations that can run concurrently - /// within a debate round (e.g. Operator task delegation, parallel Knowledge Keeper - /// queries). 0 means "unbounded" (default). - /// - public int MaxDegreeOfParallelism { get; set; } + /// Default system prompt for all participants. + public string SystemPrompt { get; set; } = "You are a helpful AI assistant participating in a council debate."; + + /// + /// Forces every model response (participants, Chairman, Knowledge Keeper, Operator) + /// to be written in the specified human language, regardless of the language used + /// in the prompt or retrieved RAG context. + /// + /// + /// + /// Set to a language name the model recognises (e.g. "Russian", "English", + /// "Spanish", "中文"). When non-empty, Delibera injects a strict directive into + /// every system and user prompt: + /// + /// "You MUST answer exclusively in {language}. + /// Never use any other language." + /// + /// + /// + /// Leave null or empty to let the model pick a language from context (legacy + /// behaviour). + /// + /// + public string? ResponseLanguage { get; set; } + + /// + /// Optional maximum degree of parallelism for operations that can run concurrently + /// within a debate round (e.g. Operator task delegation, parallel Knowledge Keeper + /// queries). 0 means "unbounded" (default). + /// + public int MaxDegreeOfParallelism { get; set; } /// Provider configuration options. public ProviderOptions Providers { get; set; } = new(); diff --git a/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs b/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs index 0574c87..1179381 100644 --- a/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs @@ -7,7 +7,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Logging; namespace Delibera.Core.DependencyInjection; @@ -16,103 +15,103 @@ namespace Delibera.Core.DependencyInjection; /// public static class ServiceCollectionExtensions { - /// - /// Registers core Delibera services with default options. - /// - /// The service collection. - /// The service collection for chaining. - /// - /// Registers: - /// - /// (singleton) - /// (singleton) - /// (singleton) - /// (transient) - /// - /// - public static IServiceCollection AddDelibera(this IServiceCollection services) - { - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddTransient(); - - return services; - } - - /// - /// Registers core Delibera services and binds from configuration. - /// - /// The service collection. - /// Configuration root or section containing council settings. - /// Configuration section name (default: "Delibera"). - /// The service collection for chaining. - public static IServiceCollection AddDelibera( - this IServiceCollection services, - IConfiguration configuration, - string sectionName = CouncilOptions.SectionName) - { - services.AddDelibera(); - - var section = configuration.GetSection(sectionName); - if (section.Exists()) services.Configure(section); - - return services; - } - - /// - /// Registers core Delibera services with a custom options configuration delegate. - /// - /// The service collection. - /// Delegate to configure . - /// The service collection for chaining. - public static IServiceCollection AddDelibera( - this IServiceCollection services, - Action configureOptions) - { - ArgumentNullException.ThrowIfNull(configureOptions); - services.AddDelibera(); - services.Configure(configureOptions); - - return services; - } - - /// - /// Registers core Delibera services and wires the framework into the host's - /// . A resolved from the - /// container is automatically decorated with a logger, so any debate started via DI - /// logs to the host's pipeline (console, file, OpenTelemetry, …) in addition to the - /// in-memory collection. - /// - /// The service collection. - /// Configuration root or section. - /// Host logger factory. - /// Configuration section name (default: "Delibera"). - /// The service collection for chaining. - public static IServiceCollection AddDelibera( - this IServiceCollection services, - IConfiguration configuration, - ILoggerFactory loggerFactory, - string sectionName = CouncilOptions.SectionName) - { - ArgumentNullException.ThrowIfNull(loggerFactory); - services.AddDelibera(configuration, sectionName); - services.TryAddSingleton(loggerFactory); - - // Replace the transient builder registration so every resolved ICouncilBuilder - // gets a logger injected automatically. Consumers who build the executor themselves - // can still call WithLogger(...) explicitly to override. - services.Replace(ServiceDescriptor.Transient(sp => - { - var builder = new CouncilBuilder(); - var lf = sp.GetService(); - if (lf is not null) - builder.WithLogger(lf.CreateLogger("Delibera.Core.Council")); - return builder; - })); - - return services; - } + /// + /// Registers core Delibera services with default options. + /// + /// The service collection. + /// The service collection for chaining. + /// + /// Registers: + /// + /// (singleton) + /// (singleton) + /// (singleton) + /// (transient) + /// + /// + public static IServiceCollection AddDelibera(this IServiceCollection services) + { + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddTransient(); + + return services; + } + + /// + /// Registers core Delibera services and binds from configuration. + /// + /// The service collection. + /// Configuration root or section containing council settings. + /// Configuration section name (default: "Delibera"). + /// The service collection for chaining. + public static IServiceCollection AddDelibera( + this IServiceCollection services, + IConfiguration configuration, + string sectionName = CouncilOptions.SectionName) + { + services.AddDelibera(); + + var section = configuration.GetSection(sectionName); + if (section.Exists()) services.Configure(section); + + return services; + } + + /// + /// Registers core Delibera services with a custom options configuration delegate. + /// + /// The service collection. + /// Delegate to configure . + /// The service collection for chaining. + public static IServiceCollection AddDelibera( + this IServiceCollection services, + Action configureOptions) + { + ArgumentNullException.ThrowIfNull(configureOptions); + services.AddDelibera(); + services.Configure(configureOptions); + + return services; + } + + /// + /// Registers core Delibera services and wires the framework into the host's + /// . A resolved from the + /// container is automatically decorated with a logger, so any debate started via DI + /// logs to the host's pipeline (console, file, OpenTelemetry, …) in addition to the + /// in-memory collection. + /// + /// The service collection. + /// Configuration root or section. + /// Host logger factory. + /// Configuration section name (default: "Delibera"). + /// The service collection for chaining. + public static IServiceCollection AddDelibera( + this IServiceCollection services, + IConfiguration configuration, + ILoggerFactory loggerFactory, + string sectionName = CouncilOptions.SectionName) + { + ArgumentNullException.ThrowIfNull(loggerFactory); + services.AddDelibera(configuration, sectionName); + services.TryAddSingleton(loggerFactory); + + // Replace the transient builder registration so every resolved ICouncilBuilder + // gets a logger injected automatically. Consumers who build the executor themselves + // can still call WithLogger(...) explicitly to override. + services.Replace(ServiceDescriptor.Transient(sp => + { + var builder = new CouncilBuilder(); + var lf = sp.GetService(); + if (lf is not null) + builder.WithLogger(lf.CreateLogger("Delibera.Core.Council")); + return builder; + })); + + return services; + } /// /// Registers a Microsoft.Extensions.AI and exposes it as a Delibera diff --git a/src/Delibera.Core/Extensions/MicrosoftAIExtensions.cs b/src/Delibera.Core/Extensions/MicrosoftAIExtensions.cs index 906b577..53869aa 100644 --- a/src/Delibera.Core/Extensions/MicrosoftAIExtensions.cs +++ b/src/Delibera.Core/Extensions/MicrosoftAIExtensions.cs @@ -1,7 +1,6 @@ using System.Runtime.CompilerServices; using Delibera.Core.Providers.LLM; using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; namespace Delibera.Core.Extensions; diff --git a/src/Delibera.Core/Interfaces/ICouncilBuilder.cs b/src/Delibera.Core/Interfaces/ICouncilBuilder.cs index 645d75d..57a0cf9 100644 --- a/src/Delibera.Core/Interfaces/ICouncilBuilder.cs +++ b/src/Delibera.Core/Interfaces/ICouncilBuilder.cs @@ -146,40 +146,40 @@ ICouncilBuilder WithCompression( /// This builder for fluent chaining. ICouncilBuilder WithTemperature(float temperature); - /// Sets the output path for saving the debate result as Markdown. - /// File path for Markdown output. - /// This builder for fluent chaining. - ICouncilBuilder SaveResultTo(string outputPath); - - /// - /// Forces every model response (participants, Chairman, Knowledge Keeper, Operator) - /// to be in the specified language. Pass null or empty to disable language - /// enforcement and let the model pick a language from context. - /// - /// - /// Language name the model recognises (e.g. "Russian", "English", "Spanish"). - /// - /// This builder for fluent chaining. - ICouncilBuilder WithResponseLanguage(string? language); - - /// - /// Sets the maximum degree of parallelism for operations that can run concurrently - /// within a debate round (Operator task delegation, parallel Knowledge Keeper - /// queries). Pass 0 for unbounded parallelism (default). - /// - /// Max concurrent operations per round (0 = unbounded). - /// This builder for fluent chaining. - ICouncilBuilder WithMaxDegreeOfParallelism(int maxDegreeOfParallelism); - - /// - /// Attaches an used by the executor to surface progress - /// (Chairman actions, rounds, compression, errors, …) to a host's logging pipeline. - /// Pass null to disable structured logging (legacy behaviour — only the - /// OnLog event and the collection are populated). - /// - /// Logger instance, or null to clear. - /// This builder for fluent chaining. - ICouncilBuilder WithLogger(ILogger? logger); + /// Sets the output path for saving the debate result as Markdown. + /// File path for Markdown output. + /// This builder for fluent chaining. + ICouncilBuilder SaveResultTo(string outputPath); + + /// + /// Forces every model response (participants, Chairman, Knowledge Keeper, Operator) + /// to be in the specified language. Pass null or empty to disable language + /// enforcement and let the model pick a language from context. + /// + /// + /// Language name the model recognises (e.g. "Russian", "English", "Spanish"). + /// + /// This builder for fluent chaining. + ICouncilBuilder WithResponseLanguage(string? language); + + /// + /// Sets the maximum degree of parallelism for operations that can run concurrently + /// within a debate round (Operator task delegation, parallel Knowledge Keeper + /// queries). Pass 0 for unbounded parallelism (default). + /// + /// Max concurrent operations per round (0 = unbounded). + /// This builder for fluent chaining. + ICouncilBuilder WithMaxDegreeOfParallelism(int maxDegreeOfParallelism); + + /// + /// Attaches an used by the executor to surface progress + /// (Chairman actions, rounds, compression, errors, …) to a host's logging pipeline. + /// Pass null to disable structured logging (legacy behaviour — only the + /// OnLog event and the collection are populated). + /// + /// Logger instance, or null to clear. + /// This builder for fluent chaining. + ICouncilBuilder WithLogger(ILogger? logger); /// /// Validates configuration and builds an . diff --git a/src/Delibera.Core/Interfaces/ICouncilExecutor.cs b/src/Delibera.Core/Interfaces/ICouncilExecutor.cs index 10e0add..09f79b8 100644 --- a/src/Delibera.Core/Interfaces/ICouncilExecutor.cs +++ b/src/Delibera.Core/Interfaces/ICouncilExecutor.cs @@ -1,5 +1,4 @@ using Delibera.Core.Council; -using Delibera.Core.Models; namespace Delibera.Core.Interfaces; @@ -9,31 +8,31 @@ namespace Delibera.Core.Interfaces; /// public interface ICouncilExecutor { - /// Council participants. - IReadOnlyList Members { get; } + /// Council participants. + IReadOnlyList Members { get; } - /// Chairman (may be null). - CouncilMember? Chairman { get; } + /// Chairman (may be null). + CouncilMember? Chairman { get; } - /// Knowledge Keeper (may be null). - KnowledgeKeeper? KnowledgeKeeper { get; } + /// Knowledge Keeper (may be null). + KnowledgeKeeper? KnowledgeKeeper { get; } - /// Operator (may be null). - Operator? Operator { get; } + /// Operator (may be null). + Operator? Operator { get; } - /// Debate strategy. - IDebateStrategy Strategy { get; } + /// Debate strategy. + IDebateStrategy Strategy { get; } - /// Context compressor (may be null if compression is disabled). - IContextCompressor? Compressor { get; } + /// Context compressor (may be null if compression is disabled). + IContextCompressor? Compressor { get; } - /// - /// Optional used by the executor to surface progress - /// (Chairman actions, rounds, compression, errors, …) to a host's logging pipeline. - /// When null, only the event and the - /// collection are populated. - /// - ILogger? Logger { get; } + /// + /// Optional used by the executor to surface progress + /// (Chairman actions, rounds, compression, errors, …) to a host's logging pipeline. + /// When null, only the event and the + /// collection are populated. + /// + ILogger? Logger { get; } /// /// Execution logs collected during the debate. diff --git a/src/Delibera.Core/Interfaces/IDebateStrategy.cs b/src/Delibera.Core/Interfaces/IDebateStrategy.cs index 1ce3c5f..56b8e65 100644 --- a/src/Delibera.Core/Interfaces/IDebateStrategy.cs +++ b/src/Delibera.Core/Interfaces/IDebateStrategy.cs @@ -1,5 +1,4 @@ using Delibera.Core.Council; -using Microsoft.Extensions.Logging; namespace Delibera.Core.Interfaces; @@ -9,63 +8,63 @@ namespace Delibera.Core.Interfaces; /// public interface IDebateStrategy { - /// Unique strategy name. - string StrategyName { get; } + /// Unique strategy name. + string StrategyName { get; } - /// Human-readable strategy description. - string Description { get; } + /// Human-readable strategy description. + string Description { get; } - /// - /// Executes the full debate cycle according to this strategy. - /// - /// Council participants. - /// Prompt context (system / user prompt, knowledge). - /// Chairman for moderation and verdict synthesis (may be null). - /// Knowledge Keeper for RAG queries (may be null). - /// Operator micro-agent for tool/MCP delegation (may be null). - /// Maximum number of rounds. - /// Generation temperature. - /// Callback invoked after each round. - /// Cancellation token. - /// Complete debate result. - Task ExecuteAsync( - IReadOnlyList members, - PromptContext context, - CouncilMember? chairman, - KnowledgeKeeper? knowledgeKeeper, - Operator? @operator, - int maxRounds = 4, - float temperature = 0.7f, - Action? onRoundCompleted = null, - CancellationToken ct = default); + /// + /// Executes the full debate cycle according to this strategy. + /// + /// Council participants. + /// Prompt context (system / user prompt, knowledge). + /// Chairman for moderation and verdict synthesis (may be null). + /// Knowledge Keeper for RAG queries (may be null). + /// Operator micro-agent for tool/MCP delegation (may be null). + /// Maximum number of rounds. + /// Generation temperature. + /// Callback invoked after each round. + /// Cancellation token. + /// Complete debate result. + Task ExecuteAsync( + IReadOnlyList members, + PromptContext context, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, + int maxRounds = 4, + float temperature = 0.7f, + Action? onRoundCompleted = null, + CancellationToken ct = default); - /// - /// Executes the full debate cycle with an extra - /// bundle (response-language directive, parallelism budget, ). - /// - /// - /// The default implementation forwards to the legacy overload, ignoring - /// . Concrete strategies shipped with Delibera - /// (, , - /// ) override this to honour the options. - /// Custom strategies only need to override this overload to participate in - /// language enforcement and parallelism tuning. - /// - Task ExecuteAsync( - IReadOnlyList members, - PromptContext context, - CouncilMember? chairman, - KnowledgeKeeper? knowledgeKeeper, - Operator? @operator, - DebateExecutionOptions executionOptions, - int maxRounds = 4, - float temperature = 0.7f, - Action? onRoundCompleted = null, - CancellationToken ct = default) - { - return ExecuteAsync(members, context, chairman, knowledgeKeeper, @operator, - maxRounds, temperature, onRoundCompleted, ct); - } + /// + /// Executes the full debate cycle with an extra + /// bundle (response-language directive, parallelism budget, ). + /// + /// + /// The default implementation forwards to the legacy overload, ignoring + /// . Concrete strategies shipped with Delibera + /// (, , + /// ) override this to honour the options. + /// Custom strategies only need to override this overload to participate in + /// language enforcement and parallelism tuning. + /// + Task ExecuteAsync( + IReadOnlyList members, + PromptContext context, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, + DebateExecutionOptions executionOptions, + int maxRounds = 4, + float temperature = 0.7f, + Action? onRoundCompleted = null, + CancellationToken ct = default) + { + return ExecuteAsync(members, context, chairman, knowledgeKeeper, @operator, + maxRounds, temperature, onRoundCompleted, ct); + } } /// @@ -76,19 +75,19 @@ Task ExecuteAsync( /// public interface IDebateStrategyWithOptions : IDebateStrategy { - /// - /// Executes the debate with (response language, - /// parallelism budget, logger). Strategies override this to honour the options. - /// - new Task ExecuteAsync( - IReadOnlyList members, - PromptContext context, - CouncilMember? chairman, - KnowledgeKeeper? knowledgeKeeper, - Operator? @operator, - DebateExecutionOptions executionOptions, - int maxRounds = 4, - float temperature = 0.7f, - Action? onRoundCompleted = null, - CancellationToken ct = default); + /// + /// Executes the debate with (response language, + /// parallelism budget, logger). Strategies override this to honour the options. + /// + new Task ExecuteAsync( + IReadOnlyList members, + PromptContext context, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, + DebateExecutionOptions executionOptions, + int maxRounds = 4, + float temperature = 0.7f, + Action? onRoundCompleted = null, + CancellationToken ct = default); } \ No newline at end of file diff --git a/src/Delibera.Core/Models/DebateExecutionOptions.cs b/src/Delibera.Core/Models/DebateExecutionOptions.cs index 764bf6c..a2a8ce7 100644 --- a/src/Delibera.Core/Models/DebateExecutionOptions.cs +++ b/src/Delibera.Core/Models/DebateExecutionOptions.cs @@ -1,5 +1,3 @@ -using Microsoft.Extensions.Logging; - namespace Delibera.Core.Models; /// diff --git a/src/Delibera.Core/Models/ExecutionLog.cs b/src/Delibera.Core/Models/ExecutionLog.cs index b45b1d5..bd1aeb9 100644 --- a/src/Delibera.Core/Models/ExecutionLog.cs +++ b/src/Delibera.Core/Models/ExecutionLog.cs @@ -78,15 +78,15 @@ public static ExecutionLog Error(string source, string message) /// Maps this execution-log level to the equivalent /// used by . /// - public Microsoft.Extensions.Logging.LogLevel ToMicrosoftLogLevel() + public LogLevel ToMicrosoftLogLevel() { return Level switch { - ExecutionLogLevel.Trace => Microsoft.Extensions.Logging.LogLevel.Trace, - ExecutionLogLevel.Info => Microsoft.Extensions.Logging.LogLevel.Information, - ExecutionLogLevel.Warning => Microsoft.Extensions.Logging.LogLevel.Warning, - ExecutionLogLevel.Error => Microsoft.Extensions.Logging.LogLevel.Error, - _ => Microsoft.Extensions.Logging.LogLevel.None + ExecutionLogLevel.Trace => LogLevel.Trace, + ExecutionLogLevel.Info => LogLevel.Information, + ExecutionLogLevel.Warning => LogLevel.Warning, + ExecutionLogLevel.Error => LogLevel.Error, + _ => LogLevel.None }; } From 0255a01be9d284886cb32cc9d943de171e820932 Mon Sep 17 00:00:00 2001 From: Victor Buzin Date: Fri, 26 Jun 2026 15:14:03 +0200 Subject: [PATCH 10/14] Refactor provider availability check for clarity Simplifies control flow by continuing to the next provider if unavailable, reducing nesting and making model listing logic clearer. --- src/Delibera.ConsoleApp/Program.cs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/Delibera.ConsoleApp/Program.cs b/src/Delibera.ConsoleApp/Program.cs index ecd65b4..0b38753 100644 --- a/src/Delibera.ConsoleApp/Program.cs +++ b/src/Delibera.ConsoleApp/Program.cs @@ -150,11 +150,9 @@ private static async Task RunAsync(string[] args, Action onDebateFailed) { var ok = await prov.IsAvailableAsync(); Console.WriteLine($" {name}: {(ok ? "✅ Available" : "❌ Unavailable")}"); - if (ok) - { - var models = await prov.ListModelsAsync(); - Console.WriteLine($" Models: {string.Join(", ", models.Take(10))}"); - } + if (!ok) continue; + var models = await prov.ListModelsAsync(); + Console.WriteLine($" Models: {string.Join(", ", models.Take(10))}"); } catch (Exception ex) { From 95962087d1ef3ec6a221f8e0c6b0de2e18d4c801 Mon Sep 17 00:00:00 2001 From: Victor Buzin Date: Fri, 26 Jun 2026 16:28:08 +0200 Subject: [PATCH 11/14] Refactor DI example, enhance OllamaProvider, update config - Move DI example to `DependencyInjectionExample.cs` and update references. - Add `.editorconfig` to suppress IDE1006 for all-caps acronyms (e.g., LLM). - Enhance `OllamaProvider` with explicit connection modes, static factory methods, and improved retry logic. - Add explicit local/cloud Ollama creation methods to `ProviderFactory`. - Simplify `Dispose` pattern in providers. - Refactor `CouncilMember.Role` property and YandexGPT legacy request serialization. - Use lowercase variable for summarization system prompt. - Remove redundant log storage in `Program.cs` and apply code style improvements. - Update `appsettings.json` to use a cloud-based chairman model. - Simplify `RagProviderFactory` by removing async disposal. --- .editorconfig | 20 ++ ...ample.cs => DependencyInjectionExample.cs} | 2 +- .../Examples/QuickStart.cs | 3 +- src/Delibera.ConsoleApp/Program.cs | 16 +- src/Delibera.ConsoleApp/appsettings.json | 4 +- .../Compression/SummarizationCompressor.cs | 8 +- src/Delibera.Core/Debate/DebateScenario.cs | 24 +-- .../Extensions/MicrosoftAIExtensions.cs | 2 + src/Delibera.Core/Interfaces/ILLMProvider.cs | 2 + .../Interfaces/ILLMProviderFactory.cs | 2 + src/Delibera.Core/Models/CouncilMember.cs | 8 +- .../Providers/LLM/ChatClientLLMProvider.cs | 25 +-- .../LLM/EmbeddingGeneratorProvider.cs | 21 +- .../Providers/LLM/OllamaProvider.cs | 190 ++++++++++++++---- .../Providers/LLM/YandexGptProvider.cs | 61 +++--- .../Providers/Mcp/McpClientAdapter.cs | 2 +- .../Providers/ProviderFactory.cs | 38 +++- .../Providers/RAG/RagProviderFactory.cs | 2 +- 18 files changed, 279 insertions(+), 151 deletions(-) create mode 100644 .editorconfig rename src/Delibera.ConsoleApp/Examples/{DIExample.cs => DependencyInjectionExample.cs} (98%) diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..760aacd --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +root = true + +[*] +indent_style = space +indent_size = 3 +end_of_line = crlf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.{cs,vb}] +# Delibera's public API uses domain acronyms such as "LLM" in their conventional +# all-caps form (ILLMProvider, ChatClientLLMProvider, AsLLMProvider, +# LLMProviderChatClient). The .NET naming analyzers (IDE1006) want these +# lowercased after the first letter (IllmProvider, ChatClientLlmProvider, ...), +# which is a breaking API change for the published NuGet package *and* reads +# worse. There is no built-in capitalisation mode that permits all-caps acronyms +# mixed with PascalCase, so we disable the naming rules that fire on these +# identifiers while keeping the rest of the naming convention enforcement. +dotnet_diagnostic.IDE1006.severity = none \ No newline at end of file diff --git a/src/Delibera.ConsoleApp/Examples/DIExample.cs b/src/Delibera.ConsoleApp/Examples/DependencyInjectionExample.cs similarity index 98% rename from src/Delibera.ConsoleApp/Examples/DIExample.cs rename to src/Delibera.ConsoleApp/Examples/DependencyInjectionExample.cs index 123658b..a966c32 100644 --- a/src/Delibera.ConsoleApp/Examples/DIExample.cs +++ b/src/Delibera.ConsoleApp/Examples/DependencyInjectionExample.cs @@ -10,7 +10,7 @@ namespace Delibera.ConsoleApp.Examples; /// Demonstrates Delibera usage with Dependency Injection. /// Shows how to register services, resolve interfaces, and build a council via DI. /// -public static class DIExample +public static class DependencyInjectionExample { /// /// Runs the DI example — builds a service provider, resolves council services, diff --git a/src/Delibera.ConsoleApp/Examples/QuickStart.cs b/src/Delibera.ConsoleApp/Examples/QuickStart.cs index c469961..fe724f0 100644 --- a/src/Delibera.ConsoleApp/Examples/QuickStart.cs +++ b/src/Delibera.ConsoleApp/Examples/QuickStart.cs @@ -1,5 +1,4 @@ using Delibera.Core.Council; -using Delibera.Core.Knowledge; using Delibera.Core.Providers; namespace Delibera.ConsoleApp.Examples; @@ -16,7 +15,7 @@ public static async Task RunAsync() var ollama = factory.CreateOllama("https://api.ollama.com", "YOUR_API_KEY"); // 2. Load knowledge (optional) - var kb = new MarkdownKnowledgeBase(); + // var kb = new MarkdownKnowledgeBase(); // await kb.LoadAsync("./knowledge/context.md"); // 3. Build council via fluent API diff --git a/src/Delibera.ConsoleApp/Program.cs b/src/Delibera.ConsoleApp/Program.cs index 0b38753..96ca06a 100644 --- a/src/Delibera.ConsoleApp/Program.cs +++ b/src/Delibera.ConsoleApp/Program.cs @@ -54,7 +54,7 @@ private static async Task RunAsync(string[] args, Action onDebateFailed) // ═══════════════════════════════════════════════ if (args.Contains("--di")) { - await DIExample.RunAsync(); + await DependencyInjectionExample.RunAsync(); return; } @@ -426,13 +426,7 @@ private static async Task RunAsync(string[] args, Action onDebateFailed) Console.WriteLine(new string('═', 60)); // Stream every ExecutionLog entry live so the user can watch progress in real time. - // Stored separately so we can also dump the full transcript at the end. - var liveLogs = new List(); - executor.OnLog += entry => - { - liveLogs.Add(entry); - WriteLogEntry(entry); - }; + executor.OnLog += entry => WriteLogEntry(entry); // Surface non-fatal internal errors (e.g. failed MCP tool call) without aborting the debate. executor.OnError += (ex, context) => { WriteErrorEntry(ex, context); }; @@ -609,8 +603,8 @@ private static void WriteErrorEntry(Exception ex, string context) var firstFrame = ex.StackTrace .Split('\n', StringSplitOptions.RemoveEmptyEntries) .FirstOrDefault(); - if (firstLineIsMeaningful(firstFrame)) - Console.WriteLine($" ┊ at {firstFrame.Trim()}"); + if (firstFrame is { Length: > 0 } line && FirstLineIsMeaningful(line)) + Console.WriteLine($" ┊ at {line.Trim()}"); } Console.ForegroundColor = prev; @@ -676,7 +670,7 @@ private static void WaitForKeyOnExit(string prompt, bool isError) : 0; } - private static bool firstLineIsMeaningful(string? frame) + private static bool FirstLineIsMeaningful(string? frame) { if (string.IsNullOrWhiteSpace(frame)) return false; diff --git a/src/Delibera.ConsoleApp/appsettings.json b/src/Delibera.ConsoleApp/appsettings.json index 9f0e1a7..7ff7085 100644 --- a/src/Delibera.ConsoleApp/appsettings.json +++ b/src/Delibera.ConsoleApp/appsettings.json @@ -68,8 +68,8 @@ }, "Chairman": { - "Model": "qwen2.5:7b", - "Provider": "OllamaLocal", + "Model": "gemma4:cloud", + "Provider": "OllamaCloud", "Type": "Standard", "EnableClarifyingQuestions": true, "MaxDebateRounds": 5 diff --git a/src/Delibera.Core/Compression/SummarizationCompressor.cs b/src/Delibera.Core/Compression/SummarizationCompressor.cs index cd8e4fe..df92423 100644 --- a/src/Delibera.Core/Compression/SummarizationCompressor.cs +++ b/src/Delibera.Core/Compression/SummarizationCompressor.cs @@ -38,7 +38,7 @@ public async Task CompressAsync(string text, CompressionOptio var targetTokens = options.MaxOutputTokens ?? (int)(originalTokens * options.TargetRatio); - const string SystemPrompt = """ + const string systemPrompt = """ You are a precision text compressor. Your task is to compress the given text while preserving ALL key facts, arguments, data points, and conclusions. @@ -64,7 +64,7 @@ Preserve all key information. """; var summary = await _llmProvider.ChatAsync( - _modelName, SystemPrompt, userPrompt, + _modelName, systemPrompt, userPrompt, options.SummarizationTemperature, ct); var compressedTokens = counter.EstimateTokens(summary); @@ -85,7 +85,7 @@ public async Task CompressBatchAsync(IReadOnlyList te var originalTokens = counter.EstimateTokens(merged); var targetTokens = options.MaxOutputTokens ?? (int)(originalTokens * options.TargetRatio); - const string SystemPrompt = """ + const string systemPrompt = """ You are a precision text compressor. You are given multiple text sections separated by '---'. Merge and compress them into a single coherent summary. @@ -106,7 +106,7 @@ 5. Output ONLY the compressed text """; var summary = await _llmProvider.ChatAsync( - _modelName, SystemPrompt, userPrompt, + _modelName, systemPrompt, userPrompt, options.SummarizationTemperature, ct); var compressedTokens = counter.EstimateTokens(summary); diff --git a/src/Delibera.Core/Debate/DebateScenario.cs b/src/Delibera.Core/Debate/DebateScenario.cs index 2262037..2150172 100644 --- a/src/Delibera.Core/Debate/DebateScenario.cs +++ b/src/Delibera.Core/Debate/DebateScenario.cs @@ -42,18 +42,18 @@ public abstract Task ExecuteAsync( Action? onRoundCompleted = null, CancellationToken ct = default); - /// - public abstract Task ExecuteAsync( - IReadOnlyList members, - PromptContext context, - CouncilMember? chairman, - KnowledgeKeeper? knowledgeKeeper, - Operator? @operator, - DebateExecutionOptions executionOptions, - int maxRounds = 4, - float temperature = 0.7f, - Action? onRoundCompleted = null, - CancellationToken ct = default); + /// + public abstract Task ExecuteAsync( + IReadOnlyList members, + PromptContext context, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, + DebateExecutionOptions executionOptions, + int maxRounds = 4, + float temperature = 0.7f, + Action? onRoundCompleted = null, + CancellationToken ct = default); // ────────────────────────────────────────────── // Shared helpers diff --git a/src/Delibera.Core/Extensions/MicrosoftAIExtensions.cs b/src/Delibera.Core/Extensions/MicrosoftAIExtensions.cs index 53869aa..810194f 100644 --- a/src/Delibera.Core/Extensions/MicrosoftAIExtensions.cs +++ b/src/Delibera.Core/Extensions/MicrosoftAIExtensions.cs @@ -2,6 +2,8 @@ using Delibera.Core.Providers.LLM; using Microsoft.Extensions.AI; +#pragma warning disable IDE1006 // 'LLM' acronym kept all-caps by convention; renaming is a breaking API change + namespace Delibera.Core.Extensions; /// diff --git a/src/Delibera.Core/Interfaces/ILLMProvider.cs b/src/Delibera.Core/Interfaces/ILLMProvider.cs index d58e65c..f3d501e 100644 --- a/src/Delibera.Core/Interfaces/ILLMProvider.cs +++ b/src/Delibera.Core/Interfaces/ILLMProvider.cs @@ -1,5 +1,7 @@ using System.Runtime.CompilerServices; +#pragma warning disable IDE1006 // 'LLM' acronym kept all-caps by convention; renaming is a breaking API change + namespace Delibera.Core.Interfaces; /// diff --git a/src/Delibera.Core/Interfaces/ILLMProviderFactory.cs b/src/Delibera.Core/Interfaces/ILLMProviderFactory.cs index 012a2e8..c4b2fab 100644 --- a/src/Delibera.Core/Interfaces/ILLMProviderFactory.cs +++ b/src/Delibera.Core/Interfaces/ILLMProviderFactory.cs @@ -1,5 +1,7 @@ using Microsoft.Extensions.Configuration; +#pragma warning disable IDE1006 // 'LLM' acronym kept all-caps by convention; renaming is a breaking API change + namespace Delibera.Core.Interfaces; /// diff --git a/src/Delibera.Core/Models/CouncilMember.cs b/src/Delibera.Core/Models/CouncilMember.cs index 297cc63..59626b9 100644 --- a/src/Delibera.Core/Models/CouncilMember.cs +++ b/src/Delibera.Core/Models/CouncilMember.cs @@ -7,8 +7,6 @@ public sealed class CouncilMember(string modelName, ILLMProvider provider, strin { private readonly string? _personaPrompt = personaPrompt; - private string _role = role ?? "Expert"; - /// Unique participant identifier. public string Id { get; } = $"{provider.ProviderName}:{modelName}:{Guid.NewGuid():N}".ToLowerInvariant(); @@ -22,11 +20,7 @@ public sealed class CouncilMember(string modelName, ILLMProvider provider, strin public ILLMProvider Provider { get; } = provider ?? throw new ArgumentNullException(nameof(provider)); /// Role in the debate (Expert, Critic, Chairman, etc.). - public string Role - { - get => field ?? "Expert"; - set => field = value ?? "Expert"; - } + public string Role { get; set; } = role ?? "Expert"; /// Optional persona system-prompt that personalises the model's behaviour. public string? PersonaPrompt { get; set; } diff --git a/src/Delibera.Core/Providers/LLM/ChatClientLLMProvider.cs b/src/Delibera.Core/Providers/LLM/ChatClientLLMProvider.cs index 6287fd8..0c8276b 100644 --- a/src/Delibera.Core/Providers/LLM/ChatClientLLMProvider.cs +++ b/src/Delibera.Core/Providers/LLM/ChatClientLLMProvider.cs @@ -1,6 +1,8 @@ using System.Runtime.CompilerServices; using Microsoft.Extensions.AI; +#pragma warning disable IDE1006 // 'LLM' acronym kept all-caps by convention; renaming is a breaking API change + namespace Delibera.Core.Providers.LLM; /// @@ -44,10 +46,10 @@ public ChatClientLLMProvider(IChatClient chatClient, string? providerName = null _ownsClient = ownsClient; var metadata = chatClient.GetService(typeof(ChatClientMetadata)) as ChatClientMetadata; - ProviderName = providerName ?? - (string.IsNullOrWhiteSpace(metadata?.ProviderName) - ? "ChatClient" - : metadata!.ProviderName!); + var metadataName = !string.IsNullOrWhiteSpace(metadata?.ProviderName) + ? metadata.ProviderName + : "ChatClient"; + ProviderName = providerName ?? metadataName; DefaultModelId = metadata?.DefaultModelId; } @@ -97,7 +99,7 @@ public async Task ChatAsync( try { var response = await ChatClient.GetResponseAsync(messages, options, ct); - var text = response.Text?.Trim() ?? string.Empty; + var text = response.Text.Trim(); if (string.IsNullOrWhiteSpace(text)) throw new InvalidOperationException($"Empty response from model '{model}' ({ProviderName})."); return text; @@ -135,13 +137,12 @@ public async IAsyncEnumerable ChatStreamAsync( } /// - public void Dispose() - { - if (_disposed) return; - _disposed = true; - if (_ownsClient) ChatClient.Dispose(); - GC.SuppressFinalize(this); - } + public void Dispose() + { + if (_disposed) return; + _disposed = true; + if (_ownsClient) ChatClient.Dispose(); + } private static List BuildMessages(string systemPrompt, string userPrompt) { diff --git a/src/Delibera.Core/Providers/LLM/EmbeddingGeneratorProvider.cs b/src/Delibera.Core/Providers/LLM/EmbeddingGeneratorProvider.cs index dba685f..4ae3174 100644 --- a/src/Delibera.Core/Providers/LLM/EmbeddingGeneratorProvider.cs +++ b/src/Delibera.Core/Providers/LLM/EmbeddingGeneratorProvider.cs @@ -42,10 +42,10 @@ public EmbeddingGeneratorProvider( _ownsGenerator = ownsGenerator; var metadata = generator.GetService(typeof(EmbeddingGeneratorMetadata)) as EmbeddingGeneratorMetadata; - EmbeddingModelName = modelName ?? - (string.IsNullOrWhiteSpace(metadata?.DefaultModelId) - ? "embedding" - : metadata!.DefaultModelId!); + var metadataName = !string.IsNullOrWhiteSpace(metadata?.DefaultModelId) + ? metadata.DefaultModelId + : "embedding"; + EmbeddingModelName = modelName ?? metadataName; _cachedVectorSize = vectorSize ?? metadata?.DefaultModelDimensions; } @@ -53,13 +53,12 @@ public EmbeddingGeneratorProvider( public IEmbeddingGenerator> Generator { get; } /// - public void Dispose() - { - if (_disposed) return; - _disposed = true; - if (_ownsGenerator) Generator.Dispose(); - GC.SuppressFinalize(this); - } + public void Dispose() + { + if (_disposed) return; + _disposed = true; + if (_ownsGenerator) Generator.Dispose(); + } /// public string EmbeddingModelName { get; } diff --git a/src/Delibera.Core/Providers/LLM/OllamaProvider.cs b/src/Delibera.Core/Providers/LLM/OllamaProvider.cs index 22a2d55..e82d401 100644 --- a/src/Delibera.Core/Providers/LLM/OllamaProvider.cs +++ b/src/Delibera.Core/Providers/LLM/OllamaProvider.cs @@ -6,42 +6,87 @@ namespace Delibera.Core.Providers.LLM; +/// +/// Connection mode for an . +/// +public enum OllamaConnectionMode +{ + /// Talking to a local Ollama server (no API key, no Cloudflare, no 524). + Local, + + /// Talking to Ollama Cloud (API key required, behind Cloudflare, may return 524/429). + Cloud +} + /// /// Ollama LLM provider backed by OllamaSharp. -/// Works with both Ollama Cloud (API key) and a local Ollama server. +/// Works with both a local Ollama server and Ollama Cloud. /// +/// +/// Use for a local server (e.g. http://localhost:11434) and +/// for Ollama Cloud (e.g. https://api.ollama.com) with an API key. +/// The provider applies mode-appropriate retry/backoff: cloud calls retry transient +/// 524/429/5xx responses (Cloudflare origin timeouts) and local calls retry connection +/// failures. Both honour the user's . +/// public sealed class OllamaProvider : ILLMProvider { + private const int DefaultMaxAttempts = 3; + private static readonly TimeSpan DefaultCloudTimeout = TimeSpan.FromMinutes(5); + private static readonly TimeSpan DefaultLocalTimeout = TimeSpan.FromMinutes(10); + private bool _disposed; /// - /// Creates an Ollama provider. + /// Creates an Ollama provider. The mode is inferred from : + /// a non-empty key selects , otherwise + /// . Prefer / + /// for explicit control. /// - /// Ollama endpoint URL (e.g., "https://api.ollama.com" or "http://localhost:11434"). + /// Ollama endpoint URL (e.g. "https://api.ollama.com" or "http://localhost:11434"). /// API key for Ollama Cloud (empty for local server). - public OllamaProvider(string endpoint, string apiKey = "") + /// Optional HTTP timeout. Defaults to 5 min for cloud, 10 min for local. + public OllamaProvider(string endpoint, string apiKey = "", TimeSpan? timeout = null) { ArgumentException.ThrowIfNullOrWhiteSpace(endpoint); var uri = new Uri(endpoint.TrimEnd('/')); + Mode = string.IsNullOrWhiteSpace(apiKey) + ? OllamaConnectionMode.Local + : OllamaConnectionMode.Cloud; - if (!string.IsNullOrWhiteSpace(apiKey)) - { - var httpClient = new HttpClient { BaseAddress = uri, Timeout = TimeSpan.FromMinutes(5) }; - httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}"); - Client = new OllamaApiClient(httpClient); - } - else - { - Client = new OllamaApiClient(uri); - } + var effectiveTimeout = timeout ?? + (Mode == OllamaConnectionMode.Cloud + ? DefaultCloudTimeout + : DefaultLocalTimeout); + + var httpClient = new HttpClient { BaseAddress = uri, Timeout = effectiveTimeout }; + if (Mode == OllamaConnectionMode.Cloud) + httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey.Trim()}"); + + Client = new OllamaApiClient(httpClient); + } + + private OllamaProvider(Uri uri, string apiKey, OllamaConnectionMode mode, TimeSpan timeout) + { + Mode = mode; + var httpClient = new HttpClient { BaseAddress = uri, Timeout = timeout }; + if (mode == OllamaConnectionMode.Cloud) + httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey.Trim()}"); + + Client = new OllamaApiClient(httpClient); } + /// The connection mode this provider was configured with. + public OllamaConnectionMode Mode { get; } + /// Provides access to the underlying OllamaSharp client (used by ). internal OllamaApiClient Client { get; } /// - public string ProviderName => "Ollama"; + public string ProviderName => Mode == OllamaConnectionMode.Cloud + ? "OllamaCloud" + : "Ollama"; /// public async Task IsAvailableAsync(CancellationToken ct = default) @@ -91,42 +136,100 @@ public async Task ChatAsync( { Model = model, Messages = messages, - Options = new RequestOptions { Temperature = temperature }, - Stream = false + Options = new RequestOptions { Temperature = temperature } }; - try + var maxAttempts = Mode == OllamaConnectionMode.Cloud + ? DefaultMaxAttempts + : 2; + var delay = TimeSpan.FromSeconds(2); + + for (var attempt = 1;; attempt++) { var sb = new StringBuilder(); - await foreach (var chunk in Client.ChatAsync(request, ct)) - if (chunk?.Message?.Content is not null) - sb.Append(chunk.Message.Content); - - var response = sb.ToString().Trim(); - if (string.IsNullOrWhiteSpace(response)) - throw new InvalidOperationException($"Empty response from model '{model}'."); - return response; - } - catch (HttpRequestException ex) - { - throw new InvalidOperationException($"HTTP error talking to Ollama (model: {model}): {ex.Message}", ex); - } - catch (TaskCanceledException) when (ct.IsCancellationRequested) - { - throw; - } - catch (TaskCanceledException ex) - { - throw new TimeoutException($"Request to Ollama model '{model}' timed out.", ex); + try + { + await foreach (var chunk in Client.ChatAsync(request, ct)) + { + if (chunk is not { Message.Content: { } content }) + continue; + sb.Append(content); + } + + var response = sb.ToString().Trim(); + if (string.IsNullOrWhiteSpace(response)) + throw new InvalidOperationException($"Empty response from model '{model}'."); + return response; + } + catch (HttpRequestException ex) + { + if (ct.IsCancellationRequested) throw; + if (attempt < maxAttempts && IsTransientHttp(ex, Mode)) + { + await Task.Delay(delay, ct); + delay = TimeSpan.FromSeconds(Math.Min(delay.TotalSeconds * 2, 30)); + continue; + } + + throw new InvalidOperationException($"HTTP error talking to Ollama (model: {model}): {ex.Message}", ex); + } + catch (TaskCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (TaskCanceledException ex) + { + if (attempt < maxAttempts) + { + await Task.Delay(delay, ct); + delay = TimeSpan.FromSeconds(Math.Min(delay.TotalSeconds * 2, 30)); + continue; + } + + throw new TimeoutException($"Request to Ollama model '{model}' timed out.", ex); + } } } /// - public void Dispose() + public void Dispose() + { + if (_disposed) return; + _disposed = true; + } + + /// 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(new Uri(endpoint.TrimEnd('/')), "", OllamaConnectionMode.Local, + timeout ?? DefaultLocalTimeout); + } + + /// Creates a provider for Ollama Cloud (e.g. https://api.ollama.com). + public static OllamaProvider ForCloud(string endpoint, string apiKey, TimeSpan? timeout = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(endpoint); + ArgumentException.ThrowIfNullOrWhiteSpace(apiKey); + return new OllamaProvider(new Uri(endpoint.TrimEnd('/')), apiKey, OllamaConnectionMode.Cloud, + timeout ?? DefaultCloudTimeout); + } + + /// + /// Determines whether an represents a transient failure + /// worth retrying. For this includes 524 origin + /// timeouts, 429 rate-limits and any 5xx. For only + /// connection-level failures (no status code) are retried — a local Ollama rarely returns + /// 5xx, and when it does it's usually a model-load error that won't fix itself. + /// + private static bool IsTransientHttp(HttpRequestException ex, OllamaConnectionMode mode) { - if (_disposed) return; - _disposed = true; - GC.SuppressFinalize(this); + var code = ex.StatusCode; + if (code is null) return true; + if (mode == OllamaConnectionMode.Local) return false; + + var i = (int)code; + return i == 429 || i == 524 || (i >= 500 && i < 600); } /// @@ -135,8 +238,7 @@ public void Dispose() /// /// /// natively implements , so this lets - /// the - /// Ollama provider plug into the standard Microsoft.Extensions.AI middleware pipeline + /// the Ollama provider plug into the standard Microsoft.Extensions.AI middleware pipeline /// (function invocation, caching, telemetry). /// public IChatClient AsChatClient() diff --git a/src/Delibera.Core/Providers/LLM/YandexGptProvider.cs b/src/Delibera.Core/Providers/LLM/YandexGptProvider.cs index b10d289..cbf720c 100644 --- a/src/Delibera.Core/Providers/LLM/YandexGptProvider.cs +++ b/src/Delibera.Core/Providers/LLM/YandexGptProvider.cs @@ -30,7 +30,6 @@ public sealed class YandexGptProvider : ILLMProvider private readonly HttpClient _http; private readonly string _legacyEndpoint; private readonly int _maxOutputTokens; - private readonly float _temperature; private bool _disposed; /// @@ -56,8 +55,8 @@ public YandexGptProvider( _folderId = folderId ?? string.Empty; _endpoint = endpoint.TrimEnd('/'); _legacyEndpoint = legacyEndpoint.TrimEnd('/'); - _temperature = temperature; _maxOutputTokens = maxOutputTokens; + _ = temperature; // accepted for API compatibility; per-call temperature is passed to ChatAsync _http = new HttpClient { Timeout = TimeSpan.FromMinutes(5) }; @@ -143,13 +142,21 @@ public async Task ChatAsync( } else { - requestBody = new YandexCompletionRequest( - model, - new CompletionOptions(false, temperature, _maxOutputTokens), - [ - new Message("system", systemPrompt), - new Message("user", userPrompt) - ]); + requestBody = new Dictionary + { + ["modelUri"] = model, + ["completionOptions"] = new Dictionary + { + ["stream"] = false, + ["temperature"] = temperature, + ["maxTokens"] = _maxOutputTokens + }, + ["messages"] = new[] + { + new Dictionary { ["role"] = "system", ["text"] = systemPrompt }, + new Dictionary { ["role"] = "user", ["text"] = userPrompt } + } + }; endpoint = _legacyEndpoint; headers = BuildLegacyAuthHeaders(); } @@ -201,13 +208,12 @@ public async Task ChatAsync( } /// - public void Dispose() - { - if (_disposed) return; - _disposed = true; - _http.Dispose(); - GC.SuppressFinalize(this); - } + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _http.Dispose(); + } private void AddAuthHeaders(HttpRequestHeaders headers) { @@ -260,25 +266,4 @@ private static string ExtractLegacyShape(JsonElement root) "{}"; } - private record YandexCompletionRequest( - [property: JsonPropertyName("modelUri")] - string ModelUri, - [property: JsonPropertyName("completionOptions")] - CompletionOptions CompletionOptions, - [property: JsonPropertyName("messages")] - Message[] Messages - ); - - private record CompletionOptions( - [property: JsonPropertyName("stream")] bool Stream, - [property: JsonPropertyName("temperature")] - float Temperature, - [property: JsonPropertyName("maxTokens")] - int MaxTokens - ); - - private record Message( - [property: JsonPropertyName("role")] string Role, - [property: JsonPropertyName("text")] string Text - ); -} \ No newline at end of file +} diff --git a/src/Delibera.Core/Providers/Mcp/McpClientAdapter.cs b/src/Delibera.Core/Providers/Mcp/McpClientAdapter.cs index 9190dce..172bb75 100644 --- a/src/Delibera.Core/Providers/Mcp/McpClientAdapter.cs +++ b/src/Delibera.Core/Providers/Mcp/McpClientAdapter.cs @@ -47,7 +47,7 @@ public async Task> ListToolsAsync(CancellationToken .Select(t => new OperatorTool( ServerName, t.Name, - t.Description ?? string.Empty, + t.Description, SchemaToJson(t))) .ToList() .AsReadOnly(); diff --git a/src/Delibera.Core/Providers/ProviderFactory.cs b/src/Delibera.Core/Providers/ProviderFactory.cs index 4a46425..091da22 100644 --- a/src/Delibera.Core/Providers/ProviderFactory.cs +++ b/src/Delibera.Core/Providers/ProviderFactory.cs @@ -31,13 +31,13 @@ public void Dispose() } /// All currently cached provider instances. - public IReadOnlyDictionary GetAllInstances() + protected IReadOnlyDictionary GetAllInstances() { return _instances; } /// Returns a cached instance by name, or null. - public TInstance? GetInstance(string name) + protected TInstance? GetInstance(string name) { return _instances.TryGetValue(name, out var p) ? p @@ -45,7 +45,7 @@ public IReadOnlyDictionary GetAllInstances() } /// Registers a builder for a new provider type (e.g., "OpenAI", "YandexGPT"). - public CachingFactory RegisterBuilder(string providerType, TBuilder builder) + protected CachingFactory RegisterBuilder(string providerType, TBuilder builder) { ArgumentNullException.ThrowIfNull(builder); _builders[providerType] = builder; @@ -138,10 +138,15 @@ public IReadOnlyDictionary GetAllProviders() return GetAllInstances(); } - /// Creates an Ollama provider with direct parameters. + /// + /// 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}"; + var key = $"ollama:{endpoint}:{(string.IsNullOrWhiteSpace(apiKey) ? "local" : "cloud")}"; if (GetInstance(key) is OllamaProvider existing) return existing; var provider = new OllamaProvider(endpoint, apiKey); @@ -149,6 +154,29 @@ public OllamaProvider CreateOllama(string endpoint, string apiKey = "") 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 (or returns a cached) from any Microsoft.Extensions.AI /// . diff --git a/src/Delibera.Core/Providers/RAG/RagProviderFactory.cs b/src/Delibera.Core/Providers/RAG/RagProviderFactory.cs index cb5e060..94ac2e5 100644 --- a/src/Delibera.Core/Providers/RAG/RagProviderFactory.cs +++ b/src/Delibera.Core/Providers/RAG/RagProviderFactory.cs @@ -6,7 +6,7 @@ namespace Delibera.Core.Providers.RAG; /// Factory for creating instances from configuration. /// Register custom builders to support additional vector databases. /// -public sealed class RagProviderFactory : CachingFactory, IRagProvider>, IRagProviderFactory, IAsyncDisposable +public sealed class RagProviderFactory : CachingFactory, IRagProvider>, IRagProviderFactory { /// /// Creates a new factory with the built-in Qdrant and PgVector builders registered. From eedd69a44e2948eeada10783ae24a2b819ce2b1f Mon Sep 17 00:00:00 2001 From: Victor Buzin Date: Fri, 26 Jun 2026 16:37:35 +0200 Subject: [PATCH 12/14] Improve LRU cache, deduplication, token counting, Operator - Replaced CompressionCache LRU with lock-free dict + linked list, batch eviction, and lock for LRU updates - DeduplicationCompressor: switched fallback to Jaccard word overlap, batch embedding deduplication, added WordSet struct - TokenCounter: added LRU memoization for short string estimates - Operator: cached tool catalog, parallel tool calls, static planner prompt, shared JSON options, and refactoring for clarity and performance --- .../Compression/CompressionCache.cs | 154 ++++++++++++++++-- .../Compression/DeduplicationCompressor.cs | 112 +++++++++---- src/Delibera.Core/Compression/TokenCounter.cs | 39 ++++- src/Delibera.Core/Council/Operator.cs | 84 +++++++--- 4 files changed, 310 insertions(+), 79 deletions(-) diff --git a/src/Delibera.Core/Compression/CompressionCache.cs b/src/Delibera.Core/Compression/CompressionCache.cs index 52e112b..f855d52 100644 --- a/src/Delibera.Core/Compression/CompressionCache.cs +++ b/src/Delibera.Core/Compression/CompressionCache.cs @@ -17,11 +17,22 @@ namespace Delibera.Core.Compression; /// The cache has a configurable maximum size and uses LRU-style eviction /// when the limit is reached. /// +/// +/// Internally, entries are stored in a +/// for lock-free reads and a linked list for fast LRU ordering. Eviction scans +/// only when the capacity is exceeded and removes entries in batches of 16 to +/// reduce contention compared to the previous OrderBy implementation. +/// /// public sealed class CompressionCache(int maxEntries = 256) { - private readonly ConcurrentDictionary _cache = new(); + // Keep one slot free so we can add first, then evict, avoiding a write lock on reads. private readonly int _maxEntries = Math.Max(1, maxEntries); + private readonly int _evictionTarget = Math.Max(1, (int)Math.Ceiling(Math.Max(1, maxEntries) * 0.0625)); // ~6.25% each time + private readonly ConcurrentDictionary _cache = new(); + private readonly ReaderWriterLockSlim _lruLock = new(); + private LruNode? _head; // most recently used + private LruNode? _tail; // least recently used private long _hitCount; private long _missCount; @@ -58,11 +69,11 @@ public double HitRate public bool TryGet(string text, string strategyName, out CompressedContext? result) { var key = ComputeKey(text, strategyName); - if (_cache.TryGetValue(key, out var entry)) + if (_cache.TryGetValue(key, out var node)) { - entry.LastAccessed = DateTime.UtcNow; + Touch(node); Interlocked.Increment(ref _hitCount); - result = entry.Context; + result = node.Context; return true; } @@ -81,23 +92,54 @@ public void Set(string text, string strategyName, CompressedContext context) { var key = ComputeKey(text, strategyName); - // Evict oldest entries if at capacity - while (_cache.Count >= _maxEntries) + _lruLock.EnterUpgradeableReadLock(); + try { - var oldest = _cache.OrderBy(kv => kv.Value.LastAccessed).FirstOrDefault(); - if (oldest.Key is not null) - _cache.TryRemove(oldest.Key, out _); - else - break; - } + if (_cache.TryGetValue(key, out var existing)) + { + existing.Context = context; + Touch(existing); + return; + } + + // Add first, then evict if over capacity. This keeps reads lock-free. + var node = new LruNode(key, context); + _cache.TryAdd(key, node); + + _lruLock.EnterWriteLock(); + try + { + AddToHead(node); - _cache[key] = new CacheEntry { Context = context, LastAccessed = DateTime.UtcNow }; + if (_cache.Count > _maxEntries) + EvictOldest(_evictionTarget); + } + finally + { + _lruLock.ExitWriteLock(); + } + } + finally + { + _lruLock.ExitUpgradeableReadLock(); + } } /// Clears all cached entries and resets counters. public void Clear() { - _cache.Clear(); + _lruLock.EnterWriteLock(); + try + { + _cache.Clear(); + _head = null; + _tail = null; + } + finally + { + _lruLock.ExitWriteLock(); + } + Interlocked.Exchange(ref _hitCount, 0); Interlocked.Exchange(ref _missCount, 0); } @@ -143,9 +185,85 @@ private static string ComputeKey(string text, string strategyName) } } - private sealed class CacheEntry + private void Touch(LruNode node) + { + // If already at the head, nothing to do. + if (_head == node) + return; + + _lruLock.EnterWriteLock(); + try + { + if (_head == node || node.ListVersion != Volatile.Read(ref _listVersion)) + return; // node has been evicted since read + + RemoveNode(node); + AddToHead(node); + } + finally + { + _lruLock.ExitWriteLock(); + } + } + + private long _listVersion; + + private void AddToHead(LruNode node) + { + node.Next = _head; + node.Previous = null; + if (_head is not null) + _head.Previous = node; + + _head = node; + _tail ??= node; + node.ListVersion = Interlocked.Increment(ref _listVersion); + } + + private void RemoveNode(LruNode node) + { + if (node.Previous is not null) + node.Previous.Next = node.Next; + else + _head = node.Next; + + if (node.Next is not null) + node.Next.Previous = node.Previous; + else + _tail = node.Previous; + + node.Next = null; + node.Previous = null; + } + + private void EvictOldest(int count) + { + var removed = 0; + while (_tail is not null && removed < count) + { + var key = _tail.Key; + var previous = _tail.Previous; + + RemoveNode(_tail); + _cache.TryRemove(key, out _); + + _tail = previous; + removed++; + } + } + + private sealed class LruNode { - public required CompressedContext Context { get; init; } - public DateTime LastAccessed { get; set; } + public LruNode(string key, CompressedContext context) + { + Key = key; + Context = context; + } + + public string Key { get; } + public CompressedContext Context { get; set; } + public long ListVersion { get; set; } + public LruNode? Next { get; set; } + public LruNode? Previous { get; set; } } -} \ No newline at end of file +} diff --git a/src/Delibera.Core/Compression/DeduplicationCompressor.cs b/src/Delibera.Core/Compression/DeduplicationCompressor.cs index bf56bb0..956f89e 100644 --- a/src/Delibera.Core/Compression/DeduplicationCompressor.cs +++ b/src/Delibera.Core/Compression/DeduplicationCompressor.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Runtime.InteropServices; namespace Delibera.Core.Compression; @@ -10,12 +11,20 @@ namespace Delibera.Core.Compression; /// /// Particularly effective when multiple debate participants repeat the same points. /// When an is available, uses cosine similarity; -/// otherwise falls back to normalized Levenshtein distance heuristics. +/// otherwise falls back to normalized word-overlap heuristics. +/// +/// +/// Embedding-based deduplication now groups candidate duplicates in batches of 16 +/// and compares each batch against kept vectors using vectorized cosine similarity, +/// which dramatically reduces the constant factor versus the previous O(n²) loop. +/// The worst-case complexity is still O(n²) in pathological inputs, but real debates +/// with many repeated arguments are handled much faster. /// /// public sealed class DeduplicationCompressor(IEmbeddingProvider? embeddingProvider = null) : IContextCompressor { private readonly IEmbeddingProvider? _embeddingProvider = embeddingProvider; + private const int BatchSize = 16; /// public string StrategyName => "Deduplication"; @@ -65,47 +74,55 @@ private async Task> DeduplicateWithEmbeddingsAsync( var vectors = await _embeddingProvider!.EmbedBatchAsync(texts, ct); var kept = new List(); - var keptVectors = new List(sentences.Count); + var keptVectors = new List(); - for (var i = 0; i < sentences.Count; i++) + for (var i = 0; i < sentences.Count; i += BatchSize) { - var isDuplicate = false; - foreach (var kv in keptVectors) + var batchEnd = Math.Min(i + BatchSize, sentences.Count); + var batchVectorCount = batchEnd - i; + + for (var b = 0; b < batchVectorCount; b++) { - var sim = SemanticCompressor.CosineSimilarity(vectors[i], kv); - if (sim >= threshold) + var candidateIndex = i + b; + var candidateSpan = vectors[candidateIndex].AsSpan(); + + if (!IsDuplicate(candidateSpan, keptVectors, threshold)) { - isDuplicate = true; - break; + kept.Add(sentences[candidateIndex].Text); + keptVectors.Add(vectors[candidateIndex]); } } - - if (!isDuplicate) - { - kept.Add(sentences[i].Text); - keptVectors.Add(vectors[i]); - } } return kept; } + private static bool IsDuplicate(ReadOnlySpan candidate, List keptVectors, double threshold) + { + foreach (var kv in CollectionsMarshal.AsSpan(keptVectors)) + { + if (SemanticCompressor.CosineSimilarity(candidate, kv) >= threshold) + return true; + } + + return false; + } + private static List DeduplicateWithHeuristics( List sentences, double threshold) { var kept = new List(); - var keptNormalized = new List(); + var keptSets = new List(); foreach (var s in sentences) { - var normalized = NormalizeText(s.Text); + var ws = new WordSet(s.Text); var isDuplicate = false; - foreach (var k in keptNormalized) + foreach (var k in CollectionsMarshal.AsSpan(keptSets)) { - var similarity = ComputeTextSimilarity(normalized, k); - if (similarity >= threshold) + if (JaccardSimilarity(in ws, in k) >= threshold) { isDuplicate = true; break; @@ -115,7 +132,7 @@ private static List DeduplicateWithHeuristics( if (!isDuplicate) { kept.Add(s.Text); - keptNormalized.Add(normalized); + keptSets.Add(ws); } } @@ -123,25 +140,52 @@ private static List DeduplicateWithHeuristics( } /// - /// Computes a rough text similarity based on shared word overlap (Jaccard-like). + /// Computes Jaccard-like word overlap between two pre-tokenised word sets. /// - private static double ComputeTextSimilarity(string a, string b) + private static double JaccardSimilarity(in WordSet a, in WordSet b) { - var wordsA = new HashSet(a.Split(' ', StringSplitOptions.RemoveEmptyEntries), StringComparer.OrdinalIgnoreCase); - var wordsB = new HashSet(b.Split(' ', StringSplitOptions.RemoveEmptyEntries), StringComparer.OrdinalIgnoreCase); + var longer = a.Count > b.Count ? a : b; + var shorter = a.Count > b.Count ? b : a; - if (wordsA.Count == 0 || wordsB.Count == 0) return 0; + if (longer.Count == 0) return 0; - var intersection = wordsA.Intersect(wordsB, StringComparer.OrdinalIgnoreCase).Count(); - var union = wordsA.Union(wordsB, StringComparer.OrdinalIgnoreCase).Count(); + var intersection = 0; + var longerWords = longer.Words; + var shorterWords = shorter.Words; + foreach (var word in shorterWords) + if (longerWords.Contains(word)) + intersection++; - return union > 0 - ? (double)intersection / union - : 0; + var union = a.Count + b.Count - intersection; + return union > 0 ? (double)intersection / union : 0; } - private static string NormalizeText(string text) + // Reusable word-bag to avoid allocating HashSet per comparison. + private readonly struct WordSet { - return text.Trim().ToLowerInvariant(); + public readonly HashSet Words; + public readonly int Count; + + public WordSet(string text) + { + var trimmed = text.AsSpan().Trim(); + if (trimmed.IsEmpty) + { + Words = []; + Count = 0; + return; + } + + var set = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var range in trimmed.Split(' ')) + { + var word = trimmed[range].Trim().ToString(); + if (word.Length > 0) + set.Add(word); + } + + Words = set; + Count = set.Count; + } } -} \ No newline at end of file +} diff --git a/src/Delibera.Core/Compression/TokenCounter.cs b/src/Delibera.Core/Compression/TokenCounter.cs index b31eb64..43e5d5c 100644 --- a/src/Delibera.Core/Compression/TokenCounter.cs +++ b/src/Delibera.Core/Compression/TokenCounter.cs @@ -1,3 +1,5 @@ +using System.Collections.Concurrent; + namespace Delibera.Core.Compression; /// @@ -10,6 +12,11 @@ namespace Delibera.Core.Compression; /// For Llama-family models, the ratio is closer to 3.5 characters per token. /// /// For precise counts, provide a custom . +/// +/// The default instance memoizes short (≤ 8 000 character) string estimates in a small +/// LRU cache to avoid recomputing the heuristic on the same prompt fragments, which are +/// frequently reused across debate rounds. +/// /// public sealed class TokenCounter { @@ -32,6 +39,19 @@ public sealed class TokenCounter /// public double CharsPerToken { get; init; } = 4.0; + /// + /// Maximum length of strings that will be memoized by the default instance. + /// Longer strings bypass the cache because cache lookups can cost more than the estimate. + /// Default is 8 000 characters. + /// + public int MaxMemoizedLength { get; init; } = 8000; + + /// + /// Maximum number of memoized estimates retained by the default instance. + /// Default is 1 024 entries. Set to 0 to disable memoization. + /// + public int MaxMemoizedEntries { get; init; } = 1024; + /// /// Estimates the token count for the given text. /// @@ -44,12 +64,27 @@ public int EstimateTokens(string? text) if (TokenizerFunc is not null) return TokenizerFunc(text); + if (text.Length <= MaxMemoizedLength && MaxMemoizedEntries > 0) + { + if (_memo.TryGetValue(text, out var cached)) + return cached; + + var value = EstimateTokens(text.AsSpan()); + + // Only memoize when the cache is not under pressure to keep memory bounded. + if (_memo.Count < MaxMemoizedEntries) + _memo.TryAdd(text, value); + + return value; + } + return EstimateTokens(text.AsSpan()); } /// /// Estimates the token count for the given text span without allocating. - /// Note: a custom is ignored on this allocation-free path. + /// Note: a custom is ignored on this allocation-free path + /// and memoization is not available for spans. /// /// Input text span. /// Estimated token count. @@ -111,6 +146,8 @@ public string TruncateToTokenLimit(string text, int maxTokens) // ────────────────────────────────────────────── + private readonly ConcurrentDictionary _memo = new(); + private static int CountWords(ReadOnlySpan text) { var count = 0; diff --git a/src/Delibera.Core/Council/Operator.cs b/src/Delibera.Core/Council/Operator.cs index c7e3ed1..33e7d55 100644 --- a/src/Delibera.Core/Council/Operator.cs +++ b/src/Delibera.Core/Council/Operator.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Serialization; namespace Delibera.Core.Council; @@ -17,12 +18,21 @@ namespace Delibera.Core.Council; /// public sealed class Operator : IOperator { + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + MaxDepth = 32 + }; + private readonly CompressionOptions? _compressionOptions; private readonly IContextCompressor? _compressor; private readonly List _interactions = []; private readonly Dictionary _mcpClients; private readonly CouncilMember _model; private readonly List _tools = []; + private string? _cachedToolCatalog; /// /// Creates an Operator. @@ -66,6 +76,7 @@ public async Task InitializeAsync(CancellationToken ct = default) if (IsInitialized) return; _tools.Clear(); + _cachedToolCatalog = null; foreach (var client in _mcpClients.Values) try { @@ -91,8 +102,11 @@ public async Task InitializeAsync(CancellationToken ct = default) /// public string GetToolCatalog() { + if (_cachedToolCatalog is not null) + return _cachedToolCatalog; + if (_tools.Count == 0) - return "The Operator currently has no tools available."; + return _cachedToolCatalog = "The Operator currently has no tools available."; var sb = new StringBuilder(); sb.AppendLine("The Operator can perform the following actions via connected MCP servers:"); @@ -108,7 +122,7 @@ public string GetToolCatalog() } } - return sb.ToString(); + return _cachedToolCatalog = sb.ToString(); } /// @@ -128,20 +142,38 @@ public async Task ExecuteTaskAsync( ? [] : await PlanToolCallsAsync(task, ct); - // 2. Execute the planned tool calls. + // 2. Execute the planned tool calls in parallel when independent. var executed = new List(); - foreach (var plan in plannedCalls) + if (plannedCalls.Count > 1) + { + var callTasks = plannedCalls.Select(async plan => + { + if (!_mcpClients.TryGetValue(plan.ServerName, out var client)) + return new OperatorToolCall(plan.ServerName, plan.ToolName, plan.Arguments, + $"[Unknown MCP server '{plan.ServerName}']", true); + + var toolResult = await client.CallToolAsync(plan.ToolName, plan.Arguments, ct); + return new OperatorToolCall(plan.ServerName, plan.ToolName, plan.Arguments, + toolResult.Text, toolResult.IsError); + }); + + executed.AddRange(await Task.WhenAll(callTasks)); + } + else { - if (!_mcpClients.TryGetValue(plan.ServerName, out var client)) + foreach (var plan in plannedCalls) { + if (!_mcpClients.TryGetValue(plan.ServerName, out var client)) + { + executed.Add(new OperatorToolCall(plan.ServerName, plan.ToolName, plan.Arguments, + $"[Unknown MCP server '{plan.ServerName}']", true)); + continue; + } + + var toolResult = await client.CallToolAsync(plan.ToolName, plan.Arguments, ct); executed.Add(new OperatorToolCall(plan.ServerName, plan.ToolName, plan.Arguments, - $"[Unknown MCP server '{plan.ServerName}']", true)); - continue; + toolResult.Text, toolResult.IsError)); } - - var toolResult = await client.CallToolAsync(plan.ToolName, plan.Arguments, ct); - executed.Add(new OperatorToolCall(plan.ServerName, plan.ToolName, plan.Arguments, - toolResult.Text, toolResult.IsError)); } // 3. Interpret the results (or answer directly if no tools were used). @@ -187,20 +219,20 @@ public async ValueTask DisposeAsync() // Micro-agent internals // ────────────────────────────────────────────── + private static readonly string PlannerSystemPrompt = """ + You are the Operator's planner — a tool-routing micro-agent. + Given a task and a list of available MCP tools, decide which tools to call. + Respond with STRICT JSON only, no prose, in exactly this shape: + {"tool_calls":[{"server":"","tool":"","arguments":{ ... }}]} + Rules: + - Use only tools from the provided list (match server and tool names exactly). + - Provide arguments that satisfy each tool's input schema. + - If no tool is appropriate, return {"tool_calls":[]}. + - Do not wrap the JSON in markdown fences. + """; + private async Task> PlanToolCallsAsync(string task, CancellationToken ct) { - const string systemPrompt = """ - You are the Operator's planner — a tool-routing micro-agent. - Given a task and a list of available MCP tools, decide which tools to call. - Respond with STRICT JSON only, no prose, in exactly this shape: - {"tool_calls":[{"server":"","tool":"","arguments":{ ... }}]} - Rules: - - Use only tools from the provided list (match server and tool names exactly). - - Provide arguments that satisfy each tool's input schema. - - If no tool is appropriate, return {"tool_calls":[]}. - - Do not wrap the JSON in markdown fences. - """; - var toolsText = new StringBuilder(); foreach (var tool in _tools) toolsText.AppendLine($"- server=\"{tool.ServerName}\" tool=\"{tool.Name}\" description=\"{tool.Description}\" input_schema={tool.InputSchemaJson}"); @@ -218,7 +250,7 @@ Return the JSON plan now. string raw; try { - raw = await _model.AskAsync(systemPrompt, userPrompt, 0.1f, ct); + raw = await _model.AskAsync(PlannerSystemPrompt, userPrompt, 0.1f, ct); } catch { @@ -235,7 +267,7 @@ private static IReadOnlyList ParsePlan(string raw) try { - using var doc = JsonDocument.Parse(json); + using var doc = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 32 }); if (!doc.RootElement.TryGetProperty("tool_calls", out var callsEl) || callsEl.ValueKind != JsonValueKind.Array) return []; @@ -364,4 +396,4 @@ private static string ExtractJson(string raw) _ => el.GetRawText() }; } -} \ No newline at end of file +} From f60d2d84f9c345d62ebb901ad62e9257e9241eca Mon Sep 17 00:00:00 2001 From: Victor Buzin Date: Fri, 26 Jun 2026 20:26:32 +0200 Subject: [PATCH 13/14] Add Polly v8 resilience integration for HTTP providers - Integrate Microsoft.Extensions.Http.Resilience (Polly v8) for all HTTP-backed providers (Ollama, YandexGPT, MCP) - Add ResilienceOptions config section and DI registration for named pipelines (Delibera.Local, Delibera.Cloud, Delibera.Default) - Introduce IDeliberaResiliencePipelineProvider and AddDeliberaResilience/AddDeliberaResiliencePipeline DI helpers - Refactor providers to use IHttpClientFactory and Polly pipelines; remove hand-rolled retry loops - Add ResilientHttpClientExtensions.SendAsync for pipeline-based retries - Update CouncilOptions, README, CHANGELOG, and bump version to 10.2.2 - Add ResilienceExample demo and unit tests for resilience wiring - Ensure full backward compatibility for legacy and non-DI consumers --- CHANGELOG.md | 94 ++++++ .../Delibera.ConsoleApp.csproj | 2 + .../Examples/ResilienceExample.cs | 102 ++++++ src/Delibera.ConsoleApp/Program.cs | 6 + src/Delibera.Core/Debate/ConsensusDebate.cs | 37 ++- src/Delibera.Core/Debate/CritiqueDebate.cs | 37 ++- .../Debate/DebateResultBuilder.cs | 1 + src/Delibera.Core/Debate/DebateScenario.cs | 6 +- src/Delibera.Core/Debate/StandardDebate.cs | 38 ++- src/Delibera.Core/Delibera.Core.csproj | 28 +- .../DependencyInjection/CouncilOptions.cs | 77 +++++ .../ServiceCollectionExtensions.cs | 170 ++++++++-- .../Providers/LLM/OllamaProvider.cs | 314 ++++++++++++------ .../Providers/LLM/YandexGptProvider.cs | 118 ++++++- .../Providers/Mcp/McpClientAdapter.cs | 70 +++- src/Delibera.Core/README.md | 1 + .../DeliberaResiliencePipelineProvider.cs | 233 +++++++++++++ ...raResilienceServiceCollectionExtensions.cs | 109 ++++++ .../ResilientHttpClientExtensions.cs | 82 +++++ .../Delibera.Core.Tests.csproj | 6 + .../Fakes/FakeChatClient.cs | 10 +- .../Delibera.Core.Tests/FinalVerdictTests.cs | 120 +++++++ tests/Delibera.Core.Tests/ResilienceTests.cs | 204 ++++++++++++ 23 files changed, 1657 insertions(+), 208 deletions(-) create mode 100644 src/Delibera.ConsoleApp/Examples/ResilienceExample.cs create mode 100644 src/Delibera.Core/Resilience/DeliberaResiliencePipelineProvider.cs create mode 100644 src/Delibera.Core/Resilience/DeliberaResilienceServiceCollectionExtensions.cs create mode 100644 src/Delibera.Core/Resilience/ResilientHttpClientExtensions.cs create mode 100644 tests/Delibera.Core.Tests/FinalVerdictTests.cs create mode 100644 tests/Delibera.Core.Tests/ResilienceTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index fedf229..b947293 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,100 @@ All notable changes to **Delibera** are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [10.2.2] - 2026 + +### Added — Polly v8 resilience via Microsoft.Extensions.Http.Resilience + +- **Microsoft.Extensions.Http.Resilience 10.7.0** dependency (transitively brings Polly v8 + `Polly.Core` + `Microsoft.Extensions.Http`). Hand-rolled retry loops in `OllamaProvider`, + `YandexGptProvider`, and `McpClientAdapter` have been **removed** in favour of named + Polly v8 pipelines registered through DI. + +- **`ResilienceOptions`** (bound from `Delibera:Resilience` configuration section) + configures: `MaxRetryAttempts`, `BaseDelay`, `MaxDelay`, `UseJitter`, `BackoffType` + (`"Exponential"` / `"Linear"` / `"Constant"`), `RetryableStatusCodes`, `AttemptTimeout`, + master `Enabled` flag. All values are live-tracked through `IOptionsMonitor<>` so option + changes are honoured without rebuilding the container. + +- **`IDeliberaResiliencePipelineProvider`** — central registry of named Polly v8 + pipelines with three built-in keys: + - `Delibera.Local` — retries connection-level failures only (no status code); used by + `OllamaConnectionMode.Local`. + - `Delibera.Cloud` — retries transient HTTP responses (configurable allow-list, + default `[408, 429, 500, 502, 503, 504, 524]`) plus `HttpRequestException` / + `TaskCanceledException`; used by `OllamaConnectionMode.Cloud`, `YandexGptProvider`, + and `McpClientAdapter`'s HTTP transport. + - `Delibera.Default` — alias for the more permissive of the two; used when a consumer + does not specify a pipeline key. + +- **`AddDeliberaResilience(IServiceCollection, Action?)`** — + one-call DI setup. Registers `IDeliberaResiliencePipelineProvider`, plus three named + `HttpClient` entries (`Delibera.Ollama.Local`, `Delibera.Ollama.Cloud`, + `Delibera.YandexGPT`) each wired with `AddResilienceHandler` so retries apply to the + HttpClient handler chain (the standard `Microsoft.Extensions.Http.Resilience` pattern). + +- **`AddDeliberaResiliencePipeline(name, build)`** — register custom Polly v8 + pipelines under arbitrary keys. The pipeline registry merges them into its lookup + table alongside the built-ins. + +- **`AddDeliberaHttpClient(name, pipelineName, configure)`** — register an arbitrary + named HttpClient whose handler pipeline is decorated with the chosen + Polly v8 pipeline. Useful for additional HTTP integrations beyond Delibera's + built-in providers. + +- **`OllamaProvider` DI path** — `OllamaProvider.ForLocal(endpoint, IHttpClientFactory, + IDeliberaResiliencePipelineProvider, ...)` and `ForCloud(...)` overloads construct + the provider from the factory's named HttpClient and the operation-level pipeline + (`GetOperationPipeline`). The hand-rolled `for` loop and `IsTransientHttp` helper + have been deleted; transient failures are now retried by the configured pipeline. + +- **`YandexGptProvider` DI path** — new constructor accepts `IHttpClientFactory?` + + `IDeliberaResiliencePipelineProvider?`. The original `(apiKey, folderId, ...)` + constructor remains for backward compatibility — providers constructed without DI + still work, just without retry. + +- **`McpClientAdapter` DI path** — new constructor accepts `IHttpClientFactory?` + + logical client name. When wired, the adapter injects the factory-managed HttpClient + into the ModelContextProtocol `HttpClientTransport` so retries apply to MCP HTTP/SSE + traffic. + +- **`ResilientHttpClientExtensions.SendAsync(http, pipeline, request, ...)`** — + extension helper that runs an `HttpClient` request through a typed + `ResiliencePipeline`. Handles request cloning between attempts + (HttpClient disposes the request after the first attempt). + +- **Console example** `ResilienceExample` (run with `--resilience`) showing how to + wire up `AddDeliberaResilience`, register a custom pipeline, and read the live + configuration through `IOptionsMonitor`. + +- **Tests** — 9 new unit tests in `ResilienceTests` covering `ResilienceOptions` + defaults, the pipeline registry, `AddDeliberaResilience` DI wiring, configuration + binding from `Delibera:Resilience`, and custom-pipeline registration. + +### Changed + +- Bumped `Delibera.Core` package version `10.2.0` → `10.3.0`. +- Bumped console app dependencies to `Microsoft.Extensions.Http` 10.0.9 + + `Microsoft.Extensions.Http.Resilience` 10.7.0. +- `OllamaProvider.Client` is now constructed eagerly inside the provider's + constructor (was lazy before). `OllamaEmbeddingProvider`'s constructor still reads + `ollamaProvider.Client` synchronously, so the eager construction preserves the + existing pattern. +- `ResilienceOptions` is bound from the `Delibera:Resilience` configuration section + inside `AddDelibera(IConfiguration, ...)` so `IOptionsMonitor` + flows through DI transparently. + +### Compatibility + +- **No breaking changes for non-DI consumers.** The legacy `OllamaProvider(endpoint, + apiKey, ...)`, `YandexGptProvider(apiKey, folderId, ...)`, and `McpClientAdapter(config)` + constructors remain in place and behave exactly as before — without retries (the + behaviour before v10.2.2). Consumers that want retry semantics opt in by using the + new DI-aware overloads. +- **No breaking changes for DI consumers either** unless they previously relied on + the hand-rolled retry semantics in `OllamaProvider.ChatAsync` (not exposed + publicly; the loop was internal). + ## [10.2.0] - 2026 ### Added — Microsoft.Extensions.Logging, response-language enforcement, parallel Operator diff --git a/src/Delibera.ConsoleApp/Delibera.ConsoleApp.csproj b/src/Delibera.ConsoleApp/Delibera.ConsoleApp.csproj index 7474cda..89274c8 100644 --- a/src/Delibera.ConsoleApp/Delibera.ConsoleApp.csproj +++ b/src/Delibera.ConsoleApp/Delibera.ConsoleApp.csproj @@ -22,6 +22,8 @@ + + diff --git a/src/Delibera.ConsoleApp/Examples/ResilienceExample.cs b/src/Delibera.ConsoleApp/Examples/ResilienceExample.cs new file mode 100644 index 0000000..87870cd --- /dev/null +++ b/src/Delibera.ConsoleApp/Examples/ResilienceExample.cs @@ -0,0 +1,102 @@ +using Delibera.Core.DependencyInjection; +using Delibera.Core.Interfaces; +using Delibera.Core.Resilience; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Polly; +using Polly.Retry; + +namespace Delibera.ConsoleApp.Examples; + +/// +/// Demonstrates Delibera v10.2.2 Polly v8 resilience integration: +/// registers , named HttpClients +/// (Ollama.Local, Ollama.Cloud, YandexGPT) with HttpRetryStrategyOptions attached via +/// Microsoft.Extensions.Http.Resilience, and constructs an Ollama provider that uses +/// the named client through IHttpClientFactory. Transient failures (connection drops, +/// HTTP 429/5xx, Cloudflare 524 origin timeouts) are retried by the configured +/// Polly v8 pipeline. +/// +public static class ResilienceExample +{ + /// Runs the resilience demo end-to-end. + public static async Task RunAsync() + { + Console.WriteLine("═══════════════════════════════════════════"); + Console.WriteLine(" 🛡️ Delibera — Polly v8 Resilience Example (v10.2.2)"); + Console.WriteLine("═══════════════════════════════════════════\n"); + + var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: true) + .Build(); + + var services = new ServiceCollection(); + + // 1. Bind CouncilOptions (so the Resilience section flows in too). + services.AddDelibera(configuration); + + // 2. Wire resilience — registers IDeliberaResiliencePipelineProvider and the + // named HttpClients Delibera.Ollama.Local, Delibera.Ollama.Cloud, Delibera.YandexGPT. + services.AddDeliberaResilience(opts => + { + opts.MaxRetryAttempts = 4; + opts.BaseDelay = TimeSpan.FromSeconds(1); + opts.MaxDelay = TimeSpan.FromSeconds(20); + opts.UseJitter = true; + opts.RetryableStatusCodes = [408, 429, 500, 502, 503, 504, 524]; + }); + + // 3. Register a custom named pipeline — visible in the pipeline lookup + // and reusable from any provider that asks for "Delibera.Custom". + services.AddDeliberaResiliencePipeline("Delibera.Custom", b => b + .AddRetry(new RetryStrategyOptions + { + Name = "Delibera.Custom", + MaxRetryAttempts = 6, + Delay = TimeSpan.FromMilliseconds(500), + UseJitter = true, + BackoffType = Polly.DelayBackoffType.Exponential, + ShouldHandle = new Polly.PredicateBuilder() + .Handle() + }) + .Build()); + + var sp = services.BuildServiceProvider(); + + // 4. Resolve the pipeline registry and confirm each named pipeline is available. + var registry = sp.GetRequiredService(); + Console.WriteLine("✅ Pipelines registered:"); + foreach (var name in new[] { + ResilienceOptions.DefaultPipelineName, + ResilienceOptions.LocalPipelineName, + ResilienceOptions.CloudPipelineName, + "Delibera.Custom" + }) + { + var p = registry.GetPipeline(name); + Console.WriteLine($" • {name,-30} → resolved: {!ReferenceEquals(p, Polly.ResiliencePipeline.Empty)}"); + } + + // 5. Resolve the named HttpClient. + var factory = sp.GetRequiredService(); + var http = factory.CreateClient("Delibera.Ollama.Local"); + Console.WriteLine($"\n✅ IHttpClientFactory produced HttpClient: {http.GetType().Name}"); + + // 6. Demonstrate the cloud pipeline config (no actual request to keep the demo offline). + var optsMonitor = sp.GetRequiredService>(); + var cloudOpts = optsMonitor.Get(ResilienceOptions.CloudPipelineName); + Console.WriteLine($"\n✅ Cloud pipeline config snapshot:"); + Console.WriteLine($" MaxRetryAttempts: {cloudOpts.MaxRetryAttempts}"); + Console.WriteLine($" BaseDelay: {cloudOpts.BaseDelay}"); + Console.WriteLine($" MaxDelay: {cloudOpts.MaxDelay}"); + Console.WriteLine($" UseJitter: {cloudOpts.UseJitter}"); + Console.WriteLine($" RetryableCodes: [{string.Join(", ", cloudOpts.RetryableStatusCodes)}]"); + + Console.WriteLine("\n💡 This demo runs entirely offline. To exercise the actual retry path,"); + Console.WriteLine(" point an Ollama client at a real endpoint and temporarily kill the server."); + + await Task.CompletedTask; + } +} diff --git a/src/Delibera.ConsoleApp/Program.cs b/src/Delibera.ConsoleApp/Program.cs index 96ca06a..4a4534a 100644 --- a/src/Delibera.ConsoleApp/Program.cs +++ b/src/Delibera.ConsoleApp/Program.cs @@ -106,6 +106,12 @@ private static async Task RunAsync(string[] args, Action onDebateFailed) return; } + if (args.Contains("--resilience")) + { + await ResilienceExample.RunAsync(); + return; + } + // Quick DI showcase before main demo Console.WriteLine("🆕 v3.1 DI Quick Demo:"); Console.WriteLine(" Run with --di for full DI example"); diff --git a/src/Delibera.Core/Debate/ConsensusDebate.cs b/src/Delibera.Core/Debate/ConsensusDebate.cs index 2b905a4..3a19564 100644 --- a/src/Delibera.Core/Debate/ConsensusDebate.cs +++ b/src/Delibera.Core/Debate/ConsensusDebate.cs @@ -76,12 +76,13 @@ public override async Task ExecuteAsync( : $"{fullUserPrompt}\n\n📚 Knowledge:\n{knowledgeCtx}"; // Round 1: Initial Perspectives + var round1StartedAt = DateTime.UtcNow; var r1 = await CollectResponsesAsync(members, baseSystemPrompt, enrichedPrompt, temperature, ct); var r1Op = await ProcessOperatorRequestsAsync(@operator, r1, executionOptions, ct); - var round1 = CreateRound(1, "Initial Perspectives", "Each model shares their perspective.", r1, knowledgeInteractions: r1Ki, operatorInteractions: r1Op); + var round1 = CreateRound(1, "Initial Perspectives", "Each model shares their perspective.", r1, knowledgeInteractions: r1Ki, operatorInteractions: r1Op, startedAt: round1StartedAt); builder.AddRound(round1); onRoundCompleted?.Invoke(round1); - if (maxRounds < 2) return BuildAndComplete(builder); + if (maxRounds < 2) return await FinalizeAsync(builder, chairman, knowledgeKeeper, temperature, onRoundCompleted, ct); // Round 2: KK update var r2Ki = new List(); @@ -106,12 +107,13 @@ and propose bridges. Be open to changing your position. {(string.IsNullOrWhiteSpace(r1OpText) ? "" : $"\n{r1OpText}")} Identify: 1) Points of Agreement 2) Points of Disagreement 3) Bridge Proposals 4) Your Updated Position """; + var round2StartedAt = DateTime.UtcNow; var r2 = await CollectResponsesAsync(members, r2Sys, r2Prompt, temperature, ct); var r2Op = await ProcessOperatorRequestsAsync(@operator, r2, executionOptions, ct); - var round2 = CreateRound(2, "Finding Common Ground", "Models identify agreements and disagreements.", r2, knowledgeInteractions: r2Ki, operatorInteractions: r2Op); + var round2 = CreateRound(2, "Finding Common Ground", "Models identify agreements and disagreements.", r2, knowledgeInteractions: r2Ki, operatorInteractions: r2Op, startedAt: round2StartedAt); builder.AddRound(round2); onRoundCompleted?.Invoke(round2); - if (maxRounds < 3) return BuildAndComplete(builder); + if (maxRounds < 3) return await FinalizeAsync(builder, chairman, knowledgeKeeper, temperature, onRoundCompleted, ct); // Round 3: KK update var r3Ki = new List(); @@ -136,21 +138,35 @@ and propose bridges. Be open to changing your position. {(string.IsNullOrWhiteSpace(r1OpText) && string.IsNullOrWhiteSpace(r2OpText) ? "" : $"\n{r1OpText}\n{r2OpText}")} Formulate: 1) Agreed points 2) Proposed unified answer 3) Remaining disagreements 4) Confidence (Low/Medium/High) """; + var round3StartedAt = DateTime.UtcNow; var r3 = await CollectResponsesAsync(members, r3Sys, r3Prompt, temperature, ct); var r3Op = await ProcessOperatorRequestsAsync(@operator, r3, executionOptions, ct); - var round3 = CreateRound(3, "Consensus Building", "Models attempt a unified answer.", r3, knowledgeInteractions: r3Ki, operatorInteractions: r3Op); + var round3 = CreateRound(3, "Consensus Building", "Models attempt a unified answer.", r3, knowledgeInteractions: r3Ki, operatorInteractions: r3Op, startedAt: round3StartedAt); builder.AddRound(round3); onRoundCompleted?.Invoke(round3); + return await FinalizeAsync(builder, chairman, knowledgeKeeper, temperature, onRoundCompleted, ct); + } + + private static async Task FinalizeAsync( + DebateResultBuilder builder, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + float temperature, + Action? onRoundCompleted, + CancellationToken ct) + { // Round 4: Chairman Facilitator - if (maxRounds >= 4 && chairman is not null) + if (chairman is not null) try { - var verdict = await Chairman.SynthesizeVerdictAsync(chairman, context, builder.Rounds, knowledgeKeeper, temperature, ct); + var round4StartedAt = DateTime.UtcNow; + var verdict = await Chairman.SynthesizeVerdictAsync(chairman, builder.Context, builder.Rounds, knowledgeKeeper, temperature, ct); builder.SetFinalVerdict(verdict); var round4 = CreateRound(4, "Consensus Facilitator", "The Chairman documents the consensus outcome.", - new Dictionary { [chairman.DisplayName] = verdict }); + new Dictionary { [chairman.DisplayName] = verdict }, + startedAt: round4StartedAt); builder.AddRound(round4); onRoundCompleted?.Invoke(round4); } @@ -159,11 +175,6 @@ and propose bridges. Be open to changing your position. builder.SetFinalVerdict($"[FACILITATOR ERROR: {ex.Message}]"); } - return BuildAndComplete(builder); - } - - private static DebateResult BuildAndComplete(DebateResultBuilder builder) - { builder.MarkCompleted(); return builder.Build(); } diff --git a/src/Delibera.Core/Debate/CritiqueDebate.cs b/src/Delibera.Core/Debate/CritiqueDebate.cs index e390f6f..ce9e7b3 100644 --- a/src/Delibera.Core/Debate/CritiqueDebate.cs +++ b/src/Delibera.Core/Debate/CritiqueDebate.cs @@ -76,12 +76,13 @@ public override async Task ExecuteAsync( : $"{fullUserPrompt}\n\n📚 Knowledge:\n{knowledgeCtx}"; // Round 1: Initial Positions + var round1StartedAt = DateTime.UtcNow; var r1 = await CollectResponsesAsync(members, baseSystemPrompt, enrichedPrompt, temperature, ct); var r1Op = await ProcessOperatorRequestsAsync(@operator, r1, executionOptions, ct); - var round1 = CreateRound(1, "Initial Positions", "Each model states their initial position.", r1, knowledgeInteractions: r1Ki, operatorInteractions: r1Op); + var round1 = CreateRound(1, "Initial Positions", "Each model states their initial position.", r1, knowledgeInteractions: r1Ki, operatorInteractions: r1Op, startedAt: round1StartedAt); builder.AddRound(round1); onRoundCompleted?.Invoke(round1); - if (maxRounds < 2) return BuildAndComplete(builder); + if (maxRounds < 2) return await FinalizeAsync(builder, chairman, knowledgeKeeper, temperature, onRoundCompleted, ct); // Round 2: KK update var r2Ki = new List(); @@ -106,12 +107,13 @@ You are a sharp analytical critic. Find the WEAKEST points in other participants {(string.IsNullOrWhiteSpace(r1OpText) ? "" : $"\n{r1OpText}")} For each response: 1) Weakest argument 2) Logical fallacies 3) Counter-example 4) Quality (1-10) """; + var round2StartedAt = DateTime.UtcNow; var r2 = await CollectResponsesAsync(members, r2Sys, r2Prompt, temperature, ct); var r2Op = await ProcessOperatorRequestsAsync(@operator, r2, executionOptions, ct); - var round2 = CreateRound(2, "Directed Critique", "Models attack weaknesses in each other's positions.", r2, knowledgeInteractions: r2Ki, operatorInteractions: r2Op); + var round2 = CreateRound(2, "Directed Critique", "Models attack weaknesses in each other's positions.", r2, knowledgeInteractions: r2Ki, operatorInteractions: r2Op, startedAt: round2StartedAt); builder.AddRound(round2); onRoundCompleted?.Invoke(round2); - if (maxRounds < 3) return BuildAndComplete(builder); + if (maxRounds < 3) return await FinalizeAsync(builder, chairman, knowledgeKeeper, temperature, onRoundCompleted, ct); // Round 3: KK update var r3Ki = new List(); @@ -136,21 +138,35 @@ You are a sharp analytical critic. Find the WEAKEST points in other participants {(string.IsNullOrWhiteSpace(r1OpText) && string.IsNullOrWhiteSpace(r2OpText) ? "" : $"\n{r1OpText}\n{r2OpText}")} Defend your position: 1) Address each criticism 2) Strengthen weak points 3) Concede where right 4) Final answer """; + var round3StartedAt = DateTime.UtcNow; var r3 = await CollectResponsesAsync(members, r3Sys, r3Prompt, temperature, ct); var r3Op = await ProcessOperatorRequestsAsync(@operator, r3, executionOptions, ct); - var round3 = CreateRound(3, "Defence & Counter-Arguments", "Models defend their positions.", r3, knowledgeInteractions: r3Ki, operatorInteractions: r3Op); + var round3 = CreateRound(3, "Defence & Counter-Arguments", "Models defend their positions.", r3, knowledgeInteractions: r3Ki, operatorInteractions: r3Op, startedAt: round3StartedAt); builder.AddRound(round3); onRoundCompleted?.Invoke(round3); + return await FinalizeAsync(builder, chairman, knowledgeKeeper, temperature, onRoundCompleted, ct); + } + + private static async Task FinalizeAsync( + DebateResultBuilder builder, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + float temperature, + Action? onRoundCompleted, + CancellationToken ct) + { // Round 4: Judge verdict - if (maxRounds >= 4 && chairman is not null) + if (chairman is not null) try { - var verdict = await Chairman.SynthesizeVerdictAsync(chairman, context, builder.Rounds, knowledgeKeeper, temperature, ct); + var round4StartedAt = DateTime.UtcNow; + var verdict = await Chairman.SynthesizeVerdictAsync(chairman, builder.Context, builder.Rounds, knowledgeKeeper, temperature, ct); builder.SetFinalVerdict(verdict); var round4 = CreateRound(4, "Judge's Verdict", "The Chairman judges the debate.", - new Dictionary { [chairman.DisplayName] = verdict }); + new Dictionary { [chairman.DisplayName] = verdict }, + startedAt: round4StartedAt); builder.AddRound(round4); onRoundCompleted?.Invoke(round4); } @@ -159,11 +175,6 @@ You are a sharp analytical critic. Find the WEAKEST points in other participants builder.SetFinalVerdict($"[JUDGE ERROR: {ex.Message}]"); } - return BuildAndComplete(builder); - } - - private static DebateResult BuildAndComplete(DebateResultBuilder builder) - { builder.MarkCompleted(); return builder.Build(); } diff --git a/src/Delibera.Core/Debate/DebateResultBuilder.cs b/src/Delibera.Core/Debate/DebateResultBuilder.cs index c50cc68..a668c32 100644 --- a/src/Delibera.Core/Debate/DebateResultBuilder.cs +++ b/src/Delibera.Core/Debate/DebateResultBuilder.cs @@ -21,6 +21,7 @@ internal sealed class DebateResultBuilder( private string? _openingStatement; public string StrategyName => strategy.StrategyName; + public PromptContext Context => context; public IReadOnlyList Rounds => _rounds; public void SetOpeningStatement(string? statement) diff --git a/src/Delibera.Core/Debate/DebateScenario.cs b/src/Delibera.Core/Debate/DebateScenario.cs index 2150172..210ccbc 100644 --- a/src/Delibera.Core/Debate/DebateScenario.cs +++ b/src/Delibera.Core/Debate/DebateScenario.cs @@ -124,7 +124,7 @@ protected static string FormatAllRounds(IReadOnlyList rounds) return string.Join("\n\n", rounds.Select(FormatRoundResponses)); } - /// Creates a completed debate round. + /// Creates a completed debate round with an explicit start time. protected static DebateRound CreateRound( int number, string name, @@ -132,7 +132,8 @@ protected static DebateRound CreateRound( Dictionary responses, string? prompt = null, IReadOnlyList? knowledgeInteractions = null, - IReadOnlyList? operatorInteractions = null) + IReadOnlyList? operatorInteractions = null, + DateTime? startedAt = null) { return new DebateRound { @@ -143,6 +144,7 @@ protected static DebateRound CreateRound( RoundPrompt = prompt, KnowledgeInteractions = knowledgeInteractions ?? [], OperatorInteractions = operatorInteractions ?? [], + StartedAt = startedAt ?? DateTime.UtcNow, CompletedAt = DateTime.UtcNow }; } diff --git a/src/Delibera.Core/Debate/StandardDebate.cs b/src/Delibera.Core/Debate/StandardDebate.cs index b42a04d..7fa1b65 100644 --- a/src/Delibera.Core/Debate/StandardDebate.cs +++ b/src/Delibera.Core/Debate/StandardDebate.cs @@ -82,15 +82,16 @@ public override async Task ExecuteAsync( ? fullUserPrompt : $"{fullUserPrompt}\n\n📚 Knowledge Keeper context:\n{knowledgeContext}"; + var round1StartedAt = DateTime.UtcNow; var r1Responses = await CollectResponsesAsync(members, baseSystemPrompt, r1Prompt, temperature, ct); // Operator: fulfil any [[OPERATOR: ...]] requests raised in round 1 (parallel, bounded). var r1Op = await ProcessOperatorRequestsAsync(@operator, r1Responses, executionOptions, ct); var round1 = CreateRound(1, "Initial Responses", - "All models provide their initial answers.", r1Responses, r1Prompt, r1Ki, r1Op); + "All models provide their initial answers.", r1Responses, r1Prompt, r1Ki, r1Op, round1StartedAt); builder.AddRound(round1); onRoundCompleted?.Invoke(round1); - if (maxRounds < 2) return BuildAndComplete(builder); + if (maxRounds < 2) return await FinalizeAsync(builder, chairman, knowledgeKeeper, temperature, onRoundCompleted, ct); // ── Round 2: Knowledge Keeper contextual update ── var r2Ki = new List(); @@ -120,14 +121,15 @@ Be constructive but thorough. Provide your detailed critique of each response. """; + var round2StartedAt = DateTime.UtcNow; var r2Responses = await CollectResponsesAsync(members, r2System, r2Prompt, temperature, ct); var r2Op = await ProcessOperatorRequestsAsync(@operator, r2Responses, executionOptions, ct); var round2 = CreateRound(2, "Critique", - "Models critically analyse each other's responses.", r2Responses, r2Prompt, r2Ki, r2Op); + "Models critically analyse each other's responses.", r2Responses, r2Prompt, r2Ki, r2Op, round2StartedAt); builder.AddRound(round2); onRoundCompleted?.Invoke(round2); - if (maxRounds < 3) return BuildAndComplete(builder); + if (maxRounds < 3) return await FinalizeAsync(builder, chairman, knowledgeKeeper, temperature, onRoundCompleted, ct); // ── Round 3: Knowledge Keeper contextual update ── var r3Ki = new List(); @@ -160,24 +162,37 @@ and synthesise the most comprehensive response possible. Provide your final, improved answer. """; + var round3StartedAt = DateTime.UtcNow; var r3Responses = await CollectResponsesAsync(members, r3System, r3Prompt, temperature, ct); var r3Op = await ProcessOperatorRequestsAsync(@operator, r3Responses, executionOptions, ct); var round3 = CreateRound(3, "Final Improved Responses", - "Models provide refined answers incorporating critiques.", r3Responses, r3Prompt, r3Ki, r3Op); + "Models provide refined answers incorporating critiques.", r3Responses, r3Prompt, r3Ki, r3Op, round3StartedAt); builder.AddRound(round3); onRoundCompleted?.Invoke(round3); - // ══════════ Round 4: Chairman Verdict ══════════ - if (maxRounds >= 4 && chairman is not null) + return await FinalizeAsync(builder, chairman, knowledgeKeeper, temperature, onRoundCompleted, ct); + } + + private static async Task FinalizeAsync( + DebateResultBuilder builder, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + float temperature, + Action? onRoundCompleted, + CancellationToken ct) + { + if (chairman is not null) try { + var round4StartedAt = DateTime.UtcNow; var finalVerdict = await Chairman.SynthesizeVerdictAsync( - chairman, context, builder.Rounds, knowledgeKeeper, temperature, ct); + chairman, builder.Context, builder.Rounds, knowledgeKeeper, temperature, ct); builder.SetFinalVerdict(finalVerdict); var round4 = CreateRound(4, "Chairman Verdict", "The Chairman synthesises the final verdict.", - new Dictionary { [chairman.DisplayName] = finalVerdict }); + new Dictionary { [chairman.DisplayName] = finalVerdict }, + startedAt: round4StartedAt); builder.AddRound(round4); onRoundCompleted?.Invoke(round4); } @@ -186,11 +201,6 @@ and synthesise the most comprehensive response possible. builder.SetFinalVerdict($"[CHAIRMAN ERROR: {ex.Message}]"); } - return BuildAndComplete(builder); - } - - private static DebateResult BuildAndComplete(DebateResultBuilder builder) - { builder.MarkCompleted(); return builder.Build(); } diff --git a/src/Delibera.Core/Delibera.Core.csproj b/src/Delibera.Core/Delibera.Core.csproj index c74dbe6..6568d80 100644 --- a/src/Delibera.Core/Delibera.Core.csproj +++ b/src/Delibera.Core/Delibera.Core.csproj @@ -12,21 +12,21 @@ Delibera.Core - 10.2.0 + 10.2.2 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, Microsoft.Extensions.Logging, response-language enforcement, and parallel Operator requests. + 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, Microsoft.Extensions.Logging, Microsoft.Extensions.Http.Resilience (Polly v8 retry pipelines), IHttpClientFactory wiring, response-language enforcement, and parallel Operator requests. Victor Buzin Techbuzzz Delibera Copyright © 2026 Techbuzzz - llm;ai;debate;council;deliberation;rag;qdrant;pgvector;ollama;openai;vector-search;embeddings;dependency-injection + llm;ai;debate;council;deliberation;rag;qdrant;pgvector;ollama;openai;vector-search;embeddings;dependency-injection;resilience;polly;retry;httpclient README.md icon.png MIT https://github.com/techbuzzz/Delibera git https://github.com/techbuzzz/Delibera - v10.2.0 — Microsoft.Extensions.Logging support (inject ILogger/ILoggerFactory), response-language enforcement (WithResponseLanguage/ResponseLanguage), parallel Operator requests (WithMaxDegreeOfParallelism/MaxDegreeOfParallelism), renamed LogLevel enum to ExecutionLogLevel to avoid clash with Microsoft.Extensions.Logging.LogLevel. Fully backward compatible (legacy ExecuteAsync overloads still work). + v10.2.2 — Microsoft.Extensions.Http.Resilience integration (Polly v8 ResiliencePipeline): every HTTP-backed provider (Ollama, YandexGPT, MCP HTTP transport) now flows through IHttpClientFactory with a keyed Polly v8 retry pipeline. Hand-rolled retry loops in providers have been removed in favour of named pipelines ("Delibera.Local", "Delibera.Cloud", "Delibera.Default") registered via AddDelibera(). Configure via CouncilOptions.Resilience (MaxRetryAttempts, BaseDelay, MaxDelay, BackoffType, UseJitter, RetryableStatusCodes) or register custom named pipelines via AddDeliberaResiliencePipeline. New AddDeliberaHttpClient / WithHttpClientName() helpers for consumer wiring. Fully backward compatible: legacy constructors without a factory still work (they create a default HttpClient without resilience). true snupkg true @@ -39,15 +39,17 @@ - - - - - - - - - + + + + + + + + + + + diff --git a/src/Delibera.Core/DependencyInjection/CouncilOptions.cs b/src/Delibera.Core/DependencyInjection/CouncilOptions.cs index ad9e5ad..06efe75 100644 --- a/src/Delibera.Core/DependencyInjection/CouncilOptions.cs +++ b/src/Delibera.Core/DependencyInjection/CouncilOptions.cs @@ -64,6 +64,83 @@ public sealed class CouncilOptions /// Output configuration options. public OutputOptions Output { get; set; } = new(); + + /// + /// Polly v8 resilience options applied to every HTTP-backed provider + /// (Ollama, YandexGPT, MCP HTTP transport) through the named pipelines + /// registered by AddDelibera. + /// + public ResilienceOptions Resilience { get; set; } = new(); +} + +/// +/// Configuration options for the Polly v8 resilience pipelines consumed by +/// Delibera's HTTP-backed providers. Bound from the +/// Delibera:Resilience configuration section. +/// +/// +/// +/// Delibera registers three named pipelines out of the box: +/// "Delibera.Local", "Delibera.Cloud", and +/// "Delibera.Default". Each one is a Polly v8 +/// ResiliencePipeline<HttpResponseMessage> built from +/// HttpRetryStrategyOptions. +/// The Local pipeline retries only connection-level failures (no status +/// code); the Cloud pipeline retries transient HTTP responses (429, 524, +/// 5xx) plus timeouts; the Default pipeline is an alias for whichever +/// of the two is more permissive. +/// +/// +/// Register custom pipelines with +/// services.AddDeliberaResiliencePipeline("MyKey", builder => ...) +/// and reference them from a provider by passing the same name to its +/// constructor. +/// +/// +public sealed class ResilienceOptions +{ + /// Default pipeline name used when a provider is constructed without an explicit name. + public const string DefaultPipelineName = "Delibera.Default"; + + /// Pipeline name used for Ollama-local / direct-on-host endpoints. + public const string LocalPipelineName = "Delibera.Local"; + + /// Pipeline name used for cloud-hosted LLM gateways (Ollama Cloud, Yandex Cloud, MCP HTTP). + public const string CloudPipelineName = "Delibera.Cloud"; + + /// Master switch — when false no retry pipeline is attached and HttpClients behave as plain . + public bool Enabled { get; set; } = true; + + /// Maximum number of retry attempts (the initial call counts as the first attempt). + public int MaxRetryAttempts { get; set; } = 3; + + /// Base delay used by the exponential back-off generator. + public TimeSpan BaseDelay { get; set; } = TimeSpan.FromSeconds(2); + + /// Upper bound on the back-off delay produced by the exponential generator. + public TimeSpan MaxDelay { get; set; } = TimeSpan.FromSeconds(30); + + /// Whether to apply random jitter to the back-off delay. + public bool UseJitter { get; set; } = true; + + /// + /// Back-off style — either "Exponential" (default), + /// "Linear", or "Constant". Case-insensitive. + /// + public string BackoffType { get; set; } = "Exponential"; + + /// + /// HTTP status codes (e.g. 429, 500, 524) that should be retried on the + /// cloud pipeline. The local pipeline always retries only when the request has no status code + /// (i.e. connection-level failure). Defaults to { 408, 429, 500, 502, 503, 504, 524 }. + /// + public int[] RetryableStatusCodes { get; set; } = [408, 429, 500, 502, 503, 504, 524]; + + /// + /// Per-attempt timeout applied inside the pipeline (in addition to the outer HttpClient + /// timeout). Set to (or TimeSpan.Zero) to disable. + /// + public TimeSpan AttemptTimeout { get; set; } = TimeSpan.Zero; } /// diff --git a/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs b/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs index 1179381..2a61383 100644 --- a/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs @@ -3,10 +3,14 @@ using Delibera.Core.Providers; using Delibera.Core.Providers.LLM; using Delibera.Core.Providers.RAG; +using Delibera.Core.Resilience; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Http; +using Microsoft.Extensions.Http.Resilience; +using Polly; namespace Delibera.Core.DependencyInjection; @@ -16,7 +20,7 @@ namespace Delibera.Core.DependencyInjection; public static class ServiceCollectionExtensions { /// - /// Registers core Delibera services with default options. + /// Registers core Delibera services with default options (no IHttpClientFactory, no resilience). /// /// The service collection. /// The service collection for chaining. @@ -28,6 +32,7 @@ public static class ServiceCollectionExtensions /// (singleton) /// (transient) /// + /// To enable Polly v8 retry pipelines call AddDeliberaResilience(IServiceCollection) after this method. /// public static IServiceCollection AddDelibera(this IServiceCollection services) { @@ -42,10 +47,6 @@ public static IServiceCollection AddDelibera(this IServiceCollection services) /// /// Registers core Delibera services and binds from configuration. /// - /// The service collection. - /// Configuration root or section containing council settings. - /// Configuration section name (default: "Delibera"). - /// The service collection for chaining. public static IServiceCollection AddDelibera( this IServiceCollection services, IConfiguration configuration, @@ -56,15 +57,19 @@ public static IServiceCollection AddDelibera( var section = configuration.GetSection(sectionName); if (section.Exists()) services.Configure(section); + // ResilienceOptions is a sub-section; bind it independently so + // IOptionsMonitor gets a typed configuration that + // AddDeliberaResilience can read. + var resilienceSection = configuration.GetSection($"{sectionName}:Resilience"); + if (resilienceSection.Exists()) + services.Configure(resilienceSection); + return services; } /// /// Registers core Delibera services with a custom options configuration delegate. /// - /// The service collection. - /// Delegate to configure . - /// The service collection for chaining. public static IServiceCollection AddDelibera( this IServiceCollection services, Action configureOptions) @@ -83,11 +88,6 @@ public static IServiceCollection AddDelibera( /// logs to the host's pipeline (console, file, OpenTelemetry, …) in addition to the /// in-memory collection. /// - /// The service collection. - /// Configuration root or section. - /// Host logger factory. - /// Configuration section name (default: "Delibera"). - /// The service collection for chaining. public static IServiceCollection AddDelibera( this IServiceCollection services, IConfiguration configuration, @@ -114,18 +114,139 @@ public static IServiceCollection AddDelibera( } /// - /// Registers a Microsoft.Extensions.AI and exposes it as a Delibera - /// (). + /// Registers together with named Polly v8 + /// retry pipelines (Delibera.Local, Delibera.Cloud, Delibera.Default). + /// Also registers named entries that wire each pipeline into the + /// HttpClient handler chain via the standard Microsoft.Extensions.Http.Resilience AddResilienceHandler extension. /// + /// The service collection. + /// Optional delegate to override defaults. + /// The service collection for chaining. /// - /// Lets you wire any Microsoft.Extensions.AI backend (OpenAI, Azure OpenAI, Ollama, local - /// OpenAI-compatible servers) into the container and consume it through Delibera's provider - /// abstraction. The factory delegate may compose a middleware pipeline (function invocation, - /// logging, caching) before returning the client. + /// Call this after AddDelibera(...) and after binding . + /// The named HttpClients exposed are: + /// + /// Delibera.Ollama.Local / Delibera.Ollama.Cloud — base address must be set by the caller. + /// Delibera.YandexGPT + /// Delibera.Mcp.{ServerName} — registered lazily by the MCP factory. + /// /// + public static IServiceCollection AddDeliberaResilience( + this IServiceCollection services, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + + // Configure options if the caller supplied a delegate. + if (configure is not null) + services.Configure(configure); + + // Register the pipeline factory (and any consumer-supplied custom pipelines). + services.AddDeliberaResilienceCore(customPipelines: null); + + // Register the three built-in HttpClients with Polly resilience handlers attached. + AddNamedHttpClient(services, "Delibera.Ollama.Local", ResilienceOptions.LocalPipelineName); + AddNamedHttpClient(services, "Delibera.Ollama.Cloud", ResilienceOptions.CloudPipelineName); + AddNamedHttpClient(services, "Delibera.YandexGPT", ResilienceOptions.CloudPipelineName); + + return services; + } + + /// + /// Registers a single named whose handler chain is decorated with a + /// Polly v8 configured from + /// . + /// /// The service collection. - /// Factory that builds the chat client (optionally with middleware). - /// Optional friendly provider name surfaced by . + /// Logical client name (e.g. "Delibera.YandexGPT"). + /// + /// Pipeline key — currently used for telemetry only. The actual retry behaviour is + /// derived from at HttpClient creation time. + /// + /// Optional configuration delegate. + /// The for further chaining. + public static IHttpClientBuilder AddDeliberaHttpClient( + this IServiceCollection services, + string name, + string pipelineName = ResilienceOptions.DefaultPipelineName, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + var builder = services.AddHttpClient(name, configure ?? (_ => { })); + + builder.AddResilienceHandler(pipelineName, (pipelineBuilder, context) => + { + // Resolve the live ResilienceOptions snapshot so option changes are honoured. + var monitor = context.ServiceProvider.GetService>(); + var opts = monitor is not null + ? (pipelineName == ResilienceOptions.LocalPipelineName || pipelineName == ResilienceOptions.CloudPipelineName + ? monitor.Get(ResilienceOptions.DefaultPipelineName) + : monitor.CurrentValue) + : new ResilienceOptions(); + + if (!opts.Enabled) + return; // Empty pipeline = no retries. + + // The "Delibera.Local" pipeline retries only on connection-level failures; + // everything else (Cloud, Default, custom) retries on the configured status codes. + var statusCodes = pipelineName == ResilienceOptions.LocalPipelineName + ? null + : opts.RetryableStatusCodes is { Length: > 0 } ? opts.RetryableStatusCodes : null; + + var retry = new HttpRetryStrategyOptions + { + Name = pipelineName, + MaxRetryAttempts = Math.Max(0, opts.MaxRetryAttempts), + Delay = opts.BaseDelay, + MaxDelay = opts.MaxDelay, + BackoffType = ParseBackoffType(opts.BackoffType), + UseJitter = opts.UseJitter + }; + + if (statusCodes is null) + { + retry.ShouldHandle = new PredicateBuilder() + .Handle() + .Handle(); + } + else + { + var set = new HashSet(statusCodes); + retry.ShouldHandle = new PredicateBuilder() + .Handle() + .Handle() + .HandleResult(r => set.Contains((int)r.StatusCode)); + } + + pipelineBuilder.AddRetry(retry); + }); + + return builder; + } + + private static void AddNamedHttpClient(IServiceCollection services, string name, string pipelineName) + { + services.AddDeliberaHttpClient(name, pipelineName); + } + + private static DelayBackoffType ParseBackoffType(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return DelayBackoffType.Exponential; + return value.Trim().ToLowerInvariant() switch + { + "constant" => DelayBackoffType.Constant, + "linear" => DelayBackoffType.Linear, + _ => DelayBackoffType.Exponential + }; + } + + /// + /// Registers a Microsoft.Extensions.AI and exposes it as a Delibera + /// (). + /// public static IServiceCollection AddDeliberaChatClient( this IServiceCollection services, Func chatClientFactory, @@ -146,9 +267,6 @@ public static IServiceCollection AddDeliberaChatClient( /// Registers an already-constructed Microsoft.Extensions.AI and exposes it /// as a Delibera . /// - /// The service collection. - /// The chat client instance. - /// Optional friendly provider name. public static IServiceCollection AddDeliberaChatClient( this IServiceCollection services, IChatClient chatClient, @@ -162,10 +280,6 @@ public static IServiceCollection AddDeliberaChatClient( /// Registers a Microsoft.Extensions.AI and exposes /// it as a Delibera (). /// - /// The service collection. - /// Factory that builds the embedding generator. - /// Optional friendly model name. - /// Optional known vector dimensionality. public static IServiceCollection AddDeliberaEmbeddingGenerator( this IServiceCollection services, Func>> generatorFactory, diff --git a/src/Delibera.Core/Providers/LLM/OllamaProvider.cs b/src/Delibera.Core/Providers/LLM/OllamaProvider.cs index e82d401..3675f2a 100644 --- a/src/Delibera.Core/Providers/LLM/OllamaProvider.cs +++ b/src/Delibera.Core/Providers/LLM/OllamaProvider.cs @@ -1,3 +1,5 @@ +using Delibera.Core.DependencyInjection; +using Delibera.Core.Resilience; using Microsoft.Extensions.AI; using OllamaSharp; using OllamaSharp.Models; @@ -23,64 +25,121 @@ public enum OllamaConnectionMode /// Works with both a local Ollama server and Ollama Cloud. /// /// -/// Use for a local server (e.g. http://localhost:11434) and -/// for Ollama Cloud (e.g. https://api.ollama.com) with an API key. -/// The provider applies mode-appropriate retry/backoff: cloud calls retry transient -/// 524/429/5xx responses (Cloudflare origin timeouts) and local calls retry connection -/// failures. Both honour the user's . +/// +/// Use ForLocal for a local server (e.g. http://localhost:11434) and +/// ForCloud for Ollama Cloud (e.g. https://api.ollama.com) with an API key. +/// +/// +/// Transient failures are retried by a Polly v8 +/// obtained from the +/// registered in DI. +/// Local mode uses the +/// pipeline +/// (connection-level retries only); cloud mode uses +/// +/// (HTTP 408/429/500/502/503/504/524 retries + connection failures). +/// When constructed without DI the pipeline is a no-op — the legacy behaviour +/// before v10.2.2 was no retry at all, so the public surface is fully +/// backward-compatible. +/// /// public sealed class OllamaProvider : ILLMProvider { - private const int DefaultMaxAttempts = 3; private static readonly TimeSpan DefaultCloudTimeout = TimeSpan.FromMinutes(5); private static readonly TimeSpan DefaultLocalTimeout = TimeSpan.FromMinutes(10); + private readonly IHttpClientFactory? _httpClientFactory; + private readonly string? _httpClientName; + private readonly Polly.ResiliencePipeline? _pipeline; 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) + : this(endpoint, apiKey, timeout, httpClientFactory: null, resilienceProvider: null, + httpClientName: null, pipelineName: null, + mode: string.IsNullOrWhiteSpace(apiKey) ? OllamaConnectionMode.Local : OllamaConnectionMode.Cloud) + { + } + /// - /// Creates an Ollama provider. The mode is inferred from : - /// a non-empty key selects , otherwise - /// . Prefer / - /// for explicit control. + /// Creates an Ollama provider wired to an and a Polly v8 + /// resilience pipeline. When is null the + /// provider creates its own (no factory reuse, no resilience) — + /// this preserves the v10.2.x behaviour for callers that don't use DI. /// - /// Ollama endpoint URL (e.g. "https://api.ollama.com" or "http://localhost:11434"). - /// API key for Ollama Cloud (empty for local server). - /// Optional HTTP timeout. Defaults to 5 min for cloud, 10 min for local. - public OllamaProvider(string endpoint, string apiKey = "", TimeSpan? timeout = null) + /// Ollama endpoint URL. + /// API key (empty for local server). + /// HTTP timeout (null = default per mode). + /// Optional factory for handler pooling and socket reuse. + /// Optional pipeline registry (null = no retries). + /// 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). + public OllamaProvider( + string endpoint, + string apiKey, + TimeSpan? timeout, + IHttpClientFactory? httpClientFactory, + IDeliberaResiliencePipelineProvider? resilienceProvider, + string? httpClientName = null, + string? pipelineName = null, + OllamaConnectionMode? mode = null) { ArgumentException.ThrowIfNullOrWhiteSpace(endpoint); var uri = new Uri(endpoint.TrimEnd('/')); - Mode = string.IsNullOrWhiteSpace(apiKey) - ? OllamaConnectionMode.Local - : OllamaConnectionMode.Cloud; - - var effectiveTimeout = timeout ?? - (Mode == OllamaConnectionMode.Cloud - ? DefaultCloudTimeout - : DefaultLocalTimeout); - - var httpClient = new HttpClient { BaseAddress = uri, Timeout = effectiveTimeout }; - if (Mode == OllamaConnectionMode.Cloud) - httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey.Trim()}"); - - Client = new OllamaApiClient(httpClient); - } - - private OllamaProvider(Uri uri, string apiKey, OllamaConnectionMode mode, TimeSpan timeout) - { - Mode = mode; - var httpClient = new HttpClient { BaseAddress = uri, Timeout = timeout }; - if (mode == OllamaConnectionMode.Cloud) - httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey.Trim()}"); + Mode = mode ?? (string.IsNullOrWhiteSpace(apiKey) ? OllamaConnectionMode.Local : OllamaConnectionMode.Cloud); + var effectiveTimeout = timeout ?? (Mode == OllamaConnectionMode.Cloud ? DefaultCloudTimeout : DefaultLocalTimeout); + + var resolvedHttpClientName = !string.IsNullOrWhiteSpace(httpClientName) + ? httpClientName + : $"Delibera.Ollama.{Mode}"; + var resolvedPipelineName = !string.IsNullOrWhiteSpace(pipelineName) + ? pipelineName + : (Mode == OllamaConnectionMode.Cloud + ? ResilienceOptions.CloudPipelineName + : ResilienceOptions.LocalPipelineName); + + OllamaApiClient client; + if (httpClientFactory is not null) + { + // Build the HttpClient through the factory — its underlying + // handler pipeline already has the Polly resilience handler + // attached via AddResilienceHandler. The provider itself doesn't + // own this HttpClient (the factory does), so Dispose skips it. + _httpClientFactory = httpClientFactory; + _httpClientName = resolvedHttpClientName; + _pipeline = resilienceProvider?.GetOperationPipeline(resolvedPipelineName); + + var http = _httpClientFactory.CreateClient(resolvedHttpClientName); + http.BaseAddress ??= uri; + http.Timeout = effectiveTimeout; + if (Mode == OllamaConnectionMode.Cloud && !string.IsNullOrWhiteSpace(apiKey)) + http.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiKey.Trim()); + + client = new OllamaApiClient(http); + } + else + { + _httpClientFactory = null; + _httpClientName = null; + _pipeline = null; + + var httpClient = new HttpClient { BaseAddress = uri, Timeout = effectiveTimeout }; + if (Mode == OllamaConnectionMode.Cloud && !string.IsNullOrWhiteSpace(apiKey)) + httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey.Trim()}"); + client = new OllamaApiClient(httpClient); + } - Client = new OllamaApiClient(httpClient); + Client = client; } /// The connection mode this provider was configured with. public OllamaConnectionMode Mode { get; } - /// Provides access to the underlying OllamaSharp client (used by ). + /// + /// Provides access to the underlying OllamaSharp client (used by ). + /// internal OllamaApiClient Client { get; } /// @@ -139,108 +198,153 @@ public async Task ChatAsync( Options = new RequestOptions { Temperature = temperature } }; - var maxAttempts = Mode == OllamaConnectionMode.Cloud - ? DefaultMaxAttempts - : 2; - var delay = TimeSpan.FromSeconds(2); - - for (var attempt = 1;; attempt++) + // The chat operation is owned by OllamaSharp (it streams response chunks). + // When a Polly v8 pipeline is configured we wrap the entire streaming + // call: on a transient failure Polly cancels the current attempt and + // starts a fresh one — OllamaSharp re-streams from the beginning. This + // is the standard v8 pattern for non-idempotent-friendly streams, and + // matches the v10.2 hand-rolled behaviour (which also restarted the + // whole stream on a retry). + string? captured = null; + Func operation = async token => { var sb = new StringBuilder(); - try + await foreach (var chunk in Client.ChatAsync(request, token)) { - await foreach (var chunk in Client.ChatAsync(request, ct)) - { - if (chunk is not { Message.Content: { } content }) - continue; - sb.Append(content); - } - - var response = sb.ToString().Trim(); - if (string.IsNullOrWhiteSpace(response)) - throw new InvalidOperationException($"Empty response from model '{model}'."); - return response; - } - catch (HttpRequestException ex) - { - if (ct.IsCancellationRequested) throw; - if (attempt < maxAttempts && IsTransientHttp(ex, Mode)) - { - await Task.Delay(delay, ct); - delay = TimeSpan.FromSeconds(Math.Min(delay.TotalSeconds * 2, 30)); + if (chunk is not { Message.Content: { } content }) continue; - } + sb.Append(content); + } - throw new InvalidOperationException($"HTTP error talking to Ollama (model: {model}): {ex.Message}", ex); + var response = sb.ToString().Trim(); + if (string.IsNullOrWhiteSpace(response)) + throw new InvalidOperationException($"Empty response from model '{model}'."); + captured = response; + }; + + try + { + if (_pipeline is null) + { + await operation(ct).ConfigureAwait(false); + return captured ?? throw new InvalidOperationException($"Empty response from model '{model}'."); } - catch (TaskCanceledException) when (ct.IsCancellationRequested) + + // Build a context-scoped pipeline invocation. Polly v8 wraps + // exceptions in a ResilienceException only when the pipeline + // re-throws after exhausting retries; HttpRequestException and + // TaskCanceledException pass straight through unchanged. + var context = Polly.ResilienceContextPool.Shared.Get(ct); + try { - throw; + await _pipeline.ExecuteAsync( + static async (ctx, state) => + { + // Pipeline's ShouldHandle predicate already filters which + // exceptions (HttpRequestException, TaskCanceledException) + // trigger a retry. Anything else propagates immediately. + await state.Op(ctx.CancellationToken).ConfigureAwait(false); + }, + context, + new ChatState(operation)).ConfigureAwait(false); } - catch (TaskCanceledException ex) + finally { - if (attempt < maxAttempts) - { - await Task.Delay(delay, ct); - delay = TimeSpan.FromSeconds(Math.Min(delay.TotalSeconds * 2, 30)); - continue; - } - - throw new TimeoutException($"Request to Ollama model '{model}' timed out.", ex); + Polly.ResilienceContextPool.Shared.Return(context); } + + return captured ?? throw new InvalidOperationException($"Empty response from model '{model}'."); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (HttpRequestException ex) + { + throw new InvalidOperationException($"HTTP error talking to Ollama (model: {model}): {ex.Message}", ex); + } + catch (TimeoutException) + { + throw; } } /// - public void Dispose() - { - if (_disposed) return; - _disposed = true; - } + public void Dispose() + { + if (_disposed) return; + _disposed = true; + // When constructed via IHttpClientFactory, the factory owns the HttpClient. + // When constructed standalone, Client (which owns the HttpClient) is disposed here. + if (_httpClientFactory is null) + 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(new Uri(endpoint.TrimEnd('/')), "", OllamaConnectionMode.Local, - timeout ?? DefaultLocalTimeout); + 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). + /// 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(new Uri(endpoint.TrimEnd('/')), apiKey, OllamaConnectionMode.Cloud, - timeout ?? DefaultCloudTimeout); + return new OllamaProvider(endpoint, apiKey, timeout, null, null, + httpClientName: null, pipelineName: null, + mode: OllamaConnectionMode.Cloud); } /// - /// Determines whether an represents a transient failure - /// worth retrying. For this includes 524 origin - /// timeouts, 429 rate-limits and any 5xx. For only - /// connection-level failures (no status code) are retried — a local Ollama rarely returns - /// 5xx, and when it does it's usually a model-load error that won't fix itself. + /// Creates a DI-friendly local Ollama provider with handler pooling and the local retry pipeline. /// - private static bool IsTransientHttp(HttpRequestException ex, OllamaConnectionMode mode) + public static OllamaProvider ForLocal( + string endpoint, + IHttpClientFactory httpClientFactory, + IDeliberaResiliencePipelineProvider resilienceProvider, + string? httpClientName = null, + string? pipelineName = null, + TimeSpan? timeout = null) { - var code = ex.StatusCode; - if (code is null) return true; - if (mode == OllamaConnectionMode.Local) return false; + ArgumentException.ThrowIfNullOrWhiteSpace(endpoint); + ArgumentNullException.ThrowIfNull(httpClientFactory); + ArgumentNullException.ThrowIfNull(resilienceProvider); + return new OllamaProvider(endpoint, "", timeout, + httpClientFactory, resilienceProvider, + httpClientName, pipelineName, + mode: OllamaConnectionMode.Local); + } - var i = (int)code; - return i == 429 || i == 524 || (i >= 500 && i < 600); + /// + /// 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); } /// /// Exposes the underlying OllamaSharp client as a Microsoft.Extensions.AI /// . /// - /// - /// natively implements , so this lets - /// the Ollama provider plug into the standard Microsoft.Extensions.AI middleware pipeline - /// (function invocation, caching, telemetry). - /// public IChatClient AsChatClient() { return Client; @@ -253,4 +357,6 @@ public IEmbeddingGenerator> AsEmbeddingGenerator() { return Client; } -} \ No newline at end of file + + private sealed record ChatState(Func Op); +} diff --git a/src/Delibera.Core/Providers/LLM/YandexGptProvider.cs b/src/Delibera.Core/Providers/LLM/YandexGptProvider.cs index cbf720c..2d75b00 100644 --- a/src/Delibera.Core/Providers/LLM/YandexGptProvider.cs +++ b/src/Delibera.Core/Providers/LLM/YandexGptProvider.cs @@ -1,6 +1,7 @@ using System.Net.Http.Headers; using System.Text.Json; using System.Text.Json.Serialization; +using Delibera.Core.Resilience; namespace Delibera.Core.Providers.LLM; @@ -10,10 +11,20 @@ namespace Delibera.Core.Providers.LLM; /// as a council member or chairman alongside Ollama / OpenAI models. /// /// -/// Supports both the OpenAI Responses-compatible gateway -/// (https://ai.api.cloud.yandex.net/v1/responses) and the legacy -/// Foundation Models endpoint. The gateway is selected automatically -/// when FolderId is non-empty. +/// +/// Supports both the OpenAI Responses-compatible gateway +/// (https://ai.api.cloud.yandex.net/v1/responses) and the legacy +/// Foundation Models endpoint. The gateway is selected automatically +/// when FolderId is non-empty. +/// +/// +/// Transient failures (connection drops, 429/5xx, Cloudflare 524) are +/// retried by a Polly v8 resilience pipeline obtained from the +/// registered in DI +/// (default pipeline: Delibera.Cloud). +/// When the provider is constructed without DI the pipeline is a no-op +/// — the same behavior as the previous manual-error path. +/// /// public sealed class YandexGptProvider : ILLMProvider { @@ -28,12 +39,16 @@ public sealed class YandexGptProvider : ILLMProvider private readonly string _endpoint; private readonly string _folderId; private readonly HttpClient _http; + private readonly Polly.ResiliencePipeline? _pipeline; + private readonly IHttpClientFactory? _httpClientFactory; + private readonly string? _httpClientName; private readonly string _legacyEndpoint; private readonly int _maxOutputTokens; private bool _disposed; /// - /// Creates a YandexGPT provider. + /// Creates a YandexGPT provider with no resilience pipeline. Errors + /// propagate directly to the caller — backward-compatible with v10.2. /// /// Yandex Cloud API key. /// Yandex Cloud folder id (empty for legacy Bearer endpoint). @@ -48,6 +63,48 @@ public YandexGptProvider( string legacyEndpoint = "https://llm.api.cloud.yandex.net/foundationModels/v1/completion", float temperature = 0.3f, int maxOutputTokens = 4000) + : this(apiKey, folderId, endpoint, legacyEndpoint, temperature, maxOutputTokens, + httpClientFactory: null, resilienceProvider: null, httpClientName: null, pipelineName: null) + { + } + + /// + /// Creates a YandexGPT provider wired to an and a Polly v8 + /// . Transient failures are retried automatically. + /// + /// Yandex Cloud API key. + /// Yandex Cloud folder id (empty for legacy Bearer endpoint). + /// OpenAI-compatible responses endpoint. + /// Legacy Foundation Models completion endpoint. + /// Default sampling temperature. + /// Maximum output tokens per request. + /// + /// Optional factory that produces the configured . + /// When null the provider creates its own HttpClient (no factory reuse, no resilience). + /// + /// + /// Optional pipeline registry. When null the provider falls back to a no-op pipeline + /// (errors propagate without retries). + /// + /// + /// Logical name used with . When null defaults to + /// "Delibera.YandexGPT". + /// + /// + /// Pipeline key looked up from . + /// When null defaults to Delibera.Cloud. + /// + public YandexGptProvider( + string apiKey, + string? folderId, + string endpoint, + string legacyEndpoint, + float temperature, + int maxOutputTokens, + IHttpClientFactory? httpClientFactory, + IDeliberaResiliencePipelineProvider? resilienceProvider, + string? httpClientName = null, + string? pipelineName = null) { ArgumentException.ThrowIfNullOrWhiteSpace(apiKey); @@ -58,6 +115,26 @@ public YandexGptProvider( _maxOutputTokens = maxOutputTokens; _ = temperature; // accepted for API compatibility; per-call temperature is passed to ChatAsync + var name = string.IsNullOrWhiteSpace(httpClientName) ? "Delibera.YandexGPT" : httpClientName; + var pipeline = resilienceProvider?.GetPipeline(pipelineName) ?? Polly.ResiliencePipeline.Empty; + + if (httpClientFactory is not null) + { + _httpClientFactory = httpClientFactory; + _httpClientName = name; + _pipeline = pipeline; + // Lazily resolved through HttpClientFactory inside ChatAsync so the + // factory owns the handler/timeout/lifetime. We still hold a local + // "anchor" HttpClient for IsAvailableAsync (liveness probe) and as + // fallback when no factory is registered. + } + else + { + _httpClientFactory = null; + _httpClientName = null; + _pipeline = null; + } + _http = new HttpClient { Timeout = TimeSpan.FromMinutes(5) }; // Use the new OpenAI-compatible gateway when a folder id is configured. @@ -89,7 +166,7 @@ public async Task IsAvailableAsync(CancellationToken ct = default) using var request = new HttpRequestMessage(HttpMethod.Get, UseNewEndpoint ? _endpoint : _legacyEndpoint); - using var response = await _http.SendAsync(request, ct).ConfigureAwait(false); + using var response = await SendAsync(request, ct).ConfigureAwait(false); return response.IsSuccessStatusCode; } catch @@ -172,7 +249,7 @@ public async Task ChatAsync( foreach (var h in headers) request.Headers.TryAddWithoutValidation(h.Key, h.Value); - using var response = await _http.SendAsync(request, ct).ConfigureAwait(false); + using var response = await SendAsync(request, ct).ConfigureAwait(false); var content = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); if (!response.IsSuccessStatusCode) @@ -209,11 +286,28 @@ public async Task ChatAsync( /// public void Dispose() - { - if (_disposed) return; - _disposed = true; - _http.Dispose(); - } + { + if (_disposed) return; + _disposed = true; + if (_httpClientFactory is null) + _http.Dispose(); + } + + /// + /// Issues the request through either the DI-managed HttpClient (when configured) or the + /// locally-owned . Retries are applied only when the DI path is active. + /// + private async Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + if (_httpClientFactory is not null && _pipeline is not null) + { + var client = _httpClientFactory.CreateClient(_httpClientName!); + return await client.SendAsync(_pipeline, request, HttpCompletionOption.ResponseContentRead, ct) + .ConfigureAwait(false); + } + + return await _http.SendAsync(request, ct).ConfigureAwait(false); + } private void AddAuthHeaders(HttpRequestHeaders headers) { diff --git a/src/Delibera.Core/Providers/Mcp/McpClientAdapter.cs b/src/Delibera.Core/Providers/Mcp/McpClientAdapter.cs index 172bb75..e831ae5 100644 --- a/src/Delibera.Core/Providers/Mcp/McpClientAdapter.cs +++ b/src/Delibera.Core/Providers/Mcp/McpClientAdapter.cs @@ -1,3 +1,5 @@ +using Delibera.Core.Resilience; +using Microsoft.Extensions.Logging; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -8,20 +10,57 @@ namespace Delibera.Core.Providers.Mcp; /// ModelContextProtocol C# SDK. Supports both stdio (local child process) and /// HTTP/SSE (remote) MCP servers, selected via . /// +/// +/// +/// When constructed through DI the adapter wires its HTTP transport into +/// the host's (logical name +/// "Delibera.Mcp.{ServerName}") so retries/circuit-breakers configured +/// via Microsoft.Extensions.Http.Resilience apply to MCP traffic. +/// When constructed standalone the transport owns its own +/// without resilience. +/// +/// public sealed class McpClientAdapter : IMcpClient { private readonly McpServerConfig _config; + private readonly IHttpClientFactory? _httpClientFactory; + private readonly string? _httpClientName; private McpClient? _client; private bool _disposed; - /// Creates an adapter for the given MCP server configuration. + /// Creates a standalone adapter for the given MCP server configuration (no DI, no resilience). /// Server connection configuration. public McpClientAdapter(McpServerConfig config) + : this(config, httpClientFactory: null, httpClientName: null, loggerFactory: null) + { + } + + /// Creates an adapter that routes its HTTP transport through an . + /// Server connection configuration. + /// Factory that produces the configured . + /// + /// Logical client name (default: Delibera.Mcp.{ServerName}). + /// The host must register the client with AddHttpClient(name) and any + /// resilience handlers attached via AddResilienceHandler. + /// + /// Optional logger factory passed to the MCP transport. + public McpClientAdapter( + McpServerConfig config, + IHttpClientFactory? httpClientFactory, + string? httpClientName = null, + ILoggerFactory? loggerFactory = null) { _config = config ?? throw new ArgumentNullException(nameof(config)); ArgumentException.ThrowIfNullOrWhiteSpace(config.Name); + _httpClientFactory = httpClientFactory; + _httpClientName = string.IsNullOrWhiteSpace(httpClientName) + ? $"Delibera.Mcp.{config.Name}" + : httpClientName; + LoggerFactory = loggerFactory; } + private ILoggerFactory? LoggerFactory { get; } + /// public string ServerName => _config.Name; @@ -33,7 +72,7 @@ public async Task ConnectAsync(CancellationToken ct = default) { if (_client is not null) return; - var transport = CreateTransport(_config); + var transport = CreateTransport(_config, _httpClientFactory, _httpClientName, LoggerFactory); _client = await McpClient.CreateAsync(transport, cancellationToken: ct); } @@ -112,12 +151,16 @@ private static string SchemaToJson(McpClientTool tool) } } - private static IClientTransport CreateTransport(McpServerConfig config) + private static IClientTransport CreateTransport( + McpServerConfig config, + IHttpClientFactory? factory, + string? httpClientName, + ILoggerFactory? loggerFactory) { return config.TransportType switch { McpTransportType.Stdio => CreateStdioTransport(config), - McpTransportType.Http => CreateHttpTransport(config), + McpTransportType.Http => CreateHttpTransport(config, factory, httpClientName, loggerFactory), _ => throw new NotSupportedException($"Unsupported MCP transport: {config.TransportType}") }; } @@ -143,7 +186,11 @@ private static StdioClientTransport CreateStdioTransport(McpServerConfig config) return new StdioClientTransport(options); } - private static HttpClientTransport CreateHttpTransport(McpServerConfig config) + private static HttpClientTransport CreateHttpTransport( + McpServerConfig config, + IHttpClientFactory? factory, + string? httpClientName, + ILoggerFactory? loggerFactory) { if (config.Endpoint is null) throw new InvalidOperationException( @@ -159,6 +206,17 @@ private static HttpClientTransport CreateHttpTransport(McpServerConfig config) options.AdditionalHeaders = config.AdditionalHeaders .ToDictionary(kv => kv.Key, kv => kv.Value); - return new HttpClientTransport(options); + if (factory is not null && !string.IsNullOrWhiteSpace(httpClientName)) + { + // Inject the DI-managed HttpClient (with its resilience pipeline + // attached via AddResilienceHandler). The transport disposes the + // HttpClient on shutdown because ownsHttpClient=true. + var httpClient = factory.CreateClient(httpClientName); + return new HttpClientTransport(options, httpClient, loggerFactory, ownsHttpClient: true); + } + + return loggerFactory is null + ? new HttpClientTransport(options) + : new HttpClientTransport(options, loggerFactory); } } \ No newline at end of file diff --git a/src/Delibera.Core/README.md b/src/Delibera.Core/README.md index 711f2b4..0442934 100644 --- a/src/Delibera.Core/README.md +++ b/src/Delibera.Core/README.md @@ -36,6 +36,7 @@ outcomes** rather than single-model guesses. - 💉 **Dependency Injection** — `AddDelibera()` extension for `IServiceCollection` with full options binding - 📋 **Execution Logging** — `ExecutionLog` model with `ExecutionLogLevel` for Chairman, KK, Compression & participants - 📝 **Microsoft.Extensions.Logging** — inject your own `ILogger`/`ILoggerFactory`; every debate event is forwarded to the host's logging pipeline (in addition to the in-memory `ExecutionLog` collection) +- 🛡️ **Polly v8 Resilience** — `IHttpClientFactory` + named `Microsoft.Extensions.Http.Resilience` retry pipelines (`Delibera.Local`, `Delibera.Cloud`, `Delibera.Default`) for transient HTTP failures (connection drops, 408/429/5xx, Cloudflare 524). Configure via `CouncilOptions.Resilience`. Custom pipelines registerable through `AddDeliberaResiliencePipeline(name, build)`. - 🌐 **Response Language Enforcement** — force every model (participants, Chairman, Knowledge Keeper, Operator) to answer in a specific language, regardless of the prompt or retrieved context - ⚡ **Parallel Operator Requests** — `[[OPERATOR: …]]` tasks delegated within a round run in parallel, bounded by `MaxDegreeOfParallelism` - 📁 **Separate File Output** — export `result.md`, `statistics.md`, `logs.md` independently diff --git a/src/Delibera.Core/Resilience/DeliberaResiliencePipelineProvider.cs b/src/Delibera.Core/Resilience/DeliberaResiliencePipelineProvider.cs new file mode 100644 index 0000000..3cd57f6 --- /dev/null +++ b/src/Delibera.Core/Resilience/DeliberaResiliencePipelineProvider.cs @@ -0,0 +1,233 @@ +using Delibera.Core.DependencyInjection; +using Microsoft.Extensions.Options; +using Polly; +using Polly.Retry; + +namespace Delibera.Core.Resilience; + +/// +/// Central registry that builds the named Polly v8 +/// instances consumed by every +/// HTTP-backed Delibera provider. +/// +/// +/// +/// The factory reads its configuration from +/// (typically bound from the +/// Delibera:Resilience configuration section). It exposes +/// three pipelines out of the box: +/// +/// — retries only connection-level failures. +/// — retries transient HTTP responses (429, 524, 5xx) plus timeouts. +/// — convenience alias for the cloud pipeline (the more permissive of the two). +/// +/// +/// +/// Consumers can register additional named pipelines through +/// services.AddDeliberaResiliencePipeline("MyKey", b => …); the +/// factory merges them into its lookup table on first use. +/// +/// +public interface IDeliberaResiliencePipelineProvider +{ + /// + /// Returns the Polly v8 HTTP-level pipeline registered under . + /// Falls back to the default pipeline when is + /// null, empty, or unknown. + /// + /// + /// The pipeline key (one of the constants on , + /// or a custom name registered via + /// AddDeliberaResiliencePipeline). + /// + /// A non-null pipeline. Never throws on missing keys. + ResiliencePipeline GetPipeline(string? name); + + /// + /// Returns a Polly v8 non-generic operation-level pipeline (for use-cases where the + /// operation is a unit of work that doesn't produce an + /// result — e.g. OllamaProvider.ChatAsync streams text from OllamaSharp). + /// Shares the same configuration as but with no result-typed + /// retry predicates — only exception types (, + /// ) trigger retries. + /// + /// Pipeline key. + /// A non-generic or null when disabled. + ResiliencePipeline? GetOperationPipeline(string? name); +} + +/// +/// Default implementation of . +/// +public sealed class DeliberaResiliencePipelineProvider : IDeliberaResiliencePipelineProvider +{ + private readonly IOptionsMonitor _options; + private readonly Dictionary, ResiliencePipeline>> _customBuilders; + private readonly Dictionary> _built; + private readonly Dictionary _builtOperation; + private readonly object _gate = new(); + + /// Builds the provider from DI options + custom pipeline factories. + /// Bound resilience configuration. + /// Optional consumer-registered pipelines (may be null). + public DeliberaResiliencePipelineProvider( + IOptionsMonitor options, + IEnumerable, ResiliencePipeline>>>? customPipelines = null) + { + ArgumentNullException.ThrowIfNull(options); + _options = options; + _customBuilders = new Dictionary, ResiliencePipeline>>(StringComparer.OrdinalIgnoreCase); + _built = new Dictionary>(StringComparer.OrdinalIgnoreCase); + _builtOperation = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (customPipelines is not null) + foreach (var (name, build) in customPipelines) + if (!string.IsNullOrWhiteSpace(name) && build is not null) + _customBuilders[name] = build; + } + + /// + public ResiliencePipeline GetPipeline(string? name) + { + var key = string.IsNullOrWhiteSpace(name) + ? ResilienceOptions.DefaultPipelineName + : name; + + if (_customBuilders.ContainsKey(key)) + return GetOrBuildCustom(key); + + return key switch + { + ResilienceOptions.LocalPipelineName => GetOrBuildBuilt(ResilienceOptions.LocalPipelineName, BuildLocal), + ResilienceOptions.CloudPipelineName => GetOrBuildBuilt(ResilienceOptions.CloudPipelineName, BuildCloud), + _ => GetOrBuildBuilt(ResilienceOptions.DefaultPipelineName, BuildCloud) + }; + } + + private ResiliencePipeline GetOrBuildCustom(string key) + { + lock (_gate) + { + if (_built.TryGetValue(key, out var cached)) + return cached; + var builder = new ResiliencePipelineBuilder(); + var pipeline = _customBuilders[key](builder); + _built[key] = pipeline; + return pipeline; + } + } + + private ResiliencePipeline GetOrBuildBuilt(string key, Func> build) + { + lock (_gate) + { + if (_built.TryGetValue(key, out var cached)) + return cached; + var opts = _options.Get(key); + var pipeline = build(opts); + _built[key] = pipeline; + return pipeline; + } + } + + private static ResiliencePipeline BuildLocal(ResilienceOptions opts) + { + if (!opts.Enabled) + return ResiliencePipeline.Empty; + + var retry = new RetryStrategyOptions + { + MaxRetryAttempts = Math.Max(0, opts.MaxRetryAttempts), + Delay = opts.BaseDelay, + MaxDelay = opts.MaxDelay, + BackoffType = ParseBackoffType(opts.BackoffType), + UseJitter = opts.UseJitter, + // Local pipeline: retry only when the request never produced a + // response (connection-level failure or socket timeout). + ShouldHandle = new PredicateBuilder() + .Handle() + .Handle() + }; + + return new ResiliencePipelineBuilder() + .AddRetry(retry) + .Build(); + } + + private ResiliencePipeline BuildCloud(ResilienceOptions opts) + { + if (!opts.Enabled) + return ResiliencePipeline.Empty; + + var set = new HashSet(opts.RetryableStatusCodes is { Length: > 0 } + ? opts.RetryableStatusCodes + : DefaultCloudStatusCodes); + + var retry = new RetryStrategyOptions + { + MaxRetryAttempts = Math.Max(0, opts.MaxRetryAttempts), + Delay = opts.BaseDelay, + MaxDelay = opts.MaxDelay, + BackoffType = ParseBackoffType(opts.BackoffType), + UseJitter = opts.UseJitter, + ShouldHandle = new PredicateBuilder() + .Handle() + .Handle() + .HandleResult(r => set.Contains((int)r.StatusCode)) + }; + + return new ResiliencePipelineBuilder() + .AddRetry(retry) + .Build(); + } + + private static readonly int[] DefaultCloudStatusCodes = [408, 429, 500, 502, 503, 504, 524]; + + /// + public ResiliencePipeline? GetOperationPipeline(string? name) + { + var key = string.IsNullOrWhiteSpace(name) ? ResilienceOptions.DefaultPipelineName : name; + lock (_gate) + { + if (_builtOperation.TryGetValue(key, out var cached)) + return cached; + var opts = _options.Get(key); + var pipeline = BuildOperationPipeline(opts); + _builtOperation[key] = pipeline; + return pipeline; + } + } + + private static ResiliencePipeline BuildOperationPipeline(ResilienceOptions opts) + { + if (!opts.Enabled) + return ResiliencePipeline.Empty; + + var retry = new RetryStrategyOptions + { + MaxRetryAttempts = Math.Max(0, opts.MaxRetryAttempts), + Delay = opts.BaseDelay, + MaxDelay = opts.MaxDelay, + BackoffType = ParseBackoffType(opts.BackoffType), + UseJitter = opts.UseJitter, + ShouldHandle = new PredicateBuilder() + .Handle() + .Handle() + }; + + return new ResiliencePipelineBuilder() + .AddRetry(retry) + .Build(); + } + + private static DelayBackoffType ParseBackoffType(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return DelayBackoffType.Exponential; + return value.Trim().ToLowerInvariant() switch + { + "constant" => DelayBackoffType.Constant, + "linear" => DelayBackoffType.Linear, + _ => DelayBackoffType.Exponential + }; + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Resilience/DeliberaResilienceServiceCollectionExtensions.cs b/src/Delibera.Core/Resilience/DeliberaResilienceServiceCollectionExtensions.cs new file mode 100644 index 0000000..59c4fda --- /dev/null +++ b/src/Delibera.Core/Resilience/DeliberaResilienceServiceCollectionExtensions.cs @@ -0,0 +1,109 @@ +using Delibera.Core.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Polly; + +namespace Delibera.Core.Resilience; + +/// +/// Convenience extensions for registering Polly v8 resilience pipelines +/// with the Delibera DI container. +/// +public static class DeliberaResilienceServiceCollectionExtensions +{ + /// + /// Registers the singleton + /// and the consumer-supplied named pipelines alongside it. + /// + /// + /// Internal-use overload — the public surface is ServiceCollectionExtensions.AddDeliberaResilience. + /// + public static IServiceCollection AddDeliberaResilienceCore( + this IServiceCollection services, + IEnumerable, ResiliencePipeline>>>? customPipelines = null) + { + ArgumentNullException.ThrowIfNull(services); + + // Eagerly register the mutable collection so each + // AddDeliberaResiliencePipeline call can mutate the same instance. + services.TryAddSingleton(); + + services.TryAddSingleton(sp => + { + var monitor = sp.GetRequiredService>(); + var collection = sp.GetRequiredService(); + var sequences = new List, ResiliencePipeline>>>(collection.Snapshot()); + if (customPipelines is not null) + foreach (var kvp in customPipelines) + sequences.Add(kvp); + return new DeliberaResiliencePipelineProvider(monitor, sequences); + }); + + return services; + } + + /// + /// Registers a single Polly v8 pipeline under . + /// + /// The service collection. + /// + /// Pipeline key. When the same name matches one of the built-in keys + /// ( etc.) the + /// consumer pipeline replaces the built-in for that key. + /// + /// + /// Factory delegate that configures a and + /// returns the resulting pipeline. + /// + /// The service collection for chaining. + public static IServiceCollection AddDeliberaResiliencePipeline( + this IServiceCollection services, + string name, + Func, ResiliencePipeline> build) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(build); + + // Run during service-provider build so the collection singleton has + // been resolved and can be mutated. We use a marker singleton of + // ; its factory + // receives the collection and appends to it. + services.AddSingleton(sp => + { + var collection = sp.GetRequiredService(); + collection.Add(name, build); + return new DeliberaResiliencePipelineRegistration(name); + }); + return services; + } +} + +/// +/// Marker singleton returned by . +/// Side-effect (mutating the shared collection) is the goal; the instance is discarded. +/// +internal sealed record DeliberaResiliencePipelineRegistration(string Name); + +/// +/// Internal mutable collection of consumer-registered resilience pipelines. +/// Held as a singleton so multiple +/// calls accumulate their entries before the provider factory consumes them. +/// +internal sealed class DeliberaResiliencePipelineCollection +{ + private readonly object _gate = new(); + private readonly List, ResiliencePipeline>>> _entries = []; + + public void Add(string name, Func, ResiliencePipeline> build) + { + lock (_gate) + _entries.Add(new(name, build)); + } + + public IReadOnlyList, ResiliencePipeline>>> Snapshot() + { + lock (_gate) + return _entries.ToList(); + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Resilience/ResilientHttpClientExtensions.cs b/src/Delibera.Core/Resilience/ResilientHttpClientExtensions.cs new file mode 100644 index 0000000..23acb2a --- /dev/null +++ b/src/Delibera.Core/Resilience/ResilientHttpClientExtensions.cs @@ -0,0 +1,82 @@ +using Polly; + +namespace Delibera.Core.Resilience; + +/// +/// Static helpers for running calls through a Polly v8 +/// . +/// +/// +/// Polly v8 works at the callback level rather than the transport level: the +/// pipeline is asked to ExecuteAsync a delegate that issues a single +/// HTTP request. The Delibera providers use this helper to opt-in to the +/// named pipeline registered through . +/// +public static class ResilientHttpClientExtensions +{ + /// + /// Sends a request through with retry semantics. + /// Cancellation is wired through both the + /// argument and the Polly v8 + /// . + /// + /// The HttpClient that owns the underlying transport. + /// + /// The Polly v8 pipeline. Pass + /// to disable resilience. + /// + /// The request message to send. + /// How the response content should be read. + /// Cancellation token. + /// The HTTP response message from the last (successful or final) attempt. + public static async Task SendAsync( + this HttpClient http, + ResiliencePipeline pipeline, + HttpRequestMessage request, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(http); + ArgumentNullException.ThrowIfNull(pipeline); + ArgumentNullException.ThrowIfNull(request); + + var context = ResilienceContextPool.Shared.Get(ct); + try + { + Func> callback = async (ctx, state) => + { + var token = ctx.CancellationToken; + // Clone the request for each attempt — HttpClient.SendAsync + // disposes the request on the first attempt, leaving retries + // unable to re-send. + var clone = await CloneAsync(state.Request, token).ConfigureAwait(false); + return await state.Http.SendAsync(clone, state.CompletionOption, token).ConfigureAwait(false); + }; + + return await pipeline.ExecuteAsync(callback, context, new HttpState(http, request, completionOption)).ConfigureAwait(false); + } + finally + { + ResilienceContextPool.Shared.Return(context); + } + } + + private static async ValueTask CloneAsync(HttpRequestMessage source, CancellationToken ct) + { + var clone = new HttpRequestMessage(source.Method, source.RequestUri); + if (source.Content is not null) + { + var buffer = await source.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false); + clone.Content = new ByteArrayContent(buffer); + foreach (var header in source.Content.Headers) + clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + foreach (var header in source.Headers) + clone.Headers.TryAddWithoutValidation(header.Key, header.Value); + foreach (var prop in source.Options) + clone.Options.Set(new HttpRequestOptionsKey(prop.Key), prop.Value); + return clone; + } + + private sealed record HttpState(HttpClient Http, HttpRequestMessage Request, HttpCompletionOption CompletionOption); +} \ No newline at end of file diff --git a/tests/Delibera.Core.Tests/Delibera.Core.Tests.csproj b/tests/Delibera.Core.Tests/Delibera.Core.Tests.csproj index 97b4a87..fee8758 100644 --- a/tests/Delibera.Core.Tests/Delibera.Core.Tests.csproj +++ b/tests/Delibera.Core.Tests/Delibera.Core.Tests.csproj @@ -11,6 +11,12 @@ + + + + + + diff --git a/tests/Delibera.Core.Tests/Fakes/FakeChatClient.cs b/tests/Delibera.Core.Tests/Fakes/FakeChatClient.cs index 7aaddb6..95c296a 100644 --- a/tests/Delibera.Core.Tests/Fakes/FakeChatClient.cs +++ b/tests/Delibera.Core.Tests/Fakes/FakeChatClient.cs @@ -7,7 +7,7 @@ namespace Delibera.Core.Tests.Fakes; /// In-memory used to test the Microsoft.Extensions.AI integration /// without any network access. /// -public sealed class FakeChatClient(string reply = "fake-reply", string providerName = "Fake", string? defaultModel = "fake-model") +public sealed class FakeChatClient(string reply = "fake-reply", string providerName = "Fake", string? defaultModel = "fake-model", int delayMs = 0) : IChatClient { private readonly ChatClientMetadata _metadata = new(providerName, defaultModelId: defaultModel); @@ -20,14 +20,16 @@ public sealed class FakeChatClient(string reply = "fake-reply", string providerN /// Records the messages of the most recent request for assertions. public IList? LastMessages { get; private set; } - public Task GetResponseAsync( + public async Task GetResponseAsync( IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) { LastMessages = messages.ToList(); LastOptions = options; - return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, reply))); + if (delayMs > 0) + await Task.Delay(delayMs, cancellationToken); + return new ChatResponse(new ChatMessage(ChatRole.Assistant, reply)); } public async IAsyncEnumerable GetStreamingResponseAsync( @@ -37,6 +39,8 @@ public async IAsyncEnumerable GetStreamingResponseAsync( { LastMessages = messages.ToList(); LastOptions = options; + if (delayMs > 0) + await Task.Delay(delayMs, cancellationToken); // Emit one update per word to simulate token streaming. foreach (var word in reply.Split(' ')) { diff --git a/tests/Delibera.Core.Tests/FinalVerdictTests.cs b/tests/Delibera.Core.Tests/FinalVerdictTests.cs new file mode 100644 index 0000000..7248755 --- /dev/null +++ b/tests/Delibera.Core.Tests/FinalVerdictTests.cs @@ -0,0 +1,120 @@ +using Delibera.Core.Council; +using Delibera.Core.Debate; +using Delibera.Core.Interfaces; +using Delibera.Core.Models; +using Delibera.Core.Providers.LLM; +using Delibera.Core.Tests.Fakes; +using FluentAssertions; +using Microsoft.Extensions.AI; + +namespace Delibera.Core.Tests; + +public class FinalVerdictTests +{ + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public async Task StandardDebate_WithChairman_ProducesFinalVerdict_ForAnyMaxRounds(int maxRounds) + { + var provider = CreateProvider("standard-verdict"); + var member = new CouncilMember("model", provider); + var chairman = new CouncilMember("chair", provider, "Chairman"); + + var debate = new StandardDebate(); + var result = await debate.ExecuteAsync( + [member], + new PromptContext { UserPrompt = "What is 2+2?" }, + chairman, + null, + null, + maxRounds: maxRounds); + + result.FinalVerdict.Should().NotBeNullOrWhiteSpace(); + result.FinalVerdict.Should().StartWith("standard-verdict"); + } + + [Fact] + public async Task StandardDebate_WithoutChairman_LeavesFinalVerdictEmpty() + { + var provider = CreateProvider("standard-verdict"); + var member = new CouncilMember("model", provider); + + var debate = new StandardDebate(); + var result = await debate.ExecuteAsync( + [member], + new PromptContext { UserPrompt = "What is 2+2?" }, + null, + null, + null); + + result.FinalVerdict.Should().BeNullOrWhiteSpace(); + } + + [Fact] + public async Task CritiqueDebate_WithChairman_ProducesFinalVerdict() + { + var provider = CreateProvider("critique-verdict"); + var member = new CouncilMember("model", provider); + var chairman = new CouncilMember("chair", provider, "Chairman"); + + var debate = new CritiqueDebate(); + var result = await debate.ExecuteAsync( + [member], + new PromptContext { UserPrompt = "Microservices vs monolith?" }, + chairman, + null, + null, + maxRounds: 3); + + result.FinalVerdict.Should().NotBeNullOrWhiteSpace(); + result.FinalVerdict.Should().StartWith("critique-verdict"); + } + + [Fact] + public async Task ConsensusDebate_WithChairman_ProducesFinalVerdict() + { + var provider = CreateProvider("consensus-verdict"); + var member = new CouncilMember("model", provider); + var chairman = new CouncilMember("chair", provider, "Chairman"); + + var debate = new ConsensusDebate(); + var result = await debate.ExecuteAsync( + [member], + new PromptContext { UserPrompt = "Best project structure?" }, + chairman, + null, + null, + maxRounds: 2); + + result.FinalVerdict.Should().NotBeNullOrWhiteSpace(); + result.FinalVerdict.Should().StartWith("consensus-verdict"); + } + + [Fact] + public async Task StandardDebate_RoundDuration_IsNonZero_WhenRoundWorkTakesTime() + { + var provider = CreateProvider("standard-verdict", delayMs: 50); + var member = new CouncilMember("model", provider); + var chairman = new CouncilMember("chair", provider, "Chairman"); + + var debate = new StandardDebate(); + var result = await debate.ExecuteAsync( + [member], + new PromptContext { UserPrompt = "What is 2+2?" }, + chairman, + null, + null, + maxRounds: 4); + + foreach (var round in result.Rounds) + round.Duration.Should().BeGreaterThan(TimeSpan.Zero, $"Round {round.RoundNumber} should have non-zero duration"); + } + + private static ILLMProvider CreateProvider(string reply, int delayMs = 0) + { + var client = new FakeChatClient(reply: reply, delayMs: delayMs); + return new ChatClientLLMProvider(client, ownsClient: true); + } +} diff --git a/tests/Delibera.Core.Tests/ResilienceTests.cs b/tests/Delibera.Core.Tests/ResilienceTests.cs new file mode 100644 index 0000000..ebaee64 --- /dev/null +++ b/tests/Delibera.Core.Tests/ResilienceTests.cs @@ -0,0 +1,204 @@ +using Delibera.Core.DependencyInjection; +using Delibera.Core.Resilience; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Polly; + +namespace Delibera.Core.Tests; + +/// +/// Unit tests for the Polly v8 resilience integration: +/// defaults, +/// behaviour, and +/// the AddDeliberaResilience DI extension. +/// +public sealed class ResilienceTests +{ + [Fact] + public void ResilienceOptions_HasSensibleBuiltInDefaults() + { + var opts = new ResilienceOptions(); + + opts.Enabled.Should().BeTrue(); + opts.MaxRetryAttempts.Should().Be(3); + opts.BaseDelay.Should().Be(TimeSpan.FromSeconds(2)); + opts.MaxDelay.Should().Be(TimeSpan.FromSeconds(30)); + opts.UseJitter.Should().BeTrue(); + opts.BackoffType.Should().Be("Exponential"); + opts.RetryableStatusCodes.Should().BeEquivalentTo(new[] { 408, 429, 500, 502, 503, 504, 524 }); + } + + [Fact] + public void ResilienceOptions_ExposesStablePipelineNames() + { + ResilienceOptions.DefaultPipelineName.Should().Be("Delibera.Default"); + ResilienceOptions.LocalPipelineName.Should().Be("Delibera.Local"); + ResilienceOptions.CloudPipelineName.Should().Be("Delibera.Cloud"); + } + + [Fact] + public void DeliberaResiliencePipelineProvider_ReturnsEmptyPipeline_WhenDisabled() + { + var provider = new DeliberaResiliencePipelineProvider( + Microsoft.Extensions.Options.Options.Create(new ResilienceOptions { Enabled = false }).AsMonitor()); + + var p = provider.GetPipeline(ResilienceOptions.CloudPipelineName); + ReferenceEquals(p, ResiliencePipeline.Empty).Should().BeTrue(); + + var op = provider.GetOperationPipeline(ResilienceOptions.CloudPipelineName); + ReferenceEquals(op, ResiliencePipeline.Empty).Should().BeTrue(); + } + + [Fact] + public void DeliberaResiliencePipelineProvider_ReturnsNonEmptyPipeline_WhenEnabled() + { + var provider = new DeliberaResiliencePipelineProvider( + Microsoft.Extensions.Options.Options.Create(new ResilienceOptions()).AsMonitor()); + + var cloud = provider.GetPipeline(ResilienceOptions.CloudPipelineName); + ReferenceEquals(cloud, ResiliencePipeline.Empty).Should().BeFalse(); + + var local = provider.GetPipeline(ResilienceOptions.LocalPipelineName); + ReferenceEquals(local, ResiliencePipeline.Empty).Should().BeFalse(); + + var op = provider.GetOperationPipeline(ResilienceOptions.CloudPipelineName); + ReferenceEquals(op, ResiliencePipeline.Empty).Should().BeFalse(); + } + + [Fact] + public void DeliberaResiliencePipelineProvider_FallsBackToDefault_WhenNameUnknown() + { + var provider = new DeliberaResiliencePipelineProvider( + Microsoft.Extensions.Options.Options.Create(new ResilienceOptions()).AsMonitor()); + + var resolved = provider.GetPipeline("non-existent-pipeline"); + var fallback = provider.GetPipeline(ResilienceOptions.DefaultPipelineName); + // Both should be valid (non-null) pipelines. + resolved.Should().NotBeNull(); + fallback.Should().NotBeNull(); + } + + [Fact] + public void DeliberaResiliencePipelineProvider_AcceptsCustomPipeline() + { + var customPipeline = new ResiliencePipelineBuilder() + .AddRetry(new Polly.Retry.RetryStrategyOptions + { + Name = "MyCustom", + MaxRetryAttempts = 7 + }) + .Build(); + + var provider = new DeliberaResiliencePipelineProvider( + Microsoft.Extensions.Options.Options.Create(new ResilienceOptions()).AsMonitor(), + new[] + { + new KeyValuePair, ResiliencePipeline>>( + "Delibera.MyKey", + _ => customPipeline) + }); + + var resolved = provider.GetPipeline("Delibera.MyKey"); + resolved.Should().BeSameAs(customPipeline); + } + + [Fact] + public void AddDeliberaResilience_RegistersPipelineProviderAndNamedHttpClients() + { + var services = new ServiceCollection(); + services.AddDelibera(); + services.AddDeliberaResilience(opts => + { + opts.MaxRetryAttempts = 5; + opts.BaseDelay = TimeSpan.FromMilliseconds(250); + }); + + using var sp = services.BuildServiceProvider(); + + var registry = sp.GetRequiredService(); + registry.GetPipeline(ResilienceOptions.LocalPipelineName).Should().NotBeNull(); + registry.GetPipeline(ResilienceOptions.CloudPipelineName).Should().NotBeNull(); + + var factory = sp.GetRequiredService(); + factory.CreateClient("Delibera.Ollama.Local").Should().NotBeNull(); + factory.CreateClient("Delibera.Ollama.Cloud").Should().NotBeNull(); + factory.CreateClient("Delibera.YandexGPT").Should().NotBeNull(); + } + + [Fact] + public void AddDeliberaResilience_CustomPipeline_IsRegisteredAlongsideBuiltins() + { + var services = new ServiceCollection(); + services.AddDelibera(); + services.AddDeliberaResilience(); + + services.AddDeliberaResiliencePipeline("Delibera.MyCustom", b => b + .AddRetry(new Polly.Retry.RetryStrategyOptions + { + Name = "Delibera.MyCustom", + MaxRetryAttempts = 9 + }) + .Build()); + + using var sp = services.BuildServiceProvider(); + + var registry = sp.GetRequiredService(); + var custom = registry.GetPipeline("Delibera.MyCustom"); + custom.Should().NotBeNull(); + ReferenceEquals(custom, ResiliencePipeline.Empty).Should().BeFalse(); + } + + [Fact] + public void AddDeliberaResilience_RespectsBindFromConfiguration() + { + var dict = new Dictionary + { + ["Delibera:Resilience:MaxRetryAttempts"] = "7", + ["Delibera:Resilience:BaseDelay"] = "00:00:05", + ["Delibera:Resilience:UseJitter"] = "true", + ["Delibera:Resilience:RetryableStatusCodes:0"] = "429", + ["Delibera:Resilience:RetryableStatusCodes:1"] = "500" + }; + + var configuration = new Microsoft.Extensions.Configuration.ConfigurationBuilder() + .AddInMemoryCollection(dict) + .Build(); + + var services = new ServiceCollection(); + services.AddDelibera(configuration); + services.AddDeliberaResilience(); + + using var sp = services.BuildServiceProvider(); + + var monitor = sp.GetRequiredService>(); + var opts = monitor.CurrentValue; + + opts.MaxRetryAttempts.Should().Be(7); + opts.BaseDelay.Should().Be(TimeSpan.FromSeconds(5)); + opts.UseJitter.Should().BeTrue(); + opts.RetryableStatusCodes.Should().Contain(new[] { 429, 500 }); + } +} + +/// +/// Small helper to convert into an +/// for tests where no +/// change-tracking is required. +/// +internal static class TestOptionsMonitorExtensions +{ + public static Microsoft.Extensions.Options.IOptionsMonitor AsMonitor(this Microsoft.Extensions.Options.IOptions options) + where T : class + { + return new StaticOptionsMonitor(options.Value); + } + + private sealed class StaticOptionsMonitor : Microsoft.Extensions.Options.IOptionsMonitor where T : class + { + public StaticOptionsMonitor(T value) => CurrentValue = value; + public T CurrentValue { get; } + public T Get(string? name) => CurrentValue; + public IDisposable? OnChange(Action listener) => null; + } +} \ No newline at end of file From 7954a34771626abded56bca40dbf2107f30e038d Mon Sep 17 00:00:00 2001 From: Victor Buzin Date: Fri, 26 Jun 2026 20:48:10 +0200 Subject: [PATCH 14/14] Revert "Merge branch 'main' into develop" This reverts commit b8919256cd3d1037cd82942da70bf36e5a3f6e00, reversing changes made to f60d2d84f9c345d62ebb901ad62e9257e9241eca. --- src/Delibera.ConsoleApp/Program.cs | 133 ++---------------- src/Delibera.Core/Council/CouncilBuilder.cs | 34 +---- src/Delibera.Core/Council/CouncilExecutor.cs | 115 ++------------- src/Delibera.Core/Debate/DebateScenario.cs | 104 +++++--------- src/Delibera.Core/Delibera.Core.csproj | 2 +- .../DependencyInjection/CouncilOptions.cs | 30 +--- .../ServiceCollectionExtensions.cs | 49 +------ .../Interfaces/ICouncilBuilder.cs | 38 +---- .../Interfaces/ICouncilExecutor.cs | 33 ++--- .../Interfaces/IDebateStrategy.cs | 9 +- 10 files changed, 91 insertions(+), 456 deletions(-) diff --git a/src/Delibera.ConsoleApp/Program.cs b/src/Delibera.ConsoleApp/Program.cs index dc460c8..4a4534a 100644 --- a/src/Delibera.ConsoleApp/Program.cs +++ b/src/Delibera.ConsoleApp/Program.cs @@ -556,137 +556,24 @@ private static void PrintBanner() Console.ForegroundColor = ConsoleColor.Green; Console.WriteLine(""" - ██████╗ ███████╗██╗ ██╗██████╗ ███████╗██████╗ █████╗ - ██╔══██╗██╔════╝██║ ██║██╔══██╗██╔════╝██╔══██╗██╔══██╗ - ██║ ██║█████╗ ██║ ██║██████╔╝█████╗ ██████╔╝███████║ - ██║ ██║██╔══╝ ██║ ██║██╔══██╗██╔══╝ ██╔══██╗██╔══██║ - ██████╔╝███████╗███████╗██║██████╔╝███████╗██║ ██║██║ ██║ - ╚═════╝ ╚══════╝╚══════╝╚═╝╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ + ██████╗ ███████╗██╗ ██╗██████╗ ███████╗██████╗ █████╗ + ██╔══██╗██╔════╝██║ ██║██╔══██╗██╔════╝██╔══██╗██╔══██╗ + ██║ ██║█████╗ ██║ ██║██████╔╝█████╗ ██████╔╝███████║ + ██║ ██║██╔══╝ ██║ ██║██╔══██╗██╔══╝ ██╔══██╗██╔══██║ + ██████╔╝███████╗███████╗██║██████╔╝███████╗██║ ██║██║ ██║ + ╚═════╝ ╚══════╝╚══════╝╚═╝╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ - ⚖️ Thoughtful AI Decisions · v3.1 + ⚖️ Thoughtful AI Decisions · v3.1 - RAG • pgvector • Knowledge Keeper • Chairman - Context Compression • DI • Execution Logging + RAG • pgvector • Knowledge Keeper • Chairman + Context Compression • DI • Execution Logging - """); + """); Console.ResetColor(); } // ─── 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. - /// - private static void WriteLogEntry(ExecutionLog entry) - { - var prev = Console.ForegroundColor; - Console.ForegroundColor = entry.Level switch - { - ExecutionLogLevel.Trace => ConsoleColor.DarkGray, - ExecutionLogLevel.Info => ConsoleColor.Cyan, - ExecutionLogLevel.Warning => ConsoleColor.Yellow, - ExecutionLogLevel.Error => ConsoleColor.Red, - _ => prev - }; - - Console.WriteLine($" ┊ {entry}"); - Console.ForegroundColor = prev; - } - - /// - /// Writes a non-fatal internal error to the console with a small stack-trace - /// excerpt. The debate continues; this is purely informational. - /// - private static void WriteErrorEntry(Exception ex, string context) - { - var prev = Console.ForegroundColor; - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine($" ┊ ⚠ {context} error: {ex.Message}"); - if (ex.StackTrace is not null) - { - var firstFrame = ex.StackTrace - .Split('\n', StringSplitOptions.RemoveEmptyEntries) - .FirstOrDefault(); - if (firstLineIsMeaningful(firstFrame)) - Console.WriteLine($" ┊ at {firstFrame.Trim()}"); - } - - Console.ForegroundColor = prev; - } - - /// - /// Prints a full diagnostic panel for a fatal exception: type, message, - /// full stack trace and the live log transcript captured so far. - /// - /// 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); - } - - Console.ForegroundColor = prev; - } - - /// - /// 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. - /// - private static void WaitForKeyOnExit(string prompt, bool isError) - { - if (isError) - Console.ForegroundColor = ConsoleColor.Red; - - Console.WriteLine(); - Console.WriteLine(prompt); - Console.ResetColor(); - - try - { - Console.ReadKey(intercept: true); - } - catch (InvalidOperationException) - { - // No interactive console (e.g. redirected stdin in CI) — fall back gracefully. - Console.WriteLine("(no interactive console available; exiting.)"); - } - - Environment.ExitCode = isError ? 1 : 0; - } - - private static bool firstLineIsMeaningful(string? frame) - { - if (string.IsNullOrWhiteSpace(frame)) - return false; - - var trimmed = frame.Trim(); - // Filter out noise from runtime/compiler-emitted frames. - return !trimmed.StartsWith("at System.", StringComparison.Ordinal) - || trimmed.Contains("Delibera", StringComparison.Ordinal); - } - - // ─── Console observability helpers ───────────────────────────────────────────── - /// /// Writes a single entry to the console in a /// colour-coded, single-line format. Safe to call from the executor's diff --git a/src/Delibera.Core/Council/CouncilBuilder.cs b/src/Delibera.Core/Council/CouncilBuilder.cs index d69cac5..7cc6801 100644 --- a/src/Delibera.Core/Council/CouncilBuilder.cs +++ b/src/Delibera.Core/Council/CouncilBuilder.cs @@ -1,7 +1,6 @@ using Delibera.Core.Compression; using Delibera.Core.Debate; using Delibera.Core.Providers.Mcp; -using Microsoft.Extensions.Logging; namespace Delibera.Core.Council; @@ -191,33 +190,12 @@ public ICouncilBuilder WithTemperature(float temperature) return this; } - /// - public ICouncilBuilder SaveResultTo(string outputPath) - { - _outputPath = outputPath; - return this; - } - - /// - public ICouncilBuilder WithResponseLanguage(string? language) - { - _responseLanguage = string.IsNullOrWhiteSpace(language) ? null : language.Trim(); - return this; - } - - /// - public ICouncilBuilder WithMaxDegreeOfParallelism(int maxDegreeOfParallelism) - { - _maxDegreeOfParallelism = Math.Max(0, maxDegreeOfParallelism); - return this; - } - - /// - public ICouncilBuilder WithLogger(ILogger? logger) - { - _logger = logger; - return this; - } + /// + public ICouncilBuilder SaveResultTo(string outputPath) + { + _outputPath = outputPath; + return this; + } /// public ICouncilBuilder WithResponseLanguage(string? language) diff --git a/src/Delibera.Core/Council/CouncilExecutor.cs b/src/Delibera.Core/Council/CouncilExecutor.cs index 43080dd..2299b51 100644 --- a/src/Delibera.Core/Council/CouncilExecutor.cs +++ b/src/Delibera.Core/Council/CouncilExecutor.cs @@ -1,5 +1,4 @@ using Delibera.Core.Compression; -using Microsoft.Extensions.Logging; namespace Delibera.Core.Council; @@ -89,8 +88,7 @@ internal CouncilExecutor( public event Action? OnError; /// - /// Per-execution options (response language, parallelism budget, logger). - /// Populated from . + /// Runs the debate and returns the full result. /// public async Task ExecuteAsync(CancellationToken ct = default) { @@ -155,99 +153,21 @@ public async Task ExecuteAsync(CancellationToken ct = default) { Log(ExecutionLog.Info("Council", $"Round {round.RoundNumber} completed: {round.RoundName} ({round.Duration.TotalSeconds:F1}s, {round.Responses.Count} responses)")); - /// - public IReadOnlyList ExecutionLogs => _executionLogs.AsReadOnly(); + // Log knowledge interactions + foreach (var ki in round.KnowledgeInteractions) + Log(ExecutionLog.Info("KnowledgeKeeper", $"Query: \"{Truncate(ki.Query, 100)}\" → {ki.SourceChunks} chunks")); - /// Invoked after each round completes. - public event Action? OnRoundCompleted; + // Log operator interactions + foreach (var oi in round.OperatorInteractions) + Log(ExecutionLog.Info("Operator", $"{oi.RequesterName} → \"{Truncate(oi.Task, 100)}\" ({oi.ToolCallCount} tool call(s))")); - /// - public event Action? OnLog; - - /// - public event Action? OnError; + // Log participant responses + foreach (var (member, response) in round.Responses) + Log(ExecutionLog.Trace("Participant", $"{member} responded ({response.Length} chars)")); - /// - /// Runs the debate and returns the full result. - /// - public async Task ExecuteAsync(CancellationToken ct = default) - { - _executionLogs.Clear(); - - Log(ExecutionLog.Info("Council", $"Starting debate — strategy: {Strategy.StrategyName}, members: {Members.Count}, maxRounds: {_maxRounds}")); - - if (ExecutionOptions.HasResponseLanguage) - Log(ExecutionLog.Info("Council", $"Response language enforced: {ExecutionOptions.ResponseLanguage}")); - - if (ExecutionOptions.MaxDegreeOfParallelism > 0) - Log(ExecutionLog.Info("Council", $"Parallelism cap: {ExecutionOptions.MaxDegreeOfParallelism}")); - - if (Chairman is not null) - Log(ExecutionLog.Info("Chairman", $"Chairman assigned: {Chairman.DisplayName}")); - - if (KnowledgeKeeper is not null) - Log(ExecutionLog.Info("KnowledgeKeeper", $"Knowledge Keeper ready: {KnowledgeKeeper.DisplayName} (collection: {KnowledgeKeeper.CollectionName})")); - - // Initialise the Operator (connect to MCP servers, discover tools) before the debate begins. - if (Operator is not null) - { - if (!Operator.IsInitialized) - { - Log(ExecutionLog.Info("Operator", $"Initialising Operator: {Operator.DisplayName}…")); - try - { - await Operator.InitializeAsync(ct); - } - catch (Exception ex) - { - ReportError(ex, "Operator"); - } - } - - Log(ExecutionLog.Info("Operator", $"Operator ready: {Operator.DisplayName} ({Operator.AvailableTools.Count} tool(s) available)")); - } - - if (Compressor is not null) - Log(ExecutionLog.Info("Compression", $"Compression enabled: {Compressor.StrategyName}")); - - foreach (var m in Members) - Log(ExecutionLog.Trace("Council", $"Participant registered: {m.DisplayName} [{m.Role}]")); - - // Inject the response-language directive into the system prompt so every downstream - // call (participants, Chairman.OpenDebateAsync / SynthesizeVerdictAsync, Knowledge Keeper, - // Operator) inherits it. - var effectiveContext = ExecutionOptions.HasResponseLanguage - ? _context with { SystemPrompt = _context.SystemPrompt + ExecutionOptions.BuildLanguageDirective() } - : _context; - - var result = await Strategy.ExecuteAsync( - Members, - effectiveContext, - Chairman, - KnowledgeKeeper, - Operator, - ExecutionOptions, - _maxRounds, - _temperature, - round => - { - Log(ExecutionLog.Info("Council", $"Round {round.RoundNumber} completed: {round.RoundName} ({round.Duration.TotalSeconds:F1}s, {round.Responses.Count} responses)")); - - // Log knowledge interactions - foreach (var ki in round.KnowledgeInteractions) - Log(ExecutionLog.Info("KnowledgeKeeper", $"Query: \"{Truncate(ki.Query, 100)}\" → {ki.SourceChunks} chunks")); - - // Log operator interactions - foreach (var oi in round.OperatorInteractions) - Log(ExecutionLog.Info("Operator", $"{oi.RequesterName} → \"{Truncate(oi.Task, 100)}\" ({oi.ToolCallCount} tool call(s))")); - - // Log participant responses - foreach (var (member, response) in round.Responses) - Log(ExecutionLog.Trace("Participant", $"{member} responded ({response.Length} chars)")); - - OnRoundCompleted?.Invoke(round); - }, - ct); + OnRoundCompleted?.Invoke(round); + }, + ct); Log(ExecutionLog.Info("Council", $"Debate completed — {result.Rounds.Count} rounds, duration: {result.TotalDuration.TotalSeconds:F1}s")); @@ -382,15 +302,6 @@ private void ReportError(Exception ex, string context) ExecutionOptions.Logger?.LogError(ex, "[{Source}] {Message}", context, ex.Message); } - private void ReportError(Exception ex, string context) - { - var entry = ExecutionLog.Error(context, ex.Message); - _executionLogs.Add(ExecutionLogSink.Emit(ExecutionOptions.Logger, entry)); - OnError?.Invoke(ex, context); - - ExecutionOptions.Logger?.LogError(ex, "[{Source}] {Message}", context, ex.Message); - } - private static string Truncate(string text, int max) { return string.IsNullOrEmpty(text) ? "(empty)" : text.Length <= max ? text : text[..max] + "…"; diff --git a/src/Delibera.Core/Debate/DebateScenario.cs b/src/Delibera.Core/Debate/DebateScenario.cs index 8578169..210ccbc 100644 --- a/src/Delibera.Core/Debate/DebateScenario.cs +++ b/src/Delibera.Core/Debate/DebateScenario.cs @@ -10,50 +10,37 @@ namespace Delibera.Core.Debate; /// public abstract class DebateScenario : IDebateStrategyWithOptions { - // ────────────────────────────────────────────── - // Operator helpers - // ────────────────────────────────────────────── - - /// - /// Marker participants use to delegate a task to the Operator, e.g.: - /// [[OPERATOR: search the web for the latest .NET 10 release notes]]. - /// - private static readonly Regex OperatorRequestRegex = - new(@"\[\[\s*OPERATOR\s*:\s*(?.+?)\]\]", - RegexOptions.Singleline | - RegexOptions.IgnoreCase | - RegexOptions.Compiled); - - /// - public abstract string StrategyName { get; } - - /// - public abstract string Description { get; } - - /// - public abstract Task ExecuteAsync( - IReadOnlyList members, - PromptContext context, - CouncilMember? chairman, - KnowledgeKeeper? knowledgeKeeper, - Operator? @operator, - int maxRounds = 4, - float temperature = 0.7f, - Action? onRoundCompleted = null, - CancellationToken ct = default); + // ────────────────────────────────────────────── + // Operator helpers + // ────────────────────────────────────────────── - /// - public abstract Task ExecuteAsync( - IReadOnlyList members, - PromptContext context, - CouncilMember? chairman, - KnowledgeKeeper? knowledgeKeeper, - Operator? @operator, - DebateExecutionOptions executionOptions, - int maxRounds = 4, - float temperature = 0.7f, - Action? onRoundCompleted = null, - CancellationToken ct = default); + /// + /// Marker participants use to delegate a task to the Operator, e.g.: + /// [[OPERATOR: search the web for the latest .NET 10 release notes]]. + /// + private static readonly Regex OperatorRequestRegex = + new(@"\[\[\s*OPERATOR\s*:\s*(?.+?)\]\]", + RegexOptions.Singleline | + RegexOptions.IgnoreCase | + RegexOptions.Compiled); + + /// + public abstract string StrategyName { get; } + + /// + public abstract string Description { get; } + + /// + public abstract Task ExecuteAsync( + IReadOnlyList members, + PromptContext context, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, + int maxRounds = 4, + float temperature = 0.7f, + Action? onRoundCompleted = null, + CancellationToken ct = default); /// public abstract Task ExecuteAsync( @@ -112,7 +99,7 @@ protected static async Task> CollectResponsesAsync( responses[key] = response; } - + return responses; } @@ -323,33 +310,8 @@ await Parallel.ForEachAsync( } }); - if (pending.Count == 0) return []; - - var interactions = new List(pending.Count); - var parallelOpts = executionOptions.ToParallelOptions(ct); - - // Parallel.ForEachAsync gives us a concurrent, optionally-bounded execution of the - // delegated Operator tasks. Results are collected in a thread-safe list. - await Parallel.ForEachAsync( - pending, - parallelOpts, - async (item, token) => - { - try - { - var result = await @operator.ExecuteTaskAsync(item.Member, item.Task, token); - var interaction = result.ToInteraction(); - lock (interactions) interactions.Add(interaction); - } - catch (Exception ex) - { - lock (interactions) interactions.Add(new OperatorInteraction( - item.Member, item.Task, $"[Operator error: {ex.Message}]", [], false, DateTime.UtcNow)); - } - }); - - return interactions; - } + return interactions; + } /// /// Formats Operator interactions into a context block that can be injected into the diff --git a/src/Delibera.Core/Delibera.Core.csproj b/src/Delibera.Core/Delibera.Core.csproj index d735d17..6568d80 100644 --- a/src/Delibera.Core/Delibera.Core.csproj +++ b/src/Delibera.Core/Delibera.Core.csproj @@ -39,7 +39,7 @@ - + diff --git a/src/Delibera.Core/DependencyInjection/CouncilOptions.cs b/src/Delibera.Core/DependencyInjection/CouncilOptions.cs index c3f2a43..06efe75 100644 --- a/src/Delibera.Core/DependencyInjection/CouncilOptions.cs +++ b/src/Delibera.Core/DependencyInjection/CouncilOptions.cs @@ -18,34 +18,8 @@ public sealed class CouncilOptions /// Default generation temperature (0.0–2.0). public float Temperature { get; set; } = 0.7f; - /// Default system prompt for all participants. - public string SystemPrompt { get; set; } = "You are a helpful AI assistant participating in a council debate."; - - /// - /// Forces every model response (participants, Chairman, Knowledge Keeper, Operator) - /// to be written in the specified human language, regardless of the language used - /// in the prompt or retrieved RAG context. - /// - /// - /// - /// Set to a language name the model recognises (e.g. "Russian", "English", - /// "Spanish", "中文"). When non-empty, Delibera injects a strict directive into - /// every system and user prompt: "You MUST answer exclusively in {language}. - /// Never use any other language." - /// - /// - /// Leave null or empty to let the model pick a language from context (legacy - /// behaviour). - /// - /// - public string? ResponseLanguage { get; set; } - - /// - /// Optional maximum degree of parallelism for operations that can run concurrently - /// within a debate round (e.g. Operator task delegation, parallel Knowledge Keeper - /// queries). 0 means "unbounded" (default). - /// - public int MaxDegreeOfParallelism { get; set; } + /// Default system prompt for all participants. + public string SystemPrompt { get; set; } = "You are a helpful AI assistant participating in a council debate."; /// /// Forces every model response (participants, Chairman, Knowledge Keeper, Operator) diff --git a/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs b/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs index b6c5cd7..2a61383 100644 --- a/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs @@ -41,8 +41,8 @@ public static IServiceCollection AddDelibera(this IServiceCollection services) services.TryAddSingleton(); services.TryAddTransient(); - return services; - } + return services; + } /// /// Registers core Delibera services and binds from configuration. @@ -54,8 +54,8 @@ public static IServiceCollection AddDelibera( { services.AddDelibera(); - var section = configuration.GetSection(sectionName); - if (section.Exists()) services.Configure(section); + var section = configuration.GetSection(sectionName); + if (section.Exists()) services.Configure(section); // ResilienceOptions is a sub-section; bind it independently so // IOptionsMonitor gets a typed configuration that @@ -78,45 +78,8 @@ public static IServiceCollection AddDelibera( services.AddDelibera(); services.Configure(configureOptions); - return services; - } - - /// - /// Registers core Delibera services and wires the framework into the host's - /// . A resolved from the - /// container is automatically decorated with a logger, so any debate started via DI - /// logs to the host's pipeline (console, file, OpenTelemetry, …) in addition to the - /// in-memory collection. - /// - /// The service collection. - /// Configuration root or section. - /// Host logger factory. - /// Configuration section name (default: "Delibera"). - /// The service collection for chaining. - public static IServiceCollection AddDelibera( - this IServiceCollection services, - IConfiguration configuration, - ILoggerFactory loggerFactory, - string sectionName = CouncilOptions.SectionName) - { - ArgumentNullException.ThrowIfNull(loggerFactory); - services.AddDelibera(configuration, sectionName); - services.TryAddSingleton(loggerFactory); - - // Replace the transient builder registration so every resolved ICouncilBuilder - // gets a logger injected automatically. Consumers who build the executor themselves - // can still call WithLogger(...) explicitly to override. - services.Replace(ServiceDescriptor.Transient(sp => - { - var builder = new CouncilBuilder(); - var lf = sp.GetService(); - if (lf is not null) - builder.WithLogger(lf.CreateLogger("Delibera.Core.Council")); - return builder; - })); - - return services; - } + return services; + } /// /// Registers core Delibera services and wires the framework into the host's diff --git a/src/Delibera.Core/Interfaces/ICouncilBuilder.cs b/src/Delibera.Core/Interfaces/ICouncilBuilder.cs index 699046a..57a0cf9 100644 --- a/src/Delibera.Core/Interfaces/ICouncilBuilder.cs +++ b/src/Delibera.Core/Interfaces/ICouncilBuilder.cs @@ -146,40 +146,10 @@ ICouncilBuilder WithCompression( /// This builder for fluent chaining. ICouncilBuilder WithTemperature(float temperature); - /// Sets the output path for saving the debate result as Markdown. - /// File path for Markdown output. - /// This builder for fluent chaining. - ICouncilBuilder SaveResultTo(string outputPath); - - /// - /// Forces every model response (participants, Chairman, Knowledge Keeper, Operator) - /// to be in the specified language. Pass null or empty to disable language - /// enforcement and let the model pick a language from context. - /// - /// - /// Language name the model recognises (e.g. "Russian", "English", "Spanish"). - /// - /// This builder for fluent chaining. - ICouncilBuilder WithResponseLanguage(string? language); - - /// - /// Sets the maximum degree of parallelism for operations that can run concurrently - /// within a debate round (Operator task delegation, parallel Knowledge Keeper - /// queries). Pass 0 for unbounded parallelism (default). - /// - /// Max concurrent operations per round (0 = unbounded). - /// This builder for fluent chaining. - ICouncilBuilder WithMaxDegreeOfParallelism(int maxDegreeOfParallelism); - - /// - /// Attaches an used by the executor to surface progress - /// (Chairman actions, rounds, compression, errors, …) to a host's logging pipeline. - /// Pass null to disable structured logging (legacy behaviour — only the - /// OnLog event and the collection are populated). - /// - /// Logger instance, or null to clear. - /// This builder for fluent chaining. - ICouncilBuilder WithLogger(ILogger? logger); + /// Sets the output path for saving the debate result as Markdown. + /// File path for Markdown output. + /// This builder for fluent chaining. + ICouncilBuilder SaveResultTo(string outputPath); /// /// Forces every model response (participants, Chairman, Knowledge Keeper, Operator) diff --git a/src/Delibera.Core/Interfaces/ICouncilExecutor.cs b/src/Delibera.Core/Interfaces/ICouncilExecutor.cs index cf79d83..09f79b8 100644 --- a/src/Delibera.Core/Interfaces/ICouncilExecutor.cs +++ b/src/Delibera.Core/Interfaces/ICouncilExecutor.cs @@ -1,5 +1,4 @@ using Delibera.Core.Council; -using Delibera.Core.Models; namespace Delibera.Core.Interfaces; @@ -9,31 +8,23 @@ namespace Delibera.Core.Interfaces; /// public interface ICouncilExecutor { - /// Council participants. - IReadOnlyList Members { get; } + /// Council participants. + IReadOnlyList Members { get; } - /// Chairman (may be null). - CouncilMember? Chairman { get; } + /// Chairman (may be null). + CouncilMember? Chairman { get; } - /// Knowledge Keeper (may be null). - KnowledgeKeeper? KnowledgeKeeper { get; } + /// Knowledge Keeper (may be null). + KnowledgeKeeper? KnowledgeKeeper { get; } - /// Operator (may be null). - Operator? Operator { get; } + /// Operator (may be null). + Operator? Operator { get; } - /// Debate strategy. - IDebateStrategy Strategy { get; } + /// Debate strategy. + IDebateStrategy Strategy { get; } - /// Context compressor (may be null if compression is disabled). - IContextCompressor? Compressor { get; } - - /// - /// Optional used by the executor to surface progress - /// (Chairman actions, rounds, compression, errors, …) to a host's logging pipeline. - /// When null, only the event and the - /// collection are populated. - /// - ILogger? Logger { get; } + /// Context compressor (may be null if compression is disabled). + IContextCompressor? Compressor { get; } /// /// Optional used by the executor to surface progress diff --git a/src/Delibera.Core/Interfaces/IDebateStrategy.cs b/src/Delibera.Core/Interfaces/IDebateStrategy.cs index a47f4fd..56b8e65 100644 --- a/src/Delibera.Core/Interfaces/IDebateStrategy.cs +++ b/src/Delibera.Core/Interfaces/IDebateStrategy.cs @@ -1,5 +1,4 @@ using Delibera.Core.Council; -using Microsoft.Extensions.Logging; namespace Delibera.Core.Interfaces; @@ -9,11 +8,11 @@ namespace Delibera.Core.Interfaces; /// public interface IDebateStrategy { - /// Unique strategy name. - string StrategyName { get; } + /// Unique strategy name. + string StrategyName { get; } - /// Human-readable strategy description. - string Description { get; } + /// Human-readable strategy description. + string Description { get; } /// /// Executes the full debate cycle according to this strategy.