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