Files
git.stella-ops.org/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/Endpoints/UnifiedSearchEndpoints.cs

1196 lines
43 KiB
C#

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<string> AllowedDomains = new(StringComparer.Ordinal)
{
"knowledge",
"findings",
"vex",
"policy",
"platform",
"graph",
"timeline",
"scanner",
"opsmemory"
};
private static readonly HashSet<string> 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<UnifiedSearchApiResponse>(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<UnifiedSearchSuggestionViabilityApiResponse>(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<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 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<IResult> 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<string>()
}, 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<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,
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<UnifiedSearchApiSuggestion>? 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<UnifiedSearchApiRefinement>? 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<EntityCard> MapSynthesisCards(IReadOnlyList<UnifiedSearchApiCard>? 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<string> 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<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 UnifiedSearchApiAmbientContext? Ambient { get; init; }
}
public sealed record UnifiedSearchSuggestionViabilityApiRequest
{
public IReadOnlyList<string> Queries { get; init; } = [];
public UnifiedSearchApiFilter? Filters { get; init; }
public UnifiedSearchApiAmbientContext? Ambient { get; init; }
}
public sealed record UnifiedSearchApiAmbientContext
{
public string? CurrentRoute { get; init; }
public IReadOnlyList<string>? VisibleEntityKeys { get; init; }
public IReadOnlyList<string>? 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<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 UnifiedSearchSynthesizeApiRequest
{
public string Q { get; init; } = string.Empty;
public IReadOnlyList<UnifiedSearchApiCard> 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<UnifiedSearchApiCard> Cards { get; init; } = [];
public UnifiedSearchApiOverflow? Overflow { get; init; }
public UnifiedSearchApiCoverage? Coverage { get; init; }
public UnifiedSearchApiSynthesis? Synthesis { get; init; }
public IReadOnlyList<UnifiedSearchApiSuggestion>? Suggestions { get; init; }
public IReadOnlyList<UnifiedSearchApiRefinement>? Refinements { get; init; }
public UnifiedSearchApiContextAnswer? ContextAnswer { get; init; }
public UnifiedSearchApiDiagnostics Diagnostics { get; init; } = new();
public IReadOnlyList<UnifiedSearchApiFederationDiagnostic>? Federation { get; init; }
}
public sealed record UnifiedSearchSuggestionViabilityApiResponse
{
public IReadOnlyList<UnifiedSearchSuggestionViabilityApiResult> 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<UnifiedSearchApiAction> Actions { get; init; } = [];
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
public IReadOnlyList<string> Sources { get; init; } = [];
public IReadOnlyList<UnifiedSearchApiFacet> Facets { get; init; } = [];
public IReadOnlyList<string> Connections { get; init; } = [];
public IReadOnlyDictionary<string, string> SynthesisHints { get; init; } =
new Dictionary<string, string>(StringComparer.Ordinal);
}
public sealed record UnifiedSearchApiOverflow
{
public string CurrentScopeDomain { get; init; } = string.Empty;
public string Reason { get; init; } = string.Empty;
public IReadOnlyList<UnifiedSearchApiCard> Cards { get; init; } = [];
}
public sealed record UnifiedSearchApiCoverage
{
public string? CurrentScopeDomain { get; init; }
public bool CurrentScopeWeighted { get; init; }
public IReadOnlyList<UnifiedSearchApiDomainCoverage> 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<string, string>? 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<string> 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<UnifiedSearchApiContextAnswerCitation>? Citations { get; init; }
public IReadOnlyList<UnifiedSearchApiContextAnswerQuestion>? 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; }
}