documentation cleanse, sprints work and planning. remaining non EF DAL migration to EF
This commit is contained in:
@@ -79,7 +79,19 @@ public sealed record AddTurnRequest
|
||||
/// Gets the user message content.
|
||||
/// </summary>
|
||||
[JsonPropertyName("content")]
|
||||
public required string Content { get; init; }
|
||||
public string? Content { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the legacy user message content field.
|
||||
/// </summary>
|
||||
[JsonPropertyName("message")]
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets normalized message content from canonical or legacy payloads.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string? EffectiveContent => !string.IsNullOrWhiteSpace(Content) ? Content : Message;
|
||||
|
||||
/// <summary>
|
||||
/// Gets optional metadata for this turn.
|
||||
|
||||
@@ -21,6 +21,8 @@ using StellaOps.AdvisoryAI.WebService.Security;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using System.Collections.Immutable;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using static StellaOps.Localization.T;
|
||||
|
||||
@@ -166,6 +168,17 @@ public static class ChatEndpoints
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
if (!result.GuardrailBlocked && !result.QuotaBlocked && !result.ToolAccessDenied)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Chat gateway runtime fallback activated for tenant {TenantId}, user {UserId}. Reason: {Reason}",
|
||||
tenantId,
|
||||
userId,
|
||||
result.Error ?? "processing_failed");
|
||||
|
||||
return Results.Ok(CreateDeterministicFallbackQueryResponse(request, result));
|
||||
}
|
||||
|
||||
var statusCode = result.GuardrailBlocked
|
||||
? StatusCodes.Status400BadRequest
|
||||
: result.QuotaBlocked
|
||||
@@ -858,6 +871,60 @@ public static class ChatEndpoints
|
||||
} : null
|
||||
};
|
||||
}
|
||||
|
||||
private static AdvisoryChatQueryResponse CreateDeterministicFallbackQueryResponse(
|
||||
AdvisoryChatQueryRequest request,
|
||||
AdvisoryChatServiceResult failedResult)
|
||||
{
|
||||
var diagnostics = failedResult.Diagnostics is null
|
||||
? null
|
||||
: new DiagnosticsResponse
|
||||
{
|
||||
IntentRoutingMs = failedResult.Diagnostics.IntentRoutingMs,
|
||||
EvidenceAssemblyMs = failedResult.Diagnostics.EvidenceAssemblyMs,
|
||||
InferenceMs = failedResult.Diagnostics.InferenceMs,
|
||||
TotalMs = failedResult.Diagnostics.TotalMs,
|
||||
PromptTokens = failedResult.Diagnostics.PromptTokens,
|
||||
CompletionTokens = failedResult.Diagnostics.CompletionTokens,
|
||||
};
|
||||
|
||||
var reason = string.IsNullOrWhiteSpace(failedResult.Error)
|
||||
? "chat runtime unavailable"
|
||||
: failedResult.Error.Trim();
|
||||
var normalizedQuery = request.Query?.Trim() ?? string.Empty;
|
||||
var fallbackId = BuildFallbackResponseId(normalizedQuery, reason);
|
||||
|
||||
return new AdvisoryChatQueryResponse
|
||||
{
|
||||
ResponseId = fallbackId,
|
||||
BundleId = null,
|
||||
Intent = (failedResult.Intent ?? AdvisoryChatIntent.General).ToString(),
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
Summary =
|
||||
$"Chat runtime is temporarily unavailable. For \"{normalizedQuery}\", start with unified search evidence, verify VEX status, and confirm active policy gates before acting.",
|
||||
Confidence = new ConfidenceResponse { Level = ConfidenceLevel.Low.ToString(), Score = 0.2d },
|
||||
EvidenceLinks = [],
|
||||
Mitigations = [],
|
||||
ProposedActions = [],
|
||||
FollowUp = new FollowUpResponse
|
||||
{
|
||||
SuggestedQueries = [normalizedQuery],
|
||||
NextSteps =
|
||||
[
|
||||
"Retry this chat query after runtime recovery.",
|
||||
"Use global search to review findings, VEX, and policy context now."
|
||||
]
|
||||
},
|
||||
Diagnostics = diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildFallbackResponseId(string query, string reason)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes($"{query}|{reason}"));
|
||||
var token = Convert.ToHexString(bytes.AsSpan(0, 8)).ToLowerInvariant();
|
||||
return $"fallback-{token}";
|
||||
}
|
||||
}
|
||||
|
||||
#region Request/Response DTOs
|
||||
|
||||
@@ -121,6 +121,24 @@ public static class SearchAnalyticsEndpoints
|
||||
|
||||
if (events.Count > 0)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
foreach (var evt in events)
|
||||
{
|
||||
if (!ShouldPersistHistory(evt))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await analyticsService.RecordHistoryAsync(
|
||||
tenant,
|
||||
userId,
|
||||
evt.Query,
|
||||
evt.ResultCount ?? 0,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Fire-and-forget: do not await in the request pipeline to keep latency low.
|
||||
// The analytics service already swallows exceptions internally.
|
||||
_ = analyticsService.RecordEventsAsync(events, CancellationToken.None);
|
||||
@@ -129,6 +147,22 @@ public static class SearchAnalyticsEndpoints
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static bool ShouldPersistHistory(SearchAnalyticsEvent evt)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(evt.Query))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (evt.Query.StartsWith("__", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(evt.EventType, "query", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(evt.EventType, "zero_result", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetHistoryAsync(
|
||||
HttpContext httpContext,
|
||||
SearchAnalyticsService analyticsService,
|
||||
|
||||
@@ -37,8 +37,12 @@ using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.RateLimiting;
|
||||
using StellaOps.Localization;
|
||||
using AdvisoryChatModels = StellaOps.AdvisoryAI.Chat.Models;
|
||||
using AdvisoryChatServices = StellaOps.AdvisoryAI.Chat.Services;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -262,23 +266,33 @@ app.MapGet("/v1/advisory-ai/rate-limits", HandleGetRateLimits)
|
||||
// Chat endpoints (SPRINT_20260107_006_003 CH-005)
|
||||
app.MapPost("/v1/advisory-ai/conversations", HandleCreateConversation)
|
||||
.RequireRateLimiting("advisory-ai")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
||||
.WithSummary("Legacy conversation-creation endpoint (deprecated).")
|
||||
.WithDescription("Creates a chat conversation using the legacy conversation surface. This endpoint family is deprecated in favor of /api/v1/chat/* and is scheduled for sunset on 2026-12-31 UTC.");
|
||||
|
||||
app.MapGet("/v1/advisory-ai/conversations/{conversationId}", HandleGetConversation)
|
||||
.RequireRateLimiting("advisory-ai")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy);
|
||||
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy)
|
||||
.WithSummary("Legacy conversation-read endpoint (deprecated).")
|
||||
.WithDescription("Returns conversation state from the legacy conversation surface. Migrate readers to /api/v1/chat/* before the 2026-12-31 UTC sunset.");
|
||||
|
||||
app.MapPost("/v1/advisory-ai/conversations/{conversationId}/turns", HandleAddTurn)
|
||||
.RequireRateLimiting("advisory-ai")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
||||
.WithSummary("Legacy conversation add-turn endpoint (deprecated).")
|
||||
.WithDescription("Adds a conversational turn on the legacy conversation surface. Canonical payload field is content. Legacy message is accepted for compatibility only during the migration window ending 2026-12-31 UTC.");
|
||||
|
||||
app.MapDelete("/v1/advisory-ai/conversations/{conversationId}", HandleDeleteConversation)
|
||||
.RequireRateLimiting("advisory-ai")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
||||
.WithSummary("Legacy conversation delete endpoint (deprecated).")
|
||||
.WithDescription("Deletes a conversation on the legacy conversation surface. Migrate clients to /api/v1/chat/* before the 2026-12-31 UTC sunset.");
|
||||
|
||||
app.MapGet("/v1/advisory-ai/conversations", HandleListConversations)
|
||||
.RequireRateLimiting("advisory-ai")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy);
|
||||
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy)
|
||||
.WithSummary("Legacy conversation list endpoint (deprecated).")
|
||||
.WithDescription("Lists conversations from the legacy conversation surface. Migrate listing flows to /api/v1/chat/* before the 2026-12-31 UTC sunset.");
|
||||
|
||||
// Chat gateway endpoints (controlled conversational interface)
|
||||
app.MapChatEndpoints();
|
||||
@@ -1118,6 +1132,7 @@ static async Task<IResult> HandleCreateConversation(
|
||||
IConversationService conversationService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ApplyLegacyConversationHeaders(httpContext);
|
||||
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.create_conversation", ActivityKind.Server);
|
||||
activity?.SetTag("advisory.tenant_id", request.TenantId);
|
||||
|
||||
@@ -1161,6 +1176,7 @@ static async Task<IResult> HandleGetConversation(
|
||||
IConversationService conversationService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ApplyLegacyConversationHeaders(httpContext);
|
||||
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.get_conversation", ActivityKind.Server);
|
||||
activity?.SetTag("advisory.conversation_id", conversationId);
|
||||
|
||||
@@ -1184,6 +1200,7 @@ static async Task<IResult> HandleAddTurn(
|
||||
string conversationId,
|
||||
StellaOps.AdvisoryAI.WebService.Contracts.AddTurnRequest request,
|
||||
IConversationService conversationService,
|
||||
AdvisoryChatServices.IAdvisoryChatService? advisoryChatService,
|
||||
ChatPromptAssembler? promptAssembler,
|
||||
ChatResponseStreamer? responseStreamer,
|
||||
GroundingValidator? groundingValidator,
|
||||
@@ -1192,15 +1209,39 @@ static async Task<IResult> HandleAddTurn(
|
||||
ILogger<Program> logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ApplyLegacyConversationHeaders(httpContext);
|
||||
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.add_turn", ActivityKind.Server);
|
||||
activity?.SetTag("advisory.conversation_id", conversationId);
|
||||
activity?.SetTag("advisory.stream", request.Stream);
|
||||
var normalizedContent = request.EffectiveContent?.Trim();
|
||||
var usingLegacyMessage = string.IsNullOrWhiteSpace(request.Content) && !string.IsNullOrWhiteSpace(request.Message);
|
||||
activity?.SetTag("advisory.request_shape", usingLegacyMessage ? "legacy_message" : "content");
|
||||
|
||||
if (usingLegacyMessage)
|
||||
{
|
||||
var tenantId = ResolveHeaderValue(httpContext, "X-StellaOps-Tenant")
|
||||
?? ResolveHeaderValue(httpContext, "X-Tenant-Id")
|
||||
?? "unknown";
|
||||
logger.LogInformation(
|
||||
"Legacy chat payload field 'message' used by tenant {TenantId} on endpoint {Endpoint} conversation {ConversationId}.",
|
||||
tenantId,
|
||||
httpContext.Request.Path.Value ?? "/v1/advisory-ai/conversations/{conversationId}/turns",
|
||||
conversationId);
|
||||
httpContext.Response.Headers.Append(
|
||||
"Warning",
|
||||
"299 - Legacy chat payload field 'message' is deprecated; use 'content'.");
|
||||
}
|
||||
|
||||
if (!EnsureChatAuthorized(httpContext))
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(normalizedContent))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Either 'content' or legacy 'message' must be provided." });
|
||||
}
|
||||
|
||||
var startTime = timeProvider.GetUtcNow();
|
||||
|
||||
// Add user turn
|
||||
@@ -1209,7 +1250,7 @@ static async Task<IResult> HandleAddTurn(
|
||||
var userTurnRequest = new TurnRequest
|
||||
{
|
||||
Role = TurnRole.User,
|
||||
Content = request.Content,
|
||||
Content = normalizedContent,
|
||||
Metadata = request.Metadata?.ToImmutableDictionary()
|
||||
};
|
||||
|
||||
@@ -1217,14 +1258,23 @@ static async Task<IResult> HandleAddTurn(
|
||||
.ConfigureAwait(false);
|
||||
|
||||
activity?.SetTag("advisory.user_turn_id", userTurn.TurnId);
|
||||
var assistantGeneration = await GenerateAssistantTurnAsync(
|
||||
httpContext,
|
||||
conversationId,
|
||||
normalizedContent,
|
||||
conversationService,
|
||||
advisoryChatService,
|
||||
logger,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// For now, return a placeholder response since we don't have the full LLM pipeline
|
||||
// In a complete implementation, this would call the prompt assembler, LLM, and validators
|
||||
var assistantContent = GeneratePlaceholderResponse(request.Content);
|
||||
var assistantContent = assistantGeneration.Content;
|
||||
var assistantTurnRequest = new TurnRequest
|
||||
{
|
||||
Role = TurnRole.Assistant,
|
||||
Content = assistantContent
|
||||
Content = assistantContent,
|
||||
EvidenceLinks = assistantGeneration.EvidenceLinks,
|
||||
ProposedActions = assistantGeneration.ProposedActions,
|
||||
Metadata = assistantGeneration.Metadata
|
||||
};
|
||||
|
||||
var assistantTurn = await conversationService.AddTurnAsync(conversationId, assistantTurnRequest, cancellationToken)
|
||||
@@ -1243,8 +1293,8 @@ static async Task<IResult> HandleAddTurn(
|
||||
ProposedActions = assistantTurn.ProposedActions.IsEmpty
|
||||
? null
|
||||
: assistantTurn.ProposedActions.Select(StellaOps.AdvisoryAI.WebService.Contracts.ProposedActionResponse.FromAction).ToList(),
|
||||
GroundingScore = 1.0, // Placeholder
|
||||
TokenCount = assistantContent.Split(' ').Length, // Rough estimate
|
||||
GroundingScore = assistantGeneration.GroundingScore,
|
||||
TokenCount = assistantGeneration.TokenCount,
|
||||
DurationMs = (long)elapsed.TotalMilliseconds
|
||||
};
|
||||
|
||||
@@ -1258,14 +1308,18 @@ static async Task<IResult> HandleAddTurn(
|
||||
{
|
||||
await httpContext.Response.WriteAsync(
|
||||
"event: token\n" +
|
||||
$"data: {assistantContent}\n\n",
|
||||
$"data: {System.Text.Json.JsonSerializer.Serialize(new { content = assistantContent })}\n\n",
|
||||
cancellationToken);
|
||||
await httpContext.Response.WriteAsync(
|
||||
"event: done\n" +
|
||||
$"data: {System.Text.Json.JsonSerializer.Serialize(new { turnId = assistantTurn.TurnId, groundingScore = assistantGeneration.GroundingScore })}\n\n",
|
||||
cancellationToken);
|
||||
await httpContext.Response.Body.FlushAsync(cancellationToken);
|
||||
return Results.Empty;
|
||||
}
|
||||
|
||||
await foreach (var streamEvent in responseStreamer.StreamResponseAsync(
|
||||
StreamPlaceholderTokens(assistantContent, cancellationToken),
|
||||
StreamContentTokens(assistantContent, cancellationToken),
|
||||
conversationId,
|
||||
assistantTurn.TurnId,
|
||||
cancellationToken))
|
||||
@@ -1292,6 +1346,7 @@ static async Task<IResult> HandleDeleteConversation(
|
||||
IConversationService conversationService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ApplyLegacyConversationHeaders(httpContext);
|
||||
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.delete_conversation", ActivityKind.Server);
|
||||
activity?.SetTag("advisory.conversation_id", conversationId);
|
||||
|
||||
@@ -1317,6 +1372,7 @@ static async Task<IResult> HandleListConversations(
|
||||
IConversationService conversationService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ApplyLegacyConversationHeaders(httpContext);
|
||||
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.list_conversations", ActivityKind.Server);
|
||||
|
||||
if (!EnsureChatAuthorized(httpContext))
|
||||
@@ -1357,6 +1413,13 @@ static async Task<IResult> HandleListConversations(
|
||||
});
|
||||
}
|
||||
|
||||
static void ApplyLegacyConversationHeaders(HttpContext context)
|
||||
{
|
||||
context.Response.Headers["Deprecation"] = "true";
|
||||
context.Response.Headers["Sunset"] = "Thu, 31 Dec 2026 23:59:59 GMT";
|
||||
context.Response.Headers["Link"] = "</api/v1/chat/query>; rel=\"successor-version\"";
|
||||
}
|
||||
|
||||
static bool EnsureChatAuthorized(HttpContext context)
|
||||
{
|
||||
if (!context.Request.Headers.TryGetValue("X-StellaOps-Scopes", out var scopes))
|
||||
@@ -1368,20 +1431,427 @@ static bool EnsureChatAuthorized(HttpContext context)
|
||||
.SelectMany(value => value?.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? [])
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return allowed.Contains("advisory:run")
|
||||
return allowed.Contains("advisory-ai:view")
|
||||
|| allowed.Contains("advisory-ai:operate")
|
||||
|| allowed.Contains("advisory-ai:admin")
|
||||
|| allowed.Contains("advisoryai:view")
|
||||
|| allowed.Contains("advisoryai:operate")
|
||||
|| allowed.Contains("advisoryai:admin")
|
||||
|| allowed.Contains("advisory:run")
|
||||
|| allowed.Contains("advisory:chat")
|
||||
|| allowed.Contains("chat:user")
|
||||
|| allowed.Contains("chat:admin");
|
||||
}
|
||||
|
||||
static string GeneratePlaceholderResponse(string userMessage)
|
||||
static async Task<AssistantGenerationResult> GenerateAssistantTurnAsync(
|
||||
HttpContext httpContext,
|
||||
string conversationId,
|
||||
string userMessage,
|
||||
IConversationService conversationService,
|
||||
AdvisoryChatServices.IAdvisoryChatService? advisoryChatService,
|
||||
ILogger logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Placeholder implementation - in production this would call the LLM
|
||||
return $"I received your message: \"{userMessage}\". This is a placeholder response. " +
|
||||
"The full chat functionality with grounded responses will be implemented when the LLM pipeline is connected.";
|
||||
var conversation = await conversationService.GetAsync(conversationId, cancellationToken).ConfigureAwait(false);
|
||||
if (conversation is null)
|
||||
{
|
||||
return CreateDeterministicFallbackTurn(userMessage, "conversation_not_found");
|
||||
}
|
||||
|
||||
if (advisoryChatService is null)
|
||||
{
|
||||
return CreateDeterministicFallbackTurn(userMessage, "chat_service_unavailable");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var tenantId = ResolveHeaderValue(httpContext, "X-StellaOps-Tenant")
|
||||
?? ResolveHeaderValue(httpContext, "X-Tenant-Id")
|
||||
?? conversation.TenantId;
|
||||
|
||||
var userId = ResolveHeaderValue(httpContext, "X-StellaOps-Actor")
|
||||
?? ResolveHeaderValue(httpContext, "X-User-Id")
|
||||
?? conversation.UserId;
|
||||
|
||||
var runtimeRequest = new AdvisoryChatServices.AdvisoryChatRequest
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = string.IsNullOrWhiteSpace(userId) ? "anonymous" : userId,
|
||||
UserRoles = ResolveUserRoles(httpContext),
|
||||
Query = BuildRuntimeQuery(userMessage, conversation.Context),
|
||||
ArtifactDigest = ResolveArtifactDigest(conversation.Context, userMessage),
|
||||
ImageReference = null,
|
||||
Environment = null,
|
||||
CorrelationId = ResolveHeaderValue(httpContext, "X-Correlation-Id"),
|
||||
ConversationId = conversationId
|
||||
};
|
||||
|
||||
var serviceResult = await advisoryChatService.ProcessQueryAsync(runtimeRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (serviceResult.Success && serviceResult.Response is not null)
|
||||
{
|
||||
return CreateRuntimeAssistantTurn(serviceResult.Response, serviceResult.Diagnostics);
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Chat runtime fell back to deterministic response for conversation {ConversationId}. Reason: {Reason}",
|
||||
conversationId,
|
||||
serviceResult.Error ?? "runtime_unavailable");
|
||||
|
||||
return CreateDeterministicFallbackTurn(userMessage, NormalizeReasonCode(serviceResult.Error));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"Chat runtime invocation failed for conversation {ConversationId}; using deterministic fallback.",
|
||||
conversationId);
|
||||
return CreateDeterministicFallbackTurn(userMessage, "runtime_exception");
|
||||
}
|
||||
}
|
||||
|
||||
static async IAsyncEnumerable<TokenChunk> StreamPlaceholderTokens(
|
||||
static AssistantGenerationResult CreateRuntimeAssistantTurn(
|
||||
AdvisoryChatModels.AdvisoryChatResponse response,
|
||||
AdvisoryChatServices.AdvisoryChatDiagnostics? diagnostics)
|
||||
{
|
||||
var content = BuildRuntimeAssistantContent(response);
|
||||
var evidenceLinks = response.EvidenceLinks
|
||||
.Select(MapEvidenceLink)
|
||||
.ToImmutableArray();
|
||||
var proposedActions = response.ProposedActions
|
||||
.Select(MapProposedAction)
|
||||
.ToImmutableArray();
|
||||
|
||||
var metadata = ImmutableDictionary<string, string>.Empty
|
||||
.Add("runtime", "advisory_chat_service")
|
||||
.Add("response_id", response.ResponseId);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(response.BundleId))
|
||||
{
|
||||
metadata = metadata.Add("bundle_id", response.BundleId);
|
||||
}
|
||||
|
||||
if (diagnostics is not null)
|
||||
{
|
||||
metadata = metadata
|
||||
.Add("diagnostics_total_ms", diagnostics.TotalMs.ToString(System.Globalization.CultureInfo.InvariantCulture))
|
||||
.Add("diagnostics_inference_ms", diagnostics.InferenceMs.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
return new AssistantGenerationResult(
|
||||
Content: content,
|
||||
EvidenceLinks: evidenceLinks,
|
||||
ProposedActions: proposedActions,
|
||||
GroundingScore: Clamp01(response.Confidence.Score),
|
||||
TokenCount: diagnostics?.CompletionTokens > 0 ? diagnostics.CompletionTokens : CountTokens(content),
|
||||
Metadata: metadata);
|
||||
}
|
||||
|
||||
static AssistantGenerationResult CreateDeterministicFallbackTurn(string userMessage, string reasonCode)
|
||||
{
|
||||
var normalizedMessage = userMessage.Trim();
|
||||
if (normalizedMessage.Length > 140)
|
||||
{
|
||||
normalizedMessage = normalizedMessage[..140] + "...";
|
||||
}
|
||||
|
||||
var content =
|
||||
$"I couldn't complete grounded AdvisoryAI analysis ({reasonCode}). " +
|
||||
"Provide a finding/CVE ID and artifact digest (sha256:...) for evidence-backed guidance. " +
|
||||
$"Request noted: \"{normalizedMessage}\". " +
|
||||
"[docs:modules/advisory-ai/architecture.md]";
|
||||
|
||||
var evidence = ImmutableArray.Create(new EvidenceLink
|
||||
{
|
||||
Type = EvidenceLinkType.Documentation,
|
||||
Uri = "docs:modules/advisory-ai/architecture.md",
|
||||
Label = "Advisory AI architecture",
|
||||
Confidence = 0.2
|
||||
});
|
||||
|
||||
var metadata = ImmutableDictionary<string, string>.Empty
|
||||
.Add("runtime", "deterministic_fallback")
|
||||
.Add("reason", reasonCode);
|
||||
|
||||
return new AssistantGenerationResult(
|
||||
Content: content,
|
||||
EvidenceLinks: evidence,
|
||||
ProposedActions: ImmutableArray<ProposedAction>.Empty,
|
||||
GroundingScore: 0.25,
|
||||
TokenCount: CountTokens(content),
|
||||
Metadata: metadata);
|
||||
}
|
||||
|
||||
static string BuildRuntimeAssistantContent(AdvisoryChatModels.AdvisoryChatResponse response)
|
||||
{
|
||||
var summary = response.Summary?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(summary))
|
||||
{
|
||||
summary = "I reviewed your request and assembled available evidence.";
|
||||
}
|
||||
|
||||
var builder = new StringBuilder(summary);
|
||||
var suggestions = response.FollowUp?.SuggestedQueries
|
||||
.Where(static query => !string.IsNullOrWhiteSpace(query))
|
||||
.Take(2)
|
||||
.ToArray();
|
||||
|
||||
if (suggestions is { Length: > 0 })
|
||||
{
|
||||
builder.AppendLine();
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("Try next:");
|
||||
foreach (var suggestion in suggestions)
|
||||
{
|
||||
builder.Append("- ").AppendLine(suggestion.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
var citations = response.EvidenceLinks
|
||||
.Select(ToCitationToken)
|
||||
.Where(static token => !string.IsNullOrWhiteSpace(token))
|
||||
.Take(4)
|
||||
.ToArray();
|
||||
|
||||
if (citations.Length > 0)
|
||||
{
|
||||
builder.AppendLine();
|
||||
builder.AppendLine();
|
||||
builder.Append("Evidence: ").Append(string.Join(' ', citations));
|
||||
}
|
||||
|
||||
return builder.ToString().Trim();
|
||||
}
|
||||
|
||||
static EvidenceLink MapEvidenceLink(AdvisoryChatModels.EvidenceLink link)
|
||||
{
|
||||
return new EvidenceLink
|
||||
{
|
||||
Type = link.Type switch
|
||||
{
|
||||
AdvisoryChatModels.EvidenceLinkType.Sbom => EvidenceLinkType.Sbom,
|
||||
AdvisoryChatModels.EvidenceLinkType.Vex => EvidenceLinkType.Vex,
|
||||
AdvisoryChatModels.EvidenceLinkType.Reach => EvidenceLinkType.Reachability,
|
||||
AdvisoryChatModels.EvidenceLinkType.Runtime => EvidenceLinkType.RuntimeTrace,
|
||||
AdvisoryChatModels.EvidenceLinkType.Attest => EvidenceLinkType.Dsse,
|
||||
AdvisoryChatModels.EvidenceLinkType.Policy => EvidenceLinkType.Documentation,
|
||||
AdvisoryChatModels.EvidenceLinkType.Binpatch => EvidenceLinkType.Other,
|
||||
AdvisoryChatModels.EvidenceLinkType.Opsmem => EvidenceLinkType.Documentation,
|
||||
_ => EvidenceLinkType.Other
|
||||
},
|
||||
Uri = string.IsNullOrWhiteSpace(link.Link)
|
||||
? $"other:{link.Type.ToString().ToLowerInvariant()}"
|
||||
: link.Link.Trim(),
|
||||
Label = string.IsNullOrWhiteSpace(link.Description) ? null : link.Description.Trim(),
|
||||
Confidence = link.Confidence switch
|
||||
{
|
||||
AdvisoryChatModels.ConfidenceLevel.High => 0.9,
|
||||
AdvisoryChatModels.ConfidenceLevel.Medium => 0.7,
|
||||
AdvisoryChatModels.ConfidenceLevel.Low => 0.4,
|
||||
AdvisoryChatModels.ConfidenceLevel.InsufficientEvidence => 0.2,
|
||||
_ => null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static ProposedAction MapProposedAction(AdvisoryChatModels.ProposedAction action)
|
||||
{
|
||||
var mappedActionType = action.ActionType switch
|
||||
{
|
||||
AdvisoryChatModels.ProposedActionType.CreateVex => "create_vex",
|
||||
AdvisoryChatModels.ProposedActionType.GeneratePr => "generate_manifest",
|
||||
AdvisoryChatModels.ProposedActionType.CreateTicket => "escalate",
|
||||
AdvisoryChatModels.ProposedActionType.Approve => "approve",
|
||||
AdvisoryChatModels.ProposedActionType.Quarantine => "quarantine",
|
||||
AdvisoryChatModels.ProposedActionType.Defer => "defer",
|
||||
AdvisoryChatModels.ProposedActionType.Waive => "defer",
|
||||
AdvisoryChatModels.ProposedActionType.Escalate => "escalate",
|
||||
_ => "dismiss"
|
||||
};
|
||||
|
||||
return new ProposedAction
|
||||
{
|
||||
ActionType = mappedActionType,
|
||||
Label = string.IsNullOrWhiteSpace(action.Label) ? mappedActionType : action.Label.Trim(),
|
||||
Rationale = action.Description,
|
||||
Parameters = action.Parameters ?? ImmutableDictionary<string, string>.Empty,
|
||||
RequiresConfirmation = action.RequiresApproval ?? true,
|
||||
PolicyGate = action.RiskLevel?.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
static string BuildRuntimeQuery(string userMessage, ConversationContext context)
|
||||
{
|
||||
var normalized = userMessage.Trim();
|
||||
var contextHints = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(context.CurrentCveId)
|
||||
&& normalized.IndexOf(context.CurrentCveId, StringComparison.OrdinalIgnoreCase) < 0)
|
||||
{
|
||||
contextHints.Add($"CVE: {context.CurrentCveId.Trim()}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(context.FindingId)
|
||||
&& normalized.IndexOf(context.FindingId, StringComparison.OrdinalIgnoreCase) < 0)
|
||||
{
|
||||
contextHints.Add($"Finding: {context.FindingId.Trim()}");
|
||||
}
|
||||
|
||||
if (contextHints.Count == 0)
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return $"{normalized}{Environment.NewLine}{Environment.NewLine}Context:{Environment.NewLine}- {string.Join(Environment.NewLine + "- ", contextHints)}";
|
||||
}
|
||||
|
||||
static string? ResolveArtifactDigest(ConversationContext context, string userMessage)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(context.CurrentImageDigest))
|
||||
{
|
||||
return context.CurrentImageDigest.Trim();
|
||||
}
|
||||
|
||||
var match = Regex.Match(userMessage, @"sha256:[a-f0-9]{16,}", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
return match.Success ? match.Value.ToLowerInvariant() : null;
|
||||
}
|
||||
|
||||
static string? ResolveHeaderValue(HttpContext context, string headerName)
|
||||
{
|
||||
if (!context.Request.Headers.TryGetValue(headerName, out var values))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static ImmutableArray<string> ResolveUserRoles(HttpContext context)
|
||||
{
|
||||
if (!context.Request.Headers.TryGetValue("X-StellaOps-Roles", out var roleValues))
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
var roles = roleValues
|
||||
.SelectMany(value => value?.Split(new[] { ' ', ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? [])
|
||||
.Where(static role => !string.IsNullOrWhiteSpace(role))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
|
||||
return roles;
|
||||
}
|
||||
|
||||
static string NormalizeReasonCode(string? reason)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(reason))
|
||||
{
|
||||
return "runtime_unavailable";
|
||||
}
|
||||
|
||||
var normalized = Regex.Replace(
|
||||
reason,
|
||||
@"[^a-z0-9]+",
|
||||
"_",
|
||||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant).Trim('_').ToLowerInvariant();
|
||||
|
||||
if (normalized.Length > 40)
|
||||
{
|
||||
normalized = normalized[..40];
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(normalized) ? "runtime_unavailable" : normalized;
|
||||
}
|
||||
|
||||
static string? ToCitationToken(AdvisoryChatModels.EvidenceLink link)
|
||||
{
|
||||
var type = link.Type switch
|
||||
{
|
||||
AdvisoryChatModels.EvidenceLinkType.Sbom => "sbom",
|
||||
AdvisoryChatModels.EvidenceLinkType.Reach => "reach",
|
||||
AdvisoryChatModels.EvidenceLinkType.Runtime => "runtime",
|
||||
AdvisoryChatModels.EvidenceLinkType.Vex => "vex",
|
||||
AdvisoryChatModels.EvidenceLinkType.Attest => "attest",
|
||||
AdvisoryChatModels.EvidenceLinkType.Policy => "policy",
|
||||
AdvisoryChatModels.EvidenceLinkType.Binpatch => "docs",
|
||||
AdvisoryChatModels.EvidenceLinkType.Opsmem => "docs",
|
||||
_ => "docs"
|
||||
};
|
||||
|
||||
var path = ExtractCitationPath(link.Link);
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
path = link.Description;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sanitizedPath = path.Trim().Replace("[", "(").Replace("]", ")");
|
||||
return $"[{type}:{sanitizedPath}]";
|
||||
}
|
||||
|
||||
static string? ExtractCitationPath(string? uri)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(uri))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = uri.Trim();
|
||||
if (trimmed.Contains("://", StringComparison.Ordinal))
|
||||
{
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
var separatorIndex = trimmed.IndexOf(':');
|
||||
if (separatorIndex > 0 && separatorIndex < trimmed.Length - 1)
|
||||
{
|
||||
return trimmed[(separatorIndex + 1)..];
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
static double Clamp01(double value)
|
||||
{
|
||||
if (double.IsNaN(value) || double.IsInfinity(value))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (value < 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return value > 1 ? 1 : value;
|
||||
}
|
||||
|
||||
static int CountTokens(string content)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Regex.Split(content.Trim(), @"\s+", RegexOptions.CultureInvariant).Length;
|
||||
}
|
||||
|
||||
static async IAsyncEnumerable<TokenChunk> StreamContentTokens(
|
||||
string content,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -1395,6 +1865,14 @@ static async IAsyncEnumerable<TokenChunk> StreamPlaceholderTokens(
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record AssistantGenerationResult(
|
||||
string Content,
|
||||
ImmutableArray<EvidenceLink> EvidenceLinks,
|
||||
ImmutableArray<ProposedAction> ProposedActions,
|
||||
double GroundingScore,
|
||||
int TokenCount,
|
||||
ImmutableDictionary<string, string> Metadata);
|
||||
|
||||
internal sealed record PipelinePlanRequest(
|
||||
AdvisoryTaskType? TaskType,
|
||||
string AdvisoryKey,
|
||||
|
||||
@@ -200,7 +200,7 @@ public sealed class ChatResponseStreamer
|
||||
// Pattern: [type:path]
|
||||
var matches = System.Text.RegularExpressions.Regex.Matches(
|
||||
content,
|
||||
@"\[(?<type>sbom|reach|runtime|vex|attest|auth|docs):(?<path>[^\]]+)\]");
|
||||
@"\[(?<type>sbom|reach|runtime|vex|attest|auth|docs|finding|scan|policy):(?<path>[^\]]+)\]");
|
||||
|
||||
for (int i = existingCount; i < matches.Count; i++)
|
||||
{
|
||||
|
||||
@@ -95,6 +95,17 @@ public sealed class KnowledgeSearchOptions
|
||||
/// </summary>
|
||||
public bool RoleBasedBiasEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Enables periodic quality-alert refresh from analytics and feedback signals.
|
||||
/// </summary>
|
||||
public bool SearchQualityMonitorEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Interval in seconds for quality-monitor refresh.
|
||||
/// </summary>
|
||||
[Range(30, 86400)]
|
||||
public int SearchQualityMonitorIntervalSeconds { get; set; } = 300;
|
||||
|
||||
// ── Live adapter settings (Sprint 103 / G2) ──
|
||||
|
||||
/// <summary>Base URL for the Scanner microservice (e.g. "http://scanner:8080").</summary>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[
|
||||
[
|
||||
{
|
||||
"checkCode": "check.core.disk.space",
|
||||
"title": "Speicherplatzverfügbarkeit",
|
||||
@@ -168,3 +168,4 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[
|
||||
[
|
||||
{
|
||||
"checkCode": "check.core.disk.space",
|
||||
"title": "Disponibilité de l'espace disque",
|
||||
|
||||
@@ -9,6 +9,9 @@ internal sealed class SearchAnalyticsService
|
||||
{
|
||||
private readonly KnowledgeSearchOptions _options;
|
||||
private readonly ILogger<SearchAnalyticsService> _logger;
|
||||
private readonly object _fallbackLock = new();
|
||||
private readonly List<(SearchAnalyticsEvent Event, DateTimeOffset RecordedAt)> _fallbackEvents = [];
|
||||
private readonly Dictionary<(string TenantId, string UserId, string Query), SearchHistoryEntry> _fallbackHistory = new();
|
||||
|
||||
public SearchAnalyticsService(
|
||||
IOptions<KnowledgeSearchOptions> options,
|
||||
@@ -20,7 +23,12 @@ internal sealed class SearchAnalyticsService
|
||||
|
||||
public async Task RecordEventAsync(SearchAnalyticsEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString)) return;
|
||||
var recordedAt = DateTimeOffset.UtcNow;
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
RecordFallbackEvent(evt, recordedAt);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -42,16 +50,32 @@ internal sealed class SearchAnalyticsService
|
||||
cmd.Parameters.AddWithValue("duration_ms", (object?)evt.DurationMs ?? DBNull.Value);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
RecordFallbackEvent(evt, recordedAt);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to record search analytics event");
|
||||
RecordFallbackEvent(evt, recordedAt);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RecordEventsAsync(IReadOnlyList<SearchAnalyticsEvent> events, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString) || events.Count == 0) return;
|
||||
if (events.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var recordedAt = DateTimeOffset.UtcNow;
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
foreach (var evt in events)
|
||||
{
|
||||
RecordFallbackEvent(evt, recordedAt);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -75,18 +99,27 @@ internal sealed class SearchAnalyticsService
|
||||
cmd.Parameters.AddWithValue("duration_ms", (object?)evt.DurationMs ?? DBNull.Value);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
RecordFallbackEvent(evt, recordedAt);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to record search analytics events batch ({Count} events)", events.Count);
|
||||
foreach (var evt in events)
|
||||
{
|
||||
RecordFallbackEvent(evt, recordedAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, int>> GetPopularityMapAsync(string tenantId, int days = 30, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
return BuildFallbackPopularityMap(tenantId, days);
|
||||
}
|
||||
|
||||
var map = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString)) return map;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -116,6 +149,7 @@ internal sealed class SearchAnalyticsService
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load popularity map");
|
||||
return BuildFallbackPopularityMap(tenantId, days);
|
||||
}
|
||||
|
||||
return map;
|
||||
@@ -123,7 +157,12 @@ internal sealed class SearchAnalyticsService
|
||||
|
||||
public async Task RecordHistoryAsync(string tenantId, string userId, string query, int resultCount, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString)) return;
|
||||
var recordedAt = DateTimeOffset.UtcNow;
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
RecordFallbackHistory(tenantId, userId, query, resultCount, recordedAt);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -156,17 +195,23 @@ internal sealed class SearchAnalyticsService
|
||||
trimCmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
trimCmd.Parameters.AddWithValue("user_id", userId);
|
||||
await trimCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
RecordFallbackHistory(tenantId, userId, query, resultCount, recordedAt);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to record search history");
|
||||
RecordFallbackHistory(tenantId, userId, query, resultCount, recordedAt);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SearchHistoryEntry>> GetHistoryAsync(string tenantId, string userId, int limit = 50, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
return GetFallbackHistory(tenantId, userId, limit);
|
||||
}
|
||||
|
||||
var entries = new List<SearchHistoryEntry>();
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString)) return entries;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -197,6 +242,7 @@ internal sealed class SearchAnalyticsService
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load search history");
|
||||
return GetFallbackHistory(tenantId, userId, limit);
|
||||
}
|
||||
|
||||
return entries;
|
||||
@@ -204,7 +250,11 @@ internal sealed class SearchAnalyticsService
|
||||
|
||||
public async Task ClearHistoryAsync(string tenantId, string userId, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString)) return;
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
ClearFallbackHistory(tenantId, userId);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -219,10 +269,12 @@ internal sealed class SearchAnalyticsService
|
||||
cmd.Parameters.AddWithValue("user_id", userId);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
ClearFallbackHistory(tenantId, userId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to clear search history");
|
||||
ClearFallbackHistory(tenantId, userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,9 +288,14 @@ internal sealed class SearchAnalyticsService
|
||||
string tenantId, string query, int limit = 3, CancellationToken ct = default)
|
||||
{
|
||||
var results = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString) || string.IsNullOrWhiteSpace(query))
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
return results;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
return FindFallbackSimilarQueries(tenantId, query, limit);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_options.ConnectionString);
|
||||
@@ -268,6 +325,7 @@ internal sealed class SearchAnalyticsService
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to find similar successful queries for '{Query}'", query);
|
||||
return FindFallbackSimilarQueries(tenantId, query, limit);
|
||||
}
|
||||
|
||||
return results;
|
||||
@@ -275,7 +333,11 @@ internal sealed class SearchAnalyticsService
|
||||
|
||||
public async Task DeleteHistoryEntryAsync(string tenantId, string userId, string historyId, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString)) return;
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
DeleteFallbackHistoryEntry(tenantId, userId, historyId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(historyId, out _)) return;
|
||||
|
||||
@@ -293,12 +355,204 @@ internal sealed class SearchAnalyticsService
|
||||
cmd.Parameters.AddWithValue("history_id", Guid.Parse(historyId));
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
DeleteFallbackHistoryEntry(tenantId, userId, historyId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete search history entry");
|
||||
DeleteFallbackHistoryEntry(tenantId, userId, historyId);
|
||||
}
|
||||
}
|
||||
|
||||
internal IReadOnlyList<(SearchAnalyticsEvent Event, DateTimeOffset RecordedAt)> GetFallbackEventsSnapshot(
|
||||
string tenantId,
|
||||
TimeSpan window)
|
||||
{
|
||||
var cutoff = DateTimeOffset.UtcNow - window;
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
return _fallbackEvents
|
||||
.Where(item => item.Event.TenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(item => item.RecordedAt >= cutoff)
|
||||
.OrderBy(item => item.RecordedAt)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
internal IReadOnlySet<string> GetKnownFallbackTenants()
|
||||
{
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
return _fallbackEvents
|
||||
.Select(item => item.Event.TenantId)
|
||||
.Where(static t => !string.IsNullOrWhiteSpace(t))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyDictionary<string, int> BuildFallbackPopularityMap(string tenantId, int days)
|
||||
{
|
||||
var cutoff = DateTimeOffset.UtcNow - TimeSpan.FromDays(Math.Max(1, days));
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
return _fallbackEvents
|
||||
.Where(item => item.RecordedAt >= cutoff)
|
||||
.Select(item => item.Event)
|
||||
.Where(evt => evt.TenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(evt => evt.EventType.Equals("click", StringComparison.OrdinalIgnoreCase))
|
||||
.Where(evt => !string.IsNullOrWhiteSpace(evt.EntityKey))
|
||||
.GroupBy(evt => evt.EntityKey!, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<SearchHistoryEntry> GetFallbackHistory(string tenantId, string userId, int limit)
|
||||
{
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
return _fallbackHistory
|
||||
.Where(item =>
|
||||
item.Key.TenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase) &&
|
||||
item.Key.UserId.Equals(userId, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(item => item.Value)
|
||||
.OrderByDescending(entry => entry.SearchedAt)
|
||||
.Take(Math.Max(1, limit))
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordFallbackEvent(SearchAnalyticsEvent evt, DateTimeOffset recordedAt)
|
||||
{
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
_fallbackEvents.Add((evt, recordedAt));
|
||||
if (_fallbackEvents.Count > 20_000)
|
||||
{
|
||||
_fallbackEvents.RemoveRange(0, _fallbackEvents.Count - 20_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordFallbackHistory(string tenantId, string userId, string query, int resultCount, DateTimeOffset recordedAt)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var normalizedQuery = query.Trim();
|
||||
(string TenantId, string UserId, string Query) key = (tenantId, userId, normalizedQuery);
|
||||
var historyId = BuildFallbackHistoryId(tenantId, userId, normalizedQuery);
|
||||
var entry = new SearchHistoryEntry(historyId, normalizedQuery, resultCount, recordedAt.UtcDateTime);
|
||||
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
_fallbackHistory[key] = entry;
|
||||
|
||||
var overflow = _fallbackHistory.Keys
|
||||
.Where(k => k.TenantId == key.TenantId && k.UserId == key.UserId)
|
||||
.Select(k => (Key: k, Entry: _fallbackHistory[k]))
|
||||
.OrderByDescending(item => item.Entry.SearchedAt)
|
||||
.Skip(50)
|
||||
.Select(item => item.Key)
|
||||
.ToArray();
|
||||
|
||||
foreach (var removeKey in overflow)
|
||||
{
|
||||
_fallbackHistory.Remove(removeKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearFallbackHistory(string tenantId, string userId)
|
||||
{
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
var keys = _fallbackHistory.Keys
|
||||
.Where(key => key.TenantId == tenantId && key.UserId == userId)
|
||||
.ToArray();
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
_fallbackHistory.Remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DeleteFallbackHistoryEntry(string tenantId, string userId, string historyId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(historyId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
var hit = _fallbackHistory.Keys
|
||||
.FirstOrDefault(key =>
|
||||
key.TenantId == tenantId &&
|
||||
key.UserId == userId &&
|
||||
BuildFallbackHistoryId(key.TenantId, key.UserId, key.Query).Equals(historyId, StringComparison.Ordinal));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(hit.TenantId))
|
||||
{
|
||||
_fallbackHistory.Remove(hit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<string> FindFallbackSimilarQueries(string tenantId, string query, int limit)
|
||||
{
|
||||
var normalized = query.Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
return _fallbackHistory
|
||||
.Where(item => item.Key.TenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(item => item.Value)
|
||||
.Where(entry => !string.Equals(entry.Query, normalized, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(entry => (entry.Query, Score: ComputeTokenSimilarity(entry.Query, normalized)))
|
||||
.Where(item => item.Score > 0.2d)
|
||||
.OrderByDescending(item => item.Score)
|
||||
.ThenBy(item => item.Query, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(Math.Max(1, limit))
|
||||
.Select(item => item.Query)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildFallbackHistoryId(string tenantId, string userId, string query)
|
||||
{
|
||||
var normalizedQuery = query.Trim().ToLowerInvariant();
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(
|
||||
System.Text.Encoding.UTF8.GetBytes($"{tenantId}|{userId}|{normalizedQuery}"));
|
||||
var guidBytes = hash[..16];
|
||||
return new Guid(guidBytes).ToString("D");
|
||||
}
|
||||
|
||||
private static double ComputeTokenSimilarity(string a, string b)
|
||||
{
|
||||
var left = a.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(static token => token.ToLowerInvariant())
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
var right = b.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(static token => token.ToLowerInvariant())
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
if (left.Count == 0 || right.Count == 0)
|
||||
{
|
||||
return 0d;
|
||||
}
|
||||
|
||||
var intersection = left.Intersect(right, StringComparer.Ordinal).Count();
|
||||
var union = left.Union(right, StringComparer.Ordinal).Count();
|
||||
return union == 0 ? 0d : (double)intersection / union;
|
||||
}
|
||||
}
|
||||
|
||||
internal record SearchAnalyticsEvent(
|
||||
|
||||
@@ -14,23 +14,38 @@ internal sealed class SearchQualityMonitor
|
||||
{
|
||||
private static readonly HashSet<string> AllowedSignals = new(StringComparer.Ordinal) { "helpful", "not_helpful" };
|
||||
private static readonly HashSet<string> AllowedAlertStatuses = new(StringComparer.Ordinal) { "acknowledged", "resolved" };
|
||||
private const int DefaultAlertWindowDays = 7;
|
||||
private const int ZeroResultAlertThreshold = 3;
|
||||
private const int NegativeFeedbackAlertThreshold = 3;
|
||||
|
||||
private readonly KnowledgeSearchOptions _options;
|
||||
private readonly ILogger<SearchQualityMonitor> _logger;
|
||||
private readonly SearchAnalyticsService _analyticsService;
|
||||
private readonly object _fallbackLock = new();
|
||||
private readonly List<(SearchFeedbackEntry Entry, DateTimeOffset CreatedAt)> _fallbackFeedback = [];
|
||||
private readonly List<SearchQualityAlertEntry> _fallbackAlerts = [];
|
||||
|
||||
public SearchQualityMonitor(
|
||||
IOptions<KnowledgeSearchOptions> options,
|
||||
ILogger<SearchQualityMonitor> logger)
|
||||
ILogger<SearchQualityMonitor> logger,
|
||||
SearchAnalyticsService? analyticsService = null)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
_analyticsService = analyticsService ??
|
||||
new SearchAnalyticsService(options, Microsoft.Extensions.Logging.Abstractions.NullLogger<SearchAnalyticsService>.Instance);
|
||||
}
|
||||
|
||||
// ----- Feedback CRUD -----
|
||||
|
||||
public async Task StoreFeedbackAsync(SearchFeedbackEntry entry, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString)) return;
|
||||
var createdAt = DateTimeOffset.UtcNow;
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
StoreFallbackFeedback(entry, createdAt);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -53,15 +68,114 @@ internal sealed class SearchQualityMonitor
|
||||
cmd.Parameters.AddWithValue("comment", (object?)entry.Comment ?? DBNull.Value);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
StoreFallbackFeedback(entry, createdAt);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to store search feedback");
|
||||
StoreFallbackFeedback(entry, createdAt);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Quality Alerts -----
|
||||
|
||||
public async Task<int> RefreshAlertsForKnownTenantsAsync(CancellationToken ct = default)
|
||||
{
|
||||
var tenants = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var tenant in _analyticsService.GetKnownFallbackTenants())
|
||||
{
|
||||
tenants.Add(tenant);
|
||||
}
|
||||
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
foreach (var item in _fallbackFeedback)
|
||||
{
|
||||
tenants.Add(item.Entry.TenantId);
|
||||
}
|
||||
|
||||
foreach (var alert in _fallbackAlerts)
|
||||
{
|
||||
tenants.Add(alert.TenantId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using var cmd = new NpgsqlCommand(@"
|
||||
SELECT DISTINCT tenant_id FROM advisoryai.search_events
|
||||
UNION
|
||||
SELECT DISTINCT tenant_id FROM advisoryai.search_feedback
|
||||
UNION
|
||||
SELECT DISTINCT tenant_id FROM advisoryai.search_quality_alerts", conn);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
if (!reader.IsDBNull(0))
|
||||
{
|
||||
tenants.Add(reader.GetString(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to enumerate tenants for quality alert refresh.");
|
||||
}
|
||||
}
|
||||
|
||||
var refreshed = 0;
|
||||
foreach (var tenantId in tenants)
|
||||
{
|
||||
await RefreshAlertsAsync(tenantId, ct).ConfigureAwait(false);
|
||||
refreshed++;
|
||||
}
|
||||
|
||||
return refreshed;
|
||||
}
|
||||
|
||||
public async Task RefreshAlertsAsync(string tenantId, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var window = TimeSpan.FromDays(DefaultAlertWindowDays);
|
||||
|
||||
var zeroResultCandidates = await LoadZeroResultCandidatesAsync(tenantId, window, ct).ConfigureAwait(false);
|
||||
foreach (var candidate in zeroResultCandidates)
|
||||
{
|
||||
await UpsertAlertAsync(
|
||||
tenantId,
|
||||
alertType: "zero_result",
|
||||
candidate.Query,
|
||||
candidate.OccurrenceCount,
|
||||
candidate.FirstSeen,
|
||||
candidate.LastSeen,
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var negativeFeedbackCandidates = await LoadNegativeFeedbackCandidatesAsync(tenantId, window, ct).ConfigureAwait(false);
|
||||
foreach (var candidate in negativeFeedbackCandidates)
|
||||
{
|
||||
await UpsertAlertAsync(
|
||||
tenantId,
|
||||
alertType: "high_negative_feedback",
|
||||
candidate.Query,
|
||||
candidate.OccurrenceCount,
|
||||
candidate.FirstSeen,
|
||||
candidate.LastSeen,
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SearchQualityAlertEntry>> GetAlertsAsync(
|
||||
string tenantId,
|
||||
string? status = null,
|
||||
@@ -70,7 +184,22 @@ internal sealed class SearchQualityMonitor
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var alerts = new List<SearchQualityAlertEntry>();
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString)) return alerts;
|
||||
await RefreshAlertsAsync(tenantId, ct).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
return _fallbackAlerts
|
||||
.Where(entry => entry.TenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(entry => string.IsNullOrWhiteSpace(status) || entry.Status.Equals(status, StringComparison.Ordinal))
|
||||
.Where(entry => string.IsNullOrWhiteSpace(alertType) || entry.AlertType.Equals(alertType, StringComparison.Ordinal))
|
||||
.OrderByDescending(entry => entry.OccurrenceCount)
|
||||
.ThenByDescending(entry => entry.LastSeen)
|
||||
.Take(Math.Max(1, limit))
|
||||
.Select(CloneAlertEntry)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -132,10 +261,49 @@ internal sealed class SearchQualityMonitor
|
||||
string? resolution,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString)) return null;
|
||||
if (string.IsNullOrWhiteSpace(tenantId)) return null;
|
||||
if (!Guid.TryParse(alertId, out var parsedAlertId)) return null;
|
||||
if (!AllowedAlertStatuses.Contains(status)) return null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
var existing = _fallbackAlerts.FirstOrDefault(entry =>
|
||||
entry.TenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase) &&
|
||||
entry.AlertId.Equals(alertId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var updated = new SearchQualityAlertEntry
|
||||
{
|
||||
AlertId = existing.AlertId,
|
||||
TenantId = existing.TenantId,
|
||||
AlertType = existing.AlertType,
|
||||
Query = existing.Query,
|
||||
OccurrenceCount = existing.OccurrenceCount,
|
||||
FirstSeen = existing.FirstSeen,
|
||||
LastSeen = existing.LastSeen,
|
||||
Status = status,
|
||||
Resolution = resolution,
|
||||
CreatedAt = existing.CreatedAt,
|
||||
};
|
||||
|
||||
var index = _fallbackAlerts.FindIndex(entry =>
|
||||
entry.TenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase) &&
|
||||
entry.AlertId.Equals(alertId, StringComparison.OrdinalIgnoreCase));
|
||||
if (index >= 0)
|
||||
{
|
||||
_fallbackAlerts[index] = updated;
|
||||
}
|
||||
|
||||
return CloneAlertEntry(updated);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_options.ConnectionString);
|
||||
@@ -174,6 +342,13 @@ internal sealed class SearchQualityMonitor
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to update search quality alert {AlertId}", alertId);
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
var existing = _fallbackAlerts.FirstOrDefault(entry =>
|
||||
entry.TenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase) &&
|
||||
entry.AlertId.Equals(alertId, StringComparison.OrdinalIgnoreCase));
|
||||
return existing is null ? null : CloneAlertEntry(existing);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -187,14 +362,13 @@ internal sealed class SearchQualityMonitor
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var metrics = new SearchQualityMetricsEntry { Period = period };
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString)) return metrics;
|
||||
|
||||
var days = period switch
|
||||
var days = ResolvePeriodDays(period);
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
"24h" => 1,
|
||||
"30d" => 30,
|
||||
_ => 7,
|
||||
};
|
||||
return BuildFallbackMetrics(tenantId, days, period);
|
||||
}
|
||||
|
||||
metrics.Period = period;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -204,12 +378,18 @@ internal sealed class SearchQualityMonitor
|
||||
// Total searches and zero-result rate from search_events
|
||||
await using var searchCmd = new NpgsqlCommand(@"
|
||||
SELECT
|
||||
COUNT(*) AS total_searches,
|
||||
COALESCE(AVG(CASE WHEN result_count = 0 THEN 1.0 ELSE 0.0 END), 0) AS zero_result_rate,
|
||||
COALESCE(AVG(result_count), 0) AS avg_result_count
|
||||
COUNT(*) FILTER (WHERE event_type IN ('query', 'zero_result')) AS total_searches,
|
||||
COALESCE(
|
||||
COUNT(*) FILTER (WHERE event_type = 'zero_result')::double precision /
|
||||
NULLIF(COUNT(*) FILTER (WHERE event_type IN ('query', 'zero_result')), 0),
|
||||
0
|
||||
) AS zero_result_rate,
|
||||
COALESCE(
|
||||
AVG(result_count) FILTER (WHERE event_type IN ('query', 'zero_result') AND result_count IS NOT NULL),
|
||||
0
|
||||
) AS avg_result_count
|
||||
FROM advisoryai.search_events
|
||||
WHERE event_type = 'search'
|
||||
AND tenant_id = @tenant_id
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND created_at > now() - make_interval(days => @days)", conn);
|
||||
|
||||
searchCmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
@@ -244,11 +424,346 @@ internal sealed class SearchQualityMonitor
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load search quality metrics");
|
||||
return BuildFallbackMetrics(tenantId, days, period);
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
private static int ResolvePeriodDays(string period)
|
||||
{
|
||||
return period switch
|
||||
{
|
||||
"24h" => 1,
|
||||
"30d" => 30,
|
||||
_ => 7,
|
||||
};
|
||||
}
|
||||
|
||||
private SearchQualityMetricsEntry BuildFallbackMetrics(string tenantId, int days, string period)
|
||||
{
|
||||
var window = TimeSpan.FromDays(Math.Max(1, days));
|
||||
var events = _analyticsService.GetFallbackEventsSnapshot(tenantId, window)
|
||||
.Select(item => item.Event)
|
||||
.ToArray();
|
||||
|
||||
var totalSearches = events.Count(evt =>
|
||||
evt.EventType.Equals("query", StringComparison.OrdinalIgnoreCase) ||
|
||||
evt.EventType.Equals("zero_result", StringComparison.OrdinalIgnoreCase));
|
||||
var zeroResults = events.Count(evt => evt.EventType.Equals("zero_result", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var avgResultCount = events
|
||||
.Where(evt => evt.EventType.Equals("query", StringComparison.OrdinalIgnoreCase) || evt.EventType.Equals("zero_result", StringComparison.OrdinalIgnoreCase))
|
||||
.Where(evt => evt.ResultCount.HasValue)
|
||||
.Select(evt => evt.ResultCount!.Value)
|
||||
.DefaultIfEmpty(0)
|
||||
.Average();
|
||||
|
||||
var feedbackSignals = GetFallbackFeedback(tenantId, window)
|
||||
.Select(item => item.Entry.Signal)
|
||||
.ToArray();
|
||||
var helpfulCount = feedbackSignals.Count(signal => signal.Equals("helpful", StringComparison.Ordinal));
|
||||
var feedbackScore = feedbackSignals.Length == 0
|
||||
? 0d
|
||||
: (double)helpfulCount / feedbackSignals.Length * 100d;
|
||||
|
||||
return new SearchQualityMetricsEntry
|
||||
{
|
||||
TotalSearches = totalSearches,
|
||||
ZeroResultRate = totalSearches == 0 ? 0d : Math.Round((double)zeroResults / totalSearches * 100d, 1),
|
||||
AvgResultCount = Math.Round(avgResultCount, 1),
|
||||
FeedbackScore = Math.Round(feedbackScore, 1),
|
||||
Period = period,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<AlertCandidate>> LoadZeroResultCandidatesAsync(
|
||||
string tenantId,
|
||||
TimeSpan window,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var candidates = new List<AlertCandidate>();
|
||||
var days = Math.Max(1, (int)Math.Ceiling(window.TotalDays));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using var cmd = new NpgsqlCommand(@"
|
||||
SELECT query, COUNT(*)::int AS occurrence_count, MIN(created_at), MAX(created_at)
|
||||
FROM advisoryai.search_events
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND event_type = 'zero_result'
|
||||
AND created_at > now() - make_interval(days => @days)
|
||||
GROUP BY query
|
||||
HAVING COUNT(*) >= @threshold
|
||||
ORDER BY occurrence_count DESC", conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
cmd.Parameters.AddWithValue("days", days);
|
||||
cmd.Parameters.AddWithValue("threshold", ZeroResultAlertThreshold);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
candidates.Add(new AlertCandidate(
|
||||
reader.GetString(0),
|
||||
reader.GetInt32(1),
|
||||
new DateTimeOffset(reader.GetDateTime(2), TimeSpan.Zero),
|
||||
new DateTimeOffset(reader.GetDateTime(3), TimeSpan.Zero)));
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to load zero-result alert candidates from database.");
|
||||
}
|
||||
}
|
||||
|
||||
var fallbackCandidates = _analyticsService.GetFallbackEventsSnapshot(tenantId, window)
|
||||
.Where(item => item.Event.EventType.Equals("zero_result", StringComparison.OrdinalIgnoreCase))
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Event.Query))
|
||||
.GroupBy(item => item.Event.Query.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => new AlertCandidate(
|
||||
group.Key,
|
||||
group.Count(),
|
||||
group.Min(item => item.RecordedAt),
|
||||
group.Max(item => item.RecordedAt)))
|
||||
.Where(candidate => candidate.OccurrenceCount >= ZeroResultAlertThreshold)
|
||||
.OrderByDescending(candidate => candidate.OccurrenceCount)
|
||||
.ToArray();
|
||||
|
||||
return fallbackCandidates;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<AlertCandidate>> LoadNegativeFeedbackCandidatesAsync(
|
||||
string tenantId,
|
||||
TimeSpan window,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var candidates = new List<AlertCandidate>();
|
||||
var days = Math.Max(1, (int)Math.Ceiling(window.TotalDays));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using var cmd = new NpgsqlCommand(@"
|
||||
SELECT query, COUNT(*)::int AS occurrence_count, MIN(created_at), MAX(created_at)
|
||||
FROM advisoryai.search_feedback
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND signal = 'not_helpful'
|
||||
AND created_at > now() - make_interval(days => @days)
|
||||
GROUP BY query
|
||||
HAVING COUNT(*) >= @threshold
|
||||
ORDER BY occurrence_count DESC", conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
cmd.Parameters.AddWithValue("days", days);
|
||||
cmd.Parameters.AddWithValue("threshold", NegativeFeedbackAlertThreshold);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
candidates.Add(new AlertCandidate(
|
||||
reader.GetString(0),
|
||||
reader.GetInt32(1),
|
||||
new DateTimeOffset(reader.GetDateTime(2), TimeSpan.Zero),
|
||||
new DateTimeOffset(reader.GetDateTime(3), TimeSpan.Zero)));
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to load negative-feedback alert candidates from database.");
|
||||
}
|
||||
}
|
||||
|
||||
return GetFallbackFeedback(tenantId, window)
|
||||
.Where(item => item.Entry.Signal.Equals("not_helpful", StringComparison.Ordinal))
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Entry.Query))
|
||||
.GroupBy(item => item.Entry.Query.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => new AlertCandidate(
|
||||
group.Key,
|
||||
group.Count(),
|
||||
group.Min(item => item.CreatedAt),
|
||||
group.Max(item => item.CreatedAt)))
|
||||
.Where(candidate => candidate.OccurrenceCount >= NegativeFeedbackAlertThreshold)
|
||||
.OrderByDescending(candidate => candidate.OccurrenceCount)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private async Task UpsertAlertAsync(
|
||||
string tenantId,
|
||||
string alertType,
|
||||
string query,
|
||||
int occurrenceCount,
|
||||
DateTimeOffset firstSeen,
|
||||
DateTimeOffset lastSeen,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using var findCmd = new NpgsqlCommand(@"
|
||||
SELECT alert_id
|
||||
FROM advisoryai.search_quality_alerts
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND alert_type = @alert_type
|
||||
AND query = @query
|
||||
AND status <> 'resolved'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1", conn);
|
||||
|
||||
findCmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
findCmd.Parameters.AddWithValue("alert_type", alertType);
|
||||
findCmd.Parameters.AddWithValue("query", query);
|
||||
|
||||
var existingId = await findCmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
|
||||
if (existingId is Guid alertId)
|
||||
{
|
||||
await using var updateCmd = new NpgsqlCommand(@"
|
||||
UPDATE advisoryai.search_quality_alerts
|
||||
SET occurrence_count = @occurrence_count,
|
||||
first_seen = LEAST(first_seen, @first_seen),
|
||||
last_seen = GREATEST(last_seen, @last_seen),
|
||||
status = 'open',
|
||||
resolution = NULL
|
||||
WHERE alert_id = @alert_id", conn);
|
||||
|
||||
updateCmd.Parameters.AddWithValue("alert_id", alertId);
|
||||
updateCmd.Parameters.AddWithValue("occurrence_count", occurrenceCount);
|
||||
updateCmd.Parameters.AddWithValue("first_seen", firstSeen.UtcDateTime);
|
||||
updateCmd.Parameters.AddWithValue("last_seen", lastSeen.UtcDateTime);
|
||||
await updateCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await using var insertCmd = new NpgsqlCommand(@"
|
||||
INSERT INTO advisoryai.search_quality_alerts
|
||||
(tenant_id, alert_type, query, occurrence_count, first_seen, last_seen, status)
|
||||
VALUES
|
||||
(@tenant_id, @alert_type, @query, @occurrence_count, @first_seen, @last_seen, 'open')", conn);
|
||||
|
||||
insertCmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
insertCmd.Parameters.AddWithValue("alert_type", alertType);
|
||||
insertCmd.Parameters.AddWithValue("query", query);
|
||||
insertCmd.Parameters.AddWithValue("occurrence_count", occurrenceCount);
|
||||
insertCmd.Parameters.AddWithValue("first_seen", firstSeen.UtcDateTime);
|
||||
insertCmd.Parameters.AddWithValue("last_seen", lastSeen.UtcDateTime);
|
||||
await insertCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to upsert quality alert in database; applying in-memory fallback.");
|
||||
}
|
||||
}
|
||||
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
var existingIndex = _fallbackAlerts.FindIndex(entry =>
|
||||
entry.TenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase) &&
|
||||
entry.AlertType.Equals(alertType, StringComparison.Ordinal) &&
|
||||
entry.Query.Equals(query, StringComparison.OrdinalIgnoreCase) &&
|
||||
!entry.Status.Equals("resolved", StringComparison.Ordinal));
|
||||
|
||||
if (existingIndex >= 0)
|
||||
{
|
||||
var existing = _fallbackAlerts[existingIndex];
|
||||
_fallbackAlerts[existingIndex] = new SearchQualityAlertEntry
|
||||
{
|
||||
AlertId = existing.AlertId,
|
||||
TenantId = existing.TenantId,
|
||||
AlertType = existing.AlertType,
|
||||
Query = existing.Query,
|
||||
OccurrenceCount = occurrenceCount,
|
||||
FirstSeen = existing.FirstSeen <= firstSeen.UtcDateTime ? existing.FirstSeen : firstSeen.UtcDateTime,
|
||||
LastSeen = existing.LastSeen >= lastSeen.UtcDateTime ? existing.LastSeen : lastSeen.UtcDateTime,
|
||||
Status = "open",
|
||||
Resolution = null,
|
||||
CreatedAt = existing.CreatedAt,
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
_fallbackAlerts.Add(new SearchQualityAlertEntry
|
||||
{
|
||||
AlertId = Guid.NewGuid().ToString("D"),
|
||||
TenantId = tenantId,
|
||||
AlertType = alertType,
|
||||
Query = query,
|
||||
OccurrenceCount = occurrenceCount,
|
||||
FirstSeen = firstSeen.UtcDateTime,
|
||||
LastSeen = lastSeen.UtcDateTime,
|
||||
Status = "open",
|
||||
Resolution = null,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void StoreFallbackFeedback(SearchFeedbackEntry entry, DateTimeOffset createdAt)
|
||||
{
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
_fallbackFeedback.Add((entry, createdAt));
|
||||
if (_fallbackFeedback.Count > 10_000)
|
||||
{
|
||||
_fallbackFeedback.RemoveRange(0, _fallbackFeedback.Count - 10_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<(SearchFeedbackEntry Entry, DateTimeOffset CreatedAt)> GetFallbackFeedback(string tenantId, TimeSpan window)
|
||||
{
|
||||
var cutoff = DateTimeOffset.UtcNow - window;
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
return _fallbackFeedback
|
||||
.Where(item => item.Entry.TenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(item => item.CreatedAt >= cutoff)
|
||||
.OrderBy(item => item.CreatedAt)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static SearchQualityAlertEntry CloneAlertEntry(SearchQualityAlertEntry source)
|
||||
{
|
||||
return new SearchQualityAlertEntry
|
||||
{
|
||||
AlertId = source.AlertId,
|
||||
TenantId = source.TenantId,
|
||||
AlertType = source.AlertType,
|
||||
Query = source.Query,
|
||||
OccurrenceCount = source.OccurrenceCount,
|
||||
FirstSeen = source.FirstSeen,
|
||||
LastSeen = source.LastSeen,
|
||||
Status = source.Status,
|
||||
Resolution = source.Resolution,
|
||||
CreatedAt = source.CreatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private readonly record struct AlertCandidate(
|
||||
string Query,
|
||||
int OccurrenceCount,
|
||||
DateTimeOffset FirstSeen,
|
||||
DateTimeOffset LastSeen);
|
||||
|
||||
// ----- Validation helpers -----
|
||||
|
||||
public static bool IsValidSignal(string? signal)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.UnifiedSearch.Analytics;
|
||||
|
||||
internal sealed class SearchQualityMonitorBackgroundService : BackgroundService
|
||||
{
|
||||
private readonly KnowledgeSearchOptions _options;
|
||||
private readonly SearchQualityMonitor _monitor;
|
||||
private readonly ILogger<SearchQualityMonitorBackgroundService> _logger;
|
||||
|
||||
public SearchQualityMonitorBackgroundService(
|
||||
IOptions<KnowledgeSearchOptions> options,
|
||||
SearchQualityMonitor monitor,
|
||||
ILogger<SearchQualityMonitorBackgroundService> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_monitor = monitor;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (!_options.SearchQualityMonitorEnabled)
|
||||
{
|
||||
_logger.LogDebug("Search quality monitor background loop is disabled.");
|
||||
return;
|
||||
}
|
||||
|
||||
var interval = TimeSpan.FromSeconds(Math.Max(30, _options.SearchQualityMonitorIntervalSeconds));
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var refreshed = await _monitor.RefreshAlertsForKnownTenantsAsync(stoppingToken).ConfigureAwait(false);
|
||||
_logger.LogDebug("Search quality monitor refreshed alerts for {TenantCount} tenants.", refreshed);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Search quality monitor background refresh failed.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(interval, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,7 @@ public static class UnifiedSearchServiceCollectionExtensions
|
||||
services.TryAddSingleton<UnifiedSearchIndexer>();
|
||||
services.TryAddSingleton<IUnifiedSearchIndexer>(provider => provider.GetRequiredService<UnifiedSearchIndexer>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService, UnifiedSearchIndexRefreshService>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService, SearchQualityMonitorBackgroundService>());
|
||||
|
||||
// Telemetry
|
||||
services.TryAddSingleton<IUnifiedSearchTelemetrySink, LoggingUnifiedSearchTelemetrySink>();
|
||||
|
||||
@@ -39,16 +39,7 @@ public sealed class ChatIntegrationTests : IClassFixture<WebApplicationFactory<S
|
||||
});
|
||||
});
|
||||
|
||||
_client = _factory.CreateClient();
|
||||
// Current advisory-ai endpoints authorize using scope + actor headers.
|
||||
_client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "test-user");
|
||||
_client.DefaultRequestHeaders.Add("X-StellaOps-Scopes", "advisory-ai:operate advisory:chat chat:user");
|
||||
_client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "test-tenant");
|
||||
|
||||
// Keep legacy headers for compatibility with older code paths.
|
||||
_client.DefaultRequestHeaders.Add("X-StellaOps-User", "test-user");
|
||||
_client.DefaultRequestHeaders.Add("X-StellaOps-Client", "test-client");
|
||||
_client.DefaultRequestHeaders.Add("X-StellaOps-Roles", "chat:user");
|
||||
_client = CreateClientWithScopes("advisory-ai:operate advisory:chat chat:user");
|
||||
}
|
||||
|
||||
#region Create Conversation Tests
|
||||
@@ -263,6 +254,8 @@ public sealed class ChatIntegrationTests : IClassFixture<WebApplicationFactory<S
|
||||
result.Should().NotBeNull();
|
||||
result!.TurnId.Should().NotBeNullOrEmpty();
|
||||
result.Content.Should().NotBeNullOrEmpty();
|
||||
result.Content.Should().NotContain("placeholder response",
|
||||
"add-turn runtime should use grounded path or deterministic fallback instead of placeholders");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -284,6 +277,133 @@ public sealed class ChatIntegrationTests : IClassFixture<WebApplicationFactory<S
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddTurn_LegacyMessageField_Returns200WithResponse()
|
||||
{
|
||||
// Arrange - Create conversation first
|
||||
var createRequest = new CreateConversationRequest { TenantId = "test-tenant-legacy-message" };
|
||||
var createResponse = await _client.PostAsJsonAsync("/v1/advisory-ai/conversations", createRequest);
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<ConversationResponse>();
|
||||
|
||||
var turnRequest = new AddTurnRequest
|
||||
{
|
||||
Message = "Legacy payload message field still works",
|
||||
Stream = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync(
|
||||
$"/v1/advisory-ai/conversations/{created!.ConversationId}/turns",
|
||||
turnRequest);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
response.Headers.TryGetValues("Deprecation", out var deprecationValues).Should().BeTrue();
|
||||
deprecationValues.Should().Contain("true");
|
||||
response.Headers.TryGetValues("Sunset", out var sunsetValues).Should().BeTrue();
|
||||
sunsetValues.Should().Contain("Thu, 31 Dec 2026 23:59:59 GMT");
|
||||
response.Headers.TryGetValues("Warning", out var warningValues).Should().BeTrue();
|
||||
warningValues.Should().Contain(v => v.Contains("message", StringComparison.OrdinalIgnoreCase));
|
||||
var result = await response.Content.ReadFromJsonAsync<AssistantTurnResponse>();
|
||||
result.Should().NotBeNull();
|
||||
result!.Content.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChatEndpointFamilies_RequireOperateScope_ForEquivalentWriteOperations()
|
||||
{
|
||||
// Arrange
|
||||
var createResponse = await _client.PostAsJsonAsync(
|
||||
"/v1/advisory-ai/conversations",
|
||||
new CreateConversationRequest { TenantId = "test-tenant-scope-parity" });
|
||||
createResponse.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<ConversationResponse>();
|
||||
|
||||
var viewOnlyClient = CreateClientWithScopes("advisory-ai:view");
|
||||
|
||||
// Act
|
||||
var legacyAddTurnResponse = await viewOnlyClient.PostAsJsonAsync(
|
||||
$"/v1/advisory-ai/conversations/{created!.ConversationId}/turns",
|
||||
new AddTurnRequest { Content = "Can I add with view-only scope?" });
|
||||
|
||||
var gatewayQueryResponse = await viewOnlyClient.PostAsJsonAsync(
|
||||
"/api/v1/chat/query",
|
||||
new AdvisoryChatQueryRequest { Query = "Can I query with view-only scope?" });
|
||||
|
||||
// Assert
|
||||
legacyAddTurnResponse.StatusCode.Should().Be(HttpStatusCode.Forbidden);
|
||||
gatewayQueryResponse.StatusCode.Should().Be(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConversationAndGatewayEndpoints_ReturnGroundedRuntimeResponses()
|
||||
{
|
||||
// Arrange
|
||||
var createResponse = await _client.PostAsJsonAsync(
|
||||
"/v1/advisory-ai/conversations",
|
||||
new CreateConversationRequest
|
||||
{
|
||||
TenantId = "test-tenant-runtime-consistency",
|
||||
Context = new ConversationContextRequest { CurrentCveId = "CVE-2023-44487" }
|
||||
});
|
||||
createResponse.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<ConversationResponse>();
|
||||
|
||||
// Act
|
||||
var addTurnResponse = await _client.PostAsJsonAsync(
|
||||
$"/v1/advisory-ai/conversations/{created!.ConversationId}/turns",
|
||||
new AddTurnRequest { Content = "Assess CVE-2023-44487 risk and next action." });
|
||||
|
||||
var gatewayQueryResponse = await _client.PostAsJsonAsync(
|
||||
"/api/v1/chat/query",
|
||||
new AdvisoryChatQueryRequest
|
||||
{
|
||||
Query = "Assess CVE-2023-44487 risk and next action.",
|
||||
ConversationId = created.ConversationId
|
||||
});
|
||||
|
||||
// Assert
|
||||
addTurnResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
gatewayQueryResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var addTurnResult = await addTurnResponse.Content.ReadFromJsonAsync<AssistantTurnResponse>();
|
||||
var gatewayResult = await gatewayQueryResponse.Content.ReadFromJsonAsync<AdvisoryChatQueryResponse>();
|
||||
|
||||
addTurnResult.Should().NotBeNull();
|
||||
addTurnResult!.Content.Should().NotBeNullOrWhiteSpace();
|
||||
addTurnResult.Content.Should().NotContain("placeholder response",
|
||||
"conversation add-turn must use grounded runtime output or deterministic fallback");
|
||||
|
||||
gatewayResult.Should().NotBeNull();
|
||||
gatewayResult!.Summary.Should().NotBeNullOrWhiteSpace();
|
||||
gatewayResult.Summary.Should().NotContain("placeholder response",
|
||||
"chat gateway endpoint must use grounded runtime output or deterministic fallback");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddTurn_EmptyPayload_Returns400()
|
||||
{
|
||||
// Arrange - Create conversation first
|
||||
var createRequest = new CreateConversationRequest { TenantId = "test-tenant-empty-message" };
|
||||
var createResponse = await _client.PostAsJsonAsync("/v1/advisory-ai/conversations", createRequest);
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<ConversationResponse>();
|
||||
|
||||
var turnRequest = new AddTurnRequest
|
||||
{
|
||||
Content = " ",
|
||||
Message = " ",
|
||||
Stream = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync(
|
||||
$"/v1/advisory-ai/conversations/{created!.ConversationId}/turns",
|
||||
turnRequest);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddTurn_MultipleMessages_BuildsConversationHistory()
|
||||
{
|
||||
@@ -394,6 +514,22 @@ public sealed class ChatIntegrationTests : IClassFixture<WebApplicationFactory<S
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private HttpClient CreateClientWithScopes(string scopes)
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "test-user");
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Scopes", scopes);
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "test-tenant");
|
||||
client.DefaultRequestHeaders.Add("X-Tenant-Id", "test-tenant");
|
||||
client.DefaultRequestHeaders.Add("X-User-Id", "test-user");
|
||||
|
||||
// Keep legacy headers for compatibility with older code paths.
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-User", "test-user");
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Client", "test-client");
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Roles", "chat:user");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -131,6 +131,85 @@ public sealed class UnifiedSearchSprintIntegrationTests : IDisposable
|
||||
"legacy search endpoint should include Sunset header");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void G5_ExactLexicalRank_PrecedesFuzzyFallbackRank()
|
||||
{
|
||||
using var exactMetadata = JsonDocument.Parse("""{"entity_key":"docs:ci-guide","domain":"knowledge"}""");
|
||||
using var fuzzyMetadata = JsonDocument.Parse("""{"entity_key":"docs:city-overview","domain":"knowledge"}""");
|
||||
|
||||
var exactRow = new KnowledgeChunkRow(
|
||||
ChunkId: "chunk-exact",
|
||||
DocId: "doc-1",
|
||||
Kind: "md_section",
|
||||
Anchor: "ci",
|
||||
SectionPath: null,
|
||||
SpanStart: 0,
|
||||
SpanEnd: 64,
|
||||
Title: "CI pipeline troubleshooting",
|
||||
Body: "CI pipeline troubleshooting and retry guidance.",
|
||||
Snippet: "CI pipeline troubleshooting",
|
||||
Metadata: exactMetadata,
|
||||
Embedding: null,
|
||||
LexicalScore: 1.2);
|
||||
|
||||
var fuzzyRow = new KnowledgeChunkRow(
|
||||
ChunkId: "chunk-fuzzy",
|
||||
DocId: "doc-2",
|
||||
Kind: "md_section",
|
||||
Anchor: "city",
|
||||
SectionPath: null,
|
||||
SpanStart: 0,
|
||||
SpanEnd: 64,
|
||||
Title: "City overview",
|
||||
Body: "City overview details for unrelated content.",
|
||||
Snippet: "City overview",
|
||||
Metadata: fuzzyMetadata,
|
||||
Embedding: null,
|
||||
LexicalScore: 0);
|
||||
|
||||
var lexicalRanks = new Dictionary<string, (string ChunkId, int Rank, KnowledgeChunkRow Row)>(StringComparer.Ordinal)
|
||||
{
|
||||
["chunk-exact"] = ("chunk-exact", 1, exactRow),
|
||||
["chunk-fuzzy"] = ("chunk-fuzzy", 2, fuzzyRow)
|
||||
};
|
||||
|
||||
var ranked = WeightedRrfFusion.Fuse(
|
||||
new Dictionary<string, double>(StringComparer.Ordinal) { ["knowledge"] = 1.0 },
|
||||
lexicalRanks,
|
||||
[],
|
||||
"ci",
|
||||
null,
|
||||
null,
|
||||
enableFreshnessBoost: false,
|
||||
referenceTime: null,
|
||||
popularityMap: null,
|
||||
popularityBoostWeight: 0);
|
||||
|
||||
ranked.Should().HaveCount(2);
|
||||
ranked[0].Row.ChunkId.Should().Be("chunk-exact",
|
||||
"exact lexical hits should stay ahead of fuzzy fallback candidates");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task G5_QueryCi_ReturnsRelevantResults()
|
||||
{
|
||||
using var client = CreateAuthenticatedClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/v1/search/query", new UnifiedSearchApiRequest
|
||||
{
|
||||
Q = "ci"
|
||||
});
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var payload = await response.Content.ReadFromJsonAsync<UnifiedSearchApiResponse>();
|
||||
payload.Should().NotBeNull();
|
||||
payload!.Cards.Should().NotBeEmpty("short technical acronyms should still return useful matches");
|
||||
payload.Cards.Any(card =>
|
||||
card.Title.Contains("CI", StringComparison.OrdinalIgnoreCase) ||
|
||||
card.Snippet.Contains("CI", StringComparison.OrdinalIgnoreCase))
|
||||
.Should().BeTrue("the CI query should return CI-relevant search cards");
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Sprint 102 (G1) - ONNX Vector Encoder
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
@@ -608,6 +687,70 @@ public sealed class UnifiedSearchSprintIntegrationTests : IDisposable
|
||||
"role bias should not apply when RoleBasedBiasEnabled is false");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void G6_DomainWeightCalculator_ScannerReadBiasesFindings_ForGenericQuery()
|
||||
{
|
||||
var calculator = new DomainWeightCalculator(
|
||||
new EntityExtractor(),
|
||||
new IntentClassifier(),
|
||||
Microsoft.Extensions.Options.Options.Create(new KnowledgeSearchOptions
|
||||
{
|
||||
RoleBasedBiasEnabled = true
|
||||
}));
|
||||
|
||||
var baseWeights = calculator.ComputeWeights("release status", [], null);
|
||||
var scopedWeights = calculator.ComputeWeights("release status", [], new UnifiedSearchFilter
|
||||
{
|
||||
UserScopes = ["scanner:read"]
|
||||
});
|
||||
|
||||
scopedWeights["findings"].Should().BeGreaterThan(baseWeights["findings"],
|
||||
"scanner:read should bias generic queries toward findings");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void G6_DomainWeightCalculator_PolicyWriteBiasesPolicy_ForGenericQuery()
|
||||
{
|
||||
var calculator = new DomainWeightCalculator(
|
||||
new EntityExtractor(),
|
||||
new IntentClassifier(),
|
||||
Microsoft.Extensions.Options.Options.Create(new KnowledgeSearchOptions
|
||||
{
|
||||
RoleBasedBiasEnabled = true
|
||||
}));
|
||||
|
||||
var baseWeights = calculator.ComputeWeights("release status", [], null);
|
||||
var scopedWeights = calculator.ComputeWeights("release status", [], new UnifiedSearchFilter
|
||||
{
|
||||
UserScopes = ["policy:write"]
|
||||
});
|
||||
|
||||
scopedWeights["policy"].Should().BeGreaterThan(baseWeights["policy"],
|
||||
"policy:write should bias generic queries toward policy");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void G6_DomainWeightCalculator_NoRelevantScopes_LeavesWeightsUnbiased()
|
||||
{
|
||||
var calculator = new DomainWeightCalculator(
|
||||
new EntityExtractor(),
|
||||
new IntentClassifier(),
|
||||
Microsoft.Extensions.Options.Options.Create(new KnowledgeSearchOptions
|
||||
{
|
||||
RoleBasedBiasEnabled = true
|
||||
}));
|
||||
|
||||
var baseWeights = calculator.ComputeWeights("release status", [], null);
|
||||
var unrelatedScopeWeights = calculator.ComputeWeights("release status", [], new UnifiedSearchFilter
|
||||
{
|
||||
UserScopes = ["dashboard:read"]
|
||||
});
|
||||
|
||||
unrelatedScopeWeights["findings"].Should().Be(baseWeights["findings"]);
|
||||
unrelatedScopeWeights["policy"].Should().Be(baseWeights["policy"]);
|
||||
unrelatedScopeWeights["knowledge"].Should().Be(baseWeights["knowledge"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void G6_WeightedRrfFusion_PopularityBoost_AppliesWhenMapProvided()
|
||||
{
|
||||
@@ -726,6 +869,15 @@ public sealed class UnifiedSearchSprintIntegrationTests : IDisposable
|
||||
detector.DetectLanguage("der Container startet nicht und die Logs zeigen einen Fehler").Should().Be("de");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void G9_QueryLanguageDetector_DetectsGermanSecurityPluralTerms()
|
||||
{
|
||||
var detector = new QueryLanguageDetector();
|
||||
var language = detector.DetectLanguage("Sicherheitslücken in der Produktion");
|
||||
language.Should().Be("de");
|
||||
detector.MapLanguageToFtsConfig(language).Should().Be("german");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void G9_QueryLanguageDetector_DetectsFrench()
|
||||
{
|
||||
@@ -888,6 +1040,13 @@ public sealed class UnifiedSearchSprintIntegrationTests : IDisposable
|
||||
classifier.Classify("qu'est-ce que une politique de s\u00e9curit\u00e9", "fr").Should().Be("explore");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void G9_IntentClassifier_ClassifiesFrenchTroubleshoot()
|
||||
{
|
||||
var classifier = new IntentClassifier();
|
||||
classifier.Classify("corriger l'erreur de connexion", "fr").Should().Be("troubleshoot");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void G9_IntentClassifier_ClassifiesSpanishCompare()
|
||||
{
|
||||
@@ -926,6 +1085,31 @@ public sealed class UnifiedSearchSprintIntegrationTests : IDisposable
|
||||
classifier.HasPolicyIntent("docker login fails").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void G9_MultilingualIntentKeywords_AreUtf8Clean()
|
||||
{
|
||||
var markerFragments = new[] { "Ã", "Ð", "Ñ", "\uFFFD" };
|
||||
var keywordMaps = new[]
|
||||
{
|
||||
MultilingualIntentKeywords.GetNavigateKeywords(),
|
||||
MultilingualIntentKeywords.GetTroubleshootKeywords(),
|
||||
MultilingualIntentKeywords.GetExploreKeywords(),
|
||||
MultilingualIntentKeywords.GetCompareKeywords(),
|
||||
};
|
||||
|
||||
foreach (var map in keywordMaps)
|
||||
{
|
||||
foreach (var terms in map.Values)
|
||||
{
|
||||
foreach (var term in terms)
|
||||
{
|
||||
markerFragments.Any(term.Contains).Should().BeFalse(
|
||||
$"keyword '{term}' should not contain mojibake fragments");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void G9_SynthesisTemplateEngine_GermanLocale_ProducesGermanOutput()
|
||||
{
|
||||
@@ -958,6 +1142,31 @@ public sealed class UnifiedSearchSprintIntegrationTests : IDisposable
|
||||
"French locale should produce French-localized synthesis output");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void G9_DoctorSearchSeedLoader_LoadsGermanLocalizedEntries()
|
||||
{
|
||||
var repoRoot = Path.GetFullPath(Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"..", "..", "..", "..", "..", "..", ".."));
|
||||
var baseSeedPath = Path.Combine(
|
||||
repoRoot,
|
||||
"src",
|
||||
"AdvisoryAI",
|
||||
"StellaOps.AdvisoryAI",
|
||||
"KnowledgeSearch",
|
||||
"doctor-search-seed.json");
|
||||
File.Exists(baseSeedPath).Should().BeTrue("doctor seed base file must exist for localization checks");
|
||||
var localized = DoctorSearchSeedLoader.LoadLocalized(baseSeedPath);
|
||||
|
||||
localized.Should().ContainKey("de",
|
||||
"German localized doctor seed file should be discovered");
|
||||
localized["de"].Should().NotBeEmpty();
|
||||
localized["de"].Any(entry =>
|
||||
entry.Title.Contains("Konnektivität", StringComparison.OrdinalIgnoreCase) ||
|
||||
entry.Description.Contains("Datenbank", StringComparison.OrdinalIgnoreCase))
|
||||
.Should().BeTrue("German doctor entries should expose localized descriptions/titles");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void G9_SynthesisTemplateEngine_SpanishLocale_ProducesSpanishOutput()
|
||||
{
|
||||
@@ -1179,6 +1388,69 @@ public sealed class UnifiedSearchSprintIntegrationTests : IDisposable
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task G6_AnalyticsClickEvent_IsStoredForPopularitySignals()
|
||||
{
|
||||
using var client = CreateAuthenticatedClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/v1/advisory-ai/search/analytics", new SearchAnalyticsApiRequest
|
||||
{
|
||||
Events =
|
||||
[
|
||||
new SearchAnalyticsApiEvent
|
||||
{
|
||||
EventType = "click",
|
||||
Query = "docker login",
|
||||
EntityKey = "docs:troubleshooting",
|
||||
Domain = "knowledge",
|
||||
Position = 1
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
|
||||
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var analyticsService = scope.ServiceProvider.GetRequiredService<SearchAnalyticsService>();
|
||||
var popularity = await analyticsService.GetPopularityMapAsync("test-tenant", 30);
|
||||
|
||||
popularity.Should().ContainKey("docs:troubleshooting",
|
||||
"click analytics events should be persisted for popularity ranking signals");
|
||||
popularity["docs:troubleshooting"].Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task G6_SearchHistory_IsPersistedAndQueryable_FromAnalyticsFlow()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Scopes", "advisory-ai:operate");
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "test-tenant");
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "integration-user");
|
||||
|
||||
var analyticsResponse = await client.PostAsJsonAsync("/v1/advisory-ai/search/analytics", new SearchAnalyticsApiRequest
|
||||
{
|
||||
Events =
|
||||
[
|
||||
new SearchAnalyticsApiEvent
|
||||
{
|
||||
EventType = "query",
|
||||
Query = "history integration probe",
|
||||
ResultCount = 2
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
analyticsResponse.StatusCode.Should().Be(HttpStatusCode.NoContent);
|
||||
|
||||
var historyResponse = await client.GetAsync("/v1/advisory-ai/search/history");
|
||||
historyResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var historyPayload = await historyResponse.Content.ReadFromJsonAsync<SearchHistoryApiResponse>();
|
||||
historyPayload.Should().NotBeNull();
|
||||
historyPayload!.Entries.Any(entry => entry.Query.Equals("history integration probe", StringComparison.Ordinal))
|
||||
.Should().BeTrue("successful query analytics should be persisted in server-side history");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task G10_AnalyticsEndpoint_EmptyEvents_ReturnsBadRequest()
|
||||
{
|
||||
@@ -1212,6 +1484,32 @@ public sealed class UnifiedSearchSprintIntegrationTests : IDisposable
|
||||
"batch exceeding 100 events should be rejected");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task G10_ZeroResultBurst_CreatesQualityAlert()
|
||||
{
|
||||
using var client = CreateAuthenticatedClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/v1/advisory-ai/search/analytics", new SearchAnalyticsApiRequest
|
||||
{
|
||||
Events = Enumerable.Range(0, 5).Select(_ => new SearchAnalyticsApiEvent
|
||||
{
|
||||
EventType = "zero_result",
|
||||
Query = "nonexistent vulnerability token",
|
||||
ResultCount = 0
|
||||
}).ToArray()
|
||||
});
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
|
||||
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var monitor = scope.ServiceProvider.GetRequiredService<SearchQualityMonitor>();
|
||||
await monitor.RefreshAlertsAsync("test-tenant");
|
||||
var alerts = await monitor.GetAlertsAsync("test-tenant", status: "open", alertType: "zero_result");
|
||||
|
||||
alerts.Any(alert => alert.Query.Equals("nonexistent vulnerability token", StringComparison.OrdinalIgnoreCase))
|
||||
.Should().BeTrue("five repeated zero-result events should create a zero_result quality alert");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task G10_AlertUpdateEndpoint_InvalidStatus_ReturnsBadRequest()
|
||||
{
|
||||
@@ -1444,6 +1742,8 @@ public sealed class UnifiedSearchSprintIntegrationTests : IDisposable
|
||||
{
|
||||
public Task<UnifiedSearchResponse> SearchAsync(UnifiedSearchRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedQuery = request.Q.Trim();
|
||||
var isCiQuery = normalizedQuery.Equals("ci", StringComparison.OrdinalIgnoreCase);
|
||||
var cards = new List<EntityCard>();
|
||||
|
||||
// Apply domain filtering if specified
|
||||
@@ -1483,8 +1783,8 @@ public sealed class UnifiedSearchSprintIntegrationTests : IDisposable
|
||||
EntityKey = "docs:troubleshooting",
|
||||
EntityType = "docs",
|
||||
Domain = "knowledge",
|
||||
Title = "Troubleshooting Guide",
|
||||
Snippet = "Common troubleshooting steps",
|
||||
Title = isCiQuery ? "CI pipeline troubleshooting" : "Troubleshooting Guide",
|
||||
Snippet = isCiQuery ? "CI checks, retries, and pipeline diagnostics." : "Common troubleshooting steps",
|
||||
Score = 0.85,
|
||||
Actions =
|
||||
[
|
||||
@@ -1499,7 +1799,7 @@ public sealed class UnifiedSearchSprintIntegrationTests : IDisposable
|
||||
{
|
||||
synthesis = new SynthesisResult
|
||||
{
|
||||
Summary = $"Found {cards.Count} results for \"{request.Q}\".",
|
||||
Summary = $"Found {cards.Count} results for \"{normalizedQuery}\".",
|
||||
Template = "mixed_overview",
|
||||
Confidence = cards.Count >= 2 ? "medium" : "low",
|
||||
SourceCount = cards.Count,
|
||||
|
||||
@@ -137,6 +137,96 @@ public sealed class WeightedRrfFusionTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fuse_popularity_boost_can_raise_high_click_result_above_lower_ranked_peer()
|
||||
{
|
||||
var weights = new Dictionary<string, double> { ["findings"] = 1.0 };
|
||||
|
||||
using var lowMeta = JsonDocument.Parse("""{"entity_key":"finding:low","domain":"findings"}""");
|
||||
using var highMeta = JsonDocument.Parse("""{"entity_key":"finding:high","domain":"findings"}""");
|
||||
|
||||
var lowerClickRow = MakeRow("chunk-low", "finding", "Lower click finding", lowMeta);
|
||||
var higherClickRow = MakeRow("chunk-high", "finding", "Higher click finding", highMeta);
|
||||
|
||||
var lexical = new Dictionary<string, (string ChunkId, int Rank, KnowledgeChunkRow Row)>(StringComparer.Ordinal)
|
||||
{
|
||||
["chunk-low"] = ("chunk-low", 1, lowerClickRow),
|
||||
["chunk-high"] = ("chunk-high", 2, higherClickRow)
|
||||
};
|
||||
|
||||
var withPopularity = WeightedRrfFusion.Fuse(
|
||||
weights,
|
||||
lexical,
|
||||
[],
|
||||
"finding",
|
||||
null,
|
||||
null,
|
||||
enableFreshnessBoost: false,
|
||||
referenceTime: null,
|
||||
popularityMap: new Dictionary<string, int>(StringComparer.Ordinal)
|
||||
{
|
||||
["finding:low"] = 0,
|
||||
["finding:high"] = 100
|
||||
},
|
||||
popularityBoostWeight: 0.05);
|
||||
|
||||
withPopularity.Should().HaveCount(2);
|
||||
withPopularity[0].Row.ChunkId.Should().Be("chunk-high",
|
||||
"high-click result should outrank lower-click peer when popularity boost is enabled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fuse_without_popularity_boost_keeps_baseline_ranking()
|
||||
{
|
||||
var weights = new Dictionary<string, double> { ["findings"] = 1.0 };
|
||||
|
||||
using var lowMeta = JsonDocument.Parse("""{"entity_key":"finding:low","domain":"findings"}""");
|
||||
using var highMeta = JsonDocument.Parse("""{"entity_key":"finding:high","domain":"findings"}""");
|
||||
|
||||
var lowerClickRow = MakeRow("chunk-low", "finding", "Lower click finding", lowMeta);
|
||||
var higherClickRow = MakeRow("chunk-high", "finding", "Higher click finding", highMeta);
|
||||
|
||||
var lexical = new Dictionary<string, (string ChunkId, int Rank, KnowledgeChunkRow Row)>(StringComparer.Ordinal)
|
||||
{
|
||||
["chunk-low"] = ("chunk-low", 1, lowerClickRow),
|
||||
["chunk-high"] = ("chunk-high", 2, higherClickRow)
|
||||
};
|
||||
|
||||
var baseline = WeightedRrfFusion.Fuse(
|
||||
weights,
|
||||
lexical,
|
||||
[],
|
||||
"finding",
|
||||
null,
|
||||
null,
|
||||
enableFreshnessBoost: false,
|
||||
referenceTime: null,
|
||||
popularityMap: null,
|
||||
popularityBoostWeight: 0.0);
|
||||
|
||||
var disabledWithMap = WeightedRrfFusion.Fuse(
|
||||
weights,
|
||||
lexical,
|
||||
[],
|
||||
"finding",
|
||||
null,
|
||||
null,
|
||||
enableFreshnessBoost: false,
|
||||
referenceTime: null,
|
||||
popularityMap: new Dictionary<string, int>(StringComparer.Ordinal)
|
||||
{
|
||||
["finding:low"] = 0,
|
||||
["finding:high"] = 100
|
||||
},
|
||||
popularityBoostWeight: 0.0);
|
||||
|
||||
baseline.Should().HaveCount(2);
|
||||
disabledWithMap.Should().HaveCount(2);
|
||||
baseline[0].Row.ChunkId.Should().Be("chunk-low");
|
||||
disabledWithMap[0].Row.ChunkId.Should().Be("chunk-low",
|
||||
"when popularity boost is disabled, ranking should match the baseline order");
|
||||
}
|
||||
|
||||
private static KnowledgeChunkRow MakeRow(
|
||||
string chunkId,
|
||||
string kind,
|
||||
|
||||
Reference in New Issue
Block a user