documentation cleanse, sprints work and planning. remaining non EF DAL migration to EF

This commit is contained in:
master
2026-02-25 01:24:07 +02:00
parent b07d27772e
commit 4db038123b
9090 changed files with 4836 additions and 2909 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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++)
{

View File

@@ -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>

View File

@@ -1,4 +1,4 @@
[
[
{
"checkCode": "check.core.disk.space",
"title": "Speicherplatzverfügbarkeit",
@@ -168,3 +168,4 @@
]
}
]

View File

@@ -1,4 +1,4 @@
[
[
{
"checkCode": "check.core.disk.space",
"title": "Disponibilité de l'espace disque",

View File

@@ -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(

View File

@@ -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)

View File

@@ -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;
}
}
}
}

View File

@@ -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>();

View File

@@ -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>

View File

@@ -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,

View File

@@ -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,