diff --git a/KernelMemory.sln.DotSettings b/KernelMemory.sln.DotSettings index 987c4a5d3..c1c45a758 100644 --- a/KernelMemory.sln.DotSettings +++ b/KernelMemory.sln.DotSettings @@ -270,6 +270,7 @@ public void It$SOMENAME$() DO_NOT_SHOW True True + True True True True diff --git a/examples/004-dotnet-ServerlessCustomPipeline/Program.cs b/examples/004-dotnet-ServerlessCustomPipeline/Program.cs index 83d3483d4..b62eb464a 100644 --- a/examples/004-dotnet-ServerlessCustomPipeline/Program.cs +++ b/examples/004-dotnet-ServerlessCustomPipeline/Program.cs @@ -1,5 +1,22 @@ // Copyright (c) Microsoft. All rights reserved. +/* + * The ingestion pipeline is composed by a set of default STEPS to process documents: + * - extract + * - partition + * - gen_embeddings + * - save_records + * + * Each step is managed by a HANDLER, see the Core/Handlers for a list of available handlers. + * + * You can create new handlers, and customize the pipeline in multiple ways: + * + * - If you are using the Memory Web Service, the list of handlers can be configured in appsettings.json and appsettings..json + * - You can create new handlers and load them in the configuration, passing the path to the assembly and the handler class. + * - If you are using the Serverless Memory, see the example below. + * - You can also remove the default handlers calling .WithoutDefaultHandlers() in the memory builder. + */ + using Microsoft.KernelMemory; using Microsoft.KernelMemory.Handlers; diff --git a/examples/202-dotnet-CustomHandlerAsAService/Program.cs b/examples/202-dotnet-CustomHandlerAsAService/Program.cs index b574bf5ff..60995976b 100644 --- a/examples/202-dotnet-CustomHandlerAsAService/Program.cs +++ b/examples/202-dotnet-CustomHandlerAsAService/Program.cs @@ -2,9 +2,9 @@ using Microsoft.KernelMemory; -/* The following code shows how to create a custom handler, attached - * to a queue and listening for work to do. You can also add multiple handlers - * the same way. +/* The following code shows how to create a custom handler and run it as a standalone service. + * The handler will automatically attach to a queue and listen for work to do. + * You can also add multiple handlers the same way. */ // Usual .NET web app builder diff --git a/extensions/AzureOpenAI/AzureOpenAIConfig.cs b/extensions/AzureOpenAI/AzureOpenAIConfig.cs index 25efa3195..d0796d506 100644 --- a/extensions/AzureOpenAI/AzureOpenAIConfig.cs +++ b/extensions/AzureOpenAI/AzureOpenAIConfig.cs @@ -84,7 +84,7 @@ public void SetCredential(TokenCredential credential) public TokenCredential GetTokenCredential() { return this._tokenCredential - ?? throw new ConfigurationException("TokenCredential not defined"); + ?? throw new ConfigurationException("Azure OpenAI TokenCredential not defined"); } /// @@ -94,33 +94,33 @@ public void Validate() { if (this.Auth == AuthTypes.Unknown) { - throw new ArgumentOutOfRangeException(nameof(this.Auth), "The authentication type is not defined"); + throw new ArgumentOutOfRangeException(nameof(this.Auth), "The Azure OpenAI Authentication Type is not defined"); } if (this.Auth == AuthTypes.APIKey && string.IsNullOrWhiteSpace(this.APIKey)) { - throw new ArgumentOutOfRangeException(nameof(this.APIKey), "The API Key is empty"); + throw new ArgumentOutOfRangeException(nameof(this.APIKey), "The Azure OpenAI API Key is empty"); } if (string.IsNullOrWhiteSpace(this.Endpoint)) { - throw new ArgumentOutOfRangeException(nameof(this.Endpoint), "The endpoint value is empty"); + throw new ArgumentOutOfRangeException(nameof(this.Endpoint), "The Azure OpenAI Endpoint value is empty"); } if (!this.Endpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { - throw new ArgumentOutOfRangeException(nameof(this.Endpoint), "The endpoint value must start with https://"); + throw new ArgumentOutOfRangeException(nameof(this.Endpoint), "The Azure OpenAI Endpoint value must start with https://"); } if (string.IsNullOrWhiteSpace(this.Deployment)) { - throw new ArgumentOutOfRangeException(nameof(this.Deployment), "The deployment value is empty"); + throw new ArgumentOutOfRangeException(nameof(this.Deployment), "The Azure OpenAI Deployment Name is empty"); } if (this.MaxTokenTotal < 1) { throw new ArgumentOutOfRangeException(nameof(this.MaxTokenTotal), - $"{nameof(this.MaxTokenTotal)} cannot be less than 1"); + $"Azure OpenAI: {nameof(this.MaxTokenTotal)} cannot be less than 1"); } } } diff --git a/service/Core/AppBuilders/DependencyInjection.cs b/service/Core/AppBuilders/DependencyInjection.cs index 8508d06d4..7b564f9d9 100644 --- a/service/Core/AppBuilders/DependencyInjection.cs +++ b/service/Core/AppBuilders/DependencyInjection.cs @@ -1,6 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.KernelMemory.Configuration; +using Microsoft.KernelMemory.Handlers; using Microsoft.KernelMemory.Pipeline; namespace Microsoft.KernelMemory; @@ -16,8 +20,63 @@ public static partial class DependencyInjection public static void AddHandlerAsHostedService(this IServiceCollection services, string stepName) where THandler : class, IPipelineStepHandler { services.AddTransient(serviceProvider => ActivatorUtilities.CreateInstance(serviceProvider, stepName)); - services.AddHostedService>( serviceProvider => ActivatorUtilities.CreateInstance>(serviceProvider, stepName)); } + + /// + /// Register the handler as a hosted service, passing the step name to the handler ctor + /// + /// Application builder service collection + /// Handler class + /// Pipeline step name + public static void AddHandlerAsHostedService(this IServiceCollection services, Type tHandler, string stepName) + { + if (!typeof(IPipelineStepHandler).IsAssignableFrom(tHandler)) + { + throw new ArgumentException($"'{tHandler.FullName}' doesn't implement interface '{nameof(IPipelineStepHandler)}'", nameof(tHandler)); + } + + if (tHandler == null) + { + throw new ArgumentNullException(nameof(tHandler), $"Handler type for '{stepName}' is NULL"); + } + + services.AddTransient(tHandler, serviceProvider => ActivatorUtilities.CreateInstance(serviceProvider, tHandler, stepName)); + + // Build generic type: HandlerAsAHostedService + Type handlerAsAHostedServiceTHandler = typeof(HandlerAsAHostedService<>).MakeGenericType(tHandler); + + Func implementationFactory = + serviceProvider => (IHostedService)ActivatorUtilities.CreateInstance(serviceProvider, handlerAsAHostedServiceTHandler, stepName); + + // See https://github.com/dotnet/runtime/issues/38751 for troubleshooting + services.Add(ServiceDescriptor.Singleton(implementationFactory)); + } + + /// + /// Register the handler as a hosted service, passing the step name to the handler ctor + /// + /// Application builder service collection + /// Handler type configuration + /// Pipeline step name + public static void AddHandlerAsHostedService(this IServiceCollection services, HandlerConfig config, string stepName) + { + if (HandlerTypeLoader.TryGetHandlerType(config, out var handlerType)) + { + services.AddHandlerAsHostedService(handlerType, stepName); + } + } + + /// + /// Register the handler as a hosted service, passing the step name to the handler ctor + /// + /// Application builder service collection + /// Path to assembly containing handler class + /// Handler type, within the assembly + /// Pipeline step name + public static void AddHandlerAsHostedService(this IServiceCollection services, string assemblyFile, string typeFullName, string stepName) + { + services.AddHandlerAsHostedService(new HandlerConfig(assemblyFile, typeFullName), stepName); + } } diff --git a/service/Core/AppBuilders/HandlerTypeLoader.cs b/service/Core/AppBuilders/HandlerTypeLoader.cs new file mode 100644 index 000000000..7be02abbb --- /dev/null +++ b/service/Core/AppBuilders/HandlerTypeLoader.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Reflection; +using Microsoft.KernelMemory.Configuration; +using Microsoft.KernelMemory.Pipeline; + +namespace Microsoft.KernelMemory.Handlers; + +internal static class HandlerTypeLoader +{ + internal static bool TryGetHandlerType(HandlerConfig config, [NotNullWhen(true)] out Type? handlerType) + { + handlerType = null; + + // If part of the config is empty, the handler is disabled + if (string.IsNullOrEmpty(config.Class) || string.IsNullOrEmpty(config.Assembly)) + { + return false; + } + + // Search the assembly in a few directories + var path = string.Empty; + var assemblyFilePaths = new HashSet + { + config.Assembly, + Path.Join(Environment.CurrentDirectory, config.Assembly), + Path.Join(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), config.Assembly), + }; + + foreach (var p in assemblyFilePaths) + { + if (!File.Exists(p)) { continue; } + + path = p; + break; + } + + // Check if the assembly exists + if (string.IsNullOrEmpty(path)) + { + throw new ConfigurationException($"Handler assembly not found: {config.Assembly}"); + } + + Assembly assembly = Assembly.LoadFrom(path); + + // IPipelineStepHandler + handlerType = assembly.GetType(config.Class); + + if (!typeof(IPipelineStepHandler).IsAssignableFrom(handlerType)) + { + throw new ConfigurationException($"Invalid handler definition: `{config.Class}` class doesn't implement interface {nameof(IPipelineStepHandler)}"); + } + + return true; + } +} diff --git a/service/Core/Configuration/HandlerConfig.cs b/service/Core/Configuration/HandlerConfig.cs new file mode 100644 index 000000000..54cf34d62 --- /dev/null +++ b/service/Core/Configuration/HandlerConfig.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.KernelMemory.Configuration; + +public class HandlerConfig +{ + /// + /// .NET assembly containing the handler class + /// + public string Assembly { get; set; } + + /// + /// .NET class in the assembly, containing the handler logic + /// + public string Class { get; set; } + + public HandlerConfig() + { + this.Assembly = string.Empty; + this.Class = string.Empty; + } + + public HandlerConfig(string assembly, string className) + { + this.Assembly = assembly; + this.Class = className; + } +} diff --git a/service/Abstractions/Configuration/KernelMemoryConfig.cs b/service/Core/Configuration/KernelMemoryConfig.cs similarity index 100% rename from service/Abstractions/Configuration/KernelMemoryConfig.cs rename to service/Core/Configuration/KernelMemoryConfig.cs diff --git a/service/Abstractions/Configuration/ServiceAuthorizationConfig.cs b/service/Core/Configuration/ServiceAuthorizationConfig.cs similarity index 100% rename from service/Abstractions/Configuration/ServiceAuthorizationConfig.cs rename to service/Core/Configuration/ServiceAuthorizationConfig.cs diff --git a/service/Abstractions/Configuration/ServiceConfig.cs b/service/Core/Configuration/ServiceConfig.cs similarity index 81% rename from service/Abstractions/Configuration/ServiceConfig.cs rename to service/Core/Configuration/ServiceConfig.cs index 0cdacbd8c..33dd55a08 100644 --- a/service/Abstractions/Configuration/ServiceConfig.cs +++ b/service/Core/Configuration/ServiceConfig.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; + namespace Microsoft.KernelMemory.Configuration; public class ServiceConfig @@ -20,4 +22,9 @@ public class ServiceConfig /// Web service settings, e.g. whether to expose OpenAPI swagger docs. /// public bool OpenApiEnabled { get; set; } = false; + + /// + /// List of handlers to enable + /// + public Dictionary Handlers { get; set; } = new(); } diff --git a/service/Core/KernelMemoryBuilder.cs b/service/Core/KernelMemoryBuilder.cs index bef80b15d..1f6a4ceb4 100644 --- a/service/Core/KernelMemoryBuilder.cs +++ b/service/Core/KernelMemoryBuilder.cs @@ -37,7 +37,7 @@ private enum ClientTypes // Services required to build the memory client class private readonly IServiceCollection _memoryServiceCollection; - // Services of the host application, when hosting pipeline handlers, e.g. in service mode + // Services of the host application private readonly IServiceCollection? _hostServiceCollection; // List of all the embedding generators to use during ingestion @@ -296,29 +296,6 @@ private MemoryService BuildAsyncClient() var orchestrator = serviceProvider.GetService() ?? throw new ConfigurationException("Unable to build orchestrator"); var searchClient = serviceProvider.GetService() ?? throw new ConfigurationException("Unable to build search client"); - if (this._useDefaultHandlers) - { - if (this._hostServiceCollection == null) - { - const string ClassName = nameof(KernelMemoryBuilder); - const string MethodName = nameof(this.WithoutDefaultHandlers); - throw new ConfigurationException("Host service collection not available: unable to register default handlers. " + - $"If you'd like using the default handlers use `new {ClassName}()`, " + - $"otherwise use `new {ClassName}(...).{MethodName}()` to manage the list of handlers manually."); - } - - // Handlers - Register these handlers to run as hosted services in the caller app. - // At start each hosted handler calls IPipelineOrchestrator.AddHandlerAsync() to register in the orchestrator. - this._hostServiceCollection.AddHandlerAsHostedService(Constants.PipelineStepsExtract); - this._hostServiceCollection.AddHandlerAsHostedService(Constants.PipelineStepsPartition); - this._hostServiceCollection.AddHandlerAsHostedService(Constants.PipelineStepsGenEmbeddings); - this._hostServiceCollection.AddHandlerAsHostedService(Constants.PipelineStepsSaveRecords); - this._hostServiceCollection.AddHandlerAsHostedService(Constants.PipelineStepsSummarize); - this._hostServiceCollection.AddHandlerAsHostedService(Constants.PipelineStepsDeleteDocument); - this._hostServiceCollection.AddHandlerAsHostedService(Constants.PipelineStepsDeleteIndex); - this._hostServiceCollection.AddHandlerAsHostedService(Constants.PipelineStepsDeleteGeneratedFiles); - } - this.CheckForMissingDependencies(); return new MemoryService(orchestrator, searchClient); diff --git a/service/Service/Auth/HttpAuthHandler.cs b/service/Service/Auth/HttpAuthHandler.cs index c05a6aea0..7ef89655f 100644 --- a/service/Service/Auth/HttpAuthHandler.cs +++ b/service/Service/Auth/HttpAuthHandler.cs @@ -13,6 +13,7 @@ public class HttpAuthEndpointFilter : IEndpointFilter public HttpAuthEndpointFilter(ServiceAuthorizationConfig config) { + config.Validate(); this._config = config; } diff --git a/service/Service/ConfigurationBuilderExtensions.cs b/service/Service/ConfigurationBuilderExtensions.cs new file mode 100644 index 000000000..6d44df131 --- /dev/null +++ b/service/Service/ConfigurationBuilderExtensions.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Reflection; +using Microsoft.Extensions.Configuration; +using Microsoft.KernelMemory.Configuration; + +namespace Microsoft.KernelMemory.Service; + +internal static class ConfigurationBuilderExtensions +{ + // ASP.NET env var + private const string AspnetEnvVar = "ASPNETCORE_ENVIRONMENT"; + + public static void AddKMConfigurationSources( + this IConfigurationBuilder builder, + bool useAppSettingsFiles = true, + bool useEnvVars = true, + bool useSecretManager = true, + string? settingsDirectory = null) + { + // Load env var name, either Development or Production + var env = Environment.GetEnvironmentVariable(AspnetEnvVar) ?? string.Empty; + + // Detect the folder containing configuration files + settingsDirectory ??= Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) + ?? Directory.GetCurrentDirectory(); + builder.SetBasePath(settingsDirectory); + + // Add configuration files as sources + if (useAppSettingsFiles) + { + // Add appsettings.json, typically used for default settings, without credentials + var main = Path.Join(settingsDirectory, "appsettings.json"); + if (!File.Exists(main)) + { + throw new ConfigurationException($"appsettings.json not found. Directory: {settingsDirectory}"); + } + + builder.AddJsonFile(main, optional: false); + + // Add appsettings.development.json, used for local overrides and credentials + if (env.Equals("development", StringComparison.OrdinalIgnoreCase)) + { + var f1 = Path.Join(settingsDirectory, "appsettings.development.json"); + var f2 = Path.Join(settingsDirectory, "appsettings.Development.json"); + if (File.Exists(f1)) + { + builder.AddJsonFile(f1, optional: false); + } + else if (File.Exists(f2)) + { + builder.AddJsonFile(f2, optional: false); + } + } + + // Add appsettings.production.json, used for production settings and credentials + if (env.Equals("production", StringComparison.OrdinalIgnoreCase)) + { + var f1 = Path.Join(settingsDirectory, "appsettings.production.json"); + var f2 = Path.Join(settingsDirectory, "appsettings.Production.json"); + if (File.Exists(f1)) + { + builder.AddJsonFile(f1, optional: false); + } + else if (File.Exists(f2)) + { + builder.AddJsonFile(f2, optional: false); + } + } + } + + // Add Secret Manager as source + if (useSecretManager) + { + // GetEntryAssembly method can return null if the library is loaded + // from an unmanaged application, in which case UserSecrets are not supported. + var entryAssembly = Assembly.GetEntryAssembly(); + + // Support for user secrets. Secret Manager doesn't encrypt the stored secrets and + // shouldn't be treated as a trusted store. It's for development purposes only. + // see: https://learn.microsoft.com/aspnet/core/security/app-secrets?#secret-manager + if (entryAssembly != null && env.Equals("development", StringComparison.OrdinalIgnoreCase)) + { + builder.AddUserSecrets(entryAssembly, optional: true); + } + } + + // Add environment variables as source. + // Environment variables can override all the settings provided by the previous sources. + if (useEnvVars) + { + // Support for environment variables overriding the config files + builder.AddEnvironmentVariables(); + } + } +} diff --git a/service/Service/OpenAPI.cs b/service/Service/OpenAPI.cs new file mode 100644 index 000000000..70df0031d --- /dev/null +++ b/service/Service/OpenAPI.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Models; + +namespace Microsoft.KernelMemory.Service; + +internal static class OpenAPI +{ + public static void ConfigureSwagger(this WebApplicationBuilder appBuilder, KernelMemoryConfig config) + { + if (!config.Service.RunWebService || !config.Service.OpenApiEnabled) { return; } + + appBuilder.Services.AddEndpointsApiExplorer(); + + // Note: this call is required even if service auth is disabled + appBuilder.Services.AddSwaggerGen(c => + { + if (!config.ServiceAuthorization.Enabled) { return; } + + const string ReqName = "auth"; + c.AddSecurityDefinition(ReqName, new OpenApiSecurityScheme + { + Description = "The API key to access the API", + Type = SecuritySchemeType.ApiKey, + Scheme = "ApiKeyScheme", + Name = config.ServiceAuthorization.HttpHeaderName, + In = ParameterLocation.Header, + }); + + var scheme = new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Id = ReqName, + Type = ReferenceType.SecurityScheme, + }, + In = ParameterLocation.Header + }; + + var requirement = new OpenApiSecurityRequirement + { + { scheme, new List() } + }; + + c.AddSecurityRequirement(requirement); + }); + } + + public static void UseSwagger(this WebApplication app, KernelMemoryConfig config) + { + if (!config.Service.RunWebService || !config.Service.OpenApiEnabled) { return; } + + // URL: http://localhost:9001/swagger/index.html + app.UseSwagger(); + app.UseSwaggerUI(); + } +} diff --git a/service/Service/Program.cs b/service/Service/Program.cs index 9f288168f..e8c8b231d 100644 --- a/service/Service/Program.cs +++ b/service/Service/Program.cs @@ -3,45 +3,26 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Threading; -using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Microsoft.KernelMemory; using Microsoft.KernelMemory.AI; using Microsoft.KernelMemory.Configuration; using Microsoft.KernelMemory.ContentStorage; using Microsoft.KernelMemory.Diagnostics; -using Microsoft.KernelMemory.InteractiveSetup; using Microsoft.KernelMemory.MemoryStorage; -using Microsoft.KernelMemory.Service; -using Microsoft.KernelMemory.WebService; -using Microsoft.OpenApi.Models; -// ******************************************************** -// ************** APP SETTINGS **************************** -// ******************************************************** - -// Run `dotnet run setup` to run this code and setup the service -if (new[] { "setup", "-setup", "config" }.Contains(args.FirstOrDefault(), StringComparer.OrdinalIgnoreCase)) -{ - Main.InteractiveSetup(args.Skip(1).ToArray(), cfgService: true); -} - -// ******************************************************** -// ************** APP BUILD ******************************* -// ******************************************************** - -// Usual .NET web app builder -var appBuilder = WebApplication.CreateBuilder(); -appBuilder.Configuration.AddEnvironmentVariables(); - -// Note: -// * .NET loads settings from appsettings.json +// KM Configuration: +// +// * Settings are loaded at runtime from multiple sources, merging values. +// Each configuration source can override settings from the previous source: +// - appsettings.json (default values) +// - appsettings.Production.json (only if ASPNETCORE_ENVIRONMENT == "Production", e.g. in the Docker image) +// - appsettings.Development.json (only if ASPNETCORE_ENVIRONMENT == "Development") +// - .NET Secret Manager (only if ASPNETCORE_ENVIRONMENT == "Development" - see https://learn.microsoft.com/aspnet/core/security/app-secrets#secret-manager) +// - environment variables (these can override everything else from the previous sources) +// // * You should set ASPNETCORE_ENVIRONMENT env var if you want to use also appsettings..json // * In production environments: // Set ASPNETCORE_ENVIRONMENT = Production @@ -50,336 +31,97 @@ // * In local dev workstations: // Set ASPNETCORE_ENVIRONMENT = Development // and the app will try to load appsettings.Development.json +// In dev mode the app will also look for settings in .NET Secret Manager +// +// * The app supports also environment variables, e.g. +// to set: KernelMemory.Service.RunWebService = true +// use an env var: KernelMemory__Service__RunWebService = true -// Read the settings, needed below -var config = appBuilder.Configuration.GetSection("KernelMemory").Get() - ?? throw new ConfigurationException("Unable to load configuration"); -config.ServiceAuthorization.Validate(); +namespace Microsoft.KernelMemory.Service; -// OpenAPI/swagger -if (config.Service.RunWebService) +internal sealed class Program { - appBuilder.Services.AddEndpointsApiExplorer(); - appBuilder.Services.AddSwaggerGen(c => + public static void Main(string[] args) { - if (!config.ServiceAuthorization.Enabled) { return; } - - const string ReqName = "auth"; - c.AddSecurityDefinition(ReqName, new OpenApiSecurityScheme - { - Description = "The API key to access the API", - Type = SecuritySchemeType.ApiKey, - Scheme = "ApiKeyScheme", - Name = config.ServiceAuthorization.HttpHeaderName, - In = ParameterLocation.Header, - }); + // *************************** CONFIG WIZARD *************************** - var scheme = new OpenApiSecurityScheme + // Run `dotnet run setup` to run this code and setup the service + if (new[] { "setup", "-setup", "config" }.Contains(args.FirstOrDefault(), StringComparer.OrdinalIgnoreCase)) { - Reference = new OpenApiReference - { - Id = ReqName, - Type = ReferenceType.SecurityScheme, - }, - In = ParameterLocation.Header - }; - - var requirement = new OpenApiSecurityRequirement - { - { scheme, new List() } - }; - - c.AddSecurityRequirement(requirement); - }); -} - -// Inject memory client and its dependencies -// Note: pass the current service collection to the builder, in order to start the pipeline handlers -IKernelMemory memory = new KernelMemoryBuilder(appBuilder.Services) - .FromAppSettings() - // .With...() // in case you need to set something not already defined by `.FromAppSettings()` - .Build(); - -appBuilder.Services.AddSingleton(memory); - -// Build .NET web app as usual -var app = appBuilder.Build(); + InteractiveSetup.Main.InteractiveSetup(args.Skip(1).ToArray(), cfgService: true); + } -Console.WriteLine("***************************************************************************************************************************"); -Console.WriteLine($"* Environment : " + Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "WARNING: ASPNETCORE_ENVIRONMENT env var not defined"); -Console.WriteLine($"* Web service : " + (config.Service.RunWebService ? "Enabled" : "Disabled")); -Console.WriteLine($"* Web service auth : " + (config.ServiceAuthorization.Enabled ? "Enabled" : "Disabled")); -Console.WriteLine($"* Pipeline handlers : " + (config.Service.RunHandlers ? "Enabled" : "Disabled")); -Console.WriteLine($"* OpenAPI swagger : " + (config.Service.OpenApiEnabled ? "Enabled" : "Disabled")); -Console.WriteLine($"* Logging level : {app.Logger.GetLogLevelName()}"); -Console.WriteLine($"* Memory Db : {app.Services.GetService()?.GetType().FullName}"); -Console.WriteLine($"* Content storage : {app.Services.GetService()?.GetType().FullName}"); -Console.WriteLine($"* Embedding generation: {app.Services.GetService()?.GetType().FullName}"); -Console.WriteLine($"* Text generation : {app.Services.GetService()?.GetType().FullName}"); -Console.WriteLine("***************************************************************************************************************************"); + // *************************** APP BUILD ******************************* -// ******************************************************** -// ************** WEB SERVICE ENDPOINTS ******************* -// ******************************************************** + // Usual .NET web app builder with settings from appsettings.json, appsettings..json, and env vars + WebApplicationBuilder appBuilder = WebApplication.CreateBuilder(); + appBuilder.Configuration.AddKMConfigurationSources(); -// ReSharper disable once TemplateIsNotCompileTimeConstantProblem - -if (config.Service.RunWebService) -{ - if (config.Service.OpenApiEnabled) - { - // URL: http://localhost:9001/swagger/index.html - app.UseSwagger(); - app.UseSwaggerUI(); - } + // Read KM settings, needed before building the app. + KernelMemoryConfig config = appBuilder.Configuration.GetSection("KernelMemory").Get() + ?? throw new ConfigurationException("Unable to load configuration"); - DateTimeOffset start = DateTimeOffset.UtcNow; - var authFilter = new HttpAuthEndpointFilter(config.ServiceAuthorization); - - // Simple ping endpoint - app.MapGet("/", () => Results.Ok("Ingestion service is running. " + - "Uptime: " + (DateTimeOffset.UtcNow.ToUnixTimeSeconds() - - start.ToUnixTimeSeconds()) + " secs " + - $"- Environment: {Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")}")) - .AddEndpointFilter(authFilter) - .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status403Forbidden); - - // File upload endpoint - app.MapPost(Constants.HttpUploadEndpoint, async Task ( - HttpRequest request, - IKernelMemory service, - ILogger log, - CancellationToken cancellationToken) => + // Register pipeline handlers if enabled + if (config.Service.RunHandlers) { - log.LogTrace("New upload HTTP request"); + // You can add handlers in the configuration or manually here using one of these syntaxes: + // appBuilder.Services.AddHandlerAsHostedService<...CLASS...>("...STEP NAME..."); + // appBuilder.Services.AddHandlerAsHostedService("...assembly file name...", "...type full name...", "...STEP NAME..."); - // Note: .NET doesn't yet support binding multipart forms including data and files - (HttpDocumentUploadRequest input, bool isValid, string errMsg) - = await HttpDocumentUploadRequest.BindHttpRequestAsync(request, cancellationToken) - .ConfigureAwait(false); - - if (!isValid) + // Register all pipeline handlers defined in the configuration to run as hosted services + foreach (KeyValuePair handlerConfig in config.Service.Handlers) { - log.LogError(errMsg); - return Results.Problem(detail: errMsg, statusCode: 400); + appBuilder.Services.AddHandlerAsHostedService(config: handlerConfig.Value, stepName: handlerConfig.Key); } + } + + // Some OpenAPI Explorer/Swagger dependencies + appBuilder.ConfigureSwagger(config); + + // Inject memory client and its dependencies + // Note: pass the current service collection to the builder, in order to start the pipeline handlers + var memoryBuilder = new KernelMemoryBuilder(appBuilder.Services) + .FromAppSettings(); + + // Build the memory client and make it available for dependency injection + appBuilder.Services.AddSingleton(memoryBuilder.Build()); + + // Build .NET web app as usual + WebApplication app = appBuilder.Build(); + + // Add HTTP endpoints using minimal API (https://learn.microsoft.com/aspnet/core/fundamentals/minimal-apis) + app.ConfigureMinimalAPI(config); + + // *************************** START *********************************** + + var env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + + Console.WriteLine("***************************************************************************************************************************"); + Console.WriteLine($"* Environment : " + (string.IsNullOrEmpty(env) ? "WARNING: ASPNETCORE_ENVIRONMENT env var not defined" : env)); + Console.WriteLine($"* Web service : " + (config.Service.RunWebService ? "Enabled" : "Disabled")); + Console.WriteLine($"* Web service auth : " + (config.ServiceAuthorization.Enabled ? "Enabled" : "Disabled")); + Console.WriteLine($"* Pipeline handlers : " + (config.Service.RunHandlers ? "Enabled" : "Disabled")); + Console.WriteLine($"* OpenAPI swagger : " + (config.Service.OpenApiEnabled ? "Enabled" : "Disabled")); + Console.WriteLine($"* Logging level : {app.Logger.GetLogLevelName()}"); + Console.WriteLine($"* Memory Db : {app.Services.GetService()?.GetType().FullName}"); + Console.WriteLine($"* Content storage : {app.Services.GetService()?.GetType().FullName}"); + Console.WriteLine($"* Embedding generation: {app.Services.GetService()?.GetType().FullName}"); + Console.WriteLine($"* Text generation : {app.Services.GetService()?.GetType().FullName}"); + Console.WriteLine("***************************************************************************************************************************"); + + app.Logger.LogInformation( + "Starting Kernel Memory service, .NET Env: {0}, Log Level: {1}, Web service: {2}, Auth: {3}, Pipeline handlers: {4}", + env, + app.Logger.GetLogLevelName(), + config.Service.RunWebService, + config.ServiceAuthorization.Enabled, + config.Service.RunHandlers); + + if (string.IsNullOrEmpty(env)) + { + app.Logger.LogError("ASPNETCORE_ENVIRONMENT env var not defined."); + } - try - { - // UploadRequest => Document - var documentId = await service.ImportDocumentAsync(input.ToDocumentUploadRequest(), cancellationToken) - .ConfigureAwait(false); - var url = Constants.HttpUploadStatusEndpointWithParams - .Replace(Constants.HttpIndexPlaceholder, input.Index, StringComparison.Ordinal) - .Replace(Constants.HttpDocumentIdPlaceholder, documentId, StringComparison.Ordinal); - return Results.Accepted(url, new UploadAccepted - { - DocumentId = documentId, - Index = input.Index, - Message = "Document upload completed, ingestion pipeline started" - }); - } - catch (Exception e) - { - return Results.Problem(title: "Document upload failed", detail: e.Message, statusCode: 503); - } - }) - .AddEndpointFilter(authFilter) - .Produces(StatusCodes.Status202Accepted) - .Produces(StatusCodes.Status400BadRequest) - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status403Forbidden) - .Produces(StatusCodes.Status503ServiceUnavailable); - - // List of indexes endpoint - app.MapGet(Constants.HttpIndexesEndpoint, - async Task ( - IKernelMemory service, - ILogger log, - CancellationToken cancellationToken) => - { - log.LogTrace("New index list HTTP request"); - - var result = new IndexCollection(); - IEnumerable list = await service.ListIndexesAsync(cancellationToken) - .ConfigureAwait(false); - - foreach (IndexDetails index in list) - { - result.Results.Add(index); - } - - return Results.Ok(result); - }) - .AddEndpointFilter(authFilter) - .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status403Forbidden); - - // Delete index endpoint - app.MapDelete(Constants.HttpIndexesEndpoint, - async Task ( - [FromQuery(Name = Constants.WebServiceIndexField)] - string? index, - IKernelMemory service, - ILogger log, - CancellationToken cancellationToken) => - { - log.LogTrace("New delete document HTTP request"); - await service.DeleteIndexAsync(index: index, cancellationToken) - .ConfigureAwait(false); - // There's no API to check the index deletion progress, so the URL is empty - var url = string.Empty; - return Results.Accepted(url, new DeleteAccepted - { - Index = index ?? string.Empty, - Message = "Index deletion request received, pipeline started" - }); - }) - .AddEndpointFilter(authFilter) - .Produces(StatusCodes.Status202Accepted) - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status403Forbidden); - - // Delete document endpoint - app.MapDelete(Constants.HttpDocumentsEndpoint, - async Task ( - [FromQuery(Name = Constants.WebServiceIndexField)] - string? index, - [FromQuery(Name = Constants.WebServiceDocumentIdField)] - string documentId, - IKernelMemory service, - ILogger log, - CancellationToken cancellationToken) => - { - log.LogTrace("New delete document HTTP request"); - await service.DeleteDocumentAsync(documentId: documentId, index: index, cancellationToken) - .ConfigureAwait(false); - var url = Constants.HttpUploadStatusEndpointWithParams - .Replace(Constants.HttpIndexPlaceholder, index, StringComparison.Ordinal) - .Replace(Constants.HttpDocumentIdPlaceholder, documentId, StringComparison.Ordinal); - return Results.Accepted(url, new DeleteAccepted - { - DocumentId = documentId, - Index = index ?? string.Empty, - Message = "Document deletion request received, pipeline started" - }); - }) - .AddEndpointFilter(authFilter) - .Produces(StatusCodes.Status202Accepted) - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status403Forbidden); - - // Ask endpoint - app.MapPost(Constants.HttpAskEndpoint, - async Task ( - MemoryQuery query, - IKernelMemory service, - ILogger log, - CancellationToken cancellationToken) => - { - log.LogTrace("New search request"); - MemoryAnswer answer = await service.AskAsync( - question: query.Question, - index: query.Index, - filters: query.Filters, - minRelevance: query.MinRelevance, - cancellationToken: cancellationToken) - .ConfigureAwait(false); - return Results.Ok(answer); - }) - .AddEndpointFilter(authFilter) - .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status403Forbidden); - - // Search endpoint - app.MapPost(Constants.HttpSearchEndpoint, - async Task ( - SearchQuery query, - IKernelMemory service, - ILogger log, - CancellationToken cancellationToken) => - { - log.LogTrace("New search HTTP request"); - SearchResult answer = await service.SearchAsync( - query: query.Query, - index: query.Index, - filters: query.Filters, - minRelevance: query.MinRelevance, - limit: query.Limit, - cancellationToken: cancellationToken) - .ConfigureAwait(false); - return Results.Ok(answer); - }) - .AddEndpointFilter(authFilter) - .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status403Forbidden); - - // Document status endpoint - app.MapGet(Constants.HttpUploadStatusEndpoint, - async Task ( - [FromQuery(Name = Constants.WebServiceIndexField)] - string? index, - [FromQuery(Name = Constants.WebServiceDocumentIdField)] - string documentId, - IKernelMemory memoryClient, - ILogger log, - CancellationToken cancellationToken) => - { - log.LogTrace("New document status HTTP request"); - index = IndexExtensions.CleanName(index); - - if (string.IsNullOrEmpty(documentId)) - { - return Results.Problem(detail: $"'{Constants.WebServiceDocumentIdField}' query parameter is missing or has no value", statusCode: 400); - } - - DataPipelineStatus? pipeline = await memoryClient.GetDocumentStatusAsync(documentId: documentId, index: index, cancellationToken) - .ConfigureAwait(false); - if (pipeline == null) - { - return Results.Problem(detail: "Document not found", statusCode: 404); - } - - if (pipeline.Empty) - { - return Results.Problem(detail: "Empty pipeline", statusCode: 404); - } - - return Results.Ok(pipeline); - }) - .AddEndpointFilter(authFilter) - .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status400BadRequest) - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status403Forbidden) - .Produces(StatusCodes.Status404NotFound); -} -#pragma warning restore CA1031 -#pragma warning restore CA2254 - -// ******************************************************** -// ************** START *********************************** -// ******************************************************** - -var env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? string.Empty; -app.Logger.LogInformation( - "Starting Kernel Memory service, .NET Env: {0}, Log Level: {1}, Web service: {2}, Auth: {3}, Pipeline handlers: {4}", - env, - app.Logger.GetLogLevelName(), - config.Service.RunWebService, - config.ServiceAuthorization.Enabled, - config.Service.RunHandlers); - -if (string.IsNullOrEmpty(env)) -{ - app.Logger.LogError("ASPNETCORE_ENVIRONMENT env var not defined."); + app.Run(); + } } - -app.Run(); - - diff --git a/service/Service/README.md b/service/Service/README.md index 16e9baac9..008cb6182 100644 --- a/service/Service/README.md +++ b/service/Service/README.md @@ -5,17 +5,34 @@ settings, ingest data and query for answers. The service is composed by two main components: -* A web service to upload files, to ask questions, and to manage settings. -* A background asynchronous data pipeline to process the files uploaded. +1. A web service to upload files, to ask questions. +2. A background asynchronous data pipeline to process the files uploaded. If you need deploying and scaling the webservice and the pipeline handlers -separately, you can configure the service to enable/disable them. +separately, you can enable/disable each of them via configuration. Once the service is up and running, you can use the **Kernel Memory web client** or simply interact with the Web API. The API schema is available at http://127.0.0.1:9001/swagger/index.html when running the service locally with **OpenAPI** enabled. +# ▶️ Docker support + +If you're looking for a Docker image, we publish a build [here](https://hub.docker.com/r/kernelmemory/service) and +you can use the [Dockerfile](https://github.com/microsoft/kernel-memory/blob/main/Dockerfile) in the repo for custom builds. + +You can test the image in demo mode passing the OPENAI_API_KEY environment variable, otherwise for a full setup +you will need a configuration file first. + +``` +docker run -e OPENAI_API_KEY="..." -p 9001:9001 -it --rm kernelmemory/service +``` + +``` +docker run --volume ./appsettings.Development.json:/app/data/appsettings.Production.json \ + -p 9001:9001 -it --rm kernelmemory/service +``` + # ⚙️ Configuration To quickly set up the service, run the following command and follow the @@ -25,23 +42,23 @@ questions on screen. dotnet run setup ``` -The app will create a configuration file `appsettings.Development.json` +The wizard will create a configuration file `appsettings.Development.json` that you can customize. Look at the comments in `appsettings.json` for details and more advanced options. -Configuration settings can be saved in four places: +Configuration settings can be saved in multiple places, each source can also override the previous +(ie environment variables can be used to override settings in the config files): -1. `appsettings.json`: although possible, it's not recommended, to avoid - risks of leaking secrets in source code repositories. -2. `appsettings.Development.json`: this works only when the environment - variable `ASPNETCORE_ENVIRONMENT` is set to `Development`. -3. `appsettings.Production.json`: this works only when the environment - variable `ASPNETCORE_ENVIRONMENT` is set to `Production`. -4. using **env vars**: preferred method for credentials. Any setting in - appsettings.json can be overridden by env vars. The env var name correspond - to the configuration key name, using `__` (double underscore) as a separator. +1. `appsettings.json`: although possible, it's not recommended, to avoid risks of leaking secrets + in source code repositories. The file is mandatory and is used only for default settings. +2. `appsettings.Development.json`: this is used only when the environment variable `ASPNETCORE_ENVIRONMENT` is set to `Development`. +3. `appsettings.Production.json`: this is used only when the environment variable `ASPNETCORE_ENVIRONMENT` is set to `Production`. +4. [.NET Secret Manager](https://learn.microsoft.com/aspnet/core/security/app-secrets#secret-manager) +5. using **env vars**: preferred method for credentials. Any setting in appsettings.json can be overridden by env vars. + The env var name corresponds to the configuration key name, using `__` (double underscore) as a separator instead of `:`. + For instance `Logging:LogLevel:Default` is set with `Logging__LogLevel__Default`. -# ▶️ Start the service +# ▶️ Start the service from source To run the Kernel Memory service: @@ -88,29 +105,18 @@ The service depends on three main components: get better results with 16k, 32k and bigger models. -* **Vector storage**: service used to persist embeddings. Currently, the - service supports **Azure AI Search**, **Qdrant** and a very basic - in memory vector storage with support for persistence on disk. - Soon we'll add support for more DBs. - - > To use Qdrant locally, install docker and launch Qdrant with: - > - > docker run -it --rm --name qdrant -p 6333:6333 qdrant/qdrant - > or simply use the `run-qdrant.sh` script from the `tools` folder. +* **Vector storage**: service used to persist embeddings. The + service supports **Azure AI Search**, **Qdrant**, **Redis** and other engines, + plus a very basic in memory vector storage with support for persistence on disk + called **SimpleVectorDb**. Unless configured differently, KM uses SimpleVectorDb + storing data in memory only. * **Data ingestion orchestration**: this can run in memory and in the same process, e.g. when working with small files, or run as a service, in which - case it requires persistent queues like **Azure Queues** or **RabbitMQ** - (corelib includes also a basic in memory queue, that might be useful - for tests and demos, with support for persistence on disk). + case it requires persistent queues like **Azure Queues** or **RabbitMQ**. + The Core assembly/package includes also a basic in memory queue called + **SimpleQueues** that might be useful for tests and demos. When running the service, we recommend persistent queues for reliability and horizontal scaling, like Azure Queues and RabbitMQ. - - > To use RabbitMQ locally, install docker and launch RabbitMQ with: - > - > docker run -it --rm --name rabbitmq \ - > -p 5672:5672 -e RABBITMQ_DEFAULT_USER=user -e RABBITMQ_DEFAULT_PASS=password \ - > rabbitmq:3 - > or simply use the `run-rabbitmq.sh` script from the `tools` folder. diff --git a/service/Service/ServiceConfiguration.cs b/service/Service/ServiceConfiguration.cs index ea5f8f99c..b98812da1 100644 --- a/service/Service/ServiceConfiguration.cs +++ b/service/Service/ServiceConfiguration.cs @@ -2,8 +2,6 @@ using System; using System.Collections.Generic; -using System.IO; -using System.Reflection; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.KernelMemory.AI; @@ -79,11 +77,6 @@ private IKernelMemoryBuilder BuildUsingConfiguration(IKernelMemoryBuilder builde // Required by ctors expecting KernelMemoryConfig via DI builder.AddSingleton(this._memoryConfiguration); - if (!this._memoryConfiguration.Service.RunHandlers) - { - builder.WithoutDefaultHandlers(); - } - this.ConfigureMimeTypeDetectionDependency(builder); this.ConfigureTextPartitioning(builder); @@ -122,71 +115,8 @@ private IKernelMemoryBuilder BuildUsingConfiguration(IKernelMemoryBuilder builde private static IConfiguration ReadAppSettings(string? settingsDirectory) { - if (settingsDirectory == null) - { - settingsDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? Directory.GetCurrentDirectory(); - } - - var env = Environment.GetEnvironmentVariable(AspnetEnvVar) ?? string.Empty; var builder = new ConfigurationBuilder(); - - builder.SetBasePath(settingsDirectory); - - var main = Path.Join(settingsDirectory, "appsettings.json"); - if (File.Exists(main)) - { - builder.AddJsonFile(main, optional: false); - } - else - { - throw new ConfigurationException($"appsettings.json not found. Directory: {settingsDirectory}"); - } - - if (env.Equals("development", StringComparison.OrdinalIgnoreCase)) - { - var f1 = Path.Join(settingsDirectory, "appsettings.development.json"); - var f2 = Path.Join(settingsDirectory, "appsettings.Development.json"); - if (File.Exists(f1)) - { - builder.AddJsonFile(f1, optional: false); - } - else if (File.Exists(f2)) - { - builder.AddJsonFile(f2, optional: false); - } - } - - if (env.Equals("production", StringComparison.OrdinalIgnoreCase)) - { - var f1 = Path.Join(settingsDirectory, "appsettings.production.json"); - var f2 = Path.Join(settingsDirectory, "appsettings.Production.json"); - if (File.Exists(f1)) - { - builder.AddJsonFile(f1, optional: false); - } - else if (File.Exists(f2)) - { - builder.AddJsonFile(f2, optional: false); - } - } - - // Support for environment variables overriding the config files - builder.AddEnvironmentVariables(); - - // Support for user secrets. Secret Manager doesn't encrypt the stored secrets and - // shouldn't be treated as a trusted store. It's for development purposes only. - // see: https://learn.microsoft.com/aspnet/core/security/app-secrets?view=aspnetcore-7.0&tabs=windows#secret-manager - if (env.Equals("development", StringComparison.OrdinalIgnoreCase)) - { - // GetEntryAssembly method can return null if this library is loaded - // from an unmanaged application, in which case UserSecrets are not supported. - var entryAssembly = Assembly.GetEntryAssembly(); - if (entryAssembly != null) - { - builder.AddUserSecrets(entryAssembly, optional: true); - } - } - + builder.AddKMConfigurationSources(settingsDirectory: settingsDirectory); return builder.Build(); } diff --git a/service/Service/WebAPIEndpoints.cs b/service/Service/WebAPIEndpoints.cs new file mode 100644 index 000000000..7c30ec826 --- /dev/null +++ b/service/Service/WebAPIEndpoints.cs @@ -0,0 +1,299 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Microsoft.KernelMemory.WebService; + +namespace Microsoft.KernelMemory.Service; + +internal static class WebAPIEndpoints +{ + private static readonly DateTimeOffset s_start = DateTimeOffset.UtcNow; + + public static void ConfigureMinimalAPI(this WebApplication app, KernelMemoryConfig config) + { + if (!config.Service.RunWebService) { return; } + + app.UseSwagger(config); + + var authFilter = new HttpAuthEndpointFilter(config.ServiceAuthorization); + + app.UseGetStatusEndpoint(config, authFilter); + app.UsePostUploadEndpoint(config, authFilter); + app.UseGetIndexesEndpoint(config, authFilter); + app.UseDeleteIndexesEndpoint(config, authFilter); + app.UseDeleteDocumentsEndpoint(config, authFilter); + app.UseAskEndpoint(config, authFilter); + app.UseSearchEndpoint(config, authFilter); + app.UseUploadStatusEndpoint(config, authFilter); + } + + public static void UseGetStatusEndpoint(this WebApplication app, KernelMemoryConfig config, HttpAuthEndpointFilter authFilter) + { + if (!config.Service.RunWebService) { return; } + + // Simple ping endpoint + app.MapGet("/", () => Results.Ok("Ingestion service is running. " + + "Uptime: " + (DateTimeOffset.UtcNow.ToUnixTimeSeconds() + - s_start.ToUnixTimeSeconds()) + " secs " + + $"- Environment: {Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")}")) + .AddEndpointFilter(authFilter) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden); + } + + public static void UsePostUploadEndpoint(this WebApplication app, KernelMemoryConfig config, HttpAuthEndpointFilter authFilter) + { + if (!config.Service.RunWebService) { return; } + + // File upload endpoint + app.MapPost(Constants.HttpUploadEndpoint, async Task ( + HttpRequest request, + IKernelMemory service, + ILogger log, + CancellationToken cancellationToken) => + { + log.LogTrace("New upload HTTP request"); + + // Note: .NET doesn't yet support binding multipart forms including data and files + (HttpDocumentUploadRequest input, bool isValid, string errMsg) + = await HttpDocumentUploadRequest.BindHttpRequestAsync(request, cancellationToken) + .ConfigureAwait(false); + + if (!isValid) + { + log.LogError(errMsg); + return Results.Problem(detail: errMsg, statusCode: 400); + } + + try + { + // UploadRequest => Document + var documentId = await service.ImportDocumentAsync(input.ToDocumentUploadRequest(), cancellationToken) + .ConfigureAwait(false); + var url = Constants.HttpUploadStatusEndpointWithParams + .Replace(Constants.HttpIndexPlaceholder, input.Index, StringComparison.Ordinal) + .Replace(Constants.HttpDocumentIdPlaceholder, documentId, StringComparison.Ordinal); + return Results.Accepted(url, new UploadAccepted + { + DocumentId = documentId, + Index = input.Index, + Message = "Document upload completed, ingestion pipeline started" + }); + } + catch (Exception e) + { + return Results.Problem(title: "Document upload failed", detail: e.Message, statusCode: 503); + } + }) + .AddEndpointFilter(authFilter) + .Produces(StatusCodes.Status202Accepted) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status503ServiceUnavailable); + } + + public static void UseGetIndexesEndpoint(this WebApplication app, KernelMemoryConfig config, HttpAuthEndpointFilter authFilter) + { + if (!config.Service.RunWebService) { return; } + + // List of indexes endpoint + app.MapGet(Constants.HttpIndexesEndpoint, + async Task ( + IKernelMemory service, + ILogger log, + CancellationToken cancellationToken) => + { + log.LogTrace("New index list HTTP request"); + + var result = new IndexCollection(); + IEnumerable list = await service.ListIndexesAsync(cancellationToken) + .ConfigureAwait(false); + + foreach (IndexDetails index in list) + { + result.Results.Add(index); + } + + return Results.Ok(result); + }) + .AddEndpointFilter(authFilter) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden); + } + + public static void UseDeleteIndexesEndpoint(this WebApplication app, KernelMemoryConfig config, HttpAuthEndpointFilter authFilter) + { + if (!config.Service.RunWebService) { return; } + + // Delete index endpoint + app.MapDelete(Constants.HttpIndexesEndpoint, + async Task ( + [FromQuery(Name = Constants.WebServiceIndexField)] + string? index, + IKernelMemory service, + ILogger log, + CancellationToken cancellationToken) => + { + log.LogTrace("New delete document HTTP request"); + await service.DeleteIndexAsync(index: index, cancellationToken) + .ConfigureAwait(false); + // There's no API to check the index deletion progress, so the URL is empty + var url = string.Empty; + return Results.Accepted(url, new DeleteAccepted + { + Index = index ?? string.Empty, + Message = "Index deletion request received, pipeline started" + }); + }) + .AddEndpointFilter(authFilter) + .Produces(StatusCodes.Status202Accepted) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden); + } + + public static void UseDeleteDocumentsEndpoint(this WebApplication app, KernelMemoryConfig config, HttpAuthEndpointFilter authFilter) + { + if (!config.Service.RunWebService) { return; } + + // Delete document endpoint + app.MapDelete(Constants.HttpDocumentsEndpoint, + async Task ( + [FromQuery(Name = Constants.WebServiceIndexField)] + string? index, + [FromQuery(Name = Constants.WebServiceDocumentIdField)] + string documentId, + IKernelMemory service, + ILogger log, + CancellationToken cancellationToken) => + { + log.LogTrace("New delete document HTTP request"); + await service.DeleteDocumentAsync(documentId: documentId, index: index, cancellationToken) + .ConfigureAwait(false); + var url = Constants.HttpUploadStatusEndpointWithParams + .Replace(Constants.HttpIndexPlaceholder, index, StringComparison.Ordinal) + .Replace(Constants.HttpDocumentIdPlaceholder, documentId, StringComparison.Ordinal); + return Results.Accepted(url, new DeleteAccepted + { + DocumentId = documentId, + Index = index ?? string.Empty, + Message = "Document deletion request received, pipeline started" + }); + }) + .AddEndpointFilter(authFilter) + .Produces(StatusCodes.Status202Accepted) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden); + } + + public static void UseAskEndpoint(this WebApplication app, KernelMemoryConfig config, HttpAuthEndpointFilter authFilter) + { + if (!config.Service.RunWebService) { return; } + + // Ask endpoint + app.MapPost(Constants.HttpAskEndpoint, + async Task ( + MemoryQuery query, + IKernelMemory service, + ILogger log, + CancellationToken cancellationToken) => + { + log.LogTrace("New search request"); + MemoryAnswer answer = await service.AskAsync( + question: query.Question, + index: query.Index, + filters: query.Filters, + minRelevance: query.MinRelevance, + cancellationToken: cancellationToken) + .ConfigureAwait(false); + return Results.Ok(answer); + }) + .AddEndpointFilter(authFilter) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden); + } + + public static void UseSearchEndpoint(this WebApplication app, KernelMemoryConfig config, HttpAuthEndpointFilter authFilter) + { + if (!config.Service.RunWebService) { return; } + + // Search endpoint + app.MapPost(Constants.HttpSearchEndpoint, + async Task ( + SearchQuery query, + IKernelMemory service, + ILogger log, + CancellationToken cancellationToken) => + { + log.LogTrace("New search HTTP request"); + SearchResult answer = await service.SearchAsync( + query: query.Query, + index: query.Index, + filters: query.Filters, + minRelevance: query.MinRelevance, + limit: query.Limit, + cancellationToken: cancellationToken) + .ConfigureAwait(false); + return Results.Ok(answer); + }) + .AddEndpointFilter(authFilter) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden); + } + + public static void UseUploadStatusEndpoint(this WebApplication app, KernelMemoryConfig config, HttpAuthEndpointFilter authFilter) + { + if (!config.Service.RunWebService) { return; } + + // Document status endpoint + app.MapGet(Constants.HttpUploadStatusEndpoint, + async Task ( + [FromQuery(Name = Constants.WebServiceIndexField)] + string? index, + [FromQuery(Name = Constants.WebServiceDocumentIdField)] + string documentId, + IKernelMemory memoryClient, + ILogger log, + CancellationToken cancellationToken) => + { + log.LogTrace("New document status HTTP request"); + index = IndexExtensions.CleanName(index); + + if (string.IsNullOrEmpty(documentId)) + { + return Results.Problem(detail: $"'{Constants.WebServiceDocumentIdField}' query parameter is missing or has no value", statusCode: 400); + } + + DataPipelineStatus? pipeline = await memoryClient.GetDocumentStatusAsync(documentId: documentId, index: index, cancellationToken) + .ConfigureAwait(false); + if (pipeline == null) + { + return Results.Problem(detail: "Document not found", statusCode: 404); + } + + if (pipeline.Empty) + { + return Results.Problem(detail: "Empty pipeline", statusCode: 404); + } + + return Results.Ok(pipeline); + }) + .AddEndpointFilter(authFilter) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound); + } +} diff --git a/service/Service/appsettings.json b/service/Service/appsettings.json index c72ae4d91..018398e2b 100644 --- a/service/Service/appsettings.json +++ b/service/Service/appsettings.json @@ -29,11 +29,54 @@ // Whether to run the web service that allows to upload files and search memory // Use these booleans to deploy the web service and the handlers on same/different VMs "RunWebService": true, + // Whether to expose OpenAPI swagger UI at http://127.0.0.1:9001/swagger/index.html + "OpenApiEnabled": false, // Whether to run the asynchronous pipeline handlers // Use these booleans to deploy the web service and the handlers on same/different VMs "RunHandlers": true, - // Whether to expose OpenAPI swagger UI at http://127.0.0.1:9001/swagger/index.html - "OpenApiEnabled": false + // Handlers to load for callers (use "steps" to choose which handlers + // to use to process a document during the ingestion) + "Handlers": { + // The key, e.g. "extract", is the name used when starting a pipeline with specific steps + "extract": { + "Assembly": "Microsoft.KernelMemory.Core.dll", + "Class": "Microsoft.KernelMemory.Handlers.TextExtractionHandler" + }, + "partition": { + "Assembly": "Microsoft.KernelMemory.Core.dll", + "Class": "Microsoft.KernelMemory.Handlers.TextPartitioningHandler" + }, + "gen_embeddings": { + "Assembly": "Microsoft.KernelMemory.Core.dll", + "Class": "Microsoft.KernelMemory.Handlers.GenerateEmbeddingsHandler" + }, + "save_records": { + "Assembly": "Microsoft.KernelMemory.Core.dll", + "Class": "Microsoft.KernelMemory.Handlers.SaveRecordsHandler" + }, + "summarize": { + "Assembly": "Microsoft.KernelMemory.Core.dll", + "Class": "Microsoft.KernelMemory.Handlers.SummarizationHandler" + }, + "delete_generated_files": { + "Assembly": "Microsoft.KernelMemory.Core.dll", + "Class": "Microsoft.KernelMemory.Handlers.DeleteGeneratedFilesHandler" + }, + "private_delete_document": { + "Assembly": "Microsoft.KernelMemory.Core.dll", + "Class": "Microsoft.KernelMemory.Handlers.DeleteDocumentHandler" + }, + "private_delete_index": { + "Assembly": "Microsoft.KernelMemory.Core.dll", + "Class": "Microsoft.KernelMemory.Handlers.DeleteIndexHandler" + }, + "disabled_handler_example": { + // Setting Class or Assembly to "" in appsettings.Development.json or appsettings.Production.json + // allows to remove a handler defined in appsettings.json + "Class": "", + "Assembly": "" + } + } }, "ServiceAuthorization": { // Whether clients must provide some credentials to interact with the HTTP API diff --git a/service/Service/run.cmd b/service/Service/run.cmd index 22eb3fcac..cdaa2038c 100644 --- a/service/Service/run.cmd +++ b/service/Service/run.cmd @@ -1,5 +1,5 @@ @echo off -dotnet restore -dotnet build -cmd /C "set ASPNETCORE_ENVIRONMENT=Development && dotnet run" +dotnet clean +dotnet build -c Debug -p "SolutionName=KernelMemory" +cmd /C "set ASPNETCORE_ENVIRONMENT=Development && dotnet run --no-build --no-restore" diff --git a/service/Service/run.sh b/service/Service/run.sh index d8c46fb7d..deaa07cb8 100755 --- a/service/Service/run.sh +++ b/service/Service/run.sh @@ -2,9 +2,8 @@ set -e -cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/" - -dotnet restore -dotnet build -ASPNETCORE_ENVIRONMENT=Development dotnet run +cd "$(dirname "${BASH_SOURCE[0]:-$0}")" +dotnet clean +dotnet build -c Debug -p "SolutionName=KernelMemory" +ASPNETCORE_ENVIRONMENT=Development dotnet run --no-build --no-restore diff --git a/service/Service/setup.cmd b/service/Service/setup.cmd index 75e18a50b..92ab5b799 100644 --- a/service/Service/setup.cmd +++ b/service/Service/setup.cmd @@ -1,5 +1,5 @@ @echo off -dotnet restore -dotnet build -dotnet run setup +dotnet clean +dotnet build -c Debug -p "SolutionName=KernelMemory" +cmd /C "set ASPNETCORE_ENVIRONMENT=Development && dotnet run setup --no-build --no-restore" diff --git a/service/Service/setup.sh b/service/Service/setup.sh index f2ee1e55a..ee22f4d11 100755 --- a/service/Service/setup.sh +++ b/service/Service/setup.sh @@ -1,9 +1,9 @@ -# This script can be used from the repo or from the docker image +# This script is used also in the Docker image if [ -f "Microsoft.KernelMemory.ServiceAssembly.dll" ]; then dotnet Microsoft.KernelMemory.ServiceAssembly.dll setup else - dotnet restore - dotnet build - dotnet run setup + dotnet clean + dotnet build -c Debug -p "SolutionName=KernelMemory" + ASPNETCORE_ENVIRONMENT=Development dotnet run setup --no-build --no-restore fi \ No newline at end of file diff --git a/service/tests/Core.FunctionalTests/DefaultTestCases/IndexListTest.cs b/service/tests/Core.FunctionalTests/DefaultTestCases/IndexListTest.cs index 87293c2ce..9275b1eef 100644 --- a/service/tests/Core.FunctionalTests/DefaultTestCases/IndexListTest.cs +++ b/service/tests/Core.FunctionalTests/DefaultTestCases/IndexListTest.cs @@ -58,25 +58,25 @@ public static async Task ItListsIndexes(IKernelMemory memory, Action log while (!await memory.IsDocumentReadyAsync(documentId: id1, index: indexName1)) { - log("Waiting for memory ingestion to complete..."); + log($"[id1: {id1}] Waiting for memory ingestion to complete..."); await Task.Delay(TimeSpan.FromSeconds(2)); } while (!await memory.IsDocumentReadyAsync(documentId: id2, index: indexName2)) { - log("Waiting for memory ingestion to complete..."); + log($"[id2: {id2}] Waiting for memory ingestion to complete..."); await Task.Delay(TimeSpan.FromSeconds(2)); } while (!await memory.IsDocumentReadyAsync(documentId: id3, index: indexNameWithDashes)) { - log("Waiting for memory ingestion to complete..."); + log($"[id3: {id3}] Waiting for memory ingestion to complete..."); await Task.Delay(TimeSpan.FromSeconds(2)); } while (!await memory.IsDocumentReadyAsync(documentId: id4, index: indexNameWithUnderscores)) { - log("Waiting for memory ingestion to complete..."); + log($"[id4: {id4}] Waiting for memory ingestion to complete..."); await Task.Delay(TimeSpan.FromSeconds(2)); } diff --git a/tools/InteractiveSetup/InteractiveSetup.csproj b/tools/InteractiveSetup/InteractiveSetup.csproj index bd9e04fb8..7c8474a77 100644 --- a/tools/InteractiveSetup/InteractiveSetup.csproj +++ b/tools/InteractiveSetup/InteractiveSetup.csproj @@ -9,8 +9,8 @@ - - + + diff --git a/tools/README.md b/tools/README.md index 578a563e0..a3865e77d 100644 --- a/tools/README.md +++ b/tools/README.md @@ -1,4 +1,6 @@ -# upload-file.sh +# Kernel memory web service scripts + +### upload-file.sh Simple client for command line uploads to Kernel Memory. @@ -8,7 +10,7 @@ Instructions: ./upload-file.sh -h ``` -# ask.sh +### ask.sh Simple client for asking questions about your documents from the command line. @@ -18,7 +20,7 @@ Instructions: ./ask.sh -h ``` -# search.sh +### search.sh Simple client for searching your indexed documents from the command line. @@ -28,14 +30,41 @@ Instructions: ./search.sh -h ``` -# run-qdrant.sh +# Vector DB scripts + +### run-elasticsearch.sh + +Script to start Elasticsearch using Docker for local development/debugging. + +Elasticsearch is used to store and search vectors, as an alternative to +[Azure AI Search](https://azure.microsoft.com/products/ai-services/ai-search/). + +### run-mssql.sh + +Script to start MS SQL using Docker for local development/debugging. + +MS SQL is used to store and search vectors, as an alternative to +[Azure AI Search](https://azure.microsoft.com/products/ai-services/ai-search/). + +### run-qdrant.sh Script to start Qdrant using Docker, for local development/debugging. Qdrant is used to store and search vectors, as an alternative to [Azure AI Search](https://azure.microsoft.com/products/ai-services/ai-search/). -# run-rabbitmq.sh +### run-redis.sh + +Script to start Redis using Docker, for local development/debugging. +This will run Redis on port 6379, as well as running a popular Redis +GUI, [RedisInsight](https://redis.com/redis-enterprise/redis-insight/), on port 8001. + +Redis is used to store and search vectors, as an alternative to +[Azure AI Search](https://azure.microsoft.com/products/ai-services/ai-search/). + +# Orchestration queues scripts + +### run-rabbitmq.sh Script to start RabbitMQ using Docker, for local development/debugging. @@ -43,10 +72,16 @@ RabbitMQ is used to provides queues for the asynchronous pipelines, as an alternative to [Azure Queues](https://learn.microsoft.com/azure/storage/queues/storage-queues-introduction). -# run-redis.sh +# Kernel memory runtime scripts -Script to start Redis using Docker, for local development/debugging. -This will run Redis on port 6379, as well as running a popular Redis GUI, [RedisInsight](https://redis.com/redis-enterprise/redis-insight/), on port 8001. +### run-km-service.sh -Redis is used to store and search vectors, as an alternative to -[Azure AI Search](https://azure.microsoft.com/products/ai-services/ai-search/). \ No newline at end of file +Script to start KM service from source code, using KM nuget packages where configured as such. + +### run-km-service-from-source.sh + +Script to start KM service from local source code, ignoring KM nuget packages. + +### setup-km-service.sh + +Script to start KM service configuration wizard and create an appsettings.Development.json file. diff --git a/service/Service/run-from-source.sh b/tools/run-km-service-from-source.sh similarity index 81% rename from service/Service/run-from-source.sh rename to tools/run-km-service-from-source.sh index 0fe022143..8f522217b 100755 --- a/service/Service/run-from-source.sh +++ b/tools/run-km-service-from-source.sh @@ -1,12 +1,19 @@ +#!/usr/bin/env bash + # Use this script to avoid relying on KM published packages, # and use the local source code instead, e.g. in case changes # are being made to Abstractions, Core, etc. +set -e + cd "$(dirname "${BASH_SOURCE[0]:-$0}")" +cd ../service/Service + +dotnet clean # Build using a different solution name. The same can be done using a KernelMemoryDev.sln file. # Note: dotnet run doesn't support [ -p "SolutionName=KernelMemoryDev" ] dotnet build -c Debug -p "SolutionName=KernelMemoryDev" # Run the special build, detached from external KM nugets. -dotnet run -c Debug --no-build +dotnet run -c Debug --no-build --no-restore diff --git a/tools/run-km-service.sh b/tools/run-km-service.sh new file mode 100755 index 000000000..6769100a5 --- /dev/null +++ b/tools/run-km-service.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "${BASH_SOURCE[0]:-$0}")" +cd ../service/Service + +dotnet clean +dotnet build -c Debug -p "SolutionName=KernelMemory" +ASPNETCORE_ENVIRONMENT=Development dotnet run --no-build --no-restore diff --git a/tools/run-service.sh b/tools/run-service.sh deleted file mode 100755 index a44d086bf..000000000 --- a/tools/run-service.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash - -set -e - -cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/" -cd ../service/Service - -dotnet restore -dotnet build -ASPNETCORE_ENVIRONMENT=Development dotnet run diff --git a/tools/setup-km-service.sh b/tools/setup-km-service.sh new file mode 100755 index 000000000..b29669a47 --- /dev/null +++ b/tools/setup-km-service.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +set -e + +cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/" +cd ../service/Service + +dotnet clean +dotnet build -c Debug -p "SolutionName=KernelMemory" +ASPNETCORE_ENVIRONMENT=Development dotnet run setup --no-build --no-restore diff --git a/tools/setup-service.sh b/tools/setup-service.sh deleted file mode 100755 index bb7d325b9..000000000 --- a/tools/setup-service.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash - -set -e - -cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/" -cd ../service/Service - -dotnet restore -dotnet build -dotnet run setup -