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.
This commit is contained in:
Marco Minerva
2026-07-24 17:27:52 +02:00
parent a39a81166a
commit 7af4214d6d
10 changed files with 76 additions and 162 deletions
+3
View File
@@ -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
@@ -83,23 +83,6 @@
</Tooltip>
</div>
</div>
@if (message.Citations is not null && message.Citations.Count() > 0)
{
<div class="mt-3 d-flex flex-wrap">
@foreach (var citation in message.Citations)
{
<div class="border rounded p-2 me-2 mb-2 citation-box small">
<div>
<strong>@citation.FileName</strong> @if (citation.PageNumber.GetValueOrDefault() > 0)
{
<span class="ms-2">pag. @citation.PageNumber</span>
}
</div>
<div class="text-secondary small mt-1">@citation.Quote</div>
</div>
}
</div>
}
}
</div>
}
@@ -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 @@
? $"<p><strong>Reformulation:</strong><br />{FormatTokenUsageDetails(tokenUsageResponse.Reformulation)}</p>"
: string.Empty;
var embeddingTokenCount = tokenUsageResponse.EmbeddingTokenCount.HasValue
? $"<p><strong>Embedding Token Count:</strong> {tokenUsageResponse.EmbeddingTokenCount}</p>"
: string.Empty;
var question = tokenUsageResponse.Question is not null
? $"<p><strong>Question:</strong><br />{FormatTokenUsageDetails(tokenUsageResponse.Question)}</p>"
: 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}<br />" +
$"Completion tokens: {tokenUsage.CompletionTokens}<br />" +
$"Total tokens: {tokenUsage.TotalTokens}";
return $"Input tokens: {tokenUsage.InputTokenCount}<br />" +
$"Output tokens: {tokenUsage.OutputTokenCount}<br />" +
$"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<Citation>? 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; }
}
}
@@ -1,3 +0,0 @@
namespace SqlDatabaseVectorSearch.Models;
public record class ChatResponse(string? Text, TokenUsage? TokenUsage = 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; }
}
+4 -6
View File
@@ -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<Citation>? 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<Citation>? 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);
}
@@ -1,6 +0,0 @@
namespace SqlDatabaseVectorSearch.Models;
public record class TokenUsage(int PromptTokens, int CompletionTokens)
{
public int TotalTokens => PromptTokens + CompletionTokens;
}
@@ -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);
@@ -8,23 +8,30 @@ public class HybridCacheSessionStoreService(HybridCache cache) : AgentSessionSto
{
public override async ValueTask<AgentSession> 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}";
}
@@ -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<AppSettings> appSettingsOptions)
[FromKeyedServices("RagAgent")] AgentSessionStore sessionStore)
{
private readonly AppSettings appSettings = appSettingsOptions.Value;
public async Task<StoreEmbeddingResponse> 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<RagResponse> AskQuestionAsync(Question question, bool reformulate = true, CancellationToken cancellationToken = default)
public async Task<Response> 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<Response> 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<AgentResponseUpdate>();
//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));
}
}
@@ -12,34 +12,34 @@
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
<PackageReference Include="EntityFrameworkCore.Exceptions.SqlServer" Version="10.0.1" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageReference Include="Microsoft.Agents.AI.Hosting" Version="1.10.0-preview.260610.1" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.10.0" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.10.0" />
<PackageReference Include="Microsoft.Agents.AI.Workflows.Generators" Version="1.10.0">
<PackageReference Include="Microsoft.Agents.AI.Hosting" Version="1.15.0-preview.260722.1" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.15.0" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.15.0" />
<PackageReference Include="Microsoft.Agents.AI.Workflows.Generators" Version="1.15.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9">
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.10">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="10.7.0" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.7.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="10.8.0" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.8.0" />
<PackageReference Include="Microsoft.ML.Tokenizers" Version="2.0.0" />
<PackageReference Include="Microsoft.ML.Tokenizers.Data.Cl100kBase" Version="2.0.0" />
<PackageReference Include="Microsoft.ML.Tokenizers.Data.O200kBase" Version="2.0.0" />
<PackageReference Include="MimeMapping" Version="4.0.0" />
<PackageReference Include="MinimalHelpers.FluentValidation" Version="1.1.8" />
<PackageReference Include="MinimalHelpers.Routing.Analyzers" Version="1.2.2" />
<PackageReference Include="PdfPig" Version="0.1.14" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.2.1" />
<PackageReference Include="TinyHelpers.AspNetCore" Version="4.2.12" />
<PackageReference Include="PdfPig" Version="0.1.15" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.2.3" />
<PackageReference Include="TinyHelpers.AspNetCore" Version="4.2.17" />
</ItemGroup>
</Project>