diff --git a/CrestApps.Core.slnx b/CrestApps.Core.slnx
index dc5b9fb1..0e079010 100644
--- a/CrestApps.Core.slnx
+++ b/CrestApps.Core.slnx
@@ -34,7 +34,7 @@
-
+
diff --git a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md
index b37d7b03..d427f915 100644
--- a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md
+++ b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md
@@ -25,6 +25,9 @@ description: Initial standalone release notes for the CrestApps.Core repository.
- evaluates every configured AI connection and deployment section when importing appsettings records, including provider-grouped connection sections and deployment entries that reference shared `ConnectionName` values
- adds shared `JsonNode` support extensions for common string, boolean, and raw-value extraction so AI configuration parsing and Elasticsearch document readers reuse one implementation instead of duplicating private helpers
- replaces removed obsolete connection-level deployment-name helpers with non-obsolete legacy lookup extensions for `AIProviderConnectionEntry`, keeping backward-compatible fallback resolution without depending on deleted APIs
+- renames `CrestApps.Core.AI.AISearch` to `CrestApps.Core.AI.Azure.AISearch`, groups the docs navigation around orchestrators, surfaces the Claude docs page, renames AI Providers to AI Clients, and updates the OpenAI docs to call out common OpenAI-compatible endpoints plus the dedicated Claude path
+- aligns the built-in Entity Framework Core stores with the same `IStoreCommitter` unit-of-work pattern as YesSql and refreshes the storage/getting-started docs to explain MVC, Minimal API, SignalR, and background commit boundaries consistently
+- adds hierarchical document retrieval mode support so document RAG can rank on chunks and then inject full matched document text when hosts or profiles opt into that behavior
- clarifies deployment-store registration by introducing `IAIDeploymentStore` for persisted deployments, moves Chat Interactions ahead of AI Profiles / AI Chat in the MVC sample onboarding flow, and adds dedicated AI Profile documentation that explains how profiles power reusable chat, agents, orchestration, retrieval, and session processing
- centralizes reusable MCP runtime registration in `AddCoreAIMcpServices()`, moves the shared MCP metadata, capability-resolution, tool-registry, SSE settings-handler, and invoke-function services into `CrestApps.Core.AI.Mcp`, and splits optional StdIO transport registration so hosts can enable it only where needed
- treats aborted and canceled request-stream failures in the Aspire AppHost as observed task exceptions so local development no longer floods the console with benign unobserved-task noise
diff --git a/src/CrestApps.Core.Docs/docs/core/architecture.md b/src/CrestApps.Core.Docs/docs/core/architecture.md
index 6704b311..02fe0e04 100644
--- a/src/CrestApps.Core.Docs/docs/core/architecture.md
+++ b/src/CrestApps.Core.Docs/docs/core/architecture.md
@@ -59,7 +59,7 @@ This page describes the project architecture and how the major layers depend on
| `CrestApps.Core.AI.AzureAIInference` | Azure AI Inference / GitHub Models provider |
| `CrestApps.Core.AI.Copilot` | GitHub Copilot chat orchestration, OAuth flow, credential management |
| `CrestApps.Core.Azure.AISearch` | Azure AI Search provider primitives for client setup, index management, document management, and OData filters |
-| `CrestApps.Core.AI.AISearch` | Azure AI Search integration for AI document index profiles, AI memory search, and AI data-source registrations |
+| `CrestApps.Core.AI.Azure.AISearch` | Azure AI Search integration for AI document index profiles, AI memory search, and AI data-source registrations |
| `CrestApps.Core.Elasticsearch` | Elasticsearch provider primitives for client setup, index management, document management, and query/filter translation |
| `CrestApps.Core.AI.Elasticsearch` | Elasticsearch integration for AI document index profiles, AI memory search, and AI data-source registrations |
| `CrestApps.Core.AI.Mcp` | Model Context Protocol (MCP) client and server |
diff --git a/src/CrestApps.Core.Docs/docs/core/copilot.md b/src/CrestApps.Core.Docs/docs/core/copilot.md
index 15f46f78..b2dec1e4 100644
--- a/src/CrestApps.Core.Docs/docs/core/copilot.md
+++ b/src/CrestApps.Core.Docs/docs/core/copilot.md
@@ -68,7 +68,7 @@ Users authenticate through a standard GitHub OAuth flow. The framework exchanges
### BYOK (API Key) Mode
-The tenant admin configures a provider type, base URL, and API key. All users share the same credentials — no per-user authentication is needed. This mode supports any OpenAI-compatible endpoint (OpenAI, Azure OpenAI, Anthropic, or self-hosted).
+The tenant admin configures a provider type, base URL, and API key. All users share the same credentials — no per-user authentication is needed. This mode supports OpenAI-compatible endpoints such as OpenAI, Azure OpenAI, Google Gemini compatibility endpoints, Groq, OpenRouter, or self-hosted OpenAI-style servers.
## Configuration
diff --git a/src/CrestApps.Core.Docs/docs/core/data-storage.md b/src/CrestApps.Core.Docs/docs/core/data-storage.md
index 78699cd6..e21b7c4f 100644
--- a/src/CrestApps.Core.Docs/docs/core/data-storage.md
+++ b/src/CrestApps.Core.Docs/docs/core/data-storage.md
@@ -79,7 +79,7 @@ public interface ICatalog : IReadCatalog
}
```
-The YesSql implementation **stages** writes only. Hosts using `CrestApps.Core.Data.YesSql` must flush the YesSql session at the end of the HTTP request, SignalR hub method, or background operation that performed the write — see [Automatic store commit (`IStoreCommitter`)](#automatic-store-commit-istorecommitter) for how the framework handles this automatically. The Entity Framework Core implementation commits after each write operation via an individual `SaveChangesAsync()` call inside each store method. Every create, update, and delete is durable immediately; no request-level middleware is required or expected for the built-in stores.
+The first-party YesSql and Entity Framework Core implementations both use a scoped unit-of-work boundary and flush tracked writes through `IStoreCommitter`. Hosts should call that commit boundary at the end of MVC actions, Minimal API endpoints, SignalR hub methods, or background scopes that performed writes — see [Automatic store commit (`IStoreCommitter`)](#automatic-store-commit-istorecommitter) for the built-in filters and background-task pattern.
### `INamedCatalog`
@@ -532,15 +532,7 @@ The package stores framework records in EF Core-managed tables and keeps the sam
## Automatic store commit (`IStoreCommitter`)
-:::info
-**If you are using Entity Framework Core**, you can skip this section entirely. The EntityCore stores commit on every individual write operation via `SaveChangesAsync()`, so no commit middleware or manual flush is required.
-:::
-
-### Why does YesSql need this?
-
-YesSql is a document store that **stages** all writes in memory during a request. Nothing is persisted to the database until the YesSql session is explicitly flushed with `ISession.SaveChangesAsync()`. This design gives you transactional consistency — all writes in a single request succeed or fail together — but it means you must call the flush at the end of every request that performs writes.
-
-Instead of requiring you to call that flush manually in every controller action, hub method, and endpoint, the framework provides `IStoreCommitter` — a thin abstraction that triggers the flush at the right time.
+The first-party YesSql and Entity Framework Core stores both use `IStoreCommitter` as their commit boundary. YesSql flushes the current `ISession`, while Entity Framework Core flushes the current `DbContext`. That keeps request, endpoint, and hub behavior consistent across the built-in store packages.
```csharp
public interface IStoreCommitter
@@ -549,11 +541,11 @@ public interface IStoreCommitter
}
```
-`AddCoreYesSqlDataStore()` registers `YesSqlStoreCommitter` as the scoped `IStoreCommitter`. The implementation calls `ISession.SaveChangesAsync()` to flush all staged writes to the database.
+`AddCoreYesSqlDataStore()` registers `YesSqlStoreCommitter`, and `AddCoreEntityCoreDataStore()` / `AddCoreEntityCoreSqliteDataStore()` register `EntityCoreStoreCommitter`. Both are scoped `IStoreCommitter` implementations.
### What happens if you forget to commit?
-If `IStoreCommitter.CommitAsync()` is never called during a YesSql request, all writes made during that request are silently lost. The data appears to be saved in memory (reads within the same request see the staged data), but nothing reaches the database. This is the most common pitfall when using YesSql stores.
+If `IStoreCommitter.CommitAsync()` is never called during a request or background scope that stages changes, those writes never become durable. Reads in the same scope may still see tracked changes, which can hide the problem until a later request.
### Automatic commit for MVC controllers
@@ -614,8 +606,8 @@ The filter infrastructure calls your committer automatically — no other wiring
| Store package | Commit behavior | Middleware required? |
|---------------|----------------|---------------------|
-| **Entity Framework Core** | Commits on every individual write (`SaveChangesAsync()` per operation) | No |
-| **YesSql** | Stages writes in memory; flushes on `IStoreCommitter.CommitAsync()` | Yes — use `AddCrestAppsStoreCommitterFilter()` for MVC/SignalR, or `StoreCommitterEndpointFilter` for Minimal APIs |
+| **Entity Framework Core** | Stages tracked `DbContext` changes and flushes on `IStoreCommitter.CommitAsync()` | Yes — use `AddCrestAppsStoreCommitterFilter()` for MVC/SignalR, or `StoreCommitterEndpointFilter` for Minimal APIs |
+| **YesSql** | Stages `ISession` writes in memory and flushes on `IStoreCommitter.CommitAsync()` | Yes — use `AddCrestAppsStoreCommitterFilter()` for MVC/SignalR, or `StoreCommitterEndpointFilter` for Minimal APIs |
## Multi-Source Binding Pattern
diff --git a/src/CrestApps.Core.Docs/docs/core/default-orchestrator.md b/src/CrestApps.Core.Docs/docs/core/default-orchestrator.md
new file mode 100644
index 00000000..d63380c1
--- /dev/null
+++ b/src/CrestApps.Core.Docs/docs/core/default-orchestrator.md
@@ -0,0 +1,47 @@
+---
+sidebar_label: Default Orchestrator
+title: Default Orchestrator
+description: The built-in CrestApps.Core orchestrator that composes tools, RAG, streaming, and response handling into one execution pipeline.
+---
+
+# Default Orchestrator
+
+> The built-in CrestApps.Core orchestration engine that connects the framework's AI clients, tools, retrieval pipelines, response handlers, and streaming loop into one end-to-end execution model.
+
+## What it is
+
+`DefaultOrchestrator` is the framework's first-party `IOrchestrator` implementation. It is the standard orchestrator used when you call `AddCoreAIOrchestration()` and do not select an alternative such as Copilot or Claude.
+
+It is responsible for:
+
+- loading the active AI client and deployment
+- building orchestration context from profiles, templates, MCP, data sources, documents, and memory
+- scoping tools progressively so large tool catalogs stay usable
+- running preemptive RAG before the main completion call
+- streaming model output and routing references back to the caller
+
+## When to use it
+
+Use the default orchestrator when you want the full CrestApps.Core pipeline instead of a provider-specific orchestrator runtime.
+
+That is usually the right choice when you need:
+
+- the shared tool and agent pipeline
+- preemptive RAG across documents, memory, and data sources
+- MCP integration through the framework's own orchestration flow
+- predictable host-controlled deployment and connection resolution
+
+## Registration
+
+```csharp
+builder.Services
+ .AddCoreAIServices()
+ .AddCoreAIOrchestration()
+ .AddCoreAIOpenAI();
+```
+
+## Relationship to the orchestration docs
+
+This page is the conceptual overview for the built-in orchestrator.
+
+Use **[Orchestration](./orchestration.md)** for the full pipeline details, registered services, progressive tool scoping, configuration knobs, and extension points.
diff --git a/src/CrestApps.Core.Docs/docs/core/getting-started-aspnet.md b/src/CrestApps.Core.Docs/docs/core/getting-started-aspnet.md
index 1f67a2a1..a5cec239 100644
--- a/src/CrestApps.Core.Docs/docs/core/getting-started-aspnet.md
+++ b/src/CrestApps.Core.Docs/docs/core/getting-started-aspnet.md
@@ -240,9 +240,24 @@ builder.Services.AddCrestAppsCore(crestApps => crestApps
```
:::tip
-YesSql stages writes in memory and flushes them as a single transaction at the end of a request. The framework provides `IStoreCommitter` and automatic commit filters to handle this. See [Data Storage — Automatic store commit](data-storage.md#automatic-store-commit-istorecommitter) for details. Entity Framework Core commits on every individual write, so no commit middleware is needed.
+Both Entity Framework Core and the built-in YesSql stores follow the same `IStoreCommitter` pattern. Register the MVC action filter, the Minimal API endpoint filter, and the existing SignalR store-committer filter when your store implementation uses a unit-of-work/session model. If your custom implementation persists immediately and does not stage tracked changes, you do not need `IStoreCommitter`.
:::
+For MVC actions:
+
+```csharp
+builder.Services
+ .AddControllersWithViews()
+ .AddCrestAppsStoreCommitterFilter();
+```
+
+For Minimal APIs:
+
+```csharp
+app.MapGroup("/api")
+ .AddEndpointFilter();
+```
+
If you already use another ORM or storage model, implement the same catalog/store abstractions against your preferred backend. See [Data Storage](data-storage.md) for the full per-feature store reference.
## 6. Add features one layer at a time
diff --git a/src/CrestApps.Core.Docs/docs/core/index.md b/src/CrestApps.Core.Docs/docs/core/index.md
index 48007e2e..e47a24eb 100644
--- a/src/CrestApps.Core.Docs/docs/core/index.md
+++ b/src/CrestApps.Core.Docs/docs/core/index.md
@@ -83,7 +83,7 @@ The quickest way to validate the setup is to use **Chat Interactions** first, th
| Claude orchestration | `AddCoreAIClaudeOrchestrator()` | `CrestApps.Core.AI.Claude` | [Claude Orchestrator](./claude.md) |
| SignalR and widgets | `AddCoreSignalR()` | `CrestApps.Core.SignalR` | [SignalR](./signalr.md) |
| Data storage | Store registration extensions | `CrestApps.Core.Data.YesSql` | [Data Storage](./data-storage.md) |
-| Providers | Provider-specific extensions | Provider packages | [AI Providers](../providers/index.md) |
+| AI clients | Provider-specific extensions | Provider packages | [AI Clients](../providers/index.md) |
| Data sources | Backend-specific extensions | Search packages | [Data Sources](../data-sources/index.md) |
| MCP | `AddCoreAIMcpClient()` / `AddCoreAIMcpServer()` | `CrestApps.Core.AI.Mcp` | [MCP](../mcp/index.md) |
| A2A | `AddCoreAIA2AClient()` | `CrestApps.Core.AI.A2A` | [A2A](../a2a/index.md) |
diff --git a/src/CrestApps.Core.Docs/docs/core/mvc-example.md b/src/CrestApps.Core.Docs/docs/core/mvc-example.md
index dcd2f9b3..a084a412 100644
--- a/src/CrestApps.Core.Docs/docs/core/mvc-example.md
+++ b/src/CrestApps.Core.Docs/docs/core/mvc-example.md
@@ -25,19 +25,12 @@ CrestApps.Core.Mvc.Web/
│ ├── DataSources/ ← Data source CRUD and storage
│ └── Indexing/ ← Index profiles and AI document indexing
├── BackgroundTasks/ ← Hosted services for maintenance
-
├── Controllers/ ← Non-area MVC controllers such as Home and Account
-
├── Hubs/ ← SignalR hubs for real-time chat
-
├── Indexes/ ← YesSql index providers
-
├── Tools/ ← Custom AI tools
-
├── Views/ ← Non-area Razor views
-
├── App_Data/ ← Runtime data (DB, logs, documents, settings)
-
└── wwwroot/ ← Static files
```
@@ -64,7 +57,6 @@ Configures NLog with daily log file rotation in `App_Data/logs/`. Replaceable wi
Loads settings from the normal appsettings chain plus `App_Data/appsettings.json` as the highest-priority local override file with automatic reload-on-change:
| Service | Purpose |
-
|---------|---------|
| `App_Data/appsettings.json` | Local machine overrides for infrastructure settings (AI connections, credentials, Elasticsearch, Azure AI Search) |
| `App_Data/site-settings.json` | Mutable admin-managed settings (AI options, deployments, chat, admin widget, etc.) owned exclusively by `SiteSettingsStore` — not registered in the configuration pipeline |
@@ -135,7 +127,7 @@ builder.Services.AddCrestAppsCore(crestApps => crestApps
`AddAISuite(...)` always wires the shared foundation, AI runtime, and orchestration together. `AddChatInteractions()` inside that suite then registers the shared `DataSourceChatInteractionSettingsHandler`, so Chat Interactions persist the selected data source and RAG metadata through the framework settings pipeline instead of MVC-only wiring. The provider service blocks also pull in the shared data-source RAG registrations, which register both `DataSourceOrchestrationHandler` and `DataSourcePreemptiveRagHandler` at the framework level so source availability instructions and preemptive RAG stay aligned with the saved chat settings.
-`AddAIDocuments()`, `AddAIDataSources()`, and `AddAIMemory()` in those indexing blocks now come from the AI-specific provider packages: `CrestApps.Core.AI.Elasticsearch` and `CrestApps.Core.AI.AISearch`. The base `CrestApps.Core.Elasticsearch` and `CrestApps.Core.Azure.AISearch` packages now stay focused on the provider primitives and shared search infrastructure only.
+`AddAIDocuments()`, `AddAIDataSources()`, and `AddAIMemory()` in those indexing blocks now come from the AI-specific provider packages: `CrestApps.Core.AI.Elasticsearch` and `CrestApps.Core.AI.Azure.AISearch`. The base `CrestApps.Core.Elasticsearch` and `CrestApps.Core.Azure.AISearch` packages now stay focused on the provider primitives and shared search infrastructure only.
The MVC sample now also registers both the **Claude** and **Copilot** orchestrators. Claude uses the official Anthropic SDK with a site-level authentication mode, API key, and live model discovery, while Copilot keeps its dedicated OAuth/BYOK flow. Admins can choose either orchestrator from the same AI Profile, AI Template, and Chat Interaction editors.
@@ -143,7 +135,7 @@ Documents, memory, and data sources now remain fully independent orchestration s
The MVC sample explicitly calls `AddMarkdown()` inside `AddAISuite(...)`. That keeps Markdown-aware normalization opt-in at the host level instead of making `CrestApps.Core.AI` depend on the Markdig-backed package automatically.
-### Section 6 — AI Providers
+### Section 6 — AI Clients
Registers all supported AI providers:
diff --git a/src/CrestApps.Core.Docs/docs/data-sources/azure-ai.md b/src/CrestApps.Core.Docs/docs/data-sources/azure-ai.md
index 21961b0c..18f9f0c7 100644
--- a/src/CrestApps.Core.Docs/docs/data-sources/azure-ai.md
+++ b/src/CrestApps.Core.Docs/docs/data-sources/azure-ai.md
@@ -58,7 +58,7 @@ builder.Services.AddCoreAzureAISearchServices();
When the `Endpoint` is provided, a `SearchIndexClient` singleton is also registered.
-AI-specific Azure AI Search registrations now live in `CrestApps.Core.AI.AISearch`. Register that package when you need `AddAIDocuments()`, `AddAIDataSources()`, `AddAIMemory()`, or Azure AI Search-backed AI RAG/search flows.
+AI-specific Azure AI Search registrations now live in `CrestApps.Core.AI.Azure.AISearch`. Register that package when you need `AddAIDocuments()`, `AddAIDataSources()`, `AddAIMemory()`, or Azure AI Search-backed AI RAG/search flows.
## Authentication
diff --git a/src/CrestApps.Core.Docs/docs/getting-started.md b/src/CrestApps.Core.Docs/docs/getting-started.md
index 33eb09a1..8e59d78f 100644
--- a/src/CrestApps.Core.Docs/docs/getting-started.md
+++ b/src/CrestApps.Core.Docs/docs/getting-started.md
@@ -111,9 +111,16 @@ By default:
"Deployments": [
{
"Name": "gpt-4.1",
- "ClientName": "OpenAI",
+ "ConnectionName": "primary-openai",
"ModelName": "gpt-4.1",
"Type": "Chat"
+ },
+ {
+ "Name": "standalone-utility",
+ "ClientName": "OpenAI",
+ "ModelName": "gpt-4.1-mini",
+ "Type": "Utility",
+ "ApiKey": "YOUR_OTHER_API_KEY"
}
]
}
@@ -121,6 +128,8 @@ By default:
}
```
+Use `ConnectionName` when a deployment should point at a shared entry from `CrestApps:AI:Connections`. Keep contained connection settings directly on the deployment only when you want a standalone deployment definition.
+
Create an AI profile that uses your chat deployment, then use Chat Interactions to test it end to end.
## Learn the registration model
diff --git a/src/CrestApps.Core.Docs/docs/mcp/client.md b/src/CrestApps.Core.Docs/docs/mcp/client.md
index ca61c344..9e7050af 100644
--- a/src/CrestApps.Core.Docs/docs/mcp/client.md
+++ b/src/CrestApps.Core.Docs/docs/mcp/client.md
@@ -1,13 +1,13 @@
---
-sidebar_label: MCP Client
+sidebar_label: Hosts
sidebar_position: 2
-title: MCP Client
-description: Connect to remote MCP servers to discover and use their tools, prompts, and resources in AI orchestration.
+title: MCP Hosts
+description: Connect to remote MCP hosts to discover and use their tools, prompts, and resources in AI orchestration.
---
-# MCP Client
+# MCP Hosts
-> Connect to remote MCP servers, discover their capabilities, and make their tools available to the AI orchestrator.
+> Connect to remote MCP hosts, discover their capabilities, and make their tools available to the AI orchestrator.
## Quick Start
diff --git a/src/CrestApps.Core.Docs/docs/mcp/index.md b/src/CrestApps.Core.Docs/docs/mcp/index.md
index 0a7966a4..2e18510b 100644
--- a/src/CrestApps.Core.Docs/docs/mcp/index.md
+++ b/src/CrestApps.Core.Docs/docs/mcp/index.md
@@ -29,7 +29,7 @@ builder.Services
- **Automatic tool discovery** — Discovered tools appear in the orchestrator's tool registry and are invoked transparently
- **Capability resolution** — Semantic similarity filtering to select relevant tools from large MCP server catalogs
-📖 **[MCP Client →](./client.md)** — Full documentation with transport configuration, authentication, and integration details.
+📖 **[MCP Hosts →](./client.md)** — Full documentation with transport configuration, authentication, and integration details.
## Server — Expose Your AI Capabilities
diff --git a/src/CrestApps.Core.Docs/docs/providers/index.md b/src/CrestApps.Core.Docs/docs/providers/index.md
index 515761d1..7fca5829 100644
--- a/src/CrestApps.Core.Docs/docs/providers/index.md
+++ b/src/CrestApps.Core.Docs/docs/providers/index.md
@@ -1,11 +1,11 @@
---
sidebar_label: Overview
sidebar_position: 1
-title: AI Providers
-description: Provider architecture and how to connect to OpenAI, Azure OpenAI, Ollama, and Azure AI Inference.
+title: AI Clients
+description: AI client architecture and how to connect to OpenAI, Azure OpenAI, Ollama, and Azure AI Inference.
---
-# AI Providers
+# AI Clients
> Connect to one or more LLM providers. Each provider registers an `IAIClientProvider` that creates typed AI clients.
diff --git a/src/CrestApps.Core.Docs/docs/providers/openai.md b/src/CrestApps.Core.Docs/docs/providers/openai.md
index 2c958dd2..64218b71 100644
--- a/src/CrestApps.Core.Docs/docs/providers/openai.md
+++ b/src/CrestApps.Core.Docs/docs/providers/openai.md
@@ -94,6 +94,30 @@ builder.Services.AddCoreAIConnectionSource("OpenAI", options =>
});
```
+## OpenAI-compatible endpoints
+
+The OpenAI client is often useful beyond `api.openai.com`. It can also connect to providers that expose OpenAI-compatible chat and embedding endpoints.
+
+Common examples include:
+
+| Provider | Notes |
+|----------|-------|
+| OpenAI | Native target for this client |
+| Azure OpenAI | OpenAI-compatible request shape with Azure-specific endpoint and deployment conventions |
+| Anthropic | Provides OpenAI-compatible endpoints in addition to its native Claude API |
+| Google Gemini | Offers an OpenAI-compatibility layer for supported Gemini models |
+| Groq | OpenAI-compatible chat/completions API |
+| Mistral | OpenAI-compatible API surface for supported models |
+| xAI | OpenAI-compatible API for Grok models |
+| Together AI | OpenAI-compatible endpoints for hosted open-weight models |
+| Fireworks AI | OpenAI-compatible endpoints for hosted open-weight models |
+| OpenRouter | OpenAI-compatible multi-provider routing layer |
+| DeepSeek | OpenAI-compatible chat and embedding-style endpoints |
+| Perplexity | OpenAI-compatible chat/completions endpoint |
+| Ollama / vLLM / LocalAI | Common self-hosted OpenAI-compatible endpoints |
+
+Some providers expose both OpenAI-compatible and native APIs. Anthropic is one example: you can use its OpenAI-compatible endpoint through this client, or use the dedicated **[Claude Orchestrator](../core/claude.md)** when you want the native Claude-oriented integration path.
+
:::tip
Never commit API keys to source control. Use environment variables, user secrets, or a vault provider:
```bash
diff --git a/src/CrestApps.Core.Docs/sidebars.js b/src/CrestApps.Core.Docs/sidebars.js
index 7dc5b230..6118f77f 100644
--- a/src/CrestApps.Core.Docs/sidebars.js
+++ b/src/CrestApps.Core.Docs/sidebars.js
@@ -40,7 +40,16 @@ const sidebars = {
'core/ai-templates',
'core/chat',
'core/context-builders',
- 'core/copilot',
+ {
+ type: 'category',
+ label: 'Orchestrators',
+ items: [
+ 'core/default-orchestrator',
+ 'core/orchestration',
+ 'core/copilot',
+ 'core/claude',
+ ],
+ },
{
type: 'category',
label: 'Data Sources',
@@ -62,10 +71,9 @@ const sidebars = {
'mcp/server',
],
},
- 'core/orchestration',
{
type: 'category',
- label: 'AI Providers',
+ label: 'AI Clients',
items: [
'providers/index',
'providers/azure-ai-inference',
diff --git a/src/Primitives/CrestApps.Core.AI.AISearch/CrestApps.Core.AI.AISearch.csproj b/src/Primitives/CrestApps.Core.AI.Azure.AISearch/CrestApps.Core.AI.Azure.AISearch.csproj
similarity index 93%
rename from src/Primitives/CrestApps.Core.AI.AISearch/CrestApps.Core.AI.AISearch.csproj
rename to src/Primitives/CrestApps.Core.AI.Azure.AISearch/CrestApps.Core.AI.Azure.AISearch.csproj
index 52d8cd89..c5091f4f 100644
--- a/src/Primitives/CrestApps.Core.AI.AISearch/CrestApps.Core.AI.AISearch.csproj
+++ b/src/Primitives/CrestApps.Core.AI.Azure.AISearch/CrestApps.Core.AI.Azure.AISearch.csproj
@@ -1,7 +1,7 @@
- CrestApps.Core.AI.AISearch
+ CrestApps.Core.AI.Azure.AISearchCrestApps AI Azure AI Search
$(CrestAppsDescription)
diff --git a/src/Primitives/CrestApps.Core.AI.AISearch/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.Azure.AISearch/ServiceCollectionExtensions.cs
similarity index 97%
rename from src/Primitives/CrestApps.Core.AI.AISearch/ServiceCollectionExtensions.cs
rename to src/Primitives/CrestApps.Core.AI.Azure.AISearch/ServiceCollectionExtensions.cs
index 52f102ef..de7af17e 100644
--- a/src/Primitives/CrestApps.Core.AI.AISearch/ServiceCollectionExtensions.cs
+++ b/src/Primitives/CrestApps.Core.AI.Azure.AISearch/ServiceCollectionExtensions.cs
@@ -1,5 +1,5 @@
using Azure.Search.Documents.Indexes;
-using CrestApps.Core.AI.AISearch.Services;
+using CrestApps.Core.AI.Azure.AISearch.Services;
using CrestApps.Core.AI.Documents;
using CrestApps.Core.AI.Indexing;
using CrestApps.Core.AI.Memory;
@@ -10,7 +10,7 @@
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
-namespace CrestApps.Core.AI.AISearch;
+namespace CrestApps.Core.AI.Azure.AISearch;
public static class ServiceCollectionExtensions
{
diff --git a/src/Primitives/CrestApps.Core.AI.AISearch/Services/AzureAISearchMemoryVectorSearchService.cs b/src/Primitives/CrestApps.Core.AI.Azure.AISearch/Services/AzureAISearchMemoryVectorSearchService.cs
similarity index 98%
rename from src/Primitives/CrestApps.Core.AI.AISearch/Services/AzureAISearchMemoryVectorSearchService.cs
rename to src/Primitives/CrestApps.Core.AI.Azure.AISearch/Services/AzureAISearchMemoryVectorSearchService.cs
index 4bde47f8..6063e908 100644
--- a/src/Primitives/CrestApps.Core.AI.AISearch/Services/AzureAISearchMemoryVectorSearchService.cs
+++ b/src/Primitives/CrestApps.Core.AI.Azure.AISearch/Services/AzureAISearchMemoryVectorSearchService.cs
@@ -8,7 +8,7 @@
using Microsoft.Extensions.Logging;
using AzureSearchDocument = Azure.Search.Documents.Models.SearchDocument;
-namespace CrestApps.Core.AI.AISearch.Services;
+namespace CrestApps.Core.AI.Azure.AISearch.Services;
internal sealed class AzureAISearchMemoryVectorSearchService : IMemoryVectorSearchService
{
diff --git a/src/Primitives/CrestApps.Core.AI.AISearch/Services/AzureAISearchVectorSearchService.cs b/src/Primitives/CrestApps.Core.AI.Azure.AISearch/Services/AzureAISearchVectorSearchService.cs
similarity index 99%
rename from src/Primitives/CrestApps.Core.AI.AISearch/Services/AzureAISearchVectorSearchService.cs
rename to src/Primitives/CrestApps.Core.AI.Azure.AISearch/Services/AzureAISearchVectorSearchService.cs
index f9324e88..47922c6a 100644
--- a/src/Primitives/CrestApps.Core.AI.AISearch/Services/AzureAISearchVectorSearchService.cs
+++ b/src/Primitives/CrestApps.Core.AI.Azure.AISearch/Services/AzureAISearchVectorSearchService.cs
@@ -8,7 +8,7 @@
using Microsoft.Extensions.Logging;
using AzureSearchDocument = Azure.Search.Documents.Models.SearchDocument;
-namespace CrestApps.Core.AI.AISearch.Services;
+namespace CrestApps.Core.AI.Azure.AISearch.Services;
///
/// Azure AI Search implementation of for searching document embeddings.
diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs b/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs
index 0d9ebdf6..435ca73d 100644
--- a/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs
+++ b/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs
@@ -1,4 +1,4 @@
-using System.Diagnostics;
+using System.Diagnostics;
using System.IO.Pipelines;
using System.Threading.Channels;
using CrestApps.Core.AI.Chat.Models;
@@ -1220,7 +1220,7 @@ private async Task RunConversationLoopAsync(AIProfile profile, string sessionId,
{
var pipe = new Pipe();
using var errorCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
- var transcriptionTask = TranscribeConversationAsync(pipe.Reader, profile, sessionId,audioFormat, speechLanguage, speechToTextClient, textToSpeechClient, voiceName, services, errorCts, cancellationToken);
+ var transcriptionTask = TranscribeConversationAsync(pipe.Reader, profile, sessionId, audioFormat, speechLanguage, speechToTextClient, textToSpeechClient, voiceName, services, errorCts, cancellationToken);
try
{
await foreach (var base64Chunk in audioChunks.WithCancellation(errorCts.Token))
@@ -1458,7 +1458,7 @@ private async Task StreamTranscriptionAsync(ISpeechToTextClient speechToTextClie
{
var pipe = new Pipe();
using var errorCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
- var transcriptionTask = TranscribeAudioInputAsync(sessionId, pipe,audioFormat, speechLanguage, speechToTextClient, errorCts, cancellationToken);
+ var transcriptionTask = TranscribeAudioInputAsync(sessionId, pipe, audioFormat, speechLanguage, speechToTextClient, errorCts, cancellationToken);
try
{
await foreach (var base64Chunk in audioChunks.WithCancellation(errorCts.Token))
diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Handlers/DocumentPreemptiveRagHandler.cs b/src/Primitives/CrestApps.Core.AI.Documents/Handlers/DocumentPreemptiveRagHandler.cs
index 151a6f83..b83da9b5 100644
--- a/src/Primitives/CrestApps.Core.AI.Documents/Handlers/DocumentPreemptiveRagHandler.cs
+++ b/src/Primitives/CrestApps.Core.AI.Documents/Handlers/DocumentPreemptiveRagHandler.cs
@@ -1,6 +1,7 @@
using CrestApps.Core.AI.Clients;
using CrestApps.Core.AI.Deployments;
using CrestApps.Core.AI.Documents.Models;
+using CrestApps.Core.AI.Documents.Services;
using CrestApps.Core.AI.Memory;
using CrestApps.Core.AI.Models;
using CrestApps.Core.AI.Orchestration;
@@ -17,8 +18,8 @@
namespace CrestApps.Core.AI.Documents.Handlers;
///
-/// Preemptively retrieves relevant document chunks for uploaded documents or profile knowledge documents
-/// and injects them into the orchestration system message before model generation begins.
+/// Preemptively retrieves relevant document context for uploaded documents or profile knowledge documents
+/// and injects it into the orchestration system message before model generation begins.
///
internal sealed class DocumentPreemptiveRagHandler : IPreemptiveRagHandler
{
@@ -80,7 +81,7 @@ public async Task HandleAsync(PreemptiveRagContext context)
try
{
- await InjectPreemptiveRagContextAsync(context, _options);
+ await InjectPreemptiveRagContextAsync(context, ResolveSettings(context.Resource, _options));
}
catch (Exception ex)
{
@@ -88,9 +89,7 @@ public async Task HandleAsync(PreemptiveRagContext context)
}
}
- private async Task InjectPreemptiveRagContextAsync(
- PreemptiveRagContext context,
- InteractionDocumentOptions settings)
+ private async Task InjectPreemptiveRagContextAsync(PreemptiveRagContext context, InteractionDocumentOptions settings)
{
var indexProfile = await _indexProfileStore.FindByNameAsync(settings.IndexProfileName);
@@ -143,24 +142,7 @@ private async Task InjectPreemptiveRagContextAsync(
return;
}
- var searchScopes = new List<(string ResourceId, string ReferenceType)>();
-
- if (context.Resource is ChatInteraction interaction)
- {
- searchScopes.Add((interaction.ItemId, AIReferenceTypes.Document.ChatInteraction));
- }
- else if (context.Resource is AIProfile profile)
- {
- searchScopes.Add((profile.ItemId, AIReferenceTypes.Document.Profile));
-
- if (context.OrchestrationContext.CompletionContext?.AdditionalProperties is not null &&
- context.OrchestrationContext.CompletionContext.AdditionalProperties.TryGetValue("Session", out var sessionObject) &&
- sessionObject is AIChatSession session &&
- session.Documents is { Count: > 0 })
- {
- searchScopes.Add((session.SessionId, AIReferenceTypes.Document.ChatSession));
- }
- }
+ var searchScopes = ResolveSearchScopes(context);
if (searchScopes.Count == 0)
{
@@ -181,11 +163,12 @@ context.Resource is not AIProfile ||
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug(
- "Document Preemptive RAG: searching {ScopeCount} scope(s): [{Scopes}] with {QueryCount} queries, topN={TopN}.",
+ "Document Preemptive RAG: searching {ScopeCount} scope(s): [{Scopes}] with {QueryCount} queries, topN={TopN}, retrievalMode={RetrievalMode}.",
searchScopes.Count,
string.Join(", ", searchScopes.Select(s => $"{s.ReferenceType}:{s.ResourceId}")),
context.Queries.Count,
- topN);
+ topN,
+ settings.RetrievalMode);
}
var allResults = new List<(DocumentChunkSearchResult Result, string ReferenceType)>();
@@ -234,7 +217,6 @@ context.Resource is not AIProfile ||
var finalResults = allResults
.OrderByDescending(r => r.Result.Score)
- .Take(topN)
.ToList();
if (finalResults.Count == 0)
@@ -247,11 +229,6 @@ context.Resource is not AIProfile ||
return;
}
- if (_logger.IsEnabled(LogLevel.Debug))
- {
- _logger.LogDebug("Document Preemptive RAG: injecting {ResultCount} chunk(s) into system message.", finalResults.Count);
- }
-
var orchestrationContext = context.OrchestrationContext;
using var builder = ZString.CreateStringBuilder();
@@ -279,88 +256,230 @@ context.Resource is not AIProfile ||
builder.Append(header);
}
+ var invocationContext = AIInvocationScope.Current;
+ var seenDocuments = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ if (settings.RetrievalMode == DocumentRetrievalMode.Hierarchical)
+ {
+ builder.Append(await AppendHierarchicalContextAsync(finalResults, topN, showUserDocumentAwareness, keepProfileDocumentAwareness, invocationContext, seenDocuments));
+ }
+ else if (showUserDocumentAwareness)
+ {
+ builder.Append(AppendChunkContext(finalResults.Take(topN), keepProfileDocumentAwareness, invocationContext, seenDocuments));
+ }
+ else
+ {
+ foreach (var (result, _) in finalResults.Take(topN))
+ {
+ builder.AppendLine("---");
+ builder.AppendLine(result.Chunk.Text);
+ }
+ }
+
if (showUserDocumentAwareness)
{
- var invocationContext = AIInvocationScope.Current;
- var seenDocuments = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ builder.Append(AddDocumentReferences(orchestrationContext, seenDocuments));
+ }
- foreach (var (result, scopeReferenceType) in finalResults)
+ orchestrationContext.SystemMessageBuilder.Append(builder);
+ }
+
+ private static InteractionDocumentOptions ResolveSettings(object resource, InteractionDocumentOptions defaults)
+ {
+ if (resource is AIProfile profile &&
+ profile.TryGet(out var metadata))
+ {
+ return new InteractionDocumentOptions
{
- if (!keepProfileDocumentAwareness && scopeReferenceType == AIReferenceTypes.Document.Profile)
- {
- builder.AppendLine("---");
- builder.AppendLine(result.Chunk.Text);
- continue;
- }
+ IndexProfileName = defaults.IndexProfileName,
+ TopN = metadata.DocumentTopN ?? defaults.TopN,
+ RetrievalMode = metadata.RetrievalMode ?? defaults.RetrievalMode,
+ };
+ }
- var documentKey = result.DocumentKey;
+ return defaults;
+ }
- if (!string.IsNullOrEmpty(documentKey) && !seenDocuments.ContainsKey(documentKey))
- {
- seenDocuments[documentKey] = (invocationContext?.NextReferenceIndex() ?? seenDocuments.Count + 1, _textNormalizer.NormalizeTitle(result.FileName));
- }
+ private static List<(string ResourceId, string ReferenceType)> ResolveSearchScopes(PreemptiveRagContext context)
+ {
+ var searchScopes = new List<(string ResourceId, string ReferenceType)>();
+
+ if (context.Resource is ChatInteraction interaction)
+ {
+ searchScopes.Add((interaction.ItemId, AIReferenceTypes.Document.ChatInteraction));
+ return searchScopes;
+ }
+
+ if (context.Resource is not AIProfile profile)
+ {
+ return searchScopes;
+ }
- var referenceIndex = !string.IsNullOrEmpty(documentKey) && seenDocuments.TryGetValue(documentKey, out var entry)
- ? entry.Index
- : invocationContext?.NextReferenceIndex() ?? seenDocuments.Count + 1;
+ searchScopes.Add((profile.ItemId, AIReferenceTypes.Document.Profile));
+ if (context.OrchestrationContext.CompletionContext?.AdditionalProperties is not null &&
+ context.OrchestrationContext.CompletionContext.AdditionalProperties.TryGetValue("Session", out var sessionObject) &&
+ sessionObject is AIChatSession session &&
+ session.Documents is { Count: > 0 })
+ {
+ searchScopes.Add((session.SessionId, AIReferenceTypes.Document.ChatSession));
+ }
+
+ return searchScopes;
+ }
+
+ private async Task AppendHierarchicalContextAsync(
+ IReadOnlyCollection<(DocumentChunkSearchResult Result, string ReferenceType)> results,
+ int topN,
+ bool showUserDocumentAwareness,
+ bool keepProfileDocumentAwareness,
+ AIInvocationContext invocationContext,
+ Dictionary seenDocuments)
+ {
+ using var builder = ZString.CreateStringBuilder();
+ var documentStore = _serviceProvider.GetService();
+
+ if (documentStore == null)
+ {
+ return AppendChunkContext(results.Take(topN), keepProfileDocumentAwareness, invocationContext, seenDocuments);
+ }
+
+ var selectedDocuments = results
+ .Where(x => !string.IsNullOrWhiteSpace(x.Result.DocumentKey))
+ .GroupBy(x => x.Result.DocumentKey, StringComparer.OrdinalIgnoreCase)
+ .Select(group => new
+ {
+ DocumentId = group.Key,
+ Score = group.Max(x => x.Result.Score),
+ FileName = group.Select(x => x.Result.FileName).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)),
+ ReferenceType = group.Select(x => x.ReferenceType).First(),
+ })
+ .OrderByDescending(x => x.Score)
+ .Take(topN)
+ .ToList();
+
+ foreach (var documentEntry in selectedDocuments)
+ {
+ var document = await documentStore.FindByIdAsync(documentEntry.DocumentId);
+
+ if (document == null)
+ {
+ continue;
+ }
+
+ if (!keepProfileDocumentAwareness && documentEntry.ReferenceType == AIReferenceTypes.Document.Profile)
+ {
builder.AppendLine("---");
- builder.Append("[doc:");
- builder.Append(referenceIndex);
- builder.Append("] ");
- builder.AppendLine(result.Chunk.Text);
+ builder.AppendLine(await DocumentContextFormatter.FormatDocumentTextFromChunksAsync(_serviceProvider, document));
+ continue;
}
- if (seenDocuments.Count > 0)
+ if (!showUserDocumentAwareness)
{
- builder.AppendLine();
- builder.AppendLine("References:");
+ builder.AppendLine("---");
+ builder.AppendLine(await DocumentContextFormatter.FormatDocumentTextFromChunksAsync(_serviceProvider, document));
+ continue;
+ }
- foreach (var kvp in seenDocuments)
- {
- builder.Append("[doc:");
- builder.Append(kvp.Value.Index);
- builder.Append("] = {DocumentId: \"");
- builder.Append(kvp.Key);
- builder.Append('"');
+ var referenceIndex = invocationContext?.NextReferenceIndex() ?? seenDocuments.Count + 1;
+ seenDocuments[document.ItemId] = (referenceIndex, _textNormalizer.NormalizeTitle(document.FileName));
- if (!string.IsNullOrWhiteSpace(kvp.Value.FileName))
- {
- builder.Append(", FileName: \"");
- builder.Append(kvp.Value.FileName);
- builder.Append('"');
- }
+ builder.AppendLine("---");
+ builder.Append("[doc:");
+ builder.Append(referenceIndex);
+ builder.AppendLine("]");
+ builder.AppendLine(await DocumentContextFormatter.FormatDocumentTextFromChunksAsync(_serviceProvider, document));
+ }
- builder.AppendLine("}");
- }
+ return builder.ToString();
+ }
- var citationMap = new Dictionary();
+ private string AppendChunkContext(
+ IEnumerable<(DocumentChunkSearchResult Result, string ReferenceType)> results,
+ bool keepProfileDocumentAwareness,
+ AIInvocationContext invocationContext,
+ Dictionary seenDocuments)
+ {
+ using var builder = ZString.CreateStringBuilder();
- foreach (var kvp in seenDocuments)
- {
- var template = $"[doc:{kvp.Value.Index}]";
- citationMap[template] = new AICompletionReference
- {
- Text = string.IsNullOrWhiteSpace(kvp.Value.FileName) ? template : kvp.Value.FileName,
- Title = kvp.Value.FileName,
- Index = kvp.Value.Index,
- ReferenceId = kvp.Key,
- ReferenceType = AIReferenceTypes.DataSource.Document,
- };
- }
+ foreach (var (result, scopeReferenceType) in results)
+ {
+ if (!keepProfileDocumentAwareness && scopeReferenceType == AIReferenceTypes.Document.Profile)
+ {
+ builder.AppendLine("---");
+ builder.AppendLine(result.Chunk.Text);
+ continue;
+ }
+
+ var documentKey = result.DocumentKey;
- orchestrationContext.Properties["DocumentReferences"] = citationMap;
+ if (!string.IsNullOrEmpty(documentKey) && !seenDocuments.ContainsKey(documentKey))
+ {
+ seenDocuments[documentKey] = (invocationContext?.NextReferenceIndex() ?? seenDocuments.Count + 1, _textNormalizer.NormalizeTitle(result.FileName));
}
+
+ var referenceIndex = !string.IsNullOrEmpty(documentKey) && seenDocuments.TryGetValue(documentKey, out var entry)
+ ? entry.Index
+ : invocationContext?.NextReferenceIndex() ?? seenDocuments.Count + 1;
+
+ builder.AppendLine("---");
+ builder.Append("[doc:");
+ builder.Append(referenceIndex);
+ builder.Append("] ");
+ builder.AppendLine(result.Chunk.Text);
}
- else
+
+ return builder.ToString();
+ }
+
+ private static string AddDocumentReferences(
+ OrchestrationContext orchestrationContext,
+ Dictionary seenDocuments)
+ {
+ using var builder = ZString.CreateStringBuilder();
+
+ if (seenDocuments.Count == 0)
+ {
+ return string.Empty;
+ }
+
+ builder.AppendLine();
+ builder.AppendLine("References:");
+
+ foreach (var kvp in seenDocuments)
{
- foreach (var (result, _) in finalResults)
+ builder.Append("[doc:");
+ builder.Append(kvp.Value.Index);
+ builder.Append("] = {DocumentId: \"");
+ builder.Append(kvp.Key);
+ builder.Append('"');
+
+ if (!string.IsNullOrWhiteSpace(kvp.Value.FileName))
{
- builder.AppendLine("---");
- builder.AppendLine(result.Chunk.Text);
+ builder.Append(", FileName: \"");
+ builder.Append(kvp.Value.FileName);
+ builder.Append('"');
}
+
+ builder.AppendLine("}");
}
- orchestrationContext.SystemMessageBuilder.Append(builder);
+ var citationMap = new Dictionary();
+
+ foreach (var kvp in seenDocuments)
+ {
+ var template = $"[doc:{kvp.Value.Index}]";
+ citationMap[template] = new AICompletionReference
+ {
+ Text = string.IsNullOrWhiteSpace(kvp.Value.FileName) ? template : kvp.Value.FileName,
+ Title = kvp.Value.FileName,
+ Index = kvp.Value.Index,
+ ReferenceId = kvp.Key,
+ ReferenceType = AIReferenceTypes.DataSource.Document,
+ };
+ }
+
+ orchestrationContext.Properties["DocumentReferences"] = citationMap;
+ return builder.ToString();
}
}
diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Models/DocumentRetrievalMode.cs b/src/Primitives/CrestApps.Core.AI.Documents/Models/DocumentRetrievalMode.cs
new file mode 100644
index 00000000..dc71ae68
--- /dev/null
+++ b/src/Primitives/CrestApps.Core.AI.Documents/Models/DocumentRetrievalMode.cs
@@ -0,0 +1,7 @@
+namespace CrestApps.Core.AI.Documents.Models;
+
+public enum DocumentRetrievalMode
+{
+ Chunk = 0,
+ Hierarchical = 1,
+}
diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Models/DocumentsMetadata.cs b/src/Primitives/CrestApps.Core.AI.Documents/Models/DocumentsMetadata.cs
index bf3b6933..43d8e7ce 100644
--- a/src/Primitives/CrestApps.Core.AI.Documents/Models/DocumentsMetadata.cs
+++ b/src/Primitives/CrestApps.Core.AI.Documents/Models/DocumentsMetadata.cs
@@ -18,4 +18,9 @@ public sealed class DocumentsMetadata
/// Default is 3 if not specified.
///
public int? DocumentTopN { get; set; }
+
+ ///
+ /// Gets or sets how retrieved document matches are added to AI context.
+ ///
+ public DocumentRetrievalMode? RetrievalMode { get; set; }
}
diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Models/InteractionDocumentOptions.cs b/src/Primitives/CrestApps.Core.AI.Documents/Models/InteractionDocumentOptions.cs
index 2c23c579..2fa37967 100644
--- a/src/Primitives/CrestApps.Core.AI.Documents/Models/InteractionDocumentOptions.cs
+++ b/src/Primitives/CrestApps.Core.AI.Documents/Models/InteractionDocumentOptions.cs
@@ -12,4 +12,9 @@ public sealed class InteractionDocumentOptions
/// Default is 3.
///
public int TopN { get; set; } = 3;
+
+ ///
+ /// Gets or sets how retrieved document matches are added to AI context.
+ ///
+ public DocumentRetrievalMode RetrievalMode { get; set; } = DocumentRetrievalMode.Chunk;
}
diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Models/InteractionDocumentSettings.cs b/src/Primitives/CrestApps.Core.AI.Documents/Models/InteractionDocumentSettings.cs
index 2b4e807e..48729afd 100644
--- a/src/Primitives/CrestApps.Core.AI.Documents/Models/InteractionDocumentSettings.cs
+++ b/src/Primitives/CrestApps.Core.AI.Documents/Models/InteractionDocumentSettings.cs
@@ -12,4 +12,9 @@ public sealed class InteractionDocumentSettings
/// Default is 3.
///
public int TopN { get; set; } = 3;
+
+ ///
+ /// Gets or sets how retrieved document matches are added to AI context.
+ ///
+ public DocumentRetrievalMode RetrievalMode { get; set; } = DocumentRetrievalMode.Chunk;
}
diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Services/DocumentContextFormatter.cs b/src/Primitives/CrestApps.Core.AI.Documents/Services/DocumentContextFormatter.cs
new file mode 100644
index 00000000..339e50ad
--- /dev/null
+++ b/src/Primitives/CrestApps.Core.AI.Documents/Services/DocumentContextFormatter.cs
@@ -0,0 +1,46 @@
+using CrestApps.Core.AI.Models;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace CrestApps.Core.AI.Documents.Services;
+
+internal static class DocumentContextFormatter
+{
+ public static async Task FormatDocumentTextFromChunksAsync(IServiceProvider services, AIDocument document, int? maxLength = null)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentNullException.ThrowIfNull(document);
+
+ var chunkStore = services.GetService();
+
+ if (chunkStore is null)
+ {
+ return $"Document '{document.FileName}' has no extractable text content.";
+ }
+
+ var chunks = await chunkStore.GetChunksByAIDocumentIdAsync(document.ItemId);
+
+ if (chunks.Count == 0)
+ {
+ return $"Document '{document.FileName}' has no extractable text content.";
+ }
+
+ var text = string.Join(Environment.NewLine, chunks.OrderBy(c => c.Index).Select(c => c.Content));
+
+ return FormatDocumentText(document.FileName, text, maxLength);
+ }
+
+ public static string FormatDocumentText(string fileName, string text, int? maxLength = null)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ return $"Document '{fileName}' has no extractable text content.";
+ }
+
+ if (maxLength is > 0 && text.Length > maxLength.Value)
+ {
+ text = string.Concat(text.AsSpan(0, maxLength.Value), "\n\n... [content truncated]");
+ }
+
+ return $"[Document: {fileName}]\n\n{text}";
+ }
+}
diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Tools/ReadDocumentTool.cs b/src/Primitives/CrestApps.Core.AI.Documents/Tools/ReadDocumentTool.cs
index 2d50bcfb..3ac45bcb 100644
--- a/src/Primitives/CrestApps.Core.AI.Documents/Tools/ReadDocumentTool.cs
+++ b/src/Primitives/CrestApps.Core.AI.Documents/Tools/ReadDocumentTool.cs
@@ -1,13 +1,11 @@
using System.Text.Json;
+using CrestApps.Core.AI.Documents.Services;
using CrestApps.Core.AI.Extensions;
using CrestApps.Core.AI.Models;
-
using CrestApps.Core.AI.Orchestration;
using CrestApps.Core.AI.Tooling;
using Microsoft.Extensions.AI;
-
using Microsoft.Extensions.DependencyInjection;
-
using Microsoft.Extensions.Logging;
namespace CrestApps.Core.AI.Documents.Tools;
@@ -146,39 +144,6 @@ sessionObj is AIChatSession session &&
private static async Task FormatDocumentTextFromChunksAsync(IServiceProvider services, AIDocument document)
{
- var chunkStore = services.GetService();
-
- if (chunkStore is null)
- {
- return $"Document '{document.FileName}' has no extractable text content.";
- }
-
- var chunks = await chunkStore.GetChunksByAIDocumentIdAsync(document.ItemId);
-
- if (chunks.Count == 0)
- {
- return $"Document '{document.FileName}' has no extractable text content.";
- }
-
- var text = string.Join(Environment.NewLine, chunks.OrderBy(c => c.Index).Select(c => c.Content));
-
- return FormatDocumentText(document.FileName, text);
- }
-
- private static string FormatDocumentText(string fileName, string text)
- {
- if (string.IsNullOrWhiteSpace(text))
- {
- return $"Document '{fileName}' has no extractable text content.";
- }
-
- const int maxLength = 50_000;
-
- if (text.Length > maxLength)
- {
- text = string.Concat(text.AsSpan(0, maxLength), "\n\n... [content truncated at 50KB]");
- }
-
- return $"[Document: {fileName}]\n\n{text}";
+ return await DocumentContextFormatter.FormatDocumentTextFromChunksAsync(services, document, 50_000);
}
}
diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Tools/SearchDocumentsTool.cs b/src/Primitives/CrestApps.Core.AI.Documents/Tools/SearchDocumentsTool.cs
index 477b7e91..d783027f 100644
--- a/src/Primitives/CrestApps.Core.AI.Documents/Tools/SearchDocumentsTool.cs
+++ b/src/Primitives/CrestApps.Core.AI.Documents/Tools/SearchDocumentsTool.cs
@@ -2,6 +2,7 @@
using CrestApps.Core.AI.Clients;
using CrestApps.Core.AI.Deployments;
using CrestApps.Core.AI.Documents.Models;
+using CrestApps.Core.AI.Documents.Services;
using CrestApps.Core.AI.Extensions;
using CrestApps.Core.AI.Models;
using CrestApps.Core.AI.Orchestration;
@@ -18,7 +19,7 @@
namespace CrestApps.Core.AI.Documents.Tools;
///
-/// Searches indexed document chunks across the active AI profile or chat interaction.
+/// Searches indexed document knowledge across the active AI profile or chat interaction.
///
public sealed class SearchDocumentsTool : AIFunction
{
@@ -35,7 +36,7 @@ public sealed class SearchDocumentsTool : AIFunction
},
"top_n": {
"type": "integer",
- "description": "Number of top matching chunks to return. Defaults to 3."
+ "description": "Number of top matches to return. Defaults to 3."
}
},
"required": ["query"],
@@ -45,7 +46,7 @@ public sealed class SearchDocumentsTool : AIFunction
public override string Name => TheName;
- public override string Description => "Searches available document knowledge using semantic vector search and returns the most relevant text chunks. If no relevant content is found, report that the documents do not contain the answer.";
+ public override string Description => "Searches available document knowledge using semantic vector search and returns the most relevant matching context. If no relevant content is found, report that the documents do not contain the answer.";
public override JsonElement JsonSchema => _jsonSchema;
@@ -62,38 +63,28 @@ protected override async ValueTask