stela ops usage fixes roles propagation and timoeut, one account to support multi tenants, migrations consolidation, search to support documentation, doctor and open api vector db search

This commit is contained in:
master
2026-02-22 19:27:54 +02:00
parent a29f438f53
commit bd8fee6ed8
373 changed files with 832097 additions and 3369 deletions

View File

@@ -0,0 +1,381 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.AdvisoryAI.KnowledgeSearch;
namespace StellaOps.AdvisoryAI.WebService.Endpoints;
public static class KnowledgeSearchEndpoints
{
private static readonly HashSet<string> AllowedKinds = new(StringComparer.OrdinalIgnoreCase)
{
"docs",
"api",
"doctor"
};
public static RouteGroupBuilder MapKnowledgeSearchEndpoints(this IEndpointRouteBuilder builder)
{
var group = builder.MapGroup("/v1/advisory-ai")
.WithTags("Advisory AI - Knowledge Search");
group.MapPost("/search", SearchAsync)
.WithName("AdvisoryAiKnowledgeSearch")
.WithSummary("Searches AdvisoryAI deterministic knowledge index (docs/api/doctor).")
.Produces<AdvisoryKnowledgeSearchResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status403Forbidden);
group.MapPost("/index/rebuild", RebuildIndexAsync)
.WithName("AdvisoryAiKnowledgeIndexRebuild")
.WithSummary("Rebuilds AdvisoryAI knowledge search index from deterministic local sources.")
.Produces<AdvisoryKnowledgeRebuildResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status403Forbidden);
return group;
}
private static async Task<IResult> SearchAsync(
HttpContext httpContext,
AdvisoryKnowledgeSearchRequest request,
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." });
}
if (request.Q.Length > 4096)
{
return Results.BadRequest(new { error = "q must be 4096 characters or fewer." });
}
var normalizedFilter = NormalizeFilter(request.Filters);
var domainRequest = new KnowledgeSearchRequest(
request.Q.Trim(),
request.K,
normalizedFilter,
request.IncludeDebug);
var response = await searchService.SearchAsync(domainRequest, cancellationToken).ConfigureAwait(false);
return Results.Ok(MapResponse(response));
}
private static async Task<IResult> RebuildIndexAsync(
HttpContext httpContext,
IKnowledgeIndexer indexer,
CancellationToken cancellationToken)
{
if (!EnsureIndexAdminAuthorized(httpContext))
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
var summary = await indexer.RebuildAsync(cancellationToken).ConfigureAwait(false);
return Results.Ok(new AdvisoryKnowledgeRebuildResponse
{
DocumentCount = summary.DocumentCount,
ChunkCount = summary.ChunkCount,
ApiSpecCount = summary.ApiSpecCount,
ApiOperationCount = summary.ApiOperationCount,
DoctorProjectionCount = summary.DoctorProjectionCount,
DurationMs = summary.DurationMs
});
}
private static KnowledgeSearchFilter? NormalizeFilter(AdvisoryKnowledgeSearchFilter? filter)
{
if (filter is null)
{
return null;
}
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)
.OrderBy(static value => value, StringComparer.Ordinal)
.ToArray()
: null;
var normalizedTags = filter.Tags is { Count: > 0 }
? filter.Tags
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
.ToArray()
: null;
return new KnowledgeSearchFilter
{
Type = normalizedKinds,
Product = NormalizeOptional(filter.Product),
Version = NormalizeOptional(filter.Version),
Service = NormalizeOptional(filter.Service),
Tags = normalizedTags
};
}
private static string? NormalizeOptional(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
private static AdvisoryKnowledgeSearchResponse MapResponse(KnowledgeSearchResponse response)
{
var results = response.Results
.Select(MapResult)
.ToArray();
return new AdvisoryKnowledgeSearchResponse
{
Query = response.Query,
TopK = response.TopK,
Results = results,
Diagnostics = new AdvisoryKnowledgeSearchDiagnostics
{
FtsMatches = response.Diagnostics.FtsMatches,
VectorMatches = response.Diagnostics.VectorMatches,
DurationMs = response.Diagnostics.DurationMs,
UsedVector = response.Diagnostics.UsedVector,
Mode = response.Diagnostics.Mode
}
};
}
private static AdvisoryKnowledgeSearchResult MapResult(KnowledgeSearchResult result)
{
var action = new AdvisoryKnowledgeOpenAction
{
Kind = result.Open.Kind switch
{
KnowledgeOpenActionType.Api => "api",
KnowledgeOpenActionType.Doctor => "doctor",
_ => "docs"
},
Docs = result.Open.Docs is null
? null
: new AdvisoryKnowledgeOpenDocAction
{
Path = result.Open.Docs.Path,
Anchor = result.Open.Docs.Anchor,
SpanStart = result.Open.Docs.SpanStart,
SpanEnd = result.Open.Docs.SpanEnd
},
Api = result.Open.Api is null
? null
: new AdvisoryKnowledgeOpenApiAction
{
Service = result.Open.Api.Service,
Method = result.Open.Api.Method,
Path = result.Open.Api.Path,
OperationId = result.Open.Api.OperationId
},
Doctor = result.Open.Doctor is null
? null
: new AdvisoryKnowledgeOpenDoctorAction
{
CheckCode = result.Open.Doctor.CheckCode,
Severity = result.Open.Doctor.Severity,
CanRun = result.Open.Doctor.CanRun,
RunCommand = result.Open.Doctor.RunCommand
}
};
return new AdvisoryKnowledgeSearchResult
{
Type = result.Type,
Title = result.Title,
Snippet = result.Snippet,
Score = result.Score,
Open = action,
Debug = result.Debug is null
? null
: new Dictionary<string, string>(result.Debug, StringComparer.Ordinal)
};
}
private static bool EnsureSearchAuthorized(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)
{
if (scopes.Contains(expectedScope))
{
return true;
}
}
return false;
}
private static void AddScopeTokens(HashSet<string> scopes, IEnumerable<string> values)
{
foreach (var value in values)
{
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
foreach (var token in value.Split(
[' ', ','],
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
scopes.Add(token);
}
}
}
}
public sealed record AdvisoryKnowledgeSearchRequest
{
public string Q { get; init; } = string.Empty;
public int? K { get; init; }
public AdvisoryKnowledgeSearchFilter? Filters { get; init; }
public bool IncludeDebug { get; init; }
}
public sealed record AdvisoryKnowledgeSearchFilter
{
public IReadOnlyList<string>? Type { 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 AdvisoryKnowledgeSearchResponse
{
public string Query { get; init; } = string.Empty;
public int TopK { get; init; }
public IReadOnlyList<AdvisoryKnowledgeSearchResult> Results { get; init; } = [];
public AdvisoryKnowledgeSearchDiagnostics Diagnostics { get; init; } = new();
}
public sealed record AdvisoryKnowledgeSearchResult
{
public string Type { get; init; } = "docs";
public string Title { get; init; } = string.Empty;
public string Snippet { get; init; } = string.Empty;
public double Score { get; init; }
public AdvisoryKnowledgeOpenAction Open { get; init; } = new();
public IReadOnlyDictionary<string, string>? Debug { get; init; }
}
public sealed record AdvisoryKnowledgeOpenAction
{
public string Kind { get; init; } = "docs";
public AdvisoryKnowledgeOpenDocAction? Docs { get; init; }
public AdvisoryKnowledgeOpenApiAction? Api { get; init; }
public AdvisoryKnowledgeOpenDoctorAction? Doctor { get; init; }
}
public sealed record AdvisoryKnowledgeOpenDocAction
{
public string Path { get; init; } = string.Empty;
public string Anchor { get; init; } = "overview";
public int SpanStart { get; init; }
public int SpanEnd { get; init; }
}
public sealed record AdvisoryKnowledgeOpenApiAction
{
public string Service { get; init; } = string.Empty;
public string Method { get; init; } = "GET";
public string Path { get; init; } = "/";
public string OperationId { get; init; } = string.Empty;
}
public sealed record AdvisoryKnowledgeOpenDoctorAction
{
public string CheckCode { get; init; } = string.Empty;
public string Severity { get; init; } = "warn";
public bool CanRun { get; init; } = true;
public string RunCommand { get; init; } = string.Empty;
}
public sealed record AdvisoryKnowledgeSearchDiagnostics
{
public int FtsMatches { get; init; }
public int VectorMatches { get; init; }
public long DurationMs { get; init; }
public bool UsedVector { get; init; }
public string Mode { get; init; } = "fts-only";
}
public sealed record AdvisoryKnowledgeRebuildResponse
{
public int DocumentCount { get; init; }
public int ChunkCount { get; init; }
public int ApiSpecCount { get; init; }
public int ApiOperationCount { get; init; }
public int DoctorProjectionCount { get; init; }
public long DurationMs { get; init; }
}