From c39f3ee7379589cca5eb61747203381501161333 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Thu, 9 Apr 2026 09:23:53 -0700 Subject: [PATCH 1/4] Improve deployments and connections configurations --- .github/workflows/preview_ci.yml | 41 +- .../Clients/IAIClientFactory.cs | 10 +- .../Clients/IAIClientProvider.cs | 6 +- .../Deployments/IAIDeploymentStore.cs | 12 - .../Json/AIProviderConnectionConverter.cs | 22 - .../Json/AIProviderConnectionJsonConverter.cs | 30 +- .../Models/AIDeployment.cs | 26 +- .../Models/AIDeploymentCatalogOptions.cs | 10 + .../Models/AIDeploymentTypeExtensions.cs | 1 + .../Models/AIProviderConnection.cs | 50 --- .../AIProviderConnectionCatalogOptions.cs | 15 + .../Models/AIProviderOptions.cs | 12 - .../Models/ValidationResultDetails.cs | 1 + .../docs/changelog/v1.0.0.md | 2 + .../docs/core/getting-started-aspnet.md | 7 +- .../docs/core/mvc-example.md | 63 +-- .../A2AAICompletionContextBuilderHandler.cs | 1 + .../DefaultA2AAgentCardCacheService.cs | 1 + .../DefaultA2AConnectionAuthService.cs | 1 + .../AzureAIInferenceClientProvider.cs | 1 + .../AzureAIInferenceCompletionClient.cs | 1 + ...eractionCompletionContextBuilderHandler.cs | 1 + ...ataSourceChatInteractionSettingsHandler.cs | 1 + ...tTemplateChatInteractionSettingsHandler.cs | 3 +- .../Hubs/AIChatHubCore.cs | 30 +- .../Services/CopilotOrchestrator.cs | 2 +- .../ServiceCollectionExtensions.cs | 1 + .../ServiceCollectionExtensions.cs | 2 +- .../Services/RagTextNormalizer.cs | 2 +- .../McpAICompletionContextBuilderHandler.cs | 1 + .../Models/McpClientAIOptions.cs | 1 + .../Models/McpOptions.cs | 1 + .../DefaultMcpServerResourceService.cs | 1 + .../Services/DefaultOAuth2TokenService.cs | 1 + .../Services/SseClientTransportProvider.cs | 1 + .../Services/StdioClientTransportProvider.cs | 1 + .../Services/OllamaAIClientProvider.cs | 1 + .../Services/OllamaCompletionClient.cs | 1 + .../Services/AzureOpenAIClientProvider.cs | 4 +- .../Services/AzureOpenAICompletionClient.cs | 12 +- .../Services/AzureSpeechClientProvider.cs | 6 +- .../AzureSpeechServiceSpeechToTextClient.cs | 10 +- .../Services/OpenAIClientProvider.cs | 1 + .../Services/OpenAICompletionClient.cs | 1 + .../ServiceCollectionExtensions.cs | 1 + src/Primitives/CrestApps.Core.AI/AIOptions.cs | 1 + .../AIFunctionArgumentsExtensions.cs | 1 + .../Handlers/AICompletionHandlerBase.cs | 1 + .../AIMemoryOrchestrationContextHelper.cs | 1 + .../Handlers/AIMemoryOrchestrationHandler.cs | 1 + ...AgentOrchestrationContextBuilderHandler.cs | 1 + ...SourceAICompletionContextBuilderHandler.cs | 1 + .../DataSourceOrchestrationHandler.cs | 1 + .../AIDocumentSearchIndexProfileHandler.cs | 3 +- .../AIMemorySearchIndexProfileHandler.cs | 3 +- .../DataSourceSearchIndexProfileHandler.cs | 3 +- .../EmbeddingSearchIndexProfileHandlerBase.cs | 1 + .../Indexing/IndexProfileHandlerBase.cs | 1 + .../Indexing/SearchIndexProfileManager.cs | 1 + .../Models/AIDataSourceOptions.cs | 4 +- .../Models/AIMemoryOptions.cs | 1 + .../Models/ChatDocumentsOptionsExtensions.cs | 1 + .../Models/ChatInteractionMemoryOptions.cs | 1 + .../Models/ExtractorExtension.cs | 3 +- .../Models/GeneralAIOptions.cs | 1 + .../Models/InteractionDocumentOptions.cs | 1 + .../Models/MemoryMetadataExtensions.cs | 1 + .../ServiceCollectionExtensions.cs | 4 +- .../Services/AIClientProviderBase.cs | 1 + .../Services/AICompletionServiceBase.cs | 6 +- .../AICompletionUsageRecordFactory.cs | 1 + .../Services/AIConfigurationRecordIds.cs | 1 + .../AIProviderConnectionEntryFactory.cs | 48 +++ .../AIProviderOptionsConnectionMerger.cs | 21 +- .../ConfigurationAIDeploymentCatalog.cs | 223 ++++------ ...onfigurationAIProviderConnectionCatalog.cs | 393 ++++++++++++++++++ ...ProviderConnectionsOptionsConfiguration.cs | 9 +- .../Services/DefaultAIClientFactory.cs | 96 ++--- .../DefaultAIDataSourceIndexingService.cs | 3 +- .../Services/DefaultAIDeploymentManager.cs | 9 +- .../Services/DefaultSpeechVoiceResolver.cs | 18 +- .../ServiceCollectionExtensions.cs | 1 + .../Services/AzureAISearchDocumentManager.cs | 2 +- .../Services/AzureAISearchIndexManager.cs | 6 +- .../ServiceCollectionExtensions.cs | 3 +- .../DictionaryExtensions.cs | 1 + .../ServiceCollectionExtensions.cs | 2 +- .../Handlers/CatalogEntryHandlerBase.cs | 1 + .../ServiceCollectionExtensions.cs | 10 + .../Controllers/A2AConnectionController.cs | 1 + .../AI/Controllers/AIConnectionController.cs | 126 +----- .../AI/Controllers/AIDeploymentController.cs | 62 +-- .../AI/Controllers/AIProfileController.cs | 1 + .../AI/Controllers/AITemplateController.cs | 1 + .../AIProviderOptionsBuilderExtensions.cs | 17 + .../MvcAIProviderOptionsConfiguration.cs | 51 --- .../AI/Services/MvcAIProviderOptionsStore.cs | 91 ---- .../AI/Services/YesSqlAIDeploymentStore.cs | 14 - .../AI/ViewModels/AIConnectionViewModel.cs | 1 + .../AI/ViewModels/AIDeploymentViewModel.cs | 1 - .../ChatExtractedDataController.cs | 3 +- .../Controllers/UsageAnalyticsController.cs | 1 + .../MvcAIChatSessionEventPostCloseObserver.cs | 1 + .../Services/MvcAIChatSessionEventService.cs | 1 + .../MvcAIChatSessionExtractedDataService.cs | 1 + .../Admin/Controllers/SettingsController.cs | 4 +- .../Handlers/ArticleIndexProfileHandler.cs | 1 + .../Hubs/ChatInteractionHub.cs | 9 +- .../Services/MvcAIDataSourceIndexingQueue.cs | 1 + .../Services/IndexProfileTypeRules.cs | 3 +- .../Controllers/McpConnectionController.cs | 1 + .../Mcp/Controllers/McpPromptController.cs | 5 +- .../Mcp/Controllers/McpResourceController.cs | 1 + src/Startup/CrestApps.Core.Mvc.Web/Program.cs | 12 - .../AppDataConfigurationFileService.cs | 4 +- .../YesSqlServiceCollectionExtensions.cs | 53 ++- .../Tools/CalculatorTool.cs | 2 +- .../Tools/SendEmailTool.cs | 1 + .../ServiceCollectionExtensions.cs | 75 +++- .../Services/CatalogRecordFactory.cs | 1 + .../Services/DocumentCatalog.cs | 3 +- .../EntityCoreAIChatSessionManager.cs | 2 +- .../Services/EntityCoreAIDeploymentStore.cs | 12 - .../EntityCoreSearchIndexProfileStore.cs | 1 + .../Services/EntityCoreStoreSerializer.cs | 1 + ...IDeploymentIndexSchemaBuilderExtensions.cs | 1 + .../AIProfileIndexSchemaBuilderExtensions.cs | 1 + ...ileTemplateIndexSchemaBuilderExtensions.cs | 1 + ...rConnectionIndexSchemaBuilderExtensions.cs | 1 + ...tractedDataIndexSchemaBuilderExtensions.cs | 1 + ...ChatSessionIndexSchemaBuilderExtensions.cs | 1 + ...sionMetricsIndexSchemaBuilderExtensions.cs | 1 + ...ssionPromptIndexSchemaBuilderExtensions.cs | 1 + ...letionUsageIndexSchemaBuilderExtensions.cs | 1 + ...MemoryEntryIndexSchemaBuilderExtensions.cs | 1 + ...InteractionIndexSchemaBuilderExtensions.cs | 1 + ...ctionPromptIndexSchemaBuilderExtensions.cs | 1 + ...IDataSourceIndexSchemaBuilderExtensions.cs | 1 + ...cumentChunkIndexSchemaBuilderExtensions.cs | 1 + .../AIDocumentIndexSchemaBuilderExtensions.cs | 1 + ...ndexProfileIndexSchemaBuilderExtensions.cs | 1 + .../ServiceCollectionExtensions.cs | 82 +++- ...rectionalJsonStringEnumConverterFactory.cs | 1 + .../StringExtensions.cs | 1 + .../Prompting/AITemplateBuilderTests.cs | 1 + .../Prompting/RenderAITemplateTagTests.cs | 1 + ...ndSessionNotificationActionHandlerTests.cs | 3 +- .../Chat/DocumentPreemptiveRagHandlerTests.cs | 3 +- .../ExtractedDataOrchestrationHandlerTests.cs | 1 + .../DataSourceOrchestrationHandlerTests.cs | 1 + .../DefaultOrchestratorResolverTests.cs | 1 + .../Orchestration/DefaultOrchestratorTests.cs | 1 + .../DocumentOrchestrationHandlerTests.cs | 3 +- .../PreemptiveRagOrchestrationHandlerTests.cs | 1 + .../PostSessionProcessingServiceTests.cs | 7 +- .../EntityCoreStoreTests.cs | 4 +- .../AI/DataExtractionServiceTests.cs | 3 +- .../Mvc/AIProviderConnectionOptionsTests.cs | 236 +++++++---- .../Framework/Mvc/ChatInteractionHubTests.cs | 1 + .../ConfigurationAIDeploymentCatalogTests.cs | 53 ++- .../Mvc/IndexProfileTypeRulesTests.cs | 1 + .../Mvc/MvcCitationReferenceCollectorTests.cs | 1 + ...rchIndexProfileProvisioningServiceTests.cs | 1 + .../OpenXmlIngestionDocumentReaderTests.cs | 31 +- .../PdfIngestionDocumentReaderTests.cs | 5 +- 165 files changed, 1411 insertions(+), 904 deletions(-) delete mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Deployments/IAIDeploymentStore.cs create mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeploymentCatalogOptions.cs create mode 100644 src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProviderConnectionCatalogOptions.cs create mode 100644 src/Primitives/CrestApps.Core.AI/Services/AIProviderConnectionEntryFactory.cs create mode 100644 src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIProviderConnectionCatalog.cs create mode 100644 src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/AIProviderOptionsBuilderExtensions.cs delete mode 100644 src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/MvcAIProviderOptionsConfiguration.cs delete mode 100644 src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/MvcAIProviderOptionsStore.cs delete mode 100644 src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/YesSqlAIDeploymentStore.cs delete mode 100644 src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIDeploymentStore.cs diff --git a/.github/workflows/preview_ci.yml b/.github/workflows/preview_ci.yml index 6cb951ae..a312bb8b 100644 --- a/.github/workflows/preview_ci.yml +++ b/.github/workflows/preview_ci.yml @@ -5,6 +5,7 @@ on: # 4:19 AM UTC every day. A random time to avoid peak times of GitHub Actions. - cron: '19 4 * * *' permissions: + actions: read contents: read packages: write env: @@ -19,10 +20,44 @@ jobs: - name: Check if should publish id: check-publish shell: pwsh + env: + GITHUB_TOKEN: ${{ github.token }} run: | - $hasCommitFromLastDay = ![string]::IsNullOrEmpty((git log --oneline --since '24 hours ago')) - Write-Output "Commits found in the last 24 hours: $hasCommitFromLastDay." - $shouldPublish = ($hasCommitFromLastDay -and '${{ github.event_name }}' -eq 'schedule') -or ('${{ github.event_name }}' -eq 'workflow_dispatch') + $eventName = '${{ github.event_name }}' + + if ($eventName -eq 'workflow_dispatch') + { + Write-Output 'Manual preview release requested. Publishing unconditionally.' + "should-publish=true" >> $Env:GITHUB_OUTPUT + exit 0 + } + + $headers = @{ + Authorization = "Bearer $Env:GITHUB_TOKEN" + Accept = 'application/vnd.github+json' + 'X-GitHub-Api-Version' = '2022-11-28' + } + + $workflowRunsUrl = 'https://api.github.com/repos/${{ github.repository }}/actions/workflows/preview_ci.yml/runs?status=success&branch=${{ github.ref_name }}&per_page=20' + $response = Invoke-RestMethod -Uri $workflowRunsUrl -Headers $headers -Method Get + $previousRun = $response.workflow_runs | + Where-Object { $_.id -ne [int64]'${{ github.run_id }}' } | + Sort-Object created_at -Descending | + Select-Object -First 1 + + if ($null -eq $previousRun) + { + Write-Output 'No previous successful preview publish run found. Publishing.' + "should-publish=true" >> $Env:GITHUB_OUTPUT + exit 0 + } + + $hasNewCommitSinceLastRelease = $previousRun.head_sha -ne '${{ github.sha }}' + Write-Output "Last successful preview publish SHA: $($previousRun.head_sha)" + Write-Output "Current SHA: ${{ github.sha }}" + Write-Output "New commits since last preview publish: $hasNewCommitSinceLastRelease" + + $shouldPublish = $eventName -eq 'schedule' -and $hasNewCommitSinceLastRelease "should-publish=$($shouldPublish ? 'true' : 'false')" >> $Env:GITHUB_OUTPUT - uses: actions/setup-node@v6 if: steps.check-publish.outputs.should-publish == 'true' diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Clients/IAIClientFactory.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Clients/IAIClientFactory.cs index e0a46ca1..72b45e97 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Clients/IAIClientFactory.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Clients/IAIClientFactory.cs @@ -37,7 +37,7 @@ public interface IAIClientFactory /// /// A representing the asynchronous operation, with the created . /// - + #pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. ValueTask CreateImageGeneratorAsync(string providerName, string connectionName, string deploymentName = null); @@ -52,7 +52,7 @@ public interface IAIClientFactory /// /// A representing the asynchronous operation, with the created . /// - + #pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. ValueTask CreateSpeechToTextClientAsync(string providerName, string connectionName, string deploymentName = null); @@ -66,7 +66,7 @@ public interface IAIClientFactory /// /// A representing the asynchronous operation, with the created . /// - + #pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. ValueTask CreateSpeechToTextClientAsync(AIDeployment deployment); @@ -81,7 +81,7 @@ public interface IAIClientFactory /// /// A representing the asynchronous operation, with the created . /// - + #pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. ValueTask CreateTextToSpeechClientAsync(string providerName, string connectionName, string deploymentName = null); @@ -95,7 +95,7 @@ public interface IAIClientFactory /// /// A representing the asynchronous operation, with the created . /// - + #pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. ValueTask CreateTextToSpeechClientAsync(AIDeployment deployment); diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Clients/IAIClientProvider.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Clients/IAIClientProvider.cs index 44f6de0c..4ed5ad73 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Clients/IAIClientProvider.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Clients/IAIClientProvider.cs @@ -33,7 +33,7 @@ public interface IAIClientProvider /// The connection entry containing provider configuration. /// The optional deployment name to use. /// A representing the asynchronous operation. - + #pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. ValueTask GetImageGeneratorAsync(AIProviderConnectionEntry connection, string deploymentName = null); @@ -45,7 +45,7 @@ public interface IAIClientProvider /// The connection entry containing provider configuration. /// The optional deployment name to use. /// A representing the asynchronous operation. - + #pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. ValueTask GetSpeechToTextClientAsync(AIProviderConnectionEntry connection, string deploymentName = null); @@ -57,7 +57,7 @@ public interface IAIClientProvider /// The connection entry containing provider configuration. /// The optional deployment name to use. /// A representing the asynchronous operation. - + #pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. ValueTask GetTextToSpeechClientAsync(AIProviderConnectionEntry connection, string deploymentName = null); diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Deployments/IAIDeploymentStore.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Deployments/IAIDeploymentStore.cs deleted file mode 100644 index 6d9d6c88..00000000 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Deployments/IAIDeploymentStore.cs +++ /dev/null @@ -1,12 +0,0 @@ -using CrestApps.Core.AI.Models; -using CrestApps.Core.Services; - -namespace CrestApps.Core.AI.Deployments; - -/// -/// Represents the host-specific persisted AI deployment catalog before any configuration-backed -/// deployments are merged into read operations. -/// -public interface IAIDeploymentStore : INamedSourceCatalog -{ -} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Json/AIProviderConnectionConverter.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Json/AIProviderConnectionConverter.cs index 2e49130e..83a93f94 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Json/AIProviderConnectionConverter.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Json/AIProviderConnectionConverter.cs @@ -6,18 +6,6 @@ namespace CrestApps.Core.AI.Json; public sealed class AIProviderConnectionConverter : JsonConverter { - /// - /// Maps legacy configuration key names to their current equivalents. - /// - private static readonly Dictionary _legacyKeyMappings = new(StringComparer.OrdinalIgnoreCase) - { - ["DefaultDeploymentName"] = "ChatDeploymentName", - ["DefaultChatDeploymentName"] = "ChatDeploymentName", - ["DefaultUtilityDeploymentName"] = "UtilityDeploymentName", - ["DefaultEmbeddingDeploymentName"] = "EmbeddingDeploymentName", - ["DefaultImagesDeploymentName"] = "ImagesDeploymentName", - }; - public override AIProviderConnectionEntry Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { // Deserialize into a dictionary first. @@ -28,16 +16,6 @@ public override AIProviderConnectionEntry Read(ref Utf8JsonReader reader, Type t return null; } - // Migrate legacy keys to current keys. - foreach (var (legacyKey, newKey) in _legacyKeyMappings) - { - if (dictionary.TryGetValue(legacyKey, out var value) && !dictionary.ContainsKey(newKey)) - { - dictionary[newKey] = value; - dictionary.Remove(legacyKey); - } - } - return new AIProviderConnectionEntry(dictionary); } diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Json/AIProviderConnectionJsonConverter.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Json/AIProviderConnectionJsonConverter.cs index e99f231e..e793d19d 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Json/AIProviderConnectionJsonConverter.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Json/AIProviderConnectionJsonConverter.cs @@ -24,27 +24,27 @@ public override AIProviderConnection Read(ref Utf8JsonReader reader, Type typeTo ?? GetString(node, "ProviderName"), Name = GetString(node, nameof(AIProviderConnection.Name)), DisplayText = GetString(node, nameof(AIProviderConnection.DisplayText)), -#pragma warning disable CS0618 // Obsolete deployment name fields retained for backward compatibility - ChatDeploymentName = GetString(node, nameof(AIProviderConnection.ChatDeploymentName)) - ?? GetString(node, "DefaultDeploymentName"), - EmbeddingDeploymentName = GetString(node, nameof(AIProviderConnection.EmbeddingDeploymentName)) - ?? GetString(node, "DefaultEmbeddingDeploymentName"), - ImagesDeploymentName = GetString(node, nameof(AIProviderConnection.ImagesDeploymentName)) - ?? GetString(node, "DefaultImagesDeploymentName"), - UtilityDeploymentName = GetString(node, nameof(AIProviderConnection.UtilityDeploymentName)) - ?? GetString(node, "DefaultUtilityDeploymentName"), -#pragma warning restore CS0618 CreatedUtc = GetDateTime(node, nameof(AIProviderConnection.CreatedUtc)), Author = GetString(node, nameof(AIProviderConnection.Author)), OwnerId = GetString(node, nameof(AIProviderConnection.OwnerId)), }; + var propertyValues = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (node.TryGetPropertyValue(nameof(AIProviderConnection.Properties), out var propertiesNode) - && propertiesNode is JsonObject properties) + && propertiesNode is JsonObject propertiesObject) { // Detach from parent before deserializing. node.Remove(nameof(AIProviderConnection.Properties)); - connection.Properties = properties.Deserialize>() ?? new Dictionary(); + foreach (var property in propertiesObject.Deserialize>() ?? new Dictionary()) + { + propertyValues[property.Key] = property.Value; + } + } + + if (propertyValues.Count > 0) + { + connection.Properties = propertyValues; } return connection; @@ -58,12 +58,6 @@ public override void Write(Utf8JsonWriter writer, AIProviderConnection value, Js WriteString(writer, nameof(AIProviderConnection.ClientName), value.ClientName); WriteString(writer, nameof(AIProviderConnection.Name), value.Name); WriteString(writer, nameof(AIProviderConnection.DisplayText), value.DisplayText); -#pragma warning disable CS0618 // Obsolete deployment name fields retained for backward compatibility - WriteString(writer, nameof(AIProviderConnection.ChatDeploymentName), value.ChatDeploymentName); - WriteString(writer, nameof(AIProviderConnection.EmbeddingDeploymentName), value.EmbeddingDeploymentName); - WriteString(writer, nameof(AIProviderConnection.ImagesDeploymentName), value.ImagesDeploymentName); - WriteString(writer, nameof(AIProviderConnection.UtilityDeploymentName), value.UtilityDeploymentName); -#pragma warning restore CS0618 writer.WriteString(nameof(AIProviderConnection.CreatedUtc), value.CreatedUtc); WriteString(writer, nameof(AIProviderConnection.Author), value.Author); WriteString(writer, nameof(AIProviderConnection.OwnerId), value.OwnerId); diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeployment.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeployment.cs index 3ff3354d..a2286366 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeployment.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeployment.cs @@ -3,6 +3,7 @@ using CrestApps.Core.Services; namespace CrestApps.Core.AI.Models; + public class AIDeployment : SourceCatalogEntry, INameAwareModel, ISourceAwareModel, ICloneable { private string _modelName; @@ -11,7 +12,11 @@ public class AIDeployment : SourceCatalogEntry, INameAwareModel, ISourceAwareMod /// This maps to a registered key in AIOptions.Clients. /// For connection-based deployments, this is typically derived from the connection's ClientName. /// - public string ClientName { get => Source; set => Source = value; } + public string ClientName + { + get => Source; + set => Source = value; + } [Obsolete("Use ClientName instead. Retained for backward compatibility.")] [JsonIgnore] @@ -28,21 +33,30 @@ public class AIDeployment : SourceCatalogEntry, INameAwareModel, ISourceAwareMod /// Gets or sets the provider-facing model or deployment name. /// Falls back to for backward compatibility with legacy records. /// - public string ModelName { get => string.IsNullOrWhiteSpace(_modelName) ? Name : _modelName; set => _modelName = value?.Trim(); } + public string ModelName + { + get => string.IsNullOrWhiteSpace(_modelName) + ? Name + : _modelName; + set => _modelName = value?.Trim(); + } + public string ConnectionName { get; set; } - public string ConnectionNameAlias { get; set; } + /// /// Gets or sets the capability types of this deployment (Chat, Utility, Embedding, Image, SpeechToText, TextToSpeech). /// A deployment can support one or more capabilities. /// public AIDeploymentType Type { get; set; } + /// /// Gets or sets whether this deployment is the default for its selected capability types /// within its connection. /// - public bool IsDefault { get; set; } public DateTime CreatedUtc { get; set; } + public string Author { get; set; } + public string OwnerId { get; set; } public bool SupportsType(AIDeploymentType type) @@ -59,13 +73,11 @@ public AIDeployment Clone() ModelName = _modelName, Source = Source, ConnectionName = ConnectionName, - ConnectionNameAlias = ConnectionNameAlias, Type = Type, - IsDefault = IsDefault, CreatedUtc = CreatedUtc, Author = Author, OwnerId = OwnerId, Properties = Properties.Clone(), }; } -} \ No newline at end of file +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeploymentCatalogOptions.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeploymentCatalogOptions.cs new file mode 100644 index 00000000..cb4c1159 --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeploymentCatalogOptions.cs @@ -0,0 +1,10 @@ +namespace CrestApps.Core.AI.Models; + +public sealed class AIDeploymentCatalogOptions +{ + public IList DeploymentSections { get; } = + [ + "CrestApps:AI:Deployments", + "CrestApps_AI:Deployments", + ]; +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeploymentTypeExtensions.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeploymentTypeExtensions.cs index 70c70dfd..e36062be 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeploymentTypeExtensions.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeploymentTypeExtensions.cs @@ -1,4 +1,5 @@ namespace CrestApps.Core.AI.Models; + public static class AIDeploymentTypeExtensions { private static readonly AIDeploymentType _allSupportedTypes = Enum.GetValues().Where(type => type != AIDeploymentType.None).Aggregate(AIDeploymentType.None, static (current, type) => current | type); diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProviderConnection.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProviderConnection.cs index 6c2e8b33..776dabbc 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProviderConnection.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProviderConnection.cs @@ -12,21 +12,6 @@ public sealed class AIProviderConnection : SourceCatalogEntry, INameAwareModel, public string DisplayText { get; set; } - [Obsolete("Use typed AIDeployment records instead. This property is retained for backward compatibility and migration.")] - public string ChatDeploymentName { get; set; } - - [Obsolete("Use typed AIDeployment records instead. This property is retained for backward compatibility and migration.")] - public string EmbeddingDeploymentName { get; set; } - - [Obsolete("Use typed AIDeployment records instead. This property is retained for backward compatibility and migration.")] - public string ImagesDeploymentName { get; set; } - - [Obsolete("Use typed AIDeployment records instead. This property is retained for backward compatibility and migration.")] - public string UtilityDeploymentName { get; set; } - - [Obsolete("Use typed AIDeployment records instead. This property is retained for backward compatibility and migration.")] - public string SpeechToTextDeploymentName { get; set; } - /// /// Gets or sets the technical name of the AI client implementation associated with this connection. /// This maps to a registered key in AIOptions.Clients. @@ -52,34 +37,6 @@ public string ProviderName public string OwnerId { get; set; } - public string GetLegacyChatDeploymentName() - { -#pragma warning disable CS0618 // Type or member is obsolete - return ChatDeploymentName; -#pragma warning restore CS0618 // Type or member is obsolete - } - - public string GetLegacyEmbeddingDeploymentName() - { -#pragma warning disable CS0618 // Type or member is obsolete - return EmbeddingDeploymentName; -#pragma warning restore CS0618 // Type or member is obsolete - } - - public string GetLegacyImageDeploymentName() - { -#pragma warning disable CS0618 // Type or member is obsolete - return ImagesDeploymentName; -#pragma warning restore CS0618 // Type or member is obsolete - } - - public string GetLegacyUtilityDeploymentName() - { -#pragma warning disable CS0618 // Type or member is obsolete - return UtilityDeploymentName; -#pragma warning restore CS0618 // Type or member is obsolete - } - public AIProviderConnection Clone() { return new AIProviderConnection @@ -88,13 +45,6 @@ public AIProviderConnection Clone() Source = Source, Name = Name, DisplayText = DisplayText, -#pragma warning disable CS0618 // Type or member is obsolete - ChatDeploymentName = ChatDeploymentName, - EmbeddingDeploymentName = EmbeddingDeploymentName, - ImagesDeploymentName = ImagesDeploymentName, - UtilityDeploymentName = UtilityDeploymentName, - SpeechToTextDeploymentName = SpeechToTextDeploymentName, -#pragma warning restore CS0618 // Type or member is obsolete CreatedUtc = CreatedUtc, Author = Author, OwnerId = OwnerId, diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProviderConnectionCatalogOptions.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProviderConnectionCatalogOptions.cs new file mode 100644 index 00000000..0ac37372 --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProviderConnectionCatalogOptions.cs @@ -0,0 +1,15 @@ +namespace CrestApps.Core.AI.Models; + +public sealed class AIProviderConnectionCatalogOptions +{ + public IList ConnectionSections { get; } = + [ + "CrestApps:AI:Connections", + ]; + + public IList ProviderSections { get; } = + [ + "CrestApps:Providers", + "CrestApps:AI:Providers", + ]; +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProviderOptions.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProviderOptions.cs index 1acb1180..f98748ea 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProviderOptions.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProviderOptions.cs @@ -11,18 +11,6 @@ public class AIProviderOptions public sealed class AIProvider { - [Obsolete("Use typed AIDeployment records with IsDefault instead. Retained for backward compatibility.")] - public string DefaultChatDeploymentName { get; set; } - - [Obsolete("Use typed AIDeployment records with IsDefault instead. Retained for backward compatibility.")] - public string DefaultEmbeddingDeploymentName { get; set; } - - [Obsolete("Use typed AIDeployment records with IsDefault instead. Retained for backward compatibility.")] - public string DefaultImagesDeploymentName { get; set; } - - [Obsolete("Use typed AIDeployment records with IsDefault instead. Retained for backward compatibility.")] - public string DefaultUtilityDeploymentName { get; set; } - public IDictionary Connections { get; set; } } diff --git a/src/Abstractions/CrestApps.Core.Abstractions/Models/ValidationResultDetails.cs b/src/Abstractions/CrestApps.Core.Abstractions/Models/ValidationResultDetails.cs index 25293ca4..5d21443a 100644 --- a/src/Abstractions/CrestApps.Core.Abstractions/Models/ValidationResultDetails.cs +++ b/src/Abstractions/CrestApps.Core.Abstractions/Models/ValidationResultDetails.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; namespace CrestApps.Core.Models; + public class ValidationResultDetails { private List _errors; 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 c50adbbd..42273936 100644 --- a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md +++ b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md @@ -17,3 +17,5 @@ description: Initial standalone release notes for the CrestApps.Core repository. - includes a reference MVC host and an Aspire host for local composition and testing - includes a dedicated `CrestApps.Core.Tests` project for framework validation - publishes a framework-focused documentation site at [core.crestapps.com](https://core.crestapps.com) +- merges appsettings-backed and UI-managed AI provider connections and deployments through runtime catalogs, so MVC selectors and AI resolution stay current without rebuilding options or restarting the app +- exposes merged AI connection and deployment views through `INamedSourceCatalog` registrations, adds generic `Add*DocumentCatalog()` helpers for custom catalog registration, keeps deterministic name conflict handling so UI-managed records override conflicting appsettings entries, and standardizes settings so connections and deployments are configured separately 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 759dfe7f..5e0231d7 100644 --- a/src/CrestApps.Core.Docs/docs/core/getting-started-aspnet.md +++ b/src/CrestApps.Core.Docs/docs/core/getting-started-aspnet.md @@ -67,8 +67,7 @@ At minimum, provide a connection and a deployment through configuration or your { "Name": "primary-openai", "ClientName": "OpenAI", - "ApiKey": "YOUR_API_KEY", - "DefaultDeploymentName": "gpt-4.1" + "ApiKey": "YOUR_API_KEY" } ], "Deployments": [ @@ -76,7 +75,7 @@ At minimum, provide a connection and a deployment through configuration or your "Name": "gpt-4.1", "ClientName": "OpenAI", "Type": "Chat", - "Model": "gpt-4.1", + "ModelName": "gpt-4.1", "IsDefault": true } ] @@ -85,7 +84,7 @@ At minimum, provide a connection and a deployment through configuration or your } ``` -The MVC sample demonstrates the full runtime options pattern, including configuration-backed connections, UI-managed overrides, and merged deployment catalogs. +The MVC sample demonstrates the full runtime options pattern, including configuration-backed connections, UI-managed overrides, and merged deployment catalogs. Keep connection credentials under `Connections` and define model/deployment choices under `Deployments`. ## 4. Pick the application model diff --git a/src/CrestApps.Core.Docs/docs/core/mvc-example.md b/src/CrestApps.Core.Docs/docs/core/mvc-example.md index 3bbc5f43..1c88159d 100644 --- a/src/CrestApps.Core.Docs/docs/core/mvc-example.md +++ b/src/CrestApps.Core.Docs/docs/core/mvc-example.md @@ -144,53 +144,66 @@ builder.Services.AddCrestAppsCore(crestApps => crestApps ``` -The MVC sample configures `AIProviderOptions` inside `AddAISuite(...)` from `CrestApps:AI:Providers`, then layers the UI-managed connection projection through `MvcAIProviderOptionsStore` so admin changes refresh the same options object without querying YesSql from the options pipeline. +The MVC sample still binds static provider metadata from `CrestApps:AI:Providers`, but mutable AI connections and deployments now come from first-class merged catalogs instead of rebuilding `AIProviderOptions` after admin edits. `AddCoreAIAzureOpenAI()` also registers the `AzureSpeech` deployment provider used by MVC speech-to-text and text-to-speech selectors, so standalone Azure AI Services deployments from `CrestApps:AI:Deployments` participate in the same merged deployment catalog as UI-managed deployments. -The shared AI options pipeline now also reads connection definitions from `CrestApps:AI:Connections`, merges them with any UI-managed MVC connections, and exposes the combined set everywhere the runtime resolves provider connections. Each configured connection must provide a `Name` plus `ClientName`, and the MVC AI Deployment editor now uses that merged options source too, so appsettings-defined connections appear alongside admin-created connections when creating or editing deployments. +The MVC runtime now reads connection definitions from `CrestApps:AI:Connections`, provider-grouped connections under `CrestApps:Providers:{ProviderName}:Connections:{ConnectionName}` or `CrestApps:AI:Providers:{ProviderName}:Connections:{ConnectionName}`, and UI-managed connection records from the store into one merged connection catalog. The deployment catalog layers together UI-managed typed deployments and standalone `CrestApps:AI:Deployments` entries. That means dropdowns, deployment resolution, and connection resolution all see the same unified set without an app restart. + +Both merged catalogs also expose configurable section lists through `AIProviderConnectionCatalogOptions` and `AIDeploymentCatalogOptions`, so a host can append additional configuration paths without replacing the MVC/UI store integration. By default, connection discovery reads `CrestApps:AI:Connections`, `CrestApps:Providers`, and `CrestApps:AI:Providers`, while deployment discovery reads `CrestApps:AI:Deployments`. ```json { "CrestApps": { "AI": { - "Connections": [ - { - "Name": "primary", - "ClientName": "OpenAI", - "ApiKey": "YOUR_API_KEY", - "DefaultDeploymentName": "gpt-4.1" - } - ] - } + "Connections": [ + { + "Name": "WinnerWare", + "ClientName": "AzureOpenAI", + "Endpoint": "https://winnerwareai.openai.azure.com/", + "AuthenticationType": "ApiKey", + "ApiKey": "YOUR_API_KEY", + "DisplayText": "WinnerWare Azure OpenAI" + } + ] + } } } ``` -Provider-grouped connection settings under `CrestApps:Providers:{ProviderName}:Connections:{ConnectionName}` still work too. The framework now keeps those provider-defined connections and the `CrestApps:AI:Connections` array in the same runtime options graph, then merges UI-managed MVC connections on top without duplicating host-specific merge code. +Provider-grouped connection settings under `CrestApps:Providers:{ProviderName}:Connections:{ConnectionName}` and `CrestApps:AI:Providers:{ProviderName}:Connections:{ConnectionName}` still work too. The merged connection catalog keeps those provider-defined records and the `CrestApps:AI:Connections` array visible alongside UI-managed MVC connections, and the MVC AI Deployment editor reads that catalog directly when it builds the connection dropdown. Connection settings only describe the provider connection itself; deployment names and types belong in `CrestApps:AI:Deployments` or in the UI deployment editor. ```json { "CrestApps": { "AI": { - "Deployments": [ - { - "ClientName": "AzureSpeech", - "Name": "whisper", - "Type": "SpeechToText", - "IsDefault": true, - "Endpoint": "https://eastus.stt.speech.microsoft.com", - "AuthenticationType": "ApiKey", - "ApiKey": "YOUR_API_KEY" - } - ] - } + "Deployments": [ + { + "ProviderName": "AzureSpeech", + "Name": "whisper", + "Type": "SpeechToText", + "IsDefault": true, + "Endpoint": "https://eastus.stt.speech.microsoft.com", + "AuthenticationType": "ApiKey", + "ApiKey": "YOUR_API_KEY" + }, + { + "ProviderName": "AzureSpeech", + "Name": "AzureTextToSpeech", + "Type": "TextToSpeech", + "IsDefault": true, + "Endpoint": "https://eastus.tts.speech.microsoft.com", + "AuthenticationType": "ApiKey", + "ApiKey": "YOUR_API_KEY" + } + ] + } } } ``` -When a connection or deployment comes from system configuration, the MVC admin keeps it visible in separate read-only cards below the user-defined records and blocks edit/delete actions. Only records created through the UI remain editable there. +When a connection or deployment comes from system configuration, the MVC admin keeps it visible in the same listing as user-defined records, marks it read-only, and blocks edit/delete actions. Only records created through the UI remain editable there. Names are also enforced across both sources: MVC rejects duplicate UI names, and if appsettings and the store define the same connection or deployment name, the UI/store record wins and the conflicting configuration record is skipped. ### Section 7 — Elasticsearch Services diff --git a/src/Primitives/CrestApps.Core.AI.A2A/Handlers/A2AAICompletionContextBuilderHandler.cs b/src/Primitives/CrestApps.Core.AI.A2A/Handlers/A2AAICompletionContextBuilderHandler.cs index aaaf04a3..ab4f6047 100644 --- a/src/Primitives/CrestApps.Core.AI.A2A/Handlers/A2AAICompletionContextBuilderHandler.cs +++ b/src/Primitives/CrestApps.Core.AI.A2A/Handlers/A2AAICompletionContextBuilderHandler.cs @@ -3,6 +3,7 @@ using CrestApps.Core.AI.Models; namespace CrestApps.Core.AI.A2A.Handlers; + internal sealed class A2AAICompletionContextBuilderHandler : IAICompletionContextBuilderHandler { public Task BuildingAsync(AICompletionContextBuildingContext context) diff --git a/src/Primitives/CrestApps.Core.AI.A2A/Services/DefaultA2AAgentCardCacheService.cs b/src/Primitives/CrestApps.Core.AI.A2A/Services/DefaultA2AAgentCardCacheService.cs index 8303a59b..58e5c382 100644 --- a/src/Primitives/CrestApps.Core.AI.A2A/Services/DefaultA2AAgentCardCacheService.cs +++ b/src/Primitives/CrestApps.Core.AI.A2A/Services/DefaultA2AAgentCardCacheService.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging; namespace CrestApps.Core.AI.A2A.Services; + internal sealed class DefaultA2AAgentCardCacheService : IA2AAgentCardCacheService { private static readonly TimeSpan _cacheDuration = TimeSpan.FromMinutes(15); diff --git a/src/Primitives/CrestApps.Core.AI.A2A/Services/DefaultA2AConnectionAuthService.cs b/src/Primitives/CrestApps.Core.AI.A2A/Services/DefaultA2AConnectionAuthService.cs index 4ce1db5d..e0f33fdf 100644 --- a/src/Primitives/CrestApps.Core.AI.A2A/Services/DefaultA2AConnectionAuthService.cs +++ b/src/Primitives/CrestApps.Core.AI.A2A/Services/DefaultA2AConnectionAuthService.cs @@ -10,6 +10,7 @@ using Microsoft.Extensions.Logging; namespace CrestApps.Core.AI.A2A.Services; + internal sealed class DefaultA2AConnectionAuthService : IA2AConnectionAuthService { private const int ExpirationBufferSeconds = 60; diff --git a/src/Primitives/CrestApps.Core.AI.AzureAIInference/Services/AzureAIInferenceClientProvider.cs b/src/Primitives/CrestApps.Core.AI.AzureAIInference/Services/AzureAIInferenceClientProvider.cs index b2b68007..41627891 100644 --- a/src/Primitives/CrestApps.Core.AI.AzureAIInference/Services/AzureAIInferenceClientProvider.cs +++ b/src/Primitives/CrestApps.Core.AI.AzureAIInference/Services/AzureAIInferenceClientProvider.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.AI; namespace CrestApps.Core.AI.AzureAIInference.Services; + public sealed class AzureAIInferenceClientProvider : AIClientProviderBase { public AzureAIInferenceClientProvider(IServiceProvider serviceProvider) : base(serviceProvider) diff --git a/src/Primitives/CrestApps.Core.AI.AzureAIInference/Services/AzureAIInferenceCompletionClient.cs b/src/Primitives/CrestApps.Core.AI.AzureAIInference/Services/AzureAIInferenceCompletionClient.cs index f4e3bd18..60ca255a 100644 --- a/src/Primitives/CrestApps.Core.AI.AzureAIInference/Services/AzureAIInferenceCompletionClient.cs +++ b/src/Primitives/CrestApps.Core.AI.AzureAIInference/Services/AzureAIInferenceCompletionClient.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.Options; namespace CrestApps.Core.AI.AzureAIInference.Services; + public sealed class AzureAIInferenceCompletionClient : NamedAICompletionClient { public AzureAIInferenceCompletionClient(IAIClientFactory aIClientFactory, ILoggerFactory loggerFactory, IDistributedCache distributedCache, IServiceProvider serviceProvider, IOptions providerOptions, IEnumerable handlers, IOptions defaultOptions, ITemplateService aiTemplateService, IAIDeploymentManager deploymentManager) : base(AzureAIInferenceConstants.ImplementationName, aIClientFactory, distributedCache, loggerFactory, serviceProvider, providerOptions.Value, defaultOptions.Value, handlers, aiTemplateService, deploymentManager) diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Handlers/ChatInteractionCompletionContextBuilderHandler.cs b/src/Primitives/CrestApps.Core.AI.Chat/Handlers/ChatInteractionCompletionContextBuilderHandler.cs index 1ff1da5e..57ede604 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Handlers/ChatInteractionCompletionContextBuilderHandler.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Handlers/ChatInteractionCompletionContextBuilderHandler.cs @@ -3,6 +3,7 @@ using CrestApps.Core.Templates.Services; namespace CrestApps.Core.AI.Chat.Handlers; + internal sealed class ChatInteractionCompletionContextBuilderHandler : IAICompletionContextBuilderHandler { private readonly ITemplateService _aiTemplateService; diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Handlers/DataSourceChatInteractionSettingsHandler.cs b/src/Primitives/CrestApps.Core.AI.Chat/Handlers/DataSourceChatInteractionSettingsHandler.cs index 4292286b..1ea7e34d 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Handlers/DataSourceChatInteractionSettingsHandler.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Handlers/DataSourceChatInteractionSettingsHandler.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging; namespace CrestApps.Core.AI.Chat.Handlers; + public sealed class DataSourceChatInteractionSettingsHandler : IChatInteractionSettingsHandler { private readonly IServiceProvider _serviceProvider; diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Handlers/PromptTemplateChatInteractionSettingsHandler.cs b/src/Primitives/CrestApps.Core.AI.Chat/Handlers/PromptTemplateChatInteractionSettingsHandler.cs index e5979197..6645553f 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Handlers/PromptTemplateChatInteractionSettingsHandler.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Handlers/PromptTemplateChatInteractionSettingsHandler.cs @@ -2,6 +2,7 @@ using CrestApps.Core.AI.Models; namespace CrestApps.Core.AI.Chat.Handlers; + public sealed class PromptTemplateChatInteractionSettingsHandler : IChatInteractionSettingsHandler { public Task UpdatingAsync(ChatInteraction interaction, JsonElement settings) @@ -22,7 +23,7 @@ private static List GetSelections(JsonElement sett { if (!settings.TryGetProperty("promptTemplates", out var promptTemplates) || promptTemplates.ValueKind != JsonValueKind.Array) { - return[]; + return []; } var selections = new List(); diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs b/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs index edcff42e..35cb0f9d 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs @@ -196,7 +196,7 @@ protected virtual Task OnMessageCompletedAsync(IServiceProvider services, ChatMe /// protected virtual void CollectStreamingReferences(IServiceProvider services, ChatResponseHandlerContext handlerContext, Dictionary references, HashSet contentItemIds) { - // No-op. OC overrides to use CitationReferenceCollector. + // No-op. OC overrides to use CitationReferenceCollector. } // ───────────────── Session title generation ───────────────── @@ -270,7 +270,7 @@ private static async Task GetAIGeneratedTitleAsync(IServiceProvider serv return null; } - var titleResponse = await completionService.CompleteAsync(chatDeployment, [new(ChatRole.User, userPrompt), ], context); + var titleResponse = await completionService.CompleteAsync(chatDeployment, [new(ChatRole.User, userPrompt),], context); return titleResponse.Messages.Count > 0 ? Truncate(titleResponse.Messages.First().Text, 255) : null; } @@ -508,7 +508,7 @@ await ExecuteInScopeAsync(async services => } catch { - // Best-effort error reporting. + // Best-effort error reporting. } } }); @@ -873,7 +873,7 @@ protected virtual async Task ProcessChatPromptAsync(ChannelWriter(); var handlerResolver = services.GetRequiredService(); var sessionHandlers = services.GetRequiredService>(); - var(chatSession, isNew) = await GetOrCreateSessionAsync(services, sessionId, profile, prompt); + var (chatSession, isNew) = await GetOrCreateSessionAsync(services, sessionId, profile, prompt); await Groups.AddToGroupAsync(Context.ConnectionId, GetSessionGroupName(chatSession.SessionId), cancellationToken); var utcNow = GetUtcNow(); if (chatSession.Status == ChatSessionStatus.Closed) @@ -1116,7 +1116,7 @@ protected virtual object CreateSessionPayload(AIChatSession chatSession, AIProfi /// /// Synthesizes the given text as speech and streams audio chunks to the caller. /// - + #pragma warning disable MEAI001 protected async Task StreamSpeechAsync(ITextToSpeechClient textToSpeechClient, string identifier, string text, string voiceName, CancellationToken cancellationToken) { @@ -1214,9 +1214,9 @@ private async Task RunConversationLoopAsync(AIProfile profile, string sessionId, } } } - catch (OperationCanceledException)when (errorCts.IsCancellationRequested) + catch (OperationCanceledException) when (errorCts.IsCancellationRequested) { - // Transcription error or connection aborted. + // Transcription error or connection aborted. } await pipe.Writer.CompleteAsync(); @@ -1272,7 +1272,7 @@ private async Task TranscribeConversationAsync(System.IO.Pipelines.PipeReader pi { effectiveSessionId = await currentResponseTask; } - catch (OperationCanceledException)when (!cancellationToken.IsCancellationRequested) + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { Logger.LogDebug("AI response was interrupted by new user speech."); } @@ -1310,9 +1310,9 @@ private async Task TranscribeConversationAsync(System.IO.Pipelines.PipeReader pi { effectiveSessionId = await currentResponseTask; } - catch (OperationCanceledException)when (!cancellationToken.IsCancellationRequested) + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { - // Interrupted. + // Interrupted. } currentResponseCts?.Dispose(); @@ -1328,9 +1328,9 @@ private async Task TranscribeConversationAsync(System.IO.Pipelines.PipeReader pi { await ProcessConversationPromptAsync(profile, effectiveSessionId, remainingText, textToSpeechClient, voiceName, services, cancellationToken); } - catch (OperationCanceledException)when (!cancellationToken.IsCancellationRequested) + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { - // Interrupted. + // Interrupted. } } } @@ -1419,7 +1419,7 @@ private async Task ProcessConversationPromptAsync(AIProfile profile, str } catch { - // Best-effort — the client may have disconnected. + // Best-effort — the client may have disconnected. } } } @@ -1450,9 +1450,9 @@ private async Task StreamTranscriptionAsync(ISpeechToTextClient speechToTextClie } } } - catch (OperationCanceledException)when (errorCts.IsCancellationRequested) + catch (OperationCanceledException) when (errorCts.IsCancellationRequested) { - // Transcription failed or connection aborted. + // Transcription failed or connection aborted. } await pipe.Writer.CompleteAsync(); diff --git a/src/Primitives/CrestApps.Core.AI.Copilot/Services/CopilotOrchestrator.cs b/src/Primitives/CrestApps.Core.AI.Copilot/Services/CopilotOrchestrator.cs index 3d35de7b..5e6dc1b1 100644 --- a/src/Primitives/CrestApps.Core.AI.Copilot/Services/CopilotOrchestrator.cs +++ b/src/Primitives/CrestApps.Core.AI.Copilot/Services/CopilotOrchestrator.cs @@ -153,7 +153,7 @@ public async IAsyncEnumerable ExecuteStreamingAsync(Orchestr _logger.LogError(ex, "CopilotOrchestrator: CLI process error. The Copilot CLI may have crashed or failed to start."); responseText = "The Copilot service encountered an error and could not process your request. Please try again."; } - catch (Exception ex)when (ex is not OperationCanceledException) + catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogError(ex, "CopilotOrchestrator: Unexpected error during Copilot session."); responseText = "An unexpected error occurred while communicating with Copilot. Please try again."; diff --git a/src/Primitives/CrestApps.Core.AI.Ftp/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.Ftp/ServiceCollectionExtensions.cs index b1ce43dd..ccb01ab8 100644 --- a/src/Primitives/CrestApps.Core.AI.Ftp/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI.Ftp/ServiceCollectionExtensions.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Localization; namespace CrestApps.Core.AI.Ftp; + public static class ServiceCollectionExtensions { public static IServiceCollection AddCoreAIFtpMcpResources(this IServiceCollection services, Action configure = null) diff --git a/src/Primitives/CrestApps.Core.AI.Markdown/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.Markdown/ServiceCollectionExtensions.cs index 4de7b78d..45e68a70 100644 --- a/src/Primitives/CrestApps.Core.AI.Markdown/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI.Markdown/ServiceCollectionExtensions.cs @@ -1,6 +1,6 @@ using CrestApps.Core.AI.Markdown.Services; -using CrestApps.Core.Builders; using CrestApps.Core.AI.Services; +using CrestApps.Core.Builders; using Microsoft.Extensions.DependencyInjection; namespace CrestApps.Core.AI.Markdown; diff --git a/src/Primitives/CrestApps.Core.AI.Markdown/Services/RagTextNormalizer.cs b/src/Primitives/CrestApps.Core.AI.Markdown/Services/RagTextNormalizer.cs index 0aae6765..bc176633 100644 --- a/src/Primitives/CrestApps.Core.AI.Markdown/Services/RagTextNormalizer.cs +++ b/src/Primitives/CrestApps.Core.AI.Markdown/Services/RagTextNormalizer.cs @@ -31,7 +31,7 @@ public static async Task> NormalizeAndChunkAsync(string text, Cance { if (string.IsNullOrWhiteSpace(text)) { - return[]; + return []; } var document = await ParseDocumentAsync(StripHtml(text), cancellationToken); diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Handlers/McpAICompletionContextBuilderHandler.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Handlers/McpAICompletionContextBuilderHandler.cs index 4d335eef..b8af7280 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Handlers/McpAICompletionContextBuilderHandler.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Handlers/McpAICompletionContextBuilderHandler.cs @@ -3,6 +3,7 @@ using CrestApps.Core.AI.Models; namespace CrestApps.Core.AI.Mcp.Handlers; + internal sealed class McpAICompletionContextBuilderHandler : IAICompletionContextBuilderHandler { public Task BuildingAsync(AICompletionContextBuildingContext context) diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Models/McpClientAIOptions.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Models/McpClientAIOptions.cs index d10d1b19..4d1e9a32 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Models/McpClientAIOptions.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Models/McpClientAIOptions.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Localization; namespace CrestApps.Core.AI.Mcp.Models; + public sealed class McpClientAIOptions { private readonly Dictionary _transportTypes = new(StringComparer.OrdinalIgnoreCase); diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Models/McpOptions.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Models/McpOptions.cs index dcb602f3..609e3e11 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Models/McpOptions.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Models/McpOptions.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Localization; namespace CrestApps.Core.AI.Mcp.Models; + public sealed class McpOptions { private readonly Dictionary _resourceTypes = new(StringComparer.OrdinalIgnoreCase); diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultMcpServerResourceService.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultMcpServerResourceService.cs index 676ef0e2..91519bd0 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultMcpServerResourceService.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultMcpServerResourceService.cs @@ -6,6 +6,7 @@ using ModelContextProtocol.Server; namespace CrestApps.Core.AI.Mcp.Services; + public sealed class DefaultMcpServerResourceService : IMcpServerResourceService { private readonly ISourceCatalog _catalog; diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultOAuth2TokenService.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultOAuth2TokenService.cs index 7f9fee07..d44af760 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultOAuth2TokenService.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Services/DefaultOAuth2TokenService.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Logging; namespace CrestApps.Core.AI.Mcp.Services; + public sealed class DefaultOAuth2TokenService : IOAuth2TokenService { private const int ExpirationBufferSeconds = 60; diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Services/SseClientTransportProvider.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Services/SseClientTransportProvider.cs index 29c2d2fe..32208561 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Services/SseClientTransportProvider.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Services/SseClientTransportProvider.cs @@ -5,6 +5,7 @@ using ModelContextProtocol.Client; namespace CrestApps.Core.AI.Mcp.Services; + public sealed class SseClientTransportProvider : IMcpClientTransportProvider { private readonly IDataProtectionProvider _dataProtectionProvider; diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Services/StdioClientTransportProvider.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Services/StdioClientTransportProvider.cs index b08d16cb..e1f4e777 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Services/StdioClientTransportProvider.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Services/StdioClientTransportProvider.cs @@ -2,6 +2,7 @@ using ModelContextProtocol.Client; namespace CrestApps.Core.AI.Mcp.Services; + public sealed class StdioClientTransportProvider : IMcpClientTransportProvider { public bool CanHandle(McpConnection connection) diff --git a/src/Primitives/CrestApps.Core.AI.Ollama/Services/OllamaAIClientProvider.cs b/src/Primitives/CrestApps.Core.AI.Ollama/Services/OllamaAIClientProvider.cs index ed18c419..45187c72 100644 --- a/src/Primitives/CrestApps.Core.AI.Ollama/Services/OllamaAIClientProvider.cs +++ b/src/Primitives/CrestApps.Core.AI.Ollama/Services/OllamaAIClientProvider.cs @@ -5,6 +5,7 @@ using OllamaSharp; namespace CrestApps.Core.AI.Ollama.Services; + public sealed class OllamaAIClientProvider : AIClientProviderBase { public OllamaAIClientProvider(IServiceProvider serviceProvider) : base(serviceProvider) diff --git a/src/Primitives/CrestApps.Core.AI.Ollama/Services/OllamaCompletionClient.cs b/src/Primitives/CrestApps.Core.AI.Ollama/Services/OllamaCompletionClient.cs index b302ed85..42e39684 100644 --- a/src/Primitives/CrestApps.Core.AI.Ollama/Services/OllamaCompletionClient.cs +++ b/src/Primitives/CrestApps.Core.AI.Ollama/Services/OllamaCompletionClient.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.Options; namespace CrestApps.Core.AI.Ollama.Services; + public sealed class OllamaCompletionClient : NamedAICompletionClient { public OllamaCompletionClient(IAIClientFactory aIClientFactory, ILoggerFactory loggerFactory, IDistributedCache distributedCache, IServiceProvider serviceProvider, IOptions providerOptions, IEnumerable handlers, IOptions defaultOptions, ITemplateService aiTemplateService, IAIDeploymentManager deploymentManager) : base(OllamaConstants.ImplementationName, aIClientFactory, distributedCache, loggerFactory, serviceProvider, providerOptions.Value, defaultOptions.Value, handlers, aiTemplateService, deploymentManager) diff --git a/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureOpenAIClientProvider.cs b/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureOpenAIClientProvider.cs index d6a89c07..8752557d 100644 --- a/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureOpenAIClientProvider.cs +++ b/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureOpenAIClientProvider.cs @@ -11,6 +11,7 @@ using Microsoft.Extensions.Logging; namespace CrestApps.Core.AI.OpenAI.Azure.Services; + public sealed class AzureOpenAIClientProvider : AIClientProviderBase { private readonly ILoggerFactory _loggerFactory; @@ -69,7 +70,8 @@ private AzureOpenAIClient GetClient(AIProviderConnectionEntry connection, Uri en AzureAuthenticationType.ApiKey => new AzureOpenAIClient(endpoint, new ApiKeyCredential(connection.GetApiKey()), options), AzureAuthenticationType.ManagedIdentity => new AzureOpenAIClient(endpoint, new ManagedIdentityCredential(string.IsNullOrEmpty(identityId) ? ManagedIdentityId.SystemAssigned : ManagedIdentityId.FromUserAssignedClientId(identityId)), options), AzureAuthenticationType.Default => new AzureOpenAIClient(endpoint, new DefaultAzureCredential(), options), - _ => throw new NotSupportedException("The provided authentication type is not supported.")}; + _ => throw new NotSupportedException("The provided authentication type is not supported.") + }; return azureClient; } } \ No newline at end of file diff --git a/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureOpenAICompletionClient.cs b/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureOpenAICompletionClient.cs index 7a3d0b3d..023d63fc 100644 --- a/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureOpenAICompletionClient.cs +++ b/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureOpenAICompletionClient.cs @@ -20,6 +20,7 @@ using OpenAI.Chat; namespace CrestApps.Core.AI.OpenAI.Azure.Services; + public sealed class AzureOpenAICompletionClient : AICompletionServiceBase, IAICompletionClient { private readonly IServiceProvider _serviceProvider; @@ -56,7 +57,7 @@ public string Name var connectionName = context.ConnectionName; // Use the deployment resolver with fallback to legacy dictionary-based resolution. - var(deploymentName, resolvedConnectionName) = await ResolveDeploymentAsync(AIDeploymentType.Chat, provider, AzureOpenAIConstants.ClientName, connectionName, deploymentName: context.ChatDeploymentName); + var (deploymentName, resolvedConnectionName) = await ResolveDeploymentAsync(AIDeploymentType.Chat, provider, AzureOpenAIConstants.ClientName, connectionName, deploymentName: context.ChatDeploymentName); connectionName = resolvedConnectionName; if (string.IsNullOrEmpty(connectionName)) { @@ -170,7 +171,7 @@ public string Name var connectionName = context.ConnectionName; // Use the deployment resolver with fallback to legacy dictionary-based resolution. - var(deploymentName, resolvedConnectionName) = await ResolveDeploymentAsync(AIDeploymentType.Chat, provider, AzureOpenAIConstants.ClientName, connectionName, deploymentName: context.ChatDeploymentName); + var (deploymentName, resolvedConnectionName) = await ResolveDeploymentAsync(AIDeploymentType.Chat, provider, AzureOpenAIConstants.ClientName, connectionName, deploymentName: context.ChatDeploymentName); connectionName = resolvedConnectionName; if (string.IsNullOrEmpty(connectionName) || !provider.Connections.TryGetValue(connectionName, out var connection)) { @@ -403,7 +404,8 @@ private AzureOpenAIClient GetChatClient(AIProviderConnectionEntry connection) AzureAuthenticationType.ApiKey => new AzureOpenAIClient(endpoint, new ApiKeyCredential(connection.GetApiKey()), _clientOptions), AzureAuthenticationType.ManagedIdentity => new AzureOpenAIClient(endpoint, new ManagedIdentityCredential(ManagedIdentityId.SystemAssigned), _clientOptions), AzureAuthenticationType.Default => new AzureOpenAIClient(endpoint, new DefaultAzureCredential(), _clientOptions), - _ => throw new NotSupportedException("The specified authentication type is not supported.")}; + _ => throw new NotSupportedException("The specified authentication type is not supported.") + }; return azureClient; } @@ -456,7 +458,7 @@ private static ChatCompletionOptions GetOptions(AICompletionContext context, IEn { if (context.DisableTools) { - return[]; + return []; } // Use the same handler pipeline as NamedAICompletionClient to resolve tools. @@ -476,7 +478,7 @@ private static ChatCompletionOptions GetOptions(AICompletionContext context, IEn if (chatOptions.Tools is null || chatOptions.Tools.Count == 0) { - return[]; + return []; } return chatOptions.Tools.OfType().ToList(); diff --git a/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureSpeechClientProvider.cs b/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureSpeechClientProvider.cs index 3b01decd..6a0efa0c 100644 --- a/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureSpeechClientProvider.cs +++ b/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureSpeechClientProvider.cs @@ -46,7 +46,7 @@ public ValueTask GetImageGeneratorAsync(AIProviderConnectionEnt public ValueTask GetSpeechToTextClientAsync(AIProviderConnectionEntry connection, string deploymentName = null) { - var(endpoint, authType, apiKey, identityId) = ExtractConnectionParams(connection); + var (endpoint, authType, apiKey, identityId) = ExtractConnectionParams(connection); var logger = _loggerFactory.CreateLogger(); var client = new AzureSpeechServiceSpeechToTextClient(endpoint, authType, apiKey, identityId, _timeProvider, logger); return ValueTask.FromResult(client); @@ -57,7 +57,7 @@ public ValueTask GetSpeechToTextClientAsync(AIProviderConne public ValueTask GetTextToSpeechClientAsync(AIProviderConnectionEntry connection, string deploymentName = null) { - var(endpoint, authType, apiKey, identityId) = ExtractConnectionParams(connection); + var (endpoint, authType, apiKey, identityId) = ExtractConnectionParams(connection); var logger = _loggerFactory.CreateLogger(); var client = new AzureSpeechServiceTextToSpeechClient(endpoint, authType, apiKey, identityId, _timeProvider, logger); return ValueTask.FromResult(client); @@ -67,7 +67,7 @@ public ValueTask GetTextToSpeechClientAsync(AIProviderConne public async Task GetSpeechVoicesAsync(AIProviderConnectionEntry connection, string deploymentName = null) { - var(endpoint, authType, apiKey, identityId) = ExtractConnectionParams(connection); + var (endpoint, authType, apiKey, identityId) = ExtractConnectionParams(connection); var logger = _loggerFactory.CreateLogger(); using var client = new AzureSpeechServiceTextToSpeechClient(endpoint, authType, apiKey, identityId, _timeProvider, logger); return await client.GetVoicesAsync(); diff --git a/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureSpeechServiceSpeechToTextClient.cs b/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureSpeechServiceSpeechToTextClient.cs index 2c5b7b06..d1664dcf 100644 --- a/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureSpeechServiceSpeechToTextClient.cs +++ b/src/Primitives/CrestApps.Core.AI.OpenAI.Azure/Services/AzureSpeechServiceSpeechToTextClient.cs @@ -28,7 +28,7 @@ public sealed class AzureSpeechServiceSpeechToTextClient : ISpeechToTextClient #pragma warning restore MEAI001 { private const string CognitiveServicesScope = "https://cognitiveservices.azure.com/.default"; - private static readonly string[] _regionSuffixes = [".api.cognitive.microsoft.com", ".tts.speech.microsoft.com", ".stt.speech.microsoft.com", ]; + private static readonly string[] _regionSuffixes = [".api.cognitive.microsoft.com", ".tts.speech.microsoft.com", ".stt.speech.microsoft.com",]; private readonly Uri _endpoint; private readonly AzureAuthenticationType _authType; private readonly string _apiKey; @@ -254,7 +254,7 @@ public async IAsyncEnumerable GetStreamingTextAsync( } catch (OperationCanceledException) { - // Expected when the connection is closed or recognition is stopped. + // Expected when the connection is closed or recognition is stopped. } try @@ -303,7 +303,7 @@ private async Task PushAudioToStreamAsync(string traceId, Stopwatch sw, Stream a _logger.LogTrace("[STT:{TraceId}] +{Elapsed}ms PushAudioToStream DONE. Chunks={ChunkCount}, TotalBytes={TotalBytes}", traceId, sw.ElapsedMilliseconds, chunkCount, totalBytes); } } - catch (OperationCanceledException)when (cancellationToken.IsCancellationRequested) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { if (_logger.IsEnabled(LogLevel.Trace)) { @@ -479,7 +479,7 @@ private static string TryExtractRegion(Uri endpoint) /// when the value is missing or unrecognized, /// which lets the SDK auto-detect the container format. /// - + #pragma warning disable MEAI001 private AudioStreamContainerFormat ResolveContainerFormat(SpeechToTextOptions options) #pragma warning restore MEAI001 @@ -545,7 +545,7 @@ private static SpeechRecognizer CreateRecognizer(SpeechConfig speechConfig, Audi { return new SpeechRecognizer(speechConfig, audioConfig); } - catch (ApplicationException ex)when (IsGStreamerError(ex)) + catch (ApplicationException ex) when (IsGStreamerError(ex)) { throw new InvalidOperationException("Azure Speech SDK requires GStreamer to decode compressed audio. " + "Install GStreamer from https://gstreamer.freedesktop.org/download/ and ensure " + "the binaries are in the system PATH. " + "See: https://learn.microsoft.com/en-us/azure/ai-services/speech-service/how-to-use-codec-compressed-audio-input-streams", ex); } diff --git a/src/Primitives/CrestApps.Core.AI.OpenAI/Services/OpenAIClientProvider.cs b/src/Primitives/CrestApps.Core.AI.OpenAI/Services/OpenAIClientProvider.cs index 73b8a016..85e1427a 100644 --- a/src/Primitives/CrestApps.Core.AI.OpenAI/Services/OpenAIClientProvider.cs +++ b/src/Primitives/CrestApps.Core.AI.OpenAI/Services/OpenAIClientProvider.cs @@ -6,6 +6,7 @@ using OpenAI; namespace CrestApps.Core.AI.OpenAI.Services; + public sealed class OpenAIClientProvider : AIClientProviderBase { public OpenAIClientProvider(IServiceProvider serviceProvider) : base(serviceProvider) diff --git a/src/Primitives/CrestApps.Core.AI.OpenAI/Services/OpenAICompletionClient.cs b/src/Primitives/CrestApps.Core.AI.OpenAI/Services/OpenAICompletionClient.cs index b1d5bcb4..b8d75271 100644 --- a/src/Primitives/CrestApps.Core.AI.OpenAI/Services/OpenAICompletionClient.cs +++ b/src/Primitives/CrestApps.Core.AI.OpenAI/Services/OpenAICompletionClient.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.Options; namespace CrestApps.Core.AI.OpenAI.Services; + public sealed class OpenAICompletionClient : NamedAICompletionClient { public OpenAICompletionClient(IAIClientFactory aIClientFactory, ILoggerFactory loggerFactory, IDistributedCache distributedCache, IServiceProvider serviceProvider, IOptions providerOptions, IEnumerable handlers, IOptions defaultOptions, ITemplateService aiTemplateService, IAIDeploymentManager deploymentManager) : base(OpenAIConstants.ImplementationName, aIClientFactory, distributedCache, loggerFactory, serviceProvider, providerOptions.Value, defaultOptions.Value, handlers, aiTemplateService, deploymentManager) diff --git a/src/Primitives/CrestApps.Core.AI.Sftp/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.Sftp/ServiceCollectionExtensions.cs index 93b246ba..80ac44d3 100644 --- a/src/Primitives/CrestApps.Core.AI.Sftp/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI.Sftp/ServiceCollectionExtensions.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Localization; namespace CrestApps.Core.AI.Sftp; + public static class ServiceCollectionExtensions { public static IServiceCollection AddCoreAISftpMcpResources(this IServiceCollection services, Action configure = null) diff --git a/src/Primitives/CrestApps.Core.AI/AIOptions.cs b/src/Primitives/CrestApps.Core.AI/AIOptions.cs index 55a06d99..5da3535d 100644 --- a/src/Primitives/CrestApps.Core.AI/AIOptions.cs +++ b/src/Primitives/CrestApps.Core.AI/AIOptions.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Localization; namespace CrestApps.Core.AI; + public sealed class AIOptions { private readonly Dictionary _clients = new(StringComparer.OrdinalIgnoreCase); diff --git a/src/Primitives/CrestApps.Core.AI/Extensions/AIFunctionArgumentsExtensions.cs b/src/Primitives/CrestApps.Core.AI/Extensions/AIFunctionArgumentsExtensions.cs index 57cd4e82..b16f92d5 100644 --- a/src/Primitives/CrestApps.Core.AI/Extensions/AIFunctionArgumentsExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI/Extensions/AIFunctionArgumentsExtensions.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.AI; namespace CrestApps.Core.AI.Extensions; + public static class AIFunctionArgumentsExtensions { public static bool TryGetFirst(this AIFunctionArguments arguments, string key, out object value) diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AICompletionHandlerBase.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AICompletionHandlerBase.cs index 77c6b1f2..4866ad72 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/AICompletionHandlerBase.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/AICompletionHandlerBase.cs @@ -2,6 +2,7 @@ using CrestApps.Core.AI.Models; namespace CrestApps.Core.AI.Handlers; + public abstract class AICompletionHandlerBase : IAICompletionHandler { public virtual Task ReceivedMessageAsync(ReceivedMessageContext context) diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryOrchestrationContextHelper.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryOrchestrationContextHelper.cs index e309595e..531cfeca 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryOrchestrationContextHelper.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryOrchestrationContextHelper.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Options; namespace CrestApps.Core.AI.Handlers; + internal static class AIMemoryOrchestrationContextHelper { public static string GetAuthenticatedUserId(IHttpContextAccessor httpContextAccessor) diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryOrchestrationHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryOrchestrationHandler.cs index 584cc966..7fc5d5fc 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryOrchestrationHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryOrchestrationHandler.cs @@ -10,6 +10,7 @@ using Microsoft.Extensions.Options; namespace CrestApps.Core.AI.Handlers; + internal sealed class AIMemoryOrchestrationHandler : IOrchestrationContextBuilderHandler { private readonly ITemplateService _templateService; diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AgentOrchestrationContextBuilderHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AgentOrchestrationContextBuilderHandler.cs index 1e7055ec..17e3bf5b 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/AgentOrchestrationContextBuilderHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/AgentOrchestrationContextBuilderHandler.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging; namespace CrestApps.Core.AI.Handlers; + internal sealed class AgentOrchestrationContextBuilderHandler : IOrchestrationContextBuilderHandler { private readonly IAIProfileManager _profileManager; diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/DataSourceAICompletionContextBuilderHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/DataSourceAICompletionContextBuilderHandler.cs index 4c4baa0d..dec1f6c8 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/DataSourceAICompletionContextBuilderHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/DataSourceAICompletionContextBuilderHandler.cs @@ -3,6 +3,7 @@ using CrestApps.Core.AI.Orchestration; namespace CrestApps.Core.AI.Handlers; + internal sealed class DataSourceAICompletionContextBuilderHandler : IAICompletionContextBuilderHandler { public Task BuildingAsync(AICompletionContextBuildingContext context) diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/DataSourceOrchestrationHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/DataSourceOrchestrationHandler.cs index 60a6c7bb..52642d64 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/DataSourceOrchestrationHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/DataSourceOrchestrationHandler.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Options; namespace CrestApps.Core.AI.Handlers; + internal sealed class DataSourceOrchestrationHandler : IOrchestrationContextBuilderHandler { private readonly AIToolDefinitionOptions _toolDefinitions; diff --git a/src/Primitives/CrestApps.Core.AI/Indexing/AIDocumentSearchIndexProfileHandler.cs b/src/Primitives/CrestApps.Core.AI/Indexing/AIDocumentSearchIndexProfileHandler.cs index d82cd87a..fe154aa4 100644 --- a/src/Primitives/CrestApps.Core.AI/Indexing/AIDocumentSearchIndexProfileHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Indexing/AIDocumentSearchIndexProfileHandler.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Logging; namespace CrestApps.Core.AI.Indexing; + public sealed class AIDocumentSearchIndexProfileHandler : EmbeddingSearchIndexProfileHandlerBase { public AIDocumentSearchIndexProfileHandler(ICatalog deploymentCatalog, IAIClientFactory aiClientFactory, ILogger logger) : base(IndexProfileTypes.AIDocuments, deploymentCatalog, aiClientFactory, logger) @@ -15,7 +16,7 @@ public AIDocumentSearchIndexProfileHandler(ICatalog deploymentCata protected override IReadOnlyCollection BuildFields(int vectorDimensions) { - return[new SearchIndexField + return [new SearchIndexField { Name = DocumentIndexConstants.ColumnNames.ChunkId, FieldType = SearchFieldType.Keyword, diff --git a/src/Primitives/CrestApps.Core.AI/Indexing/AIMemorySearchIndexProfileHandler.cs b/src/Primitives/CrestApps.Core.AI/Indexing/AIMemorySearchIndexProfileHandler.cs index ff30e52d..e75582de 100644 --- a/src/Primitives/CrestApps.Core.AI/Indexing/AIMemorySearchIndexProfileHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Indexing/AIMemorySearchIndexProfileHandler.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging; namespace CrestApps.Core.AI.Indexing; + public sealed class AIMemorySearchIndexProfileHandler : EmbeddingSearchIndexProfileHandlerBase { private const string _memoryIdFieldName = "memoryId"; @@ -21,7 +22,7 @@ public AIMemorySearchIndexProfileHandler(ICatalog deploymentCatalo protected override IReadOnlyCollection BuildFields(int vectorDimensions) { - return[new SearchIndexField + return [new SearchIndexField { Name = _memoryIdFieldName, FieldType = SearchFieldType.Keyword, diff --git a/src/Primitives/CrestApps.Core.AI/Indexing/DataSourceSearchIndexProfileHandler.cs b/src/Primitives/CrestApps.Core.AI/Indexing/DataSourceSearchIndexProfileHandler.cs index debca9cb..17dba089 100644 --- a/src/Primitives/CrestApps.Core.AI/Indexing/DataSourceSearchIndexProfileHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Indexing/DataSourceSearchIndexProfileHandler.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Logging; namespace CrestApps.Core.AI.Indexing; + public sealed class DataSourceSearchIndexProfileHandler : EmbeddingSearchIndexProfileHandlerBase { public DataSourceSearchIndexProfileHandler(ICatalog deploymentCatalog, IAIClientFactory aiClientFactory, ILogger logger) : base(IndexProfileTypes.DataSource, deploymentCatalog, aiClientFactory, logger) @@ -15,7 +16,7 @@ public DataSourceSearchIndexProfileHandler(ICatalog deploymentCata protected override IReadOnlyCollection BuildFields(int vectorDimensions) { - return[new SearchIndexField + return [new SearchIndexField { Name = DataSourceConstants.ColumnNames.ChunkId, FieldType = SearchFieldType.Keyword, diff --git a/src/Primitives/CrestApps.Core.AI/Indexing/EmbeddingSearchIndexProfileHandlerBase.cs b/src/Primitives/CrestApps.Core.AI/Indexing/EmbeddingSearchIndexProfileHandlerBase.cs index 17d55ab6..7b4e7dad 100644 --- a/src/Primitives/CrestApps.Core.AI/Indexing/EmbeddingSearchIndexProfileHandlerBase.cs +++ b/src/Primitives/CrestApps.Core.AI/Indexing/EmbeddingSearchIndexProfileHandlerBase.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging; namespace CrestApps.Core.AI.Indexing; + public abstract class EmbeddingSearchIndexProfileHandlerBase : IndexProfileHandlerBase { private readonly string _type; diff --git a/src/Primitives/CrestApps.Core.AI/Indexing/IndexProfileHandlerBase.cs b/src/Primitives/CrestApps.Core.AI/Indexing/IndexProfileHandlerBase.cs index fae8473b..99d29644 100644 --- a/src/Primitives/CrestApps.Core.AI/Indexing/IndexProfileHandlerBase.cs +++ b/src/Primitives/CrestApps.Core.AI/Indexing/IndexProfileHandlerBase.cs @@ -4,6 +4,7 @@ using CrestApps.Core.Models; namespace CrestApps.Core.AI.Indexing; + public abstract class IndexProfileHandlerBase : CatalogEntryHandlerBase, IIndexProfileHandler { public virtual ValueTask ValidateAsync(SearchIndexProfile indexProfile, ValidationResultDetails result, CancellationToken cancellationToken = default) diff --git a/src/Primitives/CrestApps.Core.AI/Indexing/SearchIndexProfileManager.cs b/src/Primitives/CrestApps.Core.AI/Indexing/SearchIndexProfileManager.cs index 0a8bb490..bfcf4b12 100644 --- a/src/Primitives/CrestApps.Core.AI/Indexing/SearchIndexProfileManager.cs +++ b/src/Primitives/CrestApps.Core.AI/Indexing/SearchIndexProfileManager.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging; namespace CrestApps.Core.AI.Indexing; + public sealed class SearchIndexProfileManager : CatalogManager, ISearchIndexProfileManager { private readonly ISearchIndexProfileStore _store; diff --git a/src/Primitives/CrestApps.Core.AI/Models/AIDataSourceOptions.cs b/src/Primitives/CrestApps.Core.AI/Models/AIDataSourceOptions.cs index 770a3ee6..f489bf21 100644 --- a/src/Primitives/CrestApps.Core.AI/Models/AIDataSourceOptions.cs +++ b/src/Primitives/CrestApps.Core.AI/Models/AIDataSourceOptions.cs @@ -19,9 +19,9 @@ public AIDataSourceOptions Clone() DefaultStrictness = DefaultStrictness, DefaultTopNDocuments = DefaultTopNDocuments, }; - foreach (var(providerName, providerMappings)in _fieldMappings) + foreach (var (providerName, providerMappings) in _fieldMappings) { - foreach (var(indexProfileType, mapping)in providerMappings) + foreach (var (indexProfileType, mapping) in providerMappings) { clone.AddFieldMapping(providerName, indexProfileType, target => { diff --git a/src/Primitives/CrestApps.Core.AI/Models/AIMemoryOptions.cs b/src/Primitives/CrestApps.Core.AI/Models/AIMemoryOptions.cs index 3c586c1c..d81620b4 100644 --- a/src/Primitives/CrestApps.Core.AI/Models/AIMemoryOptions.cs +++ b/src/Primitives/CrestApps.Core.AI/Models/AIMemoryOptions.cs @@ -1,4 +1,5 @@ namespace CrestApps.Core.AI.Models; + public sealed class AIMemoryOptions { public string IndexProfileName { get; set; } diff --git a/src/Primitives/CrestApps.Core.AI/Models/ChatDocumentsOptionsExtensions.cs b/src/Primitives/CrestApps.Core.AI/Models/ChatDocumentsOptionsExtensions.cs index dcd4d92c..fa361321 100644 --- a/src/Primitives/CrestApps.Core.AI/Models/ChatDocumentsOptionsExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI/Models/ChatDocumentsOptionsExtensions.cs @@ -1,4 +1,5 @@ namespace CrestApps.Core.AI.Models; + public static class ChatDocumentsOptionsExtensions { public static string GetAllowedFileExtensionsAcceptValue(this ChatDocumentsOptions options) diff --git a/src/Primitives/CrestApps.Core.AI/Models/ChatInteractionMemoryOptions.cs b/src/Primitives/CrestApps.Core.AI/Models/ChatInteractionMemoryOptions.cs index b912dc34..a5c41287 100644 --- a/src/Primitives/CrestApps.Core.AI/Models/ChatInteractionMemoryOptions.cs +++ b/src/Primitives/CrestApps.Core.AI/Models/ChatInteractionMemoryOptions.cs @@ -1,4 +1,5 @@ namespace CrestApps.Core.AI.Models; + public sealed class ChatInteractionMemoryOptions { public bool EnableUserMemory { get; set; } = true; diff --git a/src/Primitives/CrestApps.Core.AI/Models/ExtractorExtension.cs b/src/Primitives/CrestApps.Core.AI/Models/ExtractorExtension.cs index 0462873e..8a25f2d4 100644 --- a/src/Primitives/CrestApps.Core.AI/Models/ExtractorExtension.cs +++ b/src/Primitives/CrestApps.Core.AI/Models/ExtractorExtension.cs @@ -1,4 +1,5 @@ namespace CrestApps.Core.AI.Models; + public sealed class ExtractorExtension : IEquatable, IEquatable { public string Extension { get; } @@ -53,7 +54,7 @@ public override int GetHashCode() return !Equals(left, right); } - public static implicit operator string (ExtractorExtension ext) + public static implicit operator string(ExtractorExtension ext) { return ext.Extension; } diff --git a/src/Primitives/CrestApps.Core.AI/Models/GeneralAIOptions.cs b/src/Primitives/CrestApps.Core.AI/Models/GeneralAIOptions.cs index 5b4d4252..ac92c7b1 100644 --- a/src/Primitives/CrestApps.Core.AI/Models/GeneralAIOptions.cs +++ b/src/Primitives/CrestApps.Core.AI/Models/GeneralAIOptions.cs @@ -1,4 +1,5 @@ namespace CrestApps.Core.AI.Models; + public sealed class GeneralAIOptions { public bool EnableAIUsageTracking { get; set; } diff --git a/src/Primitives/CrestApps.Core.AI/Models/InteractionDocumentOptions.cs b/src/Primitives/CrestApps.Core.AI/Models/InteractionDocumentOptions.cs index 93e1f7e3..e0154f8f 100644 --- a/src/Primitives/CrestApps.Core.AI/Models/InteractionDocumentOptions.cs +++ b/src/Primitives/CrestApps.Core.AI/Models/InteractionDocumentOptions.cs @@ -1,4 +1,5 @@ namespace CrestApps.Core.AI.Models; + public sealed class InteractionDocumentOptions { /// diff --git a/src/Primitives/CrestApps.Core.AI/Models/MemoryMetadataExtensions.cs b/src/Primitives/CrestApps.Core.AI/Models/MemoryMetadataExtensions.cs index bfd24999..5e129292 100644 --- a/src/Primitives/CrestApps.Core.AI/Models/MemoryMetadataExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI/Models/MemoryMetadataExtensions.cs @@ -2,6 +2,7 @@ using System.Text.Json.Nodes; namespace CrestApps.Core.AI.Models; + public static class MemoryMetadataExtensions { public const string LegacyAIProfileSettingsKey = "AIProfileMemorySettings"; diff --git a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs index b89a2dce..ee553e2f 100644 --- a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs @@ -128,6 +128,8 @@ public static IServiceCollection AddCoreAIServices(this IServiceCollection servi services .AddCoreAITemplating() .AddCoreServices() + .AddOptions().Services + .AddOptions().Services .AddScoped() .AddScoped(); @@ -250,6 +252,7 @@ public static IServiceCollection AddCoreAIDataSourceRag(this IServiceCollection public static IServiceCollection AddCoreAIMemory(this IServiceCollection services) { services.TryAddSingleton(TimeProvider.System); + services.AddCatalogManagers(); services.AddOptions(); services.AddOptions(); services.AddOptions(); @@ -257,7 +260,6 @@ public static IServiceCollection AddCoreAIMemory(this IServiceCollection service services.TryAddScoped(); services.TryAddScoped(); services.TryAdd(ServiceDescriptor.Scoped>(sp => sp.GetRequiredService())); - services.TryAddScoped, CatalogManager>(); services.TryAddEnumerable(ServiceDescriptor.Scoped()); services.TryAddEnumerable(ServiceDescriptor.Scoped()); diff --git a/src/Primitives/CrestApps.Core.AI/Services/AIClientProviderBase.cs b/src/Primitives/CrestApps.Core.AI/Services/AIClientProviderBase.cs index e0cff03b..f99b0b0b 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/AIClientProviderBase.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/AIClientProviderBase.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.AI; namespace CrestApps.Core.AI.Services; + public abstract class AIClientProviderBase : IAIClientProvider { private readonly IServiceProvider _serviceProvider; diff --git a/src/Primitives/CrestApps.Core.AI/Services/AICompletionServiceBase.cs b/src/Primitives/CrestApps.Core.AI/Services/AICompletionServiceBase.cs index e0d25dcc..80f1f360 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/AICompletionServiceBase.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/AICompletionServiceBase.cs @@ -42,13 +42,11 @@ protected virtual string GetDefaultDeploymentName(AIProvider provider, string co } } -#pragma warning disable CS0618 // Obsolete deployment name fields retained for backward compatibility - return provider.DefaultChatDeploymentName; -#pragma warning restore CS0618 + return null; } /// /// Resolves a deployment name and connection name using the - /// with fallback to the legacy dictionary-based resolution. + /// with fallback to legacy connection entry values when they are still present. /// protected virtual async ValueTask<(string DeploymentName, string ConnectionName)> ResolveDeploymentAsync( AIDeploymentType type, diff --git a/src/Primitives/CrestApps.Core.AI/Services/AICompletionUsageRecordFactory.cs b/src/Primitives/CrestApps.Core.AI/Services/AICompletionUsageRecordFactory.cs index d6c4e127..cbce8bb4 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/AICompletionUsageRecordFactory.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/AICompletionUsageRecordFactory.cs @@ -2,6 +2,7 @@ using CrestApps.Core.AI.Models; namespace CrestApps.Core.AI.Services; + public static class AICompletionUsageRecordFactory { public static AICompletionUsageRecord Create(AICompletionContext completionContext, string providerName, string clientName, string connectionName, string deploymentName, string modelName, string responseId, long inputTokenCount, long outputTokenCount, long totalTokenCount, double responseLatencyMs, bool isStreaming) diff --git a/src/Primitives/CrestApps.Core.AI/Services/AIConfigurationRecordIds.cs b/src/Primitives/CrestApps.Core.AI/Services/AIConfigurationRecordIds.cs index ceb60fda..d9d1c2bf 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/AIConfigurationRecordIds.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/AIConfigurationRecordIds.cs @@ -2,6 +2,7 @@ using System.Text; namespace CrestApps.Core.AI.Services; + public static class AIConfigurationRecordIds { private const string _connectionPrefix = "cfgc"; diff --git a/src/Primitives/CrestApps.Core.AI/Services/AIProviderConnectionEntryFactory.cs b/src/Primitives/CrestApps.Core.AI/Services/AIProviderConnectionEntryFactory.cs new file mode 100644 index 00000000..10f886db --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Services/AIProviderConnectionEntryFactory.cs @@ -0,0 +1,48 @@ +using System.Text.Json.Nodes; +using CrestApps.Core.AI.Models; + +namespace CrestApps.Core.AI.Services; + +internal static class AIProviderConnectionEntryFactory +{ + public static AIProviderConnectionEntry Create(AIProviderConnection connection) + { + ArgumentNullException.ThrowIfNull(connection); + + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (connection.Properties != null) + { + foreach (var property in connection.Properties) + { + values[property.Key] = property.Value is JsonNode jsonNode + ? ConvertJsonNode(jsonNode) + : property.Value; + } + } + + values["DisplayText"] = string.IsNullOrWhiteSpace(connection.DisplayText) + ? connection.Name + : connection.DisplayText; + + return new AIProviderConnectionEntry(values); + } + + private static object ConvertJsonNode(JsonNode node) + { + return node switch + { + JsonObject jsonObject => jsonObject.ToDictionary( + property => property.Key, + property => ConvertJsonNode(property.Value), + StringComparer.OrdinalIgnoreCase), + JsonArray jsonArray => jsonArray.Select(ConvertJsonNode).ToList(), + JsonValue jsonValue when jsonValue.TryGetValue(out var s) => s, + JsonValue jsonValue when jsonValue.TryGetValue(out var b) => b, + JsonValue jsonValue when jsonValue.TryGetValue(out var i) => i, + JsonValue jsonValue when jsonValue.TryGetValue(out var l) => l, + JsonValue jsonValue when jsonValue.TryGetValue(out var d) => d, + _ => node?.ToString(), + }; + } +} diff --git a/src/Primitives/CrestApps.Core.AI/Services/AIProviderOptionsConnectionMerger.cs b/src/Primitives/CrestApps.Core.AI/Services/AIProviderOptionsConnectionMerger.cs index 22ad3328..66877da2 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/AIProviderOptionsConnectionMerger.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/AIProviderOptionsConnectionMerger.cs @@ -58,7 +58,6 @@ public static bool MergeConnection( var normalizedConnection = NormalizeConnection(connectionName, connection); provider.Connections[connectionName] = normalizedConnection; - ApplyMissingDefaults(provider, normalizedConnection); return true; } @@ -67,21 +66,17 @@ private static AIProviderConnectionEntry NormalizeConnection(string connectionNa { var values = new Dictionary(connection, StringComparer.OrdinalIgnoreCase); - if (string.IsNullOrWhiteSpace(values.GetStringValue("ConnectionNameAlias", false))) + var displayText = values.GetStringValue("DisplayText", false); + + if (string.IsNullOrWhiteSpace(displayText)) + { + values["DisplayText"] = connectionName; + } + else { - values["ConnectionNameAlias"] = connectionName; + values["DisplayText"] = displayText; } return new AIProviderConnectionEntry(values); } - - private static void ApplyMissingDefaults(AIProvider provider, AIProviderConnectionEntry connection) - { -#pragma warning disable CS0618 // Obsolete deployment name fields retained for backward compatibility - provider.DefaultChatDeploymentName ??= connection.GetChatDeploymentOrDefaultName(false); - provider.DefaultEmbeddingDeploymentName ??= connection.GetEmbeddingDeploymentOrDefaultName(false); - provider.DefaultImagesDeploymentName ??= connection.GetImagesDeploymentOrDefaultName(false); - provider.DefaultUtilityDeploymentName ??= connection.GetUtilityDeploymentOrDefaultName(false); -#pragma warning restore CS0618 - } } diff --git a/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIDeploymentCatalog.cs b/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIDeploymentCatalog.cs index a3da91eb..eebef1fc 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIDeploymentCatalog.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIDeploymentCatalog.cs @@ -1,9 +1,10 @@ using System.Text.Json; using System.Text.Json.Nodes; -using CrestApps.Core.AI.Deployments; using CrestApps.Core.AI.Models; using CrestApps.Core.Models; +using CrestApps.Core.Services; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -12,20 +13,27 @@ namespace CrestApps.Core.AI.Services; /// Decorates a persisted AI deployment store with configuration-backed deployments from appsettings.json. /// Read operations return the merged result while write operations continue to target the persisted store only. /// -public sealed class ConfigurationAIDeploymentCatalog : IAIDeploymentStore +public sealed class ConfigurationAIDeploymentCatalog : INamedSourceCatalog { - private readonly IAIDeploymentStore _inner; + public const string PersistedCatalogKey = "PersistedCatalog"; + + private readonly INamedSourceCatalog _inner; private readonly IConfiguration _configuration; - private readonly AIProviderOptions _providerOptions; private readonly AIOptions _aiOptions; + private readonly AIDeploymentCatalogOptions _catalogOptions; private readonly ILogger _logger; - private IReadOnlyCollection _configDeployments; - public ConfigurationAIDeploymentCatalog(IAIDeploymentStore inner, IConfiguration configuration, IOptions providerOptions, IOptions aiOptions, ILogger logger) + + public ConfigurationAIDeploymentCatalog( + [FromKeyedServices(PersistedCatalogKey)] INamedSourceCatalog inner, + IConfiguration configuration, + IOptions aiOptions, + IOptions catalogOptions, + ILogger logger) { _inner = inner; _configuration = configuration; - _providerOptions = providerOptions.Value; _aiOptions = aiOptions.Value; + _catalogOptions = catalogOptions.Value; _logger = logger; } @@ -37,13 +45,15 @@ public async ValueTask FindByIdAsync(string id) return result; } - return FindConfigDeployment(deployment => string.Equals(deployment.ItemId, id, StringComparison.OrdinalIgnoreCase)); + return (await GetConfigDeploymentsAsync(await _inner.GetAllAsync())) + .FirstOrDefault(deployment => string.Equals(deployment.ItemId, id, StringComparison.OrdinalIgnoreCase)) + ?.Clone(); } public async ValueTask> GetAllAsync() { var dbRecords = await _inner.GetAllAsync(); - var configRecords = GetConfigDeployments(); + var configRecords = await GetConfigDeploymentsAsync(dbRecords); if (configRecords.Count == 0) { return dbRecords; @@ -63,7 +73,7 @@ public async ValueTask> GetAsync(IEnumerable missingIds.Contains(deployment.ItemId)).ToList(); + var configMatches = (await GetConfigDeploymentsAsync(dbRecords)).Where(deployment => missingIds.Contains(deployment.ItemId)).ToList(); if (configMatches.Count == 0) { return dbRecords; @@ -75,7 +85,7 @@ public async ValueTask> GetAsync(IEnumerable> PageAsync(int page, int pageSize, TQuery context) where TQuery : QueryContext { - var configRecords = GetConfigDeployments(); + var configRecords = await GetConfigDeploymentsAsync(await _inner.GetAllAsync()); if (configRecords.Count == 0) { return await _inner.PageAsync(page, pageSize, context); @@ -99,13 +109,15 @@ public async ValueTask FindByNameAsync(string name) return result; } - return FindConfigDeployment(deployment => string.Equals(deployment.Name, name, StringComparison.OrdinalIgnoreCase)); + return (await GetConfigDeploymentsAsync(await _inner.GetAllAsync())) + .FirstOrDefault(deployment => string.Equals(deployment.Name, name, StringComparison.OrdinalIgnoreCase)) + ?.Clone(); } public async ValueTask> GetAsync(string source) { var dbRecords = await _inner.GetAsync(source); - var configMatches = GetConfigDeployments().Where(deployment => string.Equals(deployment.Source, source, StringComparison.OrdinalIgnoreCase)).ToList(); + var configMatches = (await GetConfigDeploymentsAsync(dbRecords)).Where(deployment => string.Equals(deployment.Source, source, StringComparison.OrdinalIgnoreCase)).ToList(); if (configMatches.Count == 0) { return dbRecords; @@ -122,116 +134,65 @@ public async ValueTask GetAsync(string name, string source) return result; } - return FindConfigDeployment(deployment => string.Equals(deployment.Name, name, StringComparison.OrdinalIgnoreCase) && string.Equals(deployment.Source, source, StringComparison.OrdinalIgnoreCase)); + return (await GetConfigDeploymentsAsync(await _inner.GetAllAsync())) + .FirstOrDefault(deployment => + string.Equals(deployment.Name, name, StringComparison.OrdinalIgnoreCase) && + string.Equals(deployment.Source, source, StringComparison.OrdinalIgnoreCase)) + ?.Clone(); } public ValueTask DeleteAsync(AIDeployment entry) => _inner.DeleteAsync(entry); public ValueTask CreateAsync(AIDeployment entry) => _inner.CreateAsync(entry); public ValueTask UpdateAsync(AIDeployment entry) => _inner.UpdateAsync(entry); - private AIDeployment FindConfigDeployment(Func predicate) - { - return GetConfigDeployments().FirstOrDefault(predicate)?.Clone(); - } - private IReadOnlyCollection GetConfigDeployments() + private async Task> GetConfigDeploymentsAsync(IReadOnlyCollection storedDeployments) { - if (_configDeployments != null) - { - return _configDeployments; - } + var deployments = new Dictionary(StringComparer.OrdinalIgnoreCase); + var names = storedDeployments + .Where(static deployment => !string.IsNullOrWhiteSpace(deployment.Name)) + .ToDictionary(static deployment => deployment.Name, static deployment => deployment.ItemId, StringComparer.OrdinalIgnoreCase); - var deployments = new List(); try { - ReadConnectionDeployments(deployments); - ReadStandaloneDeployments(deployments); + ReadStandaloneDeployments(deployments, names); } catch (Exception ex) { _logger.LogError(ex, "Error reading AI deployment configuration."); } - _configDeployments = deployments; - return _configDeployments; + return deployments.Values.ToArray(); } - private void ReadConnectionDeployments(List deployments) + private void ReadStandaloneDeployments(Dictionary deployments, Dictionary names) { - foreach (var(providerName, provider)in _providerOptions.Providers) + foreach (var sectionPath in _catalogOptions.DeploymentSections) { - if (provider.Connections is null) + var section = _configuration.GetSection(sectionPath); + if (!section.Exists()) { continue; } - foreach (var(connectionId, connectionEntry)in provider.Connections) + var deploymentsNode = ReadConfigurationNode(section); + switch (deploymentsNode) { - if (!connectionEntry.TryGetValue("Deployments", out var deploymentsObject)) - { - continue; - } - - var deploymentArray = ConvertToJsonArray(deploymentsObject); - if (deploymentArray is null) - { - continue; - } - - foreach (var deploymentNode in deploymentArray) - { - if (deploymentNode is not JsonObject deploymentObject) - { - continue; - } - - var deployment = ParseConnectionDeploymentEntry(deploymentObject, providerName, connectionId, connectionEntry); - if (deployment != null) - { - deployments.Add(deployment); - } - } + case JsonArray deploymentArray: + ReadStandaloneDeploymentsFromArray(deploymentArray, deployments, names, sectionPath); + break; + case JsonObject deploymentObject: + ReadStandaloneDeploymentsFromObject(deploymentObject, deployments, names, sectionPath); + break; + case null: + break; + default: + _logger.LogWarning("The AI deployments configuration at '{SectionPath}' must be either an array or an object.", sectionPath); + break; } } } - private void ReadStandaloneDeployments(List deployments) - { - var section = GetDeploymentsSection(); - if (section is null) - { - return; - } - - var deploymentsNode = ReadConfigurationNode(section); - switch (deploymentsNode) - { - case JsonArray deploymentArray: - ReadStandaloneDeploymentsFromArray(deploymentArray, deployments); - break; - case JsonObject deploymentObject: - ReadStandaloneDeploymentsFromObject(deploymentObject, deployments); - break; - case null: - break; - default: - _logger.LogWarning("The AI deployments configuration must be either an array or an object."); - break; - } - } - - private IConfigurationSection GetDeploymentsSection() - { - var section = _configuration.GetSection("CrestApps:AI:Deployments"); - if (section.Exists()) - { - return section; - } - - section = _configuration.GetSection("CrestApps_AI:Deployments"); - return section.Exists() ? section : null; - } - - private void ReadStandaloneDeploymentsFromArray(JsonArray deploymentArray, List deployments) + private void ReadStandaloneDeploymentsFromArray(JsonArray deploymentArray, Dictionary deployments, Dictionary names, string sectionPath) { foreach (var deploymentNode in deploymentArray) { @@ -242,16 +203,13 @@ private void ReadStandaloneDeploymentsFromArray(JsonArray deploymentArray, List< } var deployment = CreateStandaloneDeployment(ParseStandaloneDeploymentEntry(deploymentObject)); - if (deployment != null) - { - deployments.Add(deployment); - } + AddDeployment(deployments, names, deployment, sectionPath); } } - private void ReadStandaloneDeploymentsFromObject(JsonObject deploymentObject, List deployments) + private void ReadStandaloneDeploymentsFromObject(JsonObject deploymentObject, Dictionary deployments, Dictionary names, string sectionPath) { - foreach (var(providerName, providerDeploymentsNode)in deploymentObject) + foreach (var (providerName, providerDeploymentsNode) in deploymentObject) { if (providerDeploymentsNode is not JsonArray providerDeployments) { @@ -268,44 +226,11 @@ private void ReadStandaloneDeploymentsFromObject(JsonObject deploymentObject, Li } var deployment = CreateStandaloneDeployment(ParseStandaloneDeploymentEntry(standaloneDeploymentObject, providerName)); - if (deployment != null) - { - deployments.Add(deployment); - } + AddDeployment(deployments, names, deployment, $"{sectionPath}:{providerName}"); } } } - private AIDeployment ParseConnectionDeploymentEntry(JsonObject deploymentObject, string providerName, string connectionId, AIProviderConnectionEntry connectionEntry) - { - var name = GetStringValue(deploymentObject["Name"]); - var modelName = GetStringValue(deploymentObject["ModelName"]) ?? name; - if (string.IsNullOrWhiteSpace(name)) - { - _logger.LogWarning("A deployment entry in connection '{ConnectionId}' of provider '{ProviderName}' is missing a Name. Skipping.", connectionId, providerName); - return null; - } - - if (!TryGetDeploymentType(deploymentObject["Type"], out var type)) - { - _logger.LogWarning("Deployment entry '{Name}' in connection '{ConnectionId}' of provider '{ProviderName}' has an invalid or missing Type. Skipping.", name, connectionId, providerName); - return null; - } - - var connectionNameAlias = connectionEntry.TryGetValue("ConnectionNameAlias", out var aliasValue) ? aliasValue?.ToString() : null; - return new AIDeployment - { - ItemId = AIConfigurationRecordIds.CreateDeploymentId(providerName, connectionId, name), - Name = name, - ModelName = modelName, - Source = providerName, - ConnectionName = connectionId, - ConnectionNameAlias = connectionNameAlias, - Type = type, - IsDefault = GetBooleanValue(deploymentObject["IsDefault"]), - }; - } - private static AIDeploymentConfigurationEntry ParseStandaloneDeploymentEntry(JsonObject deploymentObject, string providerName = null) { var entry = new AIDeploymentConfigurationEntry @@ -363,7 +288,6 @@ private AIDeployment CreateStandaloneDeployment(AIDeploymentConfigurationEntry e ModelName = entry.ModelName, Source = entry.ProviderName, Type = entry.Type, - IsDefault = entry.IsDefault, Properties = entry.Properties?.Count > 0 ? JsonSerializer.Deserialize>(entry.Properties.DeepClone()) : null, }; } @@ -469,7 +393,7 @@ private static JsonObject BuildStandaloneDeploymentProperties(JsonObject deploym properties = (JsonObject)explicitProperties.DeepClone(); } - foreach (var(key, value)in deploymentObject) + foreach (var (key, value) in deploymentObject) { if (IsStandaloneDeploymentMetadataKey(key)) { @@ -516,6 +440,31 @@ private static bool GetBooleanValue(JsonNode node) return false; } + private void AddDeployment( + Dictionary deployments, + Dictionary names, + AIDeployment deployment, + string sourceDescription) + { + if (deployment == null) + { + return; + } + + if (names.TryGetValue(deployment.Name, out var existingItemId) && + !string.Equals(existingItemId, deployment.ItemId, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning( + "Skipping AI deployment '{DeploymentName}' from {SourceDescription} because another deployment with the same name is already defined.", + deployment.Name, + sourceDescription); + return; + } + + names[deployment.Name] = deployment.ItemId; + deployments[deployment.ItemId] = deployment; + } + private static List Merge(IReadOnlyCollection primary, IReadOnlyCollection secondary) { var merged = new List(primary.Count + secondary.Count); @@ -556,4 +505,4 @@ private static IEnumerable ApplyFilters(QueryContext context, IEnu return records; } -} \ No newline at end of file +} diff --git a/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIProviderConnectionCatalog.cs b/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIProviderConnectionCatalog.cs new file mode 100644 index 00000000..71784888 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIProviderConnectionCatalog.cs @@ -0,0 +1,393 @@ +using System.Globalization; +using CrestApps.Core.AI.Models; +using CrestApps.Core.Infrastructure; +using CrestApps.Core.Models; +using CrestApps.Core.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace CrestApps.Core.AI.Services; + +/// +/// Decorates a persisted AI provider connection store with configuration-backed +/// connections from appsettings.json. Read operations return the merged result +/// while write operations continue to target the persisted store only. +/// +public sealed class ConfigurationAIProviderConnectionCatalog : INamedSourceCatalog +{ + public const string PersistedCatalogKey = "PersistedCatalog"; + + private readonly INamedSourceCatalog _inner; + private readonly IConfiguration _configuration; + private readonly AIProviderConnectionCatalogOptions _options; + private readonly ILogger _logger; + + public ConfigurationAIProviderConnectionCatalog( + [FromKeyedServices(PersistedCatalogKey)] INamedSourceCatalog inner, + IConfiguration configuration, + IOptions options, + ILogger logger) + { + _inner = inner; + _configuration = configuration; + _options = options.Value; + _logger = logger; + } + + public async ValueTask FindByIdAsync(string id) + { + var result = await _inner.FindByIdAsync(id); + if (result != null) + { + return result; + } + + return (await GetConfiguredConnectionsAsync(await _inner.GetAllAsync())) + .FirstOrDefault(connection => string.Equals(connection.ItemId, id, StringComparison.OrdinalIgnoreCase)) + ?.Clone(); + } + + public async ValueTask> GetAllAsync() + { + var storedConnections = await _inner.GetAllAsync(); + var configuredConnections = await GetConfiguredConnectionsAsync(storedConnections); + + if (configuredConnections.Count == 0) + { + return storedConnections; + } + + var merged = new List(storedConnections.Count + configuredConnections.Count); + merged.AddRange(storedConnections); + merged.AddRange(configuredConnections); + + return merged; + } + + public async ValueTask> GetAsync(IEnumerable ids) + { + var storedConnections = await _inner.GetAsync(ids); + var requestedIds = ids.ToHashSet(StringComparer.OrdinalIgnoreCase); + var foundIds = storedConnections.Select(static connection => connection.ItemId).ToHashSet(StringComparer.OrdinalIgnoreCase); + var missingIds = requestedIds.Except(foundIds).ToList(); + + if (missingIds.Count == 0) + { + return storedConnections; + } + + var configuredConnections = (await GetConfiguredConnectionsAsync(storedConnections)) + .Where(connection => missingIds.Contains(connection.ItemId)) + .ToArray(); + + if (configuredConnections.Length == 0) + { + return storedConnections; + } + + var merged = new List(storedConnections.Count + configuredConnections.Length); + merged.AddRange(storedConnections); + merged.AddRange(configuredConnections); + + return merged; + } + + public async ValueTask> PageAsync(int page, int pageSize, TQuery context) + where TQuery : QueryContext + { + var allConnections = await GetAllAsync(); + var filtered = ApplyFilters(context, allConnections); + var skip = (page - 1) * pageSize; + + return new PageResult + { + Count = filtered.Count(), + Entries = filtered.Skip(skip).Take(pageSize).ToArray(), + }; + } + + public async ValueTask FindByNameAsync(string name) + { + var result = await _inner.FindByNameAsync(name); + if (result != null) + { + return result; + } + + return (await GetConfiguredConnectionsAsync(await _inner.GetAllAsync())) + .FirstOrDefault(connection => string.Equals(connection.Name, name, StringComparison.OrdinalIgnoreCase)) + ?.Clone(); + } + + public async ValueTask> GetAsync(string source) + { + var storedConnections = await _inner.GetAsync(source); + var configuredConnections = (await GetConfiguredConnectionsAsync(storedConnections)) + .Where(connection => string.Equals(connection.Source, source, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + if (configuredConnections.Length == 0) + { + return storedConnections; + } + + var merged = new List(storedConnections.Count + configuredConnections.Length); + merged.AddRange(storedConnections); + merged.AddRange(configuredConnections); + + return merged; + } + + public async ValueTask GetAsync(string name, string source) + { + var result = await _inner.GetAsync(name, source); + if (result != null) + { + return result; + } + + return (await GetConfiguredConnectionsAsync(await _inner.GetAllAsync())) + .FirstOrDefault(connection => + string.Equals(connection.Name, name, StringComparison.OrdinalIgnoreCase) && + string.Equals(connection.Source, source, StringComparison.OrdinalIgnoreCase)) + ?.Clone(); + } + + public ValueTask DeleteAsync(AIProviderConnection entry) => _inner.DeleteAsync(entry); + + public ValueTask CreateAsync(AIProviderConnection entry) => _inner.CreateAsync(entry); + + public ValueTask UpdateAsync(AIProviderConnection entry) => _inner.UpdateAsync(entry); + + private Task> GetConfiguredConnectionsAsync(IReadOnlyCollection storedConnections) + { + var connections = new Dictionary(StringComparer.OrdinalIgnoreCase); + var names = storedConnections + .Where(static connection => !string.IsNullOrWhiteSpace(connection.Name)) + .ToDictionary(static connection => connection.Name, static connection => connection.ItemId, StringComparer.OrdinalIgnoreCase); + + try + { + foreach (var sectionPath in _options.ConnectionSections) + { + ReadTopLevelConnections(sectionPath, connections, names); + } + + foreach (var sectionPath in _options.ProviderSections) + { + ReadProviderConnections(sectionPath, connections, names); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error reading AI provider connection configuration."); + } + + return Task.FromResult>(connections.Values.ToArray()); + } + + private void ReadTopLevelConnections(string sectionPath, Dictionary connections, Dictionary names) + { + var section = _configuration.GetSection(sectionPath); + if (!section.Exists()) + { + return; + } + + foreach (var connectionSection in section.GetChildren()) + { + var values = ReadObject(connectionSection); + var connection = ParseConnection(values, fallbackName: connectionSection.Key); + AddConfiguredConnection(connections, names, connection, sectionPath); + } + } + + private void ReadProviderConnections(string sectionPath, Dictionary connections, Dictionary names) + { + var section = _configuration.GetSection(sectionPath); + if (!section.Exists()) + { + return; + } + + foreach (var providerSection in section.GetChildren()) + { + var providerName = AIProviderNameNormalizer.Normalize(providerSection.Key); + var connectionsSection = providerSection.GetSection("Connections"); + if (!connectionsSection.Exists()) + { + continue; + } + + foreach (var connectionSection in connectionsSection.GetChildren()) + { + var values = ReadObject(connectionSection); + var connection = ParseConnection(values, fallbackName: connectionSection.Key, providerName: providerName); + AddConfiguredConnection(connections, names, connection, $"{sectionPath}:{providerSection.Key}:Connections:{connectionSection.Key}"); + } + } + } + + private void AddConfiguredConnection( + Dictionary connections, + Dictionary names, + AIProviderConnection connection, + string sourceDescription) + { + if (connection == null) + { + return; + } + + if (names.TryGetValue(connection.Name, out var existingItemId) && + !string.Equals(existingItemId, connection.ItemId, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning( + "Skipping AI connection '{ConnectionName}' from '{SourceDescription}' because another connection with the same name is already defined.", + connection.Name, + sourceDescription); + return; + } + + names[connection.Name] = connection.ItemId; + connections[connection.ItemId] = connection; + } + + private AIProviderConnection ParseConnection( + Dictionary values, + string fallbackName = null, + string providerName = null) + { + var connectionName = values.GetStringValue("Name", false) ?? fallbackName; + var clientName = AIProviderNameNormalizer.Normalize( + values.GetStringValue("ClientName", false) ?? + values.GetStringValue("ProviderName", false) ?? + providerName); + + if (string.IsNullOrWhiteSpace(connectionName)) + { + _logger.LogWarning("An AI connection configuration entry is missing the required Name value and will be ignored."); + return null; + } + + if (string.IsNullOrWhiteSpace(clientName)) + { + _logger.LogWarning("The AI connection '{ConnectionName}' is missing the required ClientName value and will be ignored.", connectionName); + return null; + } + + var displayText = values.GetStringValue("DisplayText", false); + var properties = values + .Where(static pair => !IsConnectionMetadataKey(pair.Key)) + .ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.OrdinalIgnoreCase); + + return new AIProviderConnection + { + ItemId = AIConfigurationRecordIds.CreateConnectionId(clientName, connectionName), + Name = connectionName, + DisplayText = string.IsNullOrWhiteSpace(displayText) ? connectionName : displayText, + ClientName = clientName, + Properties = properties.Count > 0 ? properties : null, + }; + } + + private static bool IsConnectionMetadataKey(string key) + { + return string.Equals(key, "Name", StringComparison.OrdinalIgnoreCase) || + string.Equals(key, "ClientName", StringComparison.OrdinalIgnoreCase) || + string.Equals(key, "ProviderName", StringComparison.OrdinalIgnoreCase) || + string.Equals(key, "DisplayText", StringComparison.OrdinalIgnoreCase) || + string.Equals(key, "ConnectionNameAlias", StringComparison.OrdinalIgnoreCase) || + string.Equals(key, "ChatDeploymentName", StringComparison.OrdinalIgnoreCase) || + string.Equals(key, "DefaultChatDeploymentName", StringComparison.OrdinalIgnoreCase) || + string.Equals(key, "DefaultDeploymentName", StringComparison.OrdinalIgnoreCase) || + string.Equals(key, "EmbeddingDeploymentName", StringComparison.OrdinalIgnoreCase) || + string.Equals(key, "ImagesDeploymentName", StringComparison.OrdinalIgnoreCase) || + string.Equals(key, "UtilityDeploymentName", StringComparison.OrdinalIgnoreCase) || + string.Equals(key, "SpeechToTextDeploymentName", StringComparison.OrdinalIgnoreCase); + } + + private static Dictionary ReadObject(IConfigurationSection section) + { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var child in section.GetChildren()) + { + values[child.Key] = ReadValue(child); + } + + return values; + } + + private static object ReadValue(IConfigurationSection section) + { + var children = section.GetChildren().ToArray(); + + if (children.Length == 0) + { + return ParseScalar(section.Value); + } + + if (children.All(static child => int.TryParse(child.Key, out _))) + { + return children + .OrderBy(static child => int.Parse(child.Key, CultureInfo.InvariantCulture)) + .Select(ReadValue) + .ToArray(); + } + + return children.ToDictionary(static child => child.Key, ReadValue, StringComparer.OrdinalIgnoreCase); + } + + private static object ParseScalar(string value) + { + if (bool.TryParse(value, out var booleanValue)) + { + return booleanValue; + } + + if (int.TryParse(value, out var intValue)) + { + return intValue; + } + + if (long.TryParse(value, out var longValue)) + { + return longValue; + } + + if (double.TryParse(value, out var doubleValue)) + { + return doubleValue; + } + + return value; + } + + private static IEnumerable ApplyFilters(QueryContext context, IEnumerable records) + { + if (context is null) + { + return records; + } + + if (!string.IsNullOrEmpty(context.Source)) + { + records = records.Where(connection => string.Equals(connection.Source, context.Source, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrEmpty(context.Name)) + { + records = records.Where(connection => connection.Name.Contains(context.Name, StringComparison.OrdinalIgnoreCase)); + } + + if (context.Sorted) + { + records = records.OrderBy(static connection => connection.DisplayText ?? connection.Name, StringComparer.OrdinalIgnoreCase); + } + + return records; + } +} diff --git a/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIProviderConnectionsOptionsConfiguration.cs b/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIProviderConnectionsOptionsConfiguration.cs index 6e4594eb..acb5ed6d 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIProviderConnectionsOptionsConfiguration.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIProviderConnectionsOptionsConfiguration.cs @@ -71,10 +71,11 @@ private static AIProviderConnectionEntry ReadConnection(IConfigurationSection se { var values = ReadObject(section); - if (!values.ContainsKey("ConnectionNameAlias")) - { - values["ConnectionNameAlias"] = values.GetStringValue("Name", false) ?? section.Key; - } + var displayText = values.GetStringValue("DisplayText", false) ?? + values.GetStringValue("Name", false) ?? + section.Key; + + values["DisplayText"] = displayText; return new AIProviderConnectionEntry(values); } diff --git a/src/Primitives/CrestApps.Core.AI/Services/DefaultAIClientFactory.cs b/src/Primitives/CrestApps.Core.AI/Services/DefaultAIClientFactory.cs index 02af26ff..34577ba3 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/DefaultAIClientFactory.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/DefaultAIClientFactory.cs @@ -1,15 +1,15 @@ using CrestApps.Core.AI.Clients; using CrestApps.Core.AI.Models; +using CrestApps.Core.Services; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; namespace CrestApps.Core.AI.Services; public sealed class DefaultAIClientFactory : IAIClientFactory { - private readonly AIProviderOptions _options; + private readonly INamedSourceCatalog _connectionCatalog; private readonly IEnumerable _clientProviders; private readonly IDataProtectionProvider _dataProtectionProvider; @@ -21,9 +21,9 @@ public DefaultAIClientFactory( IDataProtectionProvider dataProtectionProvider, IServiceProvider serviceProvider, ILogger logger, - IOptions options) + INamedSourceCatalog connectionCatalog) { - _options = options.Value; + _connectionCatalog = connectionCatalog; _clientProviders = clientProviders; _dataProtectionProvider = dataProtectionProvider; _serviceProvider = serviceProvider; @@ -36,17 +36,7 @@ public async ValueTask CreateChatClientAsync(string providerName, s ArgumentException.ThrowIfNullOrEmpty(connectionName); - if (!_options.Providers.TryGetValue(providerName, out var provider)) - { - throw new ArgumentException($"Provider '{providerName}' not found."); - - } - - if (!provider.Connections.TryGetValue(connectionName, out var connection)) - { - throw new ArgumentException($"Connection '{connectionName}' not found with in the provider '{providerName}'."); - - } + var connection = await GetConnectionEntryAsync(providerName, connectionName); foreach (var clientProvider in _clientProviders) { @@ -72,23 +62,13 @@ public async ValueTask CreateChatClientAsync(string providerName, s } - public ValueTask>> CreateEmbeddingGeneratorAsync(string providerName, string connectionName, string deploymentName = null) + public async ValueTask>> CreateEmbeddingGeneratorAsync(string providerName, string connectionName, string deploymentName = null) { ArgumentException.ThrowIfNullOrEmpty(providerName); ArgumentException.ThrowIfNullOrEmpty(connectionName); - if (!_options.Providers.TryGetValue(providerName, out var provider)) - { - throw new ArgumentException($"Provider '{providerName}' not found."); - - } - - if (!provider.Connections.TryGetValue(connectionName, out var connection)) - { - throw new ArgumentException($"Connection '{connectionName}' not found with in the provider '{providerName}'."); - - } + var connection = await GetConnectionEntryAsync(providerName, connectionName); foreach (var clientProvider in _clientProviders) { @@ -98,7 +78,7 @@ public ValueTask>> CreateEmbeddingG } - return clientProvider.GetEmbeddingGeneratorAsync(connection, deploymentName); + return await clientProvider.GetEmbeddingGeneratorAsync(connection, deploymentName); } @@ -107,24 +87,14 @@ public ValueTask>> CreateEmbeddingG } #pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - public ValueTask CreateImageGeneratorAsync(string providerName, string connectionName, string deploymentName = null) + public async ValueTask CreateImageGeneratorAsync(string providerName, string connectionName, string deploymentName = null) #pragma warning restore MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. { ArgumentException.ThrowIfNullOrEmpty(providerName); ArgumentException.ThrowIfNullOrEmpty(connectionName); - if (!_options.Providers.TryGetValue(providerName, out var provider)) - { - throw new ArgumentException($"Provider '{providerName}' not found."); - - } - - if (!provider.Connections.TryGetValue(connectionName, out var connection)) - { - throw new ArgumentException($"Connection '{connectionName}' not found with in the provider '{providerName}'."); - - } + var connection = await GetConnectionEntryAsync(providerName, connectionName); foreach (var clientProvider in _clientProviders) { @@ -134,7 +104,7 @@ public ValueTask CreateImageGeneratorAsync(string providerName, } - return clientProvider.GetImageGeneratorAsync(connection, deploymentName); + return await clientProvider.GetImageGeneratorAsync(connection, deploymentName); } @@ -143,24 +113,14 @@ public ValueTask CreateImageGeneratorAsync(string providerName, } #pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - public ValueTask CreateSpeechToTextClientAsync(string providerName, string connectionName, string deploymentName = null) + public async ValueTask CreateSpeechToTextClientAsync(string providerName, string connectionName, string deploymentName = null) #pragma warning restore MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. { ArgumentException.ThrowIfNullOrEmpty(providerName); ArgumentException.ThrowIfNullOrEmpty(connectionName); - if (!_options.Providers.TryGetValue(providerName, out var provider)) - { - throw new ArgumentException($"Provider '{providerName}' not found."); - - } - - if (!provider.Connections.TryGetValue(connectionName, out var connection)) - { - throw new ArgumentException($"Connection '{connectionName}' not found with in the provider '{providerName}'."); - - } + var connection = await GetConnectionEntryAsync(providerName, connectionName); foreach (var clientProvider in _clientProviders) { @@ -170,7 +130,7 @@ public ValueTask CreateSpeechToTextClientAsync(string provi } - return clientProvider.GetSpeechToTextClientAsync(connection, deploymentName); + return await clientProvider.GetSpeechToTextClientAsync(connection, deploymentName); } @@ -214,23 +174,13 @@ public ValueTask CreateSpeechToTextClientAsync(AIDeployment } #pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - public ValueTask CreateTextToSpeechClientAsync(string providerName, string connectionName, string deploymentName = null) + public async ValueTask CreateTextToSpeechClientAsync(string providerName, string connectionName, string deploymentName = null) { ArgumentException.ThrowIfNullOrEmpty(providerName); ArgumentException.ThrowIfNullOrEmpty(connectionName); - if (!_options.Providers.TryGetValue(providerName, out var provider)) - { - throw new ArgumentException($"Provider '{providerName}' not found."); - - } - - if (!provider.Connections.TryGetValue(connectionName, out var connection)) - { - throw new ArgumentException($"Connection '{connectionName}' not found with in the provider '{providerName}'."); - - } + var connection = await GetConnectionEntryAsync(providerName, connectionName); foreach (var clientProvider in _clientProviders) { @@ -240,7 +190,7 @@ public ValueTask CreateTextToSpeechClientAsync(string provi } - return clientProvider.GetTextToSpeechClientAsync(connection, deploymentName); + return await clientProvider.GetTextToSpeechClientAsync(connection, deploymentName); } @@ -281,4 +231,16 @@ public ValueTask CreateTextToSpeechClientAsync(AIDeployment } #pragma warning restore MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + + private async ValueTask GetConnectionEntryAsync(string providerName, string connectionName) + { + var connection = await _connectionCatalog.GetAsync(connectionName, providerName); + + if (connection == null) + { + throw new ArgumentException($"Connection '{connectionName}' not found with in the provider '{providerName}'."); + } + + return AIProviderConnectionEntryFactory.Create(connection); + } } diff --git a/src/Primitives/CrestApps.Core.AI/Services/DefaultAIDataSourceIndexingService.cs b/src/Primitives/CrestApps.Core.AI/Services/DefaultAIDataSourceIndexingService.cs index 415e8dc5..f4ec87a5 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/DefaultAIDataSourceIndexingService.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/DefaultAIDataSourceIndexingService.cs @@ -11,6 +11,7 @@ using Microsoft.Extensions.Logging; namespace CrestApps.Core.AI.Services; + public sealed class DefaultAIDataSourceIndexingService : IAIDataSourceIndexingService { private const int BatchSize = 250; @@ -45,7 +46,7 @@ public async Task SyncAllAsync(CancellationToken cancellationToken = default) { await SyncDataSourceAsync(dataSource, cancellationToken); } - catch (OperationCanceledException)when (cancellationToken.IsCancellationRequested) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } diff --git a/src/Primitives/CrestApps.Core.AI/Services/DefaultAIDeploymentManager.cs b/src/Primitives/CrestApps.Core.AI/Services/DefaultAIDeploymentManager.cs index fc961abc..8cb94690 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/DefaultAIDeploymentManager.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/DefaultAIDeploymentManager.cs @@ -24,8 +24,7 @@ public async ValueTask> GetAllAsync(string clientName, { var deployments = (await Catalog.GetAllAsync()) .Where(x => string.Equals(x.ClientName, clientName, StringComparison.OrdinalIgnoreCase) && - (string.Equals(x.ConnectionName ?? string.Empty, connectionName, StringComparison.OrdinalIgnoreCase) || - string.Equals(x.ConnectionNameAlias ?? string.Empty, connectionName, StringComparison.OrdinalIgnoreCase))); + string.Equals(x.ConnectionName ?? string.Empty, connectionName, StringComparison.OrdinalIgnoreCase)); foreach (var deployment in deployments) { @@ -54,8 +53,7 @@ public async ValueTask GetDefaultAsync(string clientName, string c var candidates = deployments.Where(d => d.SupportsType(type)); - return candidates.FirstOrDefault(d => d.IsDefault) - ?? candidates.FirstOrDefault(); + return candidates.FirstOrDefault(); } public ValueTask ResolveOrDefaultAsync(AIDeploymentType type, string deploymentName = null, string clientName = null, string connectionName = null) @@ -126,8 +124,7 @@ private async ValueTask GetFirstMatchingDeploymentAsync(AIDeployme return true; } - return string.Equals(deployment.ConnectionName ?? string.Empty, connectionName, StringComparison.OrdinalIgnoreCase) || - string.Equals(deployment.ConnectionNameAlias ?? string.Empty, connectionName, StringComparison.OrdinalIgnoreCase); + return string.Equals(deployment.ConnectionName ?? string.Empty, connectionName, StringComparison.OrdinalIgnoreCase); }); } diff --git a/src/Primitives/CrestApps.Core.AI/Services/DefaultSpeechVoiceResolver.cs b/src/Primitives/CrestApps.Core.AI/Services/DefaultSpeechVoiceResolver.cs index 82496ad1..56ce82fa 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/DefaultSpeechVoiceResolver.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/DefaultSpeechVoiceResolver.cs @@ -1,25 +1,25 @@ using CrestApps.Core.AI.Clients; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Speech; +using CrestApps.Core.Services; using Microsoft.AspNetCore.DataProtection; -using Microsoft.Extensions.Options; namespace CrestApps.Core.AI.Services; public sealed class DefaultSpeechVoiceResolver : ISpeechVoiceResolver { private readonly IEnumerable _clientProviders; - private readonly AIProviderOptions _options; + private readonly INamedSourceCatalog _connectionCatalog; private readonly IDataProtectionProvider _dataProtectionProvider; public DefaultSpeechVoiceResolver( IEnumerable clientProviders, IDataProtectionProvider dataProtectionProvider, - IOptions options) + INamedSourceCatalog connectionCatalog) { _clientProviders = clientProviders; _dataProtectionProvider = dataProtectionProvider; - _options = options.Value; + _connectionCatalog = connectionCatalog; } public async Task GetSpeechVoicesAsync(AIDeployment deployment) @@ -27,7 +27,7 @@ public async Task GetSpeechVoicesAsync(AIDeployment deployment) ArgumentNullException.ThrowIfNull(deployment); ArgumentException.ThrowIfNullOrEmpty(deployment.ClientName); - var connectionEntry = GetConnectionEntry(deployment); + var connectionEntry = await GetConnectionEntryAsync(deployment); foreach (var clientProvider in _clientProviders) { @@ -42,14 +42,14 @@ public async Task GetSpeechVoicesAsync(AIDeployment deployment) return []; } - private AIProviderConnectionEntry GetConnectionEntry(AIDeployment deployment) + private async ValueTask GetConnectionEntryAsync(AIDeployment deployment) { if (!string.IsNullOrEmpty(deployment.ConnectionName)) { - if (_options.Providers.TryGetValue(deployment.ClientName, out var provider) - && provider.Connections.TryGetValue(deployment.ConnectionName, out var connection)) + var connection = await _connectionCatalog.GetAsync(deployment.ConnectionName, deployment.ClientName); + if (connection != null) { - return connection; + return AIProviderConnectionEntryFactory.Create(connection); } throw new InvalidOperationException( diff --git a/src/Primitives/CrestApps.Core.Azure.AISearch/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.Azure.AISearch/ServiceCollectionExtensions.cs index 8cc2fd47..53651fdb 100644 --- a/src/Primitives/CrestApps.Core.Azure.AISearch/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.Azure.AISearch/ServiceCollectionExtensions.cs @@ -16,6 +16,7 @@ using Microsoft.Extensions.Options; namespace CrestApps.Core.Azure.AISearch; + public static class ServiceCollectionExtensions { public const string ProviderName = "AzureAISearch"; diff --git a/src/Primitives/CrestApps.Core.Azure.AISearch/Services/AzureAISearchDocumentManager.cs b/src/Primitives/CrestApps.Core.Azure.AISearch/Services/AzureAISearchDocumentManager.cs index 0808e20e..da43653e 100644 --- a/src/Primitives/CrestApps.Core.Azure.AISearch/Services/AzureAISearchDocumentManager.cs +++ b/src/Primitives/CrestApps.Core.Azure.AISearch/Services/AzureAISearchDocumentManager.cs @@ -116,7 +116,7 @@ public async Task DeleteAllAsync(IIndexProfileInfo profile, CancellationToken ca var keysToDelete = new List(); await foreach (var result in response.Value.GetResultsAsync()) { - if (result.Document.TryGetValue(keyFieldName, out var keyObj) && keyObj?.ToString()is string key && !string.IsNullOrEmpty(key)) + if (result.Document.TryGetValue(keyFieldName, out var keyObj) && keyObj?.ToString() is string key && !string.IsNullOrEmpty(key)) { keysToDelete.Add(key); } diff --git a/src/Primitives/CrestApps.Core.Azure.AISearch/Services/AzureAISearchIndexManager.cs b/src/Primitives/CrestApps.Core.Azure.AISearch/Services/AzureAISearchIndexManager.cs index 28702126..de6bdc90 100644 --- a/src/Primitives/CrestApps.Core.Azure.AISearch/Services/AzureAISearchIndexManager.cs +++ b/src/Primitives/CrestApps.Core.Azure.AISearch/Services/AzureAISearchIndexManager.cs @@ -54,7 +54,7 @@ public async Task ExistsAsync(IIndexProfileInfo profile, CancellationToken await _searchIndexClient.GetIndexAsync(indexFullName, cancellationToken); return true; } - catch (RequestFailedException ex)when (ex.Status == 404) + catch (RequestFailedException ex) when (ex.Status == 404) { if (_logger.IsEnabled(LogLevel.Debug)) { @@ -142,9 +142,9 @@ public async Task DeleteAsync(IIndexProfileInfo profile, CancellationToken cance { await _searchIndexClient.DeleteIndexAsync(indexFullName, cancellationToken); } - catch (RequestFailedException ex)when (ex.Status == 404) + catch (RequestFailedException ex) when (ex.Status == 404) { - // Index already deleted, nothing to do. + // Index already deleted, nothing to do. } catch (Exception ex) { diff --git a/src/Primitives/CrestApps.Core.Elasticsearch/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.Elasticsearch/ServiceCollectionExtensions.cs index 6d4850f3..ad1e60b2 100644 --- a/src/Primitives/CrestApps.Core.Elasticsearch/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.Elasticsearch/ServiceCollectionExtensions.cs @@ -2,8 +2,8 @@ using CrestApps.Core.AI.Indexing; using CrestApps.Core.AI.Memory; using CrestApps.Core.Builders; -using CrestApps.Core.Elasticsearch.Services; using CrestApps.Core.Elasticsearch.Builders; +using CrestApps.Core.Elasticsearch.Services; using CrestApps.Core.Infrastructure.Indexing; using CrestApps.Core.Infrastructure.Indexing.DataSources; using Elastic.Clients.Elasticsearch; @@ -14,6 +14,7 @@ using Microsoft.Extensions.Options; namespace CrestApps.Core.Elasticsearch; + public static class ServiceCollectionExtensions { public const string ProviderName = "Elasticsearch"; diff --git a/src/Primitives/CrestApps.Core.Infrastructure/DictionaryExtensions.cs b/src/Primitives/CrestApps.Core.Infrastructure/DictionaryExtensions.cs index 821ba743..288efae3 100644 --- a/src/Primitives/CrestApps.Core.Infrastructure/DictionaryExtensions.cs +++ b/src/Primitives/CrestApps.Core.Infrastructure/DictionaryExtensions.cs @@ -1,6 +1,7 @@ using System.Text.Json; namespace CrestApps.Core.Infrastructure; + public static class DictionaryExtensions { public static string GetApiKey(this IDictionary entry, bool throwException = true) diff --git a/src/Primitives/CrestApps.Core.SignalR/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.SignalR/ServiceCollectionExtensions.cs index c7ce23b8..f21fc77a 100644 --- a/src/Primitives/CrestApps.Core.SignalR/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.SignalR/ServiceCollectionExtensions.cs @@ -1,5 +1,5 @@ -using CrestApps.Core.SignalR.Services; using CrestApps.Core.Builders; +using CrestApps.Core.SignalR.Services; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection; diff --git a/src/Primitives/CrestApps.Core/Handlers/CatalogEntryHandlerBase.cs b/src/Primitives/CrestApps.Core/Handlers/CatalogEntryHandlerBase.cs index 363b3def..0c60de9e 100644 --- a/src/Primitives/CrestApps.Core/Handlers/CatalogEntryHandlerBase.cs +++ b/src/Primitives/CrestApps.Core/Handlers/CatalogEntryHandlerBase.cs @@ -2,6 +2,7 @@ using CrestApps.Core.Services; namespace CrestApps.Core.Handlers; + public abstract class CatalogEntryHandlerBase : ICatalogEntryHandler { public virtual Task DeletedAsync(DeletedContext context) diff --git a/src/Primitives/CrestApps.Core/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core/ServiceCollectionExtensions.cs index c03abcd7..e78eae46 100644 --- a/src/Primitives/CrestApps.Core/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core/ServiceCollectionExtensions.cs @@ -38,6 +38,16 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service return services; } + public static IServiceCollection AddCatalogManagers(this IServiceCollection services) + { + services.TryAddScoped(typeof(ICatalogManager<>), typeof(CatalogManager<>)); + services.TryAddScoped(typeof(INamedCatalogManager<>), typeof(NamedCatalogManager<>)); + services.TryAddScoped(typeof(ISourceCatalogManager<>), typeof(SourceCatalogManager<>)); + services.TryAddScoped(typeof(INamedSourceCatalogManager<>), typeof(NamedSourceCatalogManager<>)); + + return services; + } + /// /// Registers as a global MVC action filter. /// The filter commits all staged store writes after each controller action completes diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/A2A/Controllers/A2AConnectionController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/A2A/Controllers/A2AConnectionController.cs index 5040fe2a..d5ea7670 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/A2A/Controllers/A2AConnectionController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/A2A/Controllers/A2AConnectionController.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Mvc; namespace CrestApps.Core.Mvc.Web.Areas.A2A.Controllers; + [Area("A2A")] [Authorize(Policy = "Admin")] public sealed class A2AConnectionController : Controller diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIConnectionController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIConnectionController.cs index 43330af6..99276651 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIConnectionController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIConnectionController.cs @@ -1,53 +1,34 @@ using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Services; -using CrestApps.Core.Mvc.Web.Areas.AI.Services; using CrestApps.Core.Mvc.Web.Areas.AI.ViewModels; using CrestApps.Core.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.Extensions.Options; namespace CrestApps.Core.Mvc.Web.Areas.AI.Controllers; + [Area("AI")] [Authorize(Policy = "Admin")] public sealed class AIConnectionController : Controller { - private readonly ICatalog _catalog; - private readonly IConfiguration _configuration; - private readonly MvcAIProviderOptionsStore _providerOptionsStore; - private readonly IOptionsMonitorCache _providerOptionsCache; - private static readonly List _providers = [new("OpenAI", "OpenAI"), new("Azure OpenAI", "Azure"), new("Azure AI Inference (GitHub Models)", "AzureAIInference"), new("Ollama", "Ollama"), ]; - private static readonly List _authTypes = [new("API Key", "ApiKey"), new("Default Azure Credential", "Default"), new("Managed Identity", "ManagedIdentity"), ]; - public AIConnectionController(ICatalog catalog, IConfiguration configuration, MvcAIProviderOptionsStore providerOptionsStore, IOptionsMonitorCache providerOptionsCache) + private readonly INamedSourceCatalog _catalog; + private static readonly List _providers = [new("OpenAI", "OpenAI"), new("Azure OpenAI", "Azure"), new("Azure AI Inference (GitHub Models)", "AzureAIInference"), new("Ollama", "Ollama"),]; + private static readonly List _authTypes = [new("API Key", "ApiKey"), new("Default Azure Credential", "Default"), new("Managed Identity", "ManagedIdentity"),]; + public AIConnectionController(INamedSourceCatalog catalog) { _catalog = catalog; - _configuration = configuration; - _providerOptionsStore = providerOptionsStore; - _providerOptionsCache = providerOptionsCache; } public async Task Index() { var connections = await _catalog.GetAllAsync(); - var configuredConnections = GetConfiguredConnections(); - var configuredKeys = configuredConnections.Keys.ToHashSet(StringComparer.OrdinalIgnoreCase); var models = connections.Select(connection => { var model = AIConnectionViewModel.FromConnection(connection); - model.IsReadOnly = configuredKeys.Contains(BuildConnectionKey(connection.Source, connection.Name)); + model.IsReadOnly = AIConfigurationRecordIds.IsConfigurationConnectionId(connection.ItemId); return model; }).ToList(); - var existingKeys = models.Select(static model => BuildConnectionKey(model.Source, model.Name)).ToHashSet(StringComparer.OrdinalIgnoreCase); - foreach (var(key, connection)in configuredConnections) - { - if (existingKeys.Contains(key)) - { - continue; - } - - models.Add(AIConnectionViewModel.FromConfiguration(AIConfigurationRecordIds.CreateConnectionId(connection.ProviderName, connection.ConnectionName), connection.ConnectionName, connection.DisplayText, connection.ProviderName)); - } return View(models.OrderBy(static model => model.DisplayText ?? model.Name, StringComparer.OrdinalIgnoreCase).ToList()); } @@ -76,6 +57,8 @@ public async Task Create(AIConnectionViewModel model) ModelState.AddModelError(nameof(model.Source), "Provider is required."); } + await ValidateUniqueNameAsync(model.Name); + if (!ModelState.IsValid) { model.Providers = _providers; @@ -92,7 +75,6 @@ public async Task Create(AIConnectionViewModel model) connection.CreatedUtc = DateTime.UtcNow; model.ApplyTo(connection); await _catalog.CreateAsync(connection); - await RefreshProviderOptionsAsync(); return RedirectToAction(nameof(Index)); } @@ -110,7 +92,7 @@ public async Task Edit(string id) return NotFound(); } - if (IsConfigurationBacked(connection)) + if (AIConfigurationRecordIds.IsConfigurationConnectionId(connection.ItemId)) { TempData["ErrorMessage"] = "Connections defined in appsettings are read-only and cannot be edited from the UI."; return RedirectToAction(nameof(Index)); @@ -137,6 +119,8 @@ public async Task Edit(AIConnectionViewModel model) ModelState.AddModelError(nameof(model.Name), "Name is required."); } + await ValidateUniqueNameAsync(model.Name, model.ItemId); + if (!ModelState.IsValid) { model.Providers = _providers; @@ -150,7 +134,7 @@ public async Task Edit(AIConnectionViewModel model) return NotFound(); } - if (IsConfigurationBacked(existing)) + if (AIConfigurationRecordIds.IsConfigurationConnectionId(existing.ItemId)) { TempData["ErrorMessage"] = "Connections defined in appsettings are read-only and cannot be edited from the UI."; return RedirectToAction(nameof(Index)); @@ -158,7 +142,6 @@ public async Task Edit(AIConnectionViewModel model) model.ApplyTo(existing); await _catalog.UpdateAsync(existing); - await RefreshProviderOptionsAsync(); return RedirectToAction(nameof(Index)); } @@ -178,98 +161,27 @@ public async Task Delete(string id) return NotFound(); } - if (IsConfigurationBacked(connection)) + if (AIConfigurationRecordIds.IsConfigurationConnectionId(connection.ItemId)) { TempData["ErrorMessage"] = "Connections defined in appsettings are read-only and cannot be deleted from the UI."; return RedirectToAction(nameof(Index)); } await _catalog.DeleteAsync(connection); - await RefreshProviderOptionsAsync(); return RedirectToAction(nameof(Index)); } - private async Task RefreshProviderOptionsAsync() - { - _providerOptionsStore.Replace(await _catalog.GetAllAsync()); - _providerOptionsCache.TryRemove(Options.DefaultName); - } - - private bool IsConfigurationBacked(AIProviderConnection connection) - { - return GetConfiguredConnections().ContainsKey(BuildConnectionKey(connection.Source, connection.Name)); - } - - private Dictionary GetConfiguredConnections() - { - var connections = new Dictionary(StringComparer.OrdinalIgnoreCase); - ReadTopLevelConnections(connections); - ReadProviderConnections("CrestApps:Providers", connections); - ReadProviderConnections("CrestApps:AI:Providers", connections); - return connections; - } - - private void ReadTopLevelConnections(Dictionary connections) - { - var section = _configuration.GetSection("CrestApps:AI:Connections"); - if (!section.Exists()) - { - return; - } - - foreach (var connectionSection in section.GetChildren()) - { - var connectionName = connectionSection["Name"]; - var providerName = AIProviderNameNormalizer.Normalize(connectionSection["ClientName"]); - if (string.IsNullOrWhiteSpace(connectionName) || string.IsNullOrWhiteSpace(providerName)) - { - continue; - } - - var displayText = connectionSection["ConnectionNameAlias"]; - AddConfiguredConnection(connections, providerName, connectionName, displayText); - } - } - - private void ReadProviderConnections(string sectionPath, Dictionary connections) + private async Task ValidateUniqueNameAsync(string name, string currentItemId = null) { - var section = _configuration.GetSection(sectionPath); - if (!section.Exists()) + if (string.IsNullOrWhiteSpace(name)) { return; } - foreach (var providerSection in section.GetChildren()) + var existing = await _catalog.FindByNameAsync(name); + if (existing != null && !string.Equals(existing.ItemId, currentItemId, StringComparison.OrdinalIgnoreCase)) { - var connectionsSection = providerSection.GetSection("Connections"); - if (!connectionsSection.Exists()) - { - continue; - } - - foreach (var connectionSection in connectionsSection.GetChildren()) - { - if (string.IsNullOrWhiteSpace(connectionSection.Key)) - { - continue; - } - - AddConfiguredConnection(connections, AIProviderNameNormalizer.Normalize(providerSection.Key), connectionSection.Key, connectionSection["ConnectionNameAlias"]); - } + ModelState.AddModelError(nameof(AIConnectionViewModel.Name), "Name must be unique across appsettings and UI connections."); } } - - private static void AddConfiguredConnection(Dictionary connections, string providerName, string connectionName, string displayText) - { - providerName = AIProviderNameNormalizer.Normalize(providerName); - var key = BuildConnectionKey(providerName, connectionName); - connections[key] = new ConfiguredConnectionEntry(providerName, connectionName, string.IsNullOrWhiteSpace(displayText) ? connectionName : displayText); - } - - private static string BuildConnectionKey(string providerName, string connectionName) - { - return $"{AIProviderNameNormalizer.Normalize(providerName)}:{connectionName}"; - } - - private sealed record ConfiguredConnectionEntry(string ProviderName, string ConnectionName, string DisplayText); -} \ No newline at end of file +} diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIDeploymentController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIDeploymentController.cs index ed35b482..89af34b3 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIDeploymentController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIDeploymentController.cs @@ -1,12 +1,10 @@ using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Services; -using CrestApps.Core.Infrastructure; using CrestApps.Core.Mvc.Web.Areas.AI.ViewModels; using CrestApps.Core.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.Extensions.Options; namespace CrestApps.Core.Mvc.Web.Areas.AI.Controllers; @@ -14,8 +12,8 @@ namespace CrestApps.Core.Mvc.Web.Areas.AI.Controllers; [Authorize(Policy = "Admin")] public sealed class AIDeploymentController : Controller { - private readonly ICatalog _deploymentCatalog; - private readonly AIProviderOptions _providerOptions; + private readonly INamedSourceCatalog _deploymentCatalog; + private readonly INamedSourceCatalog _connectionCatalog; private static readonly List _providers = [ @@ -40,11 +38,11 @@ public sealed class AIDeploymentController : Controller }; public AIDeploymentController( - ICatalog deploymentCatalog, - IOptionsSnapshot providerOptions) + INamedSourceCatalog deploymentCatalog, + INamedSourceCatalog connectionCatalog) { _deploymentCatalog = deploymentCatalog; - _providerOptions = providerOptions.Value; + _connectionCatalog = connectionCatalog; } public async Task Index() @@ -90,6 +88,8 @@ public async Task Create(AIDeploymentViewModel model) ModelState.AddModelError(nameof(model.SelectedTypes), "At least one deployment type is required."); } + await ValidateUniqueNameAsync(model.TechnicalName); + if (!ModelState.IsValid) { await PopulateDropdownsAsync(model); @@ -163,6 +163,8 @@ public async Task Edit(AIDeploymentViewModel model) ModelState.AddModelError(nameof(model.SelectedTypes), "At least one deployment type is required."); } + await ValidateUniqueNameAsync(model.TechnicalName, model.ItemId); + if (!ModelState.IsValid) { await PopulateDropdownsAsync(model); @@ -219,27 +221,27 @@ public async Task Delete(string id) return RedirectToAction(nameof(Index)); } - private Task PopulateDropdownsAsync(AIDeploymentViewModel model) + private async Task PopulateDropdownsAsync(AIDeploymentViewModel model) { var selectedProvider = string.IsNullOrWhiteSpace(model.ClientName) ? null : model.ClientName; - model.Connections = _providerOptions.Providers - .Where(provider => selectedProvider is null || provider.Key.Equals(selectedProvider, StringComparison.OrdinalIgnoreCase)) - .Where(static provider => provider.Value.Connections is not null) - .OrderBy(provider => provider.Key, StringComparer.OrdinalIgnoreCase) - .SelectMany(provider => provider.Value.Connections - .OrderBy(connection => connection.Value.GetStringValue("ConnectionNameAlias", false) ?? connection.Key, StringComparer.OrdinalIgnoreCase) - .Select(connection => - { - var connectionAlias = connection.Value.GetStringValue("ConnectionNameAlias", false) ?? connection.Key; - var displayName = selectedProvider is null - ? $"{connectionAlias} ({provider.Key})" - : connectionAlias; - - return new SelectListItem(displayName, connection.Key); - })) + var connections = await _connectionCatalog.GetAllAsync(); + + model.Connections = connections + .Where(connection => selectedProvider is null || string.Equals(connection.ClientName, selectedProvider, StringComparison.OrdinalIgnoreCase)) + .OrderBy(connection => connection.ClientName, StringComparer.OrdinalIgnoreCase) + .ThenBy(connection => connection.DisplayText ?? connection.Name, StringComparer.OrdinalIgnoreCase) + .Select(connection => + { + var connectionAlias = connection.DisplayText ?? connection.Name; + var displayName = selectedProvider is null + ? $"{connectionAlias} ({connection.ClientName})" + : connectionAlias; + + return new SelectListItem(displayName, connection.Name); + }) .ToList(); model.Providers = _providers; model.AuthenticationTypes = _authTypes; @@ -247,7 +249,19 @@ private Task PopulateDropdownsAsync(AIDeploymentViewModel model) .Where(static type => type != AIDeploymentType.None) .Select(static t => new SelectListItem(t.ToString(), t.ToString())) .ToList(); + } - return Task.CompletedTask; + private async Task ValidateUniqueNameAsync(string technicalName, string currentItemId = null) + { + if (string.IsNullOrWhiteSpace(technicalName)) + { + return; + } + + var existing = await _deploymentCatalog.FindByNameAsync(technicalName); + if (existing != null && !string.Equals(existing.ItemId, currentItemId, StringComparison.OrdinalIgnoreCase)) + { + ModelState.AddModelError(nameof(AIDeploymentViewModel.TechnicalName), "Technical name must be unique across appsettings and UI deployments."); + } } } diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs index e6a5db3f..4a87e56a 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs @@ -23,6 +23,7 @@ using Microsoft.Extensions.Options; namespace CrestApps.Core.Mvc.Web.Areas.AI.Controllers; + [Area("AI")] [Authorize(Policy = "Admin")] public sealed class AIProfileController : Controller diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AITemplateController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AITemplateController.cs index 4a91b424..c8c7354f 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AITemplateController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AITemplateController.cs @@ -23,6 +23,7 @@ using Microsoft.Extensions.Options; namespace CrestApps.Core.Mvc.Web.Areas.AI.Controllers; + [Area("AI")] [Authorize(Policy = "Admin")] public sealed class AITemplateController : Controller diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/AIProviderOptionsBuilderExtensions.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/AIProviderOptionsBuilderExtensions.cs new file mode 100644 index 00000000..55c79e6d --- /dev/null +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/AIProviderOptionsBuilderExtensions.cs @@ -0,0 +1,17 @@ +using CrestApps.Core.AI.Models; +using CrestApps.Core.Builders; + +namespace CrestApps.Core.Mvc.Web.Areas.AI.Services; + +public static class AIProviderOptionsBuilderExtensions +{ + public static CrestAppsAISuiteBuilder ConfigureProviderOptions(this CrestAppsAISuiteBuilder builder, IConfigurationSection configuration) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configuration); + + builder.Services.Configure(configuration); + + return builder; + } +} diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/MvcAIProviderOptionsConfiguration.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/MvcAIProviderOptionsConfiguration.cs deleted file mode 100644 index 5b892736..00000000 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/MvcAIProviderOptionsConfiguration.cs +++ /dev/null @@ -1,51 +0,0 @@ -using CrestApps.Core.AI.Models; -using CrestApps.Core.Builders; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; - -namespace CrestApps.Core.Mvc.Web.Areas.AI.Services; - - -/// -/// Projects MVC-managed AI provider connections into -/// so framework AI clients can resolve connection settings from the sample app's -/// YesSql-backed admin UI. -/// -public sealed class MvcAIProviderOptionsConfiguration : IConfigureOptions -{ - private readonly MvcAIProviderOptionsStore _providerOptionsStore; - - public MvcAIProviderOptionsConfiguration( - MvcAIProviderOptionsStore providerOptionsStore) - { - _providerOptionsStore = providerOptionsStore; - } - - public void Configure(AIProviderOptions options) - { - _providerOptionsStore.ApplyTo(options); - } -} - -public static class MvcAIProviderOptionsBuilderExtensions -{ - public static CrestAppsAISuiteBuilder ConfigureProviderOptions(this CrestAppsAISuiteBuilder builder, IConfigurationSection configuration) - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(configuration); - - builder.Services.Configure(configuration); - - return builder; - } - - public static CrestAppsAISuiteBuilder AddMvcProviderOptions(this CrestAppsAISuiteBuilder builder) - { - ArgumentNullException.ThrowIfNull(builder); - - builder.Services.AddSingleton(); - builder.Services.AddTransient, MvcAIProviderOptionsConfiguration>(); - - return builder; - } -} diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/MvcAIProviderOptionsStore.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/MvcAIProviderOptionsStore.cs deleted file mode 100644 index 5c0cd4fe..00000000 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/MvcAIProviderOptionsStore.cs +++ /dev/null @@ -1,91 +0,0 @@ -using CrestApps.Core.AI.Models; -using CrestApps.Core.AI.Services; -using CrestApps.Core.Infrastructure; - -namespace CrestApps.Core.Mvc.Web.Areas.AI.Services; - - -/// -/// Holds the MVC sample's runtime projection of stored AI provider connections. -/// The snapshot is loaded during startup and refreshed after connection changes -/// so can be rebuilt without querying YesSql -/// inside the options pipeline. -/// -public sealed class MvcAIProviderOptionsStore -{ - private readonly object _syncLock = new(); - - private Dictionary _providers = new(StringComparer.OrdinalIgnoreCase); - - public void Replace(IEnumerable connections) - { - var providers = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var group in connections.GroupBy(static connection => AIProviderNameNormalizer.Normalize(connection.ClientName))) - { - if (string.IsNullOrWhiteSpace(group.Key)) - { - continue; - } - - var provider = new AIProvider - { - Connections = new Dictionary(StringComparer.OrdinalIgnoreCase), - }; - - foreach (var connection in group) - { - if (string.IsNullOrWhiteSpace(connection.Name)) - { - continue; - } - - var values = new Dictionary(StringComparer.OrdinalIgnoreCase); - - if (connection.Properties is not null) - { - foreach (var property in connection.Properties) - { - values[property.Key] = property.Value; - } - } - - values["ConnectionNameAlias"] = connection.Name; - - provider.Connections[connection.Name] = new AIProviderConnectionEntry(values); - } - - if (provider.Connections.Count == 0) - { - continue; - } - - providers[group.Key] = provider; - } - - lock (_syncLock) - { - _providers = providers; - } - } - - public void ApplyTo(AIProviderOptions options) - { - Dictionary providers; - - lock (_syncLock) - { - providers = new Dictionary(_providers, StringComparer.OrdinalIgnoreCase); - } - - foreach (var provider in providers) - { - var targetProvider = AIProviderOptionsConnectionMerger.GetOrAddProvider(options, provider.Key); - - foreach (var connection in provider.Value.Connections) - { - AIProviderOptionsConnectionMerger.MergeConnection(targetProvider, connection.Key, connection.Value); - } - } - } -} diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/YesSqlAIDeploymentStore.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/YesSqlAIDeploymentStore.cs deleted file mode 100644 index 6ccfc973..00000000 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/YesSqlAIDeploymentStore.cs +++ /dev/null @@ -1,14 +0,0 @@ -using CrestApps.Core.AI.Deployments; -using CrestApps.Core.AI.Models; -using CrestApps.Core.Data.YesSql.Indexes.AI; -using CrestApps.Core.Data.YesSql.Services; - -namespace CrestApps.Core.Mvc.Web.Areas.AI.Services; - -public sealed class YesSqlAIDeploymentStore : NamedSourceDocumentCatalog, IAIDeploymentStore -{ - public YesSqlAIDeploymentStore(YesSql.ISession session) - : base(session) - { - } -} diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIConnectionViewModel.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIConnectionViewModel.cs index 536d56da..0dfccac1 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIConnectionViewModel.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIConnectionViewModel.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc.Rendering; namespace CrestApps.Core.Mvc.Web.Areas.AI.ViewModels; + public sealed class AIConnectionViewModel { public string ItemId { get; set; } diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIDeploymentViewModel.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIDeploymentViewModel.cs index f8e6ba9d..eee56096 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIDeploymentViewModel.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIDeploymentViewModel.cs @@ -69,7 +69,6 @@ public void ApplyTo(AIDeployment deployment) deployment.Type = GetDeploymentType(); deployment.ConnectionName = ConnectionName; deployment.ClientName = AIProviderNameNormalizer.Normalize(ClientName); - deployment.IsDefault = false; deployment.Properties ??= new Dictionary(); diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Controllers/ChatExtractedDataController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Controllers/ChatExtractedDataController.cs index 927c71e6..dc5aeb3e 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Controllers/ChatExtractedDataController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Controllers/ChatExtractedDataController.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Mvc.Rendering; namespace CrestApps.Core.Mvc.Web.Areas.AIChat.Controllers; + [Area("AIChat")] [Authorize(Policy = "Admin")] public sealed class ChatExtractedDataController : Controller @@ -68,7 +69,7 @@ public async Task Export(ChatExtractedDataIndexViewModel model) private async Task BuildViewModelAsync(ChatExtractedDataIndexViewModel model, bool showReport) { var profiles = await _profileManager.GetAsync(AIProfileType.Chat); - model.Profiles = [new SelectListItem("Select a profile", string.Empty, string.IsNullOrEmpty(model.ProfileId)), ..profiles.OrderBy(profile => profile.DisplayText ?? profile.Name, StringComparer.OrdinalIgnoreCase).Select(profile => new SelectListItem(profile.DisplayText ?? profile.Name, profile.ItemId, profile.ItemId == model.ProfileId)), ]; + model.Profiles = [new SelectListItem("Select a profile", string.Empty, string.IsNullOrEmpty(model.ProfileId)), .. profiles.OrderBy(profile => profile.DisplayText ?? profile.Name, StringComparer.OrdinalIgnoreCase).Select(profile => new SelectListItem(profile.DisplayText ?? profile.Name, profile.ItemId, profile.ItemId == model.ProfileId)),]; model.ShowReport = showReport; return model; } diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Controllers/UsageAnalyticsController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Controllers/UsageAnalyticsController.cs index cfb3c7d7..7b810a4d 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Controllers/UsageAnalyticsController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Controllers/UsageAnalyticsController.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Options; namespace CrestApps.Core.Mvc.Web.Areas.AIChat.Controllers; + [Area("AIChat")] [Authorize(Policy = "Admin")] public sealed class UsageAnalyticsController : Controller diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/MvcAIChatSessionEventPostCloseObserver.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/MvcAIChatSessionEventPostCloseObserver.cs index cc5ff727..59d88d01 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/MvcAIChatSessionEventPostCloseObserver.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/MvcAIChatSessionEventPostCloseObserver.cs @@ -2,6 +2,7 @@ using CrestApps.Core.AI.Models; namespace CrestApps.Core.Mvc.Web.Areas.AIChat.Services; + public sealed class MvcAIChatSessionEventPostCloseObserver : IAIChatSessionAnalyticsRecorder, IAIChatSessionConversionGoalRecorder { private readonly MvcAIChatSessionEventService _eventService; diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/MvcAIChatSessionEventService.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/MvcAIChatSessionEventService.cs index ce795728..9806fa56 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/MvcAIChatSessionEventService.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/MvcAIChatSessionEventService.cs @@ -4,6 +4,7 @@ using ISession = YesSql.ISession; namespace CrestApps.Core.Mvc.Web.Areas.AIChat.Services; + public sealed class MvcAIChatSessionEventService { private readonly ISession _session; diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/MvcAIChatSessionExtractedDataService.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/MvcAIChatSessionExtractedDataService.cs index 8d6ebfcf..69f7fd5c 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/MvcAIChatSessionExtractedDataService.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/MvcAIChatSessionExtractedDataService.cs @@ -5,6 +5,7 @@ using ISession = YesSql.ISession; namespace CrestApps.Core.Mvc.Web.Areas.AIChat.Services; + public sealed class MvcAIChatSessionExtractedDataService : IAIChatSessionExtractedDataRecorder { private readonly ISession _session; 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 00f24d94..23a9a8af 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 @@ -394,12 +394,12 @@ private static IEnumerable BuildGroupedDeploymentItems(IEnumerab var groups = new Dictionary(StringComparer.OrdinalIgnoreCase); return deployments - .OrderBy(d => d.ConnectionNameAlias ?? d.ConnectionName, StringComparer.OrdinalIgnoreCase) + .OrderBy(d => d.ConnectionName, StringComparer.OrdinalIgnoreCase) .ThenBy(d => d.Name, StringComparer.OrdinalIgnoreCase) .Select(d => { SelectListGroup group = null; - var groupKey = d.ConnectionNameAlias ?? d.ConnectionName; + var groupKey = d.ConnectionName; if (!string.IsNullOrEmpty(groupKey) && !groups.TryGetValue(groupKey, out group)) { diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Handlers/ArticleIndexProfileHandler.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Handlers/ArticleIndexProfileHandler.cs index bf1006d2..33d1f4d7 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Handlers/ArticleIndexProfileHandler.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Admin/Handlers/ArticleIndexProfileHandler.cs @@ -5,6 +5,7 @@ using CrestApps.Core.Mvc.Web.Areas.Admin.Services; namespace CrestApps.Core.Mvc.Web.Areas.Admin.Handlers; + internal sealed class ArticleIndexProfileHandler : IndexProfileHandlerBase { private readonly ArticleIndexingService _indexingService; diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Hubs/ChatInteractionHub.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Hubs/ChatInteractionHub.cs index 1597c524..b3252390 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Hubs/ChatInteractionHub.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Hubs/ChatInteractionHub.cs @@ -18,6 +18,7 @@ #pragma warning disable MEAI001 // Speech APIs from Microsoft.Extensions.AI are preview and require explicit opt-in at each usage site. namespace CrestApps.Core.Mvc.Web.Areas.ChatInteractions.Hubs; + [Authorize] public sealed class ChatInteractionHub : ChatInteractionHubBase { @@ -250,7 +251,7 @@ private async Task RunConversationLoopAsync(string itemId, IAsyncEnumerable _channel = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true, SingleWriter = false, }); diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Indexing/Services/IndexProfileTypeRules.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Indexing/Services/IndexProfileTypeRules.cs index ca4aec8e..fc73228d 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Indexing/Services/IndexProfileTypeRules.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Indexing/Services/IndexProfileTypeRules.cs @@ -1,9 +1,10 @@ using CrestApps.Core.Infrastructure.Indexing; namespace CrestApps.Core.Mvc.Web.Areas.Indexing.Services; + public static class IndexProfileTypeRules { - public static readonly string[] EmbeddingTypes = [IndexProfileTypes.AIDocuments, IndexProfileTypes.AIMemory, IndexProfileTypes.DataSource, ]; + public static readonly string[] EmbeddingTypes = [IndexProfileTypes.AIDocuments, IndexProfileTypes.AIMemory, IndexProfileTypes.DataSource,]; public static bool RequiresEmbedding(string type) { return EmbeddingTypes.Contains(type, StringComparer.OrdinalIgnoreCase); diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Mcp/Controllers/McpConnectionController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Mcp/Controllers/McpConnectionController.cs index 08bb53f8..0797765a 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Mcp/Controllers/McpConnectionController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Mcp/Controllers/McpConnectionController.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Mvc; namespace CrestApps.Core.Mvc.Web.Areas.Mcp.Controllers; + [Area("Mcp")] [Authorize(Policy = "Admin")] public sealed class McpConnectionController : Controller diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Mcp/Controllers/McpPromptController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Mcp/Controllers/McpPromptController.cs index b3c3605d..a63ac9af 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Mcp/Controllers/McpPromptController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Mcp/Controllers/McpPromptController.cs @@ -7,6 +7,7 @@ using ModelContextProtocol.Protocol; namespace CrestApps.Core.Mvc.Web.Areas.Mcp.Controllers; + [Area("Mcp")] [Authorize(Policy = "Admin")] public sealed class McpPromptController : Controller @@ -110,7 +111,7 @@ private List ParseArguments(McpPromptViewModel model) if (string.IsNullOrWhiteSpace(model.Arguments)) { - return[]; + return []; } try @@ -129,7 +130,7 @@ private List ParseArguments(McpPromptViewModel model) catch (JsonException) { ModelState.AddModelError(nameof(model.Arguments), "Arguments must be valid JSON."); - return[]; + return []; } } diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Mcp/Controllers/McpResourceController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Mcp/Controllers/McpResourceController.cs index 33911d4f..9c20defd 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Mcp/Controllers/McpResourceController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Mcp/Controllers/McpResourceController.cs @@ -12,6 +12,7 @@ using ModelContextProtocol.Protocol; namespace CrestApps.Core.Mvc.Web.Areas.Mcp.Controllers; + [Area("Mcp")] [Authorize(Policy = "Admin")] public sealed class McpResourceController : Controller diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Program.cs b/src/Startup/CrestApps.Core.Mvc.Web/Program.cs index 5a835105..7044a62f 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Program.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Program.cs @@ -9,7 +9,6 @@ using CrestApps.Core.AI.Markdown; using CrestApps.Core.AI.Mcp; using CrestApps.Core.AI.Mcp.Models; -using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Ollama; using CrestApps.Core.AI.OpenAI; using CrestApps.Core.AI.OpenAI.Azure; @@ -32,12 +31,10 @@ using CrestApps.Core.Mvc.Web.Areas.DataSources.Services; using CrestApps.Core.Mvc.Web.Services; using CrestApps.Core.Mvc.Web.Tools; -using CrestApps.Core.Services; using CrestApps.Core.SignalR; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Options; using NLog.Web; // ============================================================================= @@ -128,7 +125,6 @@ builder.Services.AddCrestAppsCore(crestApps => crestApps .AddAISuite(ai => ai .ConfigureProviderOptions(builder.Configuration.GetSection("CrestApps:AI:Providers")) - .AddMvcProviderOptions() // Optional AI features layered on top of the core AI + orchestration runtime. .AddMarkdown() .AddCopilotOrchestrator() @@ -288,14 +284,6 @@ // Seed sample articles on first run. await app.Services.SeedArticlesAsync(); -using (var scope = app.Services.CreateScope()) -{ - var providerConnections = await scope.ServiceProvider.GetRequiredService>().GetAllAsync(); - app.Services.GetRequiredService().Replace(providerConnections); -} - -app.Services.GetRequiredService>().TryRemove(Options.DefaultName); -_ = app.Services.GetRequiredService>().Value; // ============================================================================= // 13. MIDDLEWARE PIPELINE // ============================================================================= diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Services/AppDataConfigurationFileService.cs b/src/Startup/CrestApps.Core.Mvc.Web/Services/AppDataConfigurationFileService.cs index 8a012afa..6ff51695 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Services/AppDataConfigurationFileService.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Services/AppDataConfigurationFileService.cs @@ -55,13 +55,13 @@ private async Task ReadRootAsync() { if (!File.Exists(FilePath)) { - return[]; + return []; } var json = await File.ReadAllTextAsync(FilePath); if (string.IsNullOrWhiteSpace(json)) { - return[]; + return []; } return JsonNode.Parse(json) as JsonObject ?? []; diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs b/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs index 72635f88..94409b74 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs @@ -65,8 +65,57 @@ public static IServiceCollection AddCoreYesSqlDataStore(this IServiceCollection Data.YesSql.ServiceCollectionExtensions.AddCoreYesSqlDataStore(services, configuration => configuration.UseSqLite(connectionStringBuilder.ToString()).SetTablePrefix("CA_")); // YesSql-backed catalogs and managers. - services.AddNamedSourceDocumentCatalog().AddNamedSourceDocumentCatalog().AddDocumentCatalog().AddSourceDocumentCatalog().AddNamedDocumentCatalog().AddSourceDocumentCatalog().AddNamedSourceDocumentCatalog().AddScoped().AddScoped(sp => sp.GetRequiredService()).AddScoped>(sp => sp.GetRequiredService()).AddScoped>(sp => sp.GetRequiredService()).AddScoped().AddScoped(sp => sp.GetRequiredService()).AddScoped>(sp => sp.GetRequiredService()).AddScoped().AddScoped().AddScoped().AddScoped().AddScoped().AddScoped().AddScoped().AddScoped().AddScoped().AddScoped(sp => sp.GetRequiredService()).AddScoped(sp => sp.GetRequiredService()).AddScoped(sp => sp.GetRequiredService()).AddScoped(sp => sp.GetRequiredService()).AddScoped().AddScoped().AddScoped().AddScoped().AddScoped().AddScoped>(sp => sp.GetRequiredService()).AddScoped, CatalogManager>().AddScoped().AddScoped, AIMemoryEntryIndexingHandler>().AddScoped().AddScoped().AddScoped().AddScoped().AddScoped().AddDocumentCatalog().AddScoped, CatalogManager>().AddScoped().AddDocumentCatalog().AddScoped, CatalogManager
>().AddScoped, AIDataSourceIndexingHandler>().AddScoped, ArticleIndexingHandler>().AddScoped(); - services.AddScoped().AddScoped(sp => sp.GetRequiredService()).AddScoped().AddScoped>(sp => sp.GetRequiredService()).AddScoped>(sp => sp.GetRequiredService()).AddScoped>(sp => sp.GetRequiredService()); + services.AddNamedSourceDocumentCatalog() + .AddNamedSourceDocumentCatalog() + .AddDocumentCatalog() + .AddSourceDocumentCatalog() + .AddNamedDocumentCatalog() + .AddSourceDocumentCatalog() + .AddNamedSourceDocumentCatalog() + .AddScoped() + .AddScoped(sp => sp.GetRequiredService()) + .AddScoped>(sp => sp.GetRequiredService()) + .AddScoped>(sp => sp.GetRequiredService()) + .AddScoped() + .AddScoped(sp => sp.GetRequiredService()) + .AddScoped>(sp => sp.GetRequiredService()) + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped(sp => sp.GetRequiredService()) + .AddScoped(sp => sp.GetRequiredService()) + .AddScoped(sp => sp.GetRequiredService()) + .AddScoped(sp => sp.GetRequiredService()) + .AddScoped().AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped>(sp => sp.GetRequiredService()) + .AddScoped() + .AddScoped, AIMemoryEntryIndexingHandler>() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddDocumentCatalog() + .AddScoped() + .AddDocumentCatalog() + .AddScoped, AIDataSourceIndexingHandler>() + .AddScoped, ArticleIndexingHandler>() + .AddScoped(); + + services.AddKeyedScoped, CrestApps.Core.Data.YesSql.Services.NamedSourceDocumentCatalog>(ConfigurationAIProviderConnectionCatalog.PersistedCatalogKey) + .AddNamedSourceDocumentCatalog(); + + services.AddKeyedScoped, CrestApps.Core.Data.YesSql.Services.NamedSourceDocumentCatalog>(ConfigurationAIDeploymentCatalog.PersistedCatalogKey) + .AddNamedSourceDocumentCatalog(); return services; } diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Tools/CalculatorTool.cs b/src/Startup/CrestApps.Core.Mvc.Web/Tools/CalculatorTool.cs index 59fdb781..34e740f6 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Tools/CalculatorTool.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Tools/CalculatorTool.cs @@ -66,7 +66,7 @@ protected override ValueTask InvokeCoreAsync(AIFunctionArguments argumen return ValueTask.FromResult("""{"error":"Parameters 'a' and 'b' must be numbers."}"""); } - var(result, error) = operation.ToLowerInvariant() switch + var (result, error) = operation.ToLowerInvariant() switch { "add" => (a + b, (string)null), "subtract" => (a - b, null), diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Tools/SendEmailTool.cs b/src/Startup/CrestApps.Core.Mvc.Web/Tools/SendEmailTool.cs index 50e1ca3a..7b318c94 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Tools/SendEmailTool.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Tools/SendEmailTool.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.AI; namespace CrestApps.Core.Mvc.Web.Tools; + public sealed class SendEmailTool : AIFunction { public const string TheName = "sendEmail"; diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs b/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs index b626b7f7..818b8005 100644 --- a/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs @@ -2,10 +2,10 @@ using CrestApps.Core.AI.A2A.Models; using CrestApps.Core.AI.Chat; using CrestApps.Core.AI.DataSources; -using CrestApps.Core.AI.Deployments; using CrestApps.Core.AI.Mcp.Models; using CrestApps.Core.AI.Memory; using CrestApps.Core.AI.Models; +using CrestApps.Core.AI.Services; using CrestApps.Core.Builders; using CrestApps.Core.Data.EntityCore.Services; using CrestApps.Core.Infrastructure.Indexing; @@ -17,6 +17,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; namespace CrestApps.Core.Data.EntityCore; + public static class ServiceCollectionExtensions { public static IServiceCollection AddCoreEntityCoreDataStore(this IServiceCollection services, Action configure, Action configureStore = null) @@ -45,6 +46,7 @@ public static IServiceCollection AddDocumentCatalogs(this IServiceCollection ser services.TryAddScoped(typeof(INamedCatalog<>), typeof(NamedDocumentCatalog<>)); services.TryAddScoped(typeof(ISourceCatalog<>), typeof(SourceDocumentCatalog<>)); services.TryAddScoped(typeof(INamedSourceCatalog<>), typeof(NamedSourceDocumentCatalog<>)); + return services; } @@ -90,8 +92,68 @@ public static IServiceCollection AddNamedSourceDocumentCatalog(this ISer return services; } + public static IServiceCollection AddDocumentCatalog(this IServiceCollection services, string collection = null) + where TModel : CatalogItem + where T : class, ICatalog + { + _ = collection; + + services.RemoveAll>(); + services.AddScoped(); + services.AddScoped>(sp => sp.GetRequiredService()); + return services; + } + + public static IServiceCollection AddNamedDocumentCatalog(this IServiceCollection services, string collection = null) + where TModel : CatalogItem, INameAwareModel + where T : class, INamedCatalog + { + _ = collection; + + services.RemoveAll>(); + services.RemoveAll>(); + services.AddScoped(); + services.AddScoped>(sp => sp.GetRequiredService()); + services.AddScoped>(sp => sp.GetRequiredService()); + return services; + } + + public static IServiceCollection AddSourceDocumentCatalog(this IServiceCollection services, string collection = null) + where TModel : CatalogItem, ISourceAwareModel + where T : class, ISourceCatalog + { + _ = collection; + + services.RemoveAll>(); + services.RemoveAll>(); + services.AddScoped(); + services.AddScoped>(sp => sp.GetRequiredService()); + services.AddScoped>(sp => sp.GetRequiredService()); + return services; + } + + public static IServiceCollection AddNamedSourceDocumentCatalog(this IServiceCollection services, string collection = null) + where TModel : CatalogItem, INameAwareModel, ISourceAwareModel + where T : class, INamedSourceCatalog + { + _ = collection; + + services.RemoveAll>(); + services.RemoveAll>(); + services.RemoveAll>(); + services.RemoveAll>(); + services.AddScoped(); + services.AddScoped>(sp => sp.GetRequiredService()); + services.AddScoped>(sp => sp.GetRequiredService()); + services.AddScoped>(sp => sp.GetRequiredService()); + services.AddScoped>(sp => sp.GetRequiredService()); + return services; + } + public static IServiceCollection AddCoreEntityCoreStores(this IServiceCollection services) { + services.AddCatalogManagers(); + // Catalog registrations services.AddNamedSourceDocumentCatalog(); services.AddNamedSourceDocumentCatalog(); @@ -127,12 +189,11 @@ public static IServiceCollection AddCoreEntityCoreStores(this IServiceCollection services.AddScoped(); services.AddScoped>(sp => sp.GetRequiredService()); - // Deployment store (implements named + source catalog interfaces) - services.AddScoped(); - services.AddScoped>(sp => sp.GetRequiredService()); - services.AddScoped>(sp => sp.GetRequiredService()); - services.AddScoped>(sp => sp.GetRequiredService()); - services.AddScoped>(sp => sp.GetRequiredService()); + services.AddKeyedScoped, Services.NamedSourceDocumentCatalog>(ConfigurationAIProviderConnectionCatalog.PersistedCatalogKey); + services.AddNamedSourceDocumentCatalog(); + + services.AddKeyedScoped, Services.NamedSourceDocumentCatalog>(ConfigurationAIDeploymentCatalog.PersistedCatalogKey); + services.AddNamedSourceDocumentCatalog(); return services; } diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/Services/CatalogRecordFactory.cs b/src/Stores/CrestApps.Core.Data.EntityCore/Services/CatalogRecordFactory.cs index 57796f7d..a590dc90 100644 --- a/src/Stores/CrestApps.Core.Data.EntityCore/Services/CatalogRecordFactory.cs +++ b/src/Stores/CrestApps.Core.Data.EntityCore/Services/CatalogRecordFactory.cs @@ -4,6 +4,7 @@ using CrestApps.Core.Models; namespace CrestApps.Core.Data.EntityCore.Services; + internal static class CatalogRecordFactory { public static string GetEntityType() diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/Services/DocumentCatalog.cs b/src/Stores/CrestApps.Core.Data.EntityCore/Services/DocumentCatalog.cs index 2e88adab..109b11de 100644 --- a/src/Stores/CrestApps.Core.Data.EntityCore/Services/DocumentCatalog.cs +++ b/src/Stores/CrestApps.Core.Data.EntityCore/Services/DocumentCatalog.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore; namespace CrestApps.Core.Data.EntityCore.Services; + public class DocumentCatalog : ICatalog where T : CatalogItem { protected readonly CrestAppsEntityDbContext DbContext; @@ -40,7 +41,7 @@ public async ValueTask> GetAsync(IEnumerable ids) var itemIds = ids.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().ToArray(); if (itemIds.Length == 0) { - return[]; + return []; } var records = await GetReadQuery().Where(x => itemIds.Contains(x.ItemId)).ToListAsync(); diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIChatSessionManager.cs b/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIChatSessionManager.cs index 0ea0e894..c42b517d 100644 --- a/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIChatSessionManager.cs +++ b/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIChatSessionManager.cs @@ -1,5 +1,4 @@ using System.Security.Claims; -using CrestApps.Core.AI; using CrestApps.Core.AI.Chat; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.ResponseHandling; @@ -9,6 +8,7 @@ using Microsoft.Extensions.AI; namespace CrestApps.Core.Data.EntityCore.Services; + public sealed class EntityCoreAIChatSessionManager : IAIChatSessionManager { private readonly IHttpContextAccessor _httpContextAccessor; diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIDeploymentStore.cs b/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIDeploymentStore.cs deleted file mode 100644 index ed619e91..00000000 --- a/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIDeploymentStore.cs +++ /dev/null @@ -1,12 +0,0 @@ -using CrestApps.Core.AI.Deployments; -using CrestApps.Core.AI.Models; - -namespace CrestApps.Core.Data.EntityCore.Services; - -public sealed class EntityCoreAIDeploymentStore : NamedSourceDocumentCatalog, IAIDeploymentStore -{ - public EntityCoreAIDeploymentStore(CrestAppsEntityDbContext dbContext) - : base(dbContext) - { - } -} diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreSearchIndexProfileStore.cs b/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreSearchIndexProfileStore.cs index 1df19e43..c23da541 100644 --- a/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreSearchIndexProfileStore.cs +++ b/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreSearchIndexProfileStore.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore; namespace CrestApps.Core.Data.EntityCore.Services; + public sealed class EntityCoreSearchIndexProfileStore : NamedDocumentCatalog, ISearchIndexProfileStore { public EntityCoreSearchIndexProfileStore(CrestAppsEntityDbContext dbContext) : base(dbContext) diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreStoreSerializer.cs b/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreStoreSerializer.cs index e50fd5e0..79b36e62 100644 --- a/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreStoreSerializer.cs +++ b/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreStoreSerializer.cs @@ -1,6 +1,7 @@ using System.Text.Json; namespace CrestApps.Core.Data.EntityCore.Services; + internal static class EntityCoreStoreSerializer { private static readonly JsonSerializerOptions _jsonSerializerOptions = new() diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AI/AIDeploymentIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AI/AIDeploymentIndexSchemaBuilderExtensions.cs index 74a31b42..671e0f9e 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AI/AIDeploymentIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AI/AIDeploymentIndexSchemaBuilderExtensions.cs @@ -1,6 +1,7 @@ using YesSql.Sql; namespace CrestApps.Core.Data.YesSql.Indexes.AI; + public static class AIDeploymentIndexSchemaBuilderExtensions { public static Task CreateAIDeploymentIndexSchemaAsync(this ISchemaBuilder schemaBuilder) diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AI/AIProfileIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AI/AIProfileIndexSchemaBuilderExtensions.cs index 136096fa..d7828724 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AI/AIProfileIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AI/AIProfileIndexSchemaBuilderExtensions.cs @@ -1,6 +1,7 @@ using YesSql.Sql; namespace CrestApps.Core.Data.YesSql.Indexes.AI; + public static class AIProfileIndexSchemaBuilderExtensions { public static Task CreateAIProfileIndexSchemaAsync(this ISchemaBuilder schemaBuilder) diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AI/AIProfileTemplateIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AI/AIProfileTemplateIndexSchemaBuilderExtensions.cs index 883cbbca..658be54b 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AI/AIProfileTemplateIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AI/AIProfileTemplateIndexSchemaBuilderExtensions.cs @@ -1,6 +1,7 @@ using YesSql.Sql; namespace CrestApps.Core.Data.YesSql.Indexes.AI; + public static class AIProfileTemplateIndexSchemaBuilderExtensions { public static Task CreateAIProfileTemplateIndexSchemaAsync(this ISchemaBuilder schemaBuilder) diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AI/AIProviderConnectionIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AI/AIProviderConnectionIndexSchemaBuilderExtensions.cs index 5209a9e6..ff16ec65 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AI/AIProviderConnectionIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AI/AIProviderConnectionIndexSchemaBuilderExtensions.cs @@ -1,6 +1,7 @@ using YesSql.Sql; namespace CrestApps.Core.Data.YesSql.Indexes.AI; + public static class AIProviderConnectionIndexSchemaBuilderExtensions { public static Task CreateAIProviderConnectionIndexSchemaAsync(this ISchemaBuilder schemaBuilder) diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AIChatSessionExtractedDataIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AIChatSessionExtractedDataIndexSchemaBuilderExtensions.cs index ad58e91b..7edc0bed 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AIChatSessionExtractedDataIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AIChatSessionExtractedDataIndexSchemaBuilderExtensions.cs @@ -1,6 +1,7 @@ using YesSql.Sql; namespace CrestApps.Core.Data.YesSql.Indexes.AIChat; + public static class AIChatSessionExtractedDataIndexSchemaBuilderExtensions { public static Task CreateAIChatSessionExtractedDataIndexSchemaAsync(this ISchemaBuilder schemaBuilder) diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AIChatSessionIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AIChatSessionIndexSchemaBuilderExtensions.cs index 18012a90..fd2af656 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AIChatSessionIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AIChatSessionIndexSchemaBuilderExtensions.cs @@ -1,6 +1,7 @@ using YesSql.Sql; namespace CrestApps.Core.Data.YesSql.Indexes.AIChat; + public static class AIChatSessionIndexSchemaBuilderExtensions { public static Task CreateAIChatSessionIndexSchemaAsync(this ISchemaBuilder schemaBuilder) diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AIChatSessionMetricsIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AIChatSessionMetricsIndexSchemaBuilderExtensions.cs index ed692155..f0f756f6 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AIChatSessionMetricsIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AIChatSessionMetricsIndexSchemaBuilderExtensions.cs @@ -1,6 +1,7 @@ using YesSql.Sql; namespace CrestApps.Core.Data.YesSql.Indexes.AIChat; + public static class AIChatSessionMetricsIndexSchemaBuilderExtensions { public static async Task CreateAIChatSessionMetricsSchemaAsync(this ISchemaBuilder schemaBuilder, AIChatSessionMetricsIndexSchemaOptions options = null) diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AIChatSessionPromptIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AIChatSessionPromptIndexSchemaBuilderExtensions.cs index 6cbad094..1b6ec143 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AIChatSessionPromptIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AIChatSessionPromptIndexSchemaBuilderExtensions.cs @@ -1,6 +1,7 @@ using YesSql.Sql; namespace CrestApps.Core.Data.YesSql.Indexes.AIChat; + public static class AIChatSessionPromptIndexSchemaBuilderExtensions { public static Task CreateAIChatSessionPromptIndexSchemaAsync(this ISchemaBuilder schemaBuilder) diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AICompletionUsageIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AICompletionUsageIndexSchemaBuilderExtensions.cs index 6d4cbfc2..f76a471c 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AICompletionUsageIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AICompletionUsageIndexSchemaBuilderExtensions.cs @@ -1,6 +1,7 @@ using YesSql.Sql; namespace CrestApps.Core.Data.YesSql.Indexes.AIChat; + public static class AICompletionUsageIndexSchemaBuilderExtensions { public static Task CreateAICompletionUsageIndexSchemaAsync(this ISchemaBuilder schemaBuilder) diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIMemory/AIMemoryEntryIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIMemory/AIMemoryEntryIndexSchemaBuilderExtensions.cs index 80c1e4fa..524f7712 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIMemory/AIMemoryEntryIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIMemory/AIMemoryEntryIndexSchemaBuilderExtensions.cs @@ -1,6 +1,7 @@ using YesSql.Sql; namespace CrestApps.Core.Data.YesSql.Indexes.AIMemory; + public static class AIMemoryEntryIndexSchemaBuilderExtensions { public static Task CreateAIMemoryEntryIndexSchemaAsync(this ISchemaBuilder schemaBuilder) diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/ChatInteractions/ChatInteractionIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/ChatInteractions/ChatInteractionIndexSchemaBuilderExtensions.cs index 97705e00..346d5347 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/ChatInteractions/ChatInteractionIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/ChatInteractions/ChatInteractionIndexSchemaBuilderExtensions.cs @@ -1,6 +1,7 @@ using YesSql.Sql; namespace CrestApps.Core.Data.YesSql.Indexes.ChatInteractions; + public static class ChatInteractionIndexSchemaBuilderExtensions { public static Task CreateChatInteractionIndexSchemaAsync(this ISchemaBuilder schemaBuilder) diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/ChatInteractions/ChatInteractionPromptIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/ChatInteractions/ChatInteractionPromptIndexSchemaBuilderExtensions.cs index 3102b2f6..804b61a4 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/ChatInteractions/ChatInteractionPromptIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/ChatInteractions/ChatInteractionPromptIndexSchemaBuilderExtensions.cs @@ -1,6 +1,7 @@ using YesSql.Sql; namespace CrestApps.Core.Data.YesSql.Indexes.ChatInteractions; + public static class ChatInteractionPromptIndexSchemaBuilderExtensions { public static Task CreateChatInteractionPromptIndexSchemaAsync(this ISchemaBuilder schemaBuilder) diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/DataSources/AIDataSourceIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/DataSources/AIDataSourceIndexSchemaBuilderExtensions.cs index b912c09b..cad5b9b3 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/DataSources/AIDataSourceIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/DataSources/AIDataSourceIndexSchemaBuilderExtensions.cs @@ -1,6 +1,7 @@ using YesSql.Sql; namespace CrestApps.Core.Data.YesSql.Indexes.DataSources; + public static class AIDataSourceIndexSchemaBuilderExtensions { public static Task CreateAIDataSourceIndexSchemaAsync(this ISchemaBuilder schemaBuilder) diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Indexing/AIDocumentChunkIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Indexing/AIDocumentChunkIndexSchemaBuilderExtensions.cs index 42ea1f2c..01fb0bdb 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Indexing/AIDocumentChunkIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Indexing/AIDocumentChunkIndexSchemaBuilderExtensions.cs @@ -1,6 +1,7 @@ using YesSql.Sql; namespace CrestApps.Core.Data.YesSql.Indexes.Indexing; + public static class AIDocumentChunkIndexSchemaBuilderExtensions { public static Task CreateAIDocumentChunkIndexSchemaAsync(this ISchemaBuilder schemaBuilder) diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Indexing/AIDocumentIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Indexing/AIDocumentIndexSchemaBuilderExtensions.cs index 887a6ed6..58a7f71a 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Indexing/AIDocumentIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Indexing/AIDocumentIndexSchemaBuilderExtensions.cs @@ -1,6 +1,7 @@ using YesSql.Sql; namespace CrestApps.Core.Data.YesSql.Indexes.Indexing; + public static class AIDocumentIndexSchemaBuilderExtensions { public static Task CreateAIDocumentIndexSchemaAsync(this ISchemaBuilder schemaBuilder) diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Indexing/SearchIndexProfileIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Indexing/SearchIndexProfileIndexSchemaBuilderExtensions.cs index e352cc99..5f106124 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Indexing/SearchIndexProfileIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Indexing/SearchIndexProfileIndexSchemaBuilderExtensions.cs @@ -1,6 +1,7 @@ using YesSql.Sql; namespace CrestApps.Core.Data.YesSql.Indexes.Indexing; + public static class SearchIndexProfileIndexSchemaBuilderExtensions { public static Task CreateSearchIndexProfileIndexSchemaAsync(this ISchemaBuilder schemaBuilder) diff --git a/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs index 2c8a500a..207614e4 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/ServiceCollectionExtensions.cs @@ -1,6 +1,6 @@ +using CrestApps.Core.Builders; using CrestApps.Core.Data.YesSql.Indexes; using CrestApps.Core.Data.YesSql.Services; -using CrestApps.Core.Builders; using CrestApps.Core.Models; using CrestApps.Core.Services; using Microsoft.Extensions.DependencyInjection; @@ -14,12 +14,11 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddCoreYesSqlDataStore(this IServiceCollection services, Func configure) { - services.AddSingleton(_ => StoreFactory.CreateAndInitializeAsync(configure(new Configuration())) - .GetAwaiter() - .GetResult()); + services.AddSingleton(_ => StoreFactory.CreateAndInitializeAsync(configure(new Configuration())).GetAwaiter().GetResult()); services.AddScoped(sp => sp.GetRequiredService().CreateSession()); services.AddScoped(); + services.AddCatalogManagers(); return services; } @@ -69,8 +68,7 @@ public static IServiceCollection AddNamedDocumentCatalog(this IS return new NamedDocumentCatalog(session, collection); }); - services.AddScoped>(sp => - (INamedCatalog)sp.GetRequiredService>()); + services.AddScoped>(sp => (INamedCatalog)sp.GetRequiredService>()); return services; } @@ -89,8 +87,7 @@ public static IServiceCollection AddSourceDocumentCatalog(this I return new SourceDocumentCatalog(session, collection); }); - services.AddScoped>(sp => - (ISourceCatalog)sp.GetRequiredService>()); + services.AddScoped>(sp => (ISourceCatalog)sp.GetRequiredService>()); return services; } @@ -111,14 +108,71 @@ public static IServiceCollection AddNamedSourceDocumentCatalog(t return new NamedSourceDocumentCatalog(session, collection); }); - services.AddScoped>(sp => - (INamedCatalog)sp.GetRequiredService>()); + services.AddScoped>(sp => (INamedCatalog)sp.GetRequiredService>()); + services.AddScoped>(sp => (ISourceCatalog)sp.GetRequiredService>()); + services.AddScoped>(sp => (INamedSourceCatalog)sp.GetRequiredService>()); + + return services; + } + + public static IServiceCollection AddDocumentCatalog(this IServiceCollection services, string collection = null) + where TModel : CatalogItem + where TIndex : CatalogItemIndex + where T : class, ICatalog + { + services.RemoveAll>(); + + services.AddScoped(); + services.AddScoped>(sp => sp.GetRequiredService()); + + return services; + } + + public static IServiceCollection AddNamedDocumentCatalog(this IServiceCollection services, string collection = null) + where TModel : CatalogItem, INameAwareModel + where TIndex : CatalogItemIndex, INameAwareIndex + where T : class, INamedCatalog + { + services.RemoveAll>(); + services.RemoveAll>(); + + services.AddScoped(); + services.AddScoped>(sp => sp.GetRequiredService()); + services.AddScoped>(sp => sp.GetRequiredService()); - services.AddScoped>(sp => - (ISourceCatalog)sp.GetRequiredService>()); + return services; + } + + public static IServiceCollection AddSourceDocumentCatalog(this IServiceCollection services, string collection = null) + where TModel : CatalogItem, ISourceAwareModel + where TIndex : CatalogItemIndex, ISourceAwareIndex + where T : class, ISourceCatalog + { + services.RemoveAll>(); + services.RemoveAll>(); + + services.AddScoped(); + services.AddScoped>(sp => sp.GetRequiredService()); + services.AddScoped>(sp => sp.GetRequiredService()); + + return services; + } + + public static IServiceCollection AddNamedSourceDocumentCatalog(this IServiceCollection services, string collection = null) + where TModel : CatalogItem, INameAwareModel, ISourceAwareModel + where TIndex : CatalogItemIndex, INameAwareIndex, ISourceAwareIndex + where T : class, INamedSourceCatalog + { + services.RemoveAll>(); + services.RemoveAll>(); + services.RemoveAll>(); + services.RemoveAll>(); - services.AddScoped>(sp => - (INamedSourceCatalog)sp.GetRequiredService>()); + services.AddScoped(); + services.AddScoped>(sp => sp.GetRequiredService()); + services.AddScoped>(sp => sp.GetRequiredService()); + services.AddScoped>(sp => sp.GetRequiredService()); + services.AddScoped>(sp => sp.GetRequiredService()); return services; } diff --git a/src/Utilities/CrestApps.Core.Support/Json/BidirectionalJsonStringEnumConverterFactory.cs b/src/Utilities/CrestApps.Core.Support/Json/BidirectionalJsonStringEnumConverterFactory.cs index f5b2e452..e715a3d2 100644 --- a/src/Utilities/CrestApps.Core.Support/Json/BidirectionalJsonStringEnumConverterFactory.cs +++ b/src/Utilities/CrestApps.Core.Support/Json/BidirectionalJsonStringEnumConverterFactory.cs @@ -2,6 +2,7 @@ using System.Text.Json.Serialization; namespace CrestApps.Core.Support.Json; + public class BidirectionalJsonStringEnumConverterFactory : JsonConverterFactory { private readonly JsonStringEnumConverter _converter; diff --git a/src/Utilities/CrestApps.Core.Support/StringExtensions.cs b/src/Utilities/CrestApps.Core.Support/StringExtensions.cs index b05fa5d9..372da5d9 100644 --- a/src/Utilities/CrestApps.Core.Support/StringExtensions.cs +++ b/src/Utilities/CrestApps.Core.Support/StringExtensions.cs @@ -1,6 +1,7 @@ using System.Text.RegularExpressions; namespace CrestApps.Core.Support; + public static class StringExtensions { /// diff --git a/tests/CrestApps.Core.Tests/AITemplates/Prompting/AITemplateBuilderTests.cs b/tests/CrestApps.Core.Tests/AITemplates/Prompting/AITemplateBuilderTests.cs index abf3d5b5..db85dfd3 100644 --- a/tests/CrestApps.Core.Tests/AITemplates/Prompting/AITemplateBuilderTests.cs +++ b/tests/CrestApps.Core.Tests/AITemplates/Prompting/AITemplateBuilderTests.cs @@ -3,6 +3,7 @@ using CrestApps.Core.Templates.Services; namespace CrestApps.OrchardCore.Tests.AI.Prompting; + public sealed class AITemplateBuilderTests { [Fact] diff --git a/tests/CrestApps.Core.Tests/AITemplates/Prompting/RenderAITemplateTagTests.cs b/tests/CrestApps.Core.Tests/AITemplates/Prompting/RenderAITemplateTagTests.cs index 47f3fbbd..403ca608 100644 --- a/tests/CrestApps.Core.Tests/AITemplates/Prompting/RenderAITemplateTagTests.cs +++ b/tests/CrestApps.Core.Tests/AITemplates/Prompting/RenderAITemplateTagTests.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions; namespace CrestApps.OrchardCore.Tests.AI.Prompting; + public sealed class RenderAITemplateTagTests { [Fact] diff --git a/tests/CrestApps.Core.Tests/ChatNotifications/EndSessionNotificationActionHandlerTests.cs b/tests/CrestApps.Core.Tests/ChatNotifications/EndSessionNotificationActionHandlerTests.cs index 1789a284..a087e49e 100644 --- a/tests/CrestApps.Core.Tests/ChatNotifications/EndSessionNotificationActionHandlerTests.cs +++ b/tests/CrestApps.Core.Tests/ChatNotifications/EndSessionNotificationActionHandlerTests.cs @@ -8,6 +8,7 @@ using Moq; namespace CrestApps.OrchardCore.Tests.ChatNotifications; + public sealed class EndSessionNotificationActionHandlerTests { // ─────────────────────────────────────────────────────────────── @@ -138,7 +139,7 @@ public LocalizedString this[string name] public IEnumerable GetAllStrings(bool includeParentCultures) { - return[]; + return []; } } } \ No newline at end of file diff --git a/tests/CrestApps.Core.Tests/Core/Chat/DocumentPreemptiveRagHandlerTests.cs b/tests/CrestApps.Core.Tests/Core/Chat/DocumentPreemptiveRagHandlerTests.cs index b4fa687c..76dae959 100644 --- a/tests/CrestApps.Core.Tests/Core/Chat/DocumentPreemptiveRagHandlerTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Chat/DocumentPreemptiveRagHandlerTests.cs @@ -16,6 +16,7 @@ #pragma warning disable MEAI001 namespace CrestApps.OrchardCore.Tests.Core.Chat; + public sealed class DocumentPreemptiveRagHandlerTests { [Fact] @@ -32,7 +33,7 @@ public async Task HandleAsync_ProfileKnowledgeDocuments_InjectsRetrievedChunksAn 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, 3, 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, and Carla wins the race.", }, }, ]); + vectorSearchService.Setup(service => service.SearchAsync(indexProfile, It.IsAny(), "profile-1", AIReferenceTypes.Document.Profile, 3, 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, and Carla wins the race.", }, },]); var services = new ServiceCollection().AddSingleton(new FakeAIClientFactory(new FakeEmbeddingGenerator([0.1f, 0.2f]))).AddSingleton(deploymentManager.Object).AddSingleton(indexProfileStore.Object).AddSingleton().AddSingleton>(Options.Create(new InteractionDocumentOptions { IndexProfileName = "docs-index", TopN = 3, })).AddLogging().AddKeyedSingleton("test-provider", vectorSearchService.Object).AddCoreAIDocumentProcessing().BuildServiceProvider(); var handler = services.GetServices().Single(); var profile = new AIProfile diff --git a/tests/CrestApps.Core.Tests/Core/Chat/ExtractedDataOrchestrationHandlerTests.cs b/tests/CrestApps.Core.Tests/Core/Chat/ExtractedDataOrchestrationHandlerTests.cs index 49529162..5cc31c38 100644 --- a/tests/CrestApps.Core.Tests/Core/Chat/ExtractedDataOrchestrationHandlerTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Chat/ExtractedDataOrchestrationHandlerTests.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging.Abstractions; namespace CrestApps.OrchardCore.Tests.Core.Chat; + public sealed class ExtractedDataOrchestrationHandlerTests { [Fact] diff --git a/tests/CrestApps.Core.Tests/Core/Orchestration/DataSourceOrchestrationHandlerTests.cs b/tests/CrestApps.Core.Tests/Core/Orchestration/DataSourceOrchestrationHandlerTests.cs index 717614ed..45916d17 100644 --- a/tests/CrestApps.Core.Tests/Core/Orchestration/DataSourceOrchestrationHandlerTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Orchestration/DataSourceOrchestrationHandlerTests.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Options; namespace CrestApps.OrchardCore.Tests.Core.Orchestration; + public sealed class DataSourceOrchestrationHandlerTests { [Fact] diff --git a/tests/CrestApps.Core.Tests/Core/Orchestration/DefaultOrchestratorResolverTests.cs b/tests/CrestApps.Core.Tests/Core/Orchestration/DefaultOrchestratorResolverTests.cs index bb01fb4b..d6b13f06 100644 --- a/tests/CrestApps.Core.Tests/Core/Orchestration/DefaultOrchestratorResolverTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Orchestration/DefaultOrchestratorResolverTests.cs @@ -14,6 +14,7 @@ using Moq; namespace CrestApps.OrchardCore.Tests.Core.Orchestration; + public sealed class DefaultOrchestratorResolverTests { [Fact] diff --git a/tests/CrestApps.Core.Tests/Core/Orchestration/DefaultOrchestratorTests.cs b/tests/CrestApps.Core.Tests/Core/Orchestration/DefaultOrchestratorTests.cs index 2ccbf4c6..0cee2be0 100644 --- a/tests/CrestApps.Core.Tests/Core/Orchestration/DefaultOrchestratorTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Orchestration/DefaultOrchestratorTests.cs @@ -15,6 +15,7 @@ #pragma warning disable MEAI001 // Text-to-speech APIs from Microsoft.Extensions.AI are preview and require explicit opt-in at each usage site. namespace CrestApps.OrchardCore.Tests.Core.Orchestration; + public sealed class DefaultOrchestratorTests { [Fact] diff --git a/tests/CrestApps.Core.Tests/Core/Orchestration/DocumentOrchestrationHandlerTests.cs b/tests/CrestApps.Core.Tests/Core/Orchestration/DocumentOrchestrationHandlerTests.cs index e508099c..6ba8d71c 100644 --- a/tests/CrestApps.Core.Tests/Core/Orchestration/DocumentOrchestrationHandlerTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Orchestration/DocumentOrchestrationHandlerTests.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.Options; namespace CrestApps.OrchardCore.Tests.Core.Orchestration; + public sealed class DocumentOrchestrationHandlerTests { private static DocumentOrchestrationHandler CreateHandler(AIToolDefinitionOptions toolOptions = null) @@ -170,7 +171,7 @@ public async Task BuiltAsync_ProfileKnowledgeBaseWithStrictScope_IncludesDocumen }; var profile = new AIProfile(); profile.Put(new AIDataSourceRagMetadata { IsInScope = true }); - profile.Put(new DocumentsMetadata { Documents = [new ChatDocumentInfo { DocumentId = "doc1", FileName = "report.pdf", ContentType = "application/pdf", FileSize = 2048, }, ], }); + profile.Put(new DocumentsMetadata { Documents = [new ChatDocumentInfo { DocumentId = "doc1", FileName = "report.pdf", ContentType = "application/pdf", FileSize = 2048, },], }); await handler.BuiltAsync(new OrchestrationContextBuiltContext(profile, context)); var systemMessage = context.SystemMessageBuilder.ToString(); Assert.Contains("Background knowledge is available for this profile.", systemMessage); diff --git a/tests/CrestApps.Core.Tests/Core/Orchestration/PreemptiveRagOrchestrationHandlerTests.cs b/tests/CrestApps.Core.Tests/Core/Orchestration/PreemptiveRagOrchestrationHandlerTests.cs index 2992f31f..b651ef30 100644 --- a/tests/CrestApps.Core.Tests/Core/Orchestration/PreemptiveRagOrchestrationHandlerTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Orchestration/PreemptiveRagOrchestrationHandlerTests.cs @@ -12,6 +12,7 @@ using Moq; namespace CrestApps.OrchardCore.Tests.Core.Orchestration; + public sealed class PreemptiveRagOrchestrationHandlerTests { /// diff --git a/tests/CrestApps.Core.Tests/Core/Services/PostSession/PostSessionProcessingServiceTests.cs b/tests/CrestApps.Core.Tests/Core/Services/PostSession/PostSessionProcessingServiceTests.cs index 96ab0771..c63d0531 100644 --- a/tests/CrestApps.Core.Tests/Core/Services/PostSession/PostSessionProcessingServiceTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Services/PostSession/PostSessionProcessingServiceTests.cs @@ -9,6 +9,7 @@ using Moq; namespace CrestApps.OrchardCore.Tests.Core.Services.PostSession; + public sealed class PostSessionProcessingServiceTests { private const string TestProviderName = "TestProvider"; @@ -348,7 +349,7 @@ public async Task ProcessAsync_SuccessfulResults_ShouldHaveSucceededStatus() mockChatClient.Verify(c => c.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny()), Times.Once); } -#region Helpers + #region Helpers private static AIProfile CreateProfile() { var profile = new AIProfile @@ -372,7 +373,7 @@ private static AIChatSession CreateSession() private static List CreatePrompts() { - return[new AIChatSessionPrompt + return [new AIChatSessionPrompt { Role = ChatRole.User, Content = "Hello, I need help with my order.", @@ -675,5 +676,5 @@ public async Task ProcessAsync_WithTools_WhenResponseIsEmpty_ShouldReturnFailedR Assert.Equal(PostSessionTaskResultStatus.Failed, result["summary"].Status); Assert.NotNull(result["summary"].ErrorMessage); } -#endregion + #endregion } \ No newline at end of file diff --git a/tests/CrestApps.Core.Tests/EntityCoreStoreTests.cs b/tests/CrestApps.Core.Tests/EntityCoreStoreTests.cs index edbce968..5f7bcf6a 100644 --- a/tests/CrestApps.Core.Tests/EntityCoreStoreTests.cs +++ b/tests/CrestApps.Core.Tests/EntityCoreStoreTests.cs @@ -1,9 +1,9 @@ using CrestApps.Core.AI; using CrestApps.Core.AI.Chat; using CrestApps.Core.AI.DataSources; -using CrestApps.Core.AI.Deployments; using CrestApps.Core.AI.Memory; using CrestApps.Core.AI.Models; +using CrestApps.Core.AI.Services; using CrestApps.Core.Data.EntityCore; using CrestApps.Core.Infrastructure.Indexing; using CrestApps.Core.Infrastructure.Indexing.Models; @@ -57,7 +57,7 @@ public async Task Entity_core_stores_support_specialized_queries() using var scope = harness.Services.CreateScope(); var services = scope.ServiceProvider; - var deploymentStore = services.GetRequiredService(); + var deploymentStore = services.GetRequiredKeyedService>(ConfigurationAIDeploymentCatalog.PersistedCatalogKey); var dataSourceStore = services.GetRequiredService(); var memoryStore = services.GetRequiredService(); var documentStore = services.GetRequiredService(); diff --git a/tests/CrestApps.Core.Tests/Framework/AI/DataExtractionServiceTests.cs b/tests/CrestApps.Core.Tests/Framework/AI/DataExtractionServiceTests.cs index d0bfe36b..8cb06c24 100644 --- a/tests/CrestApps.Core.Tests/Framework/AI/DataExtractionServiceTests.cs +++ b/tests/CrestApps.Core.Tests/Framework/AI/DataExtractionServiceTests.cs @@ -9,6 +9,7 @@ using Moq; namespace CrestApps.OrchardCore.Tests.Framework.AI; + public sealed class DataExtractionServiceTests { [Fact] @@ -100,7 +101,7 @@ public async Task ProcessAsync_UsesTemplateForExtractionPrompt() templateService.Setup(service => service.RenderAsync(AITemplateIds.DataExtractionPrompt, It.IsAny>())).Callback>((_, arguments) => promptArguments = arguments).ReturnsAsync("rendered prompt"); chatClient.Setup(client => client.GetResponseAsync(It.IsAny>(), It.IsAny(), It.IsAny())).ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "{\"fields\":[],\"sessionEnded\":false}"))); var service = new DataExtractionService(clientFactory.Object, templateService.Object, TimeProvider.System, NullLogger.Instance, deploymentManager.Object); - await service.ProcessAsync(profile, new AIChatSession(), [new AIChatSessionPrompt { Role = ChatRole.Assistant, Content = "What is your email?" }, new AIChatSessionPrompt { Role = ChatRole.User, Content = "My email is test@example.com" }, ], TestContext.Current.CancellationToken); + await service.ProcessAsync(profile, new AIChatSession(), [new AIChatSessionPrompt { Role = ChatRole.Assistant, Content = "What is your email?" }, new AIChatSessionPrompt { Role = ChatRole.User, Content = "My email is test@example.com" },], TestContext.Current.CancellationToken); templateService.Verify(service => service.RenderAsync(AITemplateIds.DataExtractionPrompt, It.IsAny>()), Times.Once); Assert.NotNull(promptArguments); Assert.Equal("My email is test@example.com", promptArguments["lastUserMessage"]); diff --git a/tests/CrestApps.Core.Tests/Framework/Mvc/AIProviderConnectionOptionsTests.cs b/tests/CrestApps.Core.Tests/Framework/Mvc/AIProviderConnectionOptionsTests.cs index dc9ffb08..00deef11 100644 --- a/tests/CrestApps.Core.Tests/Framework/Mvc/AIProviderConnectionOptionsTests.cs +++ b/tests/CrestApps.Core.Tests/Framework/Mvc/AIProviderConnectionOptionsTests.cs @@ -3,13 +3,14 @@ using CrestApps.Core.AI.OpenAI.Azure; using CrestApps.Core.AI.Services; using CrestApps.Core.Infrastructure; +using CrestApps.Core.Models; using CrestApps.Core.Mvc.Web.Areas.AI.Controllers; -using CrestApps.Core.Mvc.Web.Areas.AI.Services; using CrestApps.Core.Mvc.Web.Areas.AI.ViewModels; using CrestApps.Core.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; @@ -26,7 +27,6 @@ public void AddCrestAppsAI_WhenTopLevelConnectionsConfigured_ShouldMergeThemInto ["CrestApps:AI:Connections:0:Name"] = "config-primary", ["CrestApps:AI:Connections:0:ClientName"] = "OpenAI", ["CrestApps:AI:Connections:0:ApiKey"] = "secret", - ["CrestApps:AI:Connections:0:DefaultDeploymentName"] = "gpt-4.1-mini", ["CrestApps:AI:Connections:0:EnableLogging"] = "true", }) .Build(); @@ -42,7 +42,7 @@ public void AddCrestAppsAI_WhenTopLevelConnectionsConfigured_ShouldMergeThemInto Assert.True(options.Providers.ContainsKey("OpenAI")); var provider = options.Providers["OpenAI"]; Assert.Contains("config-primary", provider.Connections.Keys); - Assert.Equal("config-primary", provider.Connections["config-primary"].GetStringValue("ConnectionNameAlias", false)); + Assert.Equal("config-primary", provider.Connections["config-primary"].GetStringValue("DisplayText", false)); Assert.True(provider.Connections["config-primary"].GetBooleanOrFalseValue("EnableLogging")); } @@ -96,67 +96,92 @@ public void AddCrestAppsAI_WhenAzureOpenAIClientNameConfigured_ShouldNormalizeTo } [Fact] - public void MvcAIProviderOptionsStore_ApplyTo_ShouldKeepConfiguredConnectionsAndAddUiConnections() + public async Task ConfigurationAIProviderConnectionCatalog_GetAllAsync_ShouldMergeStoredAndConfiguredConnections() { - var options = new AIProviderOptions(); - options.Providers["OpenAI"] = new AIProvider - { - Connections = new Dictionary(StringComparer.OrdinalIgnoreCase) + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { - ["config-primary"] = new(new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["ApiKey"] = "config-secret", - ["ConnectionNameAlias"] = "Config primary", - ["DefaultDeploymentName"] = "gpt-4.1", - }), - }, - }; + ["CrestApps:AI:Connections:0:Name"] = "config-primary", + ["CrestApps:AI:Connections:0:ClientName"] = "OpenAI", + ["CrestApps:AI:Connections:0:DisplayText"] = "Config primary", + ["CrestApps:AI:Connections:0:ApiKey"] = "config-secret", + }) + .Build(); - var store = new MvcAIProviderOptionsStore(); - store.Replace( - [ - new AIProviderConnection - { - ClientName = "OpenAI", - Name = "ui-secondary", - Properties = new Dictionary(StringComparer.OrdinalIgnoreCase) + var catalog = new ConfigurationAIProviderConnectionCatalog( + new TestAIProviderConnectionStore( + [ + new AIProviderConnection { - ["ApiKey"] = "ui-secret", + ItemId = "ui-connection", + ClientName = "OpenAI", + Name = "ui-secondary", + DisplayText = "UI secondary", + Properties = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ApiKey"] = "ui-secret", + }, }, - }, - ]); + ]), + configuration, + Options.Create(new AIProviderConnectionCatalogOptions()), + NullLogger.Instance); - store.ApplyTo(options); + var connections = await catalog.GetAllAsync(); - Assert.Contains("config-primary", options.Providers["OpenAI"].Connections.Keys); - Assert.Contains("ui-secondary", options.Providers["OpenAI"].Connections.Keys); - Assert.Equal("Config primary", options.Providers["OpenAI"].Connections["config-primary"].GetStringValue("ConnectionNameAlias", false)); - Assert.Equal("ui-secondary", options.Providers["OpenAI"].Connections["ui-secondary"].GetStringValue("ConnectionNameAlias", false)); + Assert.Contains(connections, connection => connection.Name == "config-primary" && AIConfigurationRecordIds.IsConfigurationConnectionId(connection.ItemId)); + Assert.Contains(connections, connection => connection.Name == "ui-secondary" && connection.ItemId == "ui-connection"); } [Fact] - public async Task AIDeploymentController_Create_ShouldPopulateConnectionsFromMergedProviderOptions() + public void AddCrestAppsAI_WhenDisplayTextConfigured_ShouldKeepDisplayText() { - var deploymentCatalog = new Mock>(); - var providerOptions = new AIProviderOptions(); - providerOptions.Providers["OpenAI"] = new AIProvider - { - Connections = new Dictionary(StringComparer.OrdinalIgnoreCase) + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { - ["config-primary"] = new(new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["ConnectionNameAlias"] = "Config primary", - }), - ["ui-secondary"] = new(new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["ConnectionNameAlias"] = "UI secondary", - }), + ["CrestApps:AI:Connections:0:Name"] = "config-primary", + ["CrestApps:AI:Connections:0:ClientName"] = "OpenAI", + ["CrestApps:AI:Connections:0:DisplayText"] = "Config primary", + }) + .Build(); + + var services = new ServiceCollection(); + services.AddSingleton(configuration); + services.AddLogging(); + services.AddCoreAIServices(); + using var serviceProvider = services.BuildServiceProvider(); + + var options = serviceProvider.GetRequiredService>().Value; + + Assert.Equal("Config primary", options.Providers["OpenAI"].Connections["config-primary"].GetStringValue("DisplayText", false)); + } + + [Fact] + public async Task AIDeploymentController_Create_ShouldPopulateConnectionsFromMergedCatalog() + { + var deploymentCatalog = new Mock>(); + var connectionCatalog = new Mock>(); + connectionCatalog.Setup(catalog => catalog.GetAllAsync()).ReturnsAsync( + [ + new AIProviderConnection + { + ItemId = AIConfigurationRecordIds.CreateConnectionId("OpenAI", "config-primary"), + Name = "config-primary", + DisplayText = "Config primary", + ClientName = "OpenAI", }, - }; + new AIProviderConnection + { + ItemId = "ui-secondary-id", + Name = "ui-secondary", + DisplayText = "UI secondary", + ClientName = "OpenAI", + }, + ]); var controller = new AIDeploymentController( deploymentCatalog.Object, - new TestOptionsSnapshot(providerOptions)); + connectionCatalog.Object); var result = await controller.Create(); @@ -168,11 +193,18 @@ public async Task AIDeploymentController_Create_ShouldPopulateConnectionsFromMer } [Fact] - public async Task AIConnectionController_Index_ShouldIncludeConfiguredConnectionsAsReadOnly() + public async Task AIConnectionController_Index_ShouldIncludeMergedConnectionsAndMarkConfiguredOnesReadOnly() { - var connectionCatalog = new Mock>(); + var connectionCatalog = new Mock>(); connectionCatalog.Setup(catalog => catalog.GetAllAsync()).ReturnsAsync( [ + new AIProviderConnection + { + ItemId = AIConfigurationRecordIds.CreateConnectionId("OpenAI", "config-primary"), + Name = "config-primary", + DisplayText = "Config primary", + Source = "OpenAI", + }, new AIProviderConnection { ItemId = "ui-connection", @@ -182,20 +214,7 @@ public async Task AIConnectionController_Index_ShouldIncludeConfiguredConnection }, ]); - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["CrestApps:AI:Connections:0:Name"] = "config-primary", - ["CrestApps:AI:Connections:0:ClientName"] = "OpenAI", - ["CrestApps:AI:Connections:0:ConnectionNameAlias"] = "Config primary", - }) - .Build(); - - var controller = new AIConnectionController( - connectionCatalog.Object, - configuration, - new MvcAIProviderOptionsStore(), - new OptionsCache()); + var controller = new AIConnectionController(connectionCatalog.Object); var result = await controller.Index(); @@ -209,7 +228,7 @@ public async Task AIConnectionController_Index_ShouldIncludeConfiguredConnection [Fact] public async Task AIDeploymentController_Index_ShouldMarkConfiguredDeploymentsAsReadOnly() { - var deploymentCatalog = new Mock>(); + var deploymentCatalog = new Mock>(); deploymentCatalog.Setup(catalog => catalog.GetAllAsync()).ReturnsAsync( [ new AIDeployment @@ -222,9 +241,10 @@ public async Task AIDeploymentController_Index_ShouldMarkConfiguredDeploymentsAs }, ]); + var connectionCatalog = new Mock>(); var controller = new AIDeploymentController( deploymentCatalog.Object, - new TestOptionsSnapshot(new AIProviderOptions())); + connectionCatalog.Object); var result = await controller.Index(); @@ -264,13 +284,87 @@ public void AddAzureOpenAIProvider_ShouldRegisterAzureSpeechAsDeploymentProvider Assert.True(options.Deployments[AzureOpenAIConstants.AzureSpeechProviderName].SupportsContainedConnection); } - private sealed class TestOptionsSnapshot : IOptionsSnapshot - where TOptions : class + [Fact] + public async Task ConfigurationAIProviderConnectionCatalog_ShouldSkipConfiguredConflictsByName() { - public TestOptionsSnapshot(TOptions value) => Value = value; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["CrestApps:AI:Connections:0:Name"] = "shared-name", + ["CrestApps:AI:Connections:0:ClientName"] = "OpenAI", + }) + .Build(); - public TOptions Value { get; } + var catalog = new ConfigurationAIProviderConnectionCatalog( + new TestAIProviderConnectionStore( + [ + new AIProviderConnection + { + ItemId = "ui-connection", + ClientName = "OpenAI", + Name = "shared-name", + }, + ]), + configuration, + Options.Create(new AIProviderConnectionCatalogOptions()), + NullLogger.Instance); + + var connections = await catalog.GetAllAsync(); + + Assert.Single(connections); + Assert.Equal("ui-connection", connections.Single().ItemId); + } + + private sealed class TestAIProviderConnectionStore(List connections) : INamedSourceCatalog + { + public ValueTask CreateAsync(AIProviderConnection entry) + { + connections.Add(entry); + return ValueTask.CompletedTask; + } + + public ValueTask DeleteAsync(AIProviderConnection entry) + { + connections.Remove(entry); + return ValueTask.FromResult(true); + } + + public ValueTask FindByIdAsync(string id) + { + return ValueTask.FromResult(connections.FirstOrDefault(connection => connection.ItemId == id)); + } + + public ValueTask FindByNameAsync(string name) + { + return ValueTask.FromResult(connections.FirstOrDefault(connection => connection.Name == name)); + } + + public ValueTask> GetAllAsync() + { + return ValueTask.FromResult>(connections.ToArray()); + } + + public ValueTask> GetAsync(IEnumerable ids) + { + return ValueTask.FromResult>(connections.Where(connection => ids.Contains(connection.ItemId)).ToArray()); + } + + public ValueTask> GetAsync(string source) + { + return ValueTask.FromResult>(connections.Where(connection => connection.Source == source).ToArray()); + } + + public ValueTask GetAsync(string name, string source) + { + return ValueTask.FromResult(connections.FirstOrDefault(connection => connection.Name == name && connection.Source == source)); + } + + public ValueTask> PageAsync(int page, int pageSize, TQuery context) + where TQuery : QueryContext + { + return ValueTask.FromResult(new PageResult { Count = connections.Count, Entries = connections.ToArray(), }); + } - public TOptions Get(string name) => Value; + public ValueTask UpdateAsync(AIProviderConnection entry) => ValueTask.CompletedTask; } } diff --git a/tests/CrestApps.Core.Tests/Framework/Mvc/ChatInteractionHubTests.cs b/tests/CrestApps.Core.Tests/Framework/Mvc/ChatInteractionHubTests.cs index 74602194..fcce5c5d 100644 --- a/tests/CrestApps.Core.Tests/Framework/Mvc/ChatInteractionHubTests.cs +++ b/tests/CrestApps.Core.Tests/Framework/Mvc/ChatInteractionHubTests.cs @@ -19,6 +19,7 @@ using Moq; namespace CrestApps.OrchardCore.Tests.Framework.Mvc; + public sealed class ChatInteractionHubTests { [Fact] diff --git a/tests/CrestApps.Core.Tests/Framework/Mvc/ConfigurationAIDeploymentCatalogTests.cs b/tests/CrestApps.Core.Tests/Framework/Mvc/ConfigurationAIDeploymentCatalogTests.cs index d2bbde2c..aec3f9b7 100644 --- a/tests/CrestApps.Core.Tests/Framework/Mvc/ConfigurationAIDeploymentCatalogTests.cs +++ b/tests/CrestApps.Core.Tests/Framework/Mvc/ConfigurationAIDeploymentCatalogTests.cs @@ -1,13 +1,14 @@ using CrestApps.Core.AI; -using CrestApps.Core.AI.Deployments; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Services; using CrestApps.Core.Models; +using CrestApps.Core.Services; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; namespace CrestApps.OrchardCore.Tests.Framework.Mvc; + public sealed class ConfigurationAIDeploymentCatalogTests { [Fact] @@ -16,13 +17,12 @@ public async Task GetAllAsync_ShouldMergeStoredAndConfiguredStandaloneDeployment var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["CrestApps:AI:Deployments:0:ClientName"] = "AzureSpeech", ["CrestApps:AI:Deployments:0:Name"] = "whisper", ["CrestApps:AI:Deployments:0:Type"] = "SpeechToText", ["CrestApps:AI:Deployments:0:IsDefault"] = "true", ["CrestApps:AI:Deployments:0:Endpoint"] = "https://eastus.stt.speech.microsoft.com", ["CrestApps:AI:Deployments:0:AuthenticationType"] = "ApiKey", ["CrestApps:AI:Deployments:0:ApiKey"] = "secret", }).Build(); var aiOptions = new AIOptions(); aiOptions.AddDeploymentProvider("AzureSpeech", entry => entry.SupportsContainedConnection = true); - var innerStore = new TestAIDeploymentStore([new AIDeployment { ItemId = "ui-deployment", Name = "ui-chat", ClientName = "OpenAI", Type = AIDeploymentType.Chat, }, ]); - var catalog = new ConfigurationAIDeploymentCatalog(innerStore, configuration, Options.Create(new AIProviderOptions()), Options.Create(aiOptions), NullLogger.Instance); + var innerStore = new TestAIDeploymentStore([new AIDeployment { ItemId = "ui-deployment", Name = "ui-chat", ClientName = "OpenAI", Type = AIDeploymentType.Chat, },]); + var catalog = new ConfigurationAIDeploymentCatalog(innerStore, configuration, Options.Create(aiOptions), Options.Create(new AIDeploymentCatalogOptions()), NullLogger.Instance); var deployments = await catalog.GetAllAsync(); Assert.Contains(deployments, deployment => deployment.ItemId == "ui-deployment"); var configuredDeployment = Assert.Single(deployments, deployment => deployment.Name == "whisper"); Assert.Equal("AzureSpeech", configuredDeployment.ClientName); - Assert.True(configuredDeployment.IsDefault); Assert.Equal(AIDeploymentType.SpeechToText, configuredDeployment.Type); Assert.NotNull(configuredDeployment.Properties); Assert.Equal("https://eastus.stt.speech.microsoft.com", configuredDeployment.Properties["Endpoint"]?.ToString()); @@ -34,7 +34,7 @@ public async Task FindByNameAsync_ShouldReturnConfiguredDeploymentWhenNotInStore var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["CrestApps:AI:Deployments:0:ClientName"] = "AzureSpeech", ["CrestApps:AI:Deployments:0:Name"] = "AzureTextToSpeech", ["CrestApps:AI:Deployments:0:Type"] = "TextToSpeech", ["CrestApps:AI:Deployments:0:IsDefault"] = "true", }).Build(); var aiOptions = new AIOptions(); aiOptions.AddDeploymentProvider("AzureSpeech", entry => entry.SupportsContainedConnection = true); - var catalog = new ConfigurationAIDeploymentCatalog(new TestAIDeploymentStore([]), configuration, Options.Create(new AIProviderOptions()), Options.Create(aiOptions), NullLogger.Instance); + var catalog = new ConfigurationAIDeploymentCatalog(new TestAIDeploymentStore([]), configuration, Options.Create(aiOptions), Options.Create(new AIDeploymentCatalogOptions()), NullLogger.Instance); var deployment = await catalog.FindByNameAsync("AzureTextToSpeech"); Assert.NotNull(deployment); Assert.Equal("AzureSpeech", deployment.ClientName); @@ -47,7 +47,7 @@ public async Task GetAllAsync_ShouldReadProviderGroupedStandaloneDeployments() var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["CrestApps:AI:Deployments:AzureSpeech:0:Name"] = "grouped-whisper", ["CrestApps:AI:Deployments:AzureSpeech:0:Type"] = "SpeechToText", ["CrestApps:AI:Deployments:AzureSpeech:0:IsDefault"] = "true", }).Build(); var aiOptions = new AIOptions(); aiOptions.AddDeploymentProvider("AzureSpeech", entry => entry.SupportsContainedConnection = true); - var catalog = new ConfigurationAIDeploymentCatalog(new TestAIDeploymentStore([]), configuration, Options.Create(new AIProviderOptions()), Options.Create(aiOptions), NullLogger.Instance); + var catalog = new ConfigurationAIDeploymentCatalog(new TestAIDeploymentStore([]), configuration, Options.Create(aiOptions), Options.Create(new AIDeploymentCatalogOptions()), NullLogger.Instance); var deployment = Assert.Single(await catalog.GetAllAsync()); Assert.Equal("AzureSpeech", deployment.ClientName); Assert.Equal("grouped-whisper", deployment.Name); @@ -60,13 +60,47 @@ public async Task GetAllAsync_ShouldNormalizeAzureOpenAIStandaloneDeployments() var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["CrestApps:AI:Deployments:0:ClientName"] = "AzureOpenAI", ["CrestApps:AI:Deployments:0:Name"] = "text-embedding-3-small", ["CrestApps:AI:Deployments:0:ModelName"] = "text-embedding-3-small", ["CrestApps:AI:Deployments:0:Type"] = "Embedding", ["CrestApps:AI:Deployments:0:Endpoint"] = "https://example.openai.azure.com/", ["CrestApps:AI:Deployments:0:AuthenticationType"] = "ApiKey", ["CrestApps:AI:Deployments:0:ApiKey"] = "secret", }).Build(); var aiOptions = new AIOptions(); aiOptions.AddDeploymentProvider("Azure", entry => entry.SupportsContainedConnection = true); - var catalog = new ConfigurationAIDeploymentCatalog(new TestAIDeploymentStore([]), configuration, Options.Create(new AIProviderOptions()), Options.Create(aiOptions), NullLogger.Instance); + var catalog = new ConfigurationAIDeploymentCatalog(new TestAIDeploymentStore([]), configuration, Options.Create(aiOptions), Options.Create(new AIDeploymentCatalogOptions()), NullLogger.Instance); var deployment = Assert.Single(await catalog.GetAllAsync()); Assert.Equal("Azure", deployment.ClientName); Assert.Equal(AIDeploymentType.Embedding, deployment.Type); } - private sealed class TestAIDeploymentStore(List deployments) : IAIDeploymentStore + [Fact] + public async Task GetAllAsync_ShouldPreferStoredDeploymentWhenConfiguredNameConflicts() + { + var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["CrestApps:AI:Deployments:0:ClientName"] = "AzureSpeech", + ["CrestApps:AI:Deployments:0:Name"] = "shared-name", + ["CrestApps:AI:Deployments:0:Type"] = "SpeechToText", + }).Build(); + + var aiOptions = new AIOptions(); + aiOptions.AddDeploymentProvider("AzureSpeech", entry => entry.SupportsContainedConnection = true); + var catalog = new ConfigurationAIDeploymentCatalog( + new TestAIDeploymentStore( + [ + new AIDeployment + { + ItemId = "ui-deployment", + Name = "shared-name", + ClientName = "OpenAI", + Type = AIDeploymentType.Chat, + }, + ]), + configuration, + Options.Create(aiOptions), + Options.Create(new AIDeploymentCatalogOptions()), + NullLogger.Instance); + + var deployments = await catalog.GetAllAsync(); + + Assert.Single(deployments); + Assert.Equal("ui-deployment", deployments.Single().ItemId); + } + + private sealed class TestAIDeploymentStore(List deployments) : INamedSourceCatalog { public ValueTask CreateAsync(AIDeployment entry) { @@ -118,4 +152,5 @@ public ValueTask> PageAsync(int page, int pageS public ValueTask UpdateAsync(AIDeployment entry) => ValueTask.CompletedTask; } -} \ No newline at end of file + +} diff --git a/tests/CrestApps.Core.Tests/Framework/Mvc/IndexProfileTypeRulesTests.cs b/tests/CrestApps.Core.Tests/Framework/Mvc/IndexProfileTypeRulesTests.cs index 6264de1f..e38b8973 100644 --- a/tests/CrestApps.Core.Tests/Framework/Mvc/IndexProfileTypeRulesTests.cs +++ b/tests/CrestApps.Core.Tests/Framework/Mvc/IndexProfileTypeRulesTests.cs @@ -15,6 +15,7 @@ using Moq; namespace CrestApps.OrchardCore.Tests.Framework.Mvc; + public sealed class IndexProfileTypeRulesTests { [Theory] diff --git a/tests/CrestApps.Core.Tests/Framework/Mvc/MvcCitationReferenceCollectorTests.cs b/tests/CrestApps.Core.Tests/Framework/Mvc/MvcCitationReferenceCollectorTests.cs index 7f11721a..c20e7d92 100644 --- a/tests/CrestApps.Core.Tests/Framework/Mvc/MvcCitationReferenceCollectorTests.cs +++ b/tests/CrestApps.Core.Tests/Framework/Mvc/MvcCitationReferenceCollectorTests.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.DependencyInjection; namespace CrestApps.OrchardCore.Tests.Framework.Mvc; + public sealed class MvcCitationReferenceCollectorTests { [Fact] diff --git a/tests/CrestApps.Core.Tests/Framework/Mvc/SearchIndexProfileProvisioningServiceTests.cs b/tests/CrestApps.Core.Tests/Framework/Mvc/SearchIndexProfileProvisioningServiceTests.cs index 528c4837..84e93b31 100644 --- a/tests/CrestApps.Core.Tests/Framework/Mvc/SearchIndexProfileProvisioningServiceTests.cs +++ b/tests/CrestApps.Core.Tests/Framework/Mvc/SearchIndexProfileProvisioningServiceTests.cs @@ -15,6 +15,7 @@ using Moq; namespace CrestApps.OrchardCore.Tests.Framework.Mvc; + public sealed class SearchIndexProfileProvisioningServiceTests { [Fact] diff --git a/tests/CrestApps.Core.Tests/Helpers/DocumentReaders/OpenXmlIngestionDocumentReaderTests.cs b/tests/CrestApps.Core.Tests/Helpers/DocumentReaders/OpenXmlIngestionDocumentReaderTests.cs index 257985f6..46c4736b 100644 --- a/tests/CrestApps.Core.Tests/Helpers/DocumentReaders/OpenXmlIngestionDocumentReaderTests.cs +++ b/tests/CrestApps.Core.Tests/Helpers/DocumentReaders/OpenXmlIngestionDocumentReaderTests.cs @@ -11,10 +11,11 @@ using WpText = DocumentFormat.OpenXml.Wordprocessing.Text; namespace CrestApps.OrchardCore.Tests.Helpers.DocumentReaders; + public sealed class OpenXmlIngestionDocumentReaderTests { private readonly OpenXmlIngestionDocumentReader _reader = new(); -#region Word (.docx) + #region Word (.docx) [Fact] public async Task ReadAsync_WordDocument_ExtractsParagraphs() { @@ -43,12 +44,12 @@ public async Task ReadAsync_EmptyWordDocument_ReturnsNoSections() Assert.Empty(result.Sections); } -#endregion -#region Excel (.xlsx) - Shared Strings + #endregion + #region Excel (.xlsx) - Shared Strings [Fact] public async Task ReadAsync_ExcelWithSharedStrings_ExtractsRows() { - using var stream = CreateExcelWithSharedStrings([["Title", "Question", "Answer"], ["Thor Weapon", "What is Thor's weapon?", "Mjolnir"], ]); + using var stream = CreateExcelWithSharedStrings([["Title", "Question", "Answer"], ["Thor Weapon", "What is Thor's weapon?", "Mjolnir"],]); var result = await _reader.ReadAsync(stream, "test.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", TestContext.Current.CancellationToken); Assert.Single(result.Sections); Assert.Equal(2, result.Sections[0].Elements.Count); @@ -59,7 +60,7 @@ public async Task ReadAsync_ExcelWithSharedStrings_ExtractsRows() [Fact] public async Task ReadAsync_ExcelWithInlineStrings_ExtractsRows() { - using var stream = CreateExcelWithInlineStrings([["Name", "Value"], ["Key1", "Data1"], ]); + using var stream = CreateExcelWithInlineStrings([["Name", "Value"], ["Key1", "Data1"],]); var result = await _reader.ReadAsync(stream, "test.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", TestContext.Current.CancellationToken); Assert.Single(result.Sections); Assert.Equal(2, result.Sections[0].Elements.Count); @@ -70,7 +71,7 @@ public async Task ReadAsync_ExcelWithInlineStrings_ExtractsRows() [Fact] public async Task ReadAsync_ExcelWithNumericValues_ExtractsRows() { - using var stream = CreateExcelWithNumericValues([[1.0, 2.5, 3.7], [10.0, 20.0, 30.0], ]); + using var stream = CreateExcelWithNumericValues([[1.0, 2.5, 3.7], [10.0, 20.0, 30.0],]); var result = await _reader.ReadAsync(stream, "test.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", TestContext.Current.CancellationToken); Assert.Single(result.Sections); Assert.Equal(2, result.Sections[0].Elements.Count); @@ -115,8 +116,8 @@ public async Task ReadAsync_ExcelWithMixedCellTypes_ExtractsAll() Assert.True(result.Sections[0].Elements.Count > 0); } -#endregion -#region PowerPoint (.pptx) + #endregion + #region PowerPoint (.pptx) [Fact] public async Task ReadAsync_PowerPoint_ExtractsSlideText() { @@ -134,8 +135,8 @@ public async Task ReadAsync_EmptyPowerPoint_ReturnsNoSections() Assert.Empty(result.Sections); } -#endregion -#region Unsupported Media Types + #endregion + #region Unsupported Media Types [Fact] public async Task ReadAsync_UnsupportedMediaType_ThrowsNotSupportedException() { @@ -143,8 +144,8 @@ public async Task ReadAsync_UnsupportedMediaType_ThrowsNotSupportedException() await Assert.ThrowsAsync(() => _reader.ReadAsync(stream, "test.txt", "text/plain", TestContext.Current.CancellationToken)); } -#endregion -#region Non-Seekable Stream + #endregion + #region Non-Seekable Stream [Fact] public async Task ReadAsync_NonSeekableStream_ExtractsCorrectly() { @@ -174,8 +175,8 @@ public async Task ReadAsync_FormFileStream_ExtractsCorrectly() Assert.Equal("FormFile content", result.Sections[0].Elements[0].Text); } -#endregion -#region Helpers + #endregion + #region Helpers private static MemoryStream CreateWordDocument(params string[] paragraphs) { var stream = new MemoryStream(); @@ -510,5 +511,5 @@ public override Task CopyToAsync(Stream destination, int bufferSize, Cancellatio return _inner.CopyToAsync(destination, bufferSize, cancellationToken); } } -#endregion + #endregion } diff --git a/tests/CrestApps.Core.Tests/Helpers/DocumentReaders/PdfIngestionDocumentReaderTests.cs b/tests/CrestApps.Core.Tests/Helpers/DocumentReaders/PdfIngestionDocumentReaderTests.cs index f5a2e27c..948b27a9 100644 --- a/tests/CrestApps.Core.Tests/Helpers/DocumentReaders/PdfIngestionDocumentReaderTests.cs +++ b/tests/CrestApps.Core.Tests/Helpers/DocumentReaders/PdfIngestionDocumentReaderTests.cs @@ -3,6 +3,7 @@ using UglyToad.PdfPig.Writer; namespace CrestApps.OrchardCore.Tests.Helpers.DocumentReaders; + public sealed class PdfIngestionDocumentReaderTests { private readonly PdfIngestionDocumentReader _reader = new(); @@ -82,7 +83,7 @@ public async Task ReadAsync_FormFileStream_ExtractsCorrectly() Assert.Contains("FormFile PDF", allText); } -#region Helpers + #region Helpers private static MemoryStream CreatePdfWithText(string text) { var builder = new PdfDocumentBuilder(); @@ -145,5 +146,5 @@ public override Task CopyToAsync(Stream destination, int bufferSize, Cancellatio return _inner.CopyToAsync(destination, bufferSize, cancellationToken); } } -#endregion + #endregion } From d15e65ebad49506e429a313f42c989ce88cef012 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Thu, 9 Apr 2026 09:37:28 -0700 Subject: [PATCH 2/4] Update src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeploymentCatalogOptions.cs --- .../Models/AIDeploymentCatalogOptions.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeploymentCatalogOptions.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeploymentCatalogOptions.cs index cb4c1159..fce2cb8e 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeploymentCatalogOptions.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeploymentCatalogOptions.cs @@ -5,6 +5,5 @@ public sealed class AIDeploymentCatalogOptions public IList DeploymentSections { get; } = [ "CrestApps:AI:Deployments", - "CrestApps_AI:Deployments", ]; } From a04d1cb2d875a412ea4629ceda4f318a5745b2b8 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Thu, 9 Apr 2026 09:37:33 -0700 Subject: [PATCH 3/4] Update src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProviderConnectionCatalogOptions.cs --- .../Models/AIProviderConnectionCatalogOptions.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProviderConnectionCatalogOptions.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProviderConnectionCatalogOptions.cs index 0ac37372..e991902d 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProviderConnectionCatalogOptions.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProviderConnectionCatalogOptions.cs @@ -10,6 +10,5 @@ public sealed class AIProviderConnectionCatalogOptions public IList ProviderSections { get; } = [ "CrestApps:Providers", - "CrestApps:AI:Providers", ]; } From 3541d75cf28b081f9eb494cef816a4b2dc7e2d69 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Thu, 9 Apr 2026 09:45:19 -0700 Subject: [PATCH 4/4] cleanup --- .../Services/YesSqlServiceCollectionExtensions.cs | 5 +++-- .../ServiceCollectionExtensions.cs | 8 ++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs b/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs index 94409b74..412ee1fa 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs @@ -19,6 +19,7 @@ using CrestApps.Core.Data.YesSql.Indexes.ChatInteractions; using CrestApps.Core.Data.YesSql.Indexes.DataSources; using CrestApps.Core.Data.YesSql.Indexes.Indexing; +using CrestApps.Core.Data.YesSql.Services; using CrestApps.Core.Infrastructure.Indexing; using CrestApps.Core.Mvc.Web.Areas.A2A.Indexes; using CrestApps.Core.Mvc.Web.Areas.Admin.Handlers; @@ -111,10 +112,10 @@ public static IServiceCollection AddCoreYesSqlDataStore(this IServiceCollection .AddScoped, ArticleIndexingHandler>() .AddScoped(); - services.AddKeyedScoped, CrestApps.Core.Data.YesSql.Services.NamedSourceDocumentCatalog>(ConfigurationAIProviderConnectionCatalog.PersistedCatalogKey) + services.AddKeyedScoped, NamedSourceDocumentCatalog>(ConfigurationAIProviderConnectionCatalog.PersistedCatalogKey) .AddNamedSourceDocumentCatalog(); - services.AddKeyedScoped, CrestApps.Core.Data.YesSql.Services.NamedSourceDocumentCatalog>(ConfigurationAIDeploymentCatalog.PersistedCatalogKey) + services.AddKeyedScoped, NamedSourceDocumentCatalog>(ConfigurationAIDeploymentCatalog.PersistedCatalogKey) .AddNamedSourceDocumentCatalog(); return services; diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs b/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs index 818b8005..ace44bee 100644 --- a/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.EntityCore/ServiceCollectionExtensions.cs @@ -147,6 +147,7 @@ public static IServiceCollection AddNamedSourceDocumentCatalog>(sp => sp.GetRequiredService()); services.AddScoped>(sp => sp.GetRequiredService()); services.AddScoped>(sp => sp.GetRequiredService()); + return services; } @@ -189,10 +190,10 @@ public static IServiceCollection AddCoreEntityCoreStores(this IServiceCollection services.AddScoped(); services.AddScoped>(sp => sp.GetRequiredService()); - services.AddKeyedScoped, Services.NamedSourceDocumentCatalog>(ConfigurationAIProviderConnectionCatalog.PersistedCatalogKey); + services.AddKeyedScoped, NamedSourceDocumentCatalog>(ConfigurationAIProviderConnectionCatalog.PersistedCatalogKey); services.AddNamedSourceDocumentCatalog(); - services.AddKeyedScoped, Services.NamedSourceDocumentCatalog>(ConfigurationAIDeploymentCatalog.PersistedCatalogKey); + services.AddKeyedScoped, NamedSourceDocumentCatalog>(ConfigurationAIDeploymentCatalog.PersistedCatalogKey); services.AddNamedSourceDocumentCatalog(); return services; @@ -201,12 +202,14 @@ public static IServiceCollection AddCoreEntityCoreStores(this IServiceCollection public static CrestAppsCoreBuilder AddEntityCoreDataStore(this CrestAppsCoreBuilder builder, Action configure, Action configureStore = null) { builder.Services.AddCoreEntityCoreDataStore(configure, configureStore); + return builder; } public static CrestAppsCoreBuilder AddEntityCoreSqliteDataStore(this CrestAppsCoreBuilder builder, string connectionString, string tablePrefix = "CA_") { builder.Services.AddCoreEntityCoreSqliteDataStore(connectionString, tablePrefix); + return builder; } @@ -215,6 +218,7 @@ public static async Task InitializeEntityCoreSchemaAsync(this IServiceProvider s ArgumentNullException.ThrowIfNull(services); using var scope = services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.EnsureCreatedAsync(); } }