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.AISearch CrestApps 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 InvokeCoreAsync(AIFunctionArguments a if (!arguments.TryGetFirstString("query", out var query)) { logger.LogWarning("AI tool '{ToolName}' missing required argument 'query'.", Name); + return "Unable to find a 'query' argument in the arguments parameter."; } try { var invocationContext = AIInvocationScope.Current; - var textNormalizer = arguments.Services.GetRequiredService(); var executionContext = invocationContext?.ToolExecutionContext; - var searchScopes = new List<(string ResourceId, string ReferenceType)>(); - - if (executionContext?.Resource is ChatInteraction interaction) - { - searchScopes.Add((interaction.ItemId, AIReferenceTypes.Document.ChatInteraction)); - } - else if (executionContext?.Resource is AIProfile profile) - { - searchScopes.Add((profile.ItemId, AIReferenceTypes.Document.Profile)); - - if (invocationContext?.Items.TryGetValue(nameof(AIChatSession), out var sessionObj) == true && - sessionObj is AIChatSession session && - session.Documents is { Count: > 0 }) - { - searchScopes.Add((session.SessionId, AIReferenceTypes.Document.ChatSession)); - } - } + var textNormalizer = arguments.Services.GetRequiredService(); + var searchScopes = ResolveSearchScopes(invocationContext, executionContext?.Resource); if (searchScopes.Count == 0) { logger.LogWarning("AI tool '{ToolName}' failed: no active chat interaction session or AI profile.", Name); + return "Document search requires an active chat interaction session or AI profile."; } + var showUserDocumentAwareness = + executionContext?.Resource is not AIProfile || + searchScopes.Any(scope => scope.ReferenceType == AIReferenceTypes.Document.ChatSession); + var isStrictScope = executionContext?.Resource switch { AIProfile profile => profile.TryGet(out var profileMetadata) && profileMetadata.IsInScope, @@ -101,15 +92,13 @@ sessionObj is AIChatSession session && _ => false, }; - var showUserDocumentAwareness = - executionContext?.Resource is not AIProfile || - searchScopes.Any(scope => scope.ReferenceType == AIReferenceTypes.Document.ChatSession); - - var settings = arguments.Services.GetRequiredService>().Value; + var defaultSettings = arguments.Services.GetRequiredService>().Value; + var settings = ResolveSettings(executionContext?.Resource, defaultSettings); if (string.IsNullOrWhiteSpace(settings.IndexProfileName)) { logger.LogWarning("AI tool '{ToolName}' failed: no index profile is configured.", Name); + return "Document search is not configured. No index profile is set."; } @@ -119,6 +108,7 @@ sessionObj is AIChatSession session && if (indexProfile == null) { logger.LogWarning("AI tool '{ToolName}' failed: index profile '{IndexProfileName}' was not found.", Name, settings.IndexProfileName); + return $"Index profile '{settings.IndexProfileName}' was not found."; } @@ -127,19 +117,20 @@ sessionObj is AIChatSession session && if (searchService == null) { logger.LogWarning("AI tool '{ToolName}' failed: no search service available for provider '{ProviderName}'.", Name, indexProfile.ProviderName); + return $"No search service is available for provider '{indexProfile.ProviderName}'."; } var aiClientFactory = arguments.Services.GetRequiredService(); var deploymentManager = arguments.Services.GetRequiredService(); - var clientName = executionContext?.ClientName; var embeddingDeployment = await deploymentManager.ResolveOrDefaultAsync( AIDeploymentType.Embedding, - clientName: clientName); + clientName: executionContext?.ClientName); if (embeddingDeployment == null) { logger.LogWarning("AI tool '{ToolName}' failed: no embedding deployment configured.", Name); + return "No embedding deployment is configured for document search."; } @@ -148,6 +139,7 @@ sessionObj is AIChatSession session && if (embeddingGenerator == null) { logger.LogWarning("AI tool '{ToolName}' failed: could not create embedding generator.", Name); + return "Failed to create embedding generator for document search."; } @@ -156,167 +148,357 @@ sessionObj is AIChatSession session && if (embeddings is null || embeddings.Count == 0 || embeddings[0]?.Vector is null) { logger.LogWarning("AI tool '{ToolName}' failed: could not generate embedding for query.", Name); + return "Failed to generate embedding for the search query."; } var topN = arguments.GetFirstValueOrDefault("top_n", settings.TopN); + if (topN <= 0) { topN = 3; } - var allResults = new List<(DocumentChunkSearchResult Result, string ReferenceType)>(); - var seenChunkKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + var results = await SearchAsync( + searchService, + indexProfile, + embeddings[0].Vector.ToArray(), + searchScopes, + topN, + cancellationToken); + + if (results.Count == 0) + { + if (showUserDocumentAwareness) + { + return isStrictScope + ? "No relevant content was found in the uploaded documents for this query. Tell the user the uploaded documents do not contain the answer." + : "No relevant content was found in the uploaded documents for this query."; + } + + return isStrictScope + ? "No relevant background knowledge content was found for this query. Tell the user the available knowledge does not contain the answer." + : "No relevant background knowledge content was found for this query."; + } + var hasProfileScope = searchScopes.Any(scope => scope.ReferenceType == AIReferenceTypes.Document.Profile); var hasSessionScope = searchScopes.Any(scope => scope.ReferenceType == AIReferenceTypes.Document.ChatSession); var keepProfileDocumentAwareness = !(executionContext?.Resource is AIProfile && hasProfileScope && hasSessionScope); - foreach (var (scopeResourceId, scopeReferenceType) in searchScopes) + using var builder = ZString.CreateStringBuilder(); + builder.AppendLine(showUserDocumentAwareness + ? "Relevant content from uploaded documents:" + : "Relevant background knowledge content:"); + + if (settings.RetrievalMode == DocumentRetrievalMode.Hierarchical) + { + builder.Append(await AppendHierarchicalContextAsync( + arguments.Services, + results, + topN, + showUserDocumentAwareness, + keepProfileDocumentAwareness, + invocationContext, + textNormalizer)); + } + else { - var results = await searchService.SearchAsync( - indexProfile, - embeddings[0].Vector.ToArray(), + builder.Append(AppendChunkContext( + results.Take(topN), + showUserDocumentAwareness, + keepProfileDocumentAwareness, + invocationContext, + textNormalizer)); + } + + return builder.ToString().TrimEnd(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error during document search."); + + return "An error occurred while searching documents."; + } + } + + private static InteractionDocumentOptions ResolveSettings(object resource, InteractionDocumentOptions defaults) + { + if (resource is AIProfile profile && + profile.TryGet(out var metadata)) + { + return new InteractionDocumentOptions + { + IndexProfileName = defaults.IndexProfileName, + TopN = metadata.DocumentTopN ?? defaults.TopN, + RetrievalMode = metadata.RetrievalMode ?? defaults.RetrievalMode, + }; + } + + return defaults; + } + + private static List<(string ResourceId, string ReferenceType)> ResolveSearchScopes(AIInvocationContext invocationContext, object resource) + { + var searchScopes = new List<(string ResourceId, string ReferenceType)>(); + + if (resource is ChatInteraction interaction) + { + searchScopes.Add((interaction.ItemId, AIReferenceTypes.Document.ChatInteraction)); + return searchScopes; + } + + if (resource is not AIProfile profile) + { + return searchScopes; + } + + searchScopes.Add((profile.ItemId, AIReferenceTypes.Document.Profile)); + + if (invocationContext?.Items.TryGetValue(nameof(AIChatSession), out var sessionObj) == true && + sessionObj is AIChatSession session && + session.Documents is { Count: > 0 }) + { + searchScopes.Add((session.SessionId, AIReferenceTypes.Document.ChatSession)); + } + + return searchScopes; + } + + private static async Task> SearchAsync( + IVectorSearchService searchService, + SearchIndexProfile indexProfile, + float[] embedding, + IReadOnlyCollection<(string ResourceId, string ReferenceType)> searchScopes, + int topN, + CancellationToken cancellationToken) + { + var allResults = new List<(DocumentChunkSearchResult Result, string ReferenceType)>(); + var seenChunkKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var (scopeResourceId, scopeReferenceType) in searchScopes) + { + var results = await searchService.SearchAsync( + indexProfile, + embedding, scopeResourceId, scopeReferenceType, topN, cancellationToken); - if (results == null) + if (results == null) + { + continue; + } + + foreach (var result in results) + { + if (result.Chunk == null || string.IsNullOrWhiteSpace(result.Chunk.Text)) { continue; } - foreach (var result in results) + var chunkKey = $"{result.DocumentKey}:{result.Chunk.Index}"; + + if (seenChunkKeys.Add(chunkKey)) { - if (result.Chunk == null || string.IsNullOrWhiteSpace(result.Chunk.Text)) - { - continue; - } - - var chunkKey = $"{result.DocumentKey}:{result.Chunk.Index}"; - if (seenChunkKeys.Add(chunkKey)) - { - allResults.Add((result, scopeReferenceType)); - } + allResults.Add((result, scopeReferenceType)); } } + } - var finalResults = allResults - .OrderByDescending(entry => entry.Result.Score) - .Take(topN) - .ToList(); + return allResults + .OrderByDescending(entry => entry.Result.Score) + .ToList(); + } + + private static async Task AppendHierarchicalContextAsync( + IServiceProvider services, + IReadOnlyCollection<(DocumentChunkSearchResult Result, string ReferenceType)> results, + int topN, + bool showUserDocumentAwareness, + bool keepProfileDocumentAwareness, + AIInvocationContext invocationContext, + IAITextNormalizer textNormalizer) + { + using var builder = ZString.CreateStringBuilder(); + var documentStore = services.GetService(); + + if (documentStore == null) + { + return AppendChunkContext(results.Take(topN), showUserDocumentAwareness, keepProfileDocumentAwareness, invocationContext, textNormalizer); + } - if (finalResults.Count == 0) + var selectedDocuments = results + .Where(x => !string.IsNullOrWhiteSpace(x.Result.DocumentKey)) + .GroupBy(x => x.Result.DocumentKey, StringComparer.OrdinalIgnoreCase) + .Select(group => new { - if (showUserDocumentAwareness) - { - return isStrictScope - ? "No relevant content was found in the uploaded documents for this query. Tell the user the uploaded documents do not contain the answer." - : "No relevant content was found in the uploaded documents for this query."; - } + 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(); + + if (selectedDocuments.Count == 0) + { + return AppendChunkContext(results.Take(topN), showUserDocumentAwareness, keepProfileDocumentAwareness, invocationContext, textNormalizer); + } - return isStrictScope - ? "No relevant background knowledge content was found for this query. Tell the user the available knowledge does not contain the answer." - : "No relevant background knowledge content was found for this query."; + var seenDocuments = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var documentEntry in selectedDocuments) + { + var document = await documentStore.FindByIdAsync(documentEntry.DocumentId); + + if (document == null) + { + continue; } - using var builder = ZString.CreateStringBuilder(); - builder.AppendLine(showUserDocumentAwareness - ? "Relevant content from uploaded documents:" - : "Relevant background knowledge content:"); + if (!keepProfileDocumentAwareness && documentEntry.ReferenceType == AIReferenceTypes.Document.Profile) + { + builder.AppendLine("---"); + builder.AppendLine(await DocumentContextFormatter.FormatDocumentTextFromChunksAsync(services, document)); + continue; + } - if (showUserDocumentAwareness) + if (!showUserDocumentAwareness) { - var seenDocuments = new Dictionary(StringComparer.OrdinalIgnoreCase); + builder.AppendLine("---"); + builder.AppendLine(await DocumentContextFormatter.FormatDocumentTextFromChunksAsync(services, document)); + continue; + } - foreach (var (result, scopeReferenceType) in finalResults) - { - if (result.Chunk == null || string.IsNullOrWhiteSpace(result.Chunk.Text)) - { - continue; - } - - if (!keepProfileDocumentAwareness && scopeReferenceType == AIReferenceTypes.Document.Profile) - { - builder.AppendLine("---"); - builder.AppendLine(result.Chunk.Text); - continue; - } - - var documentKey = result.DocumentKey; - if (!string.IsNullOrEmpty(documentKey) && !seenDocuments.ContainsKey(documentKey)) - { - seenDocuments[documentKey] = (invocationContext.NextReferenceIndex(), textNormalizer.NormalizeTitle(result.FileName)); - } - - var refIdx = !string.IsNullOrEmpty(documentKey) && seenDocuments.TryGetValue(documentKey, out var entry) - ? entry.Index - : invocationContext.NextReferenceIndex(); - - builder.AppendLine("---"); - builder.Append("[doc:"); - builder.Append(refIdx); - builder.Append("] "); - builder.AppendLine(result.Chunk.Text); - } + var referenceIndex = invocationContext?.NextReferenceIndex() ?? seenDocuments.Count + 1; + seenDocuments[document.ItemId] = (referenceIndex, textNormalizer.NormalizeTitle(document.FileName)); + + builder.AppendLine("---"); + builder.Append("[doc:"); + builder.Append(referenceIndex); + builder.AppendLine("]"); + builder.AppendLine(await DocumentContextFormatter.FormatDocumentTextFromChunksAsync(services, document)); + } + + builder.Append(AddDocumentReferences(invocationContext, seenDocuments)); + return builder.ToString(); + } - if (seenDocuments.Count > 0) + private static string AppendChunkContext( + IEnumerable<(DocumentChunkSearchResult Result, string ReferenceType)> results, + bool showUserDocumentAwareness, + bool keepProfileDocumentAwareness, + AIInvocationContext invocationContext, + IAITextNormalizer textNormalizer) + { + using var builder = ZString.CreateStringBuilder(); + + if (!showUserDocumentAwareness) + { + foreach (var (result, _) in results) + { + if (result.Chunk == null || string.IsNullOrWhiteSpace(result.Chunk.Text)) { - builder.AppendLine(); - builder.AppendLine("References:"); - - foreach (var kvp in seenDocuments) - { - builder.Append("[doc:"); - builder.Append(kvp.Value.Index); - builder.Append("] = {DocumentId: \""); - builder.Append(kvp.Key); - builder.Append('"'); - - if (!string.IsNullOrWhiteSpace(kvp.Value.FileName)) - { - builder.Append(", FileName: \""); - builder.Append(kvp.Value.FileName); - builder.Append('"'); - } - - builder.AppendLine("}"); - } - - foreach (var kvp in seenDocuments) - { - var template = $"[doc:{kvp.Value.Index}]"; - invocationContext.ToolReferences.TryAdd(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, - }); - } + continue; } + + builder.AppendLine("---"); + builder.AppendLine(result.Chunk.Text); } - else + + return builder.ToString(); + } + + var seenDocuments = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var (result, scopeReferenceType) in results) + { + if (result.Chunk == null || string.IsNullOrWhiteSpace(result.Chunk.Text)) { - foreach (var (result, _) in finalResults) - { - if (result.Chunk == null || string.IsNullOrWhiteSpace(result.Chunk.Text)) - { - continue; - } + continue; + } - builder.AppendLine("---"); - builder.AppendLine(result.Chunk.Text); - } + if (!keepProfileDocumentAwareness && scopeReferenceType == AIReferenceTypes.Document.Profile) + { + builder.AppendLine("---"); + builder.AppendLine(result.Chunk.Text); + continue; + } + + var documentKey = result.DocumentKey; + + 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); + } + + builder.Append(AddDocumentReferences(invocationContext, seenDocuments)); + return builder.ToString(); + } + + private static string AddDocumentReferences( + AIInvocationContext invocationContext, + Dictionary seenDocuments) + { + using var builder = ZString.CreateStringBuilder(); + + if (seenDocuments.Count == 0) + { + return string.Empty; + } + + builder.AppendLine(); + builder.AppendLine("References:"); + + foreach (var kvp in seenDocuments) + { + builder.Append("[doc:"); + builder.Append(kvp.Value.Index); + builder.Append("] = {DocumentId: \""); + builder.Append(kvp.Key); + builder.Append('"'); + + if (!string.IsNullOrWhiteSpace(kvp.Value.FileName)) + { + builder.Append(", FileName: \""); + builder.Append(kvp.Value.FileName); + builder.Append('"'); + } + + builder.AppendLine("}"); + } + + if (invocationContext == null) + { return builder.ToString(); } - catch (Exception ex) + + foreach (var kvp in seenDocuments) { - logger.LogError(ex, "Error during document search."); - return "An error occurred while searching documents."; + var template = $"[doc:{kvp.Value.Index}]"; + invocationContext.ToolReferences.TryAdd(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, + }); } + + return builder.ToString(); } } diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIProfileViewModel.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIProfileViewModel.cs index e97de9e2..41be7268 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIProfileViewModel.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIProfileViewModel.cs @@ -106,6 +106,7 @@ public sealed class AIProfileViewModel // Documents public List AttachedDocuments { get; set; } = []; public int? DocumentTopN { get; set; } + public DocumentRetrievalMode? DocumentRetrievalMode { get; set; } public bool AllowSessionDocuments { get; set; } @@ -307,6 +308,7 @@ public static AIProfileViewModel FromProfile(AIProfile profile) if (profile.TryGet(out var docMetadata)) { vm.DocumentTopN = docMetadata.DocumentTopN; + vm.DocumentRetrievalMode = docMetadata.RetrievalMode; vm.AttachedDocuments = (docMetadata.Documents ?? []).Select(d => new DocumentItem { DocumentId = d.DocumentId, @@ -481,6 +483,7 @@ public void ApplyTo(AIProfile profile) profile.Alter(metadata => { metadata.DocumentTopN = DocumentTopN; + metadata.RetrievalMode = DocumentRetrievalMode; }); profile.Alter(metadata => diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AITemplateViewModel.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AITemplateViewModel.cs index acd5b2b9..bd6b85b3 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AITemplateViewModel.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AITemplateViewModel.cs @@ -94,6 +94,7 @@ public sealed class AITemplateViewModel public bool AllowSessionDocuments { get; set; } public int? DocumentTopN { get; set; } + public DocumentRetrievalMode? DocumentRetrievalMode { get; set; } public bool HasDocumentIndexConfiguration { get; set; } public string DocumentIndexProfileName { get; set; } public List AttachedDocuments { get; set; } = []; @@ -252,6 +253,7 @@ public static AITemplateViewModel FromTemplate(AIProfileTemplate template) if (template.TryGet(out var docMetadata)) { model.DocumentTopN = docMetadata.DocumentTopN; + model.DocumentRetrievalMode = docMetadata.RetrievalMode; } if (template.TryGet(out var dataExtractionSettings)) @@ -451,6 +453,7 @@ public void ApplyTo(AIProfileTemplate template) template.Put(new DocumentsMetadata { DocumentTopN = DocumentTopN, + RetrievalMode = DocumentRetrievalMode, }); template.Put(new AIProfileDataExtractionSettings diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Create.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Create.cshtml index 8022de28..19653353 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Create.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Create.cshtml @@ -539,15 +539,24 @@ -
-
-
- - -
Number of top matching document chunks to include as AI context.
+
+
+
+ + +
Number of top matching chunks or documents to include as AI context.
+
+
+
+
+ + +
Chunk keeps chunk-level context. Hierarchical matches on chunks, then injects the full text of the matched documents.
+
-
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Edit.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Edit.cshtml index c022a33e..7d8e9088 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Edit.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Edit.cshtml @@ -603,6 +603,15 @@
+
+
+ + +
Chunk keeps chunk-level context. Hierarchical matches on chunks, then injects the full text of the matched documents.
+
+
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Create.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Create.cshtml index 55a24d8d..4b31062c 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Create.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Create.cshtml @@ -542,7 +542,16 @@
-
Number of top matching document chunks to include as AI context.
+
Number of top matching chunks or documents to include as AI context.
+
+
+
+
+ + +
Chunk keeps chunk-level context. Hierarchical matches on chunks, then injects the full text of the matched documents.
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Edit.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Edit.cshtml index 5a7800f3..e2ed3767 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Edit.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Edit.cshtml @@ -534,7 +534,16 @@
-
Number of top matching document chunks to include as AI context.
+
Number of top matching chunks or documents to include as AI context.
+
+
+
+
+ + +
Chunk keeps chunk-level context. Hierarchical matches on chunks, then injects the full text of the matched documents.
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Controllers/SettingsController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Controllers/SettingsController.cs index 46c3de05..1e329e70 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Controllers/SettingsController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Controllers/SettingsController.cs @@ -97,6 +97,7 @@ public async Task Index() DocumentIndexProfileName = documentSettings.IndexProfileName, DocumentTopN = documentSettings.TopN, + DocumentRetrievalMode = documentSettings.RetrievalMode, DataSourceDefaultStrictness = dataSourceSettings.DefaultStrictness, DataSourceDefaultTopNDocuments = dataSourceSettings.DefaultTopNDocuments, McpServerAuthenticationType = mcpServerSettings.AuthenticationType, @@ -265,6 +266,7 @@ public async Task Save(SettingsViewModel model) { IndexProfileName = model.DocumentIndexProfileName?.Trim(), TopN = model.DocumentTopN, + RetrievalMode = model.DocumentRetrievalMode, }); _siteSettings.Set(new AIDataSourceSettings diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/ViewModels/SettingsViewModel.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/ViewModels/SettingsViewModel.cs index 7e4bafdf..3b96f345 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/ViewModels/SettingsViewModel.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/ViewModels/SettingsViewModel.cs @@ -1,5 +1,6 @@ using CrestApps.Core.AI.Claude.Models; using CrestApps.Core.AI.Copilot.Models; +using CrestApps.Core.AI.Documents.Models; using CrestApps.Core.AI.Mcp.Models; using CrestApps.Core.AI.Models; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -35,6 +36,8 @@ public sealed class SettingsViewModel public int DocumentTopN { get; set; } = 3; + public DocumentRetrievalMode DocumentRetrievalMode { get; set; } = DocumentRetrievalMode.Chunk; + public int DataSourceDefaultStrictness { get; set; } = AIDataSourceSettings.MinStrictness; public int DataSourceDefaultTopNDocuments { get; set; } = AIDataSourceSettings.MinTopNDocuments; diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Views/Settings/Index.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Views/Settings/Index.cshtml index 1b88304f..43772086 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Views/Settings/Index.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Views/Settings/Index.cshtml @@ -247,7 +247,13 @@ -
The default number of matching document chunks to include when a profile does not override it.
+
The default number of matched chunks or documents to include when a profile does not override it.
+ + +
+ + +
Use Chunk for chunk-level RAG, or Hierarchical to match on chunks and then inject the full text of the matched documents.
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Controllers/ChatInteractionController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Controllers/ChatInteractionController.cs index b862f90c..568d5d6c 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Controllers/ChatInteractionController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Controllers/ChatInteractionController.cs @@ -121,7 +121,7 @@ public ChatInteractionController( public async Task Index() { - var interactions= await _interactionManager.GetAllAsync(); + var interactions = await _interactionManager.GetAllAsync(); return View(interactions.OrderByDescending(i => i.CreatedUtc).ToList()); } @@ -657,7 +657,7 @@ private static Dictionary ParsePromptParameters(string promptPar private async Task> GetValidA2AConnectionIdsAsync(IEnumerable selectedIds) { - var allIds= (await _a2aConnectionCatalog.GetAllAsync()) + var allIds = (await _a2aConnectionCatalog.GetAllAsync()) .Select(connection => connection.ItemId) .ToHashSet(StringComparer.Ordinal); @@ -670,7 +670,7 @@ private async Task> GetValidA2AConnectionIdsAsync(IEnumerable> GetValidMcpConnectionIdsAsync(IEnumerable selectedIds) { - var allIds= (await _mcpConnectionCatalog.GetAllAsync()) + var allIds = (await _mcpConnectionCatalog.GetAllAsync()) .Select(c => c.ItemId) .ToHashSet(StringComparer.Ordinal); diff --git a/src/Startup/CrestApps.Core.Mvc.Web/CrestApps.Core.Mvc.Web.csproj b/src/Startup/CrestApps.Core.Mvc.Web/CrestApps.Core.Mvc.Web.csproj index a89e5a15..cac5b891 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/CrestApps.Core.Mvc.Web.csproj +++ b/src/Startup/CrestApps.Core.Mvc.Web/CrestApps.Core.Mvc.Web.csproj @@ -32,7 +32,7 @@ - + diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Program.cs b/src/Startup/CrestApps.Core.Mvc.Web/Program.cs index cd4f4429..7d2ea0a8 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Program.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Program.cs @@ -1,7 +1,7 @@ using CrestApps.Core; using CrestApps.Core.AI; using CrestApps.Core.AI.A2A; -using CrestApps.Core.AI.AISearch; +using CrestApps.Core.AI.Azure.AISearch; using CrestApps.Core.AI.AzureAIInference; using CrestApps.Core.AI.Chat; using CrestApps.Core.AI.Claude; @@ -60,7 +60,7 @@ // 4. ASP.NET Core MVC setup // 5. Authentication & Authorization // 6. CrestApps foundation + AI services -// 7. AI Providers (OpenAI, Azure OpenAI, Ollama, Azure AI Inference) +// 7. AI Clients (OpenAI, Azure OpenAI, Ollama, Azure AI Inference) // 8. Elasticsearch services // 9. Azure AI Search services // 10. MCP — Model Context Protocol (client + server) diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Services/SiteSettingsOptionsConfigurations.cs b/src/Startup/CrestApps.Core.Mvc.Web/Services/SiteSettingsOptionsConfigurations.cs index 44eb7b76..b2f1afab 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Services/SiteSettingsOptionsConfigurations.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Services/SiteSettingsOptionsConfigurations.cs @@ -70,6 +70,7 @@ public void Configure(InteractionDocumentOptions options) var settings = _siteSettings.Get(); options.IndexProfileName = settings.IndexProfileName; options.TopN = settings.TopN; + options.RetrievalMode = settings.RetrievalMode; } } diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs b/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs index 086a2822..a16e466f 100644 --- a/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs @@ -33,6 +33,7 @@ public static IServiceCollection AddCoreEntityCoreDataStore(this IServiceCollect } services.AddDbContext(configure); + services.AddScoped(); return services; } diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/Services/DocumentCatalog.cs b/src/Stores/CrestApps.Core.Data.EntityCore/Services/DocumentCatalog.cs index 6bf4cd37..5f7fe9c4 100644 --- a/src/Stores/CrestApps.Core.Data.EntityCore/Services/DocumentCatalog.cs +++ b/src/Stores/CrestApps.Core.Data.EntityCore/Services/DocumentCatalog.cs @@ -25,7 +25,7 @@ public async ValueTask DeleteAsync(T entry) } DbContext.CatalogRecords.Remove(existing); - await DbContext.SaveChangesAsync(); + return true; } @@ -122,8 +122,8 @@ public async ValueTask CreateAsync(T record) } await SavingAsync(record); + DbContext.CatalogRecords.Add(CatalogRecordFactory.Create(record)); - await DbContext.SaveChangesAsync(); } public async ValueTask UpdateAsync(T record) @@ -144,8 +144,6 @@ public async ValueTask UpdateAsync(T record) { CatalogRecordFactory.Update(existing, record); } - - await DbContext.SaveChangesAsync(); } protected IQueryable GetReadQuery() diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIChatSessionManager.cs b/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIChatSessionManager.cs index 5d52f6fb..fa6678f0 100644 --- a/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIChatSessionManager.cs +++ b/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIChatSessionManager.cs @@ -125,8 +125,6 @@ public async Task SaveAsync(AIChatSession chatSession) { UpdateRecord(record, chatSession); } - - await _dbContext.SaveChangesAsync(); } public async Task DeleteAsync(string sessionId) @@ -151,7 +149,6 @@ public async Task DeleteAsync(string sessionId) } _dbContext.AIChatSessionRecords.Remove(record); - await _dbContext.SaveChangesAsync(); return true; } @@ -180,8 +177,6 @@ public async Task DeleteAllAsync(string profileId) _dbContext.AIChatSessionRecords.RemoveRange(records); - await _dbContext.SaveChangesAsync(); - return records.Count; } diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIChatSessionPromptStore.cs b/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIChatSessionPromptStore.cs index 6bc89aa7..2c862399 100644 --- a/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIChatSessionPromptStore.cs +++ b/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIChatSessionPromptStore.cs @@ -39,7 +39,6 @@ public async Task DeleteAllPromptsAsync(string sessionId) } DbContext.CatalogRecords.RemoveRange(records); - await DbContext.SaveChangesAsync(); return records.Count; } diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIDocumentChunkStore.cs b/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIDocumentChunkStore.cs index 8f61af2d..44a39806 100644 --- a/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIDocumentChunkStore.cs +++ b/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIDocumentChunkStore.cs @@ -52,6 +52,5 @@ public async Task DeleteByDocumentIdAsync(string documentId) } DbContext.CatalogRecords.RemoveRange(records); - await DbContext.SaveChangesAsync(); } } diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreChatInteractionPromptStore.cs b/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreChatInteractionPromptStore.cs index ea8faf78..7e9afa3c 100644 --- a/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreChatInteractionPromptStore.cs +++ b/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreChatInteractionPromptStore.cs @@ -39,7 +39,6 @@ public async Task DeleteAllPromptsAsync(string chatInteractionId) } DbContext.CatalogRecords.RemoveRange(records); - await DbContext.SaveChangesAsync(); return records.Count; } diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreStoreCommitter.cs b/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreStoreCommitter.cs new file mode 100644 index 00000000..347f3d6d --- /dev/null +++ b/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreStoreCommitter.cs @@ -0,0 +1,31 @@ +using CrestApps.Core.Services; +using Microsoft.Extensions.Logging; + +namespace CrestApps.Core.Data.EntityCore.Services; + +/// +/// Commits all tracked Entity Framework Core changes for the current request or scope. +/// Registered automatically by AddEntityCoreDataStore. +/// +public sealed class EntityCoreStoreCommitter : IStoreCommitter +{ + private readonly CrestAppsEntityDbContext _dbContext; + private readonly ILogger _logger; + + public EntityCoreStoreCommitter(CrestAppsEntityDbContext dbContext, ILogger logger) + { + _dbContext = dbContext; + _logger = logger; + } + + public async ValueTask CommitAsync(CancellationToken cancellationToken = default) + { + if (!_dbContext.ChangeTracker.HasChanges()) + { + return; + } + + _logger.LogDebug("EntityCoreStoreCommitter flushing tracked Entity Framework Core changes."); + await _dbContext.SaveChangesAsync(cancellationToken); + } +} diff --git a/tests/CrestApps.Core.Tests/Core/Chat/DocumentPreemptiveRagHandlerTests.cs b/tests/CrestApps.Core.Tests/Core/Chat/DocumentPreemptiveRagHandlerTests.cs index 1ac9b8dc..990ef305 100644 --- a/tests/CrestApps.Core.Tests/Core/Chat/DocumentPreemptiveRagHandlerTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Chat/DocumentPreemptiveRagHandlerTests.cs @@ -129,6 +129,118 @@ public async Task HandleAsync_NoIndexProfileConfigured_DoesNotModifySystemMessag Assert.False(context.Properties.ContainsKey("DocumentReferences")); } + [Fact] + public async Task HandleAsync_HierarchicalMode_InjectsFullMatchedDocumentText() + { + var indexProfile = new SearchIndexProfile + { + Name = "docs-index", + ProviderName = "test-provider", + }; + indexProfile.Put(new DataSourceIndexProfileMetadata + { + EmbeddingDeploymentId = "embedding-id", + }); + var indexProfileStore = new Mock(); + indexProfileStore.Setup(store => store + .FindByNameAsync("docs-index")) + .ReturnsAsync(indexProfile); + var deploymentManager = new Mock(); + deploymentManager.Setup(manager => manager + .FindByIdAsync("embedding-id")) + .ReturnsAsync(new AIDeployment + { + ItemId = "embedding-id", + Name = "embedding", + ModelName = "embedding", + ClientName = "OpenAI", + ConnectionName = "Default", + Type = AIDeploymentType.Embedding, + }); + var vectorSearchService = new Mock(); + vectorSearchService.Setup(service => service + .SearchAsync(indexProfile, It.IsAny(), "profile-1", AIReferenceTypes.Document.Profile, 2, It.IsAny())) + .ReturnsAsync([new DocumentChunkSearchResult + { + DocumentKey = "doc-1", + FileName = "race.pdf", + Score = 0.95f, + Chunk = new ChatInteractionDocumentChunk + { + Index = 0, + Text = "Carla and Mark race their go carts.", + }, + },]); + var documentStore = new Mock(); + documentStore.Setup(store => store.FindByIdAsync("doc-1")) + .Returns(new ValueTask(new AIDocument + { + ItemId = "doc-1", + FileName = "race.pdf", + })); + var chunkStore = new Mock(); + chunkStore.Setup(store => store.GetChunksByAIDocumentIdAsync("doc-1")) + .ReturnsAsync((IReadOnlyCollection)[ + new AIDocumentChunk + { + AIDocumentId = "doc-1", + Index = 0, + Content = "Carla and Mark race their go carts.", + }, + new AIDocumentChunk + { + AIDocumentId = "doc-1", + Index = 1, + Content = "Carla wins the race by one lap.", + }, + ]); + var services = new ServiceCollection() + .AddSingleton(new FakeAIClientFactory(new FakeEmbeddingGenerator([0.1f, 0.2f]))) + .AddSingleton(deploymentManager.Object) + .AddSingleton(indexProfileStore.Object) + .AddSingleton(documentStore.Object) + .AddSingleton(chunkStore.Object) + .AddSingleton() + .AddSingleton>(Options.Create(new InteractionDocumentOptions + { + IndexProfileName = "docs-index", + TopN = 2, + RetrievalMode = DocumentRetrievalMode.Hierarchical, + })) + .AddLogging() + .AddKeyedSingleton("test-provider", vectorSearchService.Object) + .AddCoreAIDocumentProcessing() + .BuildServiceProvider(); + var handler = services.GetServices().Single(); + var profile = new AIProfile + { + ItemId = "profile-1" + }; + profile.Put(new DocumentsMetadata + { + DocumentTopN = 2, + RetrievalMode = DocumentRetrievalMode.Hierarchical, + }); + var context = new OrchestrationContext + { + CompletionContext = new AICompletionContext(), + Documents = [new ChatDocumentInfo + { + DocumentId = "doc-1", + FileName = "race.pdf", + }, ], + }; + + await handler.HandleAsync(new PreemptiveRagContext(context, profile, ["tell me about car race story"])); + + documentStore.Verify(store => store.FindByIdAsync("doc-1"), Times.Once); + chunkStore.Verify(store => store.GetChunksByAIDocumentIdAsync("doc-1"), Times.Once); + + var systemMessage = context.SystemMessageBuilder.ToString(); + Assert.Contains("Carla and Mark race their go carts.", systemMessage); + Assert.Contains("Carla wins the race by one lap.", systemMessage); + } + private sealed class FakeTemplateService : ITemplateService { public Task> ListAsync() => Task.FromResult>([]); diff --git a/tests/CrestApps.Core.Tests/Core/Documents/DocumentFileStoreRegistrationTests.cs b/tests/CrestApps.Core.Tests/Core/Documents/DocumentFileStoreRegistrationTests.cs index 1d05b076..b76f87dc 100644 --- a/tests/CrestApps.Core.Tests/Core/Documents/DocumentFileStoreRegistrationTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Documents/DocumentFileStoreRegistrationTests.cs @@ -1,5 +1,5 @@ -using CrestApps.Core.AI.Documents; using System.Text; +using CrestApps.Core.AI.Documents; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; diff --git a/tests/CrestApps.Core.Tests/EntityCoreStoreTests.cs b/tests/CrestApps.Core.Tests/EntityCoreStoreTests.cs index 2cedad8d..ce171303 100644 --- a/tests/CrestApps.Core.Tests/EntityCoreStoreTests.cs +++ b/tests/CrestApps.Core.Tests/EntityCoreStoreTests.cs @@ -19,9 +19,12 @@ public sealed class EntityCoreStoreTests [Fact] public async Task Generic_named_source_catalog_supports_round_trip_queries() { + var cancellationToken = TestContext.Current.CancellationToken; + await using var harness = await EntityCoreTestHarness.CreateAsync(); using var scope = harness.Services.CreateScope(); var catalog = scope.ServiceProvider.GetRequiredService>(); + var committer = scope.ServiceProvider.GetRequiredService(); var profile = new AIProfile { @@ -32,6 +35,7 @@ public async Task Generic_named_source_catalog_supports_round_trip_queries() }; await catalog.CreateAsync(profile); + await committer.CommitAsync(cancellationToken); var byId = await catalog.FindByIdAsync(profile.ItemId); var byName = await catalog.FindByNameAsync(profile.Name); @@ -54,6 +58,8 @@ public async Task Generic_named_source_catalog_supports_round_trip_queries() [Fact] public async Task Entity_core_stores_support_specialized_queries() { + var cancellationToken = TestContext.Current.CancellationToken; + await using var harness = await EntityCoreTestHarness.CreateAsync(); using var scope = harness.Services.CreateScope(); var services = scope.ServiceProvider; @@ -68,6 +74,7 @@ public async Task Entity_core_stores_support_specialized_queries() var indexProfileStore = services.GetRequiredService(); var profileCatalog = services.GetRequiredService>(); var sessionManager = services.GetRequiredService(); + var committer = services.GetRequiredService(); var deployment = new AIDeployment { @@ -77,6 +84,7 @@ public async Task Entity_core_stores_support_specialized_queries() }; await deploymentStore.CreateAsync(deployment); + await committer.CommitAsync(cancellationToken); Assert.Equal(deployment.ItemId, (await deploymentStore.GetAsync(deployment.Name, deployment.Source))?.ItemId); var dataSource = new AIDataSource @@ -86,6 +94,7 @@ public async Task Entity_core_stores_support_specialized_queries() }; await dataSourceStore.CreateAsync(dataSource); + await committer.CommitAsync(cancellationToken); Assert.Single(await dataSourceStore.GetAllAsync()); var memory = new AIMemoryEntry @@ -98,6 +107,7 @@ public async Task Entity_core_stores_support_specialized_queries() }; await memoryStore.CreateAsync(memory); + await committer.CommitAsync(cancellationToken); Assert.Equal(1, await memoryStore.CountByUserAsync("user-1")); Assert.Equal(memory.ItemId, (await memoryStore.FindByUserAndNameAsync("user-1", "favorite-language"))?.ItemId); Assert.Single(await memoryStore.GetByUserAsync("user-1")); @@ -111,6 +121,7 @@ public async Task Entity_core_stores_support_specialized_queries() }; await documentStore.CreateAsync(document); + await committer.CommitAsync(cancellationToken); Assert.Single(await documentStore.GetDocumentsAsync("profile-1", "profile")); var chunk = new AIDocumentChunk @@ -124,9 +135,11 @@ public async Task Entity_core_stores_support_specialized_queries() }; await chunkStore.CreateAsync(chunk); + await committer.CommitAsync(cancellationToken); Assert.Single(await chunkStore.GetChunksByAIDocumentIdAsync(document.ItemId)); Assert.Single(await chunkStore.GetChunksByReferenceAsync("profile-1", "profile")); await chunkStore.DeleteByDocumentIdAsync(document.ItemId); + await committer.CommitAsync(cancellationToken); Assert.Empty(await chunkStore.GetChunksByAIDocumentIdAsync(document.ItemId)); var sessionPrompt = new AIChatSessionPrompt @@ -138,9 +151,11 @@ public async Task Entity_core_stores_support_specialized_queries() }; await sessionPromptStore.CreateAsync(sessionPrompt); + await committer.CommitAsync(cancellationToken); Assert.Equal(1, await sessionPromptStore.CountAsync("session-1")); Assert.Single(await sessionPromptStore.GetPromptsAsync("session-1")); Assert.Equal(1, await sessionPromptStore.DeleteAllPromptsAsync("session-1")); + await committer.CommitAsync(cancellationToken); var interactionPrompt = new ChatInteractionPrompt { @@ -151,8 +166,10 @@ public async Task Entity_core_stores_support_specialized_queries() }; await interactionPromptStore.CreateAsync(interactionPrompt); + await committer.CommitAsync(cancellationToken); Assert.Single(await interactionPromptStore.GetPromptsAsync("interaction-1")); Assert.Equal(1, await interactionPromptStore.DeleteAllPromptsAsync("interaction-1")); + await committer.CommitAsync(cancellationToken); var indexProfile = new SearchIndexProfile { @@ -163,6 +180,7 @@ public async Task Entity_core_stores_support_specialized_queries() }; await indexProfileStore.CreateAsync(indexProfile); + await committer.CommitAsync(cancellationToken); Assert.Equal(indexProfile.ItemId, (await indexProfileStore.FindByNameAsync("docs-index"))?.ItemId); Assert.Single(await indexProfileStore.GetByTypeAsync("AIDocuments")); @@ -175,11 +193,13 @@ public async Task Entity_core_stores_support_specialized_queries() }; await profileCatalog.CreateAsync(profile); + await committer.CommitAsync(cancellationToken); var session = await sessionManager.NewAsync(profile, new NewAIChatSessionContext()); session.Title = "Welcome"; await sessionManager.SaveAsync(session); + await committer.CommitAsync(cancellationToken); var pagedSessions = await sessionManager.PageAsync(1, 10, new AIChatSessionQueryContext { @@ -189,9 +209,39 @@ public async Task Entity_core_stores_support_specialized_queries() Assert.Equal(session.SessionId, (await sessionManager.FindByIdAsync(session.SessionId))?.SessionId); Assert.Single(pagedSessions.Sessions); Assert.Equal(1, await sessionManager.DeleteAllAsync(profile.ItemId)); + await committer.CommitAsync(cancellationToken); Assert.Null(await sessionManager.FindAsync(session.SessionId)); } + [Fact] + public async Task Entity_core_store_committer_flushes_staged_changes() + { + var cancellationToken = TestContext.Current.CancellationToken; + + await using var harness = await EntityCoreTestHarness.CreateAsync(); + using var scope = harness.Services.CreateScope(); + var services = scope.ServiceProvider; + var catalog = services.GetRequiredService>(); + var committer = services.GetRequiredService(); + var dbContext = services.GetRequiredService(); + + var profile = new AIProfile + { + Name = "staged-profile", + Source = "OpenAI", + DisplayText = "Staged profile", + CreatedUtc = DateTime.UtcNow, + }; + + await catalog.CreateAsync(profile); + + Assert.True(dbContext.ChangeTracker.HasChanges()); + + await committer.CommitAsync(cancellationToken); + + Assert.NotNull(await catalog.FindByNameAsync(profile.Name)); + } + private sealed class EntityCoreTestHarness : IAsyncDisposable { private readonly string _databasePath; diff --git a/tests/CrestApps.Core.Tests/Framework/Chat.Claude/MvcClaudeSettingsExtensionsTests.cs b/tests/CrestApps.Core.Tests/Framework/Chat.Claude/MvcClaudeSettingsExtensionsTests.cs index 6dc7932b..a652c464 100644 --- a/tests/CrestApps.Core.Tests/Framework/Chat.Claude/MvcClaudeSettingsExtensionsTests.cs +++ b/tests/CrestApps.Core.Tests/Framework/Chat.Claude/MvcClaudeSettingsExtensionsTests.cs @@ -1,5 +1,4 @@ using CrestApps.Core.AI.Claude.Models; -using CrestApps.Core.Mvc.Web.Areas.AIChat.Models; using CrestApps.Core.Mvc.Web.Areas.AIChat.Services; namespace CrestApps.Core.Tests.Framework.Chat.Claude; diff --git a/tests/CrestApps.Core.Tests/Framework/Chat.Copilot/MvcAITemplateViewModelCopilotTests.cs b/tests/CrestApps.Core.Tests/Framework/Chat.Copilot/MvcAITemplateViewModelCopilotTests.cs index 0b61e5a0..2f789f05 100644 --- a/tests/CrestApps.Core.Tests/Framework/Chat.Copilot/MvcAITemplateViewModelCopilotTests.cs +++ b/tests/CrestApps.Core.Tests/Framework/Chat.Copilot/MvcAITemplateViewModelCopilotTests.cs @@ -3,7 +3,6 @@ using CrestApps.Core.AI.Copilot.Services; using CrestApps.Core.AI.Models; using CrestApps.Core.Mvc.Web.Areas.AI.ViewModels; -using CrestApps.Core.Mvc.Web.Models; namespace CrestApps.Core.Tests.Framework.Chat.Copilot; diff --git a/tests/CrestApps.Core.Tests/Framework/Chat.Copilot/MvcCopilotSettingsExtensionsTests.cs b/tests/CrestApps.Core.Tests/Framework/Chat.Copilot/MvcCopilotSettingsExtensionsTests.cs index 2924148b..5c76f85a 100644 --- a/tests/CrestApps.Core.Tests/Framework/Chat.Copilot/MvcCopilotSettingsExtensionsTests.cs +++ b/tests/CrestApps.Core.Tests/Framework/Chat.Copilot/MvcCopilotSettingsExtensionsTests.cs @@ -1,5 +1,4 @@ using CrestApps.Core.AI.Copilot.Models; -using CrestApps.Core.Mvc.Web.Areas.AIChat.Models; using CrestApps.Core.Mvc.Web.Areas.AIChat.Services; namespace CrestApps.Core.Tests.Framework.Chat.Copilot;