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:
@@ -10,6 +10,7 @@ using StellaOps.AdvisoryAI.DependencyInjection;
|
||||
using StellaOps.AdvisoryAI.Explanation;
|
||||
using StellaOps.AdvisoryAI.Guardrails;
|
||||
using StellaOps.AdvisoryAI.Inference;
|
||||
using StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
using StellaOps.AdvisoryAI.Metrics;
|
||||
using StellaOps.AdvisoryAI.Outputs;
|
||||
using StellaOps.AdvisoryAI.PolicyStudio;
|
||||
@@ -96,6 +97,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddSbomContext();
|
||||
services.AddAdvisoryPipeline();
|
||||
services.AddAdvisoryPipelineInfrastructure();
|
||||
services.AddAdvisoryKnowledgeSearch(configuration);
|
||||
|
||||
services.AddOptions<AdvisoryGuardrailOptions>()
|
||||
.Configure<IOptions<AdvisoryAiServiceOptions>, IHostEnvironment>((options, aiOptions, environment) =>
|
||||
|
||||
@@ -377,9 +377,10 @@ public static class EvidencePackEndpoints
|
||||
|
||||
private static string GetUserId(HttpContext context)
|
||||
{
|
||||
if (context.Request.Headers.TryGetValue("X-StellaOps-User", out var user))
|
||||
if (context.Request.Headers.TryGetValue("X-StellaOps-Actor", out var actor)
|
||||
&& !string.IsNullOrWhiteSpace(actor.ToString()))
|
||||
{
|
||||
return user.ToString();
|
||||
return actor.ToString();
|
||||
}
|
||||
|
||||
return context.User?.FindFirst("sub")?.Value ?? "anonymous";
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.AdvisoryAI.Inference.LlmProviders;
|
||||
using StellaOps.AdvisoryAI.Plugin.Unified;
|
||||
using StellaOps.Plugin.Abstractions.Capabilities;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using PluginLlmCompletionRequest = StellaOps.Plugin.Abstractions.Capabilities.LlmCompletionRequest;
|
||||
using PluginLlmCompletionResult = StellaOps.Plugin.Abstractions.Capabilities.LlmCompletionResult;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Unified LLM adapter exposure endpoints.
|
||||
/// Provides provider discovery and an OpenAI-compatible completion surface.
|
||||
/// </summary>
|
||||
public static class LlmAdapterEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps unified LLM adapter endpoints.
|
||||
/// </summary>
|
||||
/// <param name="builder">Endpoint route builder.</param>
|
||||
/// <returns>Route group builder.</returns>
|
||||
public static RouteGroupBuilder MapLlmAdapterEndpoints(this IEndpointRouteBuilder builder)
|
||||
{
|
||||
var group = builder.MapGroup("/v1/advisory-ai/adapters")
|
||||
.WithTags("Advisory AI - LLM Adapters");
|
||||
|
||||
group.MapGet("/llm/providers", ListProvidersAsync)
|
||||
.WithName("ListLlmProviders")
|
||||
.WithSummary("Lists LLM providers exposed via the unified adapter layer.")
|
||||
.Produces<IReadOnlyList<LlmProviderExposureResponse>>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status403Forbidden);
|
||||
|
||||
group.MapPost("/llm/{providerId}/chat/completions", CompleteWithProviderAsync)
|
||||
.WithName("LlmProviderChatCompletions")
|
||||
.WithSummary("OpenAI-compatible chat completion for a specific unified provider.")
|
||||
.Produces<OpenAiChatCompletionResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status503ServiceUnavailable);
|
||||
|
||||
group.MapPost("/openai/v1/chat/completions", CompleteOpenAiCompatAsync)
|
||||
.WithName("OpenAiAdapterChatCompletions")
|
||||
.WithSummary("OpenAI-compatible chat completion alias backed by providerId=openai.")
|
||||
.Produces<OpenAiChatCompletionResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status503ServiceUnavailable);
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static IResult ListProvidersAsync(
|
||||
HttpContext context,
|
||||
LlmProviderCatalog catalog,
|
||||
LlmPluginAdapterFactory adapterFactory,
|
||||
IServiceProvider services)
|
||||
{
|
||||
if (!EnsureAdapterReadAuthorized(context))
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
var availableProviders = catalog.GetAvailablePlugins(services)
|
||||
.Select(plugin => plugin.ProviderId)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var providers = new List<LlmProviderExposureResponse>();
|
||||
foreach (var plugin in catalog.GetPlugins().OrderBy(p => p.ProviderId, StringComparer.Ordinal))
|
||||
{
|
||||
var config = catalog.GetConfiguration(plugin.ProviderId);
|
||||
var configured = config is not null;
|
||||
var validation = configured
|
||||
? plugin.ValidateConfiguration(config!)
|
||||
: LlmProviderConfigValidation.Failed("Provider configuration file was not found.");
|
||||
|
||||
var exposed = false;
|
||||
var exposureErrors = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
exposed = adapterFactory.GetCapability(plugin.ProviderId) is not null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
exposureErrors.Add(ex.Message);
|
||||
}
|
||||
|
||||
var completionPath = string.Equals(plugin.ProviderId, "openai", StringComparison.OrdinalIgnoreCase)
|
||||
? "/v1/advisory-ai/adapters/openai/v1/chat/completions"
|
||||
: $"/v1/advisory-ai/adapters/llm/{plugin.ProviderId}/chat/completions";
|
||||
|
||||
var errors = validation.Errors.ToList();
|
||||
errors.AddRange(exposureErrors);
|
||||
|
||||
providers.Add(new LlmProviderExposureResponse
|
||||
{
|
||||
ProviderId = plugin.ProviderId,
|
||||
DisplayName = plugin.DisplayName,
|
||||
Description = plugin.Description,
|
||||
Configured = configured,
|
||||
Valid = configured && validation.IsValid,
|
||||
Available = availableProviders.Contains(plugin.ProviderId),
|
||||
Exposed = exposed,
|
||||
CompletionPath = completionPath,
|
||||
Warnings = validation.Warnings,
|
||||
Errors = errors
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(providers);
|
||||
}
|
||||
|
||||
private static Task<IResult> CompleteOpenAiCompatAsync(
|
||||
HttpContext context,
|
||||
[FromBody] OpenAiChatCompletionRequest request,
|
||||
LlmPluginAdapterFactory adapterFactory,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return CompleteWithProviderAsync(
|
||||
context,
|
||||
"openai",
|
||||
request,
|
||||
adapterFactory,
|
||||
timeProvider,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CompleteWithProviderAsync(
|
||||
HttpContext context,
|
||||
string providerId,
|
||||
[FromBody] OpenAiChatCompletionRequest request,
|
||||
LlmPluginAdapterFactory adapterFactory,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!EnsureAdapterInvokeAuthorized(context, providerId))
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
if (request.Messages.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = "messages must contain at least one item." });
|
||||
}
|
||||
|
||||
if (request.Stream)
|
||||
{
|
||||
return Results.BadRequest(new { error = "stream=true is not supported by the adapter endpoint." });
|
||||
}
|
||||
|
||||
if (!TryBuildPrompts(request.Messages, out var systemPrompt, out var userPrompt))
|
||||
{
|
||||
return Results.BadRequest(new { error = "messages must include at least one non-empty user or assistant content." });
|
||||
}
|
||||
|
||||
var capability = adapterFactory.GetCapability(providerId);
|
||||
if (capability is null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Provider '{providerId}' is not configured for adapter exposure." });
|
||||
}
|
||||
|
||||
if (!await capability.IsAvailableAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
var completionRequest = new PluginLlmCompletionRequest(
|
||||
UserPrompt: userPrompt,
|
||||
SystemPrompt: systemPrompt,
|
||||
Model: request.Model,
|
||||
Temperature: request.Temperature ?? 0,
|
||||
MaxTokens: request.MaxTokens is > 0 ? request.MaxTokens.Value : 4096,
|
||||
Seed: request.Seed,
|
||||
StopSequences: request.Stop,
|
||||
RequestId: string.IsNullOrWhiteSpace(request.RequestId) ? context.TraceIdentifier : request.RequestId);
|
||||
|
||||
var completion = await capability.CompleteAsync(completionRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new OpenAiChatCompletionResponse
|
||||
{
|
||||
Id = BuildCompletionId(completion),
|
||||
Object = "chat.completion",
|
||||
Created = timeProvider.GetUtcNow().ToUnixTimeSeconds(),
|
||||
Model = completion.ModelId,
|
||||
Choices =
|
||||
[
|
||||
new OpenAiChatCompletionChoice
|
||||
{
|
||||
Index = 0,
|
||||
Message = new OpenAiChatMessage { Role = "assistant", Content = completion.Content },
|
||||
FinishReason = string.IsNullOrWhiteSpace(completion.FinishReason) ? "stop" : completion.FinishReason
|
||||
}
|
||||
],
|
||||
Usage = new OpenAiUsageInfo
|
||||
{
|
||||
PromptTokens = completion.InputTokens ?? 0,
|
||||
CompletionTokens = completion.OutputTokens ?? 0,
|
||||
TotalTokens = (completion.InputTokens ?? 0) + (completion.OutputTokens ?? 0)
|
||||
}
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static bool TryBuildPrompts(
|
||||
IReadOnlyList<OpenAiChatMessageRequest> messages,
|
||||
out string? systemPrompt,
|
||||
out string userPrompt)
|
||||
{
|
||||
var systemLines = new List<string>();
|
||||
var userLines = new List<string>();
|
||||
|
||||
foreach (var message in messages)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message.Content))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var role = message.Role?.Trim() ?? string.Empty;
|
||||
var content = message.Content.Trim();
|
||||
if (role.Equals("system", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
systemLines.Add(content);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role.Equals("user", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
userLines.Add(content);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(role))
|
||||
{
|
||||
userLines.Add(content);
|
||||
continue;
|
||||
}
|
||||
|
||||
userLines.Add($"{role.ToLowerInvariant()}: {content}");
|
||||
}
|
||||
|
||||
systemPrompt = systemLines.Count == 0
|
||||
? null
|
||||
: string.Join(Environment.NewLine, systemLines);
|
||||
|
||||
userPrompt = string.Join(Environment.NewLine, userLines);
|
||||
return !string.IsNullOrWhiteSpace(userPrompt);
|
||||
}
|
||||
|
||||
private static string BuildCompletionId(PluginLlmCompletionResult completion)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(completion.RequestId))
|
||||
{
|
||||
return completion.RequestId;
|
||||
}
|
||||
|
||||
var input = $"{completion.ProviderId}|{completion.ModelId}|{completion.Content}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
var hex = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
return $"chatcmpl-{hex[..24]}";
|
||||
}
|
||||
|
||||
private static bool EnsureAdapterReadAuthorized(HttpContext context)
|
||||
{
|
||||
return HasAnyScope(context, "advisory:run", "advisory:adapter:read", "advisory:openai:read");
|
||||
}
|
||||
|
||||
private static bool EnsureAdapterInvokeAuthorized(HttpContext context, string providerId)
|
||||
{
|
||||
var providerScope = $"advisory:{providerId}:invoke";
|
||||
return HasAnyScope(
|
||||
context,
|
||||
"advisory:run",
|
||||
"advisory:adapter:invoke",
|
||||
"advisory:openai:invoke",
|
||||
providerScope);
|
||||
}
|
||||
|
||||
private static bool HasAnyScope(HttpContext context, params string[] expectedScopes)
|
||||
{
|
||||
var scopes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
// Only read from gateway-managed headers that are stripped and rewritten
|
||||
// from validated JWT claims by IdentityHeaderPolicyMiddleware.
|
||||
// Do NOT read from X-Scopes — it is not in the gateway's ReservedHeaders
|
||||
// list and can be spoofed by external clients.
|
||||
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 LlmProviderExposureResponse
|
||||
{
|
||||
[JsonPropertyName("provider_id")]
|
||||
public required string ProviderId { get; init; }
|
||||
|
||||
[JsonPropertyName("display_name")]
|
||||
public required string DisplayName { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public required string Description { get; init; }
|
||||
|
||||
[JsonPropertyName("configured")]
|
||||
public required bool Configured { get; init; }
|
||||
|
||||
[JsonPropertyName("valid")]
|
||||
public required bool Valid { get; init; }
|
||||
|
||||
[JsonPropertyName("available")]
|
||||
public required bool Available { get; init; }
|
||||
|
||||
[JsonPropertyName("exposed")]
|
||||
public required bool Exposed { get; init; }
|
||||
|
||||
[JsonPropertyName("completion_path")]
|
||||
public required string CompletionPath { get; init; }
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public required IReadOnlyList<string> Warnings { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public required IReadOnlyList<string> Errors { get; init; }
|
||||
}
|
||||
|
||||
public sealed record OpenAiChatCompletionRequest
|
||||
{
|
||||
[JsonPropertyName("model")]
|
||||
public string? Model { get; init; }
|
||||
|
||||
[JsonPropertyName("messages")]
|
||||
public IReadOnlyList<OpenAiChatMessageRequest> Messages { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("temperature")]
|
||||
public double? Temperature { get; init; }
|
||||
|
||||
[JsonPropertyName("max_tokens")]
|
||||
public int? MaxTokens { get; init; }
|
||||
|
||||
[JsonPropertyName("stream")]
|
||||
public bool Stream { get; init; }
|
||||
|
||||
[JsonPropertyName("seed")]
|
||||
public int? Seed { get; init; }
|
||||
|
||||
[JsonPropertyName("stop")]
|
||||
public IReadOnlyList<string>? Stop { get; init; }
|
||||
|
||||
[JsonPropertyName("request_id")]
|
||||
public string? RequestId { get; init; }
|
||||
}
|
||||
|
||||
public sealed record OpenAiChatMessageRequest
|
||||
{
|
||||
[JsonPropertyName("role")]
|
||||
public string Role { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("content")]
|
||||
public string? Content { get; init; }
|
||||
}
|
||||
|
||||
public sealed record OpenAiChatCompletionResponse
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
[JsonPropertyName("object")]
|
||||
public required string Object { get; init; }
|
||||
|
||||
[JsonPropertyName("created")]
|
||||
public required long Created { get; init; }
|
||||
|
||||
[JsonPropertyName("model")]
|
||||
public required string Model { get; init; }
|
||||
|
||||
[JsonPropertyName("choices")]
|
||||
public required IReadOnlyList<OpenAiChatCompletionChoice> Choices { get; init; }
|
||||
|
||||
[JsonPropertyName("usage")]
|
||||
public required OpenAiUsageInfo Usage { get; init; }
|
||||
}
|
||||
|
||||
public sealed record OpenAiChatCompletionChoice
|
||||
{
|
||||
[JsonPropertyName("index")]
|
||||
public required int Index { get; init; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public required OpenAiChatMessage Message { get; init; }
|
||||
|
||||
[JsonPropertyName("finish_reason")]
|
||||
public required string FinishReason { get; init; }
|
||||
}
|
||||
|
||||
public sealed record OpenAiChatMessage
|
||||
{
|
||||
[JsonPropertyName("role")]
|
||||
public required string Role { get; init; }
|
||||
|
||||
[JsonPropertyName("content")]
|
||||
public required string Content { get; init; }
|
||||
}
|
||||
|
||||
public sealed record OpenAiUsageInfo
|
||||
{
|
||||
[JsonPropertyName("prompt_tokens")]
|
||||
public required int PromptTokens { get; init; }
|
||||
|
||||
[JsonPropertyName("completion_tokens")]
|
||||
public required int CompletionTokens { get; init; }
|
||||
|
||||
[JsonPropertyName("total_tokens")]
|
||||
public required int TotalTokens { get; init; }
|
||||
}
|
||||
@@ -14,9 +14,11 @@ using StellaOps.AdvisoryAI.Diagnostics;
|
||||
using StellaOps.AdvisoryAI.Evidence;
|
||||
using StellaOps.AdvisoryAI.Explanation;
|
||||
using StellaOps.AdvisoryAI.Hosting;
|
||||
using StellaOps.AdvisoryAI.Inference.LlmProviders;
|
||||
using StellaOps.AdvisoryAI.Metrics;
|
||||
using StellaOps.AdvisoryAI.Orchestration;
|
||||
using StellaOps.AdvisoryAI.Outputs;
|
||||
using StellaOps.AdvisoryAI.Plugin.Unified;
|
||||
using StellaOps.AdvisoryAI.PolicyStudio;
|
||||
using StellaOps.AdvisoryAI.Queue;
|
||||
using StellaOps.AdvisoryAI.Remediation;
|
||||
@@ -41,6 +43,20 @@ builder.Configuration
|
||||
.AddEnvironmentVariables(prefix: "ADVISORYAI__");
|
||||
|
||||
builder.Services.AddAdvisoryAiCore(builder.Configuration);
|
||||
|
||||
var llmAdapterEnabled = builder.Configuration.GetValue<bool?>("AdvisoryAI:Adapters:Llm:Enabled") ?? false;
|
||||
if (llmAdapterEnabled)
|
||||
{
|
||||
var llmProviderConfigDirectory = builder.Configuration["AdvisoryAI:LlmProviders:ConfigDirectory"];
|
||||
if (string.IsNullOrWhiteSpace(llmProviderConfigDirectory))
|
||||
{
|
||||
llmProviderConfigDirectory = Path.Combine(builder.Environment.ContentRootPath, "etc", "llm-providers");
|
||||
}
|
||||
|
||||
builder.Services.AddLlmProviderPlugins(llmProviderConfigDirectory);
|
||||
builder.Services.AddUnifiedLlmPlugins();
|
||||
}
|
||||
|
||||
builder.Services.AddAdvisoryChat(builder.Configuration);
|
||||
builder.Services.TryAddSingleton<ICodexCompanionService, CodexZastavaCompanionService>();
|
||||
|
||||
@@ -73,11 +89,11 @@ builder.Services.AddOpenApi();
|
||||
builder.Services.AddProblemDetails();
|
||||
|
||||
// Stella Router integration
|
||||
var routerOptions = builder.Configuration.GetSection("AdvisoryAI:Router").Get<StellaRouterOptionsBase>();
|
||||
builder.Services.TryAddStellaRouter(
|
||||
var routerEnabled = builder.Services.AddRouterMicroservice(
|
||||
builder.Configuration,
|
||||
serviceName: "advisoryai",
|
||||
version: typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0",
|
||||
routerOptions: routerOptions);
|
||||
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
|
||||
routerOptionsSection: "Router");
|
||||
|
||||
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
|
||||
|
||||
@@ -121,7 +137,7 @@ if (app.Environment.IsDevelopment())
|
||||
|
||||
app.UseStellaOpsCors();
|
||||
app.UseRateLimiter();
|
||||
app.TryUseStellaRouter(routerOptions);
|
||||
app.TryUseStellaRouter(routerEnabled);
|
||||
|
||||
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
|
||||
|
||||
@@ -214,8 +230,17 @@ app.MapAttestationEndpoints();
|
||||
// Evidence Pack endpoints (Sprint: SPRINT_20260109_011_005 Task: EVPK-010)
|
||||
app.MapEvidencePackEndpoints();
|
||||
|
||||
// AdvisoryAI Knowledge Search endpoints (Sprint: SPRINT_20260222_051)
|
||||
app.MapKnowledgeSearchEndpoints();
|
||||
|
||||
if (llmAdapterEnabled)
|
||||
{
|
||||
// Unified LLM adapter exposure endpoints (RVM-08)
|
||||
app.MapLlmAdapterEndpoints();
|
||||
}
|
||||
|
||||
// Refresh Router endpoint cache
|
||||
app.TryRefreshStellaRouterEndpoints(routerOptions);
|
||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -812,7 +837,8 @@ static string GetTenantId(HttpContext context)
|
||||
|
||||
static string GetUserId(HttpContext context)
|
||||
{
|
||||
return context.Request.Headers.TryGetValue("X-StellaOps-User", out var value)
|
||||
return context.Request.Headers.TryGetValue("X-StellaOps-Actor", out var value)
|
||||
&& !string.IsNullOrWhiteSpace(value.ToString())
|
||||
? value.ToString()
|
||||
: "anonymous";
|
||||
}
|
||||
@@ -1031,10 +1057,8 @@ static async Task<IResult> HandleCreateConversation(
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
// Get user ID from header
|
||||
var userId = httpContext.Request.Headers.TryGetValue("X-StellaOps-User", out var userHeader)
|
||||
? userHeader.ToString()
|
||||
: "anonymous";
|
||||
// Get user ID from gateway-protected Actor header
|
||||
var userId = GetUserId(httpContext);
|
||||
|
||||
var conversationRequest = new ConversationRequest
|
||||
{
|
||||
@@ -1237,8 +1261,9 @@ static async Task<IResult> HandleListConversations(
|
||||
? tenantHeader.ToString()
|
||||
: "default");
|
||||
|
||||
// Get user from header for filtering
|
||||
var userId = httpContext.Request.Headers.TryGetValue("X-StellaOps-User", out var userHeader)
|
||||
// Get user from gateway-protected Actor header for filtering
|
||||
var userId = httpContext.Request.Headers.TryGetValue("X-StellaOps-Actor", out var userHeader)
|
||||
&& !string.IsNullOrWhiteSpace(userHeader.ToString())
|
||||
? userHeader.ToString()
|
||||
: null;
|
||||
|
||||
@@ -1265,40 +1290,19 @@ static async Task<IResult> HandleListConversations(
|
||||
|
||||
static bool EnsureChatAuthorized(HttpContext context)
|
||||
{
|
||||
var tokens = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (context.Request.Headers.TryGetValue("X-StellaOps-Scopes", out var scopes))
|
||||
if (!context.Request.Headers.TryGetValue("X-StellaOps-Scopes", out var scopes))
|
||||
{
|
||||
AddHeaderTokens(tokens, scopes);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (context.Request.Headers.TryGetValue("X-StellaOps-Roles", out var roles))
|
||||
{
|
||||
AddHeaderTokens(tokens, roles);
|
||||
}
|
||||
var allowed = scopes
|
||||
.SelectMany(value => value?.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? [])
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return tokens.Contains("advisory:run")
|
||||
|| tokens.Contains("advisory:chat")
|
||||
|| tokens.Contains("chat:user")
|
||||
|| tokens.Contains("chat:admin");
|
||||
}
|
||||
|
||||
static void AddHeaderTokens(HashSet<string> target, IEnumerable<string> values)
|
||||
{
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var token in value.Split(
|
||||
new[] { ' ', ',' },
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
target.Add(token);
|
||||
}
|
||||
}
|
||||
return allowed.Contains("advisory:run")
|
||||
|| allowed.Contains("advisory:chat")
|
||||
|| allowed.Contains("chat:user")
|
||||
|| allowed.Contains("chat:admin");
|
||||
}
|
||||
|
||||
static string GeneratePlaceholderResponse(string userMessage)
|
||||
@@ -1361,3 +1365,6 @@ namespace StellaOps.AdvisoryAI.WebService
|
||||
{
|
||||
public partial class Program { }
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ public sealed class HeaderBasedAuthorizationService : IAuthorizationService
|
||||
{
|
||||
private const string ScopesHeader = "X-StellaOps-Scopes";
|
||||
private const string TenantHeader = "X-StellaOps-Tenant";
|
||||
private const string UserHeader = "X-StellaOps-User";
|
||||
private const string UserHeader = "X-StellaOps-Actor";
|
||||
|
||||
public bool IsAuthorized(HttpContext context, AdvisoryTaskType taskType)
|
||||
{
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.AdvisoryAI\StellaOps.AdvisoryAI.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.AdvisoryAI.Hosting\StellaOps.AdvisoryAI.Hosting.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.AdvisoryAI.Plugin.Unified\StellaOps.AdvisoryAI.Plugin.Unified.csproj" />
|
||||
<ProjectReference Include="..\..\Router/__Libraries/StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
|
||||
<!-- AI Attestations (Sprint: SPRINT_20260109_011_001) -->
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.AdvisoryAI.Attestation\StellaOps.AdvisoryAI.Attestation.csproj" />
|
||||
@@ -21,4 +22,8 @@
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="StellaOpsReleaseVersion">
|
||||
<Version>1.0.0-alpha1</Version>
|
||||
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
{
|
||||
"AdvisoryAI": {
|
||||
"SbomBaseAddress": "http://localhost:5210/",
|
||||
"Queue": {
|
||||
"DirectoryPath": "../var/advisory-ai-queue"
|
||||
}
|
||||
},
|
||||
"AdvisoryAI": {
|
||||
"SbomBaseAddress": "http://localhost:5210/",
|
||||
"Queue": {
|
||||
"DirectoryPath": "../var/advisory-ai-queue"
|
||||
},
|
||||
"Adapters": {
|
||||
"Llm": {
|
||||
"Enabled": false
|
||||
}
|
||||
},
|
||||
"LlmProviders": {
|
||||
"ConfigDirectory": "etc/llm-providers"
|
||||
}
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
|
||||
internal sealed record DoctorSearchSeedEntry(
|
||||
string CheckCode,
|
||||
string Title,
|
||||
string Severity,
|
||||
string Description,
|
||||
string Remediation,
|
||||
string RunCommand,
|
||||
IReadOnlyList<string> Symptoms,
|
||||
IReadOnlyList<string> Tags,
|
||||
IReadOnlyList<string> References);
|
||||
|
||||
internal static class DoctorSearchSeedLoader
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public static IReadOnlyList<DoctorSearchSeedEntry> Load(string absolutePath)
|
||||
{
|
||||
if (!File.Exists(absolutePath))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
using var stream = File.OpenRead(absolutePath);
|
||||
var entries = JsonSerializer.Deserialize<List<DoctorSearchSeedEntry>>(stream, JsonOptions) ?? [];
|
||||
|
||||
return entries
|
||||
.Where(static entry => !string.IsNullOrWhiteSpace(entry.CheckCode))
|
||||
.OrderBy(static entry => entry.CheckCode, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
|
||||
public interface IKnowledgeIndexer
|
||||
{
|
||||
Task<KnowledgeRebuildSummary> RebuildAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
|
||||
public interface IKnowledgeSearchBenchmarkDatasetGenerator
|
||||
{
|
||||
Task<KnowledgeBenchmarkDataset> GenerateAsync(KnowledgeBenchmarkDatasetOptions options, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
|
||||
public interface IKnowledgeSearchBenchmarkRunner
|
||||
{
|
||||
Task<KnowledgeBenchmarkRunResult> RunAsync(
|
||||
KnowledgeBenchmarkDataset dataset,
|
||||
KnowledgeBenchmarkRunOptions options,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
|
||||
public interface IKnowledgeSearchService
|
||||
{
|
||||
Task<KnowledgeSearchResponse> SearchAsync(KnowledgeSearchRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
|
||||
internal interface IKnowledgeSearchStore
|
||||
{
|
||||
Task EnsureSchemaAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task ReplaceIndexAsync(KnowledgeIndexSnapshot snapshot, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<KnowledgeChunkRow>> SearchFtsAsync(
|
||||
string query,
|
||||
KnowledgeSearchFilter? filters,
|
||||
int take,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<KnowledgeChunkRow>> LoadVectorCandidatesAsync(
|
||||
float[] queryEmbedding,
|
||||
KnowledgeSearchFilter? filters,
|
||||
int take,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,556 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
|
||||
internal sealed class KnowledgeSearchBenchmarkDatasetGenerator : IKnowledgeSearchBenchmarkDatasetGenerator
|
||||
{
|
||||
private static readonly Regex HeadingPattern = new(
|
||||
"^#{2,3}\\s+(.+?)\\s*$",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly string[] OpenApiMethods = ["get", "put", "post", "delete", "patch", "head", "options", "trace"];
|
||||
|
||||
private static readonly IReadOnlyList<DoctorSearchSeedEntry> DefaultDoctorSeeds =
|
||||
[
|
||||
new(
|
||||
"check.core.disk.space",
|
||||
"Disk space availability",
|
||||
"high",
|
||||
"Low disk space can block ingestion pipelines and worker execution.",
|
||||
"Free disk space and verify retention settings.",
|
||||
"stella doctor run --check check.core.disk.space",
|
||||
["no space left on device", "disk full", "write failure"],
|
||||
["doctor", "storage", "core"],
|
||||
["docs/operations/devops/runbooks/deployment-upgrade.md"]),
|
||||
new(
|
||||
"check.core.db.connectivity",
|
||||
"PostgreSQL connectivity",
|
||||
"high",
|
||||
"Doctor failed to connect to PostgreSQL or connection health checks timed out.",
|
||||
"Validate credentials, network reachability, and TLS settings.",
|
||||
"stella doctor run --check check.core.db.connectivity",
|
||||
["database unavailable", "connection refused", "timeout expired"],
|
||||
["doctor", "database", "connectivity"],
|
||||
["docs/INSTALL_GUIDE.md"]),
|
||||
new(
|
||||
"check.security.oidc.readiness",
|
||||
"OIDC readiness",
|
||||
"warn",
|
||||
"OIDC prerequisites are missing or identity issuer metadata is not reachable.",
|
||||
"Verify issuer URL, JWKS availability, and Authority client configuration.",
|
||||
"stella doctor run --check check.security.oidc.readiness",
|
||||
["oidc setup", "invalid issuer", "jwks"],
|
||||
["doctor", "security", "oidc"],
|
||||
["docs/modules/authority/architecture.md"]),
|
||||
new(
|
||||
"check.router.gateway.routes",
|
||||
"Router route registration",
|
||||
"warn",
|
||||
"Expected gateway routes were not registered or health probes failed.",
|
||||
"Inspect route tables and refresh router registration.",
|
||||
"stella doctor run --check check.router.gateway.routes",
|
||||
["route missing", "404 on expected endpoint", "gateway routing"],
|
||||
["doctor", "router", "gateway"],
|
||||
["docs/modules/router/README.md"]),
|
||||
new(
|
||||
"check.integrations.secrets.binding",
|
||||
"Integration secret binding",
|
||||
"medium",
|
||||
"Integration connectors cannot resolve configured secrets.",
|
||||
"Validate secret provider configuration and rotate invalid credentials.",
|
||||
"stella doctor run --check check.integrations.secrets.binding",
|
||||
["secret missing", "invalid credential", "auth failed"],
|
||||
["doctor", "integrations", "secrets"],
|
||||
["docs/modules/platform/architecture-overview.md"]),
|
||||
];
|
||||
|
||||
private readonly KnowledgeSearchOptions _options;
|
||||
private readonly ILogger<KnowledgeSearchBenchmarkDatasetGenerator> _logger;
|
||||
|
||||
public KnowledgeSearchBenchmarkDatasetGenerator(
|
||||
IOptions<KnowledgeSearchOptions> options,
|
||||
ILogger<KnowledgeSearchBenchmarkDatasetGenerator> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options.Value ?? new KnowledgeSearchOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public Task<KnowledgeBenchmarkDataset> GenerateAsync(
|
||||
KnowledgeBenchmarkDatasetOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var repositoryRoot = ResolveRepositoryRoot();
|
||||
var targets = new List<BenchmarkTarget>(2048);
|
||||
targets.AddRange(LoadMarkdownTargets(repositoryRoot, cancellationToken));
|
||||
targets.AddRange(LoadOpenApiTargets(repositoryRoot, cancellationToken));
|
||||
targets.AddRange(LoadDoctorTargets(repositoryRoot, cancellationToken));
|
||||
|
||||
if (targets.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("Knowledge benchmark dataset generation produced no targets.");
|
||||
return Task.FromResult(
|
||||
new KnowledgeBenchmarkDataset(
|
||||
_options.Product,
|
||||
_options.Version,
|
||||
DateTimeOffset.UtcNow,
|
||||
[]));
|
||||
}
|
||||
|
||||
var orderedTargets = targets
|
||||
.OrderBy(static target => target.Type, StringComparer.Ordinal)
|
||||
.ThenBy(static target => target.TargetId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var cases = BuildCases(orderedTargets, options, cancellationToken);
|
||||
var dataset = new KnowledgeBenchmarkDataset(
|
||||
_options.Product,
|
||||
_options.Version,
|
||||
DateTimeOffset.UtcNow,
|
||||
cases);
|
||||
|
||||
return Task.FromResult(dataset);
|
||||
}
|
||||
|
||||
private string ResolveRepositoryRoot()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.RepositoryRoot))
|
||||
{
|
||||
return Directory.GetCurrentDirectory();
|
||||
}
|
||||
|
||||
if (Path.IsPathRooted(_options.RepositoryRoot))
|
||||
{
|
||||
return Path.GetFullPath(_options.RepositoryRoot);
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), _options.RepositoryRoot));
|
||||
}
|
||||
|
||||
private IReadOnlyList<BenchmarkTarget> LoadMarkdownTargets(string repositoryRoot, CancellationToken cancellationToken)
|
||||
{
|
||||
var targets = new List<BenchmarkTarget>();
|
||||
foreach (var filePath in EnumerateMarkdownFiles(repositoryRoot))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var relativePath = ToRelativeRepositoryPath(repositoryRoot, filePath);
|
||||
var lines = File.ReadAllLines(filePath);
|
||||
var slugCounter = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (!TryReadHeading(line, out var heading))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var slug = KnowledgeSearchText.Slugify(heading);
|
||||
var count = slugCounter.TryGetValue(slug, out var existing) ? existing + 1 : 1;
|
||||
slugCounter[slug] = count;
|
||||
var anchor = count == 1 ? slug : $"{slug}-{count}";
|
||||
|
||||
var title = heading.Trim();
|
||||
var id = $"docs:{relativePath}:{anchor}";
|
||||
var expected = new KnowledgeBenchmarkExpectedTarget
|
||||
{
|
||||
Type = "docs",
|
||||
Path = relativePath,
|
||||
Anchor = anchor,
|
||||
};
|
||||
|
||||
var hints = BuildDocQueryHints(title, relativePath);
|
||||
targets.Add(new BenchmarkTarget(id, "docs", title, expected, hints));
|
||||
}
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
private IReadOnlyList<BenchmarkTarget> LoadOpenApiTargets(string repositoryRoot, CancellationToken cancellationToken)
|
||||
{
|
||||
var targets = new List<BenchmarkTarget>();
|
||||
foreach (var filePath in EnumerateOpenApiFiles(repositoryRoot))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
JsonDocument document;
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(filePath);
|
||||
document = JsonDocument.Parse(stream);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or JsonException)
|
||||
{
|
||||
_logger.LogDebug(ex, "Skipping benchmark OpenAPI file {Path}.", filePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
using (document)
|
||||
{
|
||||
if (!document.RootElement.TryGetProperty("paths", out var pathsElement) || pathsElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var relativePath = ToRelativeRepositoryPath(repositoryRoot, filePath);
|
||||
var service = DeriveOpenApiServiceName(relativePath, document.RootElement);
|
||||
|
||||
foreach (var pathProperty in pathsElement.EnumerateObject().OrderBy(static item => item.Name, StringComparer.Ordinal))
|
||||
{
|
||||
if (pathProperty.Value.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var methodProperty in pathProperty.Value.EnumerateObject().OrderBy(static item => item.Name, StringComparer.Ordinal))
|
||||
{
|
||||
if (!OpenApiMethods.Contains(methodProperty.Name, StringComparer.OrdinalIgnoreCase) || methodProperty.Value.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var method = methodProperty.Name.ToUpperInvariant();
|
||||
var path = pathProperty.Name;
|
||||
var operationId = TryGetString(methodProperty.Value, "operationId");
|
||||
var summary = TryGetString(methodProperty.Value, "summary")
|
||||
?? TryGetString(methodProperty.Value, "description")
|
||||
?? $"{method} {path}";
|
||||
|
||||
var title = string.IsNullOrWhiteSpace(operationId) ? $"{method} {path}" : operationId!;
|
||||
var id = $"api:{service}:{method}:{path}:{operationId}";
|
||||
var expected = new KnowledgeBenchmarkExpectedTarget
|
||||
{
|
||||
Type = "api",
|
||||
Method = method,
|
||||
ApiPath = path,
|
||||
OperationId = operationId,
|
||||
};
|
||||
|
||||
var hints = BuildApiQueryHints(service, method, path, title, summary, ExtractStringArray(methodProperty.Value, "tags"));
|
||||
targets.Add(new BenchmarkTarget(id, "api", title, expected, hints));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
private IReadOnlyList<BenchmarkTarget> LoadDoctorTargets(string repositoryRoot, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var seedPath = ResolvePath(repositoryRoot, _options.DoctorSeedPath);
|
||||
var seeds = DoctorSearchSeedLoader.Load(seedPath);
|
||||
if (seeds.Count == 0)
|
||||
{
|
||||
seeds = DefaultDoctorSeeds;
|
||||
}
|
||||
|
||||
var targets = new List<BenchmarkTarget>(seeds.Count);
|
||||
foreach (var entry in seeds.OrderBy(static seed => seed.CheckCode, StringComparer.Ordinal))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var expected = new KnowledgeBenchmarkExpectedTarget
|
||||
{
|
||||
Type = "doctor",
|
||||
CheckCode = entry.CheckCode,
|
||||
};
|
||||
|
||||
var hints = BuildDoctorQueryHints(entry);
|
||||
targets.Add(new BenchmarkTarget(
|
||||
$"doctor:{entry.CheckCode}",
|
||||
"doctor",
|
||||
entry.Title,
|
||||
expected,
|
||||
hints));
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<KnowledgeBenchmarkCase> BuildCases(
|
||||
IReadOnlyList<BenchmarkTarget> targets,
|
||||
KnowledgeBenchmarkDatasetOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var minCount = Math.Max(1, options.MinQueryCount);
|
||||
var maxCount = Math.Max(minCount, options.MaxQueryCount);
|
||||
var variantsPerTarget = Math.Clamp(options.VariantsPerTarget, 1, 16);
|
||||
|
||||
var cases = new List<KnowledgeBenchmarkCase>(Math.Min(maxCount, targets.Count * variantsPerTarget * 2));
|
||||
var seenQueries = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var target in targets)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var added = 0;
|
||||
foreach (var query in target.QueryHints)
|
||||
{
|
||||
var normalized = KnowledgeSearchText.NormalizeWhitespace(query);
|
||||
if (string.IsNullOrWhiteSpace(normalized) || !seenQueries.Add(normalized))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var caseId = KnowledgeSearchText.StableId("bench-case", target.TargetId, added.ToString());
|
||||
cases.Add(new KnowledgeBenchmarkCase(caseId, normalized, target.Type, target.Expected));
|
||||
added++;
|
||||
if (added >= variantsPerTarget || cases.Count >= maxCount)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (cases.Count >= maxCount)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var expansionTemplates = new[]
|
||||
{
|
||||
"how to fix {0}",
|
||||
"troubleshoot {0}",
|
||||
"stella ops {0}",
|
||||
"{0} remediation",
|
||||
"{0} runbook",
|
||||
};
|
||||
|
||||
var expansionIndex = 0;
|
||||
while (cases.Count < minCount && cases.Count < maxCount && cases.Count > 0)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var baseCase = cases[expansionIndex % cases.Count];
|
||||
var template = expansionTemplates[expansionIndex % expansionTemplates.Length];
|
||||
var query = KnowledgeSearchText.NormalizeWhitespace(string.Format(template, baseCase.Query.ToLowerInvariant()));
|
||||
expansionIndex++;
|
||||
|
||||
if (!seenQueries.Add(query))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var caseId = KnowledgeSearchText.StableId("bench-case", baseCase.CaseId, expansionIndex.ToString());
|
||||
cases.Add(baseCase with
|
||||
{
|
||||
CaseId = caseId,
|
||||
Query = query,
|
||||
});
|
||||
}
|
||||
|
||||
return cases
|
||||
.OrderBy(static item => item.CaseId, StringComparer.Ordinal)
|
||||
.ThenBy(static item => item.Query, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private IEnumerable<string> EnumerateMarkdownFiles(string repositoryRoot)
|
||||
{
|
||||
foreach (var root in _options.MarkdownRoots.Where(static item => !string.IsNullOrWhiteSpace(item)))
|
||||
{
|
||||
var absoluteRoot = ResolvePath(repositoryRoot, root);
|
||||
if (!Directory.Exists(absoluteRoot))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var filePath in Directory.EnumerateFiles(absoluteRoot, "*.md", SearchOption.AllDirectories))
|
||||
{
|
||||
var relativePath = ToRelativeRepositoryPath(repositoryRoot, filePath);
|
||||
if (relativePath.StartsWith("docs-archived/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return filePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<string> EnumerateOpenApiFiles(string repositoryRoot)
|
||||
{
|
||||
foreach (var root in _options.OpenApiRoots.Where(static item => !string.IsNullOrWhiteSpace(item)))
|
||||
{
|
||||
var absoluteRoot = ResolvePath(repositoryRoot, root);
|
||||
if (!Directory.Exists(absoluteRoot))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var filePath in Directory.EnumerateFiles(absoluteRoot, "openapi.json", SearchOption.AllDirectories))
|
||||
{
|
||||
yield return filePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolvePath(string repositoryRoot, string configuredPath)
|
||||
{
|
||||
if (Path.IsPathRooted(configuredPath))
|
||||
{
|
||||
return Path.GetFullPath(configuredPath);
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(repositoryRoot, configuredPath));
|
||||
}
|
||||
|
||||
private static string ToRelativeRepositoryPath(string repositoryRoot, string absolutePath)
|
||||
{
|
||||
var relative = Path.GetRelativePath(repositoryRoot, absolutePath);
|
||||
return relative.Replace('\\', '/');
|
||||
}
|
||||
|
||||
private static bool TryReadHeading(string line, out string heading)
|
||||
{
|
||||
var match = HeadingPattern.Match(line);
|
||||
if (!match.Success)
|
||||
{
|
||||
heading = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
heading = Regex.Replace(match.Groups[1].Value, "\\s+", " ").Trim();
|
||||
if (string.IsNullOrWhiteSpace(heading))
|
||||
{
|
||||
heading = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BuildDocQueryHints(string title, string relativePath)
|
||||
{
|
||||
var fileName = Path.GetFileNameWithoutExtension(relativePath);
|
||||
var hints = new SortedSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
title,
|
||||
$"how do i {title.ToLowerInvariant()}",
|
||||
$"runbook {title.ToLowerInvariant()}",
|
||||
$"{fileName} {title}".ToLowerInvariant(),
|
||||
$"{relativePath} {title}".ToLowerInvariant(),
|
||||
};
|
||||
|
||||
return hints.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BuildApiQueryHints(
|
||||
string service,
|
||||
string method,
|
||||
string path,
|
||||
string title,
|
||||
string summary,
|
||||
IReadOnlyList<string> tags)
|
||||
{
|
||||
var hints = new SortedSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
$"{method} {path}",
|
||||
$"{service} {method} {path}",
|
||||
title,
|
||||
$"endpoint for {summary.ToLowerInvariant()}",
|
||||
$"api {service} {summary.ToLowerInvariant()}",
|
||||
};
|
||||
|
||||
foreach (var tag in tags.Where(static item => !string.IsNullOrWhiteSpace(item)))
|
||||
{
|
||||
hints.Add($"{service} endpoint {tag}".ToLowerInvariant());
|
||||
}
|
||||
|
||||
return hints.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BuildDoctorQueryHints(DoctorSearchSeedEntry entry)
|
||||
{
|
||||
var hints = new SortedSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
entry.CheckCode,
|
||||
$"doctor {entry.Title}".ToLowerInvariant(),
|
||||
$"run check {entry.CheckCode}".ToLowerInvariant(),
|
||||
entry.Title,
|
||||
};
|
||||
|
||||
foreach (var symptom in entry.Symptoms.Where(static item => !string.IsNullOrWhiteSpace(item)))
|
||||
{
|
||||
hints.Add(symptom.Trim());
|
||||
}
|
||||
|
||||
return hints.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static string DeriveOpenApiServiceName(string relativePath, JsonElement root)
|
||||
{
|
||||
var title = TryGetNestedString(root, "info", "title");
|
||||
if (!string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
return KnowledgeSearchText.Slugify(title);
|
||||
}
|
||||
|
||||
var segments = relativePath.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (segments.Length >= 2)
|
||||
{
|
||||
return KnowledgeSearchText.Slugify(segments[^2]);
|
||||
}
|
||||
|
||||
return "service";
|
||||
}
|
||||
|
||||
private static string? TryGetString(JsonElement element, string propertyName)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(propertyName, out var property))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return property.ValueKind == JsonValueKind.String ? property.GetString() : null;
|
||||
}
|
||||
|
||||
private static string? TryGetNestedString(JsonElement element, string parentProperty, string childProperty)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object ||
|
||||
!element.TryGetProperty(parentProperty, out var parent) ||
|
||||
parent.ValueKind != JsonValueKind.Object ||
|
||||
!parent.TryGetProperty(childProperty, out var child) ||
|
||||
child.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return child.GetString();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractStringArray(JsonElement element, string propertyName)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object ||
|
||||
!element.TryGetProperty(propertyName, out var property) ||
|
||||
property.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return property.EnumerateArray()
|
||||
.Where(static item => item.ValueKind == JsonValueKind.String)
|
||||
.Select(static item => item.GetString())
|
||||
.Where(static item => !string.IsNullOrWhiteSpace(item))
|
||||
.Select(static item => item!.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static item => item, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private sealed record BenchmarkTarget(
|
||||
string TargetId,
|
||||
string Type,
|
||||
string Title,
|
||||
KnowledgeBenchmarkExpectedTarget Expected,
|
||||
IReadOnlyList<string> QueryHints);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
|
||||
public static class KnowledgeSearchBenchmarkJson
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public static async Task SaveDatasetAsync(
|
||||
KnowledgeBenchmarkDataset dataset,
|
||||
string absolutePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dataset);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(absolutePath);
|
||||
|
||||
var directory = Path.GetDirectoryName(absolutePath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
await using var stream = File.Create(absolutePath);
|
||||
await JsonSerializer.SerializeAsync(stream, dataset, JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task<KnowledgeBenchmarkDataset> LoadDatasetAsync(
|
||||
string absolutePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(absolutePath);
|
||||
|
||||
await using var stream = File.OpenRead(absolutePath);
|
||||
var dataset = await JsonSerializer.DeserializeAsync<KnowledgeBenchmarkDataset>(stream, JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (dataset is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to deserialize benchmark dataset from {absolutePath}.");
|
||||
}
|
||||
|
||||
return dataset;
|
||||
}
|
||||
|
||||
public static async Task SaveRunResultAsync(
|
||||
KnowledgeBenchmarkRunResult result,
|
||||
string absolutePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(absolutePath);
|
||||
|
||||
var directory = Path.GetDirectoryName(absolutePath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
await using var stream = File.Create(absolutePath);
|
||||
await JsonSerializer.SerializeAsync(stream, result, JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
|
||||
public sealed record KnowledgeBenchmarkDatasetOptions
|
||||
{
|
||||
public int MinQueryCount { get; init; } = 1000;
|
||||
|
||||
public int MaxQueryCount { get; init; } = 10000;
|
||||
|
||||
public int VariantsPerTarget { get; init; } = 8;
|
||||
}
|
||||
|
||||
public sealed record KnowledgeBenchmarkDataset(
|
||||
string Product,
|
||||
string Version,
|
||||
DateTimeOffset GeneratedAtUtc,
|
||||
IReadOnlyList<KnowledgeBenchmarkCase> Cases);
|
||||
|
||||
public sealed record KnowledgeBenchmarkCase(
|
||||
string CaseId,
|
||||
string Query,
|
||||
string Type,
|
||||
KnowledgeBenchmarkExpectedTarget Expected);
|
||||
|
||||
public sealed record KnowledgeBenchmarkExpectedTarget
|
||||
{
|
||||
public string Type { get; init; } = "docs";
|
||||
|
||||
public string? Path { get; init; }
|
||||
|
||||
public string? Anchor { get; init; }
|
||||
|
||||
public string? Method { get; init; }
|
||||
|
||||
[JsonPropertyName("pathTemplate")]
|
||||
public string? ApiPath { get; init; }
|
||||
|
||||
public string? OperationId { get; init; }
|
||||
|
||||
public string? CheckCode { get; init; }
|
||||
}
|
||||
|
||||
public sealed record KnowledgeBenchmarkRunOptions
|
||||
{
|
||||
public int TopK { get; init; } = 5;
|
||||
|
||||
public int? MaxQueries { get; init; }
|
||||
|
||||
public int StabilityPasses { get; init; } = 2;
|
||||
|
||||
public double MinRecallAtK { get; init; } = 0.85d;
|
||||
|
||||
public double MaxP95LatencyMs { get; init; } = 500d;
|
||||
}
|
||||
|
||||
public sealed record KnowledgeBenchmarkRunResult(
|
||||
KnowledgeBenchmarkMetrics Overall,
|
||||
IReadOnlyDictionary<string, KnowledgeBenchmarkMetrics> ByType,
|
||||
double StabilityRate,
|
||||
bool Passed,
|
||||
IReadOnlyList<KnowledgeBenchmarkCaseResult> Cases);
|
||||
|
||||
public sealed record KnowledgeBenchmarkMetrics(
|
||||
int Total,
|
||||
int Matched,
|
||||
double RecallAtK,
|
||||
double P50LatencyMs,
|
||||
double P95LatencyMs);
|
||||
|
||||
public sealed record KnowledgeBenchmarkCaseResult(
|
||||
string CaseId,
|
||||
string Query,
|
||||
string Type,
|
||||
bool Matched,
|
||||
int Rank,
|
||||
double LatencyMs,
|
||||
string? TopReference,
|
||||
string ExpectedReference);
|
||||
@@ -0,0 +1,213 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
|
||||
internal sealed class KnowledgeSearchBenchmarkRunner : IKnowledgeSearchBenchmarkRunner
|
||||
{
|
||||
private readonly IKnowledgeSearchService _searchService;
|
||||
private readonly ILogger<KnowledgeSearchBenchmarkRunner> _logger;
|
||||
|
||||
public KnowledgeSearchBenchmarkRunner(
|
||||
IKnowledgeSearchService searchService,
|
||||
ILogger<KnowledgeSearchBenchmarkRunner> logger)
|
||||
{
|
||||
_searchService = searchService ?? throw new ArgumentNullException(nameof(searchService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<KnowledgeBenchmarkRunResult> RunAsync(
|
||||
KnowledgeBenchmarkDataset dataset,
|
||||
KnowledgeBenchmarkRunOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dataset);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var topK = Math.Clamp(options.TopK, 1, 100);
|
||||
var cases = options.MaxQueries.HasValue
|
||||
? dataset.Cases.Take(Math.Max(1, options.MaxQueries.Value)).ToArray()
|
||||
: dataset.Cases.ToArray();
|
||||
|
||||
if (cases.Length == 0)
|
||||
{
|
||||
var empty = new KnowledgeBenchmarkMetrics(0, 0, 0d, 0d, 0d);
|
||||
return new KnowledgeBenchmarkRunResult(empty, new Dictionary<string, KnowledgeBenchmarkMetrics>(StringComparer.Ordinal), 1d, false, []);
|
||||
}
|
||||
|
||||
var firstPass = await ExecutePassAsync(cases, topK, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
double stabilityRate;
|
||||
if (options.StabilityPasses > 1)
|
||||
{
|
||||
var secondPass = await ExecutePassAsync(cases, topK, cancellationToken).ConfigureAwait(false);
|
||||
var stable = 0;
|
||||
for (var index = 0; index < firstPass.Count; index++)
|
||||
{
|
||||
if (string.Equals(firstPass[index].TopReference, secondPass[index].TopReference, StringComparison.Ordinal))
|
||||
{
|
||||
stable++;
|
||||
}
|
||||
}
|
||||
|
||||
stabilityRate = stable / (double)firstPass.Count;
|
||||
}
|
||||
else
|
||||
{
|
||||
stabilityRate = 1d;
|
||||
}
|
||||
|
||||
var overall = BuildMetrics(firstPass);
|
||||
var byType = firstPass
|
||||
.GroupBy(static item => item.Type, StringComparer.Ordinal)
|
||||
.OrderBy(static item => item.Key, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
static group => group.Key,
|
||||
static group => BuildMetrics(group.ToArray()),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
var passed = overall.RecallAtK >= options.MinRecallAtK &&
|
||||
overall.P95LatencyMs <= options.MaxP95LatencyMs;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Knowledge benchmark completed. cases={Cases}, recall@k={RecallAtK:F4}, p95={P95:F2}ms, stability={Stability:F4}, passed={Passed}",
|
||||
firstPass.Count,
|
||||
overall.RecallAtK,
|
||||
overall.P95LatencyMs,
|
||||
stabilityRate,
|
||||
passed);
|
||||
|
||||
return new KnowledgeBenchmarkRunResult(overall, byType, stabilityRate, passed, firstPass);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<KnowledgeBenchmarkCaseResult>> ExecutePassAsync(
|
||||
IReadOnlyList<KnowledgeBenchmarkCase> cases,
|
||||
int topK,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new List<KnowledgeBenchmarkCaseResult>(cases.Count);
|
||||
foreach (var testCase in cases)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var response = await _searchService.SearchAsync(
|
||||
new KnowledgeSearchRequest(testCase.Query, topK, null, IncludeDebug: false),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
stopwatch.Stop();
|
||||
|
||||
var expectedReference = BuildExpectedReference(testCase.Expected);
|
||||
var topReference = response.Results.Count > 0 ? BuildResultReference(response.Results[0]) : null;
|
||||
|
||||
var rank = 0;
|
||||
for (var index = 0; index < response.Results.Count; index++)
|
||||
{
|
||||
if (IsMatch(response.Results[index], testCase.Expected))
|
||||
{
|
||||
rank = index + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var latencyMs = response.Diagnostics.DurationMs > 0
|
||||
? response.Diagnostics.DurationMs
|
||||
: stopwatch.Elapsed.TotalMilliseconds;
|
||||
|
||||
results.Add(new KnowledgeBenchmarkCaseResult(
|
||||
testCase.CaseId,
|
||||
testCase.Query,
|
||||
testCase.Type,
|
||||
rank > 0,
|
||||
rank,
|
||||
latencyMs,
|
||||
topReference,
|
||||
expectedReference));
|
||||
}
|
||||
|
||||
return results.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static KnowledgeBenchmarkMetrics BuildMetrics(IReadOnlyList<KnowledgeBenchmarkCaseResult> cases)
|
||||
{
|
||||
if (cases.Count == 0)
|
||||
{
|
||||
return new KnowledgeBenchmarkMetrics(0, 0, 0d, 0d, 0d);
|
||||
}
|
||||
|
||||
var matched = cases.Count(static item => item.Matched);
|
||||
var latencies = cases
|
||||
.Select(static item => item.LatencyMs)
|
||||
.OrderBy(static item => item)
|
||||
.ToArray();
|
||||
|
||||
return new KnowledgeBenchmarkMetrics(
|
||||
cases.Count,
|
||||
matched,
|
||||
matched / (double)cases.Count,
|
||||
Percentile(latencies, 0.5d),
|
||||
Percentile(latencies, 0.95d));
|
||||
}
|
||||
|
||||
private static double Percentile(IReadOnlyList<double> sortedValues, double percentile)
|
||||
{
|
||||
if (sortedValues.Count == 0)
|
||||
{
|
||||
return 0d;
|
||||
}
|
||||
|
||||
var clamped = Math.Clamp(percentile, 0d, 1d);
|
||||
var index = (int)Math.Ceiling(sortedValues.Count * clamped) - 1;
|
||||
index = Math.Clamp(index, 0, sortedValues.Count - 1);
|
||||
return sortedValues[index];
|
||||
}
|
||||
|
||||
private static bool IsMatch(KnowledgeSearchResult result, KnowledgeBenchmarkExpectedTarget expected)
|
||||
{
|
||||
if (!result.Type.Equals(expected.Type, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return expected.Type switch
|
||||
{
|
||||
"docs" => result.Open.Docs is not null &&
|
||||
string.Equals(result.Open.Docs.Path, expected.Path, StringComparison.Ordinal) &&
|
||||
string.Equals(result.Open.Docs.Anchor, expected.Anchor, StringComparison.OrdinalIgnoreCase),
|
||||
"api" => result.Open.Api is not null &&
|
||||
string.Equals(result.Open.Api.Method, expected.Method, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(result.Open.Api.Path, expected.ApiPath, StringComparison.Ordinal) &&
|
||||
(string.IsNullOrWhiteSpace(expected.OperationId) ||
|
||||
string.Equals(result.Open.Api.OperationId, expected.OperationId, StringComparison.OrdinalIgnoreCase)),
|
||||
"doctor" => result.Open.Doctor is not null &&
|
||||
string.Equals(result.Open.Doctor.CheckCode, expected.CheckCode, StringComparison.OrdinalIgnoreCase),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildResultReference(KnowledgeSearchResult result)
|
||||
{
|
||||
return result.Type switch
|
||||
{
|
||||
"docs" when result.Open.Docs is not null =>
|
||||
$"docs:{result.Open.Docs.Path}#{result.Open.Docs.Anchor}",
|
||||
"api" when result.Open.Api is not null =>
|
||||
$"api:{result.Open.Api.Method}:{result.Open.Api.Path}:{result.Open.Api.OperationId}",
|
||||
"doctor" when result.Open.Doctor is not null =>
|
||||
$"doctor:{result.Open.Doctor.CheckCode}",
|
||||
_ => result.Title,
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildExpectedReference(KnowledgeBenchmarkExpectedTarget expected)
|
||||
{
|
||||
return expected.Type switch
|
||||
{
|
||||
"docs" => $"docs:{expected.Path}#{expected.Anchor}",
|
||||
"api" => $"api:{expected.Method}:{expected.ApiPath}:{expected.OperationId}",
|
||||
"doctor" => $"doctor:{expected.CheckCode}",
|
||||
_ => expected.Type,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
|
||||
public sealed record KnowledgeSearchRequest(
|
||||
string Q,
|
||||
int? K = null,
|
||||
KnowledgeSearchFilter? Filters = null,
|
||||
bool IncludeDebug = false);
|
||||
|
||||
public sealed record KnowledgeSearchFilter
|
||||
{
|
||||
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 KnowledgeSearchResponse(
|
||||
string Query,
|
||||
int TopK,
|
||||
IReadOnlyList<KnowledgeSearchResult> Results,
|
||||
KnowledgeSearchDiagnostics Diagnostics);
|
||||
|
||||
public sealed record KnowledgeSearchResult(
|
||||
string Type,
|
||||
string Title,
|
||||
string Snippet,
|
||||
double Score,
|
||||
KnowledgeOpenAction Open,
|
||||
IReadOnlyDictionary<string, string>? Debug = null);
|
||||
|
||||
public sealed record KnowledgeOpenAction(
|
||||
KnowledgeOpenActionType Kind,
|
||||
KnowledgeOpenDocAction? Docs = null,
|
||||
KnowledgeOpenApiAction? Api = null,
|
||||
KnowledgeOpenDoctorAction? Doctor = null);
|
||||
|
||||
public enum KnowledgeOpenActionType
|
||||
{
|
||||
Docs = 1,
|
||||
Api = 2,
|
||||
Doctor = 3
|
||||
}
|
||||
|
||||
public sealed record KnowledgeOpenDocAction(
|
||||
string Path,
|
||||
string Anchor,
|
||||
int SpanStart,
|
||||
int SpanEnd);
|
||||
|
||||
public sealed record KnowledgeOpenApiAction(
|
||||
string Service,
|
||||
string Method,
|
||||
string Path,
|
||||
string OperationId);
|
||||
|
||||
public sealed record KnowledgeOpenDoctorAction(
|
||||
string CheckCode,
|
||||
string Severity,
|
||||
bool CanRun,
|
||||
string RunCommand);
|
||||
|
||||
public sealed record KnowledgeSearchDiagnostics(
|
||||
int FtsMatches,
|
||||
int VectorMatches,
|
||||
long DurationMs,
|
||||
bool UsedVector,
|
||||
string Mode);
|
||||
|
||||
internal sealed record KnowledgeSourceDocument(
|
||||
string DocId,
|
||||
string DocType,
|
||||
string Product,
|
||||
string Version,
|
||||
string SourceRef,
|
||||
string Path,
|
||||
string Title,
|
||||
string ContentHash,
|
||||
JsonDocument Metadata);
|
||||
|
||||
internal sealed record KnowledgeChunkDocument(
|
||||
string ChunkId,
|
||||
string DocId,
|
||||
string Kind,
|
||||
string? Anchor,
|
||||
string? SectionPath,
|
||||
int SpanStart,
|
||||
int SpanEnd,
|
||||
string Title,
|
||||
string Body,
|
||||
float[]? Embedding,
|
||||
JsonDocument Metadata);
|
||||
|
||||
internal sealed record KnowledgeApiSpec(
|
||||
string SpecId,
|
||||
string DocId,
|
||||
string Service,
|
||||
string? OpenApiVersion,
|
||||
string? Title,
|
||||
string? Version,
|
||||
string SourcePath,
|
||||
JsonDocument Content);
|
||||
|
||||
internal sealed record KnowledgeApiOperation(
|
||||
string OperationKey,
|
||||
string SpecId,
|
||||
string ChunkId,
|
||||
string Service,
|
||||
string Method,
|
||||
string Path,
|
||||
string? OperationId,
|
||||
IReadOnlyList<string> Tags,
|
||||
string? Summary,
|
||||
JsonDocument RequestJson,
|
||||
JsonDocument ResponsesJson,
|
||||
JsonDocument SecurityJson);
|
||||
|
||||
internal sealed record KnowledgeDoctorProjection(
|
||||
string CheckCode,
|
||||
string ChunkId,
|
||||
string Title,
|
||||
string Severity,
|
||||
string Remediation,
|
||||
string RunCommand,
|
||||
IReadOnlyList<string> Symptoms,
|
||||
JsonDocument ReferencesJson,
|
||||
JsonDocument MetadataJson);
|
||||
|
||||
internal sealed record KnowledgeIndexSnapshot(
|
||||
IReadOnlyList<KnowledgeSourceDocument> Documents,
|
||||
IReadOnlyList<KnowledgeChunkDocument> Chunks,
|
||||
IReadOnlyList<KnowledgeApiSpec> ApiSpecs,
|
||||
IReadOnlyList<KnowledgeApiOperation> ApiOperations,
|
||||
IReadOnlyList<KnowledgeDoctorProjection> DoctorProjections);
|
||||
|
||||
public sealed record KnowledgeRebuildSummary(
|
||||
int DocumentCount,
|
||||
int ChunkCount,
|
||||
int ApiSpecCount,
|
||||
int ApiOperationCount,
|
||||
int DoctorProjectionCount,
|
||||
long DurationMs);
|
||||
|
||||
internal sealed record KnowledgeChunkRow(
|
||||
string ChunkId,
|
||||
string DocId,
|
||||
string Kind,
|
||||
string? Anchor,
|
||||
string? SectionPath,
|
||||
int SpanStart,
|
||||
int SpanEnd,
|
||||
string Title,
|
||||
string Body,
|
||||
string Snippet,
|
||||
JsonDocument Metadata,
|
||||
float[]? Embedding,
|
||||
double LexicalScore);
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
|
||||
public sealed class KnowledgeSearchOptions
|
||||
{
|
||||
public const string SectionName = "AdvisoryAI:KnowledgeSearch";
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public string ConnectionString { get; set; } = string.Empty;
|
||||
|
||||
public string Product { get; set; } = "stella-ops";
|
||||
|
||||
public string Version { get; set; } = "local";
|
||||
|
||||
public string RepositoryRoot { get; set; } = ".";
|
||||
|
||||
[Range(1, 384)]
|
||||
public int DefaultTopK { get; set; } = 10;
|
||||
|
||||
[Range(1, 2000)]
|
||||
public int MaxQueryLength { get; set; } = 512;
|
||||
|
||||
[Range(16, 2048)]
|
||||
public int VectorDimensions { get; set; } = 384;
|
||||
|
||||
[Range(10, 5000)]
|
||||
public int FtsCandidateCount { get; set; } = 120;
|
||||
|
||||
[Range(10, 10000)]
|
||||
public int VectorScanLimit { get; set; } = 3000;
|
||||
|
||||
[Range(10, 5000)]
|
||||
public int VectorCandidateCount { get; set; } = 120;
|
||||
|
||||
[Range(250, 30000)]
|
||||
public int QueryTimeoutMs { get; set; } = 3000;
|
||||
|
||||
public string DoctorChecksEndpoint { get; set; } = string.Empty;
|
||||
|
||||
public string DoctorSeedPath { get; set; } =
|
||||
"src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/doctor-search-seed.json";
|
||||
|
||||
public List<string> MarkdownRoots { get; set; } = ["docs"];
|
||||
|
||||
public List<string> OpenApiRoots { get; set; } = ["src", "devops/compose"];
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Vectorization;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
|
||||
internal sealed class KnowledgeSearchService : IKnowledgeSearchService
|
||||
{
|
||||
private const int ReciprocalRankConstant = 60;
|
||||
private static readonly Regex MethodPathPattern = new("\\b(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|TRACE)\\s+(/[^\\s]+)", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
|
||||
|
||||
private readonly KnowledgeSearchOptions _options;
|
||||
private readonly IKnowledgeSearchStore _store;
|
||||
private readonly IVectorEncoder _vectorEncoder;
|
||||
private readonly ILogger<KnowledgeSearchService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public KnowledgeSearchService(
|
||||
IOptions<KnowledgeSearchOptions> options,
|
||||
IKnowledgeSearchStore store,
|
||||
IVectorEncoder vectorEncoder,
|
||||
ILogger<KnowledgeSearchService> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options.Value ?? new KnowledgeSearchOptions();
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_vectorEncoder = vectorEncoder ?? throw new ArgumentNullException(nameof(vectorEncoder));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public async Task<KnowledgeSearchResponse> SearchAsync(KnowledgeSearchRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var startedAt = _timeProvider.GetUtcNow();
|
||||
var query = NormalizeQuery(request.Q);
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return new KnowledgeSearchResponse(
|
||||
string.Empty,
|
||||
ResolveTopK(request.K),
|
||||
[],
|
||||
new KnowledgeSearchDiagnostics(0, 0, 0, false, "empty"));
|
||||
}
|
||||
|
||||
if (!_options.Enabled || string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
return new KnowledgeSearchResponse(
|
||||
query,
|
||||
ResolveTopK(request.K),
|
||||
[],
|
||||
new KnowledgeSearchDiagnostics(0, 0, 0, false, "disabled"));
|
||||
}
|
||||
|
||||
var topK = ResolveTopK(request.K);
|
||||
var timeout = TimeSpan.FromMilliseconds(Math.Max(250, _options.QueryTimeoutMs));
|
||||
|
||||
var ftsRows = await _store.SearchFtsAsync(
|
||||
query,
|
||||
request.Filters,
|
||||
Math.Max(topK, _options.FtsCandidateCount),
|
||||
timeout,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var lexicalRanks = ftsRows
|
||||
.Select((row, index) => (row.ChunkId, Rank: index + 1, Row: row))
|
||||
.ToDictionary(static item => item.ChunkId, static item => item, StringComparer.Ordinal);
|
||||
|
||||
var vectorRows = Array.Empty<(KnowledgeChunkRow Row, int Rank, double Score)>();
|
||||
var usedVector = false;
|
||||
|
||||
try
|
||||
{
|
||||
var queryEmbedding = EncodeQueryEmbedding(query);
|
||||
if (queryEmbedding.Length > 0)
|
||||
{
|
||||
var candidates = await _store.LoadVectorCandidatesAsync(
|
||||
queryEmbedding,
|
||||
request.Filters,
|
||||
Math.Max(topK, _options.VectorScanLimit),
|
||||
timeout,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var rankedVectors = candidates
|
||||
.Select(row => (Row: row, Score: row.Embedding is { Length: > 0 }
|
||||
? KnowledgeSearchText.CosineSimilarity(queryEmbedding, row.Embedding)
|
||||
: 0d))
|
||||
.Where(static item => item.Score > 0d)
|
||||
.OrderByDescending(static item => item.Score)
|
||||
.ThenBy(static item => item.Row.ChunkId, StringComparer.Ordinal)
|
||||
.Take(Math.Max(topK, _options.VectorCandidateCount))
|
||||
.Select((item, index) => (item.Row, Rank: index + 1, item.Score))
|
||||
.ToArray();
|
||||
|
||||
vectorRows = rankedVectors;
|
||||
usedVector = rankedVectors.Length > 0;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Knowledge search vector stage failed; continuing with lexical results only.");
|
||||
}
|
||||
|
||||
var merged = FuseRanks(query, request, lexicalRanks, vectorRows);
|
||||
var results = merged
|
||||
.Take(topK)
|
||||
.Select(item => BuildResult(item.Row, query, item.Score, request.IncludeDebug, item.Debug))
|
||||
.ToArray();
|
||||
|
||||
var duration = _timeProvider.GetUtcNow() - startedAt;
|
||||
return new KnowledgeSearchResponse(
|
||||
query,
|
||||
topK,
|
||||
results,
|
||||
new KnowledgeSearchDiagnostics(
|
||||
ftsRows.Count,
|
||||
vectorRows.Length,
|
||||
(long)duration.TotalMilliseconds,
|
||||
usedVector,
|
||||
usedVector ? "hybrid" : "fts-only"));
|
||||
}
|
||||
|
||||
private IReadOnlyList<(KnowledgeChunkRow Row, double Score, IReadOnlyDictionary<string, string> Debug)> FuseRanks(
|
||||
string query,
|
||||
KnowledgeSearchRequest request,
|
||||
IReadOnlyDictionary<string, (string ChunkId, int Rank, KnowledgeChunkRow Row)> lexicalRanks,
|
||||
IReadOnlyList<(KnowledgeChunkRow Row, int Rank, double Score)> vectorRanks)
|
||||
{
|
||||
var merged = new Dictionary<string, (KnowledgeChunkRow Row, double Score, Dictionary<string, string> Debug)>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var lexical in lexicalRanks.Values)
|
||||
{
|
||||
var score = ReciprocalRank(lexical.Rank);
|
||||
var debug = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["lexicalRank"] = lexical.Rank.ToString(),
|
||||
["lexicalScore"] = lexical.Row.LexicalScore.ToString("F6", System.Globalization.CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
merged[lexical.ChunkId] = (lexical.Row, score, debug);
|
||||
}
|
||||
|
||||
foreach (var vector in vectorRanks)
|
||||
{
|
||||
if (!merged.TryGetValue(vector.Row.ChunkId, out var existing))
|
||||
{
|
||||
existing = (vector.Row, 0d, new Dictionary<string, string>(StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
existing.Score += ReciprocalRank(vector.Rank);
|
||||
existing.Debug["vectorRank"] = vector.Rank.ToString();
|
||||
existing.Debug["vectorScore"] = vector.Score.ToString("F6", System.Globalization.CultureInfo.InvariantCulture);
|
||||
merged[vector.Row.ChunkId] = existing;
|
||||
}
|
||||
|
||||
var ranked = merged.Values
|
||||
.Select(item =>
|
||||
{
|
||||
var boost = ComputeBoost(query, request.Filters, item.Row);
|
||||
item.Score += boost;
|
||||
item.Debug["boost"] = boost.ToString("F6", System.Globalization.CultureInfo.InvariantCulture);
|
||||
item.Debug["chunkId"] = item.Row.ChunkId;
|
||||
return item;
|
||||
})
|
||||
.OrderByDescending(static item => item.Score)
|
||||
.ThenBy(static item => item.Row.Kind, StringComparer.Ordinal)
|
||||
.ThenBy(static item => item.Row.ChunkId, StringComparer.Ordinal)
|
||||
.Select(item => (item.Row, item.Score, (IReadOnlyDictionary<string, string>)item.Debug))
|
||||
.ToArray();
|
||||
|
||||
return ranked;
|
||||
}
|
||||
|
||||
private double ComputeBoost(string query, KnowledgeSearchFilter? filters, KnowledgeChunkRow row)
|
||||
{
|
||||
var normalizedQuery = query.Trim();
|
||||
var lowerQuery = normalizedQuery.ToLowerInvariant();
|
||||
var metadata = row.Metadata.RootElement;
|
||||
|
||||
var boost = 0d;
|
||||
if (row.Kind.Equals("doctor_check", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var checkCode = GetMetadataString(metadata, "checkCode");
|
||||
if (!string.IsNullOrWhiteSpace(checkCode) && checkCode.Equals(normalizedQuery, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
boost += 1.4d;
|
||||
}
|
||||
}
|
||||
|
||||
if (row.Kind.Equals("api_operation", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var operationId = GetMetadataString(metadata, "operationId");
|
||||
if (!string.IsNullOrWhiteSpace(operationId) && operationId.Equals(normalizedQuery, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
boost += 1.2d;
|
||||
}
|
||||
|
||||
var method = GetMetadataString(metadata, "method");
|
||||
var path = GetMetadataString(metadata, "path");
|
||||
if (!string.IsNullOrWhiteSpace(method) && !string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
var match = MethodPathPattern.Match(query);
|
||||
if (match.Success &&
|
||||
method.Equals(match.Groups[1].Value, StringComparison.OrdinalIgnoreCase) &&
|
||||
path.Equals(match.Groups[2].Value, StringComparison.Ordinal))
|
||||
{
|
||||
boost += 1.3d;
|
||||
}
|
||||
|
||||
var methodPath = $"{method} {path}".ToLowerInvariant();
|
||||
if (lowerQuery.Contains(methodPath, StringComparison.Ordinal))
|
||||
{
|
||||
boost += 0.3d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(row.Title) && row.Title.Equals(normalizedQuery, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
boost += 0.4d;
|
||||
}
|
||||
|
||||
if (filters is not null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(filters.Service))
|
||||
{
|
||||
var rowService = GetMetadataString(metadata, "service");
|
||||
if (!string.IsNullOrWhiteSpace(rowService) && rowService.Equals(filters.Service, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
boost += 0.2d;
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.Tags is { Count: > 0 })
|
||||
{
|
||||
var rowTags = GetMetadataStringArray(metadata, "tags");
|
||||
if (filters.Tags.Any(filterTag => rowTags.Contains(filterTag, StringComparer.OrdinalIgnoreCase)))
|
||||
{
|
||||
boost += 0.15d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return boost;
|
||||
}
|
||||
|
||||
private KnowledgeSearchResult BuildResult(
|
||||
KnowledgeChunkRow row,
|
||||
string query,
|
||||
double score,
|
||||
bool includeDebug,
|
||||
IReadOnlyDictionary<string, string> debug)
|
||||
{
|
||||
var resultType = row.Kind switch
|
||||
{
|
||||
"api_operation" => "api",
|
||||
"doctor_check" => "doctor",
|
||||
_ => "docs"
|
||||
};
|
||||
|
||||
var metadata = row.Metadata.RootElement;
|
||||
var snippet = string.IsNullOrWhiteSpace(row.Snippet)
|
||||
? KnowledgeSearchText.BuildSnippet(row.Body, query)
|
||||
: row.Snippet;
|
||||
|
||||
var action = resultType switch
|
||||
{
|
||||
"api" => new KnowledgeOpenAction(
|
||||
KnowledgeOpenActionType.Api,
|
||||
Api: new KnowledgeOpenApiAction(
|
||||
GetMetadataString(metadata, "service") ?? "unknown",
|
||||
GetMetadataString(metadata, "method") ?? "GET",
|
||||
GetMetadataString(metadata, "path") ?? "/",
|
||||
GetMetadataString(metadata, "operationId") ?? row.Title)),
|
||||
"doctor" => new KnowledgeOpenAction(
|
||||
KnowledgeOpenActionType.Doctor,
|
||||
Doctor: new KnowledgeOpenDoctorAction(
|
||||
GetMetadataString(metadata, "checkCode") ?? row.Title,
|
||||
GetMetadataString(metadata, "severity") ?? "warn",
|
||||
true,
|
||||
GetMetadataString(metadata, "runCommand") ?? $"stella doctor run --check {row.Title}")),
|
||||
_ => new KnowledgeOpenAction(
|
||||
KnowledgeOpenActionType.Docs,
|
||||
Docs: new KnowledgeOpenDocAction(
|
||||
GetMetadataString(metadata, "path") ?? string.Empty,
|
||||
row.Anchor ?? GetMetadataString(metadata, "anchor") ?? "overview",
|
||||
row.SpanStart,
|
||||
row.SpanEnd))
|
||||
};
|
||||
|
||||
return new KnowledgeSearchResult(
|
||||
resultType,
|
||||
row.Title,
|
||||
snippet,
|
||||
score,
|
||||
action,
|
||||
includeDebug ? debug : null);
|
||||
}
|
||||
|
||||
private float[] EncodeQueryEmbedding(string query)
|
||||
{
|
||||
var raw = _vectorEncoder.Encode(query);
|
||||
if (raw.Length == 0)
|
||||
{
|
||||
return raw;
|
||||
}
|
||||
|
||||
var dimensions = Math.Max(1, _options.VectorDimensions);
|
||||
var normalized = new float[dimensions];
|
||||
var copy = Math.Min(raw.Length, dimensions);
|
||||
Array.Copy(raw, normalized, copy);
|
||||
|
||||
var norm = 0d;
|
||||
for (var index = 0; index < normalized.Length; index++)
|
||||
{
|
||||
norm += normalized[index] * normalized[index];
|
||||
}
|
||||
|
||||
if (norm <= 0d)
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
|
||||
var magnitude = Math.Sqrt(norm);
|
||||
for (var index = 0; index < normalized.Length; index++)
|
||||
{
|
||||
normalized[index] = (float)(normalized[index] / magnitude);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string? GetMetadataString(JsonElement metadata, string propertyName)
|
||||
{
|
||||
if (metadata.ValueKind != JsonValueKind.Object || !metadata.TryGetProperty(propertyName, out var value) || value.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.GetString();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> GetMetadataStringArray(JsonElement metadata, string propertyName)
|
||||
{
|
||||
if (metadata.ValueKind != JsonValueKind.Object || !metadata.TryGetProperty(propertyName, out var value) || value.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.EnumerateArray()
|
||||
.Where(static item => item.ValueKind == JsonValueKind.String)
|
||||
.Select(static item => item.GetString())
|
||||
.Where(static item => !string.IsNullOrWhiteSpace(item))
|
||||
.Select(static item => item!.Trim())
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private int ResolveTopK(int? requested)
|
||||
{
|
||||
var fallback = Math.Max(1, _options.DefaultTopK);
|
||||
if (!requested.HasValue)
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Math.Clamp(requested.Value, 1, 100);
|
||||
}
|
||||
|
||||
private string NormalizeQuery(string query)
|
||||
{
|
||||
var normalized = KnowledgeSearchText.NormalizeWhitespace(query);
|
||||
if (normalized.Length <= _options.MaxQueryLength)
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return normalized[.._options.MaxQueryLength].TrimEnd();
|
||||
}
|
||||
|
||||
private static double ReciprocalRank(int rank)
|
||||
{
|
||||
if (rank <= 0)
|
||||
{
|
||||
return 0d;
|
||||
}
|
||||
|
||||
return 1d / (ReciprocalRankConstant + rank);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
|
||||
public static class KnowledgeSearchServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddAdvisoryKnowledgeSearch(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
services.AddOptions<KnowledgeSearchOptions>()
|
||||
.Bind(configuration.GetSection(KnowledgeSearchOptions.SectionName))
|
||||
.ValidateDataAnnotations();
|
||||
|
||||
services.TryAddSingleton<IKnowledgeSearchStore, PostgresKnowledgeSearchStore>();
|
||||
services.TryAddSingleton<IKnowledgeIndexer, KnowledgeIndexer>();
|
||||
services.TryAddSingleton<IKnowledgeSearchService, KnowledgeSearchService>();
|
||||
services.TryAddSingleton<IKnowledgeSearchBenchmarkDatasetGenerator, KnowledgeSearchBenchmarkDatasetGenerator>();
|
||||
services.TryAddSingleton<IKnowledgeSearchBenchmarkRunner, KnowledgeSearchBenchmarkRunner>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
|
||||
internal static class KnowledgeSearchText
|
||||
{
|
||||
private static readonly Regex SlugRegex = new("[^a-z0-9]+", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
private static readonly Regex MultiSpaceRegex = new("\\s+", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
public static string NormalizeWhitespace(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var normalized = value.Replace("\r\n", "\n", StringComparison.Ordinal)
|
||||
.Replace('\r', '\n')
|
||||
.Trim();
|
||||
return MultiSpaceRegex.Replace(normalized, " ").Trim();
|
||||
}
|
||||
|
||||
public static string Slugify(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return "section";
|
||||
}
|
||||
|
||||
var lower = value.Trim().ToLowerInvariant();
|
||||
var slug = SlugRegex.Replace(lower, "-").Trim('-');
|
||||
return string.IsNullOrWhiteSpace(slug) ? "section" : slug;
|
||||
}
|
||||
|
||||
public static string StableId(params string[] components)
|
||||
{
|
||||
var normalized = string.Join("|", components.Select(NormalizeWhitespace));
|
||||
var bytes = Encoding.UTF8.GetBytes(normalized);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
public static string BuildSnippet(string body, string query)
|
||||
{
|
||||
var normalizedBody = NormalizeWhitespace(body);
|
||||
if (normalizedBody.Length == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var term = NormalizeWhitespace(query).ToLowerInvariant();
|
||||
if (term.Length == 0)
|
||||
{
|
||||
return TrimSnippet(normalizedBody, 240);
|
||||
}
|
||||
|
||||
var index = normalizedBody.IndexOf(term, StringComparison.OrdinalIgnoreCase);
|
||||
if (index < 0)
|
||||
{
|
||||
return TrimSnippet(normalizedBody, 240);
|
||||
}
|
||||
|
||||
const int around = 96;
|
||||
var start = Math.Max(index - around, 0);
|
||||
var length = Math.Min(around * 2 + term.Length, normalizedBody.Length - start);
|
||||
var snippet = normalizedBody.Substring(start, length);
|
||||
var highlighted = Regex.Replace(
|
||||
snippet,
|
||||
Regex.Escape(term),
|
||||
static match => $"<mark>{match.Value}</mark>",
|
||||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
return TrimSnippet(highlighted, 320);
|
||||
}
|
||||
|
||||
public static double CosineSimilarity(float[] left, float[] right)
|
||||
{
|
||||
if (left.Length == 0 || right.Length == 0 || left.Length != right.Length)
|
||||
{
|
||||
return 0d;
|
||||
}
|
||||
|
||||
double dot = 0d;
|
||||
double leftNorm = 0d;
|
||||
double rightNorm = 0d;
|
||||
for (var i = 0; i < left.Length; i++)
|
||||
{
|
||||
dot += left[i] * right[i];
|
||||
leftNorm += left[i] * left[i];
|
||||
rightNorm += right[i] * right[i];
|
||||
}
|
||||
|
||||
if (leftNorm <= 0d || rightNorm <= 0d)
|
||||
{
|
||||
return 0d;
|
||||
}
|
||||
|
||||
return dot / Math.Sqrt(leftNorm * rightNorm);
|
||||
}
|
||||
|
||||
private static string TrimSnippet(string value, int maxChars)
|
||||
{
|
||||
if (value.Length <= maxChars)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return value[..maxChars].TrimEnd() + "...";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,956 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
|
||||
internal sealed class PostgresKnowledgeSearchStore : IKnowledgeSearchStore, IAsyncDisposable
|
||||
{
|
||||
private static readonly JsonDocument EmptyJsonDocument = JsonDocument.Parse("{}");
|
||||
|
||||
private readonly KnowledgeSearchOptions _options;
|
||||
private readonly ILogger<PostgresKnowledgeSearchStore> _logger;
|
||||
private readonly Lazy<NpgsqlDataSource?> _dataSource;
|
||||
private bool? _hasEmbeddingVectorColumn;
|
||||
|
||||
public PostgresKnowledgeSearchStore(
|
||||
IOptions<KnowledgeSearchOptions> options,
|
||||
ILogger<PostgresKnowledgeSearchStore> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options.Value ?? new KnowledgeSearchOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
_dataSource = new Lazy<NpgsqlDataSource?>(CreateDataSource, isThreadSafe: true);
|
||||
}
|
||||
|
||||
public async Task EnsureSchemaAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsConfigured())
|
||||
{
|
||||
_logger.LogDebug("Knowledge search schema ensure skipped because configuration is incomplete.");
|
||||
return;
|
||||
}
|
||||
|
||||
await using var connection = await GetDataSource().OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string createSchemaSql = "CREATE SCHEMA IF NOT EXISTS advisoryai;";
|
||||
await ExecuteNonQueryAsync(connection, transaction, createSchemaSql, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string createHistorySql = """
|
||||
CREATE TABLE IF NOT EXISTS advisoryai.__migration_history
|
||||
(
|
||||
migration_name TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
""";
|
||||
await ExecuteNonQueryAsync(connection, transaction, createHistorySql, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var scripts = LoadMigrationScripts();
|
||||
foreach (var script in scripts)
|
||||
{
|
||||
if (await IsMigrationAppliedAsync(connection, transaction, script.Name, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Applying AdvisoryAI knowledge migration {Migration}.", script.Name);
|
||||
await ExecuteNonQueryAsync(connection, transaction, script.Sql, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string insertHistorySql = """
|
||||
INSERT INTO advisoryai.__migration_history (migration_name, applied_at)
|
||||
VALUES (@migration_name, NOW())
|
||||
ON CONFLICT (migration_name) DO NOTHING;
|
||||
""";
|
||||
await using var history = connection.CreateCommand();
|
||||
history.Transaction = transaction;
|
||||
history.CommandText = insertHistorySql;
|
||||
history.Parameters.AddWithValue("migration_name", script.Name);
|
||||
history.CommandTimeout = ToCommandTimeoutSeconds(TimeSpan.FromSeconds(30));
|
||||
await history.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
_hasEmbeddingVectorColumn = null;
|
||||
}
|
||||
|
||||
public async Task ReplaceIndexAsync(KnowledgeIndexSnapshot snapshot, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
|
||||
if (!IsConfigured())
|
||||
{
|
||||
_logger.LogDebug("Knowledge search replace-index skipped because configuration is incomplete.");
|
||||
return;
|
||||
}
|
||||
|
||||
await EnsureSchemaAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var connection = await GetDataSource().OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
const string truncateSql = "DELETE FROM advisoryai.kb_doc;";
|
||||
await ExecuteNonQueryAsync(connection, transaction, truncateSql, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var hasEmbeddingVectorColumn = await HasEmbeddingVectorColumnAsync(connection, transaction, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await InsertDocumentsAsync(connection, transaction, snapshot.Documents, cancellationToken).ConfigureAwait(false);
|
||||
await InsertChunksAsync(connection, transaction, snapshot.Chunks, hasEmbeddingVectorColumn, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await InsertApiSpecsAsync(connection, transaction, snapshot.ApiSpecs, cancellationToken).ConfigureAwait(false);
|
||||
await InsertApiOperationsAsync(connection, transaction, snapshot.ApiOperations, cancellationToken).ConfigureAwait(false);
|
||||
await InsertDoctorProjectionsAsync(connection, transaction, snapshot.DoctorProjections, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<KnowledgeChunkRow>> SearchFtsAsync(
|
||||
string query,
|
||||
KnowledgeSearchFilter? filters,
|
||||
int take,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsConfigured() || string.IsNullOrWhiteSpace(query) || take <= 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var kinds = ResolveKinds(filters);
|
||||
var tags = ResolveTags(filters);
|
||||
var normalizedProduct = NormalizeOptional(filters?.Product);
|
||||
var normalizedVersion = NormalizeOptional(filters?.Version);
|
||||
var normalizedService = NormalizeOptional(filters?.Service);
|
||||
|
||||
const string sql = """
|
||||
WITH q AS (
|
||||
SELECT websearch_to_tsquery('simple', @query) AS tsq
|
||||
)
|
||||
SELECT
|
||||
c.chunk_id,
|
||||
c.doc_id,
|
||||
c.kind,
|
||||
c.anchor,
|
||||
c.section_path,
|
||||
c.span_start,
|
||||
c.span_end,
|
||||
c.title,
|
||||
c.body,
|
||||
COALESCE(
|
||||
NULLIF(ts_headline(
|
||||
'simple',
|
||||
c.body,
|
||||
q.tsq,
|
||||
'StartSel=<mark>, StopSel=</mark>, MaxFragments=2, MinWords=8, MaxWords=26, FragmentDelimiter= ... '
|
||||
), ''),
|
||||
substring(c.body from 1 for 320)
|
||||
) AS snippet,
|
||||
c.metadata,
|
||||
ts_rank_cd(c.body_tsv, q.tsq, 32) AS lexical_score,
|
||||
c.embedding
|
||||
FROM advisoryai.kb_chunk AS c
|
||||
INNER JOIN advisoryai.kb_doc AS d
|
||||
ON d.doc_id = c.doc_id
|
||||
CROSS JOIN q
|
||||
WHERE c.body_tsv @@ q.tsq
|
||||
AND (@kind_count = 0 OR c.kind = ANY(@kinds))
|
||||
AND (@tag_count = 0 OR EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements_text(COALESCE(c.metadata->'tags', '[]'::jsonb)) AS tag(value)
|
||||
WHERE lower(tag.value) = ANY(@tags)
|
||||
))
|
||||
AND (@product = '' OR lower(d.product) = lower(@product))
|
||||
AND (@version = '' OR lower(d.version) = lower(@version))
|
||||
AND (@service = '' OR lower(COALESCE(c.metadata->>'service', '')) = lower(@service))
|
||||
ORDER BY lexical_score DESC, c.chunk_id ASC
|
||||
LIMIT @take;
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, timeout);
|
||||
command.Parameters.AddWithValue("query", query);
|
||||
command.Parameters.AddWithValue("take", take);
|
||||
command.Parameters.AddWithValue("kind_count", kinds.Length);
|
||||
command.Parameters.AddWithValue(
|
||||
"kinds",
|
||||
NpgsqlDbType.Array | NpgsqlDbType.Text,
|
||||
kinds.Length == 0 ? Array.Empty<string>() : kinds);
|
||||
command.Parameters.AddWithValue("tag_count", tags.Length);
|
||||
command.Parameters.AddWithValue(
|
||||
"tags",
|
||||
NpgsqlDbType.Array | NpgsqlDbType.Text,
|
||||
tags.Length == 0 ? Array.Empty<string>() : tags);
|
||||
command.Parameters.AddWithValue("product", normalizedProduct);
|
||||
command.Parameters.AddWithValue("version", normalizedVersion);
|
||||
command.Parameters.AddWithValue("service", normalizedService);
|
||||
|
||||
return await ReadChunkRowsAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<KnowledgeChunkRow>> LoadVectorCandidatesAsync(
|
||||
float[] queryEmbedding,
|
||||
KnowledgeSearchFilter? filters,
|
||||
int take,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(queryEmbedding);
|
||||
if (!IsConfigured() || queryEmbedding.Length == 0 || take <= 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var kinds = ResolveKinds(filters);
|
||||
var tags = ResolveTags(filters);
|
||||
var normalizedProduct = NormalizeOptional(filters?.Product);
|
||||
var normalizedVersion = NormalizeOptional(filters?.Version);
|
||||
var normalizedService = NormalizeOptional(filters?.Service);
|
||||
|
||||
var queryVectorLiteral = BuildVectorLiteral(queryEmbedding);
|
||||
var useEmbeddingVectorColumn = await HasEmbeddingVectorColumnAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sql = useEmbeddingVectorColumn
|
||||
? """
|
||||
SELECT
|
||||
c.chunk_id,
|
||||
c.doc_id,
|
||||
c.kind,
|
||||
c.anchor,
|
||||
c.section_path,
|
||||
c.span_start,
|
||||
c.span_end,
|
||||
c.title,
|
||||
c.body,
|
||||
substring(c.body from 1 for 320) AS snippet,
|
||||
c.metadata,
|
||||
0::double precision AS lexical_score,
|
||||
c.embedding
|
||||
FROM advisoryai.kb_chunk AS c
|
||||
INNER JOIN advisoryai.kb_doc AS d
|
||||
ON d.doc_id = c.doc_id
|
||||
WHERE c.embedding_vec IS NOT NULL
|
||||
AND (@kind_count = 0 OR c.kind = ANY(@kinds))
|
||||
AND (@tag_count = 0 OR EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements_text(COALESCE(c.metadata->'tags', '[]'::jsonb)) AS tag(value)
|
||||
WHERE lower(tag.value) = ANY(@tags)
|
||||
))
|
||||
AND (@product = '' OR lower(d.product) = lower(@product))
|
||||
AND (@version = '' OR lower(d.version) = lower(@version))
|
||||
AND (@service = '' OR lower(COALESCE(c.metadata->>'service', '')) = lower(@service))
|
||||
ORDER BY c.embedding_vec <=> CAST(@query_vector AS vector), c.chunk_id ASC
|
||||
LIMIT @take;
|
||||
"""
|
||||
: """
|
||||
SELECT
|
||||
c.chunk_id,
|
||||
c.doc_id,
|
||||
c.kind,
|
||||
c.anchor,
|
||||
c.section_path,
|
||||
c.span_start,
|
||||
c.span_end,
|
||||
c.title,
|
||||
c.body,
|
||||
substring(c.body from 1 for 320) AS snippet,
|
||||
c.metadata,
|
||||
0::double precision AS lexical_score,
|
||||
c.embedding
|
||||
FROM advisoryai.kb_chunk AS c
|
||||
INNER JOIN advisoryai.kb_doc AS d
|
||||
ON d.doc_id = c.doc_id
|
||||
WHERE c.embedding IS NOT NULL
|
||||
AND (@kind_count = 0 OR c.kind = ANY(@kinds))
|
||||
AND (@tag_count = 0 OR EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements_text(COALESCE(c.metadata->'tags', '[]'::jsonb)) AS tag(value)
|
||||
WHERE lower(tag.value) = ANY(@tags)
|
||||
))
|
||||
AND (@product = '' OR lower(d.product) = lower(@product))
|
||||
AND (@version = '' OR lower(d.version) = lower(@version))
|
||||
AND (@service = '' OR lower(COALESCE(c.metadata->>'service', '')) = lower(@service))
|
||||
ORDER BY c.chunk_id ASC
|
||||
LIMIT @take;
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, timeout);
|
||||
command.Parameters.AddWithValue("take", take);
|
||||
command.Parameters.AddWithValue("kind_count", kinds.Length);
|
||||
command.Parameters.AddWithValue(
|
||||
"kinds",
|
||||
NpgsqlDbType.Array | NpgsqlDbType.Text,
|
||||
kinds.Length == 0 ? Array.Empty<string>() : kinds);
|
||||
command.Parameters.AddWithValue("tag_count", tags.Length);
|
||||
command.Parameters.AddWithValue(
|
||||
"tags",
|
||||
NpgsqlDbType.Array | NpgsqlDbType.Text,
|
||||
tags.Length == 0 ? Array.Empty<string>() : tags);
|
||||
command.Parameters.AddWithValue("product", normalizedProduct);
|
||||
command.Parameters.AddWithValue("version", normalizedVersion);
|
||||
command.Parameters.AddWithValue("service", normalizedService);
|
||||
command.Parameters.AddWithValue("query_vector", queryVectorLiteral);
|
||||
|
||||
return await ReadChunkRowsAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_dataSource.IsValueCreated && _dataSource.Value is not null)
|
||||
{
|
||||
await _dataSource.Value.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildVectorLiteral(float[] values)
|
||||
{
|
||||
return "[" + string.Join(",", values.Select(static value => value.ToString("G9", CultureInfo.InvariantCulture))) + "]";
|
||||
}
|
||||
|
||||
private static string NormalizeOptional(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim();
|
||||
}
|
||||
|
||||
private static string[] ResolveKinds(KnowledgeSearchFilter? filters)
|
||||
{
|
||||
if (filters?.Type is not { Count: > 0 })
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var kinds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var item in filters.Type)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.Equals("docs", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
kinds.Add("md_section");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.Equals("api", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
kinds.Add("api_operation");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.Equals("doctor", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
kinds.Add("doctor_check");
|
||||
}
|
||||
}
|
||||
|
||||
return kinds.OrderBy(static value => value, StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
|
||||
private static string[] ResolveTags(KnowledgeSearchFilter? filters)
|
||||
{
|
||||
if (filters?.Tags is not { Count: > 0 })
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return filters.Tags
|
||||
.Where(static tag => !string.IsNullOrWhiteSpace(tag))
|
||||
.Select(static tag => tag.Trim().ToLowerInvariant())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static tag => tag, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<KnowledgeChunkRow>> ReadChunkRowsAsync(
|
||||
NpgsqlCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = new List<KnowledgeChunkRow>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var metadataJson = reader.IsDBNull(10) ? "{}" : reader.GetString(10);
|
||||
var metadata = ParseJsonDocument(metadataJson);
|
||||
|
||||
float[]? embedding = null;
|
||||
if (!reader.IsDBNull(12))
|
||||
{
|
||||
if (reader.GetValue(12) is float[] values)
|
||||
{
|
||||
embedding = values;
|
||||
}
|
||||
else if (reader.GetValue(12) is Array array)
|
||||
{
|
||||
embedding = array.Cast<object?>().Select(static item => Convert.ToSingle(item, CultureInfo.InvariantCulture)).ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
rows.Add(new KnowledgeChunkRow(
|
||||
reader.GetString(0),
|
||||
reader.GetString(1),
|
||||
reader.GetString(2),
|
||||
reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||
reader.IsDBNull(4) ? null : reader.GetString(4),
|
||||
reader.GetInt32(5),
|
||||
reader.GetInt32(6),
|
||||
reader.GetString(7),
|
||||
reader.GetString(8),
|
||||
reader.IsDBNull(9) ? string.Empty : reader.GetString(9),
|
||||
metadata,
|
||||
embedding,
|
||||
reader.IsDBNull(11) ? 0d : reader.GetDouble(11)));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private async Task<bool> HasEmbeddingVectorColumnAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_hasEmbeddingVectorColumn.HasValue)
|
||||
{
|
||||
return _hasEmbeddingVectorColumn.Value;
|
||||
}
|
||||
|
||||
await using var connection = await GetDataSource().OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
_hasEmbeddingVectorColumn = await HasEmbeddingVectorColumnAsync(connection, null, cancellationToken).ConfigureAwait(false);
|
||||
return _hasEmbeddingVectorColumn.Value;
|
||||
}
|
||||
|
||||
private static async Task<bool> HasEmbeddingVectorColumnAsync(
|
||||
NpgsqlConnection connection,
|
||||
NpgsqlTransaction? transaction,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'advisoryai'
|
||||
AND table_name = 'kb_chunk'
|
||||
AND column_name = 'embedding_vec'
|
||||
);
|
||||
""";
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
command.Transaction = transaction;
|
||||
command.CommandTimeout = ToCommandTimeoutSeconds(TimeSpan.FromSeconds(30));
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return result is bool value && value;
|
||||
}
|
||||
|
||||
private async Task InsertDocumentsAsync(
|
||||
NpgsqlConnection connection,
|
||||
NpgsqlTransaction transaction,
|
||||
IReadOnlyList<KnowledgeSourceDocument> documents,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (documents.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO advisoryai.kb_doc
|
||||
(
|
||||
doc_id,
|
||||
doc_type,
|
||||
product,
|
||||
version,
|
||||
source_ref,
|
||||
path,
|
||||
title,
|
||||
content_hash,
|
||||
metadata,
|
||||
indexed_at
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@doc_id,
|
||||
@doc_type,
|
||||
@product,
|
||||
@version,
|
||||
@source_ref,
|
||||
@path,
|
||||
@title,
|
||||
@content_hash,
|
||||
@metadata::jsonb,
|
||||
NOW()
|
||||
);
|
||||
""";
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = sql;
|
||||
command.CommandTimeout = ToCommandTimeoutSeconds(TimeSpan.FromSeconds(60));
|
||||
|
||||
foreach (var document in documents)
|
||||
{
|
||||
command.Parameters.Clear();
|
||||
command.Parameters.AddWithValue("doc_id", document.DocId);
|
||||
command.Parameters.AddWithValue("doc_type", document.DocType);
|
||||
command.Parameters.AddWithValue("product", document.Product);
|
||||
command.Parameters.AddWithValue("version", document.Version);
|
||||
command.Parameters.AddWithValue("source_ref", document.SourceRef);
|
||||
command.Parameters.AddWithValue("path", document.Path);
|
||||
command.Parameters.AddWithValue("title", document.Title);
|
||||
command.Parameters.AddWithValue("content_hash", document.ContentHash);
|
||||
command.Parameters.AddWithValue("metadata", NpgsqlDbType.Jsonb, document.Metadata.RootElement.GetRawText());
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InsertChunksAsync(
|
||||
NpgsqlConnection connection,
|
||||
NpgsqlTransaction transaction,
|
||||
IReadOnlyList<KnowledgeChunkDocument> chunks,
|
||||
bool hasEmbeddingVectorColumn,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (chunks.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var sql = hasEmbeddingVectorColumn
|
||||
? """
|
||||
INSERT INTO advisoryai.kb_chunk
|
||||
(
|
||||
chunk_id,
|
||||
doc_id,
|
||||
kind,
|
||||
anchor,
|
||||
section_path,
|
||||
span_start,
|
||||
span_end,
|
||||
title,
|
||||
body,
|
||||
body_tsv,
|
||||
embedding,
|
||||
embedding_vec,
|
||||
metadata,
|
||||
indexed_at
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@chunk_id,
|
||||
@doc_id,
|
||||
@kind,
|
||||
@anchor,
|
||||
@section_path,
|
||||
@span_start,
|
||||
@span_end,
|
||||
@title,
|
||||
@body,
|
||||
setweight(to_tsvector('simple', coalesce(@title, '')), 'A') ||
|
||||
setweight(to_tsvector('simple', coalesce(@section_path, '')), 'B') ||
|
||||
setweight(to_tsvector('simple', coalesce(@body, '')), 'D'),
|
||||
@embedding,
|
||||
CAST(@embedding_vector AS vector),
|
||||
@metadata::jsonb,
|
||||
NOW()
|
||||
);
|
||||
"""
|
||||
: """
|
||||
INSERT INTO advisoryai.kb_chunk
|
||||
(
|
||||
chunk_id,
|
||||
doc_id,
|
||||
kind,
|
||||
anchor,
|
||||
section_path,
|
||||
span_start,
|
||||
span_end,
|
||||
title,
|
||||
body,
|
||||
body_tsv,
|
||||
embedding,
|
||||
metadata,
|
||||
indexed_at
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@chunk_id,
|
||||
@doc_id,
|
||||
@kind,
|
||||
@anchor,
|
||||
@section_path,
|
||||
@span_start,
|
||||
@span_end,
|
||||
@title,
|
||||
@body,
|
||||
setweight(to_tsvector('simple', coalesce(@title, '')), 'A') ||
|
||||
setweight(to_tsvector('simple', coalesce(@section_path, '')), 'B') ||
|
||||
setweight(to_tsvector('simple', coalesce(@body, '')), 'D'),
|
||||
@embedding,
|
||||
@metadata::jsonb,
|
||||
NOW()
|
||||
);
|
||||
""";
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = sql;
|
||||
command.CommandTimeout = ToCommandTimeoutSeconds(TimeSpan.FromSeconds(120));
|
||||
|
||||
foreach (var chunk in chunks)
|
||||
{
|
||||
var embedding = chunk.Embedding;
|
||||
command.Parameters.Clear();
|
||||
command.Parameters.AddWithValue("chunk_id", chunk.ChunkId);
|
||||
command.Parameters.AddWithValue("doc_id", chunk.DocId);
|
||||
command.Parameters.AddWithValue("kind", chunk.Kind);
|
||||
command.Parameters.AddWithValue("anchor", (object?)chunk.Anchor ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("section_path", (object?)chunk.SectionPath ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("span_start", chunk.SpanStart);
|
||||
command.Parameters.AddWithValue("span_end", chunk.SpanEnd);
|
||||
command.Parameters.AddWithValue("title", chunk.Title);
|
||||
command.Parameters.AddWithValue("body", chunk.Body);
|
||||
command.Parameters.AddWithValue(
|
||||
"embedding",
|
||||
NpgsqlDbType.Array | NpgsqlDbType.Real,
|
||||
embedding is null ? Array.Empty<float>() : embedding);
|
||||
command.Parameters.AddWithValue("metadata", NpgsqlDbType.Jsonb, chunk.Metadata.RootElement.GetRawText());
|
||||
|
||||
if (hasEmbeddingVectorColumn)
|
||||
{
|
||||
var vectorLiteral = embedding is null ? (object)DBNull.Value : BuildVectorLiteral(embedding);
|
||||
command.Parameters.AddWithValue("embedding_vector", vectorLiteral);
|
||||
}
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task InsertApiSpecsAsync(
|
||||
NpgsqlConnection connection,
|
||||
NpgsqlTransaction transaction,
|
||||
IReadOnlyList<KnowledgeApiSpec> specs,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (specs.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO advisoryai.api_spec
|
||||
(
|
||||
spec_id,
|
||||
doc_id,
|
||||
service,
|
||||
openapi_version,
|
||||
title,
|
||||
version,
|
||||
source_path,
|
||||
content_json,
|
||||
indexed_at
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@spec_id,
|
||||
@doc_id,
|
||||
@service,
|
||||
@openapi_version,
|
||||
@title,
|
||||
@version,
|
||||
@source_path,
|
||||
@content_json::jsonb,
|
||||
NOW()
|
||||
);
|
||||
""";
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = sql;
|
||||
command.CommandTimeout = ToCommandTimeoutSeconds(TimeSpan.FromSeconds(120));
|
||||
|
||||
foreach (var spec in specs)
|
||||
{
|
||||
command.Parameters.Clear();
|
||||
command.Parameters.AddWithValue("spec_id", spec.SpecId);
|
||||
command.Parameters.AddWithValue("doc_id", spec.DocId);
|
||||
command.Parameters.AddWithValue("service", spec.Service);
|
||||
command.Parameters.AddWithValue("openapi_version", (object?)spec.OpenApiVersion ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("title", (object?)spec.Title ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("version", (object?)spec.Version ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("source_path", spec.SourcePath);
|
||||
command.Parameters.AddWithValue("content_json", NpgsqlDbType.Jsonb, spec.Content.RootElement.GetRawText());
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task InsertApiOperationsAsync(
|
||||
NpgsqlConnection connection,
|
||||
NpgsqlTransaction transaction,
|
||||
IReadOnlyList<KnowledgeApiOperation> operations,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (operations.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO advisoryai.api_operation
|
||||
(
|
||||
operation_key,
|
||||
spec_id,
|
||||
chunk_id,
|
||||
service,
|
||||
method,
|
||||
path,
|
||||
operation_id,
|
||||
tags,
|
||||
summary,
|
||||
request_json,
|
||||
responses_json,
|
||||
security_json,
|
||||
indexed_at
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@operation_key,
|
||||
@spec_id,
|
||||
@chunk_id,
|
||||
@service,
|
||||
@method,
|
||||
@path,
|
||||
@operation_id,
|
||||
@tags,
|
||||
@summary,
|
||||
@request_json::jsonb,
|
||||
@responses_json::jsonb,
|
||||
@security_json::jsonb,
|
||||
NOW()
|
||||
);
|
||||
""";
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = sql;
|
||||
command.CommandTimeout = ToCommandTimeoutSeconds(TimeSpan.FromSeconds(120));
|
||||
|
||||
foreach (var operation in operations)
|
||||
{
|
||||
command.Parameters.Clear();
|
||||
command.Parameters.AddWithValue("operation_key", operation.OperationKey);
|
||||
command.Parameters.AddWithValue("spec_id", operation.SpecId);
|
||||
command.Parameters.AddWithValue("chunk_id", operation.ChunkId);
|
||||
command.Parameters.AddWithValue("service", operation.Service);
|
||||
command.Parameters.AddWithValue("method", operation.Method);
|
||||
command.Parameters.AddWithValue("path", operation.Path);
|
||||
command.Parameters.AddWithValue("operation_id", (object?)operation.OperationId ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue(
|
||||
"tags",
|
||||
NpgsqlDbType.Array | NpgsqlDbType.Text,
|
||||
operation.Tags.Count == 0 ? Array.Empty<string>() : operation.Tags.ToArray());
|
||||
command.Parameters.AddWithValue("summary", (object?)operation.Summary ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("request_json", NpgsqlDbType.Jsonb, operation.RequestJson.RootElement.GetRawText());
|
||||
command.Parameters.AddWithValue("responses_json", NpgsqlDbType.Jsonb, operation.ResponsesJson.RootElement.GetRawText());
|
||||
command.Parameters.AddWithValue("security_json", NpgsqlDbType.Jsonb, operation.SecurityJson.RootElement.GetRawText());
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task InsertDoctorProjectionsAsync(
|
||||
NpgsqlConnection connection,
|
||||
NpgsqlTransaction transaction,
|
||||
IReadOnlyList<KnowledgeDoctorProjection> projections,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (projections.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO advisoryai.doctor_search_projection
|
||||
(
|
||||
check_code,
|
||||
chunk_id,
|
||||
title,
|
||||
severity,
|
||||
remediation,
|
||||
run_command,
|
||||
symptoms,
|
||||
references_json,
|
||||
metadata,
|
||||
indexed_at
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@check_code,
|
||||
@chunk_id,
|
||||
@title,
|
||||
@severity,
|
||||
@remediation,
|
||||
@run_command,
|
||||
@symptoms,
|
||||
@references_json::jsonb,
|
||||
@metadata::jsonb,
|
||||
NOW()
|
||||
);
|
||||
""";
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = sql;
|
||||
command.CommandTimeout = ToCommandTimeoutSeconds(TimeSpan.FromSeconds(60));
|
||||
|
||||
foreach (var projection in projections)
|
||||
{
|
||||
command.Parameters.Clear();
|
||||
command.Parameters.AddWithValue("check_code", projection.CheckCode);
|
||||
command.Parameters.AddWithValue("chunk_id", projection.ChunkId);
|
||||
command.Parameters.AddWithValue("title", projection.Title);
|
||||
command.Parameters.AddWithValue("severity", projection.Severity);
|
||||
command.Parameters.AddWithValue("remediation", projection.Remediation);
|
||||
command.Parameters.AddWithValue("run_command", projection.RunCommand);
|
||||
command.Parameters.AddWithValue(
|
||||
"symptoms",
|
||||
NpgsqlDbType.Array | NpgsqlDbType.Text,
|
||||
projection.Symptoms.Count == 0 ? Array.Empty<string>() : projection.Symptoms.ToArray());
|
||||
command.Parameters.AddWithValue("references_json", NpgsqlDbType.Jsonb, projection.ReferencesJson.RootElement.GetRawText());
|
||||
command.Parameters.AddWithValue("metadata", NpgsqlDbType.Jsonb, projection.MetadataJson.RootElement.GetRawText());
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> IsMigrationAppliedAsync(
|
||||
NpgsqlConnection connection,
|
||||
NpgsqlTransaction transaction,
|
||||
string migrationName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT EXISTS(
|
||||
SELECT 1
|
||||
FROM advisoryai.__migration_history
|
||||
WHERE migration_name = @migration_name
|
||||
);
|
||||
""";
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = sql;
|
||||
command.CommandTimeout = ToCommandTimeoutSeconds(TimeSpan.FromSeconds(30));
|
||||
command.Parameters.AddWithValue("migration_name", migrationName);
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return result is bool value && value;
|
||||
}
|
||||
|
||||
private static async Task ExecuteNonQueryAsync(
|
||||
NpgsqlConnection connection,
|
||||
NpgsqlTransaction? transaction,
|
||||
string sql,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = sql;
|
||||
command.CommandTimeout = ToCommandTimeoutSeconds(TimeSpan.FromSeconds(120));
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static int ToCommandTimeoutSeconds(TimeSpan timeout)
|
||||
{
|
||||
if (timeout <= TimeSpan.Zero)
|
||||
{
|
||||
return 30;
|
||||
}
|
||||
|
||||
return Math.Max(1, (int)Math.Ceiling(timeout.TotalSeconds));
|
||||
}
|
||||
|
||||
private NpgsqlCommand CreateCommand(string sql, TimeSpan timeout)
|
||||
{
|
||||
var command = GetDataSource().CreateCommand(sql);
|
||||
command.CommandTimeout = ToCommandTimeoutSeconds(timeout);
|
||||
return command;
|
||||
}
|
||||
|
||||
private bool IsConfigured()
|
||||
{
|
||||
return _options.Enabled && !string.IsNullOrWhiteSpace(_options.ConnectionString);
|
||||
}
|
||||
|
||||
private NpgsqlDataSource GetDataSource()
|
||||
{
|
||||
if (_dataSource.Value is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"AdvisoryAI knowledge search is not configured. Set AdvisoryAI:KnowledgeSearch:ConnectionString.");
|
||||
}
|
||||
|
||||
return _dataSource.Value;
|
||||
}
|
||||
|
||||
private NpgsqlDataSource? CreateDataSource()
|
||||
{
|
||||
if (!IsConfigured())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = new NpgsqlDataSourceBuilder(_options.ConnectionString);
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<(string Name, string Sql)> LoadMigrationScripts()
|
||||
{
|
||||
var assembly = typeof(PostgresKnowledgeSearchStore).Assembly;
|
||||
var scripts = assembly.GetManifestResourceNames()
|
||||
.Where(static resource =>
|
||||
resource.Contains(".Storage.Migrations.", StringComparison.Ordinal) &&
|
||||
resource.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(resource =>
|
||||
{
|
||||
using var stream = assembly.GetManifestResourceStream(resource);
|
||||
if (stream is null)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
using var reader = new StreamReader(stream);
|
||||
var sql = reader.ReadToEnd();
|
||||
var marker = ".Storage.Migrations.";
|
||||
var fileName = resource[(resource.LastIndexOf(marker, StringComparison.Ordinal) + marker.Length)..];
|
||||
return (Name: fileName, Sql: sql);
|
||||
})
|
||||
.Where(static item => !string.IsNullOrWhiteSpace(item.Name) && !string.IsNullOrWhiteSpace(item.Sql))
|
||||
.OrderBy(static item => item.Name, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (scripts.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"No AdvisoryAI migration scripts were embedded. Ensure Storage/Migrations/*.sql are embedded resources.");
|
||||
}
|
||||
|
||||
return scripts;
|
||||
}
|
||||
|
||||
private static JsonDocument ParseJsonDocument(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return EmptyJsonDocument;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonDocument.Parse(json);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return EmptyJsonDocument;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
[
|
||||
{
|
||||
"checkCode": "check.core.disk.space",
|
||||
"title": "Disk space availability",
|
||||
"severity": "high",
|
||||
"description": "Low disk space can block ingestion pipelines and worker execution.",
|
||||
"remediation": "Free disk space and verify retention settings.",
|
||||
"runCommand": "stella doctor run --check check.core.disk.space",
|
||||
"symptoms": [
|
||||
"no space left on device",
|
||||
"disk full",
|
||||
"write failure"
|
||||
],
|
||||
"tags": [
|
||||
"doctor",
|
||||
"storage",
|
||||
"core"
|
||||
],
|
||||
"references": [
|
||||
"docs/operations/devops/runbooks/deployment-upgrade.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"checkCode": "check.core.db.connectivity",
|
||||
"title": "PostgreSQL connectivity",
|
||||
"severity": "high",
|
||||
"description": "Doctor failed to connect to PostgreSQL or connection health checks timed out.",
|
||||
"remediation": "Validate credentials, network reachability, and TLS settings.",
|
||||
"runCommand": "stella doctor run --check check.core.db.connectivity",
|
||||
"symptoms": [
|
||||
"database unavailable",
|
||||
"connection refused",
|
||||
"timeout expired"
|
||||
],
|
||||
"tags": [
|
||||
"doctor",
|
||||
"database",
|
||||
"connectivity"
|
||||
],
|
||||
"references": [
|
||||
"docs/INSTALL_GUIDE.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"checkCode": "check.security.oidc.readiness",
|
||||
"title": "OIDC readiness",
|
||||
"severity": "warn",
|
||||
"description": "OIDC prerequisites are missing or identity issuer metadata is not reachable.",
|
||||
"remediation": "Verify issuer URL, JWKS availability, and Authority client configuration.",
|
||||
"runCommand": "stella doctor run --check check.security.oidc.readiness",
|
||||
"symptoms": [
|
||||
"oidc setup",
|
||||
"invalid issuer",
|
||||
"jwks fetch failed"
|
||||
],
|
||||
"tags": [
|
||||
"doctor",
|
||||
"security",
|
||||
"oidc"
|
||||
],
|
||||
"references": [
|
||||
"docs/modules/authority/architecture.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"checkCode": "check.router.gateway.routes",
|
||||
"title": "Router route registration",
|
||||
"severity": "warn",
|
||||
"description": "Expected gateway routes were not registered or health probes failed.",
|
||||
"remediation": "Inspect route tables and refresh router registration.",
|
||||
"runCommand": "stella doctor run --check check.router.gateway.routes",
|
||||
"symptoms": [
|
||||
"route missing",
|
||||
"404 on expected endpoint",
|
||||
"gateway routing"
|
||||
],
|
||||
"tags": [
|
||||
"doctor",
|
||||
"router",
|
||||
"gateway"
|
||||
],
|
||||
"references": [
|
||||
"docs/modules/router/README.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"checkCode": "check.integrations.secrets.binding",
|
||||
"title": "Integration secret binding",
|
||||
"severity": "medium",
|
||||
"description": "Integration connectors cannot resolve configured secrets.",
|
||||
"remediation": "Validate secret provider configuration and rotate invalid credentials.",
|
||||
"runCommand": "stella doctor run --check check.integrations.secrets.binding",
|
||||
"symptoms": [
|
||||
"secret missing",
|
||||
"invalid credential",
|
||||
"auth failed"
|
||||
],
|
||||
"tags": [
|
||||
"doctor",
|
||||
"integrations",
|
||||
"secrets"
|
||||
],
|
||||
"references": [
|
||||
"docs/modules/platform/architecture-overview.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"checkCode": "check.release.policy.gate",
|
||||
"title": "Policy gate prerequisites",
|
||||
"severity": "warn",
|
||||
"description": "Release policy gate prerequisites are incomplete for the target environment.",
|
||||
"remediation": "Review required approvals, policy bundle versions, and attestations.",
|
||||
"runCommand": "stella doctor run --check check.release.policy.gate",
|
||||
"symptoms": [
|
||||
"policy gate failed",
|
||||
"missing attestation",
|
||||
"promotion blocked"
|
||||
],
|
||||
"tags": [
|
||||
"doctor",
|
||||
"release",
|
||||
"policy"
|
||||
],
|
||||
"references": [
|
||||
"docs/operations/upgrade-runbook.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"checkCode": "check.airgap.bundle.integrity",
|
||||
"title": "Air-gap bundle integrity",
|
||||
"severity": "high",
|
||||
"description": "Offline bundle integrity validation failed.",
|
||||
"remediation": "Rebuild the bundle and verify signatures and checksums before import.",
|
||||
"runCommand": "stella doctor run --check check.airgap.bundle.integrity",
|
||||
"symptoms": [
|
||||
"checksum mismatch",
|
||||
"signature invalid",
|
||||
"offline import failed"
|
||||
],
|
||||
"tags": [
|
||||
"doctor",
|
||||
"airgap",
|
||||
"integrity"
|
||||
],
|
||||
"references": [
|
||||
"docs/operations/devops/runbooks/deployment-upgrade.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"checkCode": "check.telemetry.pipeline.delivery",
|
||||
"title": "Telemetry delivery pipeline",
|
||||
"severity": "medium",
|
||||
"description": "Telemetry queue backlog is growing or delivery workers are stalled.",
|
||||
"remediation": "Scale workers, inspect queue depth, and validate downstream availability.",
|
||||
"runCommand": "stella doctor run --check check.telemetry.pipeline.delivery",
|
||||
"symptoms": [
|
||||
"telemetry lag",
|
||||
"queue backlog",
|
||||
"delivery timeout"
|
||||
],
|
||||
"tags": [
|
||||
"doctor",
|
||||
"telemetry",
|
||||
"queue"
|
||||
],
|
||||
"references": [
|
||||
"docs/modules/platform/architecture-overview.md"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -11,6 +11,14 @@
|
||||
<InternalsVisibleTo Include="StellaOps.Bench.AdvisoryAI" />
|
||||
<InternalsVisibleTo Include="StellaOps.AdvisoryAI.Tests" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Storage/Migrations/*.sql" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="KnowledgeSearch/doctor-search-seed.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
-- AdvisoryAI Knowledge Search schema
|
||||
-- Sprint: SPRINT_20260222_051_AdvisoryAI_knowledge_search_docs_api_doctor
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS advisoryai;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
EXCEPTION
|
||||
WHEN OTHERS THEN
|
||||
RAISE NOTICE 'pgvector extension is unavailable; AdvisoryAI Knowledge Search will use array embeddings.';
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS advisoryai.kb_doc
|
||||
(
|
||||
doc_id TEXT PRIMARY KEY,
|
||||
doc_type TEXT NOT NULL,
|
||||
product TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
source_ref TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_kb_doc_unique_source
|
||||
ON advisoryai.kb_doc (doc_type, product, version, source_ref, path);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS advisoryai.kb_chunk
|
||||
(
|
||||
chunk_id TEXT PRIMARY KEY,
|
||||
doc_id TEXT NOT NULL REFERENCES advisoryai.kb_doc (doc_id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL,
|
||||
anchor TEXT,
|
||||
section_path TEXT,
|
||||
span_start INTEGER NOT NULL DEFAULT 0,
|
||||
span_end INTEGER NOT NULL DEFAULT 0,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
body_tsv TSVECTOR NOT NULL,
|
||||
embedding REAL[],
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kb_chunk_doc_id
|
||||
ON advisoryai.kb_chunk (doc_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kb_chunk_kind
|
||||
ON advisoryai.kb_chunk (kind);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kb_chunk_body_tsv
|
||||
ON advisoryai.kb_chunk USING GIN (body_tsv);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'vector') THEN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'advisoryai'
|
||||
AND table_name = 'kb_chunk'
|
||||
AND column_name = 'embedding_vec')
|
||||
THEN
|
||||
ALTER TABLE advisoryai.kb_chunk
|
||||
ADD COLUMN embedding_vec vector(384);
|
||||
END IF;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kb_chunk_embedding_vec_hnsw
|
||||
ON advisoryai.kb_chunk USING hnsw (embedding_vec vector_cosine_ops);
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS advisoryai.api_spec
|
||||
(
|
||||
spec_id TEXT PRIMARY KEY,
|
||||
doc_id TEXT NOT NULL REFERENCES advisoryai.kb_doc (doc_id) ON DELETE CASCADE,
|
||||
service TEXT NOT NULL,
|
||||
openapi_version TEXT,
|
||||
title TEXT,
|
||||
version TEXT,
|
||||
source_path TEXT NOT NULL,
|
||||
content_json JSONB NOT NULL,
|
||||
indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_api_spec_service
|
||||
ON advisoryai.api_spec (service);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS advisoryai.api_operation
|
||||
(
|
||||
operation_key TEXT PRIMARY KEY,
|
||||
spec_id TEXT NOT NULL REFERENCES advisoryai.api_spec (spec_id) ON DELETE CASCADE,
|
||||
chunk_id TEXT NOT NULL REFERENCES advisoryai.kb_chunk (chunk_id) ON DELETE CASCADE,
|
||||
service TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
operation_id TEXT,
|
||||
tags TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
summary TEXT,
|
||||
request_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
responses_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
security_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_api_operation_method_path
|
||||
ON advisoryai.api_operation (service, method, path);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_api_operation_operation_id
|
||||
ON advisoryai.api_operation (operation_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS advisoryai.doctor_search_projection
|
||||
(
|
||||
check_code TEXT PRIMARY KEY,
|
||||
chunk_id TEXT NOT NULL REFERENCES advisoryai.kb_chunk (chunk_id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
severity TEXT NOT NULL,
|
||||
remediation TEXT NOT NULL,
|
||||
run_command TEXT NOT NULL,
|
||||
symptoms TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
references_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_doctor_projection_severity
|
||||
ON advisoryai.doctor_search_projection (severity);
|
||||
@@ -40,6 +40,12 @@ public sealed class ChatIntegrationTests : IClassFixture<WebApplicationFactory<S
|
||||
});
|
||||
|
||||
_client = _factory.CreateClient();
|
||||
// Current advisory-ai endpoints authorize using scope + actor headers.
|
||||
_client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "test-user");
|
||||
_client.DefaultRequestHeaders.Add("X-StellaOps-Scopes", "advisory:chat chat:user");
|
||||
_client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "test-tenant");
|
||||
|
||||
// Keep legacy headers for compatibility with older code paths.
|
||||
_client.DefaultRequestHeaders.Add("X-StellaOps-User", "test-user");
|
||||
_client.DefaultRequestHeaders.Add("X-StellaOps-Client", "test-client");
|
||||
_client.DefaultRequestHeaders.Add("X-StellaOps-Roles", "chat:user");
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
using StellaOps.AdvisoryAI.WebService.Endpoints;
|
||||
using StellaOps.TestKit;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.Integration;
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
public sealed class KnowledgeSearchEndpointsIntegrationTests : IDisposable
|
||||
{
|
||||
private readonly WebApplicationFactory<StellaOps.AdvisoryAI.WebService.Program> _factory;
|
||||
|
||||
public KnowledgeSearchEndpointsIntegrationTests()
|
||||
{
|
||||
var baseFactory = new WebApplicationFactory<StellaOps.AdvisoryAI.WebService.Program>();
|
||||
_factory = baseFactory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IKnowledgeSearchService>();
|
||||
services.RemoveAll<IKnowledgeIndexer>();
|
||||
services.AddSingleton<IKnowledgeSearchService, StubKnowledgeSearchService>();
|
||||
services.AddSingleton<IKnowledgeIndexer, StubKnowledgeIndexer>();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Search_MissingScope_ReturnsForbidden()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/v1/advisory-ai/search", new AdvisoryKnowledgeSearchRequest
|
||||
{
|
||||
Q = "docker login fails"
|
||||
});
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Search_WithScope_ReturnsGroundedResults()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Scopes", "advisory:search");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/v1/advisory-ai/search", new AdvisoryKnowledgeSearchRequest
|
||||
{
|
||||
Q = "docker login fails",
|
||||
K = 5,
|
||||
Filters = new AdvisoryKnowledgeSearchFilter
|
||||
{
|
||||
Type = ["docs", "doctor"]
|
||||
}
|
||||
});
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<AdvisoryKnowledgeSearchResponse>();
|
||||
payload.Should().NotBeNull();
|
||||
payload!.Query.Should().Be("docker login fails");
|
||||
payload.Results.Should().HaveCount(2);
|
||||
payload.Results.Should().Contain(result => result.Type == "docs" && result.Open.Docs != null);
|
||||
payload.Results.Should().Contain(result => result.Type == "doctor" && result.Open.Doctor != null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rebuild_WithAdminScope_ReturnsSummary()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Scopes", "advisory:index:write");
|
||||
|
||||
var response = await client.PostAsync("/v1/advisory-ai/index/rebuild", content: null);
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<AdvisoryKnowledgeRebuildResponse>();
|
||||
payload.Should().NotBeNull();
|
||||
payload!.DocumentCount.Should().Be(11);
|
||||
payload.ChunkCount.Should().Be(42);
|
||||
payload.ApiOperationCount.Should().Be(7);
|
||||
payload.DoctorProjectionCount.Should().Be(5);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_factory.Dispose();
|
||||
}
|
||||
|
||||
private sealed class StubKnowledgeSearchService : IKnowledgeSearchService
|
||||
{
|
||||
public Task<KnowledgeSearchResponse> SearchAsync(KnowledgeSearchRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new[]
|
||||
{
|
||||
new KnowledgeSearchResult(
|
||||
"docs",
|
||||
"Registry login troubleshooting",
|
||||
"Use custom CA bundle and verify trust store.",
|
||||
0.93d,
|
||||
new KnowledgeOpenAction(
|
||||
KnowledgeOpenActionType.Docs,
|
||||
Docs: new KnowledgeOpenDocAction(
|
||||
"docs/operations/troubleshooting.md",
|
||||
"docker-registry-login-fails",
|
||||
42,
|
||||
68))),
|
||||
new KnowledgeSearchResult(
|
||||
"doctor",
|
||||
"PostgreSQL connectivity",
|
||||
"Database endpoint is not reachable.",
|
||||
0.89d,
|
||||
new KnowledgeOpenAction(
|
||||
KnowledgeOpenActionType.Doctor,
|
||||
Doctor: new KnowledgeOpenDoctorAction(
|
||||
"check.core.db.connectivity",
|
||||
"high",
|
||||
true,
|
||||
"stella doctor run --check check.core.db.connectivity")))
|
||||
};
|
||||
|
||||
return Task.FromResult(new KnowledgeSearchResponse(
|
||||
request.Q.Trim(),
|
||||
request.K ?? 10,
|
||||
results,
|
||||
new KnowledgeSearchDiagnostics(
|
||||
FtsMatches: 12,
|
||||
VectorMatches: 0,
|
||||
DurationMs: 8,
|
||||
UsedVector: false,
|
||||
Mode: "fts-only")));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubKnowledgeIndexer : IKnowledgeIndexer
|
||||
{
|
||||
public Task<KnowledgeRebuildSummary> RebuildAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new KnowledgeRebuildSummary(
|
||||
DocumentCount: 11,
|
||||
ChunkCount: 42,
|
||||
ApiSpecCount: 3,
|
||||
ApiOperationCount: 7,
|
||||
DoctorProjectionCount: 5,
|
||||
DurationMs: 120));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using StellaOps.AdvisoryAI.WebService.Endpoints;
|
||||
using StellaOps.TestKit;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.Integration;
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
public sealed class LlmAdapterEndpointsIntegrationTests
|
||||
: IClassFixture<WebApplicationFactory<StellaOps.AdvisoryAI.WebService.Program>>, IDisposable
|
||||
{
|
||||
private readonly WebApplicationFactory<StellaOps.AdvisoryAI.WebService.Program> _factory;
|
||||
private readonly string? _previousAdaptersEnabled;
|
||||
private readonly string? _previousProviderDirectory;
|
||||
|
||||
public LlmAdapterEndpointsIntegrationTests(WebApplicationFactory<StellaOps.AdvisoryAI.WebService.Program> factory)
|
||||
{
|
||||
_previousAdaptersEnabled = Environment.GetEnvironmentVariable("ADVISORYAI__AdvisoryAI__Adapters__Llm__Enabled");
|
||||
_previousProviderDirectory = Environment.GetEnvironmentVariable("ADVISORYAI__AdvisoryAI__LlmProviders__ConfigDirectory");
|
||||
|
||||
Environment.SetEnvironmentVariable("ADVISORYAI__AdvisoryAI__Adapters__Llm__Enabled", "true");
|
||||
Environment.SetEnvironmentVariable("ADVISORYAI__AdvisoryAI__LlmProviders__ConfigDirectory", ResolveLlmProviderDirectory());
|
||||
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListProviders_MissingScope_Returns403()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
var response = await client.GetAsync("/v1/advisory-ai/adapters/llm/providers");
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListProviders_WithAdapterReadScope_ReturnsConfiguredProviders()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Scopes", "advisory:adapter:read");
|
||||
|
||||
var response = await client.GetAsync("/v1/advisory-ai/adapters/llm/providers");
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var providers = await response.Content.ReadFromJsonAsync<List<LlmProviderExposureResponse>>();
|
||||
providers.Should().NotBeNull();
|
||||
providers!.Should().Contain(p => p.ProviderId == "openai");
|
||||
|
||||
var openAi = providers.Single(p => p.ProviderId == "openai");
|
||||
openAi.CompletionPath.Should().Be("/v1/advisory-ai/adapters/openai/v1/chat/completions");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProviderCompletion_UnknownProvider_Returns404()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Scopes", "advisory:adapter:invoke");
|
||||
|
||||
var request = new OpenAiChatCompletionRequest
|
||||
{
|
||||
Messages =
|
||||
[
|
||||
new OpenAiChatMessageRequest
|
||||
{
|
||||
Role = "user",
|
||||
Content = "hello"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/v1/advisory-ai/adapters/llm/not-registered/chat/completions",
|
||||
request);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
private static string ResolveLlmProviderDirectory()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
var candidate = Path.Combine(current.FullName, "etc", "llm-providers");
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not resolve etc/llm-providers from test execution directory.");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("ADVISORYAI__AdvisoryAI__Adapters__Llm__Enabled", _previousAdaptersEnabled);
|
||||
Environment.SetEnvironmentVariable("ADVISORYAI__AdvisoryAI__LlmProviders__ConfigDirectory", _previousProviderDirectory);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
using StellaOps.AdvisoryAI.Vectorization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.KnowledgeSearch;
|
||||
|
||||
public sealed class KnowledgeSearchBenchmarkTests : IDisposable
|
||||
{
|
||||
private readonly List<string> _tempDirectories = [];
|
||||
|
||||
[Fact]
|
||||
public async Task DatasetGenerator_ProducesDeterministicDatasetWithAllTypes()
|
||||
{
|
||||
var repositoryRoot = await CreateFixtureRepositoryAsync();
|
||||
var options = Options.Create(new KnowledgeSearchOptions
|
||||
{
|
||||
Product = "stella-ops",
|
||||
Version = "test",
|
||||
RepositoryRoot = repositoryRoot,
|
||||
MarkdownRoots = ["docs"],
|
||||
OpenApiRoots = ["specs"],
|
||||
DoctorSeedPath = "doctor/doctor-search-seed.json",
|
||||
});
|
||||
|
||||
var generator = new KnowledgeSearchBenchmarkDatasetGenerator(options, NullLogger<KnowledgeSearchBenchmarkDatasetGenerator>.Instance);
|
||||
var datasetOptions = new KnowledgeBenchmarkDatasetOptions
|
||||
{
|
||||
MinQueryCount = 1000,
|
||||
MaxQueryCount = 1200,
|
||||
VariantsPerTarget = 6,
|
||||
};
|
||||
|
||||
var first = await generator.GenerateAsync(datasetOptions, CancellationToken.None);
|
||||
var second = await generator.GenerateAsync(datasetOptions, CancellationToken.None);
|
||||
|
||||
first.Cases.Count.Should().BeGreaterThanOrEqualTo(1000);
|
||||
first.Cases.Count.Should().BeLessThanOrEqualTo(1200);
|
||||
first.Cases.Select(static item => item.Type).Distinct().Should().BeEquivalentTo(["docs", "api", "doctor"]);
|
||||
first.Cases.Select(static item => item.CaseId).Should().Equal(second.Cases.Select(static item => item.CaseId));
|
||||
first.Cases.Select(static item => item.Query).Should().Equal(second.Cases.Select(static item => item.Query));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BenchmarkRunner_ComputesRecallAndLatencyMetrics()
|
||||
{
|
||||
var dataset = new KnowledgeBenchmarkDataset(
|
||||
"stella-ops",
|
||||
"test",
|
||||
DateTimeOffset.UtcNow,
|
||||
[
|
||||
new KnowledgeBenchmarkCase(
|
||||
"case-docs",
|
||||
"registry login troubleshooting",
|
||||
"docs",
|
||||
new KnowledgeBenchmarkExpectedTarget
|
||||
{
|
||||
Type = "docs",
|
||||
Path = "docs/runbook.md",
|
||||
Anchor = "registry-login",
|
||||
}),
|
||||
new KnowledgeBenchmarkCase(
|
||||
"case-api",
|
||||
"POST /v1/agents/register",
|
||||
"api",
|
||||
new KnowledgeBenchmarkExpectedTarget
|
||||
{
|
||||
Type = "api",
|
||||
Method = "POST",
|
||||
ApiPath = "/v1/agents/register",
|
||||
OperationId = "registerAgent",
|
||||
}),
|
||||
new KnowledgeBenchmarkCase(
|
||||
"case-doctor",
|
||||
"check.core.db.connectivity",
|
||||
"doctor",
|
||||
new KnowledgeBenchmarkExpectedTarget
|
||||
{
|
||||
Type = "doctor",
|
||||
CheckCode = "check.core.db.connectivity",
|
||||
}),
|
||||
]);
|
||||
|
||||
var searchService = new StubKnowledgeSearchService();
|
||||
var runner = new KnowledgeSearchBenchmarkRunner(searchService, NullLogger<KnowledgeSearchBenchmarkRunner>.Instance);
|
||||
|
||||
var result = await runner.RunAsync(dataset, new KnowledgeBenchmarkRunOptions
|
||||
{
|
||||
TopK = 5,
|
||||
StabilityPasses = 2,
|
||||
MinRecallAtK = 0.85d,
|
||||
MaxP95LatencyMs = 500d,
|
||||
}, CancellationToken.None);
|
||||
|
||||
result.Overall.Total.Should().Be(3);
|
||||
result.Overall.Matched.Should().Be(3);
|
||||
result.Overall.RecallAtK.Should().Be(1d);
|
||||
result.ByType["docs"].RecallAtK.Should().Be(1d);
|
||||
result.ByType["api"].RecallAtK.Should().Be(1d);
|
||||
result.ByType["doctor"].RecallAtK.Should().Be(1d);
|
||||
result.StabilityRate.Should().Be(1d);
|
||||
result.Passed.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BenchmarkRunner_WithKnowledgeSearchService_MatchesExpectedTargetsWithinTopK()
|
||||
{
|
||||
var searchService = new StellaOps.AdvisoryAI.KnowledgeSearch.KnowledgeSearchService(
|
||||
Options.Create(new KnowledgeSearchOptions
|
||||
{
|
||||
Enabled = true,
|
||||
ConnectionString = "Host=unused",
|
||||
DefaultTopK = 5,
|
||||
FtsCandidateCount = 10,
|
||||
VectorCandidateCount = 10,
|
||||
VectorScanLimit = 10,
|
||||
QueryTimeoutMs = 1000,
|
||||
}),
|
||||
new DeterministicBenchmarkStore(),
|
||||
new EmptyVectorEncoder(),
|
||||
NullLogger<StellaOps.AdvisoryAI.KnowledgeSearch.KnowledgeSearchService>.Instance,
|
||||
TimeProvider.System);
|
||||
|
||||
var runner = new KnowledgeSearchBenchmarkRunner(searchService, NullLogger<KnowledgeSearchBenchmarkRunner>.Instance);
|
||||
var dataset = new KnowledgeBenchmarkDataset(
|
||||
"stella-ops",
|
||||
"test",
|
||||
DateTimeOffset.UtcNow,
|
||||
[
|
||||
new KnowledgeBenchmarkCase(
|
||||
"case-docs",
|
||||
"registry login troubleshooting",
|
||||
"docs",
|
||||
new KnowledgeBenchmarkExpectedTarget
|
||||
{
|
||||
Type = "docs",
|
||||
Path = "docs/runbook.md",
|
||||
Anchor = "registry-login",
|
||||
}),
|
||||
new KnowledgeBenchmarkCase(
|
||||
"case-api",
|
||||
"endpoint for agent registration token",
|
||||
"api",
|
||||
new KnowledgeBenchmarkExpectedTarget
|
||||
{
|
||||
Type = "api",
|
||||
Method = "POST",
|
||||
ApiPath = "/v1/agents/register",
|
||||
OperationId = "registerAgent",
|
||||
}),
|
||||
new KnowledgeBenchmarkCase(
|
||||
"case-doctor",
|
||||
"database unavailable check",
|
||||
"doctor",
|
||||
new KnowledgeBenchmarkExpectedTarget
|
||||
{
|
||||
Type = "doctor",
|
||||
CheckCode = "check.core.db.connectivity",
|
||||
}),
|
||||
]);
|
||||
|
||||
var result = await runner.RunAsync(dataset, new KnowledgeBenchmarkRunOptions
|
||||
{
|
||||
TopK = 5,
|
||||
StabilityPasses = 1,
|
||||
MinRecallAtK = 0.85d,
|
||||
MaxP95LatencyMs = 500d,
|
||||
}, CancellationToken.None);
|
||||
|
||||
result.Overall.Matched.Should().Be(3);
|
||||
result.Overall.RecallAtK.Should().Be(1d);
|
||||
result.Cases.Should().OnlyContain(static item => item.Rank > 0);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var directory in _tempDirectories)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(directory))
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort cleanup for temporary test fixtures.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> CreateFixtureRepositoryAsync()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), $"stellaops-ks-bench-{Guid.NewGuid():N}");
|
||||
_tempDirectories.Add(root);
|
||||
|
||||
Directory.CreateDirectory(Path.Combine(root, "docs"));
|
||||
Directory.CreateDirectory(Path.Combine(root, "specs", "gateway"));
|
||||
Directory.CreateDirectory(Path.Combine(root, "doctor"));
|
||||
|
||||
const string markdown = """
|
||||
# Operations Guide
|
||||
|
||||
## Registry login troubleshooting
|
||||
Check certificates and registry auth settings.
|
||||
|
||||
## Enable OIDC
|
||||
Verify issuer URL and token audiences.
|
||||
|
||||
## Queue backlog remediation
|
||||
Scale worker replicas and inspect queue depth.
|
||||
""";
|
||||
await File.WriteAllTextAsync(Path.Combine(root, "docs", "operations.md"), markdown);
|
||||
|
||||
const string openApi = """
|
||||
{
|
||||
"openapi": "3.0.0",
|
||||
"info": {
|
||||
"title": "Gateway API",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"paths": {
|
||||
"/v1/agents/register": {
|
||||
"post": {
|
||||
"operationId": "registerAgent",
|
||||
"summary": "Register a new agent token",
|
||||
"tags": ["agents", "registration"]
|
||||
}
|
||||
},
|
||||
"/v1/releases/promote": {
|
||||
"post": {
|
||||
"operationId": "promoteRelease",
|
||||
"summary": "Promote release between environments",
|
||||
"tags": ["releases", "promotion"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
await File.WriteAllTextAsync(Path.Combine(root, "specs", "gateway", "openapi.json"), openApi);
|
||||
|
||||
var doctorSeed = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
checkCode = "check.core.db.connectivity",
|
||||
title = "PostgreSQL connectivity",
|
||||
severity = "high",
|
||||
description = "Database endpoint is not reachable.",
|
||||
remediation = "Verify host, credentials, and TLS settings.",
|
||||
runCommand = "stella doctor run --check check.core.db.connectivity",
|
||||
symptoms = new[] { "connection refused", "timeout expired", "database unavailable" },
|
||||
tags = new[] { "doctor", "database", "connectivity" },
|
||||
references = new[] { "docs/operations.md#registry-login-troubleshooting" }
|
||||
},
|
||||
new
|
||||
{
|
||||
checkCode = "check.security.oidc.readiness",
|
||||
title = "OIDC readiness",
|
||||
severity = "warn",
|
||||
description = "OIDC issuer metadata cannot be resolved.",
|
||||
remediation = "Verify OIDC issuer URL and JWKS endpoint.",
|
||||
runCommand = "stella doctor run --check check.security.oidc.readiness",
|
||||
symptoms = new[] { "oidc setup", "invalid issuer", "jwks fetch failed" },
|
||||
tags = new[] { "doctor", "security", "oidc" },
|
||||
references = new[] { "docs/operations.md#enable-oidc" }
|
||||
}
|
||||
};
|
||||
var doctorJson = JsonSerializer.Serialize(doctorSeed, new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
});
|
||||
await File.WriteAllTextAsync(Path.Combine(root, "doctor", "doctor-search-seed.json"), doctorJson);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
private sealed class StubKnowledgeSearchService : IKnowledgeSearchService
|
||||
{
|
||||
public Task<KnowledgeSearchResponse> SearchAsync(KnowledgeSearchRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
KnowledgeSearchResult result = request.Q switch
|
||||
{
|
||||
"registry login troubleshooting" => new KnowledgeSearchResult(
|
||||
"docs",
|
||||
"Registry login troubleshooting",
|
||||
"Check certificates and auth settings.",
|
||||
0.95,
|
||||
new KnowledgeOpenAction(
|
||||
KnowledgeOpenActionType.Docs,
|
||||
Docs: new KnowledgeOpenDocAction("docs/runbook.md", "registry-login", 1, 10))),
|
||||
"POST /v1/agents/register" => new KnowledgeSearchResult(
|
||||
"api",
|
||||
"registerAgent",
|
||||
"Register a new agent token.",
|
||||
0.94,
|
||||
new KnowledgeOpenAction(
|
||||
KnowledgeOpenActionType.Api,
|
||||
Api: new KnowledgeOpenApiAction("gateway", "POST", "/v1/agents/register", "registerAgent"))),
|
||||
_ => new KnowledgeSearchResult(
|
||||
"doctor",
|
||||
"PostgreSQL connectivity",
|
||||
"Database endpoint is not reachable.",
|
||||
0.93,
|
||||
new KnowledgeOpenAction(
|
||||
KnowledgeOpenActionType.Doctor,
|
||||
Doctor: new KnowledgeOpenDoctorAction(
|
||||
"check.core.db.connectivity",
|
||||
"high",
|
||||
true,
|
||||
"stella doctor run --check check.core.db.connectivity"))),
|
||||
};
|
||||
|
||||
return Task.FromResult(new KnowledgeSearchResponse(
|
||||
request.Q,
|
||||
request.K ?? 5,
|
||||
[result],
|
||||
new KnowledgeSearchDiagnostics(1, 0, 12, false, "fts-only")));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class EmptyVectorEncoder : IVectorEncoder
|
||||
{
|
||||
public float[] Encode(string text)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DeterministicBenchmarkStore : IKnowledgeSearchStore
|
||||
{
|
||||
private static readonly KnowledgeChunkRow DocsRow = new(
|
||||
"chunk-docs",
|
||||
"doc-docs",
|
||||
"md_section",
|
||||
"registry-login",
|
||||
"Docs > Registry login troubleshooting",
|
||||
10,
|
||||
25,
|
||||
"Registry login troubleshooting",
|
||||
"Check certificates and registry auth settings.",
|
||||
"Check certificates and registry auth settings.",
|
||||
JsonDocument.Parse("""{"path":"docs/runbook.md","anchor":"registry-login","service":"docs","tags":["docs","troubleshooting"]}"""),
|
||||
null,
|
||||
0.91d);
|
||||
|
||||
private static readonly KnowledgeChunkRow ApiRow = new(
|
||||
"chunk-api",
|
||||
"doc-api",
|
||||
"api_operation",
|
||||
"registeragent",
|
||||
"Gateway > POST /v1/agents/register",
|
||||
0,
|
||||
0,
|
||||
"registerAgent",
|
||||
"POST /v1/agents/register Register a new agent token.",
|
||||
"POST /v1/agents/register Register a new agent token.",
|
||||
JsonDocument.Parse("""{"service":"gateway","method":"POST","path":"/v1/agents/register","operationId":"registerAgent","tags":["agents","registration"]}"""),
|
||||
null,
|
||||
0.9d);
|
||||
|
||||
private static readonly KnowledgeChunkRow DoctorRow = new(
|
||||
"chunk-doctor",
|
||||
"doc-doctor",
|
||||
"doctor_check",
|
||||
"check-core-db-connectivity",
|
||||
"Doctor > PostgreSQL connectivity",
|
||||
0,
|
||||
0,
|
||||
"PostgreSQL connectivity",
|
||||
"Database endpoint is not reachable. Run check.core.db.connectivity.",
|
||||
"Database endpoint is not reachable.",
|
||||
JsonDocument.Parse("""{"checkCode":"check.core.db.connectivity","severity":"high","runCommand":"stella doctor run --check check.core.db.connectivity","service":"doctor","tags":["doctor","database"]}"""),
|
||||
null,
|
||||
0.89d);
|
||||
|
||||
public Task EnsureSchemaAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ReplaceIndexAsync(KnowledgeIndexSnapshot snapshot, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<KnowledgeChunkRow>> SearchFtsAsync(string query, KnowledgeSearchFilter? filters, int take, TimeSpan timeout, CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = new List<KnowledgeChunkRow>(3);
|
||||
if (query.Contains("registry", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
rows.Add(DocsRow);
|
||||
}
|
||||
|
||||
if (query.Contains("agent", StringComparison.OrdinalIgnoreCase) || query.Contains("endpoint", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
rows.Add(ApiRow);
|
||||
}
|
||||
|
||||
if (query.Contains("database", StringComparison.OrdinalIgnoreCase) || query.Contains("db.connectivity", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
rows.Add(DoctorRow);
|
||||
}
|
||||
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
rows.AddRange([DocsRow, ApiRow, DoctorRow]);
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<KnowledgeChunkRow>>(rows.Take(take).ToArray());
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<KnowledgeChunkRow>> LoadVectorCandidatesAsync(float[] queryEmbedding, KnowledgeSearchFilter? filters, int take, TimeSpan timeout, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<KnowledgeChunkRow>>([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ using StellaOps.AirGap.Controller.Endpoints;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
|
||||
using StellaOps.Router.AspNet;
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddAuthentication(HeaderScopeAuthenticationHandler.SchemeName)
|
||||
@@ -18,6 +19,12 @@ builder.Services.AddAirGapController(builder.Configuration);
|
||||
|
||||
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
|
||||
|
||||
// Stella Router integration
|
||||
var routerEnabled = builder.Services.AddRouterMicroservice(
|
||||
builder.Configuration,
|
||||
serviceName: "airgap-controller",
|
||||
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
|
||||
routerOptionsSection: "Router");
|
||||
builder.TryAddStellaOpsLocalBinding("airgap-controller");
|
||||
var app = builder.Build();
|
||||
app.LogStellaOpsLocalHostname("airgap-controller");
|
||||
@@ -25,9 +32,12 @@ app.LogStellaOpsLocalHostname("airgap-controller");
|
||||
app.UseStellaOpsCors();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.TryUseStellaRouter(routerEnabled);
|
||||
app.MapAirGapEndpoints();
|
||||
|
||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||
app.Run();
|
||||
|
||||
// Expose Program class for WebApplicationFactory tests.
|
||||
public partial class Program;
|
||||
|
||||
|
||||
@@ -12,4 +12,8 @@
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="StellaOpsReleaseVersion">
|
||||
<Version>1.0.0-alpha1</Version>
|
||||
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -10,6 +10,7 @@ using StellaOps.AirGap.Time.Parsing;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
using StellaOps.AirGap.Time.Stores;
|
||||
|
||||
using StellaOps.Router.AspNet;
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Core services
|
||||
@@ -38,11 +39,20 @@ builder.Services.AddControllers();
|
||||
|
||||
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
|
||||
|
||||
// Stella Router integration
|
||||
var routerEnabled = builder.Services.AddRouterMicroservice(
|
||||
builder.Configuration,
|
||||
serviceName: "airgap-time",
|
||||
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
|
||||
routerOptionsSection: "Router");
|
||||
builder.TryAddStellaOpsLocalBinding("airgap-time");
|
||||
var app = builder.Build();
|
||||
app.LogStellaOpsLocalHostname("airgap-time");
|
||||
|
||||
app.UseStellaOpsCors();
|
||||
app.TryUseStellaRouter(routerEnabled);
|
||||
app.MapControllers();
|
||||
app.MapHealthChecks("/healthz/ready");
|
||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||
app.Run();
|
||||
|
||||
|
||||
@@ -14,4 +14,8 @@
|
||||
<ProjectReference Include="../StellaOps.AirGap.Importer/StellaOps.AirGap.Importer.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="StellaOpsReleaseVersion">
|
||||
<Version>1.0.0-alpha1</Version>
|
||||
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -377,7 +377,7 @@ internal static class AttestorWebServiceComposition
|
||||
});
|
||||
}
|
||||
|
||||
public static void UseAttestorWebService(this WebApplication app, AttestorOptions attestorOptions, StellaRouterOptionsBase? routerOptions)
|
||||
public static void UseAttestorWebService(this WebApplication app, AttestorOptions attestorOptions, bool routerEnabled)
|
||||
{
|
||||
var guidProvider = app.Services.GetService<IGuidProvider>() ?? SystemGuidProvider.Instance;
|
||||
app.UseSerilogRequestLogging();
|
||||
@@ -411,7 +411,7 @@ internal static class AttestorWebServiceComposition
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.TryUseStellaRouter(routerOptions);
|
||||
app.TryUseStellaRouter(routerEnabled);
|
||||
|
||||
app.MapHealthChecks("/health/ready");
|
||||
app.MapHealthChecks("/health/live");
|
||||
@@ -421,7 +421,7 @@ internal static class AttestorWebServiceComposition
|
||||
app.MapWatchlistEndpoints();
|
||||
app.MapPredicateRegistryEndpoints();
|
||||
|
||||
app.TryRefreshStellaRouterEndpoints(routerOptions);
|
||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||
}
|
||||
|
||||
public static List<X509Certificate2> LoadClientCertificateAuthorities(string? path)
|
||||
|
||||
@@ -24,8 +24,7 @@ public static class PredicateRegistryEndpoints
|
||||
public static void MapPredicateRegistryEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/attestor/predicates")
|
||||
.WithTags("Predicate Registry")
|
||||
.WithOpenApi();
|
||||
.WithTags("Predicate Registry");
|
||||
|
||||
group.MapGet("/", ListPredicateTypes)
|
||||
.WithName("ListPredicateTypes")
|
||||
|
||||
@@ -31,18 +31,18 @@ builder.WebHost.ConfigureAttestorKestrel(attestorOptions, clientCertificateAutho
|
||||
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
|
||||
|
||||
// Stella Router integration
|
||||
var routerOptions = builder.Configuration.GetSection("Attestor:Router").Get<StellaRouterOptionsBase>();
|
||||
builder.Services.TryAddStellaRouter(
|
||||
serviceName: "attestor",
|
||||
version: typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0",
|
||||
routerOptions: routerOptions);
|
||||
var routerEnabled = builder.Services.AddRouterMicroservice(
|
||||
builder.Configuration,
|
||||
serviceName: "attestor",
|
||||
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
|
||||
routerOptionsSection: "Router");
|
||||
|
||||
builder.TryAddStellaOpsLocalBinding("attestor");
|
||||
var app = builder.Build();
|
||||
app.LogStellaOpsLocalHostname("attestor");
|
||||
|
||||
app.UseStellaOpsCors();
|
||||
app.UseAttestorWebService(attestorOptions, routerOptions);
|
||||
app.UseAttestorWebService(attestorOptions, routerEnabled);
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -74,3 +74,6 @@ internal sealed class NoAuthHandler : AuthenticationHandler<AuthenticationScheme
|
||||
public partial class Program
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -34,4 +34,8 @@
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.Watchlist\StellaOps.Attestor.Watchlist.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.Persistence\StellaOps.Attestor.Persistence.csproj" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="StellaOpsReleaseVersion">
|
||||
<Version>1.0.0-alpha1</Version>
|
||||
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -597,6 +597,16 @@ public static class StellaOpsScopes
|
||||
/// </summary>
|
||||
public const string AnalyticsRead = "analytics.read";
|
||||
|
||||
// Platform context scopes
|
||||
public const string PlatformContextRead = "platform.context.read";
|
||||
public const string PlatformContextWrite = "platform.context.write";
|
||||
|
||||
// Doctor scopes
|
||||
public const string DoctorRun = "doctor:run";
|
||||
public const string DoctorRunFull = "doctor:run:full";
|
||||
public const string DoctorExport = "doctor:export";
|
||||
public const string DoctorAdmin = "doctor:admin";
|
||||
|
||||
private static readonly IReadOnlyList<string> AllScopes = BuildAllScopes();
|
||||
private static readonly HashSet<string> KnownScopes = new(AllScopes, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
|
||||
@@ -203,157 +203,52 @@ internal static class AuthorizeEndpointExtensions
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static readonly string LoginTemplate = LoadLoginTemplate();
|
||||
|
||||
private static string LoadLoginTemplate()
|
||||
{
|
||||
var assembly = typeof(AuthorizeEndpointExtensions).Assembly;
|
||||
var resourceName = "StellaOps.Authority.Pages.login.html";
|
||||
using var stream = assembly.GetManifestResourceStream(resourceName)
|
||||
?? throw new InvalidOperationException($"Embedded resource '{resourceName}' not found.");
|
||||
using var reader = new StreamReader(stream);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
private static string BuildLoginHtml(
|
||||
OpenIddictRequest request, string? error = null, string? username = null)
|
||||
{
|
||||
var enc = HtmlEncoder.Default;
|
||||
|
||||
var sb = new StringBuilder(8192);
|
||||
sb.AppendLine("<!DOCTYPE html>");
|
||||
sb.AppendLine("<html lang=\"en\">");
|
||||
sb.AppendLine("<head>");
|
||||
sb.AppendLine("<meta charset=\"utf-8\">");
|
||||
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">");
|
||||
sb.AppendLine("<title>Sign In — StellaOps</title>");
|
||||
sb.AppendLine("<style>");
|
||||
// Build error block
|
||||
var errorBlock = string.IsNullOrWhiteSpace(error)
|
||||
? string.Empty
|
||||
: $"<div class=\"error\">{enc.Encode(error)}</div>";
|
||||
|
||||
// Reset
|
||||
sb.AppendLine("*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}");
|
||||
// Build username value attribute
|
||||
var usernameValue = string.IsNullOrWhiteSpace(username)
|
||||
? string.Empty
|
||||
: $" value=\"{enc.Encode(username)}\"";
|
||||
|
||||
// Body — warm amber light theme matching the Angular app
|
||||
sb.AppendLine("body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;");
|
||||
sb.AppendLine("background:linear-gradient(175deg,#FFFCF5 0%,#FFF9ED 40%,#FFFFFF 100%);");
|
||||
sb.AppendLine("color:#3D2E0A;display:flex;align-items:center;justify-content:center;min-height:100vh;");
|
||||
sb.AppendLine("-webkit-font-smoothing:antialiased;position:relative;overflow:hidden}");
|
||||
|
||||
// Animated background radials
|
||||
sb.AppendLine("body::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;");
|
||||
sb.AppendLine("background:radial-gradient(ellipse 70% 50% at 50% 0%,rgba(245,166,35,0.08) 0%,transparent 60%),");
|
||||
sb.AppendLine("radial-gradient(ellipse 60% 50% at 0% 100%,rgba(245,166,35,0.04) 0%,transparent 50%),");
|
||||
sb.AppendLine("radial-gradient(ellipse 50% 40% at 100% 80%,rgba(212,146,10,0.03) 0%,transparent 50%);");
|
||||
sb.AppendLine("pointer-events:none;z-index:0}");
|
||||
|
||||
// Card — frosted glass on warm light
|
||||
sb.AppendLine(".card{position:relative;z-index:1;background:rgba(255,255,255,0.8);");
|
||||
sb.AppendLine("backdrop-filter:blur(24px) saturate(1.4);-webkit-backdrop-filter:blur(24px) saturate(1.4);");
|
||||
sb.AppendLine("border-radius:24px;padding:2.5rem 2rem 2rem;width:100%;max-width:400px;");
|
||||
sb.AppendLine("border:1px solid rgba(212,201,168,0.25);");
|
||||
sb.AppendLine("box-shadow:0 0 60px rgba(245,166,35,0.06),0 20px 60px rgba(28,18,0,0.06),");
|
||||
sb.AppendLine("0 8px 24px rgba(28,18,0,0.04),inset 0 1px 0 rgba(255,255,255,0.8);");
|
||||
sb.AppendLine("animation:card-entrance 600ms cubic-bezier(0.18,0.89,0.32,1) both}");
|
||||
|
||||
// Logo container
|
||||
sb.AppendLine(".logo-wrap{text-align:center;margin-bottom:0.25rem}");
|
||||
sb.AppendLine(".logo-wrap img{width:56px;height:56px;border-radius:14px;");
|
||||
sb.AppendLine("filter:drop-shadow(0 4px 12px rgba(245,166,35,0.2));");
|
||||
sb.AppendLine("animation:logo-pop 650ms cubic-bezier(0.34,1.56,0.64,1) 100ms both}");
|
||||
|
||||
// Title
|
||||
sb.AppendLine("h1{font-size:1.5rem;text-align:center;margin-bottom:0.25rem;color:#1C1200;font-weight:700;");
|
||||
sb.AppendLine("letter-spacing:-0.03em;animation:slide-up 500ms ease 200ms both}");
|
||||
|
||||
// Subtitle
|
||||
sb.AppendLine(".subtitle{text-align:center;color:#6B5A2E;font-size:.8125rem;margin-bottom:1.5rem;");
|
||||
sb.AppendLine("font-weight:400;animation:fade-in 400ms ease 350ms both}");
|
||||
|
||||
// Error
|
||||
sb.AppendLine(".error{background:#fef2f2;border:1px solid rgba(239,68,68,0.2);color:#991b1b;");
|
||||
sb.AppendLine("padding:.75rem;border-radius:12px;margin-bottom:1rem;font-size:.8125rem;font-weight:500;");
|
||||
sb.AppendLine("display:flex;align-items:center;gap:.5rem}");
|
||||
sb.AppendLine(".error::before{content:'';width:6px;height:6px;border-radius:50%;background:#ef4444;flex-shrink:0}");
|
||||
|
||||
// Labels
|
||||
sb.AppendLine("label{display:block;font-size:.75rem;font-weight:600;color:#6B5A2E;margin-bottom:.375rem;");
|
||||
sb.AppendLine("letter-spacing:0.03em;text-transform:uppercase}");
|
||||
|
||||
// Inputs
|
||||
sb.AppendLine("input[type=text],input[type=password]{width:100%;padding:.75rem .875rem;");
|
||||
sb.AppendLine("background:#FFFCF5;border:1px solid rgba(212,201,168,0.4);border-radius:12px;");
|
||||
sb.AppendLine("color:#3D2E0A;font-size:.9375rem;margin-bottom:1rem;outline:none;font-family:inherit;");
|
||||
sb.AppendLine("transition:border-color .2s,box-shadow .2s}");
|
||||
sb.AppendLine("input[type=text]:focus,input[type=password]:focus{border-color:#F5A623;");
|
||||
sb.AppendLine("box-shadow:0 0 0 3px rgba(245,166,35,0.15)}");
|
||||
sb.AppendLine("input[type=text]::placeholder,input[type=password]::placeholder{color:#9A8F78}");
|
||||
|
||||
// Button — amber gradient CTA
|
||||
sb.AppendLine("button{width:100%;padding:.875rem;margin-top:0.25rem;");
|
||||
sb.AppendLine("background:linear-gradient(135deg,#F5A623 0%,#D4920A 100%);");
|
||||
sb.AppendLine("color:#fff;border:none;border-radius:14px;font-size:1rem;font-weight:600;");
|
||||
sb.AppendLine("cursor:pointer;font-family:inherit;letter-spacing:0.01em;position:relative;overflow:hidden;");
|
||||
sb.AppendLine("transition:transform .22s cubic-bezier(0.18,0.89,0.32,1),box-shadow .22s;");
|
||||
sb.AppendLine("box-shadow:0 2px 12px rgba(245,166,35,0.3),0 1px 3px rgba(28,18,0,0.08)}");
|
||||
sb.AppendLine("button:hover{transform:translateY(-2px);");
|
||||
sb.AppendLine("box-shadow:0 6px 24px rgba(245,166,35,0.4),0 2px 8px rgba(28,18,0,0.08)}");
|
||||
sb.AppendLine("button:active{transform:translateY(0);");
|
||||
sb.AppendLine("box-shadow:0 1px 6px rgba(245,166,35,0.2),0 1px 2px rgba(28,18,0,0.06)}");
|
||||
sb.AppendLine("button:focus-visible{outline:2px solid rgba(245,166,35,0.5);outline-offset:3px}");
|
||||
|
||||
// Shimmer effect on button
|
||||
sb.AppendLine("button::after{content:'';position:absolute;inset:0;");
|
||||
sb.AppendLine("background:linear-gradient(105deg,transparent 38%,rgba(255,255,255,0.3) 50%,transparent 62%);");
|
||||
sb.AppendLine("background-size:250% 100%;animation:shimmer 2.2s ease 1.2s}");
|
||||
|
||||
// Keyframes
|
||||
sb.AppendLine("@keyframes card-entrance{from{opacity:0;transform:translateY(24px) scale(0.97)}to{opacity:1;transform:translateY(0) scale(1)}}");
|
||||
sb.AppendLine("@keyframes logo-pop{from{opacity:0;transform:scale(0.6)}to{opacity:1;transform:scale(1)}}");
|
||||
sb.AppendLine("@keyframes slide-up{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}");
|
||||
sb.AppendLine("@keyframes fade-in{from{opacity:0}to{opacity:1}}");
|
||||
sb.AppendLine("@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-100% 0}}");
|
||||
|
||||
// Reduced motion
|
||||
sb.AppendLine("@media(prefers-reduced-motion:reduce){.card,h1,.subtitle,.logo-wrap img,button::after{animation:none!important}");
|
||||
sb.AppendLine(".card,h1,.subtitle,.logo-wrap img{opacity:1}button{transition:none}}");
|
||||
|
||||
// Responsive
|
||||
sb.AppendLine("@media(max-width:480px){.card{margin:0 1rem;padding:2rem 1.5rem 1.75rem;border-radius:20px}}");
|
||||
|
||||
sb.AppendLine("</style>");
|
||||
sb.AppendLine("</head>");
|
||||
sb.AppendLine("<body>");
|
||||
sb.AppendLine("<form class=\"card\" method=\"post\" action=\"\">");
|
||||
|
||||
// Logo
|
||||
sb.AppendLine("<div class=\"logo-wrap\"><img src=\"/assets/img/site.png\" alt=\"\" width=\"56\" height=\"56\" /></div>");
|
||||
|
||||
sb.AppendLine("<h1>StellaOps</h1>");
|
||||
sb.AppendLine("<p class=\"subtitle\">Sign in to continue</p>");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(error))
|
||||
{
|
||||
sb.Append("<div class=\"error\">").Append(enc.Encode(error)).AppendLine("</div>");
|
||||
}
|
||||
|
||||
// Hidden fields for OIDC parameters
|
||||
AppendHidden(sb, "response_type", request.ResponseType);
|
||||
AppendHidden(sb, "client_id", request.ClientId);
|
||||
AppendHidden(sb, "redirect_uri", request.RedirectUri);
|
||||
AppendHidden(sb, "scope", request.Scope);
|
||||
AppendHidden(sb, "state", request.State);
|
||||
AppendHidden(sb, "nonce", request.Nonce);
|
||||
AppendHidden(sb, "code_challenge", request.CodeChallenge);
|
||||
AppendHidden(sb, "code_challenge_method", request.CodeChallengeMethod);
|
||||
// Build OIDC hidden fields
|
||||
var hiddenSb = new StringBuilder();
|
||||
AppendHidden(hiddenSb, "response_type", request.ResponseType);
|
||||
AppendHidden(hiddenSb, "client_id", request.ClientId);
|
||||
AppendHidden(hiddenSb, "redirect_uri", request.RedirectUri);
|
||||
AppendHidden(hiddenSb, "scope", request.Scope);
|
||||
AppendHidden(hiddenSb, "state", request.State);
|
||||
AppendHidden(hiddenSb, "nonce", request.Nonce);
|
||||
AppendHidden(hiddenSb, "code_challenge", request.CodeChallenge);
|
||||
AppendHidden(hiddenSb, "code_challenge_method", request.CodeChallengeMethod);
|
||||
if (!string.IsNullOrWhiteSpace(request.GetParameter("audience")?.ToString()))
|
||||
{
|
||||
AppendHidden(sb, "audience", request.GetParameter("audience")?.ToString());
|
||||
AppendHidden(hiddenSb, "audience", request.GetParameter("audience")?.ToString());
|
||||
}
|
||||
|
||||
sb.AppendLine("<label for=\"username\">Username</label>");
|
||||
sb.Append("<input type=\"text\" id=\"username\" name=\"username\" autocomplete=\"username\" placeholder=\"Enter username\" required");
|
||||
if (!string.IsNullOrWhiteSpace(username))
|
||||
{
|
||||
sb.Append(" value=\"").Append(enc.Encode(username)).Append('"');
|
||||
}
|
||||
sb.AppendLine(" />");
|
||||
|
||||
sb.AppendLine("<label for=\"password\">Password</label>");
|
||||
sb.AppendLine("<input type=\"password\" id=\"password\" name=\"password\" autocomplete=\"current-password\" placeholder=\"Enter password\" required />");
|
||||
|
||||
sb.AppendLine("<button type=\"submit\">Sign In</button>");
|
||||
sb.AppendLine("</form>");
|
||||
sb.AppendLine("</body>");
|
||||
sb.AppendLine("</html>");
|
||||
|
||||
return sb.ToString();
|
||||
return LoginTemplate
|
||||
.Replace("{{error_block}}", errorBlock)
|
||||
.Replace("{{username_value}}", usernameValue)
|
||||
.Replace("{{oidc_hidden_fields}}", hiddenSb.ToString());
|
||||
}
|
||||
|
||||
private static void AppendHidden(StringBuilder sb, string name, string? value)
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
|
||||
namespace StellaOps.Authority;
|
||||
|
||||
internal static class OpenIddictGatewayBridgeEndpointExtensions
|
||||
{
|
||||
private static readonly HashSet<string> HopByHopHeaders = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Connection",
|
||||
"Keep-Alive",
|
||||
"Proxy-Authenticate",
|
||||
"Proxy-Authorization",
|
||||
"TE",
|
||||
"Trailers",
|
||||
"Transfer-Encoding",
|
||||
"Upgrade"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> IgnoredRequestHeaders = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
HeaderNames.Host,
|
||||
HeaderNames.ContentLength
|
||||
};
|
||||
|
||||
public static void MapOpenIddictGatewayBridgeEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapMethods("/connect/authorize", [HttpMethods.Get, HttpMethods.Post], (
|
||||
HttpContext context,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
CancellationToken cancellationToken) =>
|
||||
ProxyToAuthorityAsync(context, httpClientFactory, "/authorize", cancellationToken))
|
||||
.AllowAnonymous()
|
||||
.WithName("GatewayBridgeAuthorize")
|
||||
.WithSummary("OpenID Connect authorization endpoint.")
|
||||
.WithDescription("Bridges Gateway microservice `/connect/authorize` requests to Authority `/authorize`.");
|
||||
|
||||
endpoints.MapPost("/connect/token", (
|
||||
HttpContext context,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
CancellationToken cancellationToken) =>
|
||||
ProxyToAuthorityAsync(context, httpClientFactory, "/token", cancellationToken))
|
||||
.AllowAnonymous()
|
||||
.WithName("GatewayBridgeToken")
|
||||
.WithSummary("OAuth2 token endpoint.")
|
||||
.WithDescription("Bridges Gateway microservice `/connect/token` requests to Authority `/token`.");
|
||||
|
||||
endpoints.MapPost("/connect/introspect", (
|
||||
HttpContext context,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
CancellationToken cancellationToken) =>
|
||||
ProxyToAuthorityAsync(context, httpClientFactory, "/introspect", cancellationToken))
|
||||
.AllowAnonymous()
|
||||
.WithName("GatewayBridgeIntrospect")
|
||||
.WithSummary("OAuth2 introspection endpoint.")
|
||||
.WithDescription("Bridges Gateway microservice `/connect/introspect` requests to Authority `/introspect`.");
|
||||
|
||||
endpoints.MapPost("/connect/revoke", (
|
||||
HttpContext context,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
CancellationToken cancellationToken) =>
|
||||
ProxyToAuthorityAsync(context, httpClientFactory, "/revoke", cancellationToken))
|
||||
.AllowAnonymous()
|
||||
.WithName("GatewayBridgeRevoke")
|
||||
.WithSummary("OAuth2 revocation endpoint.")
|
||||
.WithDescription("Bridges Gateway microservice `/connect/revoke` requests to Authority `/revoke`.");
|
||||
|
||||
endpoints.MapGet("/well-known/openid-configuration", (
|
||||
HttpContext context,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
CancellationToken cancellationToken) =>
|
||||
ProxyToAuthorityAsync(context, httpClientFactory, "/.well-known/openid-configuration", cancellationToken))
|
||||
.AllowAnonymous()
|
||||
.WithName("GatewayBridgeOpenIdConfiguration")
|
||||
.WithSummary("OpenID Provider configuration endpoint.")
|
||||
.WithDescription("Bridges Gateway microservice `/.well-known/openid-configuration` requests to Authority OIDC discovery.");
|
||||
}
|
||||
|
||||
private static async Task ProxyToAuthorityAsync(
|
||||
HttpContext context,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
string authorityPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var loopbackPort = await ResolveLoopbackPortAsync(cancellationToken).ConfigureAwait(false);
|
||||
var query = context.Request.QueryString.HasValue
|
||||
? context.Request.QueryString.Value
|
||||
: string.Empty;
|
||||
var upstreamUri = new Uri($"http://127.0.0.1:{loopbackPort}{authorityPath}{query}", UriKind.Absolute);
|
||||
|
||||
using var upstreamRequest = new HttpRequestMessage(
|
||||
new HttpMethod(context.Request.Method),
|
||||
upstreamUri);
|
||||
|
||||
if (context.Request.ContentLength is > 0 ||
|
||||
context.Request.Headers.ContainsKey(HeaderNames.TransferEncoding))
|
||||
{
|
||||
var body = new MemoryStream();
|
||||
await context.Request.Body.CopyToAsync(body, cancellationToken).ConfigureAwait(false);
|
||||
body.Position = 0;
|
||||
upstreamRequest.Content = new StreamContent(body);
|
||||
}
|
||||
|
||||
foreach (var header in context.Request.Headers)
|
||||
{
|
||||
if (IgnoredRequestHeaders.Contains(header.Key) || HopByHopHeaders.Contains(header.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var values = header.Value.ToArray();
|
||||
if (!upstreamRequest.Headers.TryAddWithoutValidation(header.Key, values))
|
||||
{
|
||||
upstreamRequest.Content?.Headers.TryAddWithoutValidation(header.Key, values);
|
||||
}
|
||||
}
|
||||
|
||||
var client = httpClientFactory.CreateClient("AuthorityBridge");
|
||||
HttpResponseMessage upstreamResponse;
|
||||
try
|
||||
{
|
||||
upstreamResponse = await client.SendAsync(
|
||||
upstreamRequest,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status502BadGateway;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "authority_upstream_unavailable",
|
||||
message = "Authority upstream endpoint could not be reached."
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
using (upstreamResponse)
|
||||
{
|
||||
context.Response.StatusCode = (int)upstreamResponse.StatusCode;
|
||||
|
||||
foreach (var header in upstreamResponse.Headers)
|
||||
{
|
||||
if (!HopByHopHeaders.Contains(header.Key))
|
||||
{
|
||||
context.Response.Headers[header.Key] = header.Value.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var header in upstreamResponse.Content.Headers)
|
||||
{
|
||||
if (!HopByHopHeaders.Contains(header.Key) &&
|
||||
!header.Key.Equals(HeaderNames.ContentLength, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
context.Response.Headers[header.Key] = header.Value.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
await upstreamResponse.Content.CopyToAsync(context.Response.Body, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<int> ResolveLoopbackPortAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var port in GetCandidateLoopbackPorts())
|
||||
{
|
||||
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
try
|
||||
{
|
||||
await socket.ConnectAsync(IPAddress.Loopback, port, cancellationToken).ConfigureAwait(false);
|
||||
return port;
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
// Probe next candidate.
|
||||
}
|
||||
}
|
||||
|
||||
return 80;
|
||||
}
|
||||
|
||||
private static IEnumerable<int> GetCandidateLoopbackPorts()
|
||||
{
|
||||
var seen = new HashSet<int>();
|
||||
|
||||
var rawUrls = Environment.GetEnvironmentVariable("ASPNETCORE_URLS");
|
||||
if (!string.IsNullOrWhiteSpace(rawUrls))
|
||||
{
|
||||
foreach (var rawUrl in rawUrls.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (!Uri.TryCreate(rawUrl, UriKind.Absolute, out var uri))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (uri.Port > 0 && seen.Add(uri.Port))
|
||||
{
|
||||
yield return uri.Port;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (seen.Add(80))
|
||||
{
|
||||
yield return 80;
|
||||
}
|
||||
|
||||
if (seen.Add(8440))
|
||||
{
|
||||
yield return 8440;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sign In — StellaOps</title>
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
background:#080A12;
|
||||
color:#F0EDE4;display:flex;align-items:center;justify-content:center;min-height:100vh;
|
||||
-webkit-font-smoothing:antialiased;position:relative;overflow:hidden}
|
||||
body::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;
|
||||
background:radial-gradient(ellipse 55% 45% at 50% 38%,rgba(224,154,24,0.07) 0%,transparent 70%),
|
||||
radial-gradient(ellipse 40% 30% at 20% 80%,rgba(8,10,18,0.5) 0%,transparent 50%);
|
||||
pointer-events:none;z-index:0}
|
||||
.card{position:relative;z-index:1;background:rgba(18,21,31,0.85);
|
||||
backdrop-filter:blur(24px) saturate(1.4);-webkit-backdrop-filter:blur(24px) saturate(1.4);
|
||||
border-radius:24px;padding:2.5rem 2rem 2rem;width:100%;max-width:400px;
|
||||
border:1px solid rgba(224,154,24,0.12);
|
||||
box-shadow:0 0 60px rgba(224,154,24,0.04),0 20px 60px rgba(0,0,0,0.3),
|
||||
0 8px 24px rgba(0,0,0,0.2),inset 0 1px 0 rgba(255,255,255,0.04);
|
||||
animation:card-entrance 600ms cubic-bezier(0.18,0.89,0.32,1) both}
|
||||
.logo-wrap{text-align:center;margin-bottom:0.25rem}
|
||||
.logo-wrap img{max-width:248px;border-radius:32px;object-fit:contain;
|
||||
filter:drop-shadow(0 4px 12px rgba(224,154,24,0.35));
|
||||
animation:logo-pop 650ms cubic-bezier(0.34,1.56,0.64,1) 100ms both}
|
||||
h1{font-size:1.5rem;text-align:center;margin-bottom:0.25rem;color:#F0EDE4;font-weight:700;
|
||||
letter-spacing:-0.03em;animation:slide-up 500ms ease 200ms both}
|
||||
.subtitle{text-align:center;color:#7A7568;font-size:.8125rem;margin-bottom:1.5rem;
|
||||
font-weight:400;animation:fade-in 400ms ease 350ms both}
|
||||
.error{background:rgba(239,68,68,0.12);border:1px solid rgba(239,68,68,0.25);color:#f87171;
|
||||
padding:.75rem;border-radius:12px;margin-bottom:1rem;font-size:.8125rem;font-weight:500;
|
||||
display:flex;align-items:center;gap:.5rem}
|
||||
.error::before{content:'';width:6px;height:6px;border-radius:50%;background:#ef4444;flex-shrink:0}
|
||||
label{display:block;font-size:.75rem;font-weight:600;color:#7A7568;margin-bottom:.375rem;
|
||||
letter-spacing:0.03em;text-transform:uppercase}
|
||||
input[type=text],input[type=password]{width:100%;padding:.75rem .875rem;
|
||||
background:rgba(255,255,255,0.05);border:1px solid rgba(224,154,24,0.15);border-radius:12px;
|
||||
color:#F0EDE4;font-size:.9375rem;margin-bottom:1rem;outline:none;font-family:inherit;
|
||||
transition:border-color .2s,box-shadow .2s}
|
||||
input[type=text]:focus,input[type=password]:focus{border-color:#CC8810;
|
||||
box-shadow:0 0 0 3px rgba(224,154,24,0.15)}
|
||||
input[type=text]::placeholder,input[type=password]::placeholder{color:#7A7568}
|
||||
button{width:100%;padding:.875rem;margin-top:0.25rem;
|
||||
background:linear-gradient(135deg,#D4920A 0%,#B8800A 100%);
|
||||
color:#080A12;border:none;border-radius:14px;font-size:1rem;font-weight:700;
|
||||
cursor:pointer;font-family:inherit;letter-spacing:0.01em;position:relative;overflow:hidden;
|
||||
transition:transform .22s cubic-bezier(0.18,0.89,0.32,1),box-shadow .22s;
|
||||
box-shadow:0 2px 16px rgba(212,146,10,0.30),0 1px 3px rgba(0,0,0,0.25)}
|
||||
button:hover{transform:translateY(-2px);
|
||||
box-shadow:0 6px 28px rgba(212,146,10,0.40),0 2px 8px rgba(0,0,0,0.20)}
|
||||
button:active{transform:translateY(0);
|
||||
box-shadow:0 1px 8px rgba(212,146,10,0.20),0 1px 2px rgba(0,0,0,0.15)}
|
||||
button:focus-visible{outline:2px solid rgba(212,146,10,0.5);outline-offset:3px}
|
||||
button::after{content:'';position:absolute;inset:0;
|
||||
background:linear-gradient(105deg,transparent 38%,rgba(255,255,255,0.20) 50%,transparent 62%);
|
||||
background-size:250% 100%;animation:shimmer 2.2s ease 1.2s}
|
||||
@keyframes card-entrance{from{opacity:0;transform:translateY(24px) scale(0.97)}to{opacity:1;transform:translateY(0) scale(1)}}
|
||||
@keyframes logo-pop{from{opacity:0;transform:scale(0.6)}to{opacity:1;transform:scale(1)}}
|
||||
@keyframes slide-up{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
|
||||
@keyframes fade-in{from{opacity:0}to{opacity:1}}
|
||||
@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-100% 0}}
|
||||
@media(prefers-reduced-motion:reduce){.card,h1,.subtitle,.logo-wrap img,button::after{animation:none!important}
|
||||
.card,h1,.subtitle,.logo-wrap img{opacity:1}button{transition:none}}
|
||||
@media(max-width:480px){.card{margin:0 1rem;padding:2rem 1.5rem 1.75rem;border-radius:20px}
|
||||
.logo-wrap img{max-width:177px;border-radius:24px}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<form class="card" method="post" action="">
|
||||
<div class="logo-wrap"><img src="/assets/img/site.png" alt="" /></div>
|
||||
<h1>StellaOps</h1>
|
||||
<p class="subtitle">Sign in to continue</p>
|
||||
{{error_block}}
|
||||
{{oidc_hidden_fields}}
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" autocomplete="username" placeholder="Enter username" required{{username_value}} />
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" autocomplete="current-password" placeholder="Enter password" required />
|
||||
<button type="submit">Sign In</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
@@ -19,6 +19,7 @@ using Microsoft.Net.Http.Headers;
|
||||
using OpenIddict.Abstractions;
|
||||
using OpenIddict.Server;
|
||||
using OpenIddict.Server.AspNetCore;
|
||||
using StellaOps.Router.AspNet;
|
||||
// Using PostgreSQL storage with in-memory compatibility shim
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
@@ -302,6 +303,15 @@ builder.Services.AddHttpClient("StellaOps.Auth.ServerIntegration.Metadata")
|
||||
ServerCertificateCustomValidationCallback = System.Net.Http.HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
|
||||
});
|
||||
|
||||
// The gateway bridge proxies OIDC requests to the Authority's own loopback.
|
||||
// Disable auto-redirect so 302 responses (authorization code grants) are passed
|
||||
// back to the caller instead of followed inside the container.
|
||||
builder.Services.AddHttpClient("AuthorityBridge")
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new System.Net.Http.HttpClientHandler
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
builder.Services.TryAddSingleton<StellaOpsBypassEvaluator>();
|
||||
|
||||
builder.Services.AddOptions<StellaOpsResourceServerOptions>()
|
||||
@@ -445,6 +455,12 @@ builder.Services.Configure<OpenIddictServerOptions>(options =>
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(builder.Configuration, configurationSection: null);
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
// Stella Router integration
|
||||
var routerEnabled = builder.Services.AddRouterMicroservice(
|
||||
builder.Configuration,
|
||||
serviceName: "authority",
|
||||
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
|
||||
routerOptionsSection: "Router");
|
||||
builder.TryAddStellaOpsLocalBinding("authority");
|
||||
var app = builder.Build();
|
||||
app.LogStellaOpsLocalHostname("authority");
|
||||
@@ -1752,6 +1768,7 @@ app.UseRateLimiter();
|
||||
app.UseStellaOpsCors();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.TryUseStellaRouter(routerEnabled);
|
||||
|
||||
app.MapGet("/health", async (IAuthorityIdentityProviderRegistry registry, CancellationToken cancellationToken) =>
|
||||
{
|
||||
@@ -1784,6 +1801,18 @@ app.MapGet("/ready", (IAuthorityIdentityProviderRegistry registry) =>
|
||||
}))
|
||||
.WithName("ReadinessCheck");
|
||||
|
||||
app.MapGet("/api/v1/claims/overrides", (IConfiguration configuration) =>
|
||||
{
|
||||
var configured = configuration
|
||||
.GetSection("Authority:Router:ClaimsOverrides")
|
||||
.Get<RouterClaimsOverridesResponse>() ?? new RouterClaimsOverridesResponse();
|
||||
|
||||
return Results.Ok(NormalizeRouterClaimsOverrides(configured));
|
||||
})
|
||||
.WithName("GetRouterClaimsOverrides")
|
||||
.WithSummary("Get router claims overrides")
|
||||
.WithDescription("Returns Authority-managed endpoint claim overrides consumed by router-gateway authorization refresh.");
|
||||
|
||||
app.MapPost("/permalinks/vuln", async (
|
||||
VulnPermalinkRequest request,
|
||||
VulnPermalinkService service,
|
||||
@@ -3148,6 +3177,7 @@ app.MapConsoleEndpoints();
|
||||
app.MapConsoleAdminEndpoints();
|
||||
app.MapConsoleBrandingEndpoints();
|
||||
app.MapAuthorizeEndpoint();
|
||||
app.MapOpenIddictGatewayBridgeEndpoints();
|
||||
|
||||
|
||||
|
||||
@@ -3177,6 +3207,7 @@ app.MapGet("/jwks", (AuthorityJwksService jwksService, HttpContext context) =>
|
||||
app.Services.GetRequiredService<AuthorityAckTokenKeyManager>();
|
||||
app.Services.GetRequiredService<AuthoritySigningKeyManager>();
|
||||
|
||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||
app.Run();
|
||||
|
||||
static PluginHostOptions BuildPluginHostOptions(StellaOpsAuthorityOptions options, string basePath)
|
||||
@@ -3237,3 +3268,89 @@ static bool TryParseUris(IReadOnlyCollection<string>? values, out IReadOnlyColle
|
||||
uris = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
static RouterClaimsOverridesResponse NormalizeRouterClaimsOverrides(RouterClaimsOverridesResponse response)
|
||||
{
|
||||
var overrides = response.Overrides
|
||||
.Where(entry =>
|
||||
!string.IsNullOrWhiteSpace(entry.ServiceName) &&
|
||||
!string.IsNullOrWhiteSpace(entry.Method) &&
|
||||
!string.IsNullOrWhiteSpace(entry.Path))
|
||||
.Select(entry => new RouterClaimsOverrideEntry
|
||||
{
|
||||
ServiceName = entry.ServiceName.Trim(),
|
||||
Method = entry.Method.Trim().ToUpperInvariant(),
|
||||
Path = NormalizeOverridePath(entry.Path),
|
||||
RequiringClaims = entry.RequiringClaims
|
||||
.Where(claim => !string.IsNullOrWhiteSpace(claim.Type))
|
||||
.Select(claim => new RouterClaimRequirementEntry
|
||||
{
|
||||
Type = claim.Type.Trim(),
|
||||
Value = string.IsNullOrWhiteSpace(claim.Value) ? null : claim.Value.Trim()
|
||||
})
|
||||
.Distinct()
|
||||
.OrderBy(claim => claim.Type, StringComparer.Ordinal)
|
||||
.ThenBy(claim => claim.Value, StringComparer.Ordinal)
|
||||
.ToList()
|
||||
})
|
||||
.GroupBy(
|
||||
entry => $"{entry.ServiceName.ToLowerInvariant()}|{entry.Method}|{entry.Path}",
|
||||
StringComparer.Ordinal)
|
||||
.Select(group =>
|
||||
{
|
||||
var first = group.First();
|
||||
return new RouterClaimsOverrideEntry
|
||||
{
|
||||
ServiceName = first.ServiceName,
|
||||
Method = first.Method,
|
||||
Path = first.Path,
|
||||
RequiringClaims = group
|
||||
.SelectMany(entry => entry.RequiringClaims)
|
||||
.Distinct()
|
||||
.OrderBy(claim => claim.Type, StringComparer.Ordinal)
|
||||
.ThenBy(claim => claim.Value, StringComparer.Ordinal)
|
||||
.ToList()
|
||||
};
|
||||
})
|
||||
.OrderBy(entry => entry.ServiceName, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(entry => entry.Method, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(entry => entry.Path, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return new RouterClaimsOverridesResponse
|
||||
{
|
||||
Overrides = overrides
|
||||
};
|
||||
}
|
||||
|
||||
static string NormalizeOverridePath(string rawPath)
|
||||
{
|
||||
var path = rawPath.Trim();
|
||||
if (!path.StartsWith('/'))
|
||||
{
|
||||
path = "/" + path;
|
||||
}
|
||||
|
||||
path = path.TrimEnd('/');
|
||||
return string.IsNullOrEmpty(path) ? "/" : path;
|
||||
}
|
||||
|
||||
sealed class RouterClaimsOverridesResponse
|
||||
{
|
||||
public List<RouterClaimsOverrideEntry> Overrides { get; init; } = [];
|
||||
}
|
||||
|
||||
sealed class RouterClaimsOverrideEntry
|
||||
{
|
||||
public string ServiceName { get; init; } = string.Empty;
|
||||
public string Method { get; init; } = "GET";
|
||||
public string Path { get; init; } = "/";
|
||||
public List<RouterClaimRequirementEntry> RequiringClaims { get; init; } = [];
|
||||
}
|
||||
|
||||
sealed record RouterClaimRequirementEntry
|
||||
{
|
||||
public string Type { get; init; } = string.Empty;
|
||||
public string? Value { get; init; }
|
||||
}
|
||||
|
||||
|
||||
@@ -36,9 +36,16 @@
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="../../../Attestor/StellaOps.Attestation/StellaOps.Attestation.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Pages\*.html" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="..\..\StellaOps.Api.OpenApi\authority\openapi.yaml" Link="OpenApi\authority.yaml">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="StellaOpsReleaseVersion">
|
||||
<Version>1.0.0-alpha1</Version>
|
||||
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -78,9 +78,24 @@ ON CONFLICT (tenant_id, name) DO NOTHING;
|
||||
|
||||
INSERT INTO authority.clients (id, client_id, display_name, description, enabled, redirect_uris, allowed_scopes, allowed_grant_types, require_client_secret, require_pkce)
|
||||
VALUES
|
||||
('demo-client-ui', 'stellaops-console', 'Stella Ops Console', 'Web UI application', true,
|
||||
ARRAY['https://stella-ops.local/callback', 'https://stella-ops.local/silent-renew'],
|
||||
ARRAY['openid', 'profile', 'email', 'stellaops.api'],
|
||||
('demo-client-ui', 'stella-ops-ui', 'Stella Ops Console', 'Web UI application', true,
|
||||
ARRAY['https://stella-ops.local/auth/callback', 'https://stella-ops.local/auth/silent-refresh'],
|
||||
ARRAY['openid', 'profile', 'email', 'offline_access',
|
||||
'ui.read', 'ui.admin',
|
||||
'authority:tenants.read', 'authority:users.read', 'authority:roles.read',
|
||||
'authority:clients.read', 'authority:tokens.read', 'authority:branding.read',
|
||||
'authority.audit.read',
|
||||
'graph:read', 'sbom:read', 'scanner:read',
|
||||
'policy:read', 'policy:simulate', 'policy:author', 'policy:review', 'policy:approve',
|
||||
'orch:read', 'analytics.read', 'advisory:read', 'vex:read',
|
||||
'exceptions:read', 'exceptions:approve', 'aoc:verify', 'findings:read',
|
||||
'release:read', 'scheduler:read', 'scheduler:operate',
|
||||
'notify.viewer', 'notify.operator', 'notify.admin', 'notify.escalate',
|
||||
'evidence:read',
|
||||
'export.viewer', 'export.operator', 'export.admin',
|
||||
'vuln:view', 'vuln:investigate', 'vuln:operate', 'vuln:audit',
|
||||
'platform.context.read', 'platform.context.write',
|
||||
'doctor:run', 'doctor:admin'],
|
||||
ARRAY['authorization_code', 'refresh_token'],
|
||||
false, true),
|
||||
('demo-client-cli', 'stellaops-cli', 'Stella Ops CLI', 'Command-line client', true,
|
||||
|
||||
@@ -14,6 +14,7 @@ using StellaOps.BinaryIndex.WebService.Services;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.BinaryIndex.WebService.Telemetry;
|
||||
|
||||
using StellaOps.Router.AspNet;
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services to the container.
|
||||
@@ -66,6 +67,12 @@ builder.Services.AddHealthChecks()
|
||||
|
||||
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
|
||||
|
||||
// Stella Router integration
|
||||
var routerEnabled = builder.Services.AddRouterMicroservice(
|
||||
builder.Configuration,
|
||||
serviceName: "binaryindex",
|
||||
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
|
||||
routerOptionsSection: "Router");
|
||||
builder.TryAddStellaOpsLocalBinding("binaryindex");
|
||||
var app = builder.Build();
|
||||
app.LogStellaOpsLocalHostname("binaryindex");
|
||||
@@ -78,12 +85,14 @@ if (app.Environment.IsDevelopment())
|
||||
}
|
||||
|
||||
app.UseStellaOpsCors();
|
||||
// HTTPS redirection removed — the gateway handles TLS termination.
|
||||
// HTTPS redirection removed — the gateway handles TLS termination.
|
||||
app.UseResolutionRateLimiting();
|
||||
app.UseAuthorization();
|
||||
app.TryUseStellaRouter(routerEnabled);
|
||||
app.MapControllers();
|
||||
app.MapHealthChecks("/health");
|
||||
|
||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||
app.Run();
|
||||
|
||||
static IResolutionCacheService CreateResolutionCacheService(IServiceProvider services, string redisConnectionString)
|
||||
@@ -125,3 +134,4 @@ static IResolutionCacheService CreateResolutionCacheService(IServiceProvider ser
|
||||
services.GetRequiredService<ILogger<InMemoryResolutionCacheService>>());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,5 +25,8 @@
|
||||
<ProjectReference Include="../__Libraries/StellaOps.BinaryIndex.Disassembly.B2R2/StellaOps.BinaryIndex.Disassembly.B2R2.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Label="StellaOpsReleaseVersion">
|
||||
<Version>1.0.0-alpha1</Version>
|
||||
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -5,6 +5,7 @@ using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Cartographer.Options;
|
||||
|
||||
using StellaOps.Router.AspNet;
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Configuration
|
||||
@@ -72,6 +73,12 @@ builder.Services.AddHealthChecks()
|
||||
|
||||
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
|
||||
|
||||
// Stella Router integration
|
||||
var routerEnabled = builder.Services.AddRouterMicroservice(
|
||||
builder.Configuration,
|
||||
serviceName: "cartographer",
|
||||
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
|
||||
routerOptionsSection: "Router");
|
||||
builder.TryAddStellaOpsLocalBinding("cartographer");
|
||||
var app = builder.Build();
|
||||
app.LogStellaOpsLocalHostname("cartographer");
|
||||
@@ -90,6 +97,7 @@ if (authorityOptions.Enabled)
|
||||
{
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.TryUseStellaRouter(routerEnabled);
|
||||
}
|
||||
|
||||
app.MapHealthChecks("/healthz").AllowAnonymous();
|
||||
@@ -98,6 +106,8 @@ app.MapHealthChecks("/readyz", new Microsoft.AspNetCore.Diagnostics.HealthChecks
|
||||
Predicate = check => check.Tags.Contains("ready")
|
||||
}).AllowAnonymous();
|
||||
|
||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||
app.Run();
|
||||
|
||||
public partial class Program;
|
||||
|
||||
|
||||
@@ -16,4 +16,8 @@
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="StellaOpsReleaseVersion">
|
||||
<Version>1.0.0-alpha1</Version>
|
||||
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
using System.CommandLine;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Infrastructure.Postgres.Migrations;
|
||||
using StellaOps.Platform.Database;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Spectre.Console;
|
||||
|
||||
|
||||
@@ -89,6 +89,8 @@ internal static class CommandFactory
|
||||
root.Add(BundleCommandGroup.BuildBundleCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildRiskProfileCommand(verboseOption, cancellationToken));
|
||||
root.Add(BuildAdvisoryCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(KnowledgeSearchCommandGroup.BuildSearchCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(KnowledgeSearchCommandGroup.BuildAdvisoryAiCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildForensicCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildPromotionCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildDetscoreCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
@@ -56,6 +56,7 @@ internal static class DoctorCommandGroup
|
||||
doctor.Add(BuildListCommand(services, verboseOption, cancellationToken));
|
||||
doctor.Add(BuildExportCommand(services, verboseOption, cancellationToken));
|
||||
doctor.Add(BuildFixCommand(services, verboseOption, cancellationToken));
|
||||
doctor.Add(KnowledgeSearchCommandGroup.BuildDoctorSuggestCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
return doctor;
|
||||
}
|
||||
|
||||
587
src/Cli/StellaOps.Cli/Commands/KnowledgeSearchCommandGroup.cs
Normal file
587
src/Cli/StellaOps.Cli/Commands/KnowledgeSearchCommandGroup.cs
Normal file
@@ -0,0 +1,587 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.CommandLine;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
internal static class KnowledgeSearchCommandGroup
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOutputOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> AllowedTypes = new(StringComparer.Ordinal)
|
||||
{
|
||||
"docs",
|
||||
"api",
|
||||
"doctor"
|
||||
};
|
||||
|
||||
internal static Command BuildSearchCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var queryArgument = new Argument<string>("query")
|
||||
{
|
||||
Description = "Knowledge query (error text, endpoint question, runbook task)."
|
||||
};
|
||||
|
||||
var typeOption = new Option<string[]>("--type")
|
||||
{
|
||||
Description = "Filter by result type: docs, api, doctor (repeatable or comma-separated).",
|
||||
Arity = ArgumentArity.ZeroOrMore,
|
||||
AllowMultipleArgumentsPerToken = true
|
||||
};
|
||||
|
||||
var productOption = new Option<string?>("--product")
|
||||
{
|
||||
Description = "Filter by product identifier."
|
||||
};
|
||||
|
||||
var versionOption = new Option<string?>("--version")
|
||||
{
|
||||
Description = "Filter by product version."
|
||||
};
|
||||
|
||||
var serviceOption = new Option<string?>("--service")
|
||||
{
|
||||
Description = "Filter by service (especially useful for API operations)."
|
||||
};
|
||||
|
||||
var tagOption = new Option<string[]>("--tag")
|
||||
{
|
||||
Description = "Filter by tags (repeatable or comma-separated).",
|
||||
Arity = ArgumentArity.ZeroOrMore,
|
||||
AllowMultipleArgumentsPerToken = true
|
||||
};
|
||||
|
||||
var topKOption = new Option<int?>("--k")
|
||||
{
|
||||
Description = "Number of results to return (1-100, default 10)."
|
||||
};
|
||||
|
||||
var jsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Emit machine-readable JSON output."
|
||||
};
|
||||
|
||||
var search = new Command("search", "Search AdvisoryAI knowledge index across docs, API operations, and doctor checks.");
|
||||
search.Add(queryArgument);
|
||||
search.Add(typeOption);
|
||||
search.Add(productOption);
|
||||
search.Add(versionOption);
|
||||
search.Add(serviceOption);
|
||||
search.Add(tagOption);
|
||||
search.Add(topKOption);
|
||||
search.Add(jsonOption);
|
||||
search.Add(verboseOption);
|
||||
|
||||
search.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var query = parseResult.GetValue(queryArgument) ?? string.Empty;
|
||||
var types = parseResult.GetValue(typeOption) ?? Array.Empty<string>();
|
||||
var tags = parseResult.GetValue(tagOption) ?? Array.Empty<string>();
|
||||
var product = parseResult.GetValue(productOption);
|
||||
var version = parseResult.GetValue(versionOption);
|
||||
var service = parseResult.GetValue(serviceOption);
|
||||
var topK = parseResult.GetValue(topKOption);
|
||||
var emitJson = parseResult.GetValue(jsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
await ExecuteSearchAsync(
|
||||
services,
|
||||
query,
|
||||
types,
|
||||
tags,
|
||||
product,
|
||||
version,
|
||||
service,
|
||||
topK,
|
||||
emitJson,
|
||||
verbose,
|
||||
suggestMode: false,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
return search;
|
||||
}
|
||||
|
||||
internal static Command BuildAdvisoryAiCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var advisoryAi = new Command("advisoryai", "AdvisoryAI maintenance commands.");
|
||||
var index = new Command("index", "Knowledge index operations.");
|
||||
var rebuild = new Command("rebuild", "Rebuild AdvisoryAI deterministic knowledge index.");
|
||||
var jsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Emit machine-readable JSON output."
|
||||
};
|
||||
|
||||
rebuild.Add(jsonOption);
|
||||
rebuild.Add(verboseOption);
|
||||
rebuild.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var emitJson = parseResult.GetValue(jsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
await ExecuteRebuildAsync(services, emitJson, verbose, cancellationToken).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
index.Add(rebuild);
|
||||
advisoryAi.Add(index);
|
||||
return advisoryAi;
|
||||
}
|
||||
|
||||
internal static Command BuildDoctorSuggestCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var symptomArgument = new Argument<string>("symptom")
|
||||
{
|
||||
Description = "Symptom text, log fragment, or error message."
|
||||
};
|
||||
|
||||
var productOption = new Option<string?>("--product")
|
||||
{
|
||||
Description = "Optional product filter."
|
||||
};
|
||||
|
||||
var versionOption = new Option<string?>("--version")
|
||||
{
|
||||
Description = "Optional version filter."
|
||||
};
|
||||
|
||||
var topKOption = new Option<int?>("--k")
|
||||
{
|
||||
Description = "Number of results to return (1-100, default 10)."
|
||||
};
|
||||
|
||||
var jsonOption = new Option<bool>("--json")
|
||||
{
|
||||
Description = "Emit machine-readable JSON output."
|
||||
};
|
||||
|
||||
var suggest = new Command("suggest", "Suggest checks, docs, and API operations for a symptom via AdvisoryAI knowledge search.");
|
||||
suggest.Add(symptomArgument);
|
||||
suggest.Add(productOption);
|
||||
suggest.Add(versionOption);
|
||||
suggest.Add(topKOption);
|
||||
suggest.Add(jsonOption);
|
||||
suggest.Add(verboseOption);
|
||||
|
||||
suggest.SetAction(async (parseResult, _) =>
|
||||
{
|
||||
var symptom = parseResult.GetValue(symptomArgument) ?? string.Empty;
|
||||
var product = parseResult.GetValue(productOption);
|
||||
var version = parseResult.GetValue(versionOption);
|
||||
var topK = parseResult.GetValue(topKOption);
|
||||
var emitJson = parseResult.GetValue(jsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
await ExecuteSearchAsync(
|
||||
services,
|
||||
symptom,
|
||||
types: Array.Empty<string>(),
|
||||
tags: Array.Empty<string>(),
|
||||
product,
|
||||
version,
|
||||
service: null,
|
||||
topK,
|
||||
emitJson,
|
||||
verbose,
|
||||
suggestMode: true,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
return suggest;
|
||||
}
|
||||
|
||||
private static async Task ExecuteSearchAsync(
|
||||
IServiceProvider services,
|
||||
string query,
|
||||
IReadOnlyList<string> types,
|
||||
IReadOnlyList<string> tags,
|
||||
string? product,
|
||||
string? version,
|
||||
string? service,
|
||||
int? topK,
|
||||
bool emitJson,
|
||||
bool verbose,
|
||||
bool suggestMode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedQuery = (query ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalizedQuery))
|
||||
{
|
||||
Console.Error.WriteLine("Query text is required.");
|
||||
Environment.ExitCode = CliExitCodes.MissingRequiredOption;
|
||||
return;
|
||||
}
|
||||
|
||||
var normalizedTypes = NormalizeTypes(types);
|
||||
var normalizedTags = NormalizeTags(tags);
|
||||
var boundedTopK = topK.HasValue ? Math.Clamp(topK.Value, 1, 100) : (int?)null;
|
||||
|
||||
var filter = BuildFilter(normalizedTypes, normalizedTags, product, version, service);
|
||||
var request = new AdvisoryKnowledgeSearchRequestModel
|
||||
{
|
||||
Q = normalizedQuery,
|
||||
K = boundedTopK,
|
||||
Filters = filter,
|
||||
IncludeDebug = verbose
|
||||
};
|
||||
|
||||
var backend = services.GetRequiredService<IBackendOperationsClient>();
|
||||
AdvisoryKnowledgeSearchResponseModel response;
|
||||
try
|
||||
{
|
||||
response = await backend.SearchAdvisoryKnowledgeAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Knowledge search failed: {ex.Message}");
|
||||
Environment.ExitCode = CliExitCodes.GeneralError;
|
||||
return;
|
||||
}
|
||||
|
||||
if (emitJson)
|
||||
{
|
||||
WriteJson(ToJsonPayload(response));
|
||||
return;
|
||||
}
|
||||
|
||||
if (suggestMode)
|
||||
{
|
||||
RenderSuggestionOutput(response, verbose);
|
||||
return;
|
||||
}
|
||||
|
||||
RenderSearchOutput(response, verbose);
|
||||
}
|
||||
|
||||
private static async Task ExecuteRebuildAsync(
|
||||
IServiceProvider services,
|
||||
bool emitJson,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var backend = services.GetRequiredService<IBackendOperationsClient>();
|
||||
AdvisoryKnowledgeRebuildResponseModel summary;
|
||||
try
|
||||
{
|
||||
summary = await backend.RebuildAdvisoryKnowledgeIndexAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"AdvisoryAI index rebuild failed: {ex.Message}");
|
||||
Environment.ExitCode = CliExitCodes.GeneralError;
|
||||
return;
|
||||
}
|
||||
|
||||
if (emitJson)
|
||||
{
|
||||
WriteJson(new
|
||||
{
|
||||
documentCount = summary.DocumentCount,
|
||||
chunkCount = summary.ChunkCount,
|
||||
apiSpecCount = summary.ApiSpecCount,
|
||||
apiOperationCount = summary.ApiOperationCount,
|
||||
doctorProjectionCount = summary.DoctorProjectionCount,
|
||||
durationMs = summary.DurationMs
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine("AdvisoryAI knowledge index rebuilt.");
|
||||
Console.WriteLine($" Documents: {summary.DocumentCount}");
|
||||
Console.WriteLine($" Chunks: {summary.ChunkCount}");
|
||||
Console.WriteLine($" API specs: {summary.ApiSpecCount}");
|
||||
Console.WriteLine($" API operations: {summary.ApiOperationCount}");
|
||||
Console.WriteLine($" Doctor projections: {summary.DoctorProjectionCount}");
|
||||
Console.WriteLine($" Duration: {summary.DurationMs.ToString(CultureInfo.InvariantCulture)} ms");
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
Console.WriteLine(" Rebuild scope: markdown + openapi + doctor projection.");
|
||||
}
|
||||
}
|
||||
|
||||
private static AdvisoryKnowledgeSearchFilterModel? BuildFilter(
|
||||
IReadOnlyList<string> types,
|
||||
IReadOnlyList<string> tags,
|
||||
string? product,
|
||||
string? version,
|
||||
string? service)
|
||||
{
|
||||
var normalizedProduct = NormalizeOptional(product);
|
||||
var normalizedVersion = NormalizeOptional(version);
|
||||
var normalizedService = NormalizeOptional(service);
|
||||
|
||||
if (types.Count == 0 &&
|
||||
tags.Count == 0 &&
|
||||
normalizedProduct is null &&
|
||||
normalizedVersion is null &&
|
||||
normalizedService is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AdvisoryKnowledgeSearchFilterModel
|
||||
{
|
||||
Type = types.Count == 0 ? null : types,
|
||||
Product = normalizedProduct,
|
||||
Version = normalizedVersion,
|
||||
Service = normalizedService,
|
||||
Tags = tags.Count == 0 ? null : tags
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizeTypes(IEnumerable<string> types)
|
||||
{
|
||||
var result = new SortedSet<string>(StringComparer.Ordinal);
|
||||
foreach (var raw in types)
|
||||
{
|
||||
foreach (var token in SplitCsvTokens(raw))
|
||||
{
|
||||
var normalized = token.ToLowerInvariant();
|
||||
if (AllowedTypes.Contains(normalized))
|
||||
{
|
||||
result.Add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizeTags(IEnumerable<string> tags)
|
||||
{
|
||||
var result = new SortedSet<string>(StringComparer.Ordinal);
|
||||
foreach (var raw in tags)
|
||||
{
|
||||
foreach (var token in SplitCsvTokens(raw))
|
||||
{
|
||||
result.Add(token.ToLowerInvariant());
|
||||
}
|
||||
}
|
||||
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SplitCsvTokens(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var token in value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
yield return token.Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static void RenderSearchOutput(AdvisoryKnowledgeSearchResponseModel response, bool verbose)
|
||||
{
|
||||
Console.WriteLine($"Query: {response.Query}");
|
||||
Console.WriteLine($"Results: {response.Results.Count.ToString(CultureInfo.InvariantCulture)} / topK {response.TopK.ToString(CultureInfo.InvariantCulture)}");
|
||||
Console.WriteLine($"Mode: {response.Diagnostics.Mode} (fts={response.Diagnostics.FtsMatches.ToString(CultureInfo.InvariantCulture)}, vector={response.Diagnostics.VectorMatches.ToString(CultureInfo.InvariantCulture)}, duration={response.Diagnostics.DurationMs.ToString(CultureInfo.InvariantCulture)}ms)");
|
||||
Console.WriteLine();
|
||||
|
||||
if (response.Results.Count == 0)
|
||||
{
|
||||
Console.WriteLine("No results found.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (var index = 0; index < response.Results.Count; index++)
|
||||
{
|
||||
var result = response.Results[index];
|
||||
Console.WriteLine($"[{(index + 1).ToString(CultureInfo.InvariantCulture)}] {result.Type.ToUpperInvariant()} score={result.Score.ToString("F6", CultureInfo.InvariantCulture)}");
|
||||
Console.WriteLine($" {result.Title}");
|
||||
var snippet = CollapseWhitespace(result.Snippet);
|
||||
if (!string.IsNullOrWhiteSpace(snippet))
|
||||
{
|
||||
Console.WriteLine($" {snippet}");
|
||||
}
|
||||
|
||||
var reference = FormatOpenReference(result);
|
||||
if (!string.IsNullOrWhiteSpace(reference))
|
||||
{
|
||||
Console.WriteLine($" {reference}");
|
||||
}
|
||||
|
||||
if (verbose && result.Debug is { Count: > 0 })
|
||||
{
|
||||
foreach (var pair in result.Debug.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
Console.WriteLine($" debug.{pair.Key}: {pair.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
}
|
||||
|
||||
private static void RenderSuggestionOutput(AdvisoryKnowledgeSearchResponseModel response, bool verbose)
|
||||
{
|
||||
Console.WriteLine($"Symptom: {response.Query}");
|
||||
Console.WriteLine($"Mode: {response.Diagnostics.Mode} (duration={response.Diagnostics.DurationMs.ToString(CultureInfo.InvariantCulture)}ms)");
|
||||
Console.WriteLine();
|
||||
|
||||
var doctor = response.Results.Where(static result => result.Type.Equals("doctor", StringComparison.OrdinalIgnoreCase)).ToArray();
|
||||
var docs = response.Results.Where(static result => result.Type.Equals("docs", StringComparison.OrdinalIgnoreCase)).ToArray();
|
||||
var api = response.Results.Where(static result => result.Type.Equals("api", StringComparison.OrdinalIgnoreCase)).ToArray();
|
||||
|
||||
RenderSuggestionGroup("Recommended checks", doctor, verbose);
|
||||
RenderSuggestionGroup("Related docs", docs, verbose);
|
||||
RenderSuggestionGroup("Related endpoints", api, verbose);
|
||||
}
|
||||
|
||||
private static void RenderSuggestionGroup(string label, IReadOnlyList<AdvisoryKnowledgeSearchResultModel> results, bool verbose)
|
||||
{
|
||||
Console.WriteLine(label + ":");
|
||||
if (results.Count == 0)
|
||||
{
|
||||
Console.WriteLine(" (none)");
|
||||
Console.WriteLine();
|
||||
return;
|
||||
}
|
||||
|
||||
for (var index = 0; index < results.Count; index++)
|
||||
{
|
||||
var result = results[index];
|
||||
Console.WriteLine($" {(index + 1).ToString(CultureInfo.InvariantCulture)}. {result.Title} (score={result.Score.ToString("F6", CultureInfo.InvariantCulture)})");
|
||||
var reference = FormatOpenReference(result);
|
||||
if (!string.IsNullOrWhiteSpace(reference))
|
||||
{
|
||||
Console.WriteLine($" {reference}");
|
||||
}
|
||||
|
||||
var snippet = CollapseWhitespace(result.Snippet);
|
||||
if (!string.IsNullOrWhiteSpace(snippet))
|
||||
{
|
||||
Console.WriteLine($" {snippet}");
|
||||
}
|
||||
|
||||
if (verbose && result.Debug is { Count: > 0 })
|
||||
{
|
||||
foreach (var pair in result.Debug.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
|
||||
{
|
||||
Console.WriteLine($" debug.{pair.Key}: {pair.Value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
private static string FormatOpenReference(AdvisoryKnowledgeSearchResultModel result)
|
||||
{
|
||||
if (result.Open is null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (result.Type.Equals("docs", StringComparison.OrdinalIgnoreCase) && result.Open.Docs is not null)
|
||||
{
|
||||
var docs = result.Open.Docs;
|
||||
return $"docs: {docs.Path}#{docs.Anchor} lines {docs.SpanStart.ToString(CultureInfo.InvariantCulture)}-{docs.SpanEnd.ToString(CultureInfo.InvariantCulture)}";
|
||||
}
|
||||
|
||||
if (result.Type.Equals("api", StringComparison.OrdinalIgnoreCase) && result.Open.Api is not null)
|
||||
{
|
||||
var api = result.Open.Api;
|
||||
return $"api: {api.Method} {api.Path} operationId={api.OperationId} service={api.Service}";
|
||||
}
|
||||
|
||||
if (result.Type.Equals("doctor", StringComparison.OrdinalIgnoreCase) && result.Open.Doctor is not null)
|
||||
{
|
||||
var doctor = result.Open.Doctor;
|
||||
return $"doctor: {doctor.CheckCode} severity={doctor.Severity} run=\"{doctor.RunCommand}\"";
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static string CollapseWhitespace(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var collapsed = string.Join(
|
||||
' ',
|
||||
value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
||||
|
||||
const int maxLength = 320;
|
||||
if (collapsed.Length <= maxLength)
|
||||
{
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
return collapsed[..maxLength] + "...";
|
||||
}
|
||||
|
||||
private static object ToJsonPayload(AdvisoryKnowledgeSearchResponseModel response)
|
||||
{
|
||||
return new
|
||||
{
|
||||
query = response.Query,
|
||||
topK = response.TopK,
|
||||
diagnostics = new
|
||||
{
|
||||
ftsMatches = response.Diagnostics.FtsMatches,
|
||||
vectorMatches = response.Diagnostics.VectorMatches,
|
||||
durationMs = response.Diagnostics.DurationMs,
|
||||
usedVector = response.Diagnostics.UsedVector,
|
||||
mode = response.Diagnostics.Mode
|
||||
},
|
||||
results = response.Results.Select(static result => new
|
||||
{
|
||||
type = result.Type,
|
||||
title = result.Title,
|
||||
snippet = result.Snippet,
|
||||
score = result.Score,
|
||||
open = new
|
||||
{
|
||||
kind = result.Open.Kind,
|
||||
docs = result.Open.Docs,
|
||||
api = result.Open.Api,
|
||||
doctor = result.Open.Doctor
|
||||
},
|
||||
debug = result.Debug is null
|
||||
? null
|
||||
: result.Debug
|
||||
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
|
||||
.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal)
|
||||
}).ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
private static void WriteJson(object payload)
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(payload, JsonOutputOptions));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cli.Extensions;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Infrastructure.Postgres.Migrations;
|
||||
using StellaOps.Platform.Database;
|
||||
using System;
|
||||
using System.CommandLine;
|
||||
using System.Linq;
|
||||
@@ -35,9 +36,10 @@ internal static class SystemCommandBuilder
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var moduleChoices = string.Join(", ", MigrationModuleRegistry.ModuleNames.OrderBy(static n => n));
|
||||
var moduleOption = new Option<string?>("--module")
|
||||
{
|
||||
Description = "Module name (Authority, Scheduler, Concelier, Policy, Notify, Excititor, all)"
|
||||
Description = $"Module name ({moduleChoices}, all)"
|
||||
};
|
||||
var categoryOption = new Option<string?>("--category")
|
||||
{
|
||||
|
||||
@@ -1393,6 +1393,87 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AdvisoryKnowledgeSearchResponseModel> SearchAdvisoryKnowledgeAsync(
|
||||
AdvisoryKnowledgeSearchRequestModel request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Q))
|
||||
{
|
||||
throw new ArgumentException("Knowledge search query is required.", nameof(request));
|
||||
}
|
||||
|
||||
using var httpRequest = CreateRequest(HttpMethod.Post, "v1/advisory-ai/search");
|
||||
ApplyAdvisoryAiEndpoint(httpRequest, "advisory:run advisory:search");
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
httpRequest.Content = JsonContent.Create(request, options: SerializerOptions);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException(failure);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var payload = await response.Content.ReadFromJsonAsync<AdvisoryKnowledgeSearchResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (payload is null)
|
||||
{
|
||||
throw new InvalidOperationException("Advisory knowledge search response was empty.");
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
var raw = response.Content is null
|
||||
? string.Empty
|
||||
: await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to parse advisory knowledge search response. {ex.Message}", ex)
|
||||
{
|
||||
Data = { ["payload"] = raw }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AdvisoryKnowledgeRebuildResponseModel> RebuildAdvisoryKnowledgeIndexAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = CreateRequest(HttpMethod.Post, "v1/advisory-ai/index/rebuild");
|
||||
ApplyAdvisoryAiEndpoint(request, "advisory:run advisory:admin advisory:index:write");
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException(failure);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var summary = await response.Content.ReadFromJsonAsync<AdvisoryKnowledgeRebuildResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (summary is null)
|
||||
{
|
||||
throw new InvalidOperationException("Advisory knowledge index rebuild response was empty.");
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
var raw = response.Content is null
|
||||
? string.Empty
|
||||
: await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to parse advisory knowledge index rebuild response. {ex.Message}", ex)
|
||||
{
|
||||
Data = { ["payload"] = raw }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
@@ -2284,6 +2365,12 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
}
|
||||
|
||||
private void ApplyAdvisoryAiEndpoint(HttpRequestMessage request, AdvisoryAiTaskType taskType)
|
||||
{
|
||||
var taskScope = $"advisory:{taskType.ToString().ToLowerInvariant()}";
|
||||
ApplyAdvisoryAiEndpoint(request, $"{AdvisoryRunScope} {taskScope}");
|
||||
}
|
||||
|
||||
private void ApplyAdvisoryAiEndpoint(HttpRequestMessage request, string scopeHeaderValue)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
@@ -2309,15 +2396,20 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
EnsureBackendConfigured();
|
||||
}
|
||||
|
||||
var taskScope = $"advisory:{taskType.ToString().ToLowerInvariant()}";
|
||||
var combined = $"{AdvisoryRunScope} {taskScope}";
|
||||
var normalizedScopes = string.IsNullOrWhiteSpace(scopeHeaderValue)
|
||||
? AdvisoryRunScope
|
||||
: string.Join(
|
||||
' ',
|
||||
scopeHeaderValue
|
||||
.Split([' ', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
if (request.Headers.Contains(AdvisoryScopesHeader))
|
||||
{
|
||||
request.Headers.Remove(AdvisoryScopesHeader);
|
||||
}
|
||||
|
||||
request.Headers.TryAddWithoutValidation(AdvisoryScopesHeader, combined);
|
||||
request.Headers.TryAddWithoutValidation(AdvisoryScopesHeader, normalizedScopes);
|
||||
}
|
||||
|
||||
private static void ApplyTenantHeader(HttpRequestMessage request, string? tenantId)
|
||||
|
||||
@@ -68,6 +68,10 @@ internal interface IBackendOperationsClient
|
||||
|
||||
Task<AdvisoryPipelineOutputModel?> TryGetAdvisoryPipelineOutputAsync(string cacheKey, AdvisoryAiTaskType taskType, string profile, CancellationToken cancellationToken);
|
||||
|
||||
Task<AdvisoryKnowledgeSearchResponseModel> SearchAdvisoryKnowledgeAsync(AdvisoryKnowledgeSearchRequestModel request, CancellationToken cancellationToken);
|
||||
|
||||
Task<AdvisoryKnowledgeRebuildResponseModel> RebuildAdvisoryKnowledgeIndexAsync(CancellationToken cancellationToken);
|
||||
|
||||
// CLI-VEX-30-001: VEX consensus operations
|
||||
Task<VexConsensusListResponse> ListVexConsensusAsync(VexConsensusListRequest request, string? tenant, CancellationToken cancellationToken);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Infrastructure.Postgres.Migrations;
|
||||
using StellaOps.Platform.Database;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
|
||||
using StellaOps.Authority.Persistence.Postgres;
|
||||
using StellaOps.Concelier.Persistence.Postgres;
|
||||
using StellaOps.Excititor.Persistence.Postgres;
|
||||
using StellaOps.Notify.Persistence.Postgres;
|
||||
using StellaOps.Policy.Persistence.Postgres;
|
||||
using StellaOps.Scheduler.Persistence.Postgres;
|
||||
using System.Reflection;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a PostgreSQL module with its migration metadata.
|
||||
/// </summary>
|
||||
public sealed record MigrationModuleInfo(
|
||||
string Name,
|
||||
string SchemaName,
|
||||
Assembly MigrationsAssembly,
|
||||
string? ResourcePrefix = null);
|
||||
|
||||
/// <summary>
|
||||
/// Registry of all PostgreSQL modules and their migration assemblies.
|
||||
/// Stub implementation - actual module assemblies will be wired in Wave 3-8.
|
||||
/// </summary>
|
||||
public static class MigrationModuleRegistry
|
||||
{
|
||||
private static readonly List<MigrationModuleInfo> _modules =
|
||||
[
|
||||
new(
|
||||
Name: "Authority",
|
||||
SchemaName: "authority",
|
||||
MigrationsAssembly: typeof(AuthorityDataSource).Assembly,
|
||||
ResourcePrefix: "StellaOps.Authority.Persistence.Migrations"),
|
||||
new(
|
||||
Name: "Scheduler",
|
||||
SchemaName: "scheduler",
|
||||
MigrationsAssembly: typeof(SchedulerDataSource).Assembly,
|
||||
ResourcePrefix: "StellaOps.Scheduler.Persistence.Migrations"),
|
||||
new(
|
||||
Name: "Concelier",
|
||||
SchemaName: "vuln",
|
||||
MigrationsAssembly: typeof(ConcelierDataSource).Assembly,
|
||||
ResourcePrefix: "StellaOps.Concelier.Persistence.Migrations"),
|
||||
new(
|
||||
Name: "Policy",
|
||||
SchemaName: "policy",
|
||||
MigrationsAssembly: typeof(PolicyDataSource).Assembly,
|
||||
ResourcePrefix: "StellaOps.Policy.Persistence.Migrations"),
|
||||
new(
|
||||
Name: "Notify",
|
||||
SchemaName: "notify",
|
||||
MigrationsAssembly: typeof(NotifyDataSource).Assembly,
|
||||
ResourcePrefix: "StellaOps.Notify.Persistence.Migrations"),
|
||||
new(
|
||||
Name: "Excititor",
|
||||
SchemaName: "vex",
|
||||
MigrationsAssembly: typeof(ExcititorDataSource).Assembly,
|
||||
ResourcePrefix: "StellaOps.Excititor.Persistence.Migrations"),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Gets all registered modules.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<MigrationModuleInfo> Modules => _modules;
|
||||
|
||||
/// <summary>
|
||||
/// Gets module names for CLI completion.
|
||||
/// </summary>
|
||||
public static IEnumerable<string> ModuleNames => _modules.Select(m => m.Name);
|
||||
|
||||
/// <summary>
|
||||
/// Finds a module by name (case-insensitive).
|
||||
/// </summary>
|
||||
public static MigrationModuleInfo? FindModule(string name) =>
|
||||
_modules.FirstOrDefault(m =>
|
||||
string.Equals(m.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>
|
||||
/// Gets modules matching the filter, or all if filter is null/empty.
|
||||
/// </summary>
|
||||
public static IEnumerable<MigrationModuleInfo> GetModules(string? moduleFilter)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(moduleFilter) || moduleFilter.Equals("all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return _modules;
|
||||
}
|
||||
|
||||
var module = FindModule(moduleFilter);
|
||||
return module != null ? [module] : [];
|
||||
}
|
||||
}
|
||||
@@ -146,3 +146,125 @@ internal sealed class AdvisoryOutputProvenanceModel
|
||||
|
||||
public IReadOnlyList<string> Signatures { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryKnowledgeSearchRequestModel
|
||||
{
|
||||
public string Q { get; init; } = string.Empty;
|
||||
|
||||
public int? K { get; init; }
|
||||
|
||||
public AdvisoryKnowledgeSearchFilterModel? Filters { get; init; }
|
||||
|
||||
public bool IncludeDebug { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryKnowledgeSearchFilterModel
|
||||
{
|
||||
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; }
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryKnowledgeSearchResponseModel
|
||||
{
|
||||
public string Query { get; init; } = string.Empty;
|
||||
|
||||
public int TopK { get; init; }
|
||||
|
||||
public IReadOnlyList<AdvisoryKnowledgeSearchResultModel> Results { get; init; } = Array.Empty<AdvisoryKnowledgeSearchResultModel>();
|
||||
|
||||
public AdvisoryKnowledgeSearchDiagnosticsModel Diagnostics { get; init; } = new();
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryKnowledgeSearchResultModel
|
||||
{
|
||||
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 AdvisoryKnowledgeOpenActionModel Open { get; init; } = new();
|
||||
|
||||
public IReadOnlyDictionary<string, string>? Debug { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryKnowledgeOpenActionModel
|
||||
{
|
||||
public string Kind { get; init; } = "docs";
|
||||
|
||||
public AdvisoryKnowledgeOpenDocActionModel? Docs { get; init; }
|
||||
|
||||
public AdvisoryKnowledgeOpenApiActionModel? Api { get; init; }
|
||||
|
||||
public AdvisoryKnowledgeOpenDoctorActionModel? Doctor { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryKnowledgeOpenDocActionModel
|
||||
{
|
||||
public string Path { get; init; } = string.Empty;
|
||||
|
||||
public string Anchor { get; init; } = "overview";
|
||||
|
||||
public int SpanStart { get; init; }
|
||||
|
||||
public int SpanEnd { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryKnowledgeOpenApiActionModel
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryKnowledgeOpenDoctorActionModel
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryKnowledgeSearchDiagnosticsModel
|
||||
{
|
||||
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";
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryKnowledgeRebuildResponseModel
|
||||
{
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -64,12 +64,14 @@
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.DeltaVerdict/StellaOps.DeltaVerdict.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Verdict/StellaOps.Verdict.csproj" />
|
||||
<ProjectReference Include="../../AirGap/__Libraries/StellaOps.AirGap.Bundle/StellaOps.AirGap.Bundle.csproj" />
|
||||
<ProjectReference Include="../../AirGap/__Libraries/StellaOps.AirGap.Persistence/StellaOps.AirGap.Persistence.csproj" />
|
||||
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.csproj" />
|
||||
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Importer/StellaOps.AirGap.Importer.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.EntryTrace/StellaOps.Scanner.EntryTrace.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Storage/StellaOps.Scanner.Storage.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Storage.Oci/StellaOps.Scanner.Storage.Oci.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang/StellaOps.Scanner.Analyzers.Lang.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Node/StellaOps.Scanner.Analyzers.Lang.Node.csproj" />
|
||||
@@ -103,6 +105,8 @@
|
||||
<ProjectReference Include="../../Excititor/__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj" />
|
||||
<ProjectReference Include="../../Excititor/__Libraries/StellaOps.Excititor.Persistence/StellaOps.Excititor.Persistence.csproj" />
|
||||
<ProjectReference Include="../../Policy/StellaOps.Policy.Scoring/StellaOps.Policy.Scoring.csproj" />
|
||||
<ProjectReference Include="../../Platform/__Libraries/StellaOps.Platform.Database/StellaOps.Platform.Database.csproj" />
|
||||
<ProjectReference Include="../../TimelineIndexer/StellaOps.TimelineIndexer/StellaOps.TimelineIndexer.Infrastructure/StellaOps.TimelineIndexer.Infrastructure.csproj" />
|
||||
<ProjectReference Include="../../ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Client/StellaOps.ExportCenter.Client.csproj" />
|
||||
<ProjectReference Include="../../ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Core/StellaOps.ExportCenter.Core.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.AuditPack/StellaOps.AuditPack.csproj" />
|
||||
|
||||
@@ -5,6 +5,8 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| SPRINT_20260222_051-MGC-04-W1 | DONE | Expanded migration registry coverage to `AirGap`, `Scanner`, `TimelineIndexer`, and `Platform` (10 total modules); moved registry ownership to `StellaOps.Platform.Database` and rewired CLI migration commands to consume the platform-owned registry. |
|
||||
| SPRINT_20260222_051-MGC-04-W1-PLUGINS | DONE | CLI migration commands now consume plugin auto-discovered module catalog from `StellaOps.Platform.Database` (`IMigrationModulePlugin`) instead of hardcoded module registration. |
|
||||
| SPRINT_20260221_043-CLI-SEED-001 | DONE | Sprint `docs/implplan/SPRINT_20260221_043_DOCS_setup_seed_error_handling_stabilization.md`: harden seed/migration first-run flow and fix dry-run migration reporting semantics. |
|
||||
| AUDIT-0137-M | DONE | Revalidated 2026-01-06. |
|
||||
| AUDIT-0137-T | DONE | Revalidated 2026-01-06. |
|
||||
|
||||
@@ -132,4 +132,19 @@ public sealed class CommandFactoryTests
|
||||
var sbomLake = Assert.Single(analytics.Subcommands, command => string.Equals(command.Name, "sbom-lake", StringComparison.Ordinal));
|
||||
Assert.Contains(sbomLake.Subcommands, command => string.Equals(command.Name, "suppliers", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_ExposesKnowledgeSearchCommands()
|
||||
{
|
||||
using var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.None));
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var root = CommandFactory.Create(services, new StellaOpsCliOptions(), CancellationToken.None, loggerFactory);
|
||||
|
||||
var search = Assert.Single(root.Subcommands, command => string.Equals(command.Name, "search", StringComparison.Ordinal));
|
||||
Assert.NotNull(search);
|
||||
|
||||
var advisoryAi = Assert.Single(root.Subcommands, command => string.Equals(command.Name, "advisoryai", StringComparison.Ordinal));
|
||||
var index = Assert.Single(advisoryAi.Subcommands, command => string.Equals(command.Name, "index", StringComparison.Ordinal));
|
||||
Assert.Contains(index.Subcommands, command => string.Equals(command.Name, "rebuild", StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,22 @@ public sealed class DoctorCommandGroupTests
|
||||
fixCommand!.Description.Should().Contain("fix");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDoctorCommand_HasSuggestSubcommand()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateTestServices();
|
||||
var verboseOption = new Option<bool>("--verbose");
|
||||
|
||||
// Act
|
||||
var command = DoctorCommandGroup.BuildDoctorCommand(services, verboseOption, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var suggestCommand = command.Subcommands.FirstOrDefault(c => c.Name == "suggest");
|
||||
suggestCommand.Should().NotBeNull();
|
||||
suggestCommand!.Description.Should().Contain("Suggest");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Run Subcommand Options Tests
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.CommandLine;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public sealed class KnowledgeSearchCommandGroupTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SearchCommand_JsonOutput_UsesDeterministicPayloadAndNormalizedFilters()
|
||||
{
|
||||
AdvisoryKnowledgeSearchRequestModel? capturedRequest = null;
|
||||
var backend = new Mock<IBackendOperationsClient>(MockBehavior.Strict);
|
||||
backend
|
||||
.Setup(client => client.SearchAdvisoryKnowledgeAsync(
|
||||
It.IsAny<AdvisoryKnowledgeSearchRequestModel>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<AdvisoryKnowledgeSearchRequestModel, CancellationToken>((request, _) => capturedRequest = request)
|
||||
.ReturnsAsync(CreateSearchResponse());
|
||||
|
||||
using var services = new ServiceCollection()
|
||||
.AddSingleton(backend.Object)
|
||||
.BuildServiceProvider();
|
||||
|
||||
var root = new RootCommand();
|
||||
root.Add(KnowledgeSearchCommandGroup.BuildSearchCommand(
|
||||
services,
|
||||
new Option<bool>("--verbose"),
|
||||
CancellationToken.None));
|
||||
|
||||
var invocation = await InvokeWithCapturedConsoleAsync(
|
||||
root,
|
||||
"search \"OIDC cert failure\" --type doctor,api --type docs --tag Auth --tag oidc,cert --product stella --version 1.2.3 --service gateway --k 7 --json");
|
||||
|
||||
Assert.Equal(0, invocation.ExitCode);
|
||||
Assert.NotNull(capturedRequest);
|
||||
Assert.Equal("OIDC cert failure", capturedRequest!.Q);
|
||||
Assert.Equal(7, capturedRequest.K);
|
||||
Assert.NotNull(capturedRequest.Filters);
|
||||
Assert.Equal(new[] { "api", "docs", "doctor" }, capturedRequest.Filters!.Type);
|
||||
Assert.Equal(new[] { "auth", "cert", "oidc" }, capturedRequest.Filters!.Tags);
|
||||
Assert.Equal("stella", capturedRequest.Filters.Product);
|
||||
Assert.Equal("1.2.3", capturedRequest.Filters.Version);
|
||||
Assert.Equal("gateway", capturedRequest.Filters.Service);
|
||||
Assert.False(capturedRequest.IncludeDebug);
|
||||
|
||||
using var payload = JsonDocument.Parse(invocation.StdOut);
|
||||
var rootElement = payload.RootElement;
|
||||
Assert.Equal("OIDC cert failure", rootElement.GetProperty("query").GetString());
|
||||
Assert.Equal(7, rootElement.GetProperty("topK").GetInt32());
|
||||
var results = rootElement.GetProperty("results");
|
||||
Assert.Equal(3, results.GetArrayLength());
|
||||
Assert.Equal("doctor", results[0].GetProperty("type").GetString());
|
||||
Assert.Equal("check.auth.oidc.cert", results[0].GetProperty("open").GetProperty("doctor").GetProperty("checkCode").GetString());
|
||||
|
||||
backend.VerifyAll();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DoctorSuggestCommand_RendersGroupedOutput()
|
||||
{
|
||||
AdvisoryKnowledgeSearchRequestModel? capturedRequest = null;
|
||||
var backend = new Mock<IBackendOperationsClient>(MockBehavior.Strict);
|
||||
backend
|
||||
.Setup(client => client.SearchAdvisoryKnowledgeAsync(
|
||||
It.IsAny<AdvisoryKnowledgeSearchRequestModel>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<AdvisoryKnowledgeSearchRequestModel, CancellationToken>((request, _) => capturedRequest = request)
|
||||
.ReturnsAsync(CreateSearchResponse());
|
||||
|
||||
using var services = new ServiceCollection()
|
||||
.AddSingleton(backend.Object)
|
||||
.BuildServiceProvider();
|
||||
|
||||
var root = new RootCommand();
|
||||
root.Add(DoctorCommandGroup.BuildDoctorCommand(
|
||||
services,
|
||||
new Option<bool>("--verbose"),
|
||||
CancellationToken.None));
|
||||
|
||||
var invocation = await InvokeWithCapturedConsoleAsync(
|
||||
root,
|
||||
"doctor suggest \"x509: certificate signed by unknown authority\" --k 5");
|
||||
|
||||
Assert.Equal(0, invocation.ExitCode);
|
||||
Assert.NotNull(capturedRequest);
|
||||
Assert.Equal("x509: certificate signed by unknown authority", capturedRequest!.Q);
|
||||
Assert.Equal(5, capturedRequest.K);
|
||||
Assert.Null(capturedRequest.Filters);
|
||||
|
||||
Assert.Contains("Recommended checks:", invocation.StdOut, StringComparison.Ordinal);
|
||||
Assert.Contains("doctor: check.auth.oidc.cert", invocation.StdOut, StringComparison.Ordinal);
|
||||
Assert.Contains("Related docs:", invocation.StdOut, StringComparison.Ordinal);
|
||||
Assert.Contains("docs: docs/operations/oidc.md#tls_trust_chain", invocation.StdOut, StringComparison.Ordinal);
|
||||
Assert.Contains("Related endpoints:", invocation.StdOut, StringComparison.Ordinal);
|
||||
Assert.Contains("api: POST /api/v1/authority/oidc/test", invocation.StdOut, StringComparison.Ordinal);
|
||||
|
||||
backend.VerifyAll();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdvisoryAiRebuildCommand_JsonOutput_ContainsCounts()
|
||||
{
|
||||
var backend = new Mock<IBackendOperationsClient>(MockBehavior.Strict);
|
||||
backend
|
||||
.Setup(client => client.RebuildAdvisoryKnowledgeIndexAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new AdvisoryKnowledgeRebuildResponseModel
|
||||
{
|
||||
DocumentCount = 120,
|
||||
ChunkCount = 640,
|
||||
ApiSpecCount = 8,
|
||||
ApiOperationCount = 114,
|
||||
DoctorProjectionCount = 36,
|
||||
DurationMs = 2485
|
||||
});
|
||||
|
||||
using var services = new ServiceCollection()
|
||||
.AddSingleton(backend.Object)
|
||||
.BuildServiceProvider();
|
||||
|
||||
var root = new RootCommand();
|
||||
root.Add(KnowledgeSearchCommandGroup.BuildAdvisoryAiCommand(
|
||||
services,
|
||||
new Option<bool>("--verbose"),
|
||||
CancellationToken.None));
|
||||
|
||||
var invocation = await InvokeWithCapturedConsoleAsync(root, "advisoryai index rebuild --json");
|
||||
|
||||
Assert.Equal(0, invocation.ExitCode);
|
||||
using var payload = JsonDocument.Parse(invocation.StdOut);
|
||||
var rootElement = payload.RootElement;
|
||||
Assert.Equal(120, rootElement.GetProperty("documentCount").GetInt32());
|
||||
Assert.Equal(640, rootElement.GetProperty("chunkCount").GetInt32());
|
||||
Assert.Equal(8, rootElement.GetProperty("apiSpecCount").GetInt32());
|
||||
Assert.Equal(114, rootElement.GetProperty("apiOperationCount").GetInt32());
|
||||
Assert.Equal(36, rootElement.GetProperty("doctorProjectionCount").GetInt32());
|
||||
Assert.Equal(2485, rootElement.GetProperty("durationMs").GetInt64());
|
||||
|
||||
backend.VerifyAll();
|
||||
}
|
||||
|
||||
private static AdvisoryKnowledgeSearchResponseModel CreateSearchResponse()
|
||||
{
|
||||
return new AdvisoryKnowledgeSearchResponseModel
|
||||
{
|
||||
Query = "OIDC cert failure",
|
||||
TopK = 7,
|
||||
Diagnostics = new AdvisoryKnowledgeSearchDiagnosticsModel
|
||||
{
|
||||
FtsMatches = 18,
|
||||
VectorMatches = 0,
|
||||
DurationMs = 14,
|
||||
UsedVector = false,
|
||||
Mode = "fts-only"
|
||||
},
|
||||
Results =
|
||||
[
|
||||
new AdvisoryKnowledgeSearchResultModel
|
||||
{
|
||||
Type = "doctor",
|
||||
Title = "check.auth.oidc.cert - Validate OIDC trust chain",
|
||||
Snippet = "OIDC issuer TLS certificate chain is untrusted.",
|
||||
Score = 0.991,
|
||||
Open = new AdvisoryKnowledgeOpenActionModel
|
||||
{
|
||||
Kind = "doctor",
|
||||
Doctor = new AdvisoryKnowledgeOpenDoctorActionModel
|
||||
{
|
||||
CheckCode = "check.auth.oidc.cert",
|
||||
Severity = "fail",
|
||||
CanRun = true,
|
||||
RunCommand = "stella doctor run --check check.auth.oidc.cert"
|
||||
}
|
||||
},
|
||||
Debug = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["rrf"] = "1.0"
|
||||
}
|
||||
},
|
||||
new AdvisoryKnowledgeSearchResultModel
|
||||
{
|
||||
Type = "docs",
|
||||
Title = "OIDC troubleshooting - trust chain",
|
||||
Snippet = "Import the issuer CA bundle and verify trust store ordering.",
|
||||
Score = 0.876,
|
||||
Open = new AdvisoryKnowledgeOpenActionModel
|
||||
{
|
||||
Kind = "docs",
|
||||
Docs = new AdvisoryKnowledgeOpenDocActionModel
|
||||
{
|
||||
Path = "docs/operations/oidc.md",
|
||||
Anchor = "tls_trust_chain",
|
||||
SpanStart = 122,
|
||||
SpanEnd = 168
|
||||
}
|
||||
}
|
||||
},
|
||||
new AdvisoryKnowledgeSearchResultModel
|
||||
{
|
||||
Type = "api",
|
||||
Title = "POST /api/v1/authority/oidc/test",
|
||||
Snippet = "Validates OIDC configuration and returns certificate diagnostics.",
|
||||
Score = 0.744,
|
||||
Open = new AdvisoryKnowledgeOpenActionModel
|
||||
{
|
||||
Kind = "api",
|
||||
Api = new AdvisoryKnowledgeOpenApiActionModel
|
||||
{
|
||||
Service = "authority",
|
||||
Method = "POST",
|
||||
Path = "/api/v1/authority/oidc/test",
|
||||
OperationId = "Authority_TestOidcConfiguration"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<CommandInvocationResult> InvokeWithCapturedConsoleAsync(
|
||||
RootCommand root,
|
||||
string commandLine)
|
||||
{
|
||||
var originalOut = Console.Out;
|
||||
var originalError = Console.Error;
|
||||
var stdout = new StringWriter(CultureInfo.InvariantCulture);
|
||||
var stderr = new StringWriter(CultureInfo.InvariantCulture);
|
||||
try
|
||||
{
|
||||
Console.SetOut(stdout);
|
||||
Console.SetError(stderr);
|
||||
var exitCode = await root.Parse(commandLine).InvokeAsync();
|
||||
return new CommandInvocationResult(exitCode, stdout.ToString(), stderr.ToString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
Console.SetError(originalError);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record CommandInvocationResult(int ExitCode, string StdOut, string StdErr);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Platform.Database;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
@@ -9,6 +9,6 @@ public class MigrationCommandHandlersTests
|
||||
[Fact]
|
||||
public void Registry_Has_All_Modules()
|
||||
{
|
||||
Assert.Equal(6, MigrationModuleRegistry.Modules.Count);
|
||||
Assert.Equal(10, MigrationModuleRegistry.Modules.Count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Linq;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Platform.Database;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
@@ -10,14 +10,34 @@ public class MigrationModuleRegistryTests
|
||||
public void Modules_Populated_With_All_Postgres_Modules()
|
||||
{
|
||||
var modules = MigrationModuleRegistry.Modules;
|
||||
Assert.Equal(6, modules.Count);
|
||||
Assert.Equal(10, modules.Count);
|
||||
Assert.Contains(modules, m => m.Name == "AirGap" && m.SchemaName == "airgap");
|
||||
Assert.Contains(modules, m => m.Name == "Authority" && m.SchemaName == "authority");
|
||||
Assert.Contains(modules, m => m.Name == "Scheduler" && m.SchemaName == "scheduler");
|
||||
Assert.Contains(modules, m => m.Name == "Concelier" && m.SchemaName == "vuln");
|
||||
Assert.Contains(modules, m => m.Name == "Policy" && m.SchemaName == "policy");
|
||||
Assert.Contains(modules, m => m.Name == "Notify" && m.SchemaName == "notify");
|
||||
Assert.Contains(modules, m => m.Name == "Excititor" && m.SchemaName == "vex");
|
||||
Assert.Equal(6, MigrationModuleRegistry.ModuleNames.Count());
|
||||
Assert.Contains(modules, m => m.Name == "Platform" && m.SchemaName == "release");
|
||||
Assert.Contains(modules, m => m.Name == "Scanner" && m.SchemaName == "scanner");
|
||||
Assert.Contains(modules, m => m.Name == "TimelineIndexer" && m.SchemaName == "timeline");
|
||||
Assert.Equal(10, MigrationModuleRegistry.ModuleNames.Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Modules_Are_AutoDiscovered_From_Plugins()
|
||||
{
|
||||
var pluginTypes = typeof(MigrationModuleRegistry)
|
||||
.Assembly
|
||||
.GetTypes()
|
||||
.Where(static type =>
|
||||
typeof(IMigrationModulePlugin).IsAssignableFrom(type) &&
|
||||
!type.IsAbstract &&
|
||||
!type.IsInterface)
|
||||
.ToArray();
|
||||
|
||||
Assert.NotEmpty(pluginTypes);
|
||||
Assert.Equal(pluginTypes.Length, MigrationModuleRegistry.Modules.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -40,6 +60,6 @@ public class MigrationModuleRegistryTests
|
||||
public void GetModules_All_Returns_All()
|
||||
{
|
||||
var result = MigrationModuleRegistry.GetModules(null);
|
||||
Assert.Equal(6, result.Count());
|
||||
Assert.Equal(10, result.Count());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Platform.Database;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
@@ -23,12 +24,16 @@ public class SystemCommandBuilderTests
|
||||
[Fact]
|
||||
public void ModuleNames_Contains_All_Modules()
|
||||
{
|
||||
Assert.Contains("AirGap", MigrationModuleRegistry.ModuleNames);
|
||||
Assert.Contains("Authority", MigrationModuleRegistry.ModuleNames);
|
||||
Assert.Contains("Scheduler", MigrationModuleRegistry.ModuleNames);
|
||||
Assert.Contains("Concelier", MigrationModuleRegistry.ModuleNames);
|
||||
Assert.Contains("Policy", MigrationModuleRegistry.ModuleNames);
|
||||
Assert.Contains("Notify", MigrationModuleRegistry.ModuleNames);
|
||||
Assert.Contains("Excititor", MigrationModuleRegistry.ModuleNames);
|
||||
Assert.Contains("Platform", MigrationModuleRegistry.ModuleNames);
|
||||
Assert.Contains("Scanner", MigrationModuleRegistry.ModuleNames);
|
||||
Assert.Contains("TimelineIndexer", MigrationModuleRegistry.ModuleNames);
|
||||
}
|
||||
|
||||
private static Command BuildSystemCommand()
|
||||
|
||||
@@ -5,6 +5,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| SPRINT_20260222_051-MGC-04-W1-TESTS | DONE | Updated migration registry/system command tests for platform-owned 10-module coverage and validated with `dotnet test` (1182 passed on 2026-02-22). |
|
||||
| AUDIT-0143-M | DONE | Revalidated 2026-01-06. |
|
||||
| AUDIT-0143-T | DONE | Revalidated 2026-01-06. |
|
||||
| AUDIT-0143-A | DONE | Waived (test project; revalidated 2026-01-06). |
|
||||
|
||||
@@ -458,10 +458,11 @@ builder.Services.AddSingleton<IOptionsMonitor<ConcelierOptions>>(_ => new Static
|
||||
builder.Services.AddStellaOpsCrypto(concelierOptions.Crypto);
|
||||
|
||||
// Stella Router integration
|
||||
builder.Services.TryAddStellaRouter(
|
||||
var routerEnabled = builder.Services.AddRouterMicroservice(
|
||||
builder.Configuration,
|
||||
serviceName: "concelier",
|
||||
version: typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0",
|
||||
routerOptions: concelierOptions.Router);
|
||||
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
|
||||
routerOptionsSection: "Router");
|
||||
|
||||
builder.ConfigureConcelierTelemetry(concelierOptions);
|
||||
|
||||
@@ -900,7 +901,7 @@ if (authorityConfigured)
|
||||
}
|
||||
|
||||
// Stella Router integration
|
||||
app.TryUseStellaRouter(concelierOptions.Router);
|
||||
app.TryUseStellaRouter(routerEnabled);
|
||||
|
||||
// Deprecation headers for legacy endpoints (CONCELIER-WEB-OAS-63-001)
|
||||
app.UseDeprecationHeaders();
|
||||
@@ -4414,7 +4415,7 @@ app.MapGet("/v1/signals/symbols/exists/{advisoryId}", async (
|
||||
}).WithName("CheckAffectedSymbolsExist");
|
||||
|
||||
// Refresh Router endpoint cache after all endpoints are registered
|
||||
app.TryRefreshStellaRouterEndpoints(concelierOptions.Router);
|
||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||
|
||||
await app.RunAsync();
|
||||
}
|
||||
@@ -4654,3 +4655,5 @@ internal sealed class StaticOptionsMonitor<TOptions> : IOptionsMonitor<TOptions>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -51,4 +51,8 @@
|
||||
<ItemGroup>
|
||||
<Compile Include="../__Libraries/StellaOps.Concelier.Core/Federation/**/*.cs" Link="Federation/%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="StellaOpsReleaseVersion">
|
||||
<Version>1.0.0-alpha1</Version>
|
||||
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -152,6 +152,10 @@
|
||||
<!-- ============================================================================
|
||||
PROJECT REFERENCES
|
||||
============================================================================ -->
|
||||
<ItemGroup Condition="'$(UsingMicrosoftNETSdkWeb)' == 'true' and '$(IsTestProject)' != 'true'">
|
||||
<ProjectReference Include="$(MSBuildThisFileDirectory)Router\__Libraries\StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Update="../StellaOps.Plugin/StellaOps.Plugin.csproj">
|
||||
<Private>false</Private>
|
||||
|
||||
@@ -99,6 +99,56 @@
|
||||
|
||||
<Copy SourceFiles="@(ScannerLangAnalyzerPluginArtifacts)" DestinationFolder="$(ScannerLangAnalyzerPluginOutputDirectory)" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
|
||||
<Target
|
||||
Name="RouterPackTransportPluginsForWebPublish"
|
||||
AfterTargets="Publish"
|
||||
Condition="('$(UsingMicrosoftNETSdkWeb)' == 'true' or '$(EnableRouterTransportPluginPackaging)' == 'true') and '$(IsTestProject)' != 'true' and '$(DisableRouterTransportPluginPackaging)' != 'true'">
|
||||
<PropertyGroup>
|
||||
<RouterTransportPluginProject Condition="'$(RouterTransportPluginProject)' == ''">$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)Router\__Libraries\StellaOps.Router.Transport.Messaging\StellaOps.Router.Transport.Messaging.csproj'))</RouterTransportPluginProject>
|
||||
<MessagingTransportPluginProject Condition="'$(MessagingTransportPluginProject)' == ''">$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)Router\__Libraries\StellaOps.Messaging.Transport.Valkey\StellaOps.Messaging.Transport.Valkey.csproj'))</MessagingTransportPluginProject>
|
||||
<RouterTransportPluginSourceDir Condition="'$(RouterTransportPluginSourceDir)' == ''">$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)Router\__Libraries\StellaOps.Router.Transport.Messaging\bin\$(Configuration)\$(TargetFramework)'))</RouterTransportPluginSourceDir>
|
||||
<MessagingTransportPluginSourceDir Condition="'$(MessagingTransportPluginSourceDir)' == ''">$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)Router\__Libraries\StellaOps.Messaging.Transport.Valkey\bin\$(Configuration)\$(TargetFramework)'))</MessagingTransportPluginSourceDir>
|
||||
</PropertyGroup>
|
||||
|
||||
<MSBuild
|
||||
Projects="$(RouterTransportPluginProject);$(MessagingTransportPluginProject)"
|
||||
Targets="Restore;Build"
|
||||
Properties="Configuration=$(Configuration);TargetFramework=$(TargetFramework);CopyLocalLockFileAssemblies=true"
|
||||
BuildInParallel="false" />
|
||||
|
||||
<ItemGroup>
|
||||
<_RouterTransportPlugins Include="$(RouterTransportPluginSourceDir)\StellaOps*.dll" />
|
||||
<_RouterTransportPluginMetadata Include="$(RouterTransportPluginSourceDir)\*.deps.json" />
|
||||
|
||||
<_MessagingTransportPlugins Include="$(MessagingTransportPluginSourceDir)\StellaOps*.dll" />
|
||||
<_MessagingTransportPlugins Include="$(MessagingTransportPluginSourceDir)\StackExchange.Redis.dll" Condition="Exists('$(MessagingTransportPluginSourceDir)\StackExchange.Redis.dll')" />
|
||||
<_MessagingTransportPlugins Include="$(MessagingTransportPluginSourceDir)\Pipelines.Sockets.Unofficial.dll" Condition="Exists('$(MessagingTransportPluginSourceDir)\Pipelines.Sockets.Unofficial.dll')" />
|
||||
<_MessagingTransportPlugins Include="$(MessagingTransportPluginSourceDir)\System.IO.Hashing.dll" Condition="Exists('$(MessagingTransportPluginSourceDir)\System.IO.Hashing.dll')" />
|
||||
<_MessagingTransportPluginMetadata Include="$(MessagingTransportPluginSourceDir)\*.deps.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<MakeDir Directories="$(PublishDir)plugins/router/transports" />
|
||||
<MakeDir Directories="$(PublishDir)plugins/messaging" />
|
||||
|
||||
<Copy
|
||||
SourceFiles="@(_RouterTransportPlugins)"
|
||||
DestinationFolder="$(PublishDir)plugins/router/transports"
|
||||
SkipUnchangedFiles="true" />
|
||||
<Copy
|
||||
SourceFiles="@(_RouterTransportPluginMetadata)"
|
||||
DestinationFolder="$(PublishDir)plugins/router/transports"
|
||||
SkipUnchangedFiles="true" />
|
||||
<Copy
|
||||
SourceFiles="@(_MessagingTransportPlugins)"
|
||||
DestinationFolder="$(PublishDir)plugins/messaging"
|
||||
SkipUnchangedFiles="true" />
|
||||
<Copy
|
||||
SourceFiles="@(_MessagingTransportPluginMetadata)"
|
||||
DestinationFolder="$(PublishDir)plugins/messaging"
|
||||
SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
|
||||
<!-- Enable Microsoft Testing Platform for all xUnit v3 projects (including those that set UseXunitV3 in their csproj).
|
||||
This must be in targets (not props) because non-.Tests projects set UseXunitV3 in their project file,
|
||||
which is evaluated after Directory.Build.props but before Directory.Build.targets. -->
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using StellaOps.Router.AspNet;
|
||||
using StellaOps.Doctor.Scheduler;
|
||||
using StellaOps.Doctor.Scheduler.Endpoints;
|
||||
using StellaOps.Doctor.Scheduler.Models;
|
||||
@@ -47,9 +48,18 @@ builder.Services.AddHostedService<DoctorScheduleWorker>();
|
||||
|
||||
builder.Services.AddWorkerHealthChecks();
|
||||
|
||||
// Stella Router integration
|
||||
var routerEnabled = builder.Services.AddRouterMicroservice(
|
||||
builder.Configuration,
|
||||
serviceName: "doctor-scheduler",
|
||||
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
|
||||
routerOptionsSection: "Router");
|
||||
var app = builder.Build();
|
||||
app.TryUseStellaRouter(routerEnabled);
|
||||
app.MapWorkerHealthEndpoints();
|
||||
app.MapSchedulerApiEndpoints();
|
||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||
await app.RunAsync();
|
||||
|
||||
public partial class Program;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Doctor.Scheduler</RootNamespace>
|
||||
<Description>Scheduled Doctor health check runs with alerting and trending</Description>
|
||||
<EnableRouterTransportPluginPackaging>true</EnableRouterTransportPluginPackaging>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -24,5 +25,8 @@
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Worker.Health\StellaOps.Worker.Health.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Doctor.WebService\StellaOps.Doctor.WebService.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Label="StellaOpsReleaseVersion">
|
||||
<Version>1.0.0-alpha1</Version>
|
||||
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -68,7 +68,7 @@ builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddStellaOpsTelemetry(
|
||||
builder.Configuration,
|
||||
serviceName: "StellaOps.Doctor",
|
||||
serviceVersion: typeof(Program).Assembly.GetName().Version?.ToString(),
|
||||
serviceVersion: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(typeof(Program).Assembly)?.InformationalVersion ?? "1.0.0",
|
||||
configureMetrics: meterBuilder =>
|
||||
{
|
||||
meterBuilder.AddMeter("StellaOps.Doctor.Runs");
|
||||
@@ -159,11 +159,11 @@ builder.Services.AddSingleton<DoctorRunService>();
|
||||
builder.Services.AddSingleton<DoctorDiagnosisService>();
|
||||
builder.Services.AddSingleton<ITimestampingDashboardProvider, TimestampingDashboardProvider>(); // Timestamping dashboard
|
||||
|
||||
var routerOptions = builder.Configuration.GetSection("Doctor:Router").Get<StellaRouterOptionsBase>();
|
||||
builder.Services.TryAddStellaRouter(
|
||||
var routerEnabled = builder.Services.AddRouterMicroservice(
|
||||
builder.Configuration,
|
||||
serviceName: "doctor",
|
||||
version: typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0",
|
||||
routerOptions: routerOptions);
|
||||
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
|
||||
routerOptionsSection: "Router");
|
||||
|
||||
builder.TryAddStellaOpsLocalBinding("doctor");
|
||||
var app = builder.Build();
|
||||
@@ -178,7 +178,7 @@ app.UseStellaOpsTelemetryContext();
|
||||
app.UseStellaOpsCors();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.TryUseStellaRouter(routerOptions);
|
||||
app.TryUseStellaRouter(routerEnabled);
|
||||
|
||||
app.MapDoctorEndpoints();
|
||||
app.MapTimestampingEndpoints(); // Timestamping dashboard endpoints
|
||||
@@ -191,8 +191,11 @@ app.MapGet("/readyz", () => Results.Ok(new { status = "ready" }))
|
||||
.WithTags("Health")
|
||||
.AllowAnonymous();
|
||||
|
||||
app.TryRefreshStellaRouterEndpoints(routerOptions);
|
||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||
|
||||
app.Run();
|
||||
|
||||
public partial class Program;
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -37,4 +37,8 @@
|
||||
<ProjectReference Include="..\..\Router\__Libraries\StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Label="StellaOpsReleaseVersion">
|
||||
<Version>1.0.0-alpha1</Version>
|
||||
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -48,11 +48,11 @@ builder.Services.AddOpenApi();
|
||||
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
|
||||
|
||||
// Stella Router integration
|
||||
var routerOptions = builder.Configuration.GetSection("EvidenceLocker:Router").Get<StellaRouterOptionsBase>();
|
||||
builder.Services.TryAddStellaRouter(
|
||||
var routerEnabled = builder.Services.AddRouterMicroservice(
|
||||
builder.Configuration,
|
||||
serviceName: "evidencelocker",
|
||||
version: typeof(StellaOps.EvidenceLocker.WebService.Program).Assembly.GetName().Version?.ToString() ?? "1.0.0",
|
||||
routerOptions: routerOptions);
|
||||
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
|
||||
routerOptionsSection: "Router");
|
||||
|
||||
builder.TryAddStellaOpsLocalBinding("evidencelocker");
|
||||
var app = builder.Build();
|
||||
@@ -66,7 +66,7 @@ if (app.Environment.IsDevelopment())
|
||||
app.UseStellaOpsCors();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.TryUseStellaRouter(routerOptions);
|
||||
app.TryUseStellaRouter(routerEnabled);
|
||||
|
||||
app.MapHealthChecks("/health/ready");
|
||||
|
||||
@@ -447,7 +447,7 @@ app.MapEvidenceAuditEndpoints();
|
||||
app.MapEvidenceThreadEndpoints();
|
||||
|
||||
// Refresh Router endpoint cache
|
||||
app.TryRefreshStellaRouterEndpoints(routerOptions);
|
||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -464,3 +464,6 @@ namespace StellaOps.EvidenceLocker.WebService
|
||||
{
|
||||
public partial class Program { }
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -24,4 +24,8 @@
|
||||
<ProjectReference Include="..\..\..\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\..\Router/__Libraries/StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="StellaOpsReleaseVersion">
|
||||
<Version>1.0.0-alpha1</Version>
|
||||
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -193,11 +193,11 @@ services.AddMemoryCache();
|
||||
builder.ConfigureExcititorTelemetry();
|
||||
|
||||
// Stella Router integration
|
||||
var routerOptions = configuration.GetSection("Excititor:Router").Get<StellaRouterOptionsBase>();
|
||||
services.TryAddStellaRouter(
|
||||
var routerEnabled = services.AddRouterMicroservice(
|
||||
builder.Configuration,
|
||||
serviceName: "excititor",
|
||||
version: typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0",
|
||||
routerOptions: routerOptions);
|
||||
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
|
||||
routerOptionsSection: "Router");
|
||||
|
||||
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
|
||||
|
||||
@@ -207,7 +207,7 @@ app.LogStellaOpsLocalHostname("excititor");
|
||||
|
||||
app.UseStellaOpsCors();
|
||||
// Auth middleware removed -- see service registration comment above.
|
||||
app.TryUseStellaRouter(routerOptions);
|
||||
app.TryUseStellaRouter(routerEnabled);
|
||||
app.UseObservabilityHeaders();
|
||||
|
||||
app.MapGet("/excititor/status", async (HttpContext context,
|
||||
@@ -229,7 +229,7 @@ app.MapHealthChecks("/excititor/health");
|
||||
// OpenAPI discovery (WEB-OAS-61-001)
|
||||
app.MapGet("/.well-known/openapi", () =>
|
||||
{
|
||||
var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0";
|
||||
var version = System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0";
|
||||
|
||||
var payload = new
|
||||
{
|
||||
@@ -246,7 +246,7 @@ app.MapGet("/.well-known/openapi", () =>
|
||||
|
||||
app.MapGet("/openapi/excititor.json", () =>
|
||||
{
|
||||
var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0";
|
||||
var version = System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0";
|
||||
|
||||
var spec = new
|
||||
{
|
||||
@@ -2281,7 +2281,7 @@ LinksetEndpoints.MapLinksetEndpoints(app);
|
||||
RiskFeedEndpoints.MapRiskFeedEndpoints(app);
|
||||
|
||||
// Refresh Router endpoint cache
|
||||
app.TryRefreshStellaRouterEndpoints(routerOptions);
|
||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -2393,3 +2393,6 @@ internal sealed record VexSeveritySignalRequest(string Scheme, double? Score, st
|
||||
{
|
||||
public VexSeveritySignal ToDomain() => new(Scheme, Score, Label, Vector);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -33,4 +33,8 @@
|
||||
<ProjectReference Include="../../Router/__Libraries/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="StellaOpsReleaseVersion">
|
||||
<Version>1.0.0-alpha1</Version>
|
||||
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -107,11 +107,11 @@ builder.Services.AddOpenApi();
|
||||
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
|
||||
|
||||
// Stella Router integration
|
||||
var routerOptions = builder.Configuration.GetSection("ExportCenter:Router").Get<StellaRouterOptionsBase>();
|
||||
builder.Services.TryAddStellaRouter(
|
||||
var routerEnabled = builder.Services.AddRouterMicroservice(
|
||||
builder.Configuration,
|
||||
serviceName: "exportcenter",
|
||||
version: typeof(StellaOps.ExportCenter.WebService.Program).Assembly.GetName().Version?.ToString() ?? "1.0.0",
|
||||
routerOptions: routerOptions);
|
||||
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
|
||||
routerOptionsSection: "Router");
|
||||
|
||||
builder.TryAddStellaOpsLocalBinding("exportcenter");
|
||||
var app = builder.Build();
|
||||
@@ -125,7 +125,7 @@ if (app.Environment.IsDevelopment())
|
||||
app.UseStellaOpsCors();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.TryUseStellaRouter(routerOptions);
|
||||
app.TryUseStellaRouter(routerEnabled);
|
||||
|
||||
// OpenAPI discovery endpoints (anonymous)
|
||||
app.MapOpenApiDiscovery();
|
||||
@@ -177,7 +177,7 @@ app.MapDelete("/exports/{id}", (string id) => Results.NoContent())
|
||||
.WithDescription("This endpoint is deprecated. Use POST /v1/exports/runs/{id}/cancel instead.");
|
||||
|
||||
// Refresh Router endpoint cache
|
||||
app.TryRefreshStellaRouterEndpoints(routerOptions);
|
||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -186,3 +186,6 @@ namespace StellaOps.ExportCenter.WebService
|
||||
{
|
||||
public partial class Program { }
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -27,4 +27,8 @@
|
||||
<ProjectReference Include="..\..\..\Router/__Libraries/StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
|
||||
<ProjectReference Include="..\..\..\Attestor\__Libraries\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="StellaOpsReleaseVersion">
|
||||
<Version>1.0.0-alpha1</Version>
|
||||
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -293,11 +293,11 @@ builder.Services.AddHttpClient("webhook-delivery", client =>
|
||||
});
|
||||
|
||||
// Stella Router integration
|
||||
var routerOptions = builder.Configuration.GetSection("FindingsLedger:Router").Get<StellaRouterOptionsBase>();
|
||||
builder.Services.TryAddStellaRouter(
|
||||
var routerEnabled = builder.Services.AddRouterMicroservice(
|
||||
builder.Configuration,
|
||||
serviceName: "findings-ledger",
|
||||
version: typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0",
|
||||
routerOptions: routerOptions);
|
||||
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
|
||||
routerOptionsSection: "Router");
|
||||
|
||||
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
|
||||
|
||||
@@ -327,7 +327,7 @@ app.UseExceptionHandler(exceptionApp =>
|
||||
app.UseStellaOpsCors();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.TryUseStellaRouter(routerOptions);
|
||||
app.TryUseStellaRouter(routerEnabled);
|
||||
|
||||
app.MapHealthChecks("/healthz");
|
||||
|
||||
@@ -1974,7 +1974,7 @@ app.MapPatch("/api/v1/findings/{findingId}/state", async Task<Results<Ok<StateTr
|
||||
.ProducesProblem(StatusCodes.Status409Conflict);
|
||||
|
||||
// Refresh Router endpoint cache
|
||||
app.TryRefreshStellaRouterEndpoints(routerOptions);
|
||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||
|
||||
// Findings summary, evidence graph, reachability, and runtime timeline endpoints
|
||||
app.MapFindingSummaryEndpoints();
|
||||
@@ -2092,3 +2092,6 @@ namespace StellaOps.Findings.Ledger.WebService
|
||||
/// </summary>
|
||||
public partial class Program { }
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -27,4 +27,8 @@
|
||||
<ProjectReference Include="..\..\Signals\StellaOps.Signals\StellaOps.Signals.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Label="StellaOpsReleaseVersion">
|
||||
<Version>1.0.0-alpha1</Version>
|
||||
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -34,11 +34,16 @@ public sealed class IdentityHeaderPolicyMiddleware
|
||||
"X-StellaOps-Actor",
|
||||
"X-StellaOps-Scopes",
|
||||
"X-StellaOps-Client",
|
||||
"X-StellaOps-Roles",
|
||||
"X-StellaOps-User",
|
||||
// Legacy Stella headers (compatibility)
|
||||
"X-Stella-Tenant",
|
||||
"X-Stella-Project",
|
||||
"X-Stella-Actor",
|
||||
"X-Stella-Scopes",
|
||||
// Bare scope header used by some internal clients — must be stripped
|
||||
// to prevent external clients from spoofing authorization scopes.
|
||||
"X-Scopes",
|
||||
// Raw claim headers (internal/legacy pass-through)
|
||||
"sub",
|
||||
"tid",
|
||||
@@ -269,6 +274,12 @@ public sealed class IdentityHeaderPolicyMiddleware
|
||||
}
|
||||
}
|
||||
|
||||
// User header (derived from subject claim, same as Actor)
|
||||
if (!string.IsNullOrEmpty(identity.Actor))
|
||||
{
|
||||
headers["X-StellaOps-User"] = identity.Actor;
|
||||
}
|
||||
|
||||
// DPoP thumbprint (if present)
|
||||
if (!string.IsNullOrEmpty(identity.DpopThumbprint))
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Gateway.Middleware;
|
||||
|
||||
@@ -10,9 +11,10 @@ public sealed class RequestRoutingMiddleware
|
||||
public RequestRoutingMiddleware(
|
||||
RequestDelegate next,
|
||||
ILogger<RequestRoutingMiddleware> logger,
|
||||
ILogger<TransportDispatchMiddleware> dispatchLogger)
|
||||
ILogger<TransportDispatchMiddleware> dispatchLogger,
|
||||
IHostEnvironment environment)
|
||||
{
|
||||
_dispatchMiddleware = new TransportDispatchMiddleware(next, dispatchLogger);
|
||||
_dispatchMiddleware = new TransportDispatchMiddleware(next, dispatchLogger, environment);
|
||||
}
|
||||
|
||||
public Task InvokeAsync(HttpContext context, ITransportClient transportClient, IGlobalRoutingState routingState)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Security.Dpop;
|
||||
@@ -10,11 +11,11 @@ using StellaOps.Gateway.WebService.Configuration;
|
||||
using StellaOps.Gateway.WebService.Middleware;
|
||||
using StellaOps.Gateway.WebService.Security;
|
||||
using StellaOps.Gateway.WebService.Services;
|
||||
using StellaOps.Messaging.DependencyInjection;
|
||||
using StellaOps.Messaging.Transport.Valkey;
|
||||
using StellaOps.Router.AspNet;
|
||||
using StellaOps.Router.Common.Abstractions;
|
||||
using StellaOps.Router.Common.Models;
|
||||
using StellaOps.Router.Common.Plugins;
|
||||
using StellaOps.Router.Gateway;
|
||||
using StellaOps.Router.Gateway.Configuration;
|
||||
using StellaOps.Router.Gateway.DependencyInjection;
|
||||
@@ -35,6 +36,7 @@ builder.Configuration.AddStellaOpsDefaults(options =>
|
||||
options.BasePath = builder.Environment.ContentRootPath;
|
||||
options.EnvironmentPrefix = "GATEWAY_";
|
||||
});
|
||||
builder.Configuration.AddEnvironmentVariables();
|
||||
|
||||
var bootstrapOptions = builder.Configuration.BindOptions<GatewayOptions>(
|
||||
GatewayOptions.SectionName,
|
||||
@@ -56,14 +58,38 @@ builder.Services.AddSingleton<GatewayServiceStatus>();
|
||||
builder.Services.AddSingleton<GatewayMetrics>();
|
||||
builder.Services.AddSingleton<GatewayPerformanceMetrics>();
|
||||
|
||||
builder.Services.AddTcpTransportServer();
|
||||
builder.Services.AddTlsTransportServer();
|
||||
// Load router transport plugins and activate selected transports from config.
|
||||
var transportPluginLoader = new RouterTransportPluginLoader(
|
||||
NullLoggerFactory.Instance.CreateLogger<RouterTransportPluginLoader>());
|
||||
|
||||
// Messaging transport (Valkey)
|
||||
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()
|
||||
.Where(assembly =>
|
||||
assembly.GetName().Name?.StartsWith("StellaOps.Router.Transport.", StringComparison.OrdinalIgnoreCase) == true))
|
||||
{
|
||||
transportPluginLoader.LoadFromAssembly(assembly);
|
||||
}
|
||||
|
||||
var pluginsPath = builder.Configuration["Gateway:TransportPlugins:Directory"];
|
||||
if (string.IsNullOrWhiteSpace(pluginsPath))
|
||||
{
|
||||
pluginsPath = Path.Combine(AppContext.BaseDirectory, "plugins", "router", "transports");
|
||||
}
|
||||
|
||||
var transportSearchPattern = builder.Configuration["Gateway:TransportPlugins:SearchPattern"];
|
||||
if (string.IsNullOrWhiteSpace(transportSearchPattern))
|
||||
{
|
||||
transportSearchPattern = "StellaOps.Router.Transport.*.dll";
|
||||
}
|
||||
|
||||
transportPluginLoader.LoadFromDirectory(AppContext.BaseDirectory, transportSearchPattern);
|
||||
transportPluginLoader.LoadFromDirectory(pluginsPath, transportSearchPattern);
|
||||
|
||||
// Keep TCP/TLS transport registrations available (startup options still decide whether to start listeners).
|
||||
RegisterGatewayTransport("tcp", "Gateway:Transports:Tcp");
|
||||
RegisterGatewayTransport("tls", "Gateway:Transports:Tls");
|
||||
if (bootstrapOptions.Transports.Messaging.Enabled)
|
||||
{
|
||||
builder.Services.AddMessagingTransport<ValkeyTransportPlugin>(builder.Configuration, "Gateway:Transports:Messaging");
|
||||
builder.Services.AddMessagingTransportServer();
|
||||
RegisterGatewayTransport("messaging", "Gateway:Transports:Messaging");
|
||||
}
|
||||
|
||||
builder.Services.AddSingleton<GatewayTransportClient>();
|
||||
@@ -89,11 +115,11 @@ ConfigureAuthentication(builder, bootstrapOptions);
|
||||
ConfigureGatewayOptionsMapping(builder, bootstrapOptions);
|
||||
|
||||
// Stella Router integration
|
||||
var routerOptions = builder.Configuration.GetSection("Gateway:Router").Get<StellaRouterOptionsBase>();
|
||||
builder.Services.TryAddStellaRouter(
|
||||
var routerEnabled = builder.Services.AddRouterMicroservice(
|
||||
builder.Configuration,
|
||||
serviceName: "gateway",
|
||||
version: typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0",
|
||||
routerOptions: routerOptions);
|
||||
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
|
||||
routerOptionsSection: "Router");
|
||||
|
||||
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
|
||||
|
||||
@@ -109,7 +135,7 @@ app.UseMiddleware<SenderConstraintMiddleware>();
|
||||
// It strips reserved identity headers and overwrites them from validated claims (security fix)
|
||||
app.UseMiddleware<IdentityHeaderPolicyMiddleware>();
|
||||
app.UseMiddleware<HealthCheckMiddleware>();
|
||||
app.TryUseStellaRouter(routerOptions);
|
||||
app.TryUseStellaRouter(routerEnabled);
|
||||
|
||||
if (bootstrapOptions.OpenApi.Enabled)
|
||||
{
|
||||
@@ -132,10 +158,29 @@ app.UseWhen(
|
||||
});
|
||||
|
||||
// Refresh Router endpoint cache
|
||||
app.TryRefreshStellaRouterEndpoints(routerOptions);
|
||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||
|
||||
await app.RunAsync();
|
||||
|
||||
void RegisterGatewayTransport(string transportName, string configurationSection)
|
||||
{
|
||||
var plugin = transportPluginLoader.GetPlugin(transportName);
|
||||
if (plugin is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Gateway transport plugin '{transportName}' is not available. " +
|
||||
$"Provide a plugin assembly in '{pluginsPath}' or add the transport plugin dependency.");
|
||||
}
|
||||
|
||||
plugin.Register(new RouterTransportRegistrationContext(
|
||||
builder.Services,
|
||||
builder.Configuration,
|
||||
RouterTransportMode.Server)
|
||||
{
|
||||
ConfigurationSection = configurationSection
|
||||
});
|
||||
}
|
||||
|
||||
static void ConfigureAuthentication(WebApplicationBuilder builder, GatewayOptions options)
|
||||
{
|
||||
var authOptions = options.Auth;
|
||||
@@ -287,3 +332,6 @@ static void ConfigureGatewayOptionsMapping(WebApplicationBuilder builder, Gatewa
|
||||
options.Database = messaging.Database;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -18,4 +18,8 @@
|
||||
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="..\..\Router/__Libraries/StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="StellaOpsReleaseVersion">
|
||||
<Version>1.0.0-alpha1</Version>
|
||||
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -119,6 +119,75 @@ public sealed class IdentityHeaderPolicyMiddlewareTests
|
||||
Assert.DoesNotContain("cnf.jkt", context.Request.Headers.Keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_StripsSpoofedRolesHeader()
|
||||
{
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext("/api/scan");
|
||||
|
||||
// Client attempts to spoof roles header to bypass authorization
|
||||
context.Request.Headers["X-StellaOps-Roles"] = "chat:admin";
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
Assert.True(_nextCalled);
|
||||
// Roles header must be stripped to prevent authorization bypass
|
||||
Assert.DoesNotContain("X-StellaOps-Roles", context.Request.Headers.Keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_StripsSpoofedUserHeader()
|
||||
{
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext("/api/scan");
|
||||
|
||||
// Client attempts to spoof user identity
|
||||
context.Request.Headers["X-StellaOps-User"] = "victim-admin";
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
Assert.True(_nextCalled);
|
||||
// User header must be stripped to prevent identity spoofing
|
||||
Assert.DoesNotContain("victim-admin",
|
||||
context.Request.Headers.TryGetValue("X-StellaOps-User", out var val) ? val.ToString() : "");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WritesUserHeaderFromValidatedClaims()
|
||||
{
|
||||
var middleware = CreateMiddleware();
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(StellaOpsClaimTypes.Subject, "real-user-123")
|
||||
};
|
||||
var context = CreateHttpContext("/api/scan", claims);
|
||||
|
||||
// Client attempts to spoof user identity
|
||||
context.Request.Headers["X-StellaOps-User"] = "spoofed-user";
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
Assert.True(_nextCalled);
|
||||
// User header should contain the validated claim value, not the spoofed value
|
||||
Assert.Equal("real-user-123", context.Request.Headers["X-StellaOps-User"].ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_StripsBareXScopesHeader()
|
||||
{
|
||||
var middleware = CreateMiddleware();
|
||||
var context = CreateHttpContext("/api/scan");
|
||||
|
||||
// Client attempts to spoof authorization via bare X-Scopes header
|
||||
context.Request.Headers["X-Scopes"] = "advisory:adapter:invoke advisory:run";
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
Assert.True(_nextCalled);
|
||||
// X-Scopes must be stripped to prevent authorization bypass
|
||||
Assert.DoesNotContain("X-Scopes", context.Request.Headers.Keys);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Header Overwriting (Not Set-If-Missing)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Graph.Api.Contracts;
|
||||
using StellaOps.Graph.Api.Services;
|
||||
using StellaOps.Router.AspNet;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -19,12 +20,21 @@ builder.Services.AddSingleton<IGraphMetrics, GraphMetrics>();
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddScoped<IEdgeMetadataService, InMemoryEdgeMetadataService>();
|
||||
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
|
||||
|
||||
// Stella Router integration
|
||||
var routerEnabled = builder.Services.AddRouterMicroservice(
|
||||
builder.Configuration,
|
||||
serviceName: "graph",
|
||||
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
|
||||
routerOptionsSection: "Router");
|
||||
|
||||
builder.TryAddStellaOpsLocalBinding("graph");
|
||||
var app = builder.Build();
|
||||
app.LogStellaOpsLocalHostname("graph");
|
||||
|
||||
app.UseStellaOpsCors();
|
||||
app.UseRouting();
|
||||
app.TryUseStellaRouter(routerEnabled);
|
||||
|
||||
app.MapPost("/graph/search", async (HttpContext context, GraphSearchRequest request, IGraphSearchService service, CancellationToken ct) =>
|
||||
{
|
||||
@@ -400,9 +410,9 @@ app.MapGet("/graph/export/{jobId}", async (string jobId, HttpContext context, IG
|
||||
return Results.File(job.Payload, job.ContentType, $"graph-export-{job.JobId}.{job.Format}");
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────────
|
||||
// ────────────────────────────────────────────────────────────────────────────────
|
||||
// Edge Metadata API
|
||||
// ────────────────────────────────────────────────────────────────────────────────
|
||||
// ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
app.MapPost("/graph/edges/metadata", async (EdgeMetadataRequest request, HttpContext context, IEdgeMetadataService service, CancellationToken ct) =>
|
||||
{
|
||||
@@ -608,6 +618,7 @@ app.MapGet("/graph/edges/by-evidence", async (string evidenceType, string eviden
|
||||
|
||||
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
|
||||
|
||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||
app.Run();
|
||||
|
||||
static async Task WriteError(HttpContext ctx, int status, string code, string message, CancellationToken ct)
|
||||
|
||||
@@ -15,4 +15,8 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="StellaOpsReleaseVersion">
|
||||
<Version>1.0.0-alpha1</Version>
|
||||
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -8,6 +8,7 @@ using StellaOps.Integrations.WebService;
|
||||
using StellaOps.Integrations.WebService.AiCodeGuard;
|
||||
using StellaOps.Integrations.WebService.Infrastructure;
|
||||
|
||||
using StellaOps.Router.AspNet;
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services
|
||||
@@ -69,6 +70,12 @@ builder.Services.AddScoped<IAiCodeGuardRunService, AiCodeGuardRunService>();
|
||||
|
||||
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
|
||||
|
||||
// Stella Router integration
|
||||
var routerEnabled = builder.Services.AddRouterMicroservice(
|
||||
builder.Configuration,
|
||||
serviceName: "integrations",
|
||||
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
|
||||
routerOptionsSection: "Router");
|
||||
builder.TryAddStellaOpsLocalBinding("integrations");
|
||||
var app = builder.Build();
|
||||
app.LogStellaOpsLocalHostname("integrations");
|
||||
@@ -81,6 +88,7 @@ if (app.Environment.IsDevelopment())
|
||||
}
|
||||
|
||||
app.UseStellaOpsCors();
|
||||
app.TryUseStellaRouter(routerEnabled);
|
||||
|
||||
// Map endpoints
|
||||
app.MapIntegrationEndpoints();
|
||||
@@ -98,6 +106,8 @@ if (app.Environment.IsDevelopment())
|
||||
await dbContext.Database.EnsureCreatedAsync();
|
||||
}
|
||||
|
||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||
app.Run();
|
||||
|
||||
public partial class Program { }
|
||||
|
||||
|
||||
@@ -26,5 +26,8 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Label="StellaOpsReleaseVersion">
|
||||
<Version>1.0.0-alpha1</Version>
|
||||
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -103,11 +103,11 @@ builder.Services.AddOpenTelemetry()
|
||||
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
|
||||
|
||||
// Stella Router integration
|
||||
var routerOptions = builder.Configuration.GetSection("IssuerDirectory:Router").Get<StellaRouterOptionsBase>();
|
||||
builder.Services.TryAddStellaRouter(
|
||||
var routerEnabled = builder.Services.AddRouterMicroservice(
|
||||
builder.Configuration,
|
||||
serviceName: "issuerdirectory",
|
||||
version: typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0",
|
||||
routerOptions: routerOptions);
|
||||
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
|
||||
routerOptionsSection: "Router");
|
||||
|
||||
builder.TryAddStellaOpsLocalBinding("issuerdirectory");
|
||||
var app = builder.Build();
|
||||
@@ -117,12 +117,12 @@ app.UseSerilogRequestLogging();
|
||||
app.UseStellaOpsCors();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.TryUseStellaRouter(routerOptions);
|
||||
app.TryUseStellaRouter(routerEnabled);
|
||||
|
||||
var issuerGroup = app.MapIssuerEndpoints();
|
||||
|
||||
// Refresh Router endpoint cache
|
||||
app.TryRefreshStellaRouterEndpoints(routerOptions);
|
||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||
|
||||
var seedingTask = SeedPublishersAsync(app.Services, app.Environment);
|
||||
await seedingTask.ConfigureAwait(false);
|
||||
@@ -257,3 +257,6 @@ internal sealed class AllowAnonymousAuthenticationHandler : AuthenticationHandle
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -29,4 +29,8 @@
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="StellaOpsReleaseVersion">
|
||||
<Version>1.0.0-alpha1</Version>
|
||||
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -128,11 +128,11 @@ builder.Services.AddHealthChecks();
|
||||
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
|
||||
|
||||
// Stella Router integration
|
||||
var routerOptions = builder.Configuration.GetSection("Notifier:Router").Get<StellaRouterOptionsBase>();
|
||||
builder.Services.TryAddStellaRouter(
|
||||
var routerEnabled = builder.Services.AddRouterMicroservice(
|
||||
builder.Configuration,
|
||||
serviceName: "notifier",
|
||||
version: typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0",
|
||||
routerOptions: routerOptions);
|
||||
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
|
||||
routerOptionsSection: "Router");
|
||||
|
||||
builder.TryAddStellaOpsLocalBinding("notifier");
|
||||
var app = builder.Build();
|
||||
@@ -164,7 +164,7 @@ app.Use(async (context, next) =>
|
||||
|
||||
// Tenant context middleware (extracts and validates tenant from headers/query)
|
||||
app.UseTenantContext();
|
||||
app.TryUseStellaRouter(routerOptions);
|
||||
app.TryUseStellaRouter(routerEnabled);
|
||||
|
||||
app.MapPost("/api/v1/notify/pack-approvals", async (
|
||||
HttpContext context,
|
||||
@@ -3235,9 +3235,12 @@ static object Error(string code, string message, HttpContext context) => new
|
||||
};
|
||||
|
||||
// Refresh Router endpoint cache
|
||||
app.TryRefreshStellaRouterEndpoints(routerOptions);
|
||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||
|
||||
app.Run();
|
||||
|
||||
// Make Program class accessible to test projects using WebApplicationFactory
|
||||
public partial class Program;
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -17,4 +17,8 @@
|
||||
<ProjectReference Include="../../../Router/__Libraries/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
|
||||
<ProjectReference Include="../../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="StellaOpsReleaseVersion">
|
||||
<Version>1.0.0-alpha1</Version>
|
||||
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -105,11 +105,11 @@ ConfigureRateLimiting(builder, bootstrapOptions);
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
|
||||
// Stella Router integration
|
||||
var routerOptions = builder.Configuration.GetSection("Notify:Router").Get<StellaRouterOptionsBase>();
|
||||
builder.Services.TryAddStellaRouter(
|
||||
var routerEnabled = builder.Services.AddRouterMicroservice(
|
||||
builder.Configuration,
|
||||
serviceName: "notify",
|
||||
version: typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0",
|
||||
routerOptions: routerOptions);
|
||||
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
|
||||
routerOptionsSection: "Router");
|
||||
|
||||
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
|
||||
|
||||
@@ -122,12 +122,11 @@ var readyStatus = app.Services.GetRequiredService<ServiceStatus>();
|
||||
var resolvedOptions = app.Services.GetRequiredService<IOptions<NotifyWebServiceOptions>>().Value;
|
||||
await InitialiseAsync(app.Services, readyStatus, app.Logger, resolvedOptions);
|
||||
|
||||
ConfigureRequestPipeline(app, bootstrapOptions);
|
||||
ConfigureRequestPipeline(app, bootstrapOptions, routerEnabled);
|
||||
ConfigureEndpoints(app);
|
||||
|
||||
// Refresh Router endpoint cache
|
||||
var notifyRouterOptions = app.Configuration.GetSection("Notify:Router").Get<StellaRouterOptionsBase>();
|
||||
app.TryRefreshStellaRouterEndpoints(notifyRouterOptions);
|
||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||
|
||||
await app.RunAsync();
|
||||
|
||||
@@ -338,7 +337,7 @@ static async Task InitialiseAsync(IServiceProvider services, ServiceStatus statu
|
||||
}
|
||||
}
|
||||
|
||||
static void ConfigureRequestPipeline(WebApplication app, NotifyWebServiceOptions options)
|
||||
static void ConfigureRequestPipeline(WebApplication app, NotifyWebServiceOptions options, bool routerEnabled)
|
||||
{
|
||||
if (options.Telemetry.EnableRequestLogging)
|
||||
{
|
||||
@@ -355,8 +354,7 @@ static void ConfigureRequestPipeline(WebApplication app, NotifyWebServiceOptions
|
||||
app.UseAuthorization();
|
||||
|
||||
// Stella Router integration
|
||||
var routerOptions = app.Configuration.GetSection("Notify:Router").Get<StellaRouterOptionsBase>();
|
||||
app.TryUseStellaRouter(routerOptions);
|
||||
app.TryUseStellaRouter(routerEnabled);
|
||||
}
|
||||
|
||||
static void ConfigureEndpoints(WebApplication app)
|
||||
@@ -1506,3 +1504,6 @@ static LogEventLevel MapLogLevel(string configuredLevel)
|
||||
_ => LogEventLevel.Information
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user