using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using StellaOps.AdvisoryAI.UnifiedSearch; using StellaOps.AdvisoryAI.UnifiedSearch.Synthesis; using StellaOps.AdvisoryAI.WebService.Security; using StellaOps.Auth.ServerIntegration.Tenancy; using System.Linq; using System.Text.Json; using static StellaOps.Localization.T; namespace StellaOps.AdvisoryAI.WebService.Endpoints; public static class UnifiedSearchEndpoints { private static readonly HashSet AllowedDomains = new(StringComparer.Ordinal) { "knowledge", "findings", "vex", "policy", "platform", "graph", "timeline", "scanner", "opsmemory" }; private static readonly HashSet AllowedEntityTypes = new(StringComparer.Ordinal) { "docs", "api", "doctor", "finding", "vex_statement", "policy_rule", "platform_entity", "package", "image", "registry", "event", "scan", "graph_node" }; 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(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status403Forbidden); group.MapPost("/suggestions/evaluate", EvaluateSuggestionsAsync) .WithName("UnifiedSearchEvaluateSuggestions") .WithSummary("Preflights contextual search suggestions against the active corpus.") .WithDescription( "Evaluates a bounded list of suggested queries without recording user-search analytics so the UI can suppress dead suggestion chips. " + "Returns per-query viability plus aggregate domain coverage for the active context.") .RequireAuthorization(AdvisoryAIPolicies.OperatePolicy) .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status403Forbidden); group.MapPost("/synthesize", SynthesizeAsync) .WithName("UnifiedSearchSynthesize") .WithSummary("Streams deterministic-first search synthesis as SSE.") .WithDescription( "Produces deterministic synthesis first, then optional LLM synthesis chunks, grounding score, and actions. " + "Requires search synthesis scope and tenant context.") .RequireAuthorization(AdvisoryAIPolicies.OperatePolicy) .Produces(StatusCodes.Status200OK, contentType: "text/event-stream") .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(StatusCodes.Status200OK) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status403Forbidden); return group; } private static async Task 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 userId = ResolveUserId(httpContext); var domainRequest = new UnifiedSearchRequest( request.Q.Trim(), request.K, NormalizeFilter(request.Filters, tenant, userScopes, userId), request.IncludeSynthesis, request.IncludeDebug, NormalizeAmbient(request.Ambient)); 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 EvaluateSuggestionsAsync( HttpContext httpContext, UnifiedSearchSuggestionViabilityApiRequest request, IUnifiedSearchService searchService, CancellationToken cancellationToken) { if (request is null || request.Queries is not { Count: > 0 }) { return Results.BadRequest(new { error = _t("advisoryai.validation.suggestions_required") }); } var tenant = ResolveTenant(httpContext); if (tenant is null) { return Results.BadRequest(new { error = _t("advisoryai.validation.tenant_required") }); } try { var userScopes = ResolveUserScopes(httpContext); var userId = ResolveUserId(httpContext); var domainRequest = new SearchSuggestionViabilityRequest( request.Queries .Where(static query => !string.IsNullOrWhiteSpace(query)) .Select(static query => query.Trim()) .ToArray(), NormalizeFilter(request.Filters, tenant, userScopes, userId), NormalizeAmbient(request.Ambient)); var response = await searchService.EvaluateSuggestionsAsync(domainRequest, cancellationToken).ConfigureAwait(false); return Results.Ok(MapSuggestionViabilityResponse(response)); } catch (ArgumentException ex) { return Results.BadRequest(new { error = ex.Message }); } } private static async Task SynthesizeAsync( HttpContext httpContext, UnifiedSearchSynthesizeApiRequest request, SearchSynthesisService synthesisService, CancellationToken cancellationToken) { if (request is null || string.IsNullOrWhiteSpace(request.Q)) { httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; await httpContext.Response.WriteAsJsonAsync(new { error = _t("advisoryai.validation.q_required") }, cancellationToken); return; } var tenant = ResolveTenant(httpContext); if (tenant is null) { httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; await httpContext.Response.WriteAsJsonAsync(new { error = _t("advisoryai.validation.tenant_required") }, cancellationToken); return; } if (!HasSynthesisScope(httpContext)) { httpContext.Response.StatusCode = StatusCodes.Status403Forbidden; await httpContext.Response.WriteAsJsonAsync(new { error = "Missing required scope: search:synthesize" }, cancellationToken); return; } var cards = MapSynthesisCards(request.TopCards); if (cards.Count == 0) { httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; await httpContext.Response.WriteAsJsonAsync(new { error = "topCards is required" }, cancellationToken); return; } var userId = ResolveUserId(httpContext) ?? "anonymous"; var domainRequest = new SearchSynthesisRequest( request.Q.Trim(), cards, request.Plan, request.Preferences is null ? null : new SearchSynthesisPreferences { Depth = request.Preferences.Depth, MaxTokens = request.Preferences.MaxTokens, IncludeActions = request.Preferences.IncludeActions, Locale = request.Preferences.Locale }); httpContext.Response.StatusCode = StatusCodes.Status200OK; httpContext.Response.ContentType = "text/event-stream"; httpContext.Response.Headers.CacheControl = "no-cache"; httpContext.Response.Headers.Connection = "keep-alive"; await httpContext.Response.Body.FlushAsync(cancellationToken); var started = DateTimeOffset.UtcNow; try { var result = await synthesisService.ExecuteAsync( tenant, userId, domainRequest, cancellationToken).ConfigureAwait(false); await WriteSseEventAsync(httpContext, "synthesis_start", new { tier = "deterministic", summary = result.DeterministicSummary }, cancellationToken).ConfigureAwait(false); if (result.QuotaExceeded) { await WriteSseEventAsync(httpContext, "llm_status", new { status = "quota_exceeded" }, cancellationToken).ConfigureAwait(false); } else if (result.LlmUnavailable || string.IsNullOrWhiteSpace(result.LlmSummary)) { await WriteSseEventAsync(httpContext, "llm_status", new { status = "unavailable" }, cancellationToken).ConfigureAwait(false); } else { await WriteSseEventAsync(httpContext, "llm_status", new { status = "starting" }, cancellationToken).ConfigureAwait(false); await WriteSseEventAsync(httpContext, "llm_status", new { status = "streaming" }, cancellationToken).ConfigureAwait(false); foreach (var chunk in Chunk(result.LlmSummary, 240)) { await WriteSseEventAsync(httpContext, "llm_chunk", new { content = chunk, isComplete = false }, cancellationToken).ConfigureAwait(false); } await WriteSseEventAsync(httpContext, "llm_chunk", new { content = string.Empty, isComplete = true }, cancellationToken).ConfigureAwait(false); await WriteSseEventAsync(httpContext, "llm_status", new { status = "validating" }, cancellationToken).ConfigureAwait(false); await WriteSseEventAsync(httpContext, "grounding", new { score = result.GroundingScore, citations = 0, ungrounded = 0, issues = Array.Empty() }, cancellationToken).ConfigureAwait(false); await WriteSseEventAsync(httpContext, "llm_status", new { status = "complete" }, cancellationToken).ConfigureAwait(false); } if (result.Actions.Count > 0) { await WriteSseEventAsync(httpContext, "actions", new { actions = result.Actions.Select(static action => new { label = action.Label, route = action.Route, sourceEntityKey = action.SourceEntityKey }) }, cancellationToken).ConfigureAwait(false); } var durationMs = (long)(DateTimeOffset.UtcNow - started).TotalMilliseconds; await WriteSseEventAsync(httpContext, "synthesis_end", new { totalTokens = result.TotalTokens, durationMs, provider = result.Provider, promptVersion = result.PromptVersion }, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { await WriteSseEventAsync(httpContext, "error", new { code = "synthesis_error", message = ex.Message }, cancellationToken).ConfigureAwait(false); var durationMs = (long)(DateTimeOffset.UtcNow - started).TotalMilliseconds; await WriteSseEventAsync(httpContext, "synthesis_end", new { totalTokens = 0, durationMs, provider = "none", promptVersion = "search-synth-v1" }, cancellationToken).ConfigureAwait(false); } } private static async Task 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? userScopes = null, string? userId = null) { if (filter is null) { return new UnifiedSearchFilter { Tenant = tenant, UserScopes = userScopes, UserId = userId }; } 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, UserId = userId }; } private static AmbientContext? NormalizeAmbient(UnifiedSearchApiAmbientContext? ambient) { if (ambient is null) { return null; } return new AmbientContext { CurrentRoute = string.IsNullOrWhiteSpace(ambient.CurrentRoute) ? null : ambient.CurrentRoute.Trim(), SessionId = string.IsNullOrWhiteSpace(ambient.SessionId) ? null : ambient.SessionId.Trim(), ResetSession = ambient.ResetSession, LastAction = ambient.LastAction is null || string.IsNullOrWhiteSpace(ambient.LastAction.Action) ? null : new AmbientAction { Action = ambient.LastAction.Action.Trim(), Source = string.IsNullOrWhiteSpace(ambient.LastAction.Source) ? null : ambient.LastAction.Source.Trim(), QueryHint = string.IsNullOrWhiteSpace(ambient.LastAction.QueryHint) ? null : ambient.LastAction.QueryHint.Trim(), Domain = string.IsNullOrWhiteSpace(ambient.LastAction.Domain) ? null : ambient.LastAction.Domain.Trim().ToLowerInvariant(), EntityKey = string.IsNullOrWhiteSpace(ambient.LastAction.EntityKey) ? null : ambient.LastAction.EntityKey.Trim(), Route = string.IsNullOrWhiteSpace(ambient.LastAction.Route) ? null : ambient.LastAction.Route.Trim(), OccurredAt = ambient.LastAction.OccurredAt }, VisibleEntityKeys = ambient.VisibleEntityKeys is { Count: > 0 } ? ambient.VisibleEntityKeys .Where(static value => !string.IsNullOrWhiteSpace(value)) .Select(static value => value.Trim()) .Distinct(StringComparer.Ordinal) .ToArray() : null, RecentSearches = ambient.RecentSearches is { Count: > 0 } ? ambient.RecentSearches .Where(static value => !string.IsNullOrWhiteSpace(value)) .Select(static value => value.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray() : null }; } 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(), Facets = card.Facets.Select(static facet => new UnifiedSearchApiFacet { Domain = facet.Domain, Title = facet.Title, Snippet = facet.Snippet, Score = facet.Score, Metadata = facet.Metadata }).ToArray(), Connections = card.Connections.ToArray(), SynthesisHints = card.SynthesisHints }).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? suggestions = null; if (response.Suggestions is { Count: > 0 }) { suggestions = response.Suggestions.Select(static s => new UnifiedSearchApiSuggestion { Text = s.Text, Reason = s.Reason, Domain = s.Domain, CandidateCount = s.CandidateCount }).ToArray(); } IReadOnlyList? refinements = null; if (response.Refinements is { Count: > 0 }) { refinements = response.Refinements.Select(static r => new UnifiedSearchApiRefinement { Text = r.Text, Source = r.Source, Domain = r.Domain, CandidateCount = r.CandidateCount }).ToArray(); } UnifiedSearchApiContextAnswer? contextAnswer = null; if (response.ContextAnswer is not null) { contextAnswer = new UnifiedSearchApiContextAnswer { Status = response.ContextAnswer.Status, Code = response.ContextAnswer.Code, Summary = response.ContextAnswer.Summary, Reason = response.ContextAnswer.Reason, Evidence = response.ContextAnswer.Evidence, Citations = response.ContextAnswer.Citations?.Select(static citation => new UnifiedSearchApiContextAnswerCitation { EntityKey = citation.EntityKey, Title = citation.Title, Domain = citation.Domain, Route = citation.Route }).ToArray(), Questions = response.ContextAnswer.Questions?.Select(static question => new UnifiedSearchApiContextAnswerQuestion { Query = question.Query, Kind = question.Kind }).ToArray() }; } return new UnifiedSearchApiResponse { Query = response.Query, TopK = response.TopK, Cards = cards, Overflow = response.Overflow is null ? null : new UnifiedSearchApiOverflow { CurrentScopeDomain = response.Overflow.CurrentScopeDomain, Reason = response.Overflow.Reason, Cards = response.Overflow.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(), Facets = card.Facets.Select(static facet => new UnifiedSearchApiFacet { Domain = facet.Domain, Title = facet.Title, Snippet = facet.Snippet, Score = facet.Score, Metadata = facet.Metadata }).ToArray(), Connections = card.Connections.ToArray(), SynthesisHints = card.SynthesisHints }).ToArray() }, Coverage = response.Coverage is null ? null : new UnifiedSearchApiCoverage { CurrentScopeDomain = response.Coverage.CurrentScopeDomain, CurrentScopeWeighted = response.Coverage.CurrentScopeWeighted, Domains = response.Coverage.Domains.Select(static domain => new UnifiedSearchApiDomainCoverage { Domain = domain.Domain, CandidateCount = domain.CandidateCount, VisibleCardCount = domain.VisibleCardCount, TopScore = domain.TopScore, IsCurrentScope = domain.IsCurrentScope, HasVisibleResults = domain.HasVisibleResults }).ToArray() }, Synthesis = synthesis, Suggestions = suggestions, Refinements = refinements, ContextAnswer = contextAnswer, 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 }, Federation = response.Diagnostics.Federation?.Select(static diag => new UnifiedSearchApiFederationDiagnostic { Backend = diag.Backend, ResultCount = diag.ResultCount, DurationMs = diag.DurationMs, TimedOut = diag.TimedOut, Status = diag.Status }).ToArray() }; } private static UnifiedSearchSuggestionViabilityApiResponse MapSuggestionViabilityResponse( SearchSuggestionViabilityResponse response) { return new UnifiedSearchSuggestionViabilityApiResponse { Suggestions = response.Suggestions.Select(static suggestion => new UnifiedSearchSuggestionViabilityApiResult { Query = suggestion.Query, Viable = suggestion.Viable, Status = suggestion.Status, Code = suggestion.Code, CardCount = suggestion.CardCount, LeadingDomain = suggestion.LeadingDomain, Reason = suggestion.Reason, ViabilityState = suggestion.ViabilityState, ScopeReady = suggestion.ScopeReady }).ToArray(), Coverage = response.Coverage is null ? null : new UnifiedSearchApiCoverage { CurrentScopeDomain = response.Coverage.CurrentScopeDomain, CurrentScopeWeighted = response.Coverage.CurrentScopeWeighted, Domains = response.Coverage.Domains.Select(static domain => new UnifiedSearchApiDomainCoverage { Domain = domain.Domain, CandidateCount = domain.CandidateCount, VisibleCardCount = domain.VisibleCardCount, TopScore = domain.TopScore, IsCurrentScope = domain.IsCurrentScope, HasVisibleResults = domain.HasVisibleResults }).ToArray() } }; } private static bool HasSynthesisScope(HttpContext context) { var scopes = ResolveUserScopes(context); if (scopes is null || scopes.Count == 0) { return false; } return scopes.Contains("search:synthesize", StringComparer.OrdinalIgnoreCase) || scopes.Contains("advisory-ai:admin", StringComparer.OrdinalIgnoreCase); } private static IReadOnlyList MapSynthesisCards(IReadOnlyList? cards) { if (cards is not { Count: > 0 }) { return []; } return cards .Select(static card => new EntityCard { EntityKey = card.EntityKey, EntityType = card.EntityType, Domain = card.Domain, Title = card.Title, Snippet = card.Snippet, Score = card.Score, Severity = card.Severity, Metadata = card.Metadata, Sources = card.Sources, Actions = card.Actions.Select(static action => new EntityCardAction( action.Label, action.ActionType, action.Route, action.Command, action.IsPrimary)).ToArray(), Facets = card.Facets.Select(static facet => new EntityCardFacet { Domain = facet.Domain, Title = facet.Title, Snippet = facet.Snippet, Score = facet.Score, Metadata = facet.Metadata }).ToArray(), Connections = card.Connections, SynthesisHints = card.SynthesisHints }) .ToArray(); } private static async Task WriteSseEventAsync( HttpContext context, string eventName, object payload, CancellationToken cancellationToken) { var json = JsonSerializer.Serialize(payload); await context.Response.WriteAsync($"event: {eventName}\n", cancellationToken).ConfigureAwait(false); await context.Response.WriteAsync($"data: {json}\n\n", cancellationToken).ConfigureAwait(false); await context.Response.Body.FlushAsync(cancellationToken).ConfigureAwait(false); } private static IEnumerable Chunk(string content, int size) { if (string.IsNullOrEmpty(content) || size <= 0) { yield break; } for (var index = 0; index < content.Length; index += size) { var length = Math.Min(size, content.Length - index); yield return content.Substring(index, length); } } 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? ResolveUserScopes(HttpContext context) { var scopes = new List(); 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 UnifiedSearchApiAmbientContext? Ambient { get; init; } } public sealed record UnifiedSearchSuggestionViabilityApiRequest { public IReadOnlyList Queries { get; init; } = []; public UnifiedSearchApiFilter? Filters { get; init; } public UnifiedSearchApiAmbientContext? Ambient { get; init; } } public sealed record UnifiedSearchApiAmbientContext { public string? CurrentRoute { get; init; } public IReadOnlyList? VisibleEntityKeys { get; init; } public IReadOnlyList? RecentSearches { get; init; } public string? SessionId { get; init; } public bool ResetSession { get; init; } public UnifiedSearchApiAmbientAction? LastAction { get; init; } } public sealed record UnifiedSearchApiAmbientAction { public string Action { get; init; } = string.Empty; public string? Source { get; init; } public string? QueryHint { get; init; } public string? Domain { get; init; } public string? EntityKey { get; init; } public string? Route { get; init; } public DateTimeOffset? OccurredAt { get; init; } } public sealed record UnifiedSearchApiFilter { public IReadOnlyList? Domains { get; init; } public IReadOnlyList? 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? Tags { get; init; } } public sealed record UnifiedSearchSynthesizeApiRequest { public string Q { get; init; } = string.Empty; public IReadOnlyList TopCards { get; init; } = []; public QueryPlan? Plan { get; init; } public UnifiedSearchSynthesisPreferencesApi? Preferences { get; init; } } public sealed record UnifiedSearchSynthesisPreferencesApi { public string Depth { get; init; } = "brief"; public int? MaxTokens { get; init; } public bool IncludeActions { get; init; } = true; public string Locale { get; init; } = "en"; } public sealed record UnifiedSearchApiResponse { public string Query { get; init; } = string.Empty; public int TopK { get; init; } public IReadOnlyList Cards { get; init; } = []; public UnifiedSearchApiOverflow? Overflow { get; init; } public UnifiedSearchApiCoverage? Coverage { get; init; } public UnifiedSearchApiSynthesis? Synthesis { get; init; } public IReadOnlyList? Suggestions { get; init; } public IReadOnlyList? Refinements { get; init; } public UnifiedSearchApiContextAnswer? ContextAnswer { get; init; } public UnifiedSearchApiDiagnostics Diagnostics { get; init; } = new(); public IReadOnlyList? Federation { get; init; } } public sealed record UnifiedSearchSuggestionViabilityApiResponse { public IReadOnlyList Suggestions { get; init; } = []; public UnifiedSearchApiCoverage? Coverage { get; init; } } public sealed record UnifiedSearchSuggestionViabilityApiResult { public string Query { get; init; } = string.Empty; public bool Viable { get; init; } public string Status { get; init; } = string.Empty; public string Code { get; init; } = string.Empty; public int CardCount { get; init; } public string? LeadingDomain { get; init; } public string Reason { get; init; } = string.Empty; public string ViabilityState { get; init; } = "no_match"; public bool ScopeReady { get; init; } } 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 Actions { get; init; } = []; public IReadOnlyDictionary? Metadata { get; init; } public IReadOnlyList Sources { get; init; } = []; public IReadOnlyList Facets { get; init; } = []; public IReadOnlyList Connections { get; init; } = []; public IReadOnlyDictionary SynthesisHints { get; init; } = new Dictionary(StringComparer.Ordinal); } public sealed record UnifiedSearchApiOverflow { public string CurrentScopeDomain { get; init; } = string.Empty; public string Reason { get; init; } = string.Empty; public IReadOnlyList Cards { get; init; } = []; } public sealed record UnifiedSearchApiCoverage { public string? CurrentScopeDomain { get; init; } public bool CurrentScopeWeighted { get; init; } public IReadOnlyList Domains { get; init; } = []; } public sealed record UnifiedSearchApiDomainCoverage { public string Domain { get; init; } = string.Empty; public int CandidateCount { get; init; } public int VisibleCardCount { get; init; } public double TopScore { get; init; } public bool IsCurrentScope { get; init; } public bool HasVisibleResults { get; init; } } public sealed record UnifiedSearchApiFacet { 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 IReadOnlyDictionary? Metadata { 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 DomainsCovered { get; init; } = []; } public sealed record UnifiedSearchApiSuggestion { public string Text { get; init; } = string.Empty; public string Reason { get; init; } = string.Empty; public string? Domain { get; init; } public int CandidateCount { get; init; } } public sealed record UnifiedSearchApiRefinement { public string Text { get; init; } = string.Empty; public string Source { get; init; } = string.Empty; public string? Domain { get; init; } public int CandidateCount { get; init; } } public sealed record UnifiedSearchApiContextAnswer { public string Status { get; init; } = string.Empty; public string Code { get; init; } = string.Empty; public string Summary { get; init; } = string.Empty; public string Reason { get; init; } = string.Empty; public string Evidence { get; init; } = string.Empty; public IReadOnlyList? Citations { get; init; } public IReadOnlyList? Questions { get; init; } } public sealed record UnifiedSearchApiContextAnswerCitation { public string EntityKey { get; init; } = string.Empty; public string Title { get; init; } = string.Empty; public string Domain { get; init; } = string.Empty; public string? Route { get; init; } } public sealed record UnifiedSearchApiContextAnswerQuestion { public string Query { get; init; } = string.Empty; public string Kind { get; init; } = "follow_up"; } 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 UnifiedSearchApiFederationDiagnostic { public string Backend { get; init; } = string.Empty; public int ResultCount { get; init; } public long DurationMs { get; init; } public bool TimedOut { get; init; } public string Status { get; init; } = "ok"; } public sealed record UnifiedSearchRebuildApiResponse { public int DomainCount { get; init; } public int ChunkCount { get; init; } public long DurationMs { get; init; } }