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

@@ -4,6 +4,8 @@ using Microsoft.AspNetCore.Routing;
using StellaOps.AdvisoryAI.KnowledgeSearch;
using StellaOps.AdvisoryAI.WebService.Security;
using StellaOps.Auth.ServerIntegration.Tenancy;
using System.Linq;
using static StellaOps.Localization.T;
namespace StellaOps.AdvisoryAI.WebService.Endpoints;
@@ -28,6 +30,7 @@ public static class KnowledgeSearchEndpoints
.WithSummary("Searches AdvisoryAI deterministic knowledge index (docs/api/doctor).")
.WithDescription("Performs a hybrid full-text and vector similarity search over the AdvisoryAI deterministic knowledge index, which is composed of product documentation, OpenAPI specs, and Doctor health check projections. Supports filtering by content type (docs, api, doctor), product, version, service, and tags. Returns ranked result snippets with actionable open-actions for UI navigation.")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
.RequireRateLimiting("advisory-ai")
.Produces<AdvisoryKnowledgeSearchResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status403Forbidden);
@@ -37,6 +40,7 @@ public static class KnowledgeSearchEndpoints
.WithSummary("Rebuilds AdvisoryAI knowledge search index from deterministic local sources.")
.WithDescription("Triggers a full rebuild of the knowledge search index from local deterministic sources: product documentation files, embedded OpenAPI specs, and Doctor health check metadata. The rebuild is synchronous and returns document, chunk, and operation counts with duration. Requires admin-level scope; does not fetch external content.")
.RequireAuthorization(AdvisoryAIPolicies.AdminPolicy)
.RequireRateLimiting("advisory-ai")
.Produces<AdvisoryKnowledgeRebuildResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status403Forbidden);
@@ -49,22 +53,32 @@ public static class KnowledgeSearchEndpoints
IKnowledgeSearchService searchService,
CancellationToken cancellationToken)
{
if (!EnsureSearchAuthorized(httpContext))
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
if (request is null || string.IsNullOrWhiteSpace(request.Q))
{
return Results.BadRequest(new { error = "q is required." });
return Results.BadRequest(new { error = _t("advisoryai.validation.q_required") });
}
if (request.Q.Length > 4096)
if (request.Q.Length > 512)
{
return Results.BadRequest(new { error = "q must be 4096 characters or fewer." });
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") });
}
KnowledgeSearchFilter? normalizedFilter;
try
{
normalizedFilter = NormalizeFilter(request.Filters, tenant);
}
catch (ArgumentException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
var normalizedFilter = NormalizeFilter(request.Filters);
var domainRequest = new KnowledgeSearchRequest(
request.Q.Trim(),
request.K,
@@ -72,6 +86,7 @@ public static class KnowledgeSearchEndpoints
request.IncludeDebug);
var response = await searchService.SearchAsync(domainRequest, cancellationToken).ConfigureAwait(false);
ApplyLegacyKnowledgeSearchDeprecationHeaders(httpContext.Response.Headers);
return Results.Ok(MapResponse(response));
}
@@ -80,9 +95,9 @@ public static class KnowledgeSearchEndpoints
IKnowledgeIndexer indexer,
CancellationToken cancellationToken)
{
if (!EnsureIndexAdminAuthorized(httpContext))
if (ResolveTenant(httpContext) is null)
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
return Results.BadRequest(new { error = _t("advisoryai.validation.tenant_required") });
}
var summary = await indexer.RebuildAsync(cancellationToken).ConfigureAwait(false);
@@ -97,22 +112,42 @@ public static class KnowledgeSearchEndpoints
});
}
private static KnowledgeSearchFilter? NormalizeFilter(AdvisoryKnowledgeSearchFilter? filter)
private static KnowledgeSearchFilter? NormalizeFilter(AdvisoryKnowledgeSearchFilter? filter, string tenant)
{
if (filter is null)
{
return null;
return new KnowledgeSearchFilter
{
Tenant = tenant
};
}
var normalizedKinds = filter.Type is { Count: > 0 }
? filter.Type
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim().ToLowerInvariant())
.Where(value => AllowedKinds.Contains(value))
.Distinct(StringComparer.Ordinal)
string[]? normalizedKinds = null;
if (filter.Type is { Count: > 0 })
{
var kinds = new HashSet<string>(StringComparer.Ordinal);
foreach (var item in filter.Type)
{
if (string.IsNullOrWhiteSpace(item))
{
continue;
}
var normalized = item.Trim().ToLowerInvariant();
if (!AllowedKinds.Contains(normalized))
{
throw new ArgumentException(
_t("advisoryai.validation.filter_type_unsupported", normalized),
nameof(filter));
}
kinds.Add(normalized);
}
normalizedKinds = kinds
.OrderBy(static value => value, StringComparer.Ordinal)
.ToArray()
: null;
.ToArray();
}
var normalizedTags = filter.Tags is { Count: > 0 }
? filter.Tags
@@ -129,7 +164,8 @@ public static class KnowledgeSearchEndpoints
Product = NormalizeOptional(filter.Product),
Version = NormalizeOptional(filter.Version),
Service = NormalizeOptional(filter.Service),
Tags = normalizedTags
Tags = normalizedTags,
Tenant = tenant
};
}
@@ -155,7 +191,8 @@ public static class KnowledgeSearchEndpoints
VectorMatches = response.Diagnostics.VectorMatches,
DurationMs = response.Diagnostics.DurationMs,
UsedVector = response.Diagnostics.UsedVector,
Mode = response.Diagnostics.Mode
Mode = response.Diagnostics.Mode,
ActiveEncoder = response.Diagnostics.ActiveEncoder
}
};
}
@@ -215,57 +252,34 @@ public static class KnowledgeSearchEndpoints
};
}
private static bool EnsureSearchAuthorized(HttpContext context)
private static string? ResolveTenant(HttpContext context)
{
return HasAnyScope(
context,
"advisory:run",
"advisory:search",
"advisory:read");
}
private static bool EnsureIndexAdminAuthorized(HttpContext context)
{
return HasAnyScope(
context,
"advisory:run",
"advisory:admin",
"advisory:index:write");
}
private static bool HasAnyScope(HttpContext context, params string[] expectedScopes)
{
var scopes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
AddScopeTokens(scopes, context.Request.Headers["X-StellaOps-Scopes"]);
AddScopeTokens(scopes, context.Request.Headers["X-Stella-Scopes"]);
foreach (var expectedScope in expectedScopes)
foreach (var value in context.Request.Headers["X-StellaOps-Tenant"])
{
if (scopes.Contains(expectedScope))
if (!string.IsNullOrWhiteSpace(value))
{
return true;
return value.Trim();
}
}
return false;
}
private static void AddScopeTokens(HashSet<string> scopes, IEnumerable<string> values)
{
foreach (var value in values)
foreach (var value in context.Request.Headers["X-Tenant-Id"])
{
if (string.IsNullOrWhiteSpace(value))
if (!string.IsNullOrWhiteSpace(value))
{
continue;
}
foreach (var token in value.Split(
[' ', ','],
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
scopes.Add(token);
return value.Trim();
}
}
var claimTenant = context.User?.FindFirst("tenant_id")?.Value;
return string.IsNullOrWhiteSpace(claimTenant) ? null : claimTenant.Trim();
}
private static void ApplyLegacyKnowledgeSearchDeprecationHeaders(IHeaderDictionary headers)
{
headers["Deprecation"] = "true";
headers["Sunset"] = "2026-04-30T00:00:00Z";
headers["Link"] = "</v1/search/query>; rel=\"successor-version\"";
headers["Warning"] = "299 - AdvisoryAI legacy knowledge search is deprecated; migrate to /v1/search/query";
}
}
@@ -380,6 +394,12 @@ public sealed record AdvisoryKnowledgeSearchDiagnostics
public bool UsedVector { get; init; }
public string Mode { get; init; } = "fts-only";
/// <summary>
/// Reports which vector encoder implementation is active: "hash" (deterministic SHA-256),
/// "onnx" (semantic ONNX inference), or "onnx-fallback" (configured for ONNX but fell back to hash).
/// </summary>
public string ActiveEncoder { get; init; } = "hash";
}
public sealed record AdvisoryKnowledgeRebuildResponse