1196 lines
43 KiB
C#
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; }
|
|
}
|