419 lines
14 KiB
C#
419 lines
14 KiB
C#
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Routing;
|
|
using StellaOps.AdvisoryAI.KnowledgeSearch;
|
|
using StellaOps.AdvisoryAI.WebService.Security;
|
|
using StellaOps.Auth.ServerIntegration.Tenancy;
|
|
using System.Linq;
|
|
using static StellaOps.Localization.T;
|
|
|
|
namespace StellaOps.AdvisoryAI.WebService.Endpoints;
|
|
|
|
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")
|
|
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy)
|
|
.RequireTenant();
|
|
|
|
group.MapPost("/search", SearchAsync)
|
|
.WithName("AdvisoryAiKnowledgeSearch")
|
|
.WithSummary("Searches AdvisoryAI deterministic knowledge index (docs/api/doctor).")
|
|
.WithDescription("Performs a hybrid full-text and vector similarity search over the AdvisoryAI deterministic knowledge index, which is composed of product documentation, OpenAPI specs, and Doctor health check projections. Supports filtering by content type (docs, api, doctor), product, version, service, and tags. Returns ranked result snippets with actionable open-actions for UI navigation.")
|
|
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
|
.RequireRateLimiting("advisory-ai")
|
|
.Produces<AdvisoryKnowledgeSearchResponse>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status400BadRequest)
|
|
.Produces(StatusCodes.Status403Forbidden);
|
|
|
|
group.MapPost("/index/rebuild", RebuildIndexAsync)
|
|
.WithName("AdvisoryAiKnowledgeIndexRebuild")
|
|
.WithSummary("Rebuilds AdvisoryAI knowledge search index from deterministic local sources.")
|
|
.WithDescription("Triggers a full rebuild of the knowledge search index from local deterministic sources: product documentation files, embedded OpenAPI specs, and Doctor health check metadata. The rebuild is synchronous and returns document, chunk, and operation counts with duration. Requires admin-level scope; does not fetch external content.")
|
|
.RequireAuthorization(AdvisoryAIPolicies.AdminPolicy)
|
|
.RequireRateLimiting("advisory-ai")
|
|
.Produces<AdvisoryKnowledgeRebuildResponse>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status403Forbidden);
|
|
|
|
return group;
|
|
}
|
|
|
|
private static async Task<IResult> SearchAsync(
|
|
HttpContext httpContext,
|
|
AdvisoryKnowledgeSearchRequest request,
|
|
IKnowledgeSearchService searchService,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (request is null || string.IsNullOrWhiteSpace(request.Q))
|
|
{
|
|
return Results.BadRequest(new { error = _t("advisoryai.validation.q_required") });
|
|
}
|
|
|
|
if (request.Q.Length > 512)
|
|
{
|
|
return Results.BadRequest(new { error = _t("advisoryai.validation.q_max_512") });
|
|
}
|
|
|
|
var tenant = ResolveTenant(httpContext);
|
|
if (tenant is null)
|
|
{
|
|
return Results.BadRequest(new { error = _t("advisoryai.validation.tenant_required") });
|
|
}
|
|
|
|
KnowledgeSearchFilter? normalizedFilter;
|
|
try
|
|
{
|
|
normalizedFilter = NormalizeFilter(request.Filters, tenant);
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
return Results.BadRequest(new { error = ex.Message });
|
|
}
|
|
|
|
var domainRequest = new KnowledgeSearchRequest(
|
|
request.Q.Trim(),
|
|
request.K,
|
|
normalizedFilter,
|
|
request.IncludeDebug);
|
|
|
|
var response = await searchService.SearchAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
|
ApplyLegacyKnowledgeSearchDeprecationHeaders(httpContext.Response.Headers);
|
|
return Results.Ok(MapResponse(response));
|
|
}
|
|
|
|
private static async Task<IResult> RebuildIndexAsync(
|
|
HttpContext httpContext,
|
|
IKnowledgeIndexer indexer,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (ResolveTenant(httpContext) is null)
|
|
{
|
|
return Results.BadRequest(new { error = _t("advisoryai.validation.tenant_required") });
|
|
}
|
|
|
|
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, string tenant)
|
|
{
|
|
if (filter is null)
|
|
{
|
|
return new KnowledgeSearchFilter
|
|
{
|
|
Tenant = tenant
|
|
};
|
|
}
|
|
|
|
string[]? normalizedKinds = null;
|
|
if (filter.Type is { Count: > 0 })
|
|
{
|
|
var kinds = new HashSet<string>(StringComparer.Ordinal);
|
|
foreach (var item in filter.Type)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(item))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var normalized = item.Trim().ToLowerInvariant();
|
|
if (!AllowedKinds.Contains(normalized))
|
|
{
|
|
throw new ArgumentException(
|
|
_t("advisoryai.validation.filter_type_unsupported", normalized),
|
|
nameof(filter));
|
|
}
|
|
|
|
kinds.Add(normalized);
|
|
}
|
|
|
|
normalizedKinds = kinds
|
|
.OrderBy(static value => value, StringComparer.Ordinal)
|
|
.ToArray();
|
|
}
|
|
|
|
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,
|
|
Tenant = tenant
|
|
};
|
|
}
|
|
|
|
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,
|
|
ActiveEncoder = response.Diagnostics.ActiveEncoder
|
|
}
|
|
};
|
|
}
|
|
|
|
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,
|
|
Control = result.Open.Doctor.Control,
|
|
RequiresConfirmation = result.Open.Doctor.RequiresConfirmation,
|
|
IsDestructive = result.Open.Doctor.IsDestructive
|
|
}
|
|
};
|
|
|
|
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 string? ResolveTenant(HttpContext context)
|
|
{
|
|
foreach (var value in context.Request.Headers["X-StellaOps-Tenant"])
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return value.Trim();
|
|
}
|
|
}
|
|
|
|
foreach (var value in context.Request.Headers["X-Tenant-Id"])
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return value.Trim();
|
|
}
|
|
}
|
|
|
|
var claimTenant = context.User?.FindFirst("tenant_id")?.Value;
|
|
return string.IsNullOrWhiteSpace(claimTenant) ? null : claimTenant.Trim();
|
|
}
|
|
|
|
private static void ApplyLegacyKnowledgeSearchDeprecationHeaders(IHeaderDictionary headers)
|
|
{
|
|
headers["Deprecation"] = "true";
|
|
headers["Sunset"] = "2026-04-30T00:00:00Z";
|
|
headers["Link"] = "</v1/search/query>; rel=\"successor-version\"";
|
|
headers["Warning"] = "299 - AdvisoryAI legacy knowledge search is deprecated; migrate to /v1/search/query";
|
|
}
|
|
}
|
|
|
|
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 string Control { get; init; } = "safe";
|
|
|
|
public bool RequiresConfirmation { get; init; }
|
|
|
|
public bool IsDestructive { get; init; }
|
|
}
|
|
|
|
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";
|
|
|
|
/// <summary>
|
|
/// Reports which vector encoder implementation is active: "hash" (deterministic SHA-256),
|
|
/// "onnx" (semantic ONNX inference), or "onnx-fallback" (configured for ONNX but fell back to hash).
|
|
/// </summary>
|
|
public string ActiveEncoder { get; init; } = "hash";
|
|
}
|
|
|
|
public sealed record AdvisoryKnowledgeRebuildResponse
|
|
{
|
|
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; }
|
|
}
|