search and ai stabilization work, localization stablized.

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

View File

@@ -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; }
}