mirror of
https://github.com/marcominerva/SqlDatabaseVectorSearch.git
synced 2026-08-04 09:48:57 +00:00
d4c88ab10f
Removed the custom TraceHttpClientHandler and switched OpenAIClient initialization to use the default HTTP transport. Cleaned up unused using directives. Updated SqlDatabaseVectorSearch_WebApp.png with a new version.
331 lines
13 KiB
C#
331 lines
13 KiB
C#
using System.ClientModel;
|
|
using System.Net.Mime;
|
|
using System.Text;
|
|
using System.Text.Json.Serialization;
|
|
using FluentValidation;
|
|
using Microsoft.Agents.AI;
|
|
using Microsoft.Agents.AI.Hosting;
|
|
using Microsoft.Agents.AI.Workflows;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.AI;
|
|
using OpenAI;
|
|
using OpenAI.Responses;
|
|
using SqlDatabaseVectorSearch.Components;
|
|
using SqlDatabaseVectorSearch.ContentDecoders;
|
|
using SqlDatabaseVectorSearch.Data;
|
|
using SqlDatabaseVectorSearch.Extensions;
|
|
using SqlDatabaseVectorSearch.Services;
|
|
using SqlDatabaseVectorSearch.Settings;
|
|
using SqlDatabaseVectorSearch.TextChunkers;
|
|
using SqlDatabaseVectorSearch.Workflows;
|
|
using TinyHelpers.AspNetCore.Extensions;
|
|
using TinyHelpers.AspNetCore.OpenApi;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
builder.Configuration.AddJsonFile("appsettings.local.json", optional: true, reloadOnChange: true);
|
|
|
|
// Add services to the container.
|
|
var aiSettings = builder.Services.ConfigureAndGet<AzureOpenAISettings>(builder.Configuration, "AzureOpenAI")!;
|
|
var appSettings = builder.Services.ConfigureAndGet<AppSettings>(builder.Configuration, nameof(AppSettings))!;
|
|
|
|
builder.Services.AddRazorComponents()
|
|
.AddInteractiveServerComponents();
|
|
|
|
builder.Services.AddBlazorBootstrap();
|
|
|
|
builder.Services.ConfigureHttpJsonOptions(options =>
|
|
{
|
|
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
|
});
|
|
|
|
builder.Services.AddSingleton(TimeProvider.System);
|
|
|
|
builder.Services.AddDbContext<ApplicationDbContext>(options =>
|
|
{
|
|
var connectionString = builder.Configuration.GetConnectionString("SqlConnection")!;
|
|
|
|
if (connectionString.Contains("database.windows.net"))
|
|
{
|
|
options.UseAzureSql(connectionString);
|
|
}
|
|
else
|
|
{
|
|
options.UseSqlServer(connectionString, sqlOptions =>
|
|
{
|
|
sqlOptions.EnableRetryOnFailure(maxRetryCount: 5, maxRetryDelay: TimeSpan.FromSeconds(10), errorNumbersToAdd: null);
|
|
});
|
|
}
|
|
|
|
options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
|
|
});
|
|
|
|
builder.Services.ConfigureHttpClientDefaults(configure =>
|
|
{
|
|
configure.AddStandardResilienceHandler(options =>
|
|
{
|
|
options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(15);
|
|
options.TotalRequestTimeout.Timeout = TimeSpan.FromMinutes(2);
|
|
});
|
|
});
|
|
|
|
builder.Services.AddSingleton(_ =>
|
|
{
|
|
var embeddingClient = new OpenAIClient(new ApiKeyCredential(aiSettings.Embedding.ApiKey), new()
|
|
{
|
|
Endpoint = new(aiSettings.Embedding.Endpoint),
|
|
}).GetEmbeddingClient(aiSettings.Embedding.Deployment).AsIEmbeddingGenerator(aiSettings.Embedding.Dimensions);
|
|
|
|
return embeddingClient;
|
|
});
|
|
|
|
builder.Services.AddChatClient(_ =>
|
|
{
|
|
var chatClient = new OpenAIClient(new ApiKeyCredential(aiSettings.ChatCompletion.ApiKey), new()
|
|
{
|
|
Endpoint = new(aiSettings.ChatCompletion.Endpoint)
|
|
}).GetResponsesClient().AsIChatClientWithStoredOutputDisabled(aiSettings.ChatCompletion.Deployment);
|
|
|
|
return chatClient;
|
|
});
|
|
|
|
builder.Services.AddKeyedSingleton<IContentDecoder, PdfContentDecoder>(MediaTypeNames.Application.Pdf);
|
|
builder.Services.AddKeyedSingleton<IContentDecoder, DocxContentDecoder>("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
|
|
builder.Services.AddKeyedSingleton<IContentDecoder, TextContentDecoder>(MediaTypeNames.Text.Plain);
|
|
builder.Services.AddKeyedSingleton<IContentDecoder, TextContentDecoder>(MediaTypeNames.Text.Markdown);
|
|
|
|
builder.Services.AddKeyedSingleton<ITextChunker, DefaultTextChunker>(KeyedService.AnyKey);
|
|
builder.Services.AddKeyedSingleton<ITextChunker, MarkdownTextChunker>(MediaTypeNames.Text.Markdown);
|
|
|
|
builder.Services.AddSingleton<TokenizerService>();
|
|
|
|
builder.Services.AddScoped<DocumentService>();
|
|
builder.Services.AddScoped<VectorSearchService>();
|
|
builder.Services.AddScoped<ContextProvider>();
|
|
|
|
builder.Services.AddSingleton<GenerateEmbeddingExecutor>();
|
|
builder.Services.AddScoped<StoreEmbeddingExecutor>(); // This executor is registered as scoped because it uses the DbContext, which is also scoped.
|
|
|
|
builder.AddWorkflow("EmbeddingWorkflow", (services, key) =>
|
|
{
|
|
var generateEmbeddingExecutor = services.GetRequiredService<GenerateEmbeddingExecutor>();
|
|
var storeEmbeddingExecutor = services.GetRequiredService<StoreEmbeddingExecutor>();
|
|
|
|
var workflow = new WorkflowBuilder(generateEmbeddingExecutor).WithName(key)
|
|
.AddEdge(generateEmbeddingExecutor, storeEmbeddingExecutor)
|
|
.WithOutputFrom(storeEmbeddingExecutor)
|
|
.Build(validateOrphans: true);
|
|
|
|
return workflow;
|
|
}, ServiceLifetime.Scoped);
|
|
|
|
builder.Services.AddAIAgent("ReformulationAgent", (services, key) =>
|
|
{
|
|
var chatClient = services.GetRequiredService<IChatClient>();
|
|
|
|
return chatClient.AsAIAgent(new ChatClientAgentOptions()
|
|
{
|
|
Id = key.ToLowerInvariant(),
|
|
Name = key,
|
|
ChatOptions = new()
|
|
{
|
|
Instructions = """
|
|
You are a helpful assistant that reformulates questions to perform embeddings search.
|
|
Your task is to reformulate the question taking into account the context of the chat.
|
|
The reformulated question must always explicitly contain the subject of the question.
|
|
|
|
You MUST reformulate the question in the SAME language as the user's question.
|
|
For example, if the user asks a question in English, the reformulated question MUST be in English. If the user asks in Italian, the reformulated question MUST be in Italian.
|
|
|
|
Never add "in this chat", "in the context of this chat", "in the context of our conversation", "search for" or something like that in your answer.
|
|
Your answer must contain only the reformulated question and nothing else.
|
|
Never add follow-up messages, clarifications, notes, disclaimers, or requests for more information such as "if you give me more information, I can be more precise".
|
|
""",
|
|
Reasoning = new()
|
|
{
|
|
Effort = ReasoningEffort.None,
|
|
Output = ReasoningOutput.None
|
|
}
|
|
},
|
|
ChatHistoryProvider = new InMemoryChatHistoryProvider(new()
|
|
{
|
|
// The reformulation agent reads the conversation only to get the context it needs, but its own questions and answers
|
|
// must not pollute the session: the history is kept clean for the RAG agent, so nothing is stored back.
|
|
StorageInputRequestMessageFilter = _ => [],
|
|
StorageInputResponseMessageFilter = _ => []
|
|
})
|
|
},
|
|
loggerFactory: services.GetRequiredService<ILoggerFactory>(),
|
|
services: services);
|
|
});
|
|
|
|
var textSearchOptions = new TextSearchProviderOptions()
|
|
{
|
|
ContextFormatter = results =>
|
|
{
|
|
var sb = new StringBuilder();
|
|
|
|
sb.AppendLine("## Additional Context");
|
|
sb.AppendLine("Use the excerpts below to answer the user.");
|
|
sb.AppendLine("Citation rules:");
|
|
sb.AppendLine("- Do NOT add inline citations.");
|
|
sb.AppendLine("- At the END of your answer, add a sources section that follows this template exactly, where the sources label and the page label are localized in the same language as the user's question:");
|
|
sb.AppendLine(" *localized-sources-label*");
|
|
sb.AppendLine(" 1. **SourceName**, localized-page-label PageNumber: *supporting excerpt of about 20-30 words*");
|
|
sb.AppendLine("- Omit the page label and the page number when the page number is not available.");
|
|
sb.AppendLine("- Do NOT use headings or links in the sources section.");
|
|
sb.AppendLine("- Include ONLY sources you actually used. No duplicates.");
|
|
sb.AppendLine();
|
|
|
|
sb.AppendLine("### Sources");
|
|
foreach (var (i, r) in results.Index())
|
|
{
|
|
sb.AppendLine($"[{i + 1}] {GetSourceName(r, i)}");
|
|
sb.AppendLine(r.Text);
|
|
sb.AppendLine("---");
|
|
}
|
|
|
|
return sb.ToString();
|
|
|
|
static string GetSourceName(TextSearchProvider.TextSearchResult result, int index)
|
|
{
|
|
var name = string.IsNullOrWhiteSpace(result.SourceName) ? $"Source {index + 1}" : result.SourceName;
|
|
var pageNumber = result.RawRepresentation is int number ? number : (int?)null;
|
|
var pageText = pageNumber.HasValue ? $", page {pageNumber}" : string.Empty;
|
|
|
|
return $"{name}{pageText}";
|
|
}
|
|
}
|
|
};
|
|
|
|
builder.Services.AddHybridCache(options =>
|
|
{
|
|
options.DefaultEntryOptions = new()
|
|
{
|
|
LocalCacheExpiration = appSettings.MessageExpiration
|
|
};
|
|
});
|
|
builder.Services.AddSingleton<HybridCacheSessionStoreService>();
|
|
|
|
builder.Services.AddAIAgent("RagAgent", (services, key) =>
|
|
{
|
|
var chatClient = services.GetRequiredService<IChatClient>();
|
|
|
|
return chatClient.AsAIAgent(new ChatClientAgentOptions
|
|
{
|
|
Id = key.ToLowerInvariant(),
|
|
Name = key,
|
|
ChatOptions = new()
|
|
{
|
|
Instructions = """
|
|
You are a helpful assistant. Answer questions using the provided context and cite the source document when available.
|
|
You can use only the information provided in this chat to answer questions. If you don't know the answer, reply suggesting to refine the question.
|
|
|
|
For example, if the user asks "What is the capital of Italy?" and in this chat there isn't information about Italy, you should reply something like:
|
|
- This information isn't available in the given context.
|
|
- I'm sorry, I don't know the answer to that question.
|
|
- I don't have that information.
|
|
- I don't know.
|
|
- Given the context, I can't answer that question.
|
|
- I'm sorry, I don't have enough information to answer that question.
|
|
|
|
Never answer questions that are not related to this chat.
|
|
""",
|
|
Reasoning = new()
|
|
{
|
|
Effort = ReasoningEffort.Low,
|
|
Output = ReasoningOutput.None
|
|
}
|
|
},
|
|
ChatHistoryProvider = new InMemoryChatHistoryProvider(new()
|
|
{
|
|
ChatReducer = new MessageCountingChatReducer(appSettings.MessageLimit),
|
|
ReducerTriggerEvent = InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded,
|
|
StorageInputRequestMessageFilter = messages =>
|
|
{
|
|
return messages.Where(m => m.GetAgentRequestMessageSourceType() != AgentRequestMessageSourceType.ChatHistory
|
|
&& m.GetAgentRequestMessageSourceType() != AgentRequestMessageSourceType.AIContextProvider);
|
|
}
|
|
}),
|
|
AIContextProviders = [new TextSearchProvider(services.GetRequiredService<ContextProvider>().SearchAsync, textSearchOptions)]
|
|
},
|
|
loggerFactory: services.GetRequiredService<ILoggerFactory>(),
|
|
services: services);
|
|
}, ServiceLifetime.Scoped)
|
|
.WithSessionStore((services, _) =>
|
|
{
|
|
var sessionStore = services.GetRequiredService<HybridCacheSessionStoreService>();
|
|
return sessionStore;
|
|
}, withIsolation: false);
|
|
|
|
builder.Services.AddOpenApi(options =>
|
|
{
|
|
options.RemoveServerList();
|
|
options.AddDefaultProblemDetailsResponse();
|
|
});
|
|
|
|
ValidatorOptions.Global.LanguageManager.Enabled = false;
|
|
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
|
|
|
|
builder.Services.AddDefaultProblemDetails();
|
|
builder.Services.AddDefaultExceptionHandler();
|
|
|
|
var app = builder.Build();
|
|
await ConfigureDatabaseAsync(app.Services);
|
|
|
|
// Configure the HTTP request pipeline.
|
|
app.UseHttpsRedirection();
|
|
|
|
app.UseWhen(context => context.IsWebRequest(), builder =>
|
|
{
|
|
if (!app.Environment.IsDevelopment())
|
|
{
|
|
builder.UseExceptionHandler("/error", createScopeForErrors: true);
|
|
|
|
// The default HSTS value is 30 days.
|
|
builder.UseHsts();
|
|
}
|
|
|
|
builder.UseStatusCodePagesWithRedirects("/error?code={0}");
|
|
});
|
|
|
|
app.UseWhen(context => context.IsApiRequest(), builder =>
|
|
{
|
|
app.UseExceptionHandler(new ExceptionHandlerOptions
|
|
{
|
|
SuppressDiagnosticsCallback = _ => false,
|
|
StatusCodeSelector = exception => exception switch
|
|
{
|
|
NotSupportedException => StatusCodes.Status501NotImplemented,
|
|
_ => StatusCodes.Status500InternalServerError
|
|
}
|
|
});
|
|
|
|
builder.UseStatusCodePages();
|
|
});
|
|
|
|
app.MapOpenApi();
|
|
app.UseSwaggerUI(options =>
|
|
{
|
|
options.SwaggerEndpoint("/openapi/v1.json", builder.Environment.ApplicationName);
|
|
});
|
|
|
|
app.UseRouting();
|
|
app.UseRequestLocalization();
|
|
|
|
app.UseAntiforgery();
|
|
|
|
app.MapStaticAssets();
|
|
app.MapRazorComponents<App>()
|
|
.AddInteractiveServerRenderMode();
|
|
|
|
app.MapEndpoints();
|
|
|
|
app.Run();
|
|
|
|
static async Task ConfigureDatabaseAsync(IServiceProvider serviceProvider)
|
|
{
|
|
await using var scope = serviceProvider.CreateAsyncScope();
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
|
|
|
await dbContext.Database.MigrateAsync();
|
|
} |