search and ai stabilization work, localization stablized.

This commit is contained in:
master
2026-02-24 23:29:36 +02:00
parent 4f947a8b61
commit b07d27772e
766 changed files with 55299 additions and 3221 deletions

View File

@@ -11,6 +11,7 @@ using StellaOps.AdvisoryAI.Attestation.Models;
using StellaOps.AdvisoryAI.Attestation.Storage;
using StellaOps.AdvisoryAI.WebService.Security;
using StellaOps.Auth.ServerIntegration.Tenancy;
using static StellaOps.Localization.T;
namespace StellaOps.AdvisoryAI.WebService.Endpoints;
@@ -94,13 +95,13 @@ public static class AttestationEndpoints
if (attestation is null)
{
return Results.NotFound(new { error = "Run attestation not found", runId });
return Results.NotFound(new { error = _t("advisoryai.error.run_attestation_not_found"), runId });
}
// Enforce tenant isolation
if (attestation.TenantId != tenantId)
{
return Results.NotFound(new { error = "Run attestation not found", runId });
return Results.NotFound(new { error = _t("advisoryai.error.run_attestation_not_found"), runId });
}
// Get the signed envelope if available (from store)
@@ -141,7 +142,7 @@ public static class AttestationEndpoints
if (attestation is null || attestation.TenantId != tenantId)
{
return Results.NotFound(new { error = "Run not found", runId });
return Results.NotFound(new { error = _t("advisoryai.error.run_not_found", runId), runId });
}
var claims = await attestationService.GetClaimAttestationsAsync(runId, cancellationToken)
@@ -197,7 +198,7 @@ public static class AttestationEndpoints
return Results.BadRequest(new AttestationVerificationResponse
{
IsValid = false,
Error = "RunId is required"
Error = _t("advisoryai.validation.run_id_required")
});
}
@@ -211,7 +212,7 @@ public static class AttestationEndpoints
{
IsValid = false,
RunId = request.RunId,
Error = "Attestation not found or access denied"
Error = _t("advisoryai.error.attestation_not_found")
});
}

View File

@@ -22,6 +22,7 @@ using StellaOps.Auth.ServerIntegration.Tenancy;
using System.Collections.Immutable;
using System.Runtime.CompilerServices;
using System.Text.Json;
using static StellaOps.Localization.T;
namespace StellaOps.AdvisoryAI.WebService.Endpoints;
@@ -134,13 +135,13 @@ public static class ChatEndpoints
if (!options.Value.Enabled)
{
return Results.Json(
new ErrorResponse { Error = "Advisory chat is disabled", Code = "CHAT_DISABLED" },
new ErrorResponse { Error = _t("advisoryai.error.chat_disabled"), Code = "CHAT_DISABLED" },
statusCode: StatusCodes.Status503ServiceUnavailable);
}
if (string.IsNullOrWhiteSpace(request.Query))
{
return Results.BadRequest(new ErrorResponse { Error = "Query cannot be empty", Code = "INVALID_QUERY" });
return Results.BadRequest(new ErrorResponse { Error = _t("advisoryai.error.query_empty"), Code = "INVALID_QUERY" });
}
tenantId ??= "default";
@@ -235,7 +236,7 @@ public static class ChatEndpoints
{
httpContext.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
await httpContext.Response.WriteAsJsonAsync(
new ErrorResponse { Error = "Advisory chat is disabled", Code = "CHAT_DISABLED" },
new ErrorResponse { Error = _t("advisoryai.error.chat_disabled"), Code = "CHAT_DISABLED" },
ct);
return;
}
@@ -244,7 +245,7 @@ public static class ChatEndpoints
{
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(
new ErrorResponse { Error = "Query cannot be empty", Code = "INVALID_QUERY" },
new ErrorResponse { Error = _t("advisoryai.error.query_empty"), Code = "INVALID_QUERY" },
ct);
return;
}
@@ -427,7 +428,7 @@ public static class ChatEndpoints
{
if (string.IsNullOrWhiteSpace(request.Query))
{
return Results.BadRequest(new ErrorResponse { Error = "Query cannot be empty", Code = "INVALID_QUERY" });
return Results.BadRequest(new ErrorResponse { Error = _t("advisoryai.error.query_empty"), Code = "INVALID_QUERY" });
}
var result = await intentRouter.RouteAsync(request.Query, ct);

View File

@@ -12,6 +12,7 @@ using StellaOps.Determinism;
using StellaOps.Evidence.Pack;
using StellaOps.Evidence.Pack.Models;
using System.Collections.Immutable;
using static StellaOps.Localization.T;
namespace StellaOps.AdvisoryAI.WebService.Endpoints;
@@ -132,12 +133,12 @@ public static class EvidencePackEndpoints
if (request.Claims is null || request.Claims.Count == 0)
{
return Results.BadRequest(new { error = "At least one claim is required" });
return Results.BadRequest(new { error = _t("advisoryai.validation.claims_required") });
}
if (request.Evidence is null || request.Evidence.Count == 0)
{
return Results.BadRequest(new { error = "At least one evidence item is required" });
return Results.BadRequest(new { error = _t("advisoryai.validation.evidence_items_required") });
}
var claims = request.Claims.Select(c => new EvidenceClaim
@@ -205,7 +206,7 @@ public static class EvidencePackEndpoints
if (pack is null)
{
return Results.NotFound(new { error = "Evidence pack not found", packId });
return Results.NotFound(new { error = _t("advisoryai.error.evidence_pack_not_found"), packId });
}
return Results.Ok(EvidencePackResponse.FromPack(pack));
@@ -228,7 +229,7 @@ public static class EvidencePackEndpoints
if (pack is null)
{
return Results.NotFound(new { error = "Evidence pack not found", packId });
return Results.NotFound(new { error = _t("advisoryai.error.evidence_pack_not_found"), packId });
}
var signedPack = await evidencePackService.SignAsync(pack, cancellationToken)
@@ -254,7 +255,7 @@ public static class EvidencePackEndpoints
if (pack is null)
{
return Results.NotFound(new { error = "Evidence pack not found", packId });
return Results.NotFound(new { error = _t("advisoryai.error.evidence_pack_not_found"), packId });
}
// Get signed version from store
@@ -265,7 +266,7 @@ public static class EvidencePackEndpoints
if (signedPack is null)
{
return Results.BadRequest(new { error = "Pack is not signed", packId });
return Results.BadRequest(new { error = _t("advisoryai.error.pack_not_signed"), packId });
}
var result = await evidencePackService.VerifyAsync(signedPack, cancellationToken)
@@ -307,7 +308,7 @@ public static class EvidencePackEndpoints
if (pack is null)
{
return Results.NotFound(new { error = "Evidence pack not found", packId });
return Results.NotFound(new { error = _t("advisoryai.error.evidence_pack_not_found"), packId });
}
var exportFormat = format?.ToLowerInvariant() switch

View File

@@ -4,6 +4,8 @@ using Microsoft.AspNetCore.Routing;
using StellaOps.AdvisoryAI.KnowledgeSearch;
using StellaOps.AdvisoryAI.WebService.Security;
using StellaOps.Auth.ServerIntegration.Tenancy;
using System.Linq;
using static StellaOps.Localization.T;
namespace StellaOps.AdvisoryAI.WebService.Endpoints;
@@ -28,6 +30,7 @@ public static class KnowledgeSearchEndpoints
.WithSummary("Searches AdvisoryAI deterministic knowledge index (docs/api/doctor).")
.WithDescription("Performs a hybrid full-text and vector similarity search over the AdvisoryAI deterministic knowledge index, which is composed of product documentation, OpenAPI specs, and Doctor health check projections. Supports filtering by content type (docs, api, doctor), product, version, service, and tags. Returns ranked result snippets with actionable open-actions for UI navigation.")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
.RequireRateLimiting("advisory-ai")
.Produces<AdvisoryKnowledgeSearchResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status403Forbidden);
@@ -37,6 +40,7 @@ public static class KnowledgeSearchEndpoints
.WithSummary("Rebuilds AdvisoryAI knowledge search index from deterministic local sources.")
.WithDescription("Triggers a full rebuild of the knowledge search index from local deterministic sources: product documentation files, embedded OpenAPI specs, and Doctor health check metadata. The rebuild is synchronous and returns document, chunk, and operation counts with duration. Requires admin-level scope; does not fetch external content.")
.RequireAuthorization(AdvisoryAIPolicies.AdminPolicy)
.RequireRateLimiting("advisory-ai")
.Produces<AdvisoryKnowledgeRebuildResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status403Forbidden);
@@ -49,22 +53,32 @@ public static class KnowledgeSearchEndpoints
IKnowledgeSearchService searchService,
CancellationToken cancellationToken)
{
if (!EnsureSearchAuthorized(httpContext))
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
if (request is null || string.IsNullOrWhiteSpace(request.Q))
{
return Results.BadRequest(new { error = "q is required." });
return Results.BadRequest(new { error = _t("advisoryai.validation.q_required") });
}
if (request.Q.Length > 4096)
if (request.Q.Length > 512)
{
return Results.BadRequest(new { error = "q must be 4096 characters or fewer." });
return Results.BadRequest(new { error = _t("advisoryai.validation.q_max_512") });
}
var tenant = ResolveTenant(httpContext);
if (tenant is null)
{
return Results.BadRequest(new { error = _t("advisoryai.validation.tenant_required") });
}
KnowledgeSearchFilter? normalizedFilter;
try
{
normalizedFilter = NormalizeFilter(request.Filters, tenant);
}
catch (ArgumentException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
var normalizedFilter = NormalizeFilter(request.Filters);
var domainRequest = new KnowledgeSearchRequest(
request.Q.Trim(),
request.K,
@@ -72,6 +86,7 @@ public static class KnowledgeSearchEndpoints
request.IncludeDebug);
var response = await searchService.SearchAsync(domainRequest, cancellationToken).ConfigureAwait(false);
ApplyLegacyKnowledgeSearchDeprecationHeaders(httpContext.Response.Headers);
return Results.Ok(MapResponse(response));
}
@@ -80,9 +95,9 @@ public static class KnowledgeSearchEndpoints
IKnowledgeIndexer indexer,
CancellationToken cancellationToken)
{
if (!EnsureIndexAdminAuthorized(httpContext))
if (ResolveTenant(httpContext) is null)
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
return Results.BadRequest(new { error = _t("advisoryai.validation.tenant_required") });
}
var summary = await indexer.RebuildAsync(cancellationToken).ConfigureAwait(false);
@@ -97,22 +112,42 @@ public static class KnowledgeSearchEndpoints
});
}
private static KnowledgeSearchFilter? NormalizeFilter(AdvisoryKnowledgeSearchFilter? filter)
private static KnowledgeSearchFilter? NormalizeFilter(AdvisoryKnowledgeSearchFilter? filter, string tenant)
{
if (filter is null)
{
return null;
return new KnowledgeSearchFilter
{
Tenant = tenant
};
}
var normalizedKinds = filter.Type is { Count: > 0 }
? filter.Type
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim().ToLowerInvariant())
.Where(value => AllowedKinds.Contains(value))
.Distinct(StringComparer.Ordinal)
string[]? normalizedKinds = null;
if (filter.Type is { Count: > 0 })
{
var kinds = new HashSet<string>(StringComparer.Ordinal);
foreach (var item in filter.Type)
{
if (string.IsNullOrWhiteSpace(item))
{
continue;
}
var normalized = item.Trim().ToLowerInvariant();
if (!AllowedKinds.Contains(normalized))
{
throw new ArgumentException(
_t("advisoryai.validation.filter_type_unsupported", normalized),
nameof(filter));
}
kinds.Add(normalized);
}
normalizedKinds = kinds
.OrderBy(static value => value, StringComparer.Ordinal)
.ToArray()
: null;
.ToArray();
}
var normalizedTags = filter.Tags is { Count: > 0 }
? filter.Tags
@@ -129,7 +164,8 @@ public static class KnowledgeSearchEndpoints
Product = NormalizeOptional(filter.Product),
Version = NormalizeOptional(filter.Version),
Service = NormalizeOptional(filter.Service),
Tags = normalizedTags
Tags = normalizedTags,
Tenant = tenant
};
}
@@ -155,7 +191,8 @@ public static class KnowledgeSearchEndpoints
VectorMatches = response.Diagnostics.VectorMatches,
DurationMs = response.Diagnostics.DurationMs,
UsedVector = response.Diagnostics.UsedVector,
Mode = response.Diagnostics.Mode
Mode = response.Diagnostics.Mode,
ActiveEncoder = response.Diagnostics.ActiveEncoder
}
};
}
@@ -215,57 +252,34 @@ public static class KnowledgeSearchEndpoints
};
}
private static bool EnsureSearchAuthorized(HttpContext context)
private static string? ResolveTenant(HttpContext context)
{
return HasAnyScope(
context,
"advisory:run",
"advisory:search",
"advisory:read");
}
private static bool EnsureIndexAdminAuthorized(HttpContext context)
{
return HasAnyScope(
context,
"advisory:run",
"advisory:admin",
"advisory:index:write");
}
private static bool HasAnyScope(HttpContext context, params string[] expectedScopes)
{
var scopes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
AddScopeTokens(scopes, context.Request.Headers["X-StellaOps-Scopes"]);
AddScopeTokens(scopes, context.Request.Headers["X-Stella-Scopes"]);
foreach (var expectedScope in expectedScopes)
foreach (var value in context.Request.Headers["X-StellaOps-Tenant"])
{
if (scopes.Contains(expectedScope))
if (!string.IsNullOrWhiteSpace(value))
{
return true;
return value.Trim();
}
}
return false;
}
private static void AddScopeTokens(HashSet<string> scopes, IEnumerable<string> values)
{
foreach (var value in values)
foreach (var value in context.Request.Headers["X-Tenant-Id"])
{
if (string.IsNullOrWhiteSpace(value))
if (!string.IsNullOrWhiteSpace(value))
{
continue;
}
foreach (var token in value.Split(
[' ', ','],
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
scopes.Add(token);
return value.Trim();
}
}
var claimTenant = context.User?.FindFirst("tenant_id")?.Value;
return string.IsNullOrWhiteSpace(claimTenant) ? null : claimTenant.Trim();
}
private static void ApplyLegacyKnowledgeSearchDeprecationHeaders(IHeaderDictionary headers)
{
headers["Deprecation"] = "true";
headers["Sunset"] = "2026-04-30T00:00:00Z";
headers["Link"] = "</v1/search/query>; rel=\"successor-version\"";
headers["Warning"] = "299 - AdvisoryAI legacy knowledge search is deprecated; migrate to /v1/search/query";
}
}
@@ -380,6 +394,12 @@ public sealed record AdvisoryKnowledgeSearchDiagnostics
public bool UsedVector { get; init; }
public string Mode { get; init; } = "fts-only";
/// <summary>
/// Reports which vector encoder implementation is active: "hash" (deterministic SHA-256),
/// "onnx" (semantic ONNX inference), or "onnx-fallback" (configured for ONNX but fell back to hash).
/// </summary>
public string ActiveEncoder { get; init; } = "hash";
}
public sealed record AdvisoryKnowledgeRebuildResponse

View File

@@ -12,6 +12,7 @@ using System.Text;
using System.Text.Json.Serialization;
using PluginLlmCompletionRequest = StellaOps.Plugin.Abstractions.Capabilities.LlmCompletionRequest;
using PluginLlmCompletionResult = StellaOps.Plugin.Abstractions.Capabilities.LlmCompletionResult;
using static StellaOps.Localization.T;
namespace StellaOps.AdvisoryAI.WebService.Endpoints;
@@ -157,23 +158,23 @@ public static class LlmAdapterEndpoints
if (request.Messages.Count == 0)
{
return Results.BadRequest(new { error = "messages must contain at least one item." });
return Results.BadRequest(new { error = _t("advisoryai.validation.messages_empty") });
}
if (request.Stream)
{
return Results.BadRequest(new { error = "stream=true is not supported by the adapter endpoint." });
return Results.BadRequest(new { error = _t("advisoryai.error.stream_not_supported") });
}
if (!TryBuildPrompts(request.Messages, out var systemPrompt, out var userPrompt))
{
return Results.BadRequest(new { error = "messages must include at least one non-empty user or assistant content." });
return Results.BadRequest(new { error = _t("advisoryai.validation.messages_no_content") });
}
var capability = adapterFactory.GetCapability(providerId);
if (capability is null)
{
return Results.NotFound(new { error = $"Provider '{providerId}' is not configured for adapter exposure." });
return Results.NotFound(new { error = _t("advisoryai.error.provider_not_configured", providerId) });
}
if (!await capability.IsAvailableAsync(cancellationToken).ConfigureAwait(false))

View File

@@ -12,6 +12,7 @@ using StellaOps.AdvisoryAI.WebService.Security;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Determinism;
using System.Collections.Immutable;
using static StellaOps.Localization.T;
namespace StellaOps.AdvisoryAI.WebService.Endpoints;
@@ -211,7 +212,7 @@ public static class RunEndpoints
var run = await runService.GetAsync(tenantId, runId, ct);
if (run is null)
{
return Results.NotFound(new { message = $"Run {runId} not found" });
return Results.NotFound(new { message = _t("advisoryai.error.run_not_found", runId) });
}
return Results.Ok(MapToDto(run));

View File

@@ -0,0 +1,294 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.AdvisoryAI.UnifiedSearch.Analytics;
using StellaOps.AdvisoryAI.WebService.Security;
using StellaOps.Auth.ServerIntegration.Tenancy;
using System.Linq;
using System.Security.Claims;
using static StellaOps.Localization.T;
namespace StellaOps.AdvisoryAI.WebService.Endpoints;
public static class SearchAnalyticsEndpoints
{
private static readonly HashSet<string> AllowedEventTypes = new(StringComparer.OrdinalIgnoreCase)
{
"query",
"click",
"zero_result"
};
public static RouteGroupBuilder MapSearchAnalyticsEndpoints(this IEndpointRouteBuilder builder)
{
var group = builder.MapGroup("/v1/advisory-ai/search")
.WithTags("Unified Search - Analytics & History")
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy)
.RequireTenant()
.RequireRateLimiting("advisory-ai");
group.MapPost("/analytics", RecordAnalyticsAsync)
.WithName("SearchAnalyticsRecord")
.WithSummary("Records batch search analytics events (query, click, zero_result).")
.WithDescription(
"Accepts a batch of search analytics events for tracking query frequency, click-through rates, " +
"and zero-result queries. Events are tenant-scoped and user ID is optional for privacy. " +
"Fire-and-forget from the client; failures do not affect search functionality.")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status403Forbidden);
group.MapGet("/history", GetHistoryAsync)
.WithName("SearchHistoryGet")
.WithSummary("Returns the authenticated user's recent search queries.")
.WithDescription(
"Returns up to 50 recent search queries for the current user, ordered by recency. " +
"Server-side history supplements localStorage-based history in the UI.")
.Produces<SearchHistoryApiResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status403Forbidden);
group.MapDelete("/history", ClearHistoryAsync)
.WithName("SearchHistoryClear")
.WithSummary("Clears the authenticated user's search history.")
.WithDescription("Removes all server-side search history entries for the current user and tenant.")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status403Forbidden);
group.MapDelete("/history/{historyId}", DeleteHistoryEntryAsync)
.WithName("SearchHistoryDeleteEntry")
.WithSummary("Removes a single search history entry.")
.WithDescription("Removes a specific search history entry by ID for the current user and tenant.")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status403Forbidden);
return group;
}
private static async Task<IResult> RecordAnalyticsAsync(
HttpContext httpContext,
SearchAnalyticsApiRequest request,
SearchAnalyticsService analyticsService,
CancellationToken cancellationToken)
{
if (request?.Events is not { Count: > 0 })
{
return Results.BadRequest(new { error = _t("advisoryai.validation.analytics_events_required") });
}
if (request.Events.Count > 100)
{
return Results.BadRequest(new { error = _t("advisoryai.validation.analytics_events_max_100") });
}
var tenant = ResolveTenant(httpContext);
if (tenant is null)
{
return Results.BadRequest(new { error = _t("advisoryai.validation.tenant_required") });
}
var userId = ResolveUserId(httpContext);
var events = new List<SearchAnalyticsEvent>(request.Events.Count);
foreach (var apiEvent in request.Events)
{
if (string.IsNullOrWhiteSpace(apiEvent.EventType) || !AllowedEventTypes.Contains(apiEvent.EventType))
{
continue;
}
if (string.IsNullOrWhiteSpace(apiEvent.Query))
{
continue;
}
events.Add(new SearchAnalyticsEvent(
TenantId: tenant,
EventType: apiEvent.EventType.Trim().ToLowerInvariant(),
Query: apiEvent.Query.Trim(),
UserId: userId,
EntityKey: string.IsNullOrWhiteSpace(apiEvent.EntityKey) ? null : apiEvent.EntityKey.Trim(),
Domain: string.IsNullOrWhiteSpace(apiEvent.Domain) ? null : apiEvent.Domain.Trim(),
ResultCount: apiEvent.ResultCount,
Position: apiEvent.Position,
DurationMs: apiEvent.DurationMs));
}
if (events.Count > 0)
{
// 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);
}
return Results.NoContent();
}
private static async Task<IResult> GetHistoryAsync(
HttpContext httpContext,
SearchAnalyticsService analyticsService,
CancellationToken cancellationToken)
{
var tenant = ResolveTenant(httpContext);
if (tenant is null)
{
return Results.BadRequest(new { error = _t("advisoryai.validation.tenant_required") });
}
var userId = ResolveUserId(httpContext);
if (string.IsNullOrWhiteSpace(userId))
{
return Results.BadRequest(new { error = _t("advisoryai.validation.user_required") });
}
var entries = await analyticsService.GetHistoryAsync(tenant, userId, 50, cancellationToken).ConfigureAwait(false);
return Results.Ok(new SearchHistoryApiResponse
{
Entries = entries.Select(static e => new SearchHistoryApiEntry
{
HistoryId = e.HistoryId,
Query = e.Query,
ResultCount = e.ResultCount,
SearchedAt = e.SearchedAt.ToString("o")
}).ToArray()
});
}
private static async Task<IResult> ClearHistoryAsync(
HttpContext httpContext,
SearchAnalyticsService analyticsService,
CancellationToken cancellationToken)
{
var tenant = ResolveTenant(httpContext);
if (tenant is null)
{
return Results.BadRequest(new { error = _t("advisoryai.validation.tenant_required") });
}
var userId = ResolveUserId(httpContext);
if (string.IsNullOrWhiteSpace(userId))
{
return Results.BadRequest(new { error = _t("advisoryai.validation.user_required") });
}
await analyticsService.ClearHistoryAsync(tenant, userId, cancellationToken).ConfigureAwait(false);
return Results.NoContent();
}
private static async Task<IResult> DeleteHistoryEntryAsync(
HttpContext httpContext,
string historyId,
SearchAnalyticsService analyticsService,
CancellationToken cancellationToken)
{
var tenant = ResolveTenant(httpContext);
if (tenant is null)
{
return Results.BadRequest(new { error = _t("advisoryai.validation.tenant_required") });
}
var userId = ResolveUserId(httpContext);
if (string.IsNullOrWhiteSpace(userId))
{
return Results.BadRequest(new { error = _t("advisoryai.validation.user_required") });
}
if (string.IsNullOrWhiteSpace(historyId) || !Guid.TryParse(historyId, out _))
{
return Results.BadRequest(new { error = _t("advisoryai.validation.history_id_invalid") });
}
await analyticsService.DeleteHistoryEntryAsync(tenant, userId, historyId, cancellationToken).ConfigureAwait(false);
return Results.NoContent();
}
private static string? ResolveTenant(HttpContext context)
{
foreach (var value in context.Request.Headers["X-StellaOps-Tenant"])
{
if (!string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
}
foreach (var value in context.Request.Headers["X-Tenant-Id"])
{
if (!string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
}
var claimTenant = context.User?.FindFirst("tenant_id")?.Value;
return string.IsNullOrWhiteSpace(claimTenant) ? null : claimTenant.Trim();
}
private static string? ResolveUserId(HttpContext context)
{
foreach (var value in context.Request.Headers["X-StellaOps-Actor"])
{
if (!string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
}
foreach (var value in context.Request.Headers["X-User-Id"])
{
if (!string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
}
var claim = context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
return string.IsNullOrWhiteSpace(claim) || claim == "anonymous" ? null : claim.Trim();
}
}
// API DTOs for Search Analytics
public sealed record SearchAnalyticsApiRequest
{
public IReadOnlyList<SearchAnalyticsApiEvent> Events { get; init; } = [];
}
public sealed record SearchAnalyticsApiEvent
{
public string EventType { get; init; } = string.Empty;
public string Query { get; init; } = string.Empty;
public string? EntityKey { get; init; }
public string? Domain { get; init; }
public int? ResultCount { get; init; }
public int? Position { get; init; }
public int? DurationMs { get; init; }
}
public sealed record SearchHistoryApiResponse
{
public IReadOnlyList<SearchHistoryApiEntry> Entries { get; init; } = [];
}
public sealed record SearchHistoryApiEntry
{
public string HistoryId { get; init; } = string.Empty;
public string Query { get; init; } = string.Empty;
public int? ResultCount { get; init; }
public string SearchedAt { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,284 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.AdvisoryAI.UnifiedSearch.Analytics;
using StellaOps.AdvisoryAI.WebService.Security;
using StellaOps.Auth.ServerIntegration.Tenancy;
using System.Linq;
using static StellaOps.Localization.T;
namespace StellaOps.AdvisoryAI.WebService.Endpoints;
/// <summary>
/// Endpoints for search feedback collection and quality alerting.
/// Sprint: SPRINT_20260224_110 (G10-001, G10-002)
/// </summary>
public static class SearchFeedbackEndpoints
{
public static RouteGroupBuilder MapSearchFeedbackEndpoints(this IEndpointRouteBuilder builder)
{
var group = builder.MapGroup("/v1/advisory-ai/search")
.WithTags("Advisory AI - Search Feedback & Quality")
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy)
.RequireTenant()
.RequireRateLimiting("advisory-ai");
// G10-001: Submit feedback on a search result
group.MapPost("/feedback", SubmitFeedbackAsync)
.WithName("SearchFeedbackSubmit")
.WithSummary("Submits user feedback (helpful/not_helpful) for a search result or synthesis.")
.WithDescription(
"Records a thumbs-up or thumbs-down signal for a specific search result, " +
"identified by entity key and domain. Used to improve search quality over time. " +
"Fire-and-forget from the UI perspective.")
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy)
.Produces(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status403Forbidden);
// G10-002: List quality alerts (admin only)
group.MapGet("/quality/alerts", GetAlertsAsync)
.WithName("SearchQualityAlertsList")
.WithSummary("Lists open search quality alerts (zero-result queries, high negative feedback).")
.WithDescription(
"Returns search quality alerts ordered by occurrence count. " +
"Filterable by status (open, acknowledged, resolved) and alert type " +
"(zero_result, low_feedback, high_negative_feedback). Requires admin scope.")
.RequireAuthorization(AdvisoryAIPolicies.AdminPolicy)
.Produces<IReadOnlyList<SearchQualityAlertDto>>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status403Forbidden);
// G10-002: Update alert status
group.MapPatch("/quality/alerts/{alertId}", UpdateAlertAsync)
.WithName("SearchQualityAlertUpdate")
.WithSummary("Updates a search quality alert status (acknowledge or resolve).")
.WithDescription(
"Transitions a search quality alert to acknowledged or resolved status. " +
"Optionally includes a resolution description text.")
.RequireAuthorization(AdvisoryAIPolicies.AdminPolicy)
.Produces<SearchQualityAlertDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status403Forbidden);
// G10-003: Quality metrics
group.MapGet("/quality/metrics", GetMetricsAsync)
.WithName("SearchQualityMetrics")
.WithSummary("Returns aggregate search quality metrics for the dashboard.")
.WithDescription(
"Provides total searches, zero-result rate, average result count, " +
"and feedback score for a specified period (24h, 7d, 30d). Requires admin scope.")
.RequireAuthorization(AdvisoryAIPolicies.AdminPolicy)
.Produces<SearchQualityMetricsDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status403Forbidden);
return group;
}
private static async Task<IResult> SubmitFeedbackAsync(
HttpContext httpContext,
SearchFeedbackRequestDto request,
SearchQualityMonitor monitor,
CancellationToken cancellationToken)
{
if (request is null)
{
return Results.BadRequest(new { error = _t("advisoryai.validation.request_required") });
}
if (string.IsNullOrWhiteSpace(request.Query) || request.Query.Length > 512)
{
return Results.BadRequest(new { error = _t("advisoryai.validation.q_max_512") });
}
if (string.IsNullOrWhiteSpace(request.EntityKey))
{
return Results.BadRequest(new { error = "entityKey is required." });
}
if (!SearchQualityMonitor.IsValidSignal(request.Signal))
{
return Results.BadRequest(new { error = "signal must be 'helpful' or 'not_helpful'." });
}
if (request.Comment is not null && request.Comment.Length > 500)
{
return Results.BadRequest(new { error = "comment must not exceed 500 characters." });
}
var tenant = ResolveTenant(httpContext);
if (tenant is null)
{
return Results.BadRequest(new { error = _t("advisoryai.validation.tenant_required") });
}
var userId = httpContext.User?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
await monitor.StoreFeedbackAsync(new SearchFeedbackEntry
{
TenantId = tenant,
UserId = userId,
Query = request.Query.Trim(),
EntityKey = request.EntityKey.Trim(),
Domain = request.Domain?.Trim() ?? "unknown",
Position = request.Position,
Signal = request.Signal.Trim(),
Comment = request.Comment?.Trim(),
}, cancellationToken).ConfigureAwait(false);
return Results.Created();
}
private static async Task<IResult> GetAlertsAsync(
HttpContext httpContext,
SearchQualityMonitor monitor,
string? status,
string? alertType,
CancellationToken cancellationToken)
{
var tenant = ResolveTenant(httpContext);
if (tenant is null)
{
return Results.BadRequest(new { error = _t("advisoryai.validation.tenant_required") });
}
var alerts = await monitor.GetAlertsAsync(tenant, status, alertType, ct: cancellationToken).ConfigureAwait(false);
var dtos = alerts.Select(MapAlertDto).ToArray();
return Results.Ok(dtos);
}
private static async Task<IResult> UpdateAlertAsync(
HttpContext httpContext,
string alertId,
SearchQualityAlertUpdateDto request,
SearchQualityMonitor monitor,
CancellationToken cancellationToken)
{
if (request is null || string.IsNullOrWhiteSpace(request.Status))
{
return Results.BadRequest(new { error = "status is required (acknowledged or resolved)." });
}
if (!SearchQualityMonitor.IsValidAlertStatus(request.Status))
{
return Results.BadRequest(new { error = "status must be 'acknowledged' or 'resolved'." });
}
var tenant = ResolveTenant(httpContext);
if (tenant is null)
{
return Results.BadRequest(new { error = _t("advisoryai.validation.tenant_required") });
}
var updated = await monitor.UpdateAlertAsync(tenant, alertId, request.Status, request.Resolution, cancellationToken).ConfigureAwait(false);
if (updated is null)
{
return Results.NotFound(new { error = "Alert not found." });
}
return Results.Ok(MapAlertDto(updated));
}
private static async Task<IResult> GetMetricsAsync(
HttpContext httpContext,
SearchQualityMonitor monitor,
string? period,
CancellationToken cancellationToken)
{
var tenant = ResolveTenant(httpContext);
if (tenant is null)
{
return Results.BadRequest(new { error = _t("advisoryai.validation.tenant_required") });
}
var metrics = await monitor.GetMetricsAsync(tenant, period ?? "7d", cancellationToken).ConfigureAwait(false);
return Results.Ok(new SearchQualityMetricsDto
{
TotalSearches = metrics.TotalSearches,
ZeroResultRate = metrics.ZeroResultRate,
AvgResultCount = metrics.AvgResultCount,
FeedbackScore = metrics.FeedbackScore,
Period = metrics.Period,
});
}
private static SearchQualityAlertDto MapAlertDto(SearchQualityAlertEntry entry)
{
return new SearchQualityAlertDto
{
AlertId = entry.AlertId,
TenantId = entry.TenantId,
AlertType = entry.AlertType,
Query = entry.Query,
OccurrenceCount = entry.OccurrenceCount,
FirstSeen = entry.FirstSeen.ToString("o"),
LastSeen = entry.LastSeen.ToString("o"),
Status = entry.Status,
Resolution = entry.Resolution,
CreatedAt = entry.CreatedAt.ToString("o"),
};
}
private static string? ResolveTenant(HttpContext context)
{
foreach (var value in context.Request.Headers["X-StellaOps-Tenant"])
{
if (!string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
}
foreach (var value in context.Request.Headers["X-Tenant-Id"])
{
if (!string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
}
var claimTenant = context.User?.FindFirst("tenant_id")?.Value;
return string.IsNullOrWhiteSpace(claimTenant) ? null : claimTenant.Trim();
}
}
// DTOs
public sealed record SearchFeedbackRequestDto
{
public string Query { get; init; } = string.Empty;
public string EntityKey { get; init; } = string.Empty;
public string? Domain { get; init; }
public int Position { get; init; }
public string Signal { get; init; } = string.Empty;
public string? Comment { get; init; }
}
public sealed record SearchQualityAlertDto
{
public string AlertId { get; init; } = string.Empty;
public string TenantId { get; init; } = string.Empty;
public string AlertType { get; init; } = string.Empty;
public string Query { get; init; } = string.Empty;
public int OccurrenceCount { get; init; }
public string FirstSeen { get; init; } = string.Empty;
public string LastSeen { get; init; } = string.Empty;
public string Status { get; init; } = "open";
public string? Resolution { get; init; }
public string CreatedAt { get; init; } = string.Empty;
}
public sealed record SearchQualityAlertUpdateDto
{
public string Status { get; init; } = string.Empty;
public string? Resolution { get; init; }
}
public sealed record SearchQualityMetricsDto
{
public int TotalSearches { get; init; }
public double ZeroResultRate { get; init; }
public double AvgResultCount { get; init; }
public double FeedbackScore { get; init; }
public string Period { get; init; } = "7d";
}

View File

@@ -0,0 +1,498 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.AdvisoryAI.UnifiedSearch;
using StellaOps.AdvisoryAI.WebService.Security;
using StellaOps.Auth.ServerIntegration.Tenancy;
using System.Linq;
using static StellaOps.Localization.T;
namespace StellaOps.AdvisoryAI.WebService.Endpoints;
public static class UnifiedSearchEndpoints
{
private static readonly HashSet<string> AllowedDomains = new(StringComparer.Ordinal)
{
"knowledge",
"findings",
"vex",
"policy",
"platform"
};
private static readonly HashSet<string> AllowedEntityTypes = new(StringComparer.Ordinal)
{
"docs",
"api",
"doctor",
"finding",
"vex_statement",
"policy_rule",
"platform_entity"
};
public static RouteGroupBuilder MapUnifiedSearchEndpoints(this IEndpointRouteBuilder builder)
{
var group = builder.MapGroup("/v1/search")
.WithTags("Unified Search")
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy)
.RequireTenant()
.RequireRateLimiting("advisory-ai");
group.MapPost("/query", QueryAsync)
.WithName("UnifiedSearchQuery")
.WithSummary("Searches across all Stella Ops domains with weighted fusion and entity grouping.")
.WithDescription(
"Performs a unified search across knowledge base, findings, VEX statements, policy rules, and platform catalog entities. " +
"Returns entity-grouped cards with domain-weighted RRF scoring and optional deterministic synthesis. " +
"Supports domain/entity-type filtering and ambient context-aware search.")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
.Produces<UnifiedSearchApiResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status403Forbidden);
group.MapPost("/index/rebuild", RebuildIndexAsync)
.WithName("UnifiedSearchRebuild")
.WithSummary("Rebuilds unified search index from configured ingestion sources.")
.WithDescription(
"Triggers a full unified index rebuild across all registered ingestion adapters " +
"(knowledge, findings, vex, policy, platform). Existing domain rows are replaced deterministically.")
.RequireAuthorization(AdvisoryAIPolicies.AdminPolicy)
.Produces<UnifiedSearchRebuildApiResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status403Forbidden);
return group;
}
private static async Task<IResult> QueryAsync(
HttpContext httpContext,
UnifiedSearchApiRequest request,
IUnifiedSearchService searchService,
CancellationToken cancellationToken)
{
if (request is null || string.IsNullOrWhiteSpace(request.Q))
{
return Results.BadRequest(new { error = _t("advisoryai.validation.q_required") });
}
if (request.Q.Length > 512)
{
return Results.BadRequest(new { error = _t("advisoryai.validation.q_max_512") });
}
var tenant = ResolveTenant(httpContext);
if (tenant is null)
{
return Results.BadRequest(new { error = _t("advisoryai.validation.tenant_required") });
}
try
{
var userScopes = ResolveUserScopes(httpContext);
var domainRequest = new UnifiedSearchRequest(
request.Q.Trim(),
request.K,
NormalizeFilter(request.Filters, tenant, userScopes),
request.IncludeSynthesis,
request.IncludeDebug);
var response = await searchService.SearchAsync(domainRequest, cancellationToken).ConfigureAwait(false);
return Results.Ok(MapResponse(response));
}
catch (ArgumentException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}
private static async Task<IResult> RebuildIndexAsync(
HttpContext httpContext,
IUnifiedSearchIndexer indexer,
CancellationToken cancellationToken)
{
if (ResolveTenant(httpContext) is null)
{
return Results.BadRequest(new { error = _t("advisoryai.validation.tenant_required") });
}
var summary = await indexer.RebuildAllAsync(cancellationToken).ConfigureAwait(false);
return Results.Ok(new UnifiedSearchRebuildApiResponse
{
DomainCount = summary.DomainCount,
ChunkCount = summary.ChunkCount,
DurationMs = summary.DurationMs
});
}
private static UnifiedSearchFilter? NormalizeFilter(UnifiedSearchApiFilter? filter, string tenant, IReadOnlyList<string>? userScopes = null)
{
if (filter is null)
{
return new UnifiedSearchFilter
{
Tenant = tenant,
UserScopes = userScopes
};
}
var domains = filter.Domains is { Count: > 0 }
? filter.Domains.Where(static v => !string.IsNullOrWhiteSpace(v)).Select(static v => v.Trim().ToLowerInvariant()).Distinct(StringComparer.Ordinal).ToArray()
: null;
var entityTypes = filter.EntityTypes is { Count: > 0 }
? filter.EntityTypes.Where(static v => !string.IsNullOrWhiteSpace(v)).Select(static v => v.Trim().ToLowerInvariant()).Distinct(StringComparer.Ordinal).ToArray()
: null;
var tags = filter.Tags is { Count: > 0 }
? filter.Tags.Where(static v => !string.IsNullOrWhiteSpace(v)).Select(static v => v.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToArray()
: null;
if (domains is not null)
{
var unsupportedDomain = domains.FirstOrDefault(static d => !AllowedDomains.Contains(d));
if (!string.IsNullOrWhiteSpace(unsupportedDomain))
{
throw new ArgumentException(
_t("advisoryai.validation.filter_domain_unsupported", unsupportedDomain),
nameof(filter));
}
}
if (entityTypes is not null)
{
var unsupportedEntityType = entityTypes.FirstOrDefault(static e => !AllowedEntityTypes.Contains(e));
if (!string.IsNullOrWhiteSpace(unsupportedEntityType))
{
throw new ArgumentException(
_t("advisoryai.validation.filter_entity_type_unsupported", unsupportedEntityType),
nameof(filter));
}
}
return new UnifiedSearchFilter
{
Domains = domains,
EntityTypes = entityTypes,
EntityKey = string.IsNullOrWhiteSpace(filter.EntityKey) ? null : filter.EntityKey.Trim(),
Product = string.IsNullOrWhiteSpace(filter.Product) ? null : filter.Product.Trim(),
Version = string.IsNullOrWhiteSpace(filter.Version) ? null : filter.Version.Trim(),
Service = string.IsNullOrWhiteSpace(filter.Service) ? null : filter.Service.Trim(),
Tags = tags,
Tenant = tenant,
UserScopes = userScopes
};
}
private static UnifiedSearchApiResponse MapResponse(UnifiedSearchResponse response)
{
var cards = response.Cards.Select(static card => new UnifiedSearchApiCard
{
EntityKey = card.EntityKey,
EntityType = card.EntityType,
Domain = card.Domain,
Title = card.Title,
Snippet = card.Snippet,
Score = card.Score,
Severity = card.Severity,
Actions = card.Actions.Select(static action => new UnifiedSearchApiAction
{
Label = action.Label,
ActionType = action.ActionType,
Route = action.Route,
Command = action.Command,
IsPrimary = action.IsPrimary
}).ToArray(),
Metadata = card.Metadata,
Sources = card.Sources.ToArray()
}).ToArray();
UnifiedSearchApiSynthesis? synthesis = null;
if (response.Synthesis is not null)
{
synthesis = new UnifiedSearchApiSynthesis
{
Summary = response.Synthesis.Summary,
Template = response.Synthesis.Template,
Confidence = response.Synthesis.Confidence,
SourceCount = response.Synthesis.SourceCount,
DomainsCovered = response.Synthesis.DomainsCovered.ToArray()
};
}
IReadOnlyList<UnifiedSearchApiSuggestion>? suggestions = null;
if (response.Suggestions is { Count: > 0 })
{
suggestions = response.Suggestions.Select(static s => new UnifiedSearchApiSuggestion
{
Text = s.Text,
Reason = s.Reason
}).ToArray();
}
IReadOnlyList<UnifiedSearchApiRefinement>? refinements = null;
if (response.Refinements is { Count: > 0 })
{
refinements = response.Refinements.Select(static r => new UnifiedSearchApiRefinement
{
Text = r.Text,
Source = r.Source
}).ToArray();
}
return new UnifiedSearchApiResponse
{
Query = response.Query,
TopK = response.TopK,
Cards = cards,
Synthesis = synthesis,
Suggestions = suggestions,
Refinements = refinements,
Diagnostics = new UnifiedSearchApiDiagnostics
{
FtsMatches = response.Diagnostics.FtsMatches,
VectorMatches = response.Diagnostics.VectorMatches,
EntityCardCount = response.Diagnostics.EntityCardCount,
DurationMs = response.Diagnostics.DurationMs,
UsedVector = response.Diagnostics.UsedVector,
Mode = response.Diagnostics.Mode
}
};
}
private static string? ResolveTenant(HttpContext context)
{
foreach (var value in context.Request.Headers["X-StellaOps-Tenant"])
{
if (!string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
}
foreach (var value in context.Request.Headers["X-Tenant-Id"])
{
if (!string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
}
var claimTenant = context.User?.FindFirst("tenant_id")?.Value;
return string.IsNullOrWhiteSpace(claimTenant) ? null : claimTenant.Trim();
}
private static string? ResolveUserId(HttpContext context)
{
foreach (var value in context.Request.Headers["X-StellaOps-Actor"])
{
if (!string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
}
foreach (var value in context.Request.Headers["X-User-Id"])
{
if (!string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
}
var claim = context.User?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
return string.IsNullOrWhiteSpace(claim) ? null : claim.Trim();
}
private static IReadOnlyList<string>? ResolveUserScopes(HttpContext context)
{
var scopes = new List<string>();
foreach (var headerName in new[] { "X-StellaOps-Scopes", "X-Stella-Scopes" })
{
if (!context.Request.Headers.TryGetValue(headerName, out var values))
{
continue;
}
foreach (var value in values)
{
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
foreach (var token in value.Split(
[' ', ','],
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
scopes.Add(token);
}
}
}
// Also check claims
if (context.User is not null)
{
foreach (var claim in context.User.FindAll("scope"))
{
foreach (var token in claim.Value.Split(
' ',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (!scopes.Contains(token, StringComparer.OrdinalIgnoreCase))
{
scopes.Add(token);
}
}
}
foreach (var claim in context.User.FindAll("scp"))
{
if (!string.IsNullOrWhiteSpace(claim.Value) &&
!scopes.Contains(claim.Value.Trim(), StringComparer.OrdinalIgnoreCase))
{
scopes.Add(claim.Value.Trim());
}
}
}
return scopes.Count > 0 ? scopes : null;
}
}
// API DTOs
public sealed record UnifiedSearchApiRequest
{
public string Q { get; init; } = string.Empty;
public int? K { get; init; }
public UnifiedSearchApiFilter? Filters { get; init; }
public bool IncludeSynthesis { get; init; } = true;
public bool IncludeDebug { get; init; }
}
public sealed record UnifiedSearchApiFilter
{
public IReadOnlyList<string>? Domains { get; init; }
public IReadOnlyList<string>? EntityTypes { get; init; }
public string? EntityKey { get; init; }
public string? Product { get; init; }
public string? Version { get; init; }
public string? Service { get; init; }
public IReadOnlyList<string>? Tags { get; init; }
}
public sealed record UnifiedSearchApiResponse
{
public string Query { get; init; } = string.Empty;
public int TopK { get; init; }
public IReadOnlyList<UnifiedSearchApiCard> Cards { get; init; } = [];
public UnifiedSearchApiSynthesis? Synthesis { get; init; }
public IReadOnlyList<UnifiedSearchApiSuggestion>? Suggestions { get; init; }
public IReadOnlyList<UnifiedSearchApiRefinement>? Refinements { get; init; }
public UnifiedSearchApiDiagnostics Diagnostics { get; init; } = new();
}
public sealed record UnifiedSearchApiCard
{
public string EntityKey { get; init; } = string.Empty;
public string EntityType { get; init; } = string.Empty;
public string Domain { get; init; } = "knowledge";
public string Title { get; init; } = string.Empty;
public string Snippet { get; init; } = string.Empty;
public double Score { get; init; }
public string? Severity { get; init; }
public IReadOnlyList<UnifiedSearchApiAction> Actions { get; init; } = [];
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
public IReadOnlyList<string> Sources { get; init; } = [];
}
public sealed record UnifiedSearchApiAction
{
public string Label { get; init; } = string.Empty;
public string ActionType { get; init; } = "navigate";
public string? Route { get; init; }
public string? Command { get; init; }
public bool IsPrimary { get; init; }
}
public sealed record UnifiedSearchApiSynthesis
{
public string Summary { get; init; } = string.Empty;
public string Template { get; init; } = string.Empty;
public string Confidence { get; init; } = "low";
public int SourceCount { get; init; }
public IReadOnlyList<string> DomainsCovered { get; init; } = [];
}
public sealed record UnifiedSearchApiSuggestion
{
public string Text { get; init; } = string.Empty;
public string Reason { get; init; } = string.Empty;
}
public sealed record UnifiedSearchApiRefinement
{
public string Text { get; init; } = string.Empty;
public string Source { get; init; } = string.Empty;
}
public sealed record UnifiedSearchApiDiagnostics
{
public int FtsMatches { get; init; }
public int VectorMatches { get; init; }
public int EntityCardCount { get; init; }
public long DurationMs { get; init; }
public bool UsedVector { get; init; }
public string Mode { get; init; } = "fts-only";
}
public sealed record UnifiedSearchRebuildApiResponse
{
public int DomainCount { get; init; }
public int ChunkCount { get; init; }
public long DurationMs { get; init; }
}