From 7af4214d6daa94ed606a52cb3048f4dc14727dd7 Mon Sep 17 00:00:00 2001 From: Marco Minerva Date: Fri, 24 Jul 2026 17:27:52 +0200 Subject: [PATCH] Refactor models, update usage tracking, and clean citations - Enabled additional C# code style suggestions in .editorconfig. - Removed citation rendering and extraction from Ask.razor. - Deleted Citation, ChatResponse, and TokenUsage models and references. - Updated TokenUsageResponse to use UsageDetails from Microsoft.Extensions.AI. - Refactored Response model: removed citations, added ConversationId, and adopted new token usage structure. - Updated HybridCacheSessionStoreService with new cache key format and DeleteSessionAsync method. - Refactored VectorSearchService to remove AppSettings dependency, update method signatures, and use UsageDetails for token tracking. - Updated NuGet package references in SqlDatabaseVectorSearch.csproj. --- .editorconfig | 3 + .../Components/Pages/Ask.razor | 60 ++------------- .../Models/ChatResponse.cs | 3 - SqlDatabaseVectorSearch/Models/Citation.cs | 16 ---- SqlDatabaseVectorSearch/Models/Response.cs | 10 +-- SqlDatabaseVectorSearch/Models/TokenUsage.cs | 6 -- .../Models/TokenUsageResponse.cs | 12 +-- .../HybridCacheSessionStoreService.cs | 29 +++++--- .../Services/VectorSearchService.cs | 73 ++++++++----------- .../SqlDatabaseVectorSearch.csproj | 26 +++---- 10 files changed, 76 insertions(+), 162 deletions(-) delete mode 100644 SqlDatabaseVectorSearch/Models/ChatResponse.cs delete mode 100644 SqlDatabaseVectorSearch/Models/Citation.cs delete mode 100644 SqlDatabaseVectorSearch/Models/TokenUsage.cs diff --git a/.editorconfig b/.editorconfig index 8f8512a..430f71b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -124,6 +124,7 @@ csharp_style_prefer_null_check_over_type_check = true:suggestion # Modifier preferences csharp_prefer_static_local_function = true:suggestion +csharp_prefer_static_anonymous_function = true:suggestion csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:silent # Code-block preferences @@ -139,9 +140,11 @@ csharp_prefer_system_threading_lock = true:suggestion csharp_prefer_simple_default_expression = true:suggestion csharp_style_deconstructed_variable_declaration = false:suggestion csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_prefer_implicitly_typed_lambda_expression = true:suggestion csharp_style_pattern_local_over_anonymous_function = true:suggestion csharp_style_prefer_index_operator = true:suggestion csharp_style_prefer_range_operator = true:suggestion +csharp_style_prefer_unbound_generic_type_in_nameof = true:suggestion csharp_style_throw_expression = true:suggestion csharp_style_unused_value_assignment_preference = discard_variable:none csharp_style_unused_value_expression_statement_preference = discard_variable:none diff --git a/SqlDatabaseVectorSearch/Components/Pages/Ask.razor b/SqlDatabaseVectorSearch/Components/Pages/Ask.razor index b18fa18..9cf8cb6 100644 --- a/SqlDatabaseVectorSearch/Components/Pages/Ask.razor +++ b/SqlDatabaseVectorSearch/Components/Pages/Ask.razor @@ -83,23 +83,6 @@ - @if (message.Citations is not null && message.Citations.Count() > 0) - { -
- @foreach (var citation in message.Citations) - { -
-
- @citation.FileName @if (citation.PageNumber.GetValueOrDefault() > 0) - { - pag. @citation.PageNumber - } -
-
@citation.Quote
-
- } -
- } } } @@ -212,17 +195,6 @@ } else if (delta.StreamState == StreamState.End) { - // Get citations from the response. - assistantMessage.Citations = delta.Citations?.Select(c => new Citation - { - DocumentId = c.DocumentId, - ChunkId = c.ChunkId, - FileName = c.FileName, - Quote = c.Quote, - PageNumber = c.PageNumber, - IndexOnPage = c.IndexOnPage - }); - assistantMessage.Status = MessageStatus.Completed; assistantMessage.TokenUsage += FormatTokenUsage(delta.TokenUsage); } @@ -282,26 +254,22 @@ ? $"

Reformulation:
{FormatTokenUsageDetails(tokenUsageResponse.Reformulation)}

" : string.Empty; - var embeddingTokenCount = tokenUsageResponse.EmbeddingTokenCount.HasValue - ? $"

Embedding Token Count: {tokenUsageResponse.EmbeddingTokenCount}

" - : string.Empty; - var question = tokenUsageResponse.Question is not null ? $"

Question:
{FormatTokenUsageDetails(tokenUsageResponse.Question)}

" : string.Empty; - return $"{reformulation}{embeddingTokenCount}{question}"; + return $"{reformulation}{question}"; - static string FormatTokenUsageDetails(TokenUsage? tokenUsage) + static string FormatTokenUsageDetails(Microsoft.Extensions.AI.UsageDetails? tokenUsage) { if (tokenUsage is null) { return string.Empty; } - return $"Prompt tokens: {tokenUsage.PromptTokens}
" + - $"Completion tokens: {tokenUsage.CompletionTokens}
" + - $"Total tokens: {tokenUsage.TotalTokens}"; + return $"Input tokens: {tokenUsage.InputTokenCount}
" + + $"Output tokens: {tokenUsage.OutputTokenCount}
" + + $"Total tokens: {tokenUsage.TotalTokenCount}"; } } @@ -326,23 +294,5 @@ public MessageStatus Status { get; set; } = MessageStatus.New; public string? TokenUsage { get; set; } - - // List of citations extracted from the answer. - public IEnumerable? Citations { get; set; } - } - - public class Citation - { - public Guid DocumentId { get; set; } - - public Guid ChunkId { get; set; } - - public string FileName { get; set; } = null!; - - public string Quote { get; set; } = null!; - - public int? PageNumber { get; set; } - - public int IndexOnPage { get; set; } } } \ No newline at end of file diff --git a/SqlDatabaseVectorSearch/Models/ChatResponse.cs b/SqlDatabaseVectorSearch/Models/ChatResponse.cs deleted file mode 100644 index 1f67ca9..0000000 --- a/SqlDatabaseVectorSearch/Models/ChatResponse.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace SqlDatabaseVectorSearch.Models; - -public record class ChatResponse(string? Text, TokenUsage? TokenUsage = null); \ No newline at end of file diff --git a/SqlDatabaseVectorSearch/Models/Citation.cs b/SqlDatabaseVectorSearch/Models/Citation.cs deleted file mode 100644 index 04fb64b..0000000 --- a/SqlDatabaseVectorSearch/Models/Citation.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace SqlDatabaseVectorSearch.Models; - -public class Citation -{ - public Guid DocumentId { get; set; } - - public Guid ChunkId { get; set; } - - public string FileName { get; set; } = null!; - - public string Quote { get; set; } = null!; - - public int? PageNumber { get; set; } - - public int IndexOnPage { get; set; } -} \ No newline at end of file diff --git a/SqlDatabaseVectorSearch/Models/Response.cs b/SqlDatabaseVectorSearch/Models/Response.cs index 20e6e55..3aed02f 100644 --- a/SqlDatabaseVectorSearch/Models/Response.cs +++ b/SqlDatabaseVectorSearch/Models/Response.cs @@ -1,12 +1,10 @@ namespace SqlDatabaseVectorSearch.Models; // Question and Answer can be null when using response streaming. -public record class Response(string? OriginalQuestion, string? ReformulatedQuestion, string? Answer, StreamState? StreamState = null, TokenUsageResponse? TokenUsage = null, IEnumerable? Citations = null) +public record class Response(Guid ConversationId, string? OriginalQuestion, string? ReformulatedQuestion, string? Answer, StreamState? StreamState = null, TokenUsageResponse? TokenUsage = null) { - public Response(string? token, StreamState streamState, TokenUsageResponse? tokenUsageResponse = null, IEnumerable? citations = null) - : this(null, null, token, streamState, tokenUsageResponse, citations) + public Response(Guid conversationId, string? token, StreamState streamState, TokenUsageResponse? tokenUsageResponse = null) + : this(conversationId, null, null, token, streamState, tokenUsageResponse) { } -} - -public record class RagResponse(Guid ConversationId, string OriginalQuestion, string ReformulatedQuestion, string Answer); \ No newline at end of file +} \ No newline at end of file diff --git a/SqlDatabaseVectorSearch/Models/TokenUsage.cs b/SqlDatabaseVectorSearch/Models/TokenUsage.cs deleted file mode 100644 index 9a39649..0000000 --- a/SqlDatabaseVectorSearch/Models/TokenUsage.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace SqlDatabaseVectorSearch.Models; - -public record class TokenUsage(int PromptTokens, int CompletionTokens) -{ - public int TotalTokens => PromptTokens + CompletionTokens; -} diff --git a/SqlDatabaseVectorSearch/Models/TokenUsageResponse.cs b/SqlDatabaseVectorSearch/Models/TokenUsageResponse.cs index 500a72e..1dae2e0 100644 --- a/SqlDatabaseVectorSearch/Models/TokenUsageResponse.cs +++ b/SqlDatabaseVectorSearch/Models/TokenUsageResponse.cs @@ -1,9 +1,5 @@ -namespace SqlDatabaseVectorSearch.Models; +using Microsoft.Extensions.AI; -public record class TokenUsageResponse(TokenUsage? Reformulation, int? EmbeddingTokenCount, TokenUsage? Question) -{ - public TokenUsageResponse(TokenUsage? question) - : this(null, null, question) - { - } -} +namespace SqlDatabaseVectorSearch.Models; + +public record class TokenUsageResponse(UsageDetails? Reformulation, UsageDetails? Question); diff --git a/SqlDatabaseVectorSearch/Services/HybridCacheSessionStoreService.cs b/SqlDatabaseVectorSearch/Services/HybridCacheSessionStoreService.cs index 2ae9b7b..e66a48f 100644 --- a/SqlDatabaseVectorSearch/Services/HybridCacheSessionStoreService.cs +++ b/SqlDatabaseVectorSearch/Services/HybridCacheSessionStoreService.cs @@ -8,23 +8,30 @@ public class HybridCacheSessionStoreService(HybridCache cache) : AgentSessionSto { public override async ValueTask GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default) { - var sessionContent = await cache.GetOrCreateAsync( - GetCacheKey(conversationId), - async ct => - { - var session = await agent.CreateSessionAsync(ct); - return await agent.SerializeSessionAsync(session, cancellationToken: ct); - }, - cancellationToken: cancellationToken); + var key = GetKey(agent, conversationId); + var sessionContent = await cache.GetOrCreateAsync(key, async ct => + { + var session = await agent.CreateSessionAsync(ct); + return await agent.SerializeSessionAsync(session, cancellationToken: ct); + }, cancellationToken: cancellationToken); return await agent.DeserializeSessionAsync(sessionContent, cancellationToken: cancellationToken); } public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default) { + var key = GetKey(agent, conversationId); var sessionContent = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken); - await cache.SetAsync(GetCacheKey(conversationId), sessionContent, cancellationToken: cancellationToken); + + await cache.SetAsync(key, sessionContent, cancellationToken: cancellationToken); } - private static string GetCacheKey(string conversationId) => $"agent-session:{conversationId}"; -} + public override async ValueTask DeleteSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default) + { + var key = GetKey(agent, conversationId); + await cache.RemoveAsync(key, cancellationToken); + } + + private static string GetKey(AIAgent agent, string conversationId) + => $"{agent.Id}:{conversationId}"; +} \ No newline at end of file diff --git a/SqlDatabaseVectorSearch/Services/VectorSearchService.cs b/SqlDatabaseVectorSearch/Services/VectorSearchService.cs index d3b2c92..1668fe7 100644 --- a/SqlDatabaseVectorSearch/Services/VectorSearchService.cs +++ b/SqlDatabaseVectorSearch/Services/VectorSearchService.cs @@ -15,10 +15,8 @@ using SqlDatabaseVectorSearch.Workflows; namespace SqlDatabaseVectorSearch.Services; public partial class VectorSearchService([FromKeyedServices("EmbeddingWorkflow")] Workflow workflow, [FromKeyedServices("ReformulationAgent")] AIAgent reformulationAgent, [FromKeyedServices("RagAgent")] AIAgent ragAgent, - [FromKeyedServices("RagAgent")] AgentSessionStore sessionStore, IOptions appSettingsOptions) + [FromKeyedServices("RagAgent")] AgentSessionStore sessionStore) { - private readonly AppSettings appSettings = appSettingsOptions.Value; - public async Task ImportAsync(FormFileEmbeddingRequest request, CancellationToken cancellationToken = default) { await using var run = await InProcessExecution.RunAsync(workflow, request, cancellationToken: cancellationToken); @@ -34,8 +32,9 @@ public partial class VectorSearchService([FromKeyedServices("EmbeddingWorkflow") return result; } - public async Task AskQuestionAsync(Question question, bool reformulate = true, CancellationToken cancellationToken = default) + public async Task AskQuestionAsync(Question question, bool reformulate = true, CancellationToken cancellationToken = default) { + UsageDetails? reformulationUsage = null; var reformulatedQuestion = question.Text; var session = await sessionStore.GetSessionAsync(ragAgent, question.ConversationId.ToString(), cancellationToken); @@ -44,62 +43,48 @@ public partial class VectorSearchService([FromKeyedServices("EmbeddingWorkflow") // Reformulates the question taking into account the context of the chat to perform keyword search and embeddings. var reformulationResponse = await reformulationAgent.RunAsync(question.Text, session, cancellationToken: cancellationToken); reformulatedQuestion = reformulationResponse.Text; + reformulationUsage = reformulationResponse.Usage; } var response = await ragAgent.RunAsync(reformulatedQuestion, session, cancellationToken: cancellationToken); await sessionStore.SaveSessionAsync(ragAgent, question.ConversationId.ToString(), session, cancellationToken); - session.TryGetInMemoryChatHistory(out var chatHistory); - - return new(question.ConversationId, question.Text, reformulatedQuestion, response.Text); + return new(question.ConversationId, question.Text, reformulatedQuestion, response.Text, null, new TokenUsageResponse(reformulationUsage, response.Usage)); } public async IAsyncEnumerable AskStreamingAsync(Question question, bool reformulate = true, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - yield return null!; + UsageDetails? reformulationUsage = null; + var reformulatedQuestion = question.Text; + var session = await sessionStore.GetSessionAsync(ragAgent, question.ConversationId.ToString(), cancellationToken); - //// It the user doesn't want to reforulate the question, CreateContextAsync returns the original one. - //var (reformulatedQuestion, embeddingTokenCount, chunks) = await CreateContextAsync(question, reformulate, cancellationToken); + if (reformulate) + { + // Reformulates the question taking into account the context of the chat to perform keyword search and embeddings. + var reformulationResponse = await reformulationAgent.RunAsync(question.Text, session, cancellationToken: cancellationToken); + reformulatedQuestion = reformulationResponse.Text; + reformulationUsage = reformulationResponse.Usage; + } - //var answerStream = chatService.AskStreamingAsync(question.ConversationId, chunks, reformulatedQuestion.Text!, cancellationToken: cancellationToken); + // The first message contains the question and the corresponding token usage (if reformulated). + yield return new(question.ConversationId, question.Text, reformulatedQuestion, null, StreamState.Start, new(reformulationUsage, null)); - //// The first message contains the question and the corresponding token usage (if reformulated). - //yield return new(question.Text, reformulatedQuestion.Text!, null, StreamState.Start, new(reformulatedQuestion.TokenUsage, embeddingTokenCount, null)); + var updates = new List(); - //TokenUsageResponse? tokenUsageResponse = null; - //var fullAnswer = new StringBuilder(); - //var citationsStarted = false; + await foreach (var update in ragAgent.RunStreamingAsync(reformulatedQuestion, session, cancellationToken: cancellationToken)) + { + updates.Add(update); + if (!string.IsNullOrEmpty(update.Text)) + { + yield return new(question.ConversationId, update.Text, StreamState.Append); + } + } - //// Returns each token as a partial response. - //await foreach (var (token, tokenUsage) in answerStream) - //{ - // if (token is not null) // token can be null when the stream ends. - // { - // fullAnswer.Append(token); + await sessionStore.SaveSessionAsync(ragAgent, question.ConversationId.ToString(), session, cancellationToken); + var response = updates.ToAgentResponse(); - // if (token.Contains('【')) - // { - // // Citations start when we encounter a token containing a 【 character. - // // We need to track it because we don't want to return the citations in the actual response. - // citationsStarted = true; - // } - - // if (!citationsStarted) - // { - // yield return new(token, StreamState.Append); - // } - // } - // else - // { - // // Token usage is expected in the last message, when token is null. - // tokenUsageResponse ??= tokenUsage is not null ? new(tokenUsage) : null; - // } - //} - - //// Extract citations at the end of streaming. - //var (_, citations) = ExtractCitations(fullAnswer.ToString()); - //yield return new(null, StreamState.End, tokenUsageResponse, citations); + yield return new(question.ConversationId, null, null, response.Text, StreamState.End, new TokenUsageResponse(null, response.Usage)); } } diff --git a/SqlDatabaseVectorSearch/SqlDatabaseVectorSearch.csproj b/SqlDatabaseVectorSearch/SqlDatabaseVectorSearch.csproj index b0062e0..fd24438 100644 --- a/SqlDatabaseVectorSearch/SqlDatabaseVectorSearch.csproj +++ b/SqlDatabaseVectorSearch/SqlDatabaseVectorSearch.csproj @@ -12,34 +12,34 @@ - - - - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + - - - + + +