diff --git a/CHANGELOG.md b/CHANGELOG.md index b947293..159368a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,89 @@ 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 +## [10.2.3] - 2026 + +### Added — AutoChunking (progressive disclosure for large documents) + +- **AutoChunking** — automatic splitting of large knowledge documents (contracts, reports, articles) + into context-window-sized chunks distributed across debate rounds via progressive disclosure. + When a document exceeds the smallest model's context window, the orchestrator creates a + `ChunkingPlan` and distributes chunks evenly across rounds so every model receives a complete + view of the document by the final round. + +- **`Chunking` namespace** (`Delibera.Core.Chunking`): + - `AutoChunker` — static planner with 3 strategies: `SemanticBoundary` (respects Markdown + headers → paragraphs → sentences), `FixedSize`, `SlidingWindow` (50% overlap). + - `AutoChunkingOrchestrator` — analyses model capabilities, calculates overhead, creates + the plan, and distributes chunks per round. + - `AutoChunkingOptions` — configuration: strategy, safety margin, max chunks/round, + Map-Reduce toggle, progressive disclosure toggle. + - `ChunkingPlan` / `DocumentChunk` — immutable records describing the split. + +- **`ModelCapabilities`** record — context window, max output tokens, vision/tool support, + model family. Obtained from providers via the new `ILLMProvider.GetModelCapabilitiesAsync()`. + +- **`ModelContextWindowRegistry`** — static registry of 40+ popular models (Llama, Qwen, + DeepSeek, Phi, Mistral, Gemma, GPT, Claude, YandexGPT, …) with their context window sizes. + `Register()` for custom models. Case-insensitive substring matching. + +- **`ILLMProvider.GetModelCapabilitiesAsync(string model, CancellationToken)`** — new + default interface method (returns `null`). Overridden by: + - `OllamaProvider` — queries `/api/show` and extracts `num_ctx` from Modelfile parameters + via regex. Falls back to `ModelContextWindowRegistry`. + - `ChatClientLLMProvider` / `YandexGptProvider` — fall back to `ModelContextWindowRegistry`. + +- **`PromptContext` extended** — new fields: `ChunkingPlan`, `AutoChunkingEnabled`, + `MinContextWindow`. New method `GetChunkedUserPrompt(roundNumber, totalRounds, previousRounds)` + returns round-appropriate chunks with `[Chunk X/Y]` markers and section titles. + +- **`DebateScenario.BuildChunkedPrompt()`** — protected helper used by all built-in strategies + (`StandardDebate`, `CritiqueDebate`, `ConsensusDebate`). Replaces `GetFullUserPrompt()` calls + with round-aware chunk distribution. + +- **`CouncilBuilder` bulk configuration**: + - `WithOptions(CouncilOptions options)` — apply a pre-built options snapshot. + - `WithOptions(Action configure)` — inline lambda configuration. + - `CouncilBuilder(CouncilOptions options)` constructor — one-shot setup. + - `WithAutoChunking(AutoChunkingOptions?)` — enable chunking via fluent API. + - `WithModelContextWindow(pattern, tokens)` — register custom model context windows. + - `ApplyOptions()` — transfers all non-default `CouncilOptions` fields to the builder. + +- **`ICouncilBuilder` updated** — new methods: `WithOptions(CouncilOptions)`, + `WithOptions(Action)`, `WithAutoChunking(...)`, `WithModelContextWindow(...)`. + +- **`AutoChunkingConfig`** in `CouncilOptions` — bound from `Delibera:AutoChunking` + configuration section. Fields: `Enabled`, `Strategy`, `SafetyMargin`, `MaxChunksPerRound`, + `EnableMapReduce`, `EnableProgressiveDisclosure`, `ModelContextWindows` (dictionary). + `ToOptions()` converts to `AutoChunkingOptions` and registers custom model windows. + +- **DI auto-wiring** — `AddDelibera(IConfiguration, ILoggerFactory, ...)` now resolves + `IOptions` and passes it to `new CouncilBuilder(options)`, so all + settings (strategy, rounds, temperature, compression, auto-chunking, etc.) are applied + automatically. Explicit builder calls take precedence. + +- **Console example** `AutoChunkingExample` (run with `--autochunking`) demonstrating: + - 3 configuration paths: fluent API, options snapshot, lambda. + - Offline chunking plan demo for 4K/8K/32K/128K context windows. + - Model context window registry dump. + - Synthetic contract document (~15K+ chars) that triggers chunking on small-context models. + +### Changed + +- Bumped `Delibera.Core` package version `10.2.2` → `10.2.3`. +- `CouncilExecutor` constructor now accepts optional `AutoChunkingOptions?` parameter. +- `CouncilExecutor.ExecuteAsync()` invokes `AutoChunkingOrchestrator.PrepareContextAsync()` + before the debate when AutoChunking is enabled. +- `CouncilExecutor.GetInfo()` displays AutoChunking configuration when active. +- `appsettings.json` updated with `AutoChunking` section. + +### Compatibility + +- **No breaking changes.** AutoChunking is opt-in — disabled by default. All existing + `ILLMProvider` implementations continue to work (default `GetModelCapabilitiesAsync` + returns `null`). `PromptContext` is a `record` with `with`-expression support, so + existing code that constructs it directly is unaffected. The new `CouncilExecutor` + constructor parameter is optional. ### Added — Polly v8 resilience via Microsoft.Extensions.Http.Resilience diff --git a/README-RU.md b/README-RU.md index 02d0b84..4baf04f 100644 --- a/README-RU.md +++ b/README-RU.md @@ -6,7 +6,7 @@ ### ⚖️ Продуманные решения с помощью ИИ -**Коллективное принятие решений через структурированное обсуждение ИИ — с RAG, pgvector, Knowledge Keeper, 🛠️ Operator (MCP-инструменты), Chairman, 🔥 сжатием контекста, 💉 Dependency Injection и 📋 журналированием выполнения** +**Коллективное принятие решений через структурированное обсуждение ИИ — с RAG, pgvector, Knowledge Keeper, 🛠️ Operator (MCP-инструменты), Chairman, 🔥 сжатием контекста, ✂️ AutoChunking, 💉 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) @@ -44,6 +44,7 @@ | **🛠️ Operator (MCP-инструменты)** | Микроагент, делегирующий задачи MCP-серверам (веб, файлы, Marp, Notion, …) по запросу в ходе дебатов | | **🐘 Qdrant + pgvector** | Подключаемые векторные хранилища — отдельная БД или ваш существующий PostgreSQL | | **🗜️ Сжатие контекста** | 4 стратегии (Semantic, Deduplication, Summarization, Hybrid) экономят 30–70% токенов | +| **✂️ AutoChunking** | Прогрессивное раскрытие больших документов по раундам — с учётом контекстных окон моделей | | **💉 Dependency Injection** | Расширение `AddDelibera()` для `IServiceCollection` с полной привязкой опций | | **📋 Журналирование выполнения** | Модель `ExecutionLog` с `LogLevel` — события Chairman, KK, сжатия и участников | | **📁 Раздельный вывод файлов** | Экспорт `result.md`, `statistics.md` и `logs.md` по отдельности | @@ -63,6 +64,7 @@ - [Dependency Injection](#-dependency-injection) - [Operator (MCP-инструменты)](#️-operator-mcp-инструменты) - [Сжатие контекста](#️-сжатие-контекста) +- [AutoChunking](#-autochunking) - [Интеграция RAG](#-интеграция-rag) - [Стратегии дебатов](#️-стратегии-дебатов) - [Структура выходных файлов](#-структура-выходных-файлов) @@ -452,6 +454,93 @@ TokenCounter ← Эвристическая оценка ток --- +## ✂️ AutoChunking + +Автоматическое разбиение больших документов (договоры, отчёты, статьи) на чанки, +соответствующие размеру контекстного окна моделей, с распределением по раундам дебатов +через **прогрессивное раскрытие** (progressive disclosure). Если документ превышает +контекстное окно самой маленькой модели, оркестратор создаёт план чанкинга и равномерно +распределяет чанки — каждая модель получает полное представление к финальному раунду. + +### Как это работает + +1. **Определение моделей** — запрос контекстного окна каждой модели через + `GetModelCapabilitiesAsync()` (Ollama `/api/show` или встроенный реестр из 40+ моделей). +2. **Расчёт накладных расходов** — системный промпт + вопрос + буфер ответа + история. +3. **План чанкинга** — разбивка документа по семантическим границам (Markdown-заголовки → + параграфы → предложения), каждый чанк ≤ `minWindow − overhead − safetyMargin`. +4. **Прогрессивное раскрытие** — Раунд 1 получает чанки 1..N/3, Раунд 2 — N/3+1..2N/3 и т.д. + Каждый раунд видит маркеры `[Chunk X/Y] SectionTitle` + сводку предыдущих раундов. + +### Три способа конфигурации + +```csharp +// Способ 1: Fluent API +var executor = new CouncilBuilder() + .WithAutoChunking(new AutoChunkingOptions + { + Strategy = ChunkingStrategy.SemanticBoundary, + SafetyMargin = 0.15, + MaxChunksPerRound = 3 + }) + /* …участники, chairman, знания… */ + .Build(); + +// Способ 2: Объект CouncilOptions (из DI или вручную) +var options = new CouncilOptions +{ + AutoChunking = new AutoChunkingConfig { Enabled = true, Strategy = "SemanticBoundary" } +}; +var executor = new CouncilBuilder(options) // или .WithOptions(options) + /* … */ + .Build(); + +// Способ 3: Лямбда-конфигурация +var executor = new CouncilBuilder() + .WithOptions(o => + { + o.AutoChunking.Enabled = true; + o.AutoChunking.MaxChunksPerRound = 2; + }) + /* … */ + .Build(); +``` + +### Конфигурация (`appsettings.json`) + +```json +{ + "Delibera": { + "AutoChunking": { + "Enabled": true, + "Strategy": "SemanticBoundary", + "SafetyMargin": 0.15, + "MaxChunksPerRound": 3, + "EnableMapReduce": true, + "EnableProgressiveDisclosure": true, + "ModelContextWindows": { + "my-custom-model": 65536 + } + } + } +} +``` + +### Реестр контекстных окон моделей + +```csharp +// Запрос известных моделей +var window = ModelContextWindowRegistry.GetContextWindow("llama3.2"); // → 131072 +var window = ModelContextWindowRegistry.GetContextWindow("phi3:mini"); // → 4096 + +// Регистрация своих моделей +ModelContextWindowRegistry.Register("my-fine-tuned-model", 65536); +``` + +> ▶️ Запустите демо: `dotnet run --project src/Delibera.ConsoleApp -- --autochunking` + +--- + ## 📚 Интеграция RAG Используйте выделенный экземпляр **Qdrant** или вашу существующую базу **PostgreSQL/pgvector** в @@ -567,6 +656,7 @@ dotnet run -- --rag # RAG на базе Qdrant с Knowledge Keepe dotnet run -- --pgvector # RAG на базе pgvector dotnet run -- --operator # Основы роли Operator (MCP-инструменты) dotnet run -- --operator-mcp # 🆕 Operator с MCP-серверами browser + Marp +dotnet run -- --autochunking # 🆕 Демо AutoChunking (большие документы) # Или запустить полное демо по умолчанию (читает appsettings.json) dotnet run @@ -736,6 +826,7 @@ Delibera.Core ├── Council/ ← CouncilBuilder, CouncilExecutor, Chairman, KnowledgeKeeper, Operator ├── Debate/ ← StandardDebate, CritiqueDebate, ConsensusDebate ├── Compression/ ← Semantic / Deduplication / Summarization / Hybrid +├── Chunking/ ← AutoChunker, AutoChunkingOrchestrator, AutoChunkingOptions ├── Providers/ │ ├── LLM/ ← OllamaProvider, ChatClientLLMProvider, EmbeddingGeneratorProvider │ ├── RAG/ ← QdrantRagProvider, PgVectorRagProvider diff --git a/README.md b/README.md index d180f50..4607b0c 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ### ⚖️ 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** +**Collective decision making through structured AI deliberation — with RAG, pgvector, Knowledge Keeper, 🛠️ Operator (MCP tools), Chairman, 🔥 Context Compression, ✂️ AutoChunking, 💉 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) @@ -43,6 +43,7 @@ well-reasoned outcomes** rather than single-model guesses. | **🛠️ 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 | +| **✂️ AutoChunking** | Progressive disclosure of large documents across rounds — respects model context windows | | **💉 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 | @@ -62,6 +63,7 @@ well-reasoned outcomes** rather than single-model guesses. - [Dependency Injection](#-dependency-injection) - [Operator (MCP Tools)](#️-operator-mcp-tools) - [Context Compression](#-context-compression) +- [AutoChunking](#-autochunking) - [RAG Integration](#-rag-integration) - [Debate Strategies](#-debate-strategies) - [Output Files Structure](#-output-files-structure) @@ -448,6 +450,93 @@ TokenCounter ← Heuristic token estimation --- +## ✂️ AutoChunking + +Automatically split large knowledge documents (contracts, reports, articles) into +context-window-sized chunks distributed across debate rounds via **progressive disclosure**. +When a document exceeds the smallest model's context window, the orchestrator creates a +chunking plan and distributes chunks evenly — every model receives a complete view by the +final round. + +### How it works + +1. **Model discovery** — queries each model's context window via `GetModelCapabilitiesAsync()` + (Ollama `/api/show`, or the built-in registry of 40+ models). +2. **Overhead calculation** — system prompt + user question + response buffer + history. +3. **Chunking plan** — splits the document on semantic boundaries (Markdown headers → + paragraphs → sentences), each chunk ≤ `minWindow − overhead − safetyMargin`. +4. **Progressive disclosure** — Round 1 gets chunks 1..N/3, Round 2 gets N/3+1..2N/3, etc. + Each round sees `[Chunk X/Y] SectionTitle` markers + a summary of previous rounds. + +### Three configuration paths + +```csharp +// Path 1: Fluent API +var executor = new CouncilBuilder() + .WithAutoChunking(new AutoChunkingOptions + { + Strategy = ChunkingStrategy.SemanticBoundary, + SafetyMargin = 0.15, + MaxChunksPerRound = 3 + }) + /* …members, chairman, knowledge… */ + .Build(); + +// Path 2: CouncilOptions snapshot (from DI or manual) +var options = new CouncilOptions +{ + AutoChunking = new AutoChunkingConfig { Enabled = true, Strategy = "SemanticBoundary" } +}; +var executor = new CouncilBuilder(options) // or .WithOptions(options) + /* … */ + .Build(); + +// Path 3: Lambda configuration +var executor = new CouncilBuilder() + .WithOptions(o => + { + o.AutoChunking.Enabled = true; + o.AutoChunking.MaxChunksPerRound = 2; + }) + /* … */ + .Build(); +``` + +### Configuration (`appsettings.json`) + +```json +{ + "Delibera": { + "AutoChunking": { + "Enabled": true, + "Strategy": "SemanticBoundary", + "SafetyMargin": 0.15, + "MaxChunksPerRound": 3, + "EnableMapReduce": true, + "EnableProgressiveDisclosure": true, + "ModelContextWindows": { + "my-custom-model": 65536 + } + } + } +} +``` + +### Model Context Window Registry + +```csharp +// Query known models +var window = ModelContextWindowRegistry.GetContextWindow("llama3.2"); // → 131072 +var window = ModelContextWindowRegistry.GetContextWindow("phi3:mini"); // → 4096 + +// Register custom models +ModelContextWindowRegistry.Register("my-fine-tuned-model", 65536); +``` + +> ▶️ Run the demo: `dotnet run --project src/Delibera.ConsoleApp -- --autochunking` + +--- + ## 📚 RAG Integration Use a dedicated **Qdrant** instance or your existing **PostgreSQL/pgvector** database as a vector store. @@ -562,6 +651,7 @@ 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 +dotnet run -- --autochunking # 🆕 AutoChunking demo (large documents) # Or run the full default demo (reads appsettings.json) dotnet run @@ -731,6 +821,7 @@ Delibera.Core ├── Council/ ← CouncilBuilder, CouncilExecutor, Chairman, KnowledgeKeeper, Operator ├── Debate/ ← StandardDebate, CritiqueDebate, ConsensusDebate ├── Compression/ ← Semantic / Deduplication / Summarization / Hybrid +├── Chunking/ ← AutoChunker, AutoChunkingOrchestrator, AutoChunkingOptions ├── Providers/ │ ├── LLM/ ← OllamaProvider, ChatClientLLMProvider, EmbeddingGeneratorProvider │ ├── RAG/ ← QdrantRagProvider, PgVectorRagProvider diff --git a/src/Delibera.ConsoleApp/Examples/AutoChunkingExample.cs b/src/Delibera.ConsoleApp/Examples/AutoChunkingExample.cs new file mode 100644 index 0000000..cbbecb4 --- /dev/null +++ b/src/Delibera.ConsoleApp/Examples/AutoChunkingExample.cs @@ -0,0 +1,496 @@ +using System.Text; +using Delibera.Core.Chunking; +using Delibera.Core.Council; +using Delibera.Core.Debate; +using Delibera.Core.DependencyInjection; +using Delibera.Core.Interfaces; +using Delibera.Core.Knowledge; +using Delibera.Core.Models; +using Delibera.Core.Providers; +using Delibera.Core.Providers.LLM; + +namespace Delibera.ConsoleApp.Examples; + +/// +/// Demonstrates the AutoChunking feature — automatic splitting of large documents +/// into context-window-sized chunks distributed across debate rounds. +/// +/// +/// +/// This example shows three integration paths: +/// +/// +/// Fluent API.WithAutoChunking() on the builder. +/// CouncilOptions snapshot.WithOptions(options) with pre-built config. +/// Lambda configuration.WithOptions(o => { ... }) inline. +/// +/// +public static class AutoChunkingExample +{ + /// + /// Runs the AutoChunking example. + /// + public static async Task RunAsync() + { + Console.WriteLine("═══════════════════════════════════════"); + Console.WriteLine(" ✂️ AutoChunking Example"); + Console.WriteLine("═══════════════════════════════════════\n"); + + // ═══════════════════════════════════════════════ + // 1. Prepare a large knowledge document + // ═══════════════════════════════════════════════ + Console.WriteLine("📄 Preparing knowledge base...\n"); + + var kb = new MarkdownKnowledgeBase("Contract Analysis"); + + // Load a knowledge file if it exists, otherwise generate a synthetic large document. + var knowledgePath = Path.Combine(Directory.GetCurrentDirectory(), "knowledge", "context.md"); + if (File.Exists(knowledgePath)) + { + await kb.LoadAsync(knowledgePath); + Console.WriteLine($" ✅ Loaded: {knowledgePath} ({kb.TotalCharacters:N0} chars)"); + } + else + { + // Generate a synthetic large document to demonstrate chunking. + var syntheticDoc = GenerateSyntheticContract(); + var tempPath = Path.Combine(Path.GetTempPath(), "delibera_demo_contract.md"); + await File.WriteAllTextAsync(tempPath, syntheticDoc); + await kb.LoadAsync(tempPath); + Console.WriteLine($" 📝 Generated synthetic contract ({kb.TotalCharacters:N0} chars)"); + Console.WriteLine($" Saved to: {tempPath}"); + } + + Console.WriteLine($" Documents: {kb.DocumentCount}, Total chars: {kb.TotalCharacters:N0}\n"); + + // ═══════════════════════════════════════════════ + // 2. Initialise providers + // ═══════════════════════════════════════════════ + Console.WriteLine("🔧 Initialising providers...\n"); + + using var ollamaLocal = OllamaProvider.ForLocal("http://localhost:11434"); + using var ollamaCloud = OllamaProvider.ForCloud( + "https://api.ollama.com", + Environment.GetEnvironmentVariable("OLLAMA_CLOUD_API_KEY") ?? ""); + + // Check availability + var localAvailable = await IsAvailableSafe(ollamaLocal, "Ollama Local"); + var cloudAvailable = await IsAvailableSafe(ollamaCloud, "Ollama Cloud"); + + if (!localAvailable && !cloudAvailable) + { + Console.WriteLine("\n⚠️ No Ollama providers available. Skipping live demo."); + Console.WriteLine(" Start a local Ollama server: ollama serve"); + Console.WriteLine(" Or set OLLAMA_CLOUD_API_KEY environment variable."); + await DemoChunkingPlanOnly(kb); + return; + } + + var provider = localAvailable ? ollamaLocal : ollamaCloud; + var providerLabel = localAvailable ? "Ollama Local" : "Ollama Cloud"; + + Console.WriteLine($" Using: {providerLabel}\n"); + + // List available models + IReadOnlyList models; + try + { + models = await provider.ListModelsAsync(); + Console.WriteLine($" Available models: {string.Join(", ", models.Take(5))}"); + } + catch + { + models = ["llama3.2", "qwen2.5", "phi3:mini"]; + Console.WriteLine(" Using default model list (could not enumerate)"); + } + + // ═══════════════════════════════════════════════ + // 3. Demo: Fluent API with WithAutoChunking() + // ═══════════════════════════════════════════════ + Console.WriteLine("\n── Path 1: Fluent API ──\n"); + + var model1 = models.FirstOrDefault(m => m.Contains("phi", StringComparison.OrdinalIgnoreCase)) ?? models[0]; + var model2 = models.FirstOrDefault(m => m.Contains("llama", StringComparison.OrdinalIgnoreCase)) ?? models[0]; + var chairModel = models.FirstOrDefault(m => m.Contains("qwen", StringComparison.OrdinalIgnoreCase)) ?? models[0]; + + Console.WriteLine($" Small-context model: {model1}"); + Console.WriteLine($" Large-context model: {model2}"); + Console.WriteLine($" Chairman model: {chairModel}\n"); + + try + { + var executor1 = new CouncilBuilder() + .AddMember(model1, provider, "Legal Expert", + "You are a legal expert specialising in contract law. Analyse contracts for risks, liabilities, and compliance issues.") + .AddMember(model2, provider, "Business Analyst", + "You are a business analyst. Evaluate contracts from a commercial perspective — pricing, terms, obligations.") + .SetChairman(chairModel, provider) + .WithKnowledge(kb) + .WithUserPrompt("Проанализируй данный договор на предмет рисков для заказчика. Выдели ключевые проблемные пункты.") + .WithSystemPrompt("You are an expert participating in a contract review council. Be thorough and precise.") + .WithMaxRounds(4) + .WithTemperature(0.3f) + .WithResponseLanguage("Russian") + .WithAutoChunking(new AutoChunkingOptions + { + Strategy = ChunkingStrategy.SemanticBoundary, + SafetyMargin = 0.15, + MaxChunksPerRound = 3, + EnableProgressiveDisclosure = true + }) + .SaveResultTo(Path.Combine(Directory.GetCurrentDirectory(), "debate_results", "autochunking_fluent.md")) + .Build(); + + Console.WriteLine(executor1.GetInfo()); + Console.WriteLine("\n🎯 Running debate with AutoChunking (fluent API)...\n"); + + var result1 = await executor1.ExecuteAsync(); + PrintResultSummary(result1); + } + catch (Exception ex) + { + Console.WriteLine($" ⚠️ Fluent API demo failed: {ex.Message}"); + Console.WriteLine(" (This is expected if the models are not pulled locally)"); + } + + // ═══════════════════════════════════════════════ + // 4. Demo: CouncilOptions snapshot + // ═══════════════════════════════════════════════ + Console.WriteLine("\n── Path 2: CouncilOptions snapshot ──\n"); + + try + { + var options = new CouncilOptions + { + Strategy = "Standard", + MaxRounds = 4, + Temperature = 0.3f, + SystemPrompt = "You are an expert participating in a contract review council.", + ResponseLanguage = "Russian", + AutoChunking = new AutoChunkingConfig + { + Enabled = true, + Strategy = "SemanticBoundary", + SafetyMargin = 0.15, + MaxChunksPerRound = 3, + EnableProgressiveDisclosure = true + } + }; + + var executor2 = new CouncilBuilder() + .WithOptions(options) // ← bulk configuration + .AddMember(model1, provider, "Legal Expert") + .AddMember(model2, provider, "Business Analyst") + .SetChairman(chairModel, provider) + .WithKnowledge(kb) + .WithUserPrompt("Выдели топ-5 самых рискованных пунктов договора и предложи изменения.") + .SaveResultTo(Path.Combine(Directory.GetCurrentDirectory(), "debate_results", "autochunking_options.md")) + .Build(); + + Console.WriteLine(executor2.GetInfo()); + Console.WriteLine("\n🎯 Running debate with AutoChunking (options snapshot)...\n"); + + var result2 = await executor2.ExecuteAsync(); + PrintResultSummary(result2); + } + catch (Exception ex) + { + Console.WriteLine($" ⚠️ Options snapshot demo failed: {ex.Message}"); + } + + // ═══════════════════════════════════════════════ + // 5. Demo: Lambda configuration + // ═══════════════════════════════════════════════ + Console.WriteLine("\n── Path 3: Lambda configuration ──\n"); + + try + { + var executor3 = new CouncilBuilder() + .WithOptions(o => + { + o.Strategy = "Critique"; + o.MaxRounds = 4; + o.Temperature = 0.3f; + o.ResponseLanguage = "Russian"; + o.AutoChunking.Enabled = true; + o.AutoChunking.Strategy = "SemanticBoundary"; + o.AutoChunking.MaxChunksPerRound = 2; + o.AutoChunking.EnableProgressiveDisclosure = true; + }) + .AddMember(model1, provider, "Legal Expert") + .AddMember(model2, provider, "Business Analyst") + .SetChairman(chairModel, provider) + .WithKnowledge(kb) + .WithUserPrompt("Проведи адверсариальный анализ договора: один ищет риски, другой защищает условия.") + .SaveResultTo(Path.Combine(Directory.GetCurrentDirectory(), "debate_results", "autochunking_lambda.md")) + .Build(); + + Console.WriteLine(executor3.GetInfo()); + Console.WriteLine("\n🎯 Running debate with AutoChunking (lambda config)...\n"); + + var result3 = await executor3.ExecuteAsync(); + PrintResultSummary(result3); + } + catch (Exception ex) + { + Console.WriteLine($" ⚠️ Lambda config demo failed: {ex.Message}"); + } + + // ═══════════════════════════════════════════════ + // 6. Model Context Window Registry demo + // ═══════════════════════════════════════════════ + Console.WriteLine("\n── Model Context Window Registry ──\n"); + + Console.WriteLine(" Known model context windows:"); + foreach (var testModel in new[] { "llama3.2", "phi3:mini", "qwen2.5:7b", "deepseek-r1", "gpt-4o", "unknown-model" }) + { + var window = ModelContextWindowRegistry.GetContextWindow(testModel); + Console.WriteLine($" {testModel,-25} → {(window is { } w ? $"{w:N0} tokens" : "unknown")}"); + } + + Console.WriteLine("\n ✅ AutoChunking example complete!"); + } + + /// + /// Demonstrates the chunking plan without running a live debate (offline mode). + /// + private static Task DemoChunkingPlanOnly(MarkdownKnowledgeBase kb) + { + Console.WriteLine("\n── Chunking Plan Demo (offline) ──\n"); + + var content = kb.GetAllContent(); + if (string.IsNullOrWhiteSpace(content)) + { + Console.WriteLine(" No knowledge content to chunk."); + return Task.CompletedTask; + } + + // Simulate different context window sizes. + foreach (var windowSize in new[] { 4096, 8192, 32768, 131072 }) + { + var overhead = 5000; // system prompt + user question + response buffer + var plan = AutoChunker.CreatePlan(content, windowSize, overhead); + + Console.WriteLine($" Context window: {windowSize:N0} tokens:"); + Console.WriteLine($" Fits in single round: {plan.FitsInSingleRound}"); + Console.WriteLine($" Total chunks: {plan.TotalChunks}"); + Console.WriteLine($" Tokens per chunk: ~{plan.EstimatedTokensPerChunk:N0}"); + Console.WriteLine($" Available per round: {plan.AvailableTokensPerRound:N0}"); + Console.WriteLine($" Recommended rounds: {plan.RecommendedRounds}"); + Console.WriteLine(); + } + + return Task.CompletedTask; + } + + private static void PrintResultSummary(DebateResult result) + { + Console.WriteLine($"\n ✅ Debate completed: {result.Rounds.Count} rounds, {result.TotalDuration.TotalSeconds:F1}s"); + if (result.TokenStats is not null) + Console.WriteLine($" 📊 Tokens: {result.TokenStats.TotalOriginalTokens:N0} original → {result.TokenStats.TotalCompressedTokens:N0} compressed"); + + if (!string.IsNullOrWhiteSpace(result.FinalVerdict)) + { + var preview = result.FinalVerdict.Length > 500 + ? result.FinalVerdict[..500] + "…" + : result.FinalVerdict; + Console.WriteLine($"\n 📝 Verdict preview:\n {preview.Replace("\n", "\n ")}"); + } + } + + private static async Task IsAvailableSafe(ILLMProvider provider, string label) + { + try + { + var ok = await provider.IsAvailableAsync(); + Console.WriteLine($" {label}: {(ok ? "✅ Available" : "❌ Unavailable")}"); + return ok; + } + catch + { + Console.WriteLine($" {label}: ❌ Unavailable"); + return false; + } + } + + /// + /// Generates a synthetic contract document large enough to trigger chunking + /// on small-context models (4K–8K tokens). + /// + private static string GenerateSyntheticContract() + { + var sb = new StringBuilder(); + + sb.AppendLine("# ДОГОВОР ОКАЗАНИЯ УСЛУГ ПО РАЗРАБОТКЕ ПРОГРАММНОГО ОБЕСПЕЧЕНИЯ"); + sb.AppendLine(); + sb.AppendLine("г. Москва «___» ________ 2026 г."); + sb.AppendLine(); + sb.AppendLine("## 1. ПРЕДМЕТ ДОГОВОРА"); + sb.AppendLine(); + sb.AppendLine("1.1. Исполнитель обязуется по заданию Заказчика оказать услуги по разработке программного обеспечения (далее — «ПО»), а Заказчик обязуется принять и оплатить эти услуги в порядке и на условиях, предусмотренных настоящим Договором."); + sb.AppendLine(); + sb.AppendLine("1.2. Техническое задание (ТЗ) является неотъемлемой частью настоящего Договора (Приложение №1). Любые изменения ТЗ оформляются дополнительным соглашением сторон."); + sb.AppendLine(); + sb.AppendLine("1.3. Срок оказания услуг: с даты подписания Договора до полного выполнения обязательств, но не позднее 6 (шести) месяцев с даты начала работ."); + sb.AppendLine(); + + // Generate many sections to make the document large. + var sections = new (string Title, string[] Paragraphs)[] + { + ("## 2. СТОИМОСТЬ УСЛУГ И ПОРЯДОК РАСЧЁТОВ", [ + "2.1. Общая стоимость услуг по настоящему Договору составляет 5 000 000 (пять миллионов) рублей, НДС не облагается в связи с применением Исполнителем упрощённой системы налогообложения.", + "2.2. Оплата производится поэтапно:", + "2.2.1. Аванс в размере 30% от общей стоимости — 1 500 000 рублей — в течение 5 рабочих дней с даты подписания Договора.", + "2.2.2. Промежуточный платёж в размере 40% — 2 000 000 рублей — после демонстрации MVP (минимально жизнеспособного продукта) и подписания акта приёмки промежуточного этапа.", + "2.2.3. Окончательный расчёт в размере 30% — 1 500 000 рублей — в течение 10 рабочих дней после подписания итогового акта приёмки-сдачи.", + "2.3. Все платежи осуществляются в безналичной форме на расчётный счёт Исполнителя. Датой оплаты считается дата списания денежных средств с расчётного счёта Заказчика.", + "2.4. В случае задержки оплаты Заказчик уплачивает пени в размере 0.1% от неоплаченной суммы за каждый день просрочки, но не более 10% от общей стоимости Договора." + ]), + ("## 3. ПРАВА И ОБЯЗАННОСТИ СТОРОН", [ + "3.1. Исполнитель обязуется:", + "3.1.1. Оказать услуги качественно и в срок, соответствующий требованиям ТЗ и профессиональным стандартам в области разработки ПО.", + "3.1.2. Предоставлять Заказчику еженедельные отчёты о ходе работ.", + "3.1.3. Обеспечить конфиденциальность всей информации, полученной от Заказчика.", + "3.1.4. Передать Заказчику исключительные права на разработанное ПО в полном объёме с момента подписания итогового акта.", + "3.1.5. Обеспечить соответствие ПО требованиям законодательства РФ о персональных данных (152-ФЗ).", + "3.2. Заказчик обязуется:", + "3.2.1. Предоставить Исполнителю всю необходимую информацию и доступы для выполнения работ.", + "3.2.2. Своевременно принимать и оплачивать оказанные услуги.", + "3.2.3. Назначить ответственное лицо для оперативного взаимодействия с Исполнителем.", + "3.2.4. Не вмешиваться в операционную деятельность Исполнителя, за исключением согласования ключевых этапов." + ]), + ("## 4. ПОРЯДОК СДАЧИ-ПРИЁМКИ УСЛУГ", [ + "4.1. По завершении каждого этапа Исполнитель направляет Заказчику акт приёмки-сдачи и результаты работ.", + "4.2. Заказчик в течение 10 рабочих дней обязан подписать акт или направить мотивированный отказ с перечнем замечаний.", + "4.3. Если Заказчик не подписал акт и не направил мотивированный отказ в указанный срок, услуги считаются принятыми в полном объёме.", + "4.4. Исполнитель обязан устранить обоснованные замечания в течение 15 рабочих дней с даты их получения.", + "4.5. Приёмка итогового результата осуществляется комиссией, состоящей из представителей обеих сторон." + ]), + ("## 5. ГАРАНТИЙНЫЕ ОБЯЗАТЕЛЬСТВА", [ + "5.1. Исполнитель гарантирует качество оказанных услуг в соответствии с ТЗ и профессиональными стандартами.", + "5.2. Гарантийный срок на разработанное ПО составляет 12 месяцев с даты подписания итогового акта приёмки-сдачи.", + "5.3. В течение гарантийного срока Исполнитель обязуется безвозмездно устранять дефекты, допущенные по его вине.", + "5.4. Гарантия не распространяется на дефекты, возникшие в результате:", + "5.4.1. Неправильной эксплуатации ПО Заказчиком.", + "5.4.2. Внесения изменений в ПО третьими лицами без согласования с Исполнителем.", + "5.4.3. Действий непреодолимой силы.", + "5.5. Срок реакции на гарантийный инцидент — не более 8 рабочих часов. Срок устранения — не более 5 рабочих дней." + ]), + ("## 6. ОТВЕТСТВЕННОСТЬ СТОРОН", [ + "6.1. За неисполнение или ненадлежащее исполнение обязательств стороны несут ответственность в соответствии с действующим законодательством РФ.", + "6.2. Исполнитель несёт ответственность за:", + "6.2.1. Нарушение сроков выполнения работ — пени в размере 0.1% от стоимости невыполненных работ за каждый день просрочки.", + "6.2.2. Некачественное выполнение работ — устранение дефектов за свой счёт.", + "6.2.3. Нарушение конфиденциальности — штраф в размере 500 000 рублей за каждый подтверждённый случай.", + "6.3. Заказчик несёт ответственность за:", + "6.3.1. Нарушение сроков оплаты — пени согласно п. 2.4.", + "6.3.2. Непредоставление необходимой информации — продление сроков выполнения работ соразмерно задержке.", + "6.4. Общий размер ответственности Исполнителя ограничен суммой, фактически уплаченной Заказчиком по Договору.", + "6.5. Стороны освобождаются от ответственности за частичное или полное неисполнение обязательств, если оно явилось следствием обстоятельств непреодолимой силы." + ]), + ("## 7. ИНТЕЛЛЕКТУАЛЬНАЯ СОБСТВЕННОСТЬ", [ + "7.1. Исключительные права на разработанное ПО, включая исходный код, документацию, дизайн и иные результаты интеллектуальной деятельности, переходят к Заказчику в полном объёме с момента подписания итогового акта приёмки-сдачи.", + "7.2. До момента перехода прав Исполнитель вправе использовать разработанное ПО исключительно в целях исполнения настоящего Договора.", + "7.3. Исполнитель гарантирует, что разработанное ПО не нарушает прав третьих лиц на объекты интеллектуальной собственности.", + "7.4. В случае предъявления претензий третьими лицами Исполнитель обязуется урегулировать их самостоятельно и за свой счёт.", + "7.5. Исполнитель сохраняет право использовать общие методы, алгоритмы и ноу-хау, разработанные в ходе исполнения Договора, в других проектах, если это не нарушает конфиденциальность." + ]), + ("## 8. КОНФИДЕНЦИАЛЬНОСТЬ", [ + "8.1. Стороны обязуются сохранять конфиденциальность всей информации, полученной друг от друга в ходе исполнения Договора.", + "8.2. К конфиденциальной информации относится: техническая документация, бизнес-процессы, коммерческая тайна, персональные данные, финансовая информация.", + "8.3. Обязательства по конфиденциальности сохраняют силу в течение 3 лет после прекращения действия Договора.", + "8.4. Стороны обязуются обеспечить режим конфиденциальности своими сотрудниками и привлечёнными третьими лицами.", + "8.5. Разглашение конфиденциальной информации допускается только в случаях, предусмотренных законодательством РФ." + ]), + ("## 9. РАСТОРЖЕНИЕ ДОГОВОРА", [ + "9.1. Договор может быть расторгнут по соглашению сторон или в одностороннем порядке в случаях, предусмотренных законодательством РФ.", + "9.2. Заказчик вправе расторгнуть Договор в одностороннем порядке в случаях:", + "9.2.1. Просрочки Исполнителем сроков выполнения работ более чем на 30 календарных дней.", + "9.2.2. Существенного несоответствия результатов работ требованиям ТЗ, не устранённого в разумный срок.", + "9.3. Исполнитель вправе расторгнуть Договор в одностороннем порядке в случаях:", + "9.3.1. Просрочки Заказчиком оплаты более чем на 30 календарных дней.", + "9.3.2. Непредоставления Заказчиком информации, необходимой для выполнения работ, в течение 20 рабочих дней после запроса.", + "9.4. При расторжении Договора Исполнитель возвращает Заказчику неотработанный аванс за вычетом стоимости фактически выполненных работ." + ]), + ("## 10. РАЗРЕШЕНИЕ СПОРОВ", [ + "10.1. Все споры и разногласия, возникающие из настоящего Договора, разрешаются путём переговоров.", + "10.2. Срок ответа на претензию — 15 рабочих дней с даты её получения.", + "10.3. При недостижении согласия спор передаётся на рассмотрение Арбитражного суда г. Москвы.", + "10.4. Применимое право — законодательство Российской Федерации." + ]), + ("## 11. ЗАКЛЮЧИТЕЛЬНЫЕ ПОЛОЖЕНИЯ", [ + "11.1. Договор вступает в силу с даты его подписания обеими сторонами и действует до полного исполнения обязательств.", + "11.2. Все изменения и дополнения к Договору действительны только в письменной форме и подписываются обеими сторонами.", + "11.3. Стороны признают юридическую силу документов, переданных по электронной почте, при условии последующего обмена оригиналами.", + "11.4. Договор составлен в двух экземплярах, имеющих равную юридическую силу — по одному для каждой из сторон." + ]) + }; + + foreach (var (title, paragraphs) in sections) + { + sb.AppendLine(title); + sb.AppendLine(); + foreach (var p in paragraphs) + { + sb.AppendLine(p); + sb.AppendLine(); + } + } + + // Add detailed risk analysis commentary to make the document even larger. + sb.AppendLine("## 12. АНАЛИЗ РИСКОВ (ПРИЛОЖЕНИЕ)"); + sb.AppendLine(); + sb.AppendLine("12.1. Ниже представлен детальный анализ потенциальных рисков для Заказчика по каждому разделу Договора."); + sb.AppendLine(); + + var riskItems = new[] + { + "12.2. Риск по п. 1.3 (Сроки): Отсутствие жёсткой даты завершения работ. Формулировка «не позднее 6 месяцев» без указания конкретной даты создаёт неопределённость. Рекомендуется указать точную дату или привязать срок к календарному плану.", + "12.3. Риск по п. 2.2 (Поэтапная оплата): Аванс 30% является стандартным, однако промежуточный платёж 40% после MVP создаёт риск — критерии MVP должны быть чётко определены в ТЗ во избежание споров о готовности.", + "12.4. Риск по п. 2.4 (Пени): Ограничение пеней 10% от стоимости Договора может быть недостаточным при длительной просрочке. Рекомендуется увеличить до 20% или исключить ограничение.", + "12.5. Риск по п. 3.1.4 (Права на ПО): Переход прав с момента подписания акта — стандартная практика. Однако необходимо убедиться, что в ТЗ явно указано, что все компоненты ПО (включая сторонние библиотеки) либо созданы Исполнителем, либо имеют надлежащие лицензии.", + "12.6. Риск по п. 4.3 (Молчаливая приёмка): Автоматическая приёмка при отсутствии мотивированного отказа в течение 10 дней — существенный риск. Рекомендуется увеличить срок до 20 рабочих дней и добавить требование повторного уведомления.", + "12.7. Риск по п. 5.2 (Гарантийный срок): 12 месяцев — стандартный срок, однако для сложного ПО может быть недостаточным. Рекомендуется увеличить до 24 месяцев или предусмотреть возможность продления.", + "12.8. Риск по п. 6.4 (Ограничение ответственности): Ограничение ответственности Исполнителя суммой Договора — критический риск. При срыве проекта убытки Заказчика могут многократно превышать стоимость Договора. Рекомендуется исключить это ограничение или увеличить лимит.", + "12.9. Риск по п. 7.5 (Ноу-хау Исполнителя): Исполнитель сохраняет право использовать методы и алгоритмы в других проектах. Это может привести к передаче конкурентных преимуществ Заказчика третьим лицам. Рекомендуется ограничить использование ноу-хау проектами, не конкурирующими с бизнесом Заказчика.", + "12.10. Риск по п. 8.3 (Срок конфиденциальности): 3 года после прекращения Договора — недостаточный срок для коммерческой тайны. Рекомендуется увеличить до 5 лет или сделать бессрочным для ключевой информации.", + "12.11. Риск по п. 9.2.1 (Одностороннее расторжение): 30 дней просрочки — разумный срок, но необходимо добавить право Заказчика привлечь третье лицо для завершения работ за счёт Исполнителя.", + "12.12. Риск по п. 10.3 (Подсудность): Арбитражный суд г. Москвы — стандартная практика для московских компаний. Если Заказчик находится в другом регионе, рекомендуется указать суд по месту нахождения Заказчика." + }; + + foreach (var item in riskItems) + { + sb.AppendLine(item); + sb.AppendLine(); + } + + // Add more filler to ensure the document is large enough for chunking. + sb.AppendLine("## 13. ТЕХНИЧЕСКИЕ ТРЕБОВАНИЯ (СПРАВОЧНО)"); + sb.AppendLine(); + sb.AppendLine("13.1. Разрабатываемое ПО должно соответствовать следующим техническим требованиям:"); + sb.AppendLine(); + sb.AppendLine("- Архитектура: микросервисная, контейнеризация Docker, оркестрация Kubernetes."); + sb.AppendLine("- Стек: .NET 10, C# 15, PostgreSQL 17, Redis, RabbitMQ."); + sb.AppendLine("- Фронтенд: React 19, TypeScript 5.7, Next.js 15."); + sb.AppendLine("- CI/CD: GitHub Actions, автоматическое тестирование (unit, integration, e2e)."); + sb.AppendLine("- Мониторинг: OpenTelemetry, Prometheus, Grafana, ELK Stack."); + sb.AppendLine("- Безопасность: OWASP Top-10, пентест перед релизом, SAST/DAST сканирование."); + sb.AppendLine("- Нагрузка: до 10 000 RPS, время ответа API ≤ 200ms (p95)."); + sb.AppendLine("- Доступность: 99.95% (SLA), автоматическое восстановление после сбоев."); + sb.AppendLine("- Локализация: русский, английский, китайский языки интерфейса."); + sb.AppendLine("- Доступность: WCAG 2.1 Level AA."); + sb.AppendLine(); + sb.AppendLine("13.2. Все требования должны быть верифицированы на этапе приёмки. Несоответствие любому из требований является основанием для отказа в приёмке."); + sb.AppendLine(); + sb.AppendLine("---"); + sb.AppendLine(); + sb.AppendLine("**Подписи сторон:**"); + sb.AppendLine(); + sb.AppendLine("| Исполнитель | Заказчик |"); + sb.AppendLine("|-------------|----------|"); + sb.AppendLine("| ___________ | ___________ |"); + sb.AppendLine("| М.П. | М.П. |"); + + return sb.ToString(); + } +} diff --git a/src/Delibera.ConsoleApp/Program.cs b/src/Delibera.ConsoleApp/Program.cs index 4a4534a..38073be 100644 --- a/src/Delibera.ConsoleApp/Program.cs +++ b/src/Delibera.ConsoleApp/Program.cs @@ -112,10 +112,17 @@ private static async Task RunAsync(string[] args, Action onDebateFailed) return; } + if (args.Contains("--autochunking")) + { + await AutoChunkingExample.RunAsync(); + return; + } + // Quick DI showcase before main demo Console.WriteLine("🆕 v3.1 DI Quick Demo:"); Console.WriteLine(" Run with --di for full DI example"); - Console.WriteLine(" Run with --separate-files for file output demo\n"); + Console.WriteLine(" Run with --separate-files for file output demo"); + Console.WriteLine(" Run with --autochunking for AutoChunking demo (large documents)\n"); // ═══════════════════════════════════════════════ // 1. Load configuration diff --git a/src/Delibera.ConsoleApp/appsettings.json b/src/Delibera.ConsoleApp/appsettings.json index 7ff7085..0acf4df 100644 --- a/src/Delibera.ConsoleApp/appsettings.json +++ b/src/Delibera.ConsoleApp/appsettings.json @@ -38,6 +38,18 @@ "Directory": "./debate_results", "SeparateFiles": true, "FilePrefix": null + }, + + "AutoChunking": { + "Enabled": false, + "Strategy": "SemanticBoundary", + "SafetyMargin": 0.15, + "MaxChunksPerRound": 3, + "EnableMapReduce": true, + "EnableProgressiveDisclosure": true, + "ModelContextWindows": { + "my-custom-model": 65536 + } } }, diff --git a/src/Delibera.Core/Chunking/AutoChunker.cs b/src/Delibera.Core/Chunking/AutoChunker.cs new file mode 100644 index 0000000..eaf92c5 --- /dev/null +++ b/src/Delibera.Core/Chunking/AutoChunker.cs @@ -0,0 +1,585 @@ +using Delibera.Core.Compression; + +namespace Delibera.Core.Chunking; + +/// +/// Strategy for splitting a large document into chunks that fit within a model's context window. +/// +public enum ChunkingStrategy +{ + /// + /// Splits on semantic boundaries — Markdown headers first, then paragraphs, then sentences. + /// Best for structured documents (contracts, reports, articles). + /// + SemanticBoundary = 0, + + /// + /// Splits into fixed-size chunks of approximately availableTokens tokens each. + /// Fast but may break mid-sentence. Good for unstructured text. + /// + FixedSize, + + /// + /// Sliding window with 20% overlap between chunks. Each chunk starts halfway through + /// the previous one, ensuring no information is lost at boundaries. + /// Best for narrative text where context continuity matters. + /// + SlidingWindow +} + +/// +/// A single chunk of a larger document, annotated with positional metadata. +/// +public sealed record DocumentChunk +{ + /// Zero-based index of this chunk within the plan. + public required int Index { get; init; } + + /// The chunk text content. + public required string Content { get; init; } + + /// Estimated token count for this chunk. + public required int EstimatedTokens { get; init; } + + /// + /// The nearest Markdown heading that precedes this chunk, or the document title. + /// Provides context so the model knows which section it is reading. + /// + public required string SectionTitle { get; init; } + + /// Character offset where this chunk starts in the original document. + public required int StartChar { get; init; } + + /// Character offset where this chunk ends in the original document (exclusive). + public required int EndChar { get; init; } +} + +/// +/// The result of planning how to split a document for progressive disclosure across debate rounds. +/// +public sealed record ChunkingPlan +{ + /// All chunks in order. + public required IReadOnlyList Chunks { get; init; } + + /// Total number of chunks. + public required int TotalChunks { get; init; } + + /// Average estimated tokens per chunk. + public required int EstimatedTokensPerChunk { get; init; } + + /// The context window size used for planning (tokens). + public required int ContextWindowTokens { get; init; } + + /// Tokens available per round after subtracting overhead. + public required int AvailableTokensPerRound { get; init; } + + /// true when the entire document fits in a single round without chunking. + public required bool FitsInSingleRound { get; init; } + + /// Recommended number of debate rounds to cover all chunks. + public required int RecommendedRounds { get; init; } + + /// Estimated token overhead per round (system prompt + user question + response buffer). + public required int OverheadTokens { get; init; } +} + +/// +/// Splits large documents into context-window-sized chunks using semantic boundary detection. +/// Used by to enable progressive disclosure in debates. +/// +/// +/// +/// The chunker uses for token estimation. +/// For precise counts, configure to match +/// the model family (4.0 for GPT-style, 3.5 for Llama). +/// +/// +/// (default) respects document structure: +/// it splits on Markdown headers first, then paragraph breaks, and only falls back to +/// sentence boundaries when a single section is too large. +/// +/// +public static class AutoChunker +{ + // Separators in priority order — from largest structural boundary to smallest. + private static readonly string[] HeaderSeps = ["\n# ", "\n## ", "\n### ", "\n#### ", "\n##### "]; + private static readonly string[] ParaSeps = ["\n\n", "\r\n\r\n"]; + private static readonly string[] SentSeps = [". ", "! ", "? ", ".\n", "!\n", "?\n", ".\r\n", "!\r\n", "?\r\n"]; + + /// + /// Creates a chunking plan for the given document, respecting the model's context window. + /// + /// The full document text. + /// + /// The model's total context window in tokens. This is the hard limit — the chunker + /// ensures no single chunk exceeds contextWindowTokens - overheadTokens. + /// + /// + /// Estimated tokens consumed by the system prompt, user question, debate history, + /// and expected response. Subtracted from the context window to determine available space. + /// + /// Chunking strategy to use. + /// + /// Additional safety margin as a fraction of the context window (0.0–0.5). + /// Default 0.15 = 15% reserved for token estimation inaccuracy. + /// + /// A describing how to split and distribute the document. + public static ChunkingPlan CreatePlan( + string document, + int contextWindowTokens, + int overheadTokens, + ChunkingStrategy strategy = ChunkingStrategy.SemanticBoundary, + double safetyMargin = 0.15) + { + ArgumentException.ThrowIfNullOrWhiteSpace(document); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(contextWindowTokens); + ArgumentOutOfRangeException.ThrowIfNegative(overheadTokens); + + // Calculate available tokens per round with safety margin. + var safetyReserve = (int)(contextWindowTokens * Math.Clamp(safetyMargin, 0.0, 0.5)); + var availableTokens = contextWindowTokens - overheadTokens - safetyReserve; + if (availableTokens <= 0) + availableTokens = Math.Max(256, contextWindowTokens / 4); // fallback: at least 256 tokens + + var totalTokens = TokenCounter.Default.EstimateTokens(document); + var fitsInSingle = totalTokens <= availableTokens; + + List chunks; + if (fitsInSingle) + { + chunks = + [ + new DocumentChunk + { + Index = 0, + Content = document, + EstimatedTokens = totalTokens, + SectionTitle = "Full Document", + StartChar = 0, + EndChar = document.Length + } + ]; + } + else + { + chunks = strategy switch + { + ChunkingStrategy.SlidingWindow => ChunkBySlidingWindow(document, availableTokens), + ChunkingStrategy.FixedSize => ChunkByFixedSize(document, availableTokens), + _ => ChunkBySemanticBoundaries(document, availableTokens) + }; + } + + // Recommend rounds: aim for ~3 chunks per round, minimum 1. + var recommendedRounds = Math.Max(1, (int)Math.Ceiling((double)chunks.Count / 3)); + + return new ChunkingPlan + { + Chunks = chunks.AsReadOnly(), + TotalChunks = chunks.Count, + EstimatedTokensPerChunk = chunks.Count > 0 + ? (int)chunks.Average(c => c.EstimatedTokens) + : 0, + ContextWindowTokens = contextWindowTokens, + AvailableTokensPerRound = availableTokens, + FitsInSingleRound = fitsInSingle, + RecommendedRounds = recommendedRounds, + OverheadTokens = overheadTokens + }; + } + + // ────────────────────────────────────────────── + // Strategy: SemanticBoundary + // ────────────────────────────────────────────── + + private static List ChunkBySemanticBoundaries(string doc, int maxTokens) + { + var sections = SplitByHeaders(doc); + var chunks = new List(); + var index = 0; + + foreach (var (title, content, start, end) in sections) + { + var sectionTokens = TokenCounter.Default.EstimateTokens(content); + + if (sectionTokens <= maxTokens) + { + chunks.Add(MakeChunk(index++, content, title, start, end)); + } + else + { + // Section too large — split by paragraphs. + var subChunks = SplitByParagraphs(content, maxTokens); + foreach (var sc in subChunks) + chunks.Add(MakeChunk(index++, sc, title, start, end)); + } + } + + return chunks; + } + + /// + /// Splits text into sections delimited by Markdown headers. + /// Each section includes its header line as the title. + /// Uses a single-pass scan over the text — O(n) instead of O(n·k) per iteration. + /// + private static List<(string Title, string Content, int Start, int End)> SplitByHeaders(string doc) + { + var sections = new List<(string, string, int, int)>(); + var span = doc.AsSpan(); + + // Single-pass: scan for any header pattern at each position. + var headerPositions = new List<(int Pos, int Level, string Title)>(); + var i = 0; + while (i < span.Length) + { + // Check if current position starts a header line (must be at line start or after \n). + if (span[i] == '#' && (i == 0 || span[i - 1] == '\n')) + { + var hashCount = 1; + var j = i + 1; + while (j < span.Length && span[j] == '#') { hashCount++; j++; } + // Must be followed by a space to be a valid Markdown header. + if (j < span.Length && span[j] == ' ' && hashCount <= 6) + { + j++; // skip the space + var titleStart = j; + while (j < span.Length && span[j] != '\n' && span[j] != '\r') j++; + var title = span[titleStart..j].Trim().ToString(); + headerPositions.Add((i, hashCount, title)); + i = j; // continue after this header line + } + } + + // Advance to next newline. + while (i < span.Length && span[i] != '\n') i++; + if (i < span.Length) i++; // skip the newline + } + + if (headerPositions.Count == 0) + { + sections.Add(("Document", doc, 0, doc.Length)); + return sections; + } + + // Build sections between headers. + for (var idx = 0; idx < headerPositions.Count; idx++) + { + var (pos, _, title) = headerPositions[idx]; + var contentStart = doc.IndexOf('\n', pos); + if (contentStart < 0) contentStart = pos; + else contentStart++; + + var contentEnd = idx + 1 < headerPositions.Count + ? headerPositions[idx + 1].Pos + : doc.Length; + + var content = doc[contentStart..contentEnd].Trim(); + if (content.Length > 0) + sections.Add((title, content, contentStart, contentEnd)); + } + + // If there's text before the first header, add it as a preamble. + if (headerPositions[0].Pos > 0) + { + var preamble = doc[..headerPositions[0].Pos].Trim(); + if (preamble.Length > 0) + sections.Insert(0, ("Preamble", preamble, 0, headerPositions[0].Pos)); + } + + return sections; + } + + private static List SplitByParagraphs(string text, int maxTokens) + { + var paragraphs = SplitBySeparators(text, ParaSeps); + return MergeSmallChunks(paragraphs, maxTokens); + } + + // ────────────────────────────────────────────── + // Strategy: FixedSize + // ────────────────────────────────────────────── + + private static List ChunkByFixedSize(string doc, int maxTokens) + { + // Approximate character count for maxTokens. + var approxChars = (int)(maxTokens * TokenCounter.Default.CharsPerToken); + var chunks = new List(); + var index = 0; + var pos = 0; + + while (pos < doc.Length) + { + var end = Math.Min(pos + approxChars, doc.Length); + + // Try to break at a sentence boundary near the target. + if (end < doc.Length) + { + var bestBreak = FindBestBreak(doc, pos, end); + if (bestBreak > pos) end = bestBreak; + } + + var chunk = doc[pos..end].Trim(); + if (chunk.Length > 0) + { + chunks.Add(MakeChunk(index++, chunk, $"Part {index}", pos, end)); + } + + if (end >= doc.Length) break; + pos = end; + } + + return chunks; + } + + // ────────────────────────────────────────────── + // Strategy: SlidingWindow + // ────────────────────────────────────────────── + + private static List ChunkBySlidingWindow(string doc, int maxTokens) + { + var approxChars = (int)(maxTokens * TokenCounter.Default.CharsPerToken); + var step = approxChars / 2; // 50% overlap + var chunks = new List(); + var index = 0; + var pos = 0; + + while (pos < doc.Length) + { + var end = Math.Min(pos + approxChars, doc.Length); + + if (end < doc.Length) + { + var bestBreak = FindBestBreak(doc, pos, end); + if (bestBreak > pos) end = bestBreak; + } + + var chunk = doc[pos..end].Trim(); + if (chunk.Length > 0) + { + chunks.Add(MakeChunk(index++, chunk, $"Window {index}", pos, end)); + } + + if (end >= doc.Length) break; + pos = Math.Max(pos + step, pos + 1); + } + + return chunks; + } + + // ────────────────────────────────────────────── + // Helpers + // ────────────────────────────────────────────── + + private static List SplitBySeparators(string text, string[] separators) + { + var parts = new List(); + var span = text.AsSpan(); + + // Single-pass: find the first matching separator and split on it. + foreach (var sep in separators) + { + if (span.IsEmpty) break; + + var sepSpan = sep.AsSpan(); + var idx = span.IndexOf(sepSpan, StringComparison.Ordinal); + if (idx >= 0) + { + // Split on all occurrences of this separator. + var pos = 0; + while (pos < span.Length) + { + var nextIdx = span[pos..].IndexOf(sepSpan, StringComparison.Ordinal); + if (nextIdx < 0) + { + var remaining = span[pos..].Trim(); + if (remaining.Length > 0) + parts.Add(remaining.ToString()); + break; + } + + var part = span.Slice(pos, nextIdx).Trim(); + if (part.Length > 0) + parts.Add(part.ToString()); + + pos += nextIdx + sepSpan.Length; + } + + return parts; + } + } + + // No separator matched — split by sentences as last resort. + var sentParts = SplitBySentencesSpan(span); + foreach (var p in sentParts) + { + var trimmed = p.Trim(); + if (trimmed.Length > 0) + parts.Add(trimmed); + } + + return parts.Count > 0 ? parts : [text.Trim()]; + } + + /// + /// Splits a span by sentence-ending delimiters without intermediate string.Split allocations. + /// + private static List SplitBySentencesSpan(ReadOnlySpan span) + { + var parts = new List(); + var pos = 0; + + while (pos < span.Length) + { + var nextDelim = int.MaxValue; + var delimLen = 0; + + foreach (var d in SentSeps) + { + var idx = span[pos..].IndexOf(d.AsSpan(), StringComparison.Ordinal); + if (idx >= 0 && pos + idx < nextDelim) + { + nextDelim = pos + idx; + delimLen = d.Length; + } + } + + if (nextDelim == int.MaxValue) + { + var remaining = span[pos..].Trim(); + if (remaining.Length > 0) + parts.Add(remaining.ToString()); + break; + } + + var sentence = span[pos..(nextDelim + delimLen)].Trim(); + if (sentence.Length > 0) + parts.Add(sentence.ToString()); + + pos = nextDelim + delimLen; + } + + return parts; + } + + /// + /// Merges small chunks together so each chunk is as close to maxTokens as possible + /// without exceeding it. This avoids having many tiny chunks. + /// + private static List MergeSmallChunks(List parts, int maxTokens) + { + var merged = new List(); + var current = new StringBuilder(); + var currentTokens = 0; + + foreach (var part in parts) + { + var partTokens = TokenCounter.Default.EstimateTokens(part); + + if (currentTokens + partTokens <= maxTokens) + { + if (current.Length > 0) current.Append("\n\n"); + current.Append(part); + currentTokens += partTokens; + } + else + { + // Flush current chunk. + if (current.Length > 0) + { + merged.Add(current.ToString()); + current.Clear(); + currentTokens = 0; + } + + // If a single part exceeds maxTokens, split it further by sentences. + if (partTokens > maxTokens) + { + var subParts = SplitBySeparators(part, SentSeps); + foreach (var sp in subParts) + { + var spTokens = TokenCounter.Default.EstimateTokens(sp); + if (currentTokens + spTokens <= maxTokens) + { + if (current.Length > 0) current.Append(' '); + current.Append(sp); + currentTokens += spTokens; + } + else + { + if (current.Length > 0) + { + merged.Add(current.ToString()); + current.Clear(); + currentTokens = 0; + } + + // If even a single sentence exceeds maxTokens, add it as-is. + current.Append(sp); + currentTokens = spTokens; + merged.Add(current.ToString()); + current.Clear(); + currentTokens = 0; + } + } + } + else + { + current.Append(part); + currentTokens = partTokens; + } + } + } + + if (current.Length > 0) + merged.Add(current.ToString()); + + return merged; + } + + /// + /// Finds the best sentence/paragraph break position between and . + /// Searches backwards from for a natural boundary. + /// + private static int FindBestBreak(string text, int start, int end) + { + var minBreakPos = start + (end - start) / 2; // don't break in the first half + + // Try paragraph breaks first. + foreach (var sep in ParaSeps) + { + var idx = text.LastIndexOf(sep, end, end - start, StringComparison.Ordinal); + if (idx >= minBreakPos) + return idx + sep.Length; + } + + // Then sentence breaks. + foreach (var sep in SentSeps) + { + var idx = text.LastIndexOf(sep, end, end - start, StringComparison.Ordinal); + if (idx >= minBreakPos) + return idx + sep.Length; + } + + // Fallback: break at a space. + var spaceIdx = text.LastIndexOf(' ', end, end - start); + if (spaceIdx >= minBreakPos) + return spaceIdx + 1; + + return end; // no good break found + } + + private static DocumentChunk MakeChunk(int index, string content, string sectionTitle, int start, int end) + { + return new DocumentChunk + { + Index = index, + Content = content, + EstimatedTokens = TokenCounter.Default.EstimateTokens(content), + SectionTitle = sectionTitle, + StartChar = start, + EndChar = end + }; + } +} diff --git a/src/Delibera.Core/Chunking/AutoChunkingOptions.cs b/src/Delibera.Core/Chunking/AutoChunkingOptions.cs new file mode 100644 index 0000000..dc8b42b --- /dev/null +++ b/src/Delibera.Core/Chunking/AutoChunkingOptions.cs @@ -0,0 +1,49 @@ +namespace Delibera.Core.Chunking; + +/// +/// Configuration options for the AutoChunking feature. +/// Controls how large documents are split and distributed across debate rounds. +/// +public sealed record AutoChunkingOptions +{ + /// + /// The chunking strategy to use when splitting documents. + /// Default is — respects + /// Markdown headers, paragraphs, and sentence boundaries. + /// + public ChunkingStrategy Strategy { get; init; } = ChunkingStrategy.SemanticBoundary; + + /// + /// Safety margin as a fraction of the context window (0.0–0.5). + /// Reserves this portion of the context window to account for token estimation + /// inaccuracy. Default 0.15 = 15% safety margin. + /// + public double SafetyMargin { get; init; } = 0.15; + + /// + /// Maximum number of chunks to include in a single round's prompt. + /// Default is 3. Higher values mean fewer rounds but larger per-round prompts. + /// + public int MaxChunksPerRound { get; init; } = 3; + + /// + /// When true, the Chairman receives a Map-Reduce summary of all chunks + /// instead of the raw chunks. This is essential when the total document is too + /// large even for the Chairman's context window. + /// Default is true. + /// + public bool EnableMapReduce { get; init; } = true; + + /// + /// When true, chunks are progressively disclosed across rounds — + /// each round reveals new portions of the document. When false, + /// all chunks are included in every round (may exceed context window). + /// Default is true. + /// + public bool EnableProgressiveDisclosure { get; init; } = true; + + /// + /// The default options used when none are explicitly provided. + /// + public static AutoChunkingOptions Default { get; } = new(); +} diff --git a/src/Delibera.Core/Chunking/AutoChunkingOrchestrator.cs b/src/Delibera.Core/Chunking/AutoChunkingOrchestrator.cs new file mode 100644 index 0000000..dfbf016 --- /dev/null +++ b/src/Delibera.Core/Chunking/AutoChunkingOrchestrator.cs @@ -0,0 +1,305 @@ +using Delibera.Core.Compression; + +namespace Delibera.Core.Chunking; + +/// +/// Orchestrates the AutoChunking workflow — analyses model capabilities, +/// creates a chunking plan, and distributes chunks across debate rounds. +/// +/// +/// +/// The orchestrator is invoked by before the +/// debate begins. It queries each model's context window (via +/// or the +/// fallback), calculates the +/// available token budget per round, and creates a +/// if the knowledge content exceeds the smallest context window. +/// +/// +/// During the debate, is called by each strategy +/// to retrieve the appropriate chunks for the current round. Chunks are distributed +/// evenly across rounds with progressive disclosure — each round reveals new +/// portions of the document. +/// +/// +public sealed class AutoChunkingOrchestrator +{ + private readonly AutoChunkingOptions _options; + private readonly ILogger? _logger; + + /// + /// Creates a new orchestrator with the specified options. + /// + /// Chunking configuration. Defaults to . + /// Optional logger for diagnostic output. + public AutoChunkingOrchestrator(AutoChunkingOptions? options = null, ILogger? logger = null) + { + _options = options ?? AutoChunkingOptions.Default; + _logger = logger; + } + + /// + /// Analyses all council models and the knowledge content, then creates a + /// if the content exceeds any model's context window. + /// Returns an enriched with the plan attached. + /// + /// The original prompt context with knowledge content. + /// All council participants. + /// The Chairman (may be null). + /// Cancellation token. + /// + /// A new with + /// and populated. + /// + public async Task PrepareContextAsync( + PromptContext originalContext, + IReadOnlyList members, + CouncilMember? chairman, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(originalContext); + ArgumentNullException.ThrowIfNull(members); + + // Nothing to chunk if there's no knowledge content. + if (string.IsNullOrWhiteSpace(originalContext.KnowledgeContent)) + { + _logger?.LogDebug("AutoChunking: no knowledge content to chunk — skipping."); + return originalContext with { AutoChunkingEnabled = false }; + } + + // Collect all models that will receive prompts. + var allModels = members + .Select(m => (m.ModelName, Provider: m.Provider, m.DisplayName)) + .ToList(); + + if (chairman is not null) + allModels.Add((chairman.ModelName, chairman.Provider, chairman.DisplayName)); + + // Determine the minimum context window across all models. + var (minWindow, minModel) = await DetermineMinContextWindowAsync(allModels, ct); + + if (minWindow is null) + { + _logger?.LogWarning( + "AutoChunking: could not determine context window for any model. " + + "Chunking disabled — prompts will be sent as-is."); + return originalContext with { AutoChunkingEnabled = false }; + } + + // Calculate overhead: system prompt + user question + response buffer + history. + var overheadTokens = CalculateOverhead(originalContext); + + _logger?.LogDebug( + "AutoChunking: min context window = {MinWindow} tokens (model: {Model}), " + + "overhead = {Overhead} tokens, available = {Available} tokens", + minWindow.Value, minModel, overheadTokens, minWindow.Value - overheadTokens); + + // Create the chunking plan. + var plan = AutoChunker.CreatePlan( + originalContext.KnowledgeContent, + minWindow.Value, + overheadTokens, + _options.Strategy, + _options.SafetyMargin); + + if (plan.FitsInSingleRound) + { + _logger?.LogInformation( + "AutoChunking: knowledge content ({TotalTokens} tokens) fits in a single round " + + "({Available} tokens available). Chunking not needed.", + plan.EstimatedTokensPerChunk * plan.TotalChunks, + plan.AvailableTokensPerRound); + + return originalContext with + { + ChunkingPlan = plan, + AutoChunkingEnabled = false, + MinContextWindow = minWindow + }; + } + + _logger?.LogInformation( + "AutoChunking: knowledge content requires chunking — {TotalChunks} chunks, " + + "~{TokensPerChunk} tokens/chunk, recommended {Rounds} rounds. " + + "Smallest context window: {MinWindow} tokens ({Model}).", + plan.TotalChunks, plan.EstimatedTokensPerChunk, plan.RecommendedRounds, + minWindow.Value, minModel); + + return originalContext with + { + ChunkingPlan = plan, + AutoChunkingEnabled = true, + MinContextWindow = minWindow + }; + } + + /// + /// Returns the chunked user prompt for a specific debate round. + /// When AutoChunking is disabled, falls back to . + /// + /// The prompt context (must have a ). + /// Current round number (1-based). + /// Total number of rounds in the debate. + /// + /// Previous rounds for context continuity. When provided, a brief summary of + /// previously disclosed chunks is prepended so the model maintains context. + /// + /// The formatted user prompt with the appropriate chunks for this round. + public string GetRoundContext( + PromptContext context, + int roundNumber, + int totalRounds, + IReadOnlyList? previousRounds = null) + { + ArgumentNullException.ThrowIfNull(context); + + if (!context.AutoChunkingEnabled || context.ChunkingPlan is not { } plan) + return context.GetFullUserPrompt(); + + var chunks = DistributeChunks(plan, roundNumber, totalRounds); + + if (chunks.Count == 0) + return context.GetFullUserPrompt(); + + // Build the chunked context block. + var sb = new StringBuilder(); + + // Add a summary of previously seen chunks for continuity. + if (_options.EnableProgressiveDisclosure && roundNumber > 1 && previousRounds is { Count: > 0 }) + { + sb.AppendLine("### Previously Reviewed (Summary):"); + sb.AppendLine($"(Rounds 1–{roundNumber - 1} covered chunks 1–" + + $"{Math.Min((roundNumber - 1) * _options.MaxChunksPerRound, plan.TotalChunks)} " + + $"of {plan.TotalChunks})"); + sb.AppendLine(); + } + + sb.AppendLine($"### Context (Knowledge Base) — Part {roundNumber}/{totalRounds}:"); + sb.AppendLine($"(Chunks {chunks[0].Index + 1}–{chunks[^1].Index + 1} of {plan.TotalChunks})"); + sb.AppendLine(); + + foreach (var chunk in chunks) + { + sb.AppendLine($"#### [Chunk {chunk.Index + 1}/{plan.TotalChunks}] {chunk.SectionTitle}"); + sb.AppendLine(chunk.Content); + sb.AppendLine(); + } + + sb.AppendLine("### Question:"); + sb.Append(context.UserPrompt); + + return sb.ToString(); + } + + /// + /// Returns the chunks assigned to a specific round, evenly distributing + /// all chunks across the total number of rounds. + /// + private IReadOnlyList DistributeChunks( + ChunkingPlan plan, + int roundNumber, + int totalRounds) + { + if (plan.TotalChunks == 0) return []; + + // Progressive disclosure: each round gets a slice of the chunks. + if (_options.EnableProgressiveDisclosure) + { + var perRound = Math.Max(1, (int)Math.Ceiling((double)plan.TotalChunks / totalRounds)); + var start = (roundNumber - 1) * perRound; + var end = Math.Min(start + perRound, plan.TotalChunks); + + if (start >= plan.TotalChunks) return []; // past the last chunk + + return plan.Chunks + .Skip(start) + .Take(Math.Min(end - start, _options.MaxChunksPerRound)) + .ToList() + .AsReadOnly(); + } + + // Non-progressive: all chunks in every round (may exceed context window). + return plan.Chunks + .Take(_options.MaxChunksPerRound) + .ToList() + .AsReadOnly(); + } + + /// + /// Queries all models for their context window size in parallel and returns the minimum. + /// Falls back to when a provider + /// cannot report capabilities dynamically. + /// + private static async Task<(int? MinWindow, string? ModelName)> DetermineMinContextWindowAsync( + IReadOnlyList<(string ModelName, ILLMProvider Provider, string DisplayName)> models, + CancellationToken ct) + { + // Query all models in parallel — each provider call is I/O-bound. + var tasks = models.Select(async m => + { + int? window = null; + + // 1. Try the provider's dynamic capabilities. + try + { + var caps = await m.Provider.GetModelCapabilitiesAsync(m.ModelName, ct); + if (caps?.ContextWindowTokens is { } w and > 0) + window = w; + } + catch + { + // Provider introspection failed — fall through to registry. + } + + // 2. Fall back to the static registry. + window ??= ModelContextWindowRegistry.GetContextWindow(m.ModelName); + + return (Window: window, m.DisplayName); + }); + + var results = await Task.WhenAll(tasks); + + int? minWindow = null; + string? minModel = null; + + foreach (var (window, displayName) in results) + { + if (window is { } w and > 0) + { + if (minWindow is null || w < minWindow.Value) + { + minWindow = w; + minModel = displayName; + } + } + } + + return (minWindow, minModel); + } + + /// + /// Estimates the token overhead per round — system prompt, user question, + /// response buffer, and debate history. + /// + private static int CalculateOverhead(PromptContext context) + { + var overhead = 0; + + // System prompt. + overhead += TokenCounter.Default.EstimateTokens(context.SystemPrompt); + + // User question (without knowledge content). + overhead += TokenCounter.Default.EstimateTokens(context.UserPrompt); + + // Response buffer — reserve space for the model's answer. + overhead += 2000; + + // Debate history buffer — reserve space for previous rounds' responses. + overhead += 3000; + + // Chunk metadata overhead (headers, markers). + overhead += 200; + + return overhead; + } +} diff --git a/src/Delibera.Core/Compression/HybridCompressor.cs b/src/Delibera.Core/Compression/HybridCompressor.cs index d49b8de..771af57 100644 --- a/src/Delibera.Core/Compression/HybridCompressor.cs +++ b/src/Delibera.Core/Compression/HybridCompressor.cs @@ -30,6 +30,11 @@ public sealed class HybridCompressor : IContextCompressor private readonly ILLMProvider? _llmProvider; private readonly string? _modelName; + // Lazy-cached sub-compressors to avoid re-allocation on every CompressAsync call. + private DeduplicationCompressor? _dedup; + private SemanticCompressor? _semantic; + private SummarizationCompressor? _summarizer; + /// /// Creates a hybrid compressor. /// @@ -67,8 +72,8 @@ public async Task CompressAsync(string text, CompressionOptio var currentText = text; // ── Stage 1: Deduplication ── - var dedup = new DeduplicationCompressor(_embeddingProvider); - var dedupResult = await dedup.CompressAsync(currentText, options, ct); + _dedup ??= new DeduplicationCompressor(_embeddingProvider); + var dedupResult = await _dedup.CompressAsync(currentText, options, ct); currentText = dedupResult.Text; var currentTokens = counter.EstimateTokens(currentText); @@ -79,8 +84,8 @@ public async Task CompressAsync(string text, CompressionOptio if (_embeddingProvider is not null) { var semanticOptions = options with { MaxOutputTokens = targetTokens }; - var semantic = new SemanticCompressor(_embeddingProvider); - var semanticResult = await semantic.CompressAsync(currentText, semanticOptions, ct); + _semantic ??= new SemanticCompressor(_embeddingProvider); + var semanticResult = await _semantic.CompressAsync(currentText, semanticOptions, ct); currentText = semanticResult.Text; currentTokens = counter.EstimateTokens(currentText); @@ -92,8 +97,8 @@ public async Task CompressAsync(string text, CompressionOptio if (_llmProvider is not null && _modelName is not null) { var summOptions = options with { MaxOutputTokens = targetTokens }; - var summarizer = new SummarizationCompressor(_llmProvider, _modelName); - var summResult = await summarizer.CompressAsync(currentText, summOptions, ct); + _summarizer ??= new SummarizationCompressor(_llmProvider, _modelName); + var summResult = await _summarizer.CompressAsync(currentText, summOptions, ct); currentText = summResult.Text; currentTokens = counter.EstimateTokens(currentText); } diff --git a/src/Delibera.Core/Compression/SemanticCompressor.cs b/src/Delibera.Core/Compression/SemanticCompressor.cs index 082c66a..5c0d90d 100644 --- a/src/Delibera.Core/Compression/SemanticCompressor.cs +++ b/src/Delibera.Core/Compression/SemanticCompressor.cs @@ -112,41 +112,43 @@ private static List SelectTopSentences( return selected; } + /// + /// Splits text into sentences using a single-pass scan — O(n) instead of O(n·k) per iteration. + /// Checks each character position against all delimiters simultaneously. + /// internal static List SplitSentences(string text) { ArgumentNullException.ThrowIfNull(text); var sentences = new List(); - ReadOnlySpan delimiters = [". ", "! ", "? ", ".\n", "!\n", "?\n"]; - var pos = 0; + var span = text.AsSpan(); + var sentenceStart = 0; - while (pos < text.Length) + for (var i = 0; i < span.Length; i++) { - var nextDelim = int.MaxValue; + // Check if current position starts a sentence-ending delimiter. var delimLen = 0; + if (span[i] == '.' && i + 1 < span.Length && (span[i + 1] == ' ' || span[i + 1] == '\n')) delimLen = 2; + else if (span[i] == '!' && i + 1 < span.Length && (span[i + 1] == ' ' || span[i + 1] == '\n')) delimLen = 2; + else if (span[i] == '?' && i + 1 < span.Length && (span[i + 1] == ' ' || span[i + 1] == '\n')) delimLen = 2; - foreach (var d in delimiters) + if (delimLen > 0) { - var idx = text.IndexOf(d, pos, StringComparison.Ordinal); - if (idx >= 0 && idx < nextDelim) - { - nextDelim = idx; - delimLen = d.Length; - } - } + var end = i + delimLen; + var sentence = span[sentenceStart..end].Trim(); + if (sentence.Length > 0) + sentences.Add(new SentenceSpan(sentenceStart, sentence.ToString())); - if (nextDelim == int.MaxValue) - { - var remaining = text[pos..].Trim(); - if (remaining.Length > 0) - sentences.Add(new SentenceSpan(pos, remaining)); - break; + sentenceStart = end; + i = end - 1; // will be incremented by the for loop } + } - var sentence = text[pos..(nextDelim + delimLen)].Trim(); - if (sentence.Length > 0) - sentences.Add(new SentenceSpan(pos, sentence)); - - pos = nextDelim + delimLen; + // Add the final sentence. + if (sentenceStart < span.Length) + { + var remaining = span[sentenceStart..].Trim(); + if (remaining.Length > 0) + sentences.Add(new SentenceSpan(sentenceStart, remaining.ToString())); } return sentences; diff --git a/src/Delibera.Core/Compression/TokenCounter.cs b/src/Delibera.Core/Compression/TokenCounter.cs index 43e5d5c..156fa71 100644 --- a/src/Delibera.Core/Compression/TokenCounter.cs +++ b/src/Delibera.Core/Compression/TokenCounter.cs @@ -67,13 +67,20 @@ public int EstimateTokens(string? text) if (text.Length <= MaxMemoizedLength && MaxMemoizedEntries > 0) { if (_memo.TryGetValue(text, out var cached)) + { + // Touch: move to end of access-order list for LRU. + TouchMemoEntry(text); 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); + // Evict oldest entries if at capacity before adding. + if (_memo.Count >= MaxMemoizedEntries) + EvictMemoEntries(_evictionBatchSize); + + if (_memo.TryAdd(text, value)) + TrackMemoEntry(text); return value; } @@ -147,6 +154,44 @@ public string TruncateToTokenLimit(string text, int maxTokens) // ────────────────────────────────────────────── private readonly ConcurrentDictionary _memo = new(); + private readonly LinkedList _memoOrder = new(); // LRU access order + private readonly object _memoLock = new(); + private const int _evictionBatchSize = 64; // evict 64 entries at a time when over capacity + + private void TrackMemoEntry(string key) + { + lock (_memoLock) + { + _memoOrder.AddLast(key); + } + } + + private void TouchMemoEntry(string key) + { + lock (_memoLock) + { + // Remove and re-add to move to end (most recently used). + _memoOrder.Remove(key); + _memoOrder.AddLast(key); + } + } + + private void EvictMemoEntries(int count) + { + lock (_memoLock) + { + var removed = 0; + var node = _memoOrder.First; + while (node is not null && removed < count) + { + var next = node.Next; + _memo.TryRemove(node.Value, out _); + _memoOrder.Remove(node); + node = next; + removed++; + } + } + } private static int CountWords(ReadOnlySpan text) { diff --git a/src/Delibera.Core/Council/CouncilBuilder.cs b/src/Delibera.Core/Council/CouncilBuilder.cs index 7cc6801..286ce92 100644 --- a/src/Delibera.Core/Council/CouncilBuilder.cs +++ b/src/Delibera.Core/Council/CouncilBuilder.cs @@ -1,5 +1,7 @@ +using Delibera.Core.Chunking; using Delibera.Core.Compression; using Delibera.Core.Debate; +using Delibera.Core.DependencyInjection; using Delibera.Core.Providers.Mcp; namespace Delibera.Core.Council; @@ -10,6 +12,7 @@ namespace Delibera.Core.Council; public sealed class CouncilBuilder : ICouncilBuilder { private readonly List _members = []; + private AutoChunkingOptions? _autoChunkingOptions; private CouncilMember? _chairman; private CompressionCache? _compressionCache; private CompressionOptions? _compressionOptions; @@ -30,6 +33,25 @@ public sealed class CouncilBuilder : ICouncilBuilder private float _temperature = 0.7f; private string _userPrompt = string.Empty; + /// + /// Creates an empty builder. Use or + /// to apply pre-built configuration. + /// + public CouncilBuilder() + { + } + + /// + /// Creates a builder pre-configured from a snapshot. + /// Equivalent to new CouncilBuilder().WithOptions(options). + /// + /// Configuration to apply immediately. + public CouncilBuilder(CouncilOptions options) + { + ArgumentNullException.ThrowIfNull(options); + ApplyOptions(options); + } + // ── Members ── /// @@ -220,6 +242,105 @@ public ICouncilBuilder WithLogger(ILogger? logger) return this; } + // ── AutoChunking ── + + /// + public ICouncilBuilder WithAutoChunking(AutoChunkingOptions? options = null) + { + _autoChunkingOptions = options ?? AutoChunkingOptions.Default; + return this; + } + + /// + public ICouncilBuilder WithModelContextWindow(string modelNamePattern, int contextWindowTokens) + { + ModelContextWindowRegistry.Register(modelNamePattern, contextWindowTokens); + return this; + } + + // ── Options (bulk configuration) ── + + /// + public ICouncilBuilder WithOptions(CouncilOptions options) + { + ArgumentNullException.ThrowIfNull(options); + ApplyOptions(options); + return this; + } + + /// + public ICouncilBuilder WithOptions(Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + var options = new CouncilOptions(); + configure(options); + ApplyOptions(options); + return this; + } + + private const string DefaultStrategy = "Standard"; + private const int DefaultMaxRounds = 4; + private const float DefaultTemperature = 0.7f; + private const string DefaultSystemPrompt = "You are a helpful AI assistant participating in a council debate."; + + /// + /// Applies a snapshot to the builder. + /// Only sets fields that have non-default values — explicit builder calls + /// made before or after WithOptions take precedence. + /// + private void ApplyOptions(CouncilOptions options) + { + // Strategy + if (!string.Equals(options.Strategy, DefaultStrategy, StringComparison.OrdinalIgnoreCase)) + { + _strategy = options.Strategy.ToLowerInvariant() switch + { + "critique" => new CritiqueDebate(), + "consensus" => new ConsensusDebate(), + _ => new StandardDebate() + }; + } + + // Core parameters + if (options.MaxRounds != DefaultMaxRounds) _maxRounds = options.MaxRounds; + if (Math.Abs(options.Temperature - DefaultTemperature) > 0.001f) _temperature = options.Temperature; + if (options.SystemPrompt is { Length: > 0 } sp && sp != DefaultSystemPrompt) + _systemPrompt = sp; + if (options.ResponseLanguage is { Length: > 0 } lang) + _responseLanguage = lang; + if (options.MaxDegreeOfParallelism > 0) + _maxDegreeOfParallelism = options.MaxDegreeOfParallelism; + + // Compression + if (options.Compression is { Enabled: true }) + { + if (_compressor is null) + { + _compressor = CompressionFactory.Create( + options.Compression.Strategy, + llmProvider: null, + modelName: null, + embeddingProvider: null); + } + + _compressionOptions ??= new CompressionOptions + { + TargetRatio = options.Compression.TargetRatio + }; + + if (options.Compression.EnableCache && _compressionCache is null) + _compressionCache = new CompressionCache(options.Compression.MaxCacheEntries); + } + + // AutoChunking + if (options.AutoChunking is { Enabled: true } && _autoChunkingOptions is null) + _autoChunkingOptions = options.AutoChunking.ToOptions(); + + // Output + if (options.Output is { Directory: { Length: > 0 } dir } && dir != "./debate_results") + _outputPath = dir; + } + /// ICouncilExecutor ICouncilBuilder.Build() { @@ -310,6 +431,7 @@ public CouncilExecutor Build() _compressionOptions, _compressionCache, @operator, - executionOptions); + executionOptions, + _autoChunkingOptions); } } \ No newline at end of file diff --git a/src/Delibera.Core/Council/CouncilExecutor.cs b/src/Delibera.Core/Council/CouncilExecutor.cs index 2299b51..f69b571 100644 --- a/src/Delibera.Core/Council/CouncilExecutor.cs +++ b/src/Delibera.Core/Council/CouncilExecutor.cs @@ -1,3 +1,4 @@ +using Delibera.Core.Chunking; using Delibera.Core.Compression; namespace Delibera.Core.Council; @@ -8,6 +9,7 @@ namespace Delibera.Core.Council; /// public sealed class CouncilExecutor : ICouncilExecutor { + private readonly AutoChunkingOptions? _autoChunkingOptions; private readonly CompressionOptions? _compressionOptions; private readonly PromptContext _context; private readonly List _executionLogs = []; @@ -28,7 +30,8 @@ internal CouncilExecutor( CompressionOptions? compressionOptions = null, CompressionCache? compressionCache = null, Operator? @operator = null, - DebateExecutionOptions? executionOptions = null) + DebateExecutionOptions? executionOptions = null, + AutoChunkingOptions? autoChunkingOptions = null) { Members = members; Chairman = chairman; @@ -43,6 +46,7 @@ internal CouncilExecutor( _compressionOptions = compressionOptions; CompressionCache = compressionCache; ExecutionOptions = executionOptions ?? DebateExecutionOptions.Default; + _autoChunkingOptions = autoChunkingOptions; } /// Compression cache (may be null). @@ -140,6 +144,36 @@ public async Task ExecuteAsync(CancellationToken ct = default) ? _context with { SystemPrompt = _context.SystemPrompt + ExecutionOptions.BuildLanguageDirective() } : _context; + // ── AutoChunking: analyse model capabilities and create chunking plan ── + if (_autoChunkingOptions is not null) + { + Log(ExecutionLog.Info("AutoChunking", "AutoChunking enabled — analysing model context windows…")); + + var orchestrator = new AutoChunkingOrchestrator(_autoChunkingOptions, ExecutionOptions.Logger); + effectiveContext = await orchestrator.PrepareContextAsync( + effectiveContext, Members, Chairman, ct); + + if (effectiveContext.AutoChunkingEnabled && effectiveContext.ChunkingPlan is { } plan) + { + Log(ExecutionLog.Info("AutoChunking", + $"Chunking plan created: {plan.TotalChunks} chunks, " + + $"~{plan.EstimatedTokensPerChunk} tokens/chunk, " + + $"recommended {plan.RecommendedRounds} rounds. " + + $"Min context window: {plan.ContextWindowTokens} tokens, " + + $"available per round: {plan.AvailableTokensPerRound} tokens.")); + } + else if (effectiveContext.ChunkingPlan is not null) + { + Log(ExecutionLog.Info("AutoChunking", + "Knowledge content fits in a single round — chunking not needed.")); + } + else + { + Log(ExecutionLog.Info("AutoChunking", + "No knowledge content to chunk or context windows could not be determined.")); + } + } + var result = await Strategy.ExecuteAsync( Members, effectiveContext, @@ -275,6 +309,17 @@ public string GetInfo() sb.AppendLine($" Cache: enabled (max {CompressionCache.Count} entries)"); } + if (_autoChunkingOptions is not null) + { + sb.AppendLine(); + sb.AppendLine(" ── AutoChunking ──"); + sb.AppendLine($" ✂️ Strategy: {_autoChunkingOptions.Strategy}"); + sb.AppendLine($" Safety margin: {_autoChunkingOptions.SafetyMargin:P0}"); + sb.AppendLine($" Max chunks/round: {_autoChunkingOptions.MaxChunksPerRound}"); + sb.AppendLine($" Map-Reduce: {(_autoChunkingOptions.EnableMapReduce ? "enabled" : "disabled")}"); + sb.AppendLine($" Progressive disclosure: {(_autoChunkingOptions.EnableProgressiveDisclosure ? "enabled" : "disabled")}"); + } + sb.AppendLine(); sb.AppendLine(" ── Prompts ──"); sb.AppendLine($" System: {Truncate(_context.SystemPrompt, 80)}"); diff --git a/src/Delibera.Core/Council/KnowledgeKeeper.cs b/src/Delibera.Core/Council/KnowledgeKeeper.cs index b80d04b..d026e8a 100644 --- a/src/Delibera.Core/Council/KnowledgeKeeper.cs +++ b/src/Delibera.Core/Council/KnowledgeKeeper.cs @@ -86,7 +86,7 @@ Please state that you have no relevant context and provide Provide a clear, factual answer based on the context above. Cite relevant source numbers in your answer. """; - sourceChunks = context.Split("[Source ").Length - 1; + sourceChunks = CountOccurrences(context, "[Source "); } // 2. Generate answer via dedicated LLM @@ -239,4 +239,23 @@ public Task IndexFileAsync( { return _ragProvider.IndexFileAsync(CollectionName, filePath, chunkSize, chunkOverlap, ct); } + + /// + /// Counts non-overlapping occurrences of a substring without allocating a string array. + /// + private static int CountOccurrences(string text, string pattern) + { + if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(pattern)) + return 0; + + var count = 0; + var idx = 0; + while ((idx = text.IndexOf(pattern, idx, StringComparison.Ordinal)) >= 0) + { + count++; + idx += pattern.Length; + } + + return count; + } } \ No newline at end of file diff --git a/src/Delibera.Core/Debate/ConsensusDebate.cs b/src/Delibera.Core/Debate/ConsensusDebate.cs index 3a19564..d966a61 100644 --- a/src/Delibera.Core/Debate/ConsensusDebate.cs +++ b/src/Delibera.Core/Debate/ConsensusDebate.cs @@ -52,7 +52,7 @@ public override async Task ExecuteAsync( ArgumentNullException.ThrowIfNull(members); ArgumentNullException.ThrowIfNull(context); var builder = new DebateResultBuilder(this, members, context, chairman, knowledgeKeeper, @operator); - var fullUserPrompt = context.GetFullUserPrompt(); + var totalRounds = Math.Min(maxRounds, 4); // Operator briefing appended to participant system prompts. var operatorBriefing = BuildOperatorBriefing(@operator); @@ -71,9 +71,10 @@ public override async Task ExecuteAsync( if (ki is not null) r1Ki.Add(ki); } + var r1BasePrompt = BuildChunkedPrompt(context, 1, totalRounds); var enrichedPrompt = string.IsNullOrWhiteSpace(knowledgeCtx) - ? fullUserPrompt - : $"{fullUserPrompt}\n\n📚 Knowledge:\n{knowledgeCtx}"; + ? r1BasePrompt + : $"{r1BasePrompt}\n\n📚 Knowledge:\n{knowledgeCtx}"; // Round 1: Initial Perspectives var round1StartedAt = DateTime.UtcNow; @@ -95,13 +96,14 @@ public override async Task ExecuteAsync( // Round 2: Finding Common Ground var r1Text = FormatRoundResponses(round1); var r1OpText = FormatOperatorInteractions(r1Op); + var r2BasePrompt = BuildChunkedPrompt(context, 2, totalRounds, builder.Rounds); var r2Sys = $""" {baseSystemPrompt} Work collaboratively. Find COMMON GROUND. Identify agreements, disagreements, and propose bridges. Be open to changing your position. """; var r2Prompt = $""" - Original question: {fullUserPrompt} + Original question: {r2BasePrompt} All perspectives: {r1Text} {(string.IsNullOrWhiteSpace(r1OpText) ? "" : $"\n{r1OpText}")} @@ -126,13 +128,14 @@ and propose bridges. Be open to changing your position. // Round 3: Consensus Building var r2Text = FormatRoundResponses(round2); var r2OpText = FormatOperatorInteractions(r2Op); + var r3BasePrompt = BuildChunkedPrompt(context, 3, totalRounds, builder.Rounds); var r3Sys = $""" {baseSystemPrompt} This is the CONSENSUS round. Formulate a single, unified answer. If full consensus is impossible, state what can be agreed upon. """; var r3Prompt = $""" - Original question: {fullUserPrompt} + Original question: {r3BasePrompt} Perspectives: {r1Text} Common ground: {r2Text} {(string.IsNullOrWhiteSpace(r1OpText) && string.IsNullOrWhiteSpace(r2OpText) ? "" : $"\n{r1OpText}\n{r2OpText}")} diff --git a/src/Delibera.Core/Debate/CritiqueDebate.cs b/src/Delibera.Core/Debate/CritiqueDebate.cs index ce9e7b3..d0eaeff 100644 --- a/src/Delibera.Core/Debate/CritiqueDebate.cs +++ b/src/Delibera.Core/Debate/CritiqueDebate.cs @@ -52,7 +52,7 @@ public override async Task ExecuteAsync( ArgumentNullException.ThrowIfNull(members); ArgumentNullException.ThrowIfNull(context); var builder = new DebateResultBuilder(this, members, context, chairman, knowledgeKeeper, @operator); - var fullUserPrompt = context.GetFullUserPrompt(); + var totalRounds = Math.Min(maxRounds, 4); // Operator briefing appended to participant system prompts. var operatorBriefing = BuildOperatorBriefing(@operator); @@ -71,9 +71,10 @@ public override async Task ExecuteAsync( if (ki is not null) r1Ki.Add(ki); } + var r1BasePrompt = BuildChunkedPrompt(context, 1, totalRounds); var enrichedPrompt = string.IsNullOrWhiteSpace(knowledgeCtx) - ? fullUserPrompt - : $"{fullUserPrompt}\n\n📚 Knowledge:\n{knowledgeCtx}"; + ? r1BasePrompt + : $"{r1BasePrompt}\n\n📚 Knowledge:\n{knowledgeCtx}"; // Round 1: Initial Positions var round1StartedAt = DateTime.UtcNow; @@ -95,13 +96,14 @@ public override async Task ExecuteAsync( // Round 2: Directed Critique var r1Text = FormatRoundResponses(round1); var r1OpText = FormatOperatorInteractions(r1Op); + var r2BasePrompt = BuildChunkedPrompt(context, 2, totalRounds, builder.Rounds); var r2Sys = $""" {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. """; var r2Prompt = $""" - Original question: {fullUserPrompt} + Original question: {r2BasePrompt} Responses to critique: {r1Text} {(string.IsNullOrWhiteSpace(r1OpText) ? "" : $"\n{r1OpText}")} @@ -126,13 +128,14 @@ 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 r3BasePrompt = BuildChunkedPrompt(context, 3, totalRounds, builder.Rounds); var r3Sys = $""" {baseSystemPrompt} Defend your position. Address every critique, strengthen weak arguments, provide additional evidence, and concede where critics are right. """; var r3Prompt = $""" - Original question: {fullUserPrompt} + Original question: {r3BasePrompt} Initial responses: {r1Text} Critiques: {r2Text} {(string.IsNullOrWhiteSpace(r1OpText) && string.IsNullOrWhiteSpace(r2OpText) ? "" : $"\n{r1OpText}\n{r2OpText}")} diff --git a/src/Delibera.Core/Debate/DebateScenario.cs b/src/Delibera.Core/Debate/DebateScenario.cs index 210ccbc..c5103f5 100644 --- a/src/Delibera.Core/Debate/DebateScenario.cs +++ b/src/Delibera.Core/Debate/DebateScenario.cs @@ -313,11 +313,34 @@ await Parallel.ForEachAsync( 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) + /// + /// Builds the user prompt for a specific round, respecting AutoChunking when enabled. + /// When the context has a and + /// is true, returns the + /// chunk-appropriate prompt. Otherwise falls back to . + /// + /// The prompt context. + /// Current round number (1-based). + /// Total number of rounds in the debate. + /// Previous rounds for context continuity (optional). + /// The formatted user prompt for this round. + protected static string BuildChunkedPrompt( + PromptContext context, + int roundNumber, + int totalRounds, + IReadOnlyList? previousRounds = null) + { + if (context.AutoChunkingEnabled && context.ChunkingPlan is not null) + return context.GetChunkedUserPrompt(roundNumber, totalRounds, previousRounds); + + return context.GetFullUserPrompt(); + } + + /// + /// 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; diff --git a/src/Delibera.Core/Debate/StandardDebate.cs b/src/Delibera.Core/Debate/StandardDebate.cs index 7fa1b65..c026ebf 100644 --- a/src/Delibera.Core/Debate/StandardDebate.cs +++ b/src/Delibera.Core/Debate/StandardDebate.cs @@ -56,7 +56,7 @@ public override async Task ExecuteAsync( ArgumentNullException.ThrowIfNull(members); ArgumentNullException.ThrowIfNull(context); var builder = new DebateResultBuilder(this, members, context, chairman, knowledgeKeeper, @operator); - var fullUserPrompt = context.GetFullUserPrompt(); + var totalRounds = Math.Min(maxRounds, 4); // Operator briefing is appended to participant system prompts so they know what tools exist. var operatorBriefing = BuildOperatorBriefing(@operator); @@ -78,9 +78,10 @@ public override async Task ExecuteAsync( } // ══════════ Round 1: Initial Responses ══════════ + var r1BasePrompt = BuildChunkedPrompt(context, 1, totalRounds); var r1Prompt = string.IsNullOrWhiteSpace(knowledgeContext) - ? fullUserPrompt - : $"{fullUserPrompt}\n\n📚 Knowledge Keeper context:\n{knowledgeContext}"; + ? r1BasePrompt + : $"{r1BasePrompt}\n\n📚 Knowledge Keeper context:\n{knowledgeContext}"; var round1StartedAt = DateTime.UtcNow; var r1Responses = await CollectResponsesAsync(members, baseSystemPrompt, r1Prompt, temperature, ct); @@ -105,6 +106,7 @@ public override async Task ExecuteAsync( // ══════════ Round 2: Critique ══════════ var r1Text = FormatRoundResponses(round1); var r1OpText = FormatOperatorInteractions(r1Op); + var r2BasePrompt = BuildChunkedPrompt(context, 2, totalRounds, builder.Rounds); var r2System = $""" {baseSystemPrompt} @@ -113,7 +115,7 @@ You are now in critique mode. Critically analyse the responses from other counci Be constructive but thorough. """; var r2Prompt = $""" - Original question: {fullUserPrompt} + Original question: {r2BasePrompt} Initial responses: {r1Text} @@ -143,6 +145,7 @@ Provide your detailed critique of each response. // ══════════ Round 3: Improved Responses ══════════ var r2Text = FormatRoundResponses(round2); var r2OpText = FormatOperatorInteractions(r2Op); + var r3BasePrompt = BuildChunkedPrompt(context, 3, totalRounds, builder.Rounds); var r3System = $""" {baseSystemPrompt} @@ -151,7 +154,7 @@ Provide your detailed critique of each response. and synthesise the most comprehensive response possible. """; var r3Prompt = $""" - Original question: {fullUserPrompt} + Original question: {r3BasePrompt} Initial responses: {r1Text} diff --git a/src/Delibera.Core/Delibera.Core.csproj b/src/Delibera.Core/Delibera.Core.csproj index 6568d80..e50f7a6 100644 --- a/src/Delibera.Core/Delibera.Core.csproj +++ b/src/Delibera.Core/Delibera.Core.csproj @@ -12,21 +12,21 @@ Delibera.Core - 10.2.2 + 10.2.3 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, Microsoft.Extensions.Http.Resilience (Polly v8 retry pipelines), IHttpClientFactory wiring, 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, AutoChunking (progressive disclosure for large documents), 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;resilience;polly;retry;httpclient + llm;ai;debate;council;deliberation;rag;qdrant;pgvector;ollama;openai;vector-search;embeddings;dependency-injection;resilience;polly;retry;httpclient;autochunking;chunking;context-window README.md icon.png MIT https://github.com/techbuzzz/Delibera git https://github.com/techbuzzz/Delibera - 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). + v10.2.3 — AutoChunking: automatic splitting of large knowledge documents into context-window-sized chunks distributed across debate rounds via progressive disclosure. New Chunking namespace with AutoChunker (SemanticBoundary / FixedSize / SlidingWindow strategies), AutoChunkingOrchestrator, and AutoChunkingOptions. ModelContextWindowRegistry with 40+ pre-registered models. ILLMProvider.GetModelCapabilitiesAsync() — OllamaProvider queries /api/show for num_ctx, others fall back to the registry. PromptContext extended with ChunkingPlan, AutoChunkingEnabled, MinContextWindow, and GetChunkedUserPrompt(). All debate strategies (Standard, Critique, Consensus) use BuildChunkedPrompt() for round-aware chunk distribution. CouncilBuilder gains WithOptions(CouncilOptions) / WithOptions(Action{CouncilOptions}) for bulk configuration from DI, appsettings, or inline lambdas. CouncilBuilder(CouncilOptions) constructor for one-shot setup. DI auto-wires CouncilOptions → CouncilBuilder. New AutoChunkingConfig in CouncilOptions with appsettings binding. ConsoleApp example: dotnet run -- --autochunking. Fully backward compatible — AutoChunking is opt-in. true snupkg true diff --git a/src/Delibera.Core/DependencyInjection/CouncilOptions.cs b/src/Delibera.Core/DependencyInjection/CouncilOptions.cs index 06efe75..28e2fb3 100644 --- a/src/Delibera.Core/DependencyInjection/CouncilOptions.cs +++ b/src/Delibera.Core/DependencyInjection/CouncilOptions.cs @@ -1,3 +1,5 @@ +using Delibera.Core.Chunking; + namespace Delibera.Core.DependencyInjection; /// @@ -71,6 +73,9 @@ public sealed class CouncilOptions /// registered by AddDelibera. /// public ResilienceOptions Resilience { get; set; } = new(); + + /// AutoChunking configuration options. + public AutoChunkingConfig AutoChunking { get; set; } = new(); } /// @@ -108,7 +113,10 @@ public sealed class ResilienceOptions /// 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 . + /// + /// 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). @@ -302,4 +310,68 @@ public sealed class OutputOptions /// Optional file prefix (default: "debate_{timestamp}"). public string? FilePrefix { get; set; } -} \ No newline at end of file +} + +/// +/// Configuration options for AutoChunking — automatic splitting of large knowledge +/// documents into context-window-sized chunks distributed across debate rounds. +/// Bound from the Delibera:AutoChunking configuration section. +/// +public sealed class AutoChunkingConfig +{ + /// Whether AutoChunking is enabled. Default is false. + public bool Enabled { get; set; } + + /// + /// Chunking strategy: "SemanticBoundary" (default), "FixedSize", or "SlidingWindow". + /// + public string Strategy { get; set; } = "SemanticBoundary"; + + /// + /// Safety margin as a fraction of the context window (0.0–0.5). + /// Default 0.15 = 15% reserved for token estimation inaccuracy. + /// + public double SafetyMargin { get; set; } = 0.15; + + /// Maximum number of chunks to include in a single round's prompt. Default is 3. + public int MaxChunksPerRound { get; set; } = 3; + + /// + /// When true, the Chairman receives a Map-Reduce summary of all chunks. + /// Default is true. + /// + public bool EnableMapReduce { get; set; } = true; + + /// + /// When true, chunks are progressively disclosed across rounds. + /// Default is true. + /// + public bool EnableProgressiveDisclosure { get; set; } = true; + + /// + /// Custom model context window registrations. Key = model name pattern (substring match), + /// Value = context window size in tokens. + /// + public Dictionary ModelContextWindows { get; set; } = []; + + /// + /// Converts this configuration to an instance. + /// + public AutoChunkingOptions ToOptions() + { + // Register any custom model context windows. + foreach (var (pattern, window) in ModelContextWindows) + ModelContextWindowRegistry.Register(pattern, window); + + return new AutoChunkingOptions + { + Strategy = Enum.TryParse(Strategy, ignoreCase: true, out var s) + ? s + : ChunkingStrategy.SemanticBoundary, + SafetyMargin = Math.Clamp(SafetyMargin, 0.0, 0.5), + MaxChunksPerRound = Math.Max(1, MaxChunksPerRound), + EnableMapReduce = EnableMapReduce, + EnableProgressiveDisclosure = EnableProgressiveDisclosure + }; + } +} diff --git a/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs b/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs index 2a61383..d5a6aa8 100644 --- a/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs @@ -1,3 +1,4 @@ +using Delibera.Core.Chunking; using Delibera.Core.Compression; using Delibera.Core.Council; using Delibera.Core.Providers; @@ -19,211 +20,214 @@ namespace Delibera.Core.DependencyInjection; /// public static class ServiceCollectionExtensions { - /// - /// Registers core Delibera services with default options (no IHttpClientFactory, no resilience). - /// /// The service collection. - /// The service collection for chaining. - /// - /// Registers: - /// - /// (singleton) - /// (singleton) - /// (singleton) - /// (transient) - /// - /// To enable Polly v8 retry pipelines call AddDeliberaResilience(IServiceCollection) after this method. - /// - public static IServiceCollection AddDelibera(this IServiceCollection services) + extension(IServiceCollection services) { - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddTransient(); - - return services; - } - - /// - /// Registers core Delibera services and binds from configuration. - /// - 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); - - // 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); + /// + /// Registers core Delibera services with default options (no IHttpClientFactory, no resilience). + /// + /// The service collection for chaining. + /// + /// Registers: + /// + /// (singleton) + /// (singleton) + /// (singleton) + /// (transient) + /// + /// To enable Polly v8 retry pipelines call AddDeliberaResilience(IServiceCollection) after this method. + /// + public IServiceCollection AddDelibera() + { + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddTransient(); + + return services; + } + + /// + /// Registers core Delibera services and binds from configuration. + /// + public IServiceCollection AddDelibera(IConfiguration configuration, + string sectionName = CouncilOptions.SectionName) + { + services.AddDelibera(); - return services; - } + var section = configuration.GetSection(sectionName); + if (section.Exists()) services.Configure(section); - /// - /// Registers core Delibera services with a custom options configuration delegate. - /// - public static IServiceCollection AddDelibera( - this IServiceCollection services, - Action configureOptions) - { - ArgumentNullException.ThrowIfNull(configureOptions); - services.AddDelibera(); - services.Configure(configureOptions); + // 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; - } + 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. - /// - 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); + /// + /// Registers core Delibera services with a custom options configuration delegate. + /// + public IServiceCollection AddDelibera(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. + /// + public IServiceCollection AddDelibera(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. + // gets a logger injected automatically and CouncilOptions applied from DI. + // Consumers who build the executor themselves can still call WithLogger(...) + // or WithOptions(...) explicitly to override. services.Replace(ServiceDescriptor.Transient(sp => { - var builder = new CouncilBuilder(); + // Resolve CouncilOptions from DI if available. + var options = sp.GetService>()?.Value; + + // Create builder — if options are available, pass them to the constructor + // so all settings (strategy, rounds, temperature, compression, auto-chunking, etc.) + // are applied automatically. + var builder = options is not null + ? new CouncilBuilder(options) + : new CouncilBuilder(); + + // Attach logger from DI. var lf = sp.GetService(); if (lf is not null) builder.WithLogger(lf.CreateLogger("Delibera.Core.Council")); + return builder; })); - return services; - } - - /// - /// 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. - /// - /// 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. - /// 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); + return services; + } + + /// + /// 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. + /// + /// Optional delegate to override defaults. + /// The service collection for chaining. + /// + /// 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 IServiceCollection AddDeliberaResilience(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 + /// . + /// + /// 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 IHttpClientBuilder AddDeliberaHttpClient(string name, + string pipelineName = ResilienceOptions.DefaultPipelineName, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(name); - var builder = services.AddHttpClient(name, configure ?? (_ => { })); + 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) + builder.AddResilienceHandler(pipelineName, (pipelineBuilder, context) => { - 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); - }); + // 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; + return builder; + } } private static void AddNamedHttpClient(IServiceCollection services, string name, string pipelineName) @@ -243,60 +247,57 @@ private static DelayBackoffType ParseBackoffType(string value) }; } - /// - /// Registers a Microsoft.Extensions.AI and exposes it as a Delibera - /// (). - /// - public static IServiceCollection AddDeliberaChatClient( - this IServiceCollection services, - Func chatClientFactory, - string? providerName = null) + extension(IServiceCollection services) { - 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, false)); - - return services; - } - - /// - /// Registers an already-constructed Microsoft.Extensions.AI and exposes it - /// as a Delibera . - /// - 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 (). - /// - 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, - false)); - - return services; + /// + /// Registers a Microsoft.Extensions.AI and exposes it as a Delibera + /// (). + /// + public IServiceCollection AddDeliberaChatClient(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, false)); + + return services; + } + + /// + /// Registers an already-constructed Microsoft.Extensions.AI and exposes it + /// as a Delibera . + /// + public IServiceCollection AddDeliberaChatClient(IChatClient chatClient, + string? providerName = null) + { + ArgumentNullException.ThrowIfNull(chatClient); + return services.AddDeliberaChatClient(_ => chatClient, providerName); + } + + /// + /// Registers a Microsoft.Extensions.AI and exposes + /// it as a Delibera (). + /// + public IServiceCollection AddDeliberaEmbeddingGenerator(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, + false)); + + return services; + } } -} \ No newline at end of file +} diff --git a/src/Delibera.Core/Interfaces/ICouncilBuilder.cs b/src/Delibera.Core/Interfaces/ICouncilBuilder.cs index 57a0cf9..c0fe1e3 100644 --- a/src/Delibera.Core/Interfaces/ICouncilBuilder.cs +++ b/src/Delibera.Core/Interfaces/ICouncilBuilder.cs @@ -1,5 +1,7 @@ +using Delibera.Core.Chunking; using Delibera.Core.Council; using Delibera.Core.Debate; +using Delibera.Core.DependencyInjection; namespace Delibera.Core.Interfaces; @@ -181,6 +183,69 @@ ICouncilBuilder WithCompression( /// This builder for fluent chaining. ICouncilBuilder WithLogger(ILogger? logger); + /// + /// Enables AutoChunking — automatic splitting of large knowledge documents into + /// context-window-sized chunks distributed across debate rounds. + /// + /// + /// + /// When enabled, the orchestrator queries each model's context window size + /// (via or the + /// fallback) and creates a + /// if the knowledge content exceeds the smallest + /// model's capacity. Chunks are progressively disclosed across rounds so + /// every model receives a complete view of the document by the final round. + /// + /// + /// Use to register custom model context + /// window sizes that are not in the built-in registry. + /// + /// + /// + /// Chunking configuration. Pass null to use . + /// + /// This builder for fluent chaining. + ICouncilBuilder WithAutoChunking(AutoChunkingOptions? options = null); + + /// + /// Registers a custom context window size for a model pattern. + /// The pattern is matched case-insensitively as a substring of the model name. + /// + /// + /// Substring pattern (e.g. "my-fine-tuned-llama" matches "my-fine-tuned-llama:v2"). + /// + /// Context window size in tokens. + /// This builder for fluent chaining. + ICouncilBuilder WithModelContextWindow(string modelNamePattern, int contextWindowTokens); + + /// + /// Applies a pre-built snapshot to the builder. + /// All non-default values are transferred. Explicit builder calls made before + /// or after this method take precedence over the options snapshot. + /// + /// Configuration to apply. + /// This builder for fluent chaining. + ICouncilBuilder WithOptions(CouncilOptions options); + + /// + /// Applies configuration via a delegate that receives a fresh + /// instance. Useful for inline configuration + /// without a separate options object. + /// + /// Delegate that populates the options. + /// This builder for fluent chaining. + /// + /// + /// builder.WithOptions(o => + /// { + /// o.Strategy = "Critique"; + /// o.MaxRounds = 6; + /// o.AutoChunking.Enabled = true; + /// }); + /// + /// + ICouncilBuilder WithOptions(Action configure); + /// /// Validates configuration and builds an . /// diff --git a/src/Delibera.Core/Interfaces/ILLMProvider.cs b/src/Delibera.Core/Interfaces/ILLMProvider.cs index f3d501e..78df52f 100644 --- a/src/Delibera.Core/Interfaces/ILLMProvider.cs +++ b/src/Delibera.Core/Interfaces/ILLMProvider.cs @@ -60,4 +60,32 @@ async IAsyncEnumerable ChatStreamAsync( { yield return await ChatAsync(model, systemPrompt, userPrompt, temperature, ct); } + + /// + /// Queries the provider for the capabilities of a specific model — + /// context window size, maximum output tokens, supported modalities. + /// + /// + /// + /// This is used by the AutoChunking orchestrator to determine whether a document + /// fits within every participant's context window. When a provider cannot determine + /// capabilities dynamically, it should return null — the orchestrator will + /// fall back to . + /// + /// + /// The default implementation returns null so existing providers keep + /// working unchanged. Providers that can introspect model metadata (e.g. Ollama + /// via /api/show) override this to return real data. + /// + /// + /// Model name to query. + /// Cancellation token. + /// + /// A instance, or null when the + /// provider cannot determine capabilities for this model. + /// + Task GetModelCapabilitiesAsync(string model, CancellationToken ct = default) + { + return Task.FromResult(null); + } } \ No newline at end of file diff --git a/src/Delibera.Core/Models/ModelCapabilities.cs b/src/Delibera.Core/Models/ModelCapabilities.cs new file mode 100644 index 0000000..c652942 --- /dev/null +++ b/src/Delibera.Core/Models/ModelCapabilities.cs @@ -0,0 +1,53 @@ +namespace Delibera.Core.Models; + +/// +/// Describes the capabilities of an LLM model — context window size, +/// maximum output tokens, and supported modalities. +/// +/// +/// +/// Obtained from the provider via +/// or inferred from the built-in . +/// +/// +/// When a provider cannot determine the context window, +/// remains null and the AutoChunking orchestrator falls back to the registry or +/// a conservative default. +/// +/// +public sealed record ModelCapabilities +{ + /// Model identifier as recognised by the provider (e.g. "llama3.2", "gpt-4o-mini"). + public required string ModelName { get; init; } + + /// + /// Maximum context window size in tokens. null when unknown. + /// This is the total number of tokens the model can process in a single request + /// (system prompt + user prompt + response). + /// + public int? ContextWindowTokens { get; init; } + + /// + /// Maximum number of tokens the model can generate in a single response. + /// null when unknown. + /// + public int? MaxOutputTokens { get; init; } + + /// Whether the model supports image / vision inputs. + public bool SupportsVision { get; init; } + + /// Whether the model supports tool / function calling. + public bool SupportsTools { get; init; } + + /// + /// Model family name for grouping and heuristics (e.g. "llama", "qwen", "phi"). + /// May be null. + /// + public string? Family { get; init; } + + /// + /// Creates a placeholder instance for a model whose capabilities are unknown. + /// All optional fields are left at their default (null / false). + /// + public static ModelCapabilities Unknown(string modelName) => new() { ModelName = modelName }; +} diff --git a/src/Delibera.Core/Models/ModelContextWindowRegistry.cs b/src/Delibera.Core/Models/ModelContextWindowRegistry.cs new file mode 100644 index 0000000..193cbeb --- /dev/null +++ b/src/Delibera.Core/Models/ModelContextWindowRegistry.cs @@ -0,0 +1,125 @@ +namespace Delibera.Core.Models; + +/// +/// Static registry of known context-window sizes for popular LLM models. +/// Used as a fallback when a provider cannot report capabilities dynamically. +/// +/// +/// +/// The registry is consulted by +/// when returns +/// null or a with an unknown context window. +/// +/// +/// Call to add custom models at startup. The lookup is +/// case-insensitive and uses substring matching — "llama3.2:latest" matches the +/// "llama3.2" entry. +/// +/// +public static class ModelContextWindowRegistry +{ + private static readonly Dictionary KnownWindows = new(StringComparer.OrdinalIgnoreCase) + { + // ── Llama family ── + ["llama3.2"] = 131_072, + ["llama3.1"] = 131_072, + ["llama3"] = 8_192, + ["llama2"] = 4_096, + + // ── Qwen family ── + ["qwen2.5"] = 32_768, + ["qwen2"] = 32_768, + ["qwen"] = 8_192, + + // ── DeepSeek family ── + ["deepseek-r1"] = 131_072, + ["deepseek-v3"] = 131_072, + ["deepseek-coder"] = 16_384, + + // ── Phi family ── + ["phi4"] = 16_384, + ["phi3.5"] = 131_072, + ["phi3"] = 4_096, + ["phi-3"] = 4_096, + + // ── Mistral / Mixtral ── + ["mistral"] = 32_768, + ["mixtral"] = 32_768, + ["codestral"] = 32_768, + ["ministral"] = 131_072, + + // ── Gemma ── + ["gemma3"] = 32_768, + ["gemma2"] = 8_192, + ["gemma"] = 8_192, + + // ── Command R ── + ["command-r"] = 131_072, + ["command-r-plus"] = 131_072, + + // ── OpenAI ── + ["gpt-4o"] = 131_072, + ["gpt-4o-mini"] = 131_072, + ["gpt-4-turbo"] = 131_072, + ["gpt-4"] = 8_192, + ["gpt-3.5-turbo"] = 16_384, + ["o1"] = 200_000, + ["o1-mini"] = 131_072, + ["o3-mini"] = 200_000, + + // ── Anthropic ── + ["claude-3.5"] = 200_000, + ["claude-3"] = 200_000, + ["claude"] = 200_000, + + // ── YandexGPT ── + ["yandexgpt-5"] = 32_768, + ["yandexgpt-32k"] = 32_768, + ["yandexgpt"] = 8_000, + + // ── Other ── + ["nomic"] = 8_192, + ["mxbai"] = 32_768, + ["tinyllama"] = 2_048, + ["stable-code"] = 16_384, + }; + + /// + /// Looks up the context window size for a model by name. + /// Uses case-insensitive substring matching — "llama3.2:7b" matches "llama3.2". + /// + /// Model name as reported by the provider. + /// Context window in tokens, or null if unknown. + public static int? GetContextWindow(string modelName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(modelName); + + foreach (var (pattern, window) in KnownWindows) + if (modelName.Contains(pattern, StringComparison.OrdinalIgnoreCase)) + return window; + + return null; + } + + /// + /// Registers a custom model pattern with its context window size. + /// Overwrites any existing entry for the same pattern. + /// + /// + /// Substring pattern to match against model names (case-insensitive). + /// E.g. "my-fine-tuned-llama" will match "my-fine-tuned-llama:v2". + /// + /// Context window size in tokens. + public static void Register(string modelNamePattern, int contextWindowTokens) + { + ArgumentException.ThrowIfNullOrWhiteSpace(modelNamePattern); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(contextWindowTokens); + + KnownWindows[modelNamePattern] = contextWindowTokens; + } + + /// + /// Returns a read-only snapshot of all registered model patterns and their context windows. + /// + public static IReadOnlyDictionary GetAll() => KnownWindows.AsReadOnly(); +} diff --git a/src/Delibera.Core/Models/PromptContext.cs b/src/Delibera.Core/Models/PromptContext.cs index adba2b6..7c91faa 100644 --- a/src/Delibera.Core/Models/PromptContext.cs +++ b/src/Delibera.Core/Models/PromptContext.cs @@ -1,3 +1,5 @@ +using Delibera.Core.Chunking; + namespace Delibera.Core.Models; /// @@ -22,20 +24,108 @@ public sealed record PromptContext( { } + // ── AutoChunking fields ── + + /// + /// The chunking plan created by . + /// null when AutoChunking is disabled or no knowledge content is present. + /// + public ChunkingPlan? ChunkingPlan { get; init; } + + /// + /// true when the knowledge content exceeds the smallest model's context window + /// and must be split into chunks across rounds. + /// + public bool AutoChunkingEnabled { get; init; } + + /// + /// The smallest context window (in tokens) among all council models. + /// null when unknown. Used for diagnostics and logging. + /// + public int? MinContextWindow { get; init; } + + // ── Prompt builders ── + /// /// Builds the full user prompt, injecting knowledge context when available. + /// When AutoChunking is enabled, use instead + /// to get the round-appropriate chunks. /// public string GetFullUserPrompt() { - if (string.IsNullOrWhiteSpace(KnowledgeContent)) - return UserPrompt; + return string.IsNullOrWhiteSpace(KnowledgeContent) + ? UserPrompt + : $""" + ### Context (Knowledge Base): + {KnowledgeContent} + + ### Question: + {UserPrompt} + """; + } + + /// + /// Builds a chunked user prompt for a specific debate round. + /// When AutoChunking is disabled, falls back to . + /// + /// Current round number (1-based). + /// Total number of rounds in the debate. + /// + /// Previous rounds for context continuity. When provided, a summary of previously + /// disclosed chunks is prepended. + /// + /// The formatted user prompt with the appropriate chunks for this round. + public string GetChunkedUserPrompt( + int roundNumber, + int totalRounds, + IReadOnlyList? previousRounds = null) + { + if (!AutoChunkingEnabled || ChunkingPlan is not { } plan) + return GetFullUserPrompt(); + + // Distribute chunks evenly across rounds. + var perRound = Math.Max(1, (int)Math.Ceiling((double)plan.TotalChunks / totalRounds)); + var start = (roundNumber - 1) * perRound; + var end = Math.Min(start + perRound, plan.TotalChunks); + + if (start >= plan.TotalChunks) + return GetFullUserPrompt(); // past the last chunk — fall back + + // Index-based access — avoids LINQ Skip/Take enumerator allocations. + var count = end - start; + var chunks = new List(count); + for (var i = start; i < end; i++) + chunks.Add(plan.Chunks[i]); + + if (chunks.Count == 0) + return GetFullUserPrompt(); + + var sb = new StringBuilder(); + + // Summary of previously seen chunks for continuity. + if (roundNumber > 1 && previousRounds is { Count: > 0 }) + { + sb.AppendLine("### Previously Reviewed (Summary):"); + sb.AppendLine($"(Rounds 1–{roundNumber - 1} covered chunks 1–" + + $"{Math.Min((roundNumber - 1) * perRound, plan.TotalChunks)} " + + $"of {plan.TotalChunks})"); + sb.AppendLine(); + } + + sb.AppendLine($"### Context (Knowledge Base) — Part {roundNumber}/{totalRounds}:"); + sb.AppendLine($"(Chunks {chunks[0].Index + 1}–{chunks[^1].Index + 1} of {plan.TotalChunks})"); + sb.AppendLine(); + + foreach (var chunk in chunks) + { + sb.AppendLine($"#### [Chunk {chunk.Index + 1}/{plan.TotalChunks}] {chunk.SectionTitle}"); + sb.AppendLine(chunk.Content); + sb.AppendLine(); + } - return $$""" - ### Context (Knowledge Base): - {{KnowledgeContent}} + sb.AppendLine("### Question:"); + sb.Append(UserPrompt); - ### Question: - {{UserPrompt}} - """; + return sb.ToString(); } -} \ 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 0c8276b..38028f0 100644 --- a/src/Delibera.Core/Providers/LLM/ChatClientLLMProvider.cs +++ b/src/Delibera.Core/Providers/LLM/ChatClientLLMProvider.cs @@ -83,6 +83,24 @@ public Task> ListModelsAsync(CancellationToken ct = defaul return Task.FromResult(models); } + /// + /// + /// The Microsoft.Extensions.AI abstraction does not define a capabilities contract, + /// so this falls back to the static . + /// Providers that can introspect model metadata (e.g. Ollama) override this. + /// + public Task GetModelCapabilitiesAsync(string model, CancellationToken ct = default) + { + var window = ModelContextWindowRegistry.GetContextWindow(model); + if (window is not null) + { + return Task.FromResult( + new ModelCapabilities { ModelName = model, ContextWindowTokens = window }); + } + + return Task.FromResult(null); + } + /// public async Task ChatAsync( string model, diff --git a/src/Delibera.Core/Providers/LLM/OllamaProvider.cs b/src/Delibera.Core/Providers/LLM/OllamaProvider.cs index 3675f2a..76bc863 100644 --- a/src/Delibera.Core/Providers/LLM/OllamaProvider.cs +++ b/src/Delibera.Core/Providers/LLM/OllamaProvider.cs @@ -1,3 +1,4 @@ +using System.Text.RegularExpressions; using Delibera.Core.DependencyInjection; using Delibera.Core.Resilience; using Microsoft.Extensions.AI; @@ -175,6 +176,69 @@ public async Task> ListModelsAsync(CancellationToken ct = } } + /// + public async Task GetModelCapabilitiesAsync(string model, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(model); + + try + { + // Use Ollama's /api/show endpoint to get model metadata including Modelfile parameters. + var response = await Client.ShowModelAsync(model, ct); + + // 1. Try to extract num_ctx from the Modelfile parameters string. + var contextWindow = ExtractContextWindowFromParameters(response.Parameters); + + // 2. Fall back to the static registry if the API didn't report a context window. + contextWindow ??= ModelContextWindowRegistry.GetContextWindow(model); + + // 3. Determine model family from the model info. + var family = response.Info?.Architecture ?? response.Details?.Family; + + // 4. Check capabilities for vision support. + var supportsVision = response.Capabilities is not null && + response.Capabilities.Any(c => c.Contains("vision", StringComparison.OrdinalIgnoreCase)); + + return new ModelCapabilities + { + ModelName = model, + ContextWindowTokens = contextWindow, + Family = family, + SupportsVision = supportsVision, + SupportsTools = false // Ollama tool support is model-dependent; default to false. + }; + } + catch (Exception ex) + { + // If the API call fails, fall back to the static registry. + System.Diagnostics.Debug.WriteLine( + $"OllamaProvider: failed to get capabilities for '{model}': {ex.Message}"); + + var window = ModelContextWindowRegistry.GetContextWindow(model); + return window is not null + ? new ModelCapabilities { ModelName = model, ContextWindowTokens = window } + : null; + } + } + + /// + /// Extracts the num_ctx parameter from an Ollama Modelfile parameters string. + /// The parameters string looks like: + /// num_keep 24\nstop "<|start_header_id|>"\nnum_ctx 131072\n... + /// + private static int? ExtractContextWindowFromParameters(string? parameters) + { + if (string.IsNullOrWhiteSpace(parameters)) + return null; + + // Match "num_ctx " in the parameters string. + var match = Regex.Match(parameters, @"num_ctx\s+(\d+)", RegexOptions.IgnoreCase); + if (match.Success && int.TryParse(match.Groups[1].Value, out var ctx)) + return ctx; + + return null; + } + /// public async Task ChatAsync( string model, diff --git a/src/Delibera.Core/Providers/LLM/YandexGptProvider.cs b/src/Delibera.Core/Providers/LLM/YandexGptProvider.cs index 2d75b00..eba60bb 100644 --- a/src/Delibera.Core/Providers/LLM/YandexGptProvider.cs +++ b/src/Delibera.Core/Providers/LLM/YandexGptProvider.cs @@ -191,6 +191,24 @@ public Task> ListModelsAsync(CancellationToken ct = defaul return Task.FromResult(models); } + /// + /// + /// YandexGPT does not expose a capabilities endpoint, so this falls back to the + /// static . Known Yandex models are + /// pre-registered there (yandexgpt-5 = 32K, yandexgpt-32k = 32K, yandexgpt = 8K). + /// + public Task GetModelCapabilitiesAsync(string model, CancellationToken ct = default) + { + var window = ModelContextWindowRegistry.GetContextWindow(model); + if (window is not null) + { + return Task.FromResult( + new ModelCapabilities { ModelName = model, ContextWindowTokens = window }); + } + + return Task.FromResult(null); + } + /// public async Task ChatAsync( string model, diff --git a/src/Delibera.Core/README.md b/src/Delibera.Core/README.md index 0442934..bbf9581 100644 --- a/src/Delibera.Core/README.md +++ b/src/Delibera.Core/README.md @@ -2,7 +2,7 @@ ### ⚖️ 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.** +**Collective decision making through structured AI deliberation — with RAG, pgvector, Knowledge Keeper, Operator (MCP tools), Chairman, Context Compression, AutoChunking, 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)](https://github.com/techbuzzz/Delibera/blob/develop/LICENSE) @@ -33,6 +33,7 @@ outcomes** rather than single-model guesses. - 🐘 **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 +- ✂️ **AutoChunking** — progressive disclosure of large documents across rounds, respecting model context windows - 💉 **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) @@ -205,6 +206,55 @@ Console.WriteLine(result.TokenStats?.ToSummary()); --- +## ✂️ AutoChunking + +Automatically split large knowledge documents (contracts, reports, articles) into +context-window-sized chunks distributed across debate rounds via **progressive disclosure**. + +```csharp +using Delibera.Core.Chunking; +using Delibera.Core.Knowledge; + +var kb = new MarkdownKnowledgeBase(); +await kb.LoadAsync("contract_200_pages.md"); + +var result = await new CouncilBuilder() + .AddMember("phi3:mini", ollama, "Legal Expert") // 4K context window + .AddMember("llama3.2", ollama, "Business Analyst") // 128K context window + .SetChairman(Chairman.CreateStandard("qwen2.5", ollama)) + .WithKnowledge(kb) + .WithUserPrompt("Analyse this contract for risks to the client.") + .WithAutoChunking() // ← auto-chunking enabled + .Build() + .ExecuteAsync(); +// Document is split into ~3K-token chunks (phi3:mini = 4K window) +// Chunks are progressively disclosed across rounds +``` + +### Three configuration paths + +```csharp +// 1. Fluent API +builder.WithAutoChunking(new AutoChunkingOptions { Strategy = ChunkingStrategy.SemanticBoundary }); + +// 2. CouncilOptions snapshot +builder.WithOptions(new CouncilOptions { AutoChunking = new() { Enabled = true } }); + +// 3. Lambda +builder.WithOptions(o => { o.AutoChunking.Enabled = true; o.AutoChunking.MaxChunksPerRound = 2; }); +``` + +### Model Context Window Registry + +```csharp +var window = ModelContextWindowRegistry.GetContextWindow("llama3.2"); // → 131072 +ModelContextWindowRegistry.Register("my-custom-model", 65536); +``` + +> ▶️ Run the demo: `dotnet run --project src/Delibera.ConsoleApp -- --autochunking` + +--- + ## 📚 RAG Integration Use a dedicated **Qdrant** instance or your existing **PostgreSQL/pgvector** database as a vector store. @@ -514,10 +564,18 @@ var (resultPath, statsPath, logsPath) = await result.SaveAllAsync("./output"); } ] }, - "Output": { - "Directory": "./debate_results", - "SeparateFiles": true - } + "Output": { + "Directory": "./debate_results", + "SeparateFiles": true + }, + "AutoChunking": { + "Enabled": true, + "Strategy": "SemanticBoundary", + "SafetyMargin": 0.15, + "MaxChunksPerRound": 3, + "EnableMapReduce": true, + "EnableProgressiveDisclosure": true + } } } ```