Search/AdvisoryAI and DAL conversion to EF finishes up. Preparation for microservices consolidation.
This commit is contained in:
@@ -34,6 +34,11 @@
|
||||
### Search sprint test infrastructure (G1–G10)
|
||||
**Infrastructure setup guide**: `src/AdvisoryAI/__Tests/INFRASTRUCTURE.md` — covers what each test tier needs and exact Docker/config steps.
|
||||
Full feature documentation: `docs/modules/advisory-ai/knowledge-search.md` → "Search improvement sprints (G1–G10) — testing infrastructure guide".
|
||||
Unified-search architecture and operations docs:
|
||||
- `docs/modules/advisory-ai/unified-search-architecture.md`
|
||||
- `docs/operations/unified-search-operations.md`
|
||||
- `docs/modules/advisory-ai/unified-search-ranking-benchmark.md`
|
||||
- `docs/modules/advisory-ai/unified-search-release-readiness.md`
|
||||
|
||||
**Quick-start (no Docker required):**
|
||||
```bash
|
||||
@@ -66,6 +71,9 @@ dotnet test src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.Advisory
|
||||
|
||||
**Key test files:**
|
||||
- `Integration/UnifiedSearchSprintIntegrationTests.cs` — 87 integration tests covering all 10 sprints
|
||||
- `UnifiedSearch/UnifiedSearchQualityBenchmarkFastSubsetTests.cs` — 50-query PR benchmark gate
|
||||
- `UnifiedSearch/UnifiedSearchQualityBenchmarkTests.cs` — full quality benchmark + deterministic tuning grid search
|
||||
- `UnifiedSearch/UnifiedSearchPerformanceEnvelopeTests.cs` — latency/capacity envelope assertions
|
||||
- `KnowledgeSearch/FtsRecallBenchmarkTests.cs` + `FtsRecallBenchmarkStore.cs` — FTS recall benchmark
|
||||
- `KnowledgeSearch/SemanticRecallBenchmarkTests.cs` + `SemanticRecallBenchmarkStore.cs` — Semantic recall benchmark
|
||||
- `TestData/fts-recall-benchmark.json` — 34-query FTS fixture
|
||||
|
||||
@@ -16,7 +16,8 @@ public static class SearchAnalyticsEndpoints
|
||||
{
|
||||
"query",
|
||||
"click",
|
||||
"zero_result"
|
||||
"zero_result",
|
||||
"synthesis"
|
||||
};
|
||||
|
||||
public static RouteGroupBuilder MapSearchAnalyticsEndpoints(this IEndpointRouteBuilder builder)
|
||||
@@ -29,10 +30,10 @@ public static class SearchAnalyticsEndpoints
|
||||
|
||||
group.MapPost("/analytics", RecordAnalyticsAsync)
|
||||
.WithName("SearchAnalyticsRecord")
|
||||
.WithSummary("Records batch search analytics events (query, click, zero_result).")
|
||||
.WithSummary("Records batch search analytics events (query, click, zero_result, synthesis).")
|
||||
.WithDescription(
|
||||
"Accepts a batch of search analytics events for tracking query frequency, click-through rates, " +
|
||||
"and zero-result queries. Events are tenant-scoped and user ID is optional for privacy. " +
|
||||
"zero-result queries, and synthesis usage. Queries and user identifiers are pseudonymized before persistence. " +
|
||||
"Fire-and-forget from the client; failures do not affect search functionality.")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
|
||||
@@ -29,7 +29,7 @@ public static class SearchFeedbackEndpoints
|
||||
.WithSummary("Submits user feedback (helpful/not_helpful) for a search result or synthesis.")
|
||||
.WithDescription(
|
||||
"Records a thumbs-up or thumbs-down signal for a specific search result, " +
|
||||
"identified by entity key and domain. Used to improve search quality over time. " +
|
||||
"identified by entity key and domain. Query/user dimensions are pseudonymized for analytics privacy. " +
|
||||
"Fire-and-forget from the UI perspective.")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy)
|
||||
.Produces(StatusCodes.Status201Created)
|
||||
@@ -199,6 +199,34 @@ public static class SearchFeedbackEndpoints
|
||||
AvgResultCount = metrics.AvgResultCount,
|
||||
FeedbackScore = metrics.FeedbackScore,
|
||||
Period = metrics.Period,
|
||||
LowQualityResults = metrics.LowQualityResults
|
||||
.Select(row => new SearchLowQualityResultDto
|
||||
{
|
||||
EntityKey = row.EntityKey,
|
||||
Domain = row.Domain,
|
||||
NegativeFeedbackCount = row.NegativeFeedbackCount,
|
||||
TotalFeedback = row.TotalFeedback,
|
||||
NegativeRate = row.NegativeRate,
|
||||
})
|
||||
.ToArray(),
|
||||
TopQueries = metrics.TopQueries
|
||||
.Select(row => new SearchTopQueryDto
|
||||
{
|
||||
Query = row.Query,
|
||||
TotalSearches = row.TotalSearches,
|
||||
AvgResultCount = row.AvgResultCount,
|
||||
FeedbackScore = row.FeedbackScore,
|
||||
})
|
||||
.ToArray(),
|
||||
Trend = metrics.Trend
|
||||
.Select(point => new SearchQualityTrendPointDto
|
||||
{
|
||||
Day = point.Day.ToString("yyyy-MM-dd"),
|
||||
TotalSearches = point.TotalSearches,
|
||||
ZeroResultRate = point.ZeroResultRate,
|
||||
FeedbackScore = point.FeedbackScore,
|
||||
})
|
||||
.ToArray(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -281,4 +309,32 @@ public sealed record SearchQualityMetricsDto
|
||||
public double AvgResultCount { get; init; }
|
||||
public double FeedbackScore { get; init; }
|
||||
public string Period { get; init; } = "7d";
|
||||
public IReadOnlyList<SearchLowQualityResultDto> LowQualityResults { get; init; } = [];
|
||||
public IReadOnlyList<SearchTopQueryDto> TopQueries { get; init; } = [];
|
||||
public IReadOnlyList<SearchQualityTrendPointDto> Trend { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed record SearchLowQualityResultDto
|
||||
{
|
||||
public string EntityKey { get; init; } = string.Empty;
|
||||
public string Domain { get; init; } = string.Empty;
|
||||
public int NegativeFeedbackCount { get; init; }
|
||||
public int TotalFeedback { get; init; }
|
||||
public double NegativeRate { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SearchTopQueryDto
|
||||
{
|
||||
public string Query { get; init; } = string.Empty;
|
||||
public int TotalSearches { get; init; }
|
||||
public double AvgResultCount { get; init; }
|
||||
public double FeedbackScore { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SearchQualityTrendPointDto
|
||||
{
|
||||
public string Day { get; init; } = string.Empty;
|
||||
public int TotalSearches { get; init; }
|
||||
public double ZeroResultRate { get; init; }
|
||||
public double FeedbackScore { get; init; }
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.Synthesis;
|
||||
using StellaOps.AdvisoryAI.WebService.Security;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using static StellaOps.Localization.T;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.WebService.Endpoints;
|
||||
@@ -17,7 +19,11 @@ public static class UnifiedSearchEndpoints
|
||||
"findings",
|
||||
"vex",
|
||||
"policy",
|
||||
"platform"
|
||||
"platform",
|
||||
"graph",
|
||||
"timeline",
|
||||
"scanner",
|
||||
"opsmemory"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> AllowedEntityTypes = new(StringComparer.Ordinal)
|
||||
@@ -28,7 +34,13 @@ public static class UnifiedSearchEndpoints
|
||||
"finding",
|
||||
"vex_statement",
|
||||
"policy_rule",
|
||||
"platform_entity"
|
||||
"platform_entity",
|
||||
"package",
|
||||
"image",
|
||||
"registry",
|
||||
"event",
|
||||
"scan",
|
||||
"graph_node"
|
||||
};
|
||||
|
||||
public static RouteGroupBuilder MapUnifiedSearchEndpoints(this IEndpointRouteBuilder builder)
|
||||
@@ -51,6 +63,17 @@ public static class UnifiedSearchEndpoints
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status403Forbidden);
|
||||
|
||||
group.MapPost("/synthesize", SynthesizeAsync)
|
||||
.WithName("UnifiedSearchSynthesize")
|
||||
.WithSummary("Streams deterministic-first search synthesis as SSE.")
|
||||
.WithDescription(
|
||||
"Produces deterministic synthesis first, then optional LLM synthesis chunks, grounding score, and actions. " +
|
||||
"Requires search synthesis scope and tenant context.")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
||||
.Produces(StatusCodes.Status200OK, contentType: "text/event-stream")
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status403Forbidden);
|
||||
|
||||
group.MapPost("/index/rebuild", RebuildIndexAsync)
|
||||
.WithName("UnifiedSearchRebuild")
|
||||
.WithSummary("Rebuilds unified search index from configured ingestion sources.")
|
||||
@@ -90,12 +113,14 @@ public static class UnifiedSearchEndpoints
|
||||
try
|
||||
{
|
||||
var userScopes = ResolveUserScopes(httpContext);
|
||||
var userId = ResolveUserId(httpContext);
|
||||
var domainRequest = new UnifiedSearchRequest(
|
||||
request.Q.Trim(),
|
||||
request.K,
|
||||
NormalizeFilter(request.Filters, tenant, userScopes),
|
||||
NormalizeFilter(request.Filters, tenant, userScopes, userId),
|
||||
request.IncludeSynthesis,
|
||||
request.IncludeDebug);
|
||||
request.IncludeDebug,
|
||||
NormalizeAmbient(request.Ambient));
|
||||
|
||||
var response = await searchService.SearchAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(MapResponse(response));
|
||||
@@ -106,6 +131,158 @@ public static class UnifiedSearchEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SynthesizeAsync(
|
||||
HttpContext httpContext,
|
||||
UnifiedSearchSynthesizeApiRequest request,
|
||||
SearchSynthesisService synthesisService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request is null || string.IsNullOrWhiteSpace(request.Q))
|
||||
{
|
||||
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
await httpContext.Response.WriteAsJsonAsync(new { error = _t("advisoryai.validation.q_required") }, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var tenant = ResolveTenant(httpContext);
|
||||
if (tenant is null)
|
||||
{
|
||||
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
await httpContext.Response.WriteAsJsonAsync(new { error = _t("advisoryai.validation.tenant_required") }, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!HasSynthesisScope(httpContext))
|
||||
{
|
||||
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
await httpContext.Response.WriteAsJsonAsync(new { error = "Missing required scope: search:synthesize" }, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var cards = MapSynthesisCards(request.TopCards);
|
||||
if (cards.Count == 0)
|
||||
{
|
||||
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
await httpContext.Response.WriteAsJsonAsync(new { error = "topCards is required" }, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var userId = ResolveUserId(httpContext) ?? "anonymous";
|
||||
var domainRequest = new SearchSynthesisRequest(
|
||||
request.Q.Trim(),
|
||||
cards,
|
||||
request.Plan,
|
||||
request.Preferences is null
|
||||
? null
|
||||
: new SearchSynthesisPreferences
|
||||
{
|
||||
Depth = request.Preferences.Depth,
|
||||
MaxTokens = request.Preferences.MaxTokens,
|
||||
IncludeActions = request.Preferences.IncludeActions,
|
||||
Locale = request.Preferences.Locale
|
||||
});
|
||||
|
||||
httpContext.Response.StatusCode = StatusCodes.Status200OK;
|
||||
httpContext.Response.ContentType = "text/event-stream";
|
||||
httpContext.Response.Headers.CacheControl = "no-cache";
|
||||
httpContext.Response.Headers.Connection = "keep-alive";
|
||||
await httpContext.Response.Body.FlushAsync(cancellationToken);
|
||||
|
||||
var started = DateTimeOffset.UtcNow;
|
||||
try
|
||||
{
|
||||
var result = await synthesisService.ExecuteAsync(
|
||||
tenant,
|
||||
userId,
|
||||
domainRequest,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await WriteSseEventAsync(httpContext, "synthesis_start", new
|
||||
{
|
||||
tier = "deterministic",
|
||||
summary = result.DeterministicSummary
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.QuotaExceeded)
|
||||
{
|
||||
await WriteSseEventAsync(httpContext, "llm_status", new { status = "quota_exceeded" }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else if (result.LlmUnavailable || string.IsNullOrWhiteSpace(result.LlmSummary))
|
||||
{
|
||||
await WriteSseEventAsync(httpContext, "llm_status", new { status = "unavailable" }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await WriteSseEventAsync(httpContext, "llm_status", new { status = "starting" }, cancellationToken).ConfigureAwait(false);
|
||||
await WriteSseEventAsync(httpContext, "llm_status", new { status = "streaming" }, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var chunk in Chunk(result.LlmSummary, 240))
|
||||
{
|
||||
await WriteSseEventAsync(httpContext, "llm_chunk", new
|
||||
{
|
||||
content = chunk,
|
||||
isComplete = false
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await WriteSseEventAsync(httpContext, "llm_chunk", new
|
||||
{
|
||||
content = string.Empty,
|
||||
isComplete = true
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await WriteSseEventAsync(httpContext, "llm_status", new { status = "validating" }, cancellationToken).ConfigureAwait(false);
|
||||
await WriteSseEventAsync(httpContext, "grounding", new
|
||||
{
|
||||
score = result.GroundingScore,
|
||||
citations = 0,
|
||||
ungrounded = 0,
|
||||
issues = Array.Empty<string>()
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
await WriteSseEventAsync(httpContext, "llm_status", new { status = "complete" }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (result.Actions.Count > 0)
|
||||
{
|
||||
await WriteSseEventAsync(httpContext, "actions", new
|
||||
{
|
||||
actions = result.Actions.Select(static action => new
|
||||
{
|
||||
label = action.Label,
|
||||
route = action.Route,
|
||||
sourceEntityKey = action.SourceEntityKey
|
||||
})
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var durationMs = (long)(DateTimeOffset.UtcNow - started).TotalMilliseconds;
|
||||
await WriteSseEventAsync(httpContext, "synthesis_end", new
|
||||
{
|
||||
totalTokens = result.TotalTokens,
|
||||
durationMs,
|
||||
provider = result.Provider,
|
||||
promptVersion = result.PromptVersion
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await WriteSseEventAsync(httpContext, "error", new
|
||||
{
|
||||
code = "synthesis_error",
|
||||
message = ex.Message
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var durationMs = (long)(DateTimeOffset.UtcNow - started).TotalMilliseconds;
|
||||
await WriteSseEventAsync(httpContext, "synthesis_end", new
|
||||
{
|
||||
totalTokens = 0,
|
||||
durationMs,
|
||||
provider = "none",
|
||||
promptVersion = "search-synth-v1"
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> RebuildIndexAsync(
|
||||
HttpContext httpContext,
|
||||
IUnifiedSearchIndexer indexer,
|
||||
@@ -125,14 +302,19 @@ public static class UnifiedSearchEndpoints
|
||||
});
|
||||
}
|
||||
|
||||
private static UnifiedSearchFilter? NormalizeFilter(UnifiedSearchApiFilter? filter, string tenant, IReadOnlyList<string>? userScopes = null)
|
||||
private static UnifiedSearchFilter? NormalizeFilter(
|
||||
UnifiedSearchApiFilter? filter,
|
||||
string tenant,
|
||||
IReadOnlyList<string>? userScopes = null,
|
||||
string? userId = null)
|
||||
{
|
||||
if (filter is null)
|
||||
{
|
||||
return new UnifiedSearchFilter
|
||||
{
|
||||
Tenant = tenant,
|
||||
UserScopes = userScopes
|
||||
UserScopes = userScopes,
|
||||
UserId = userId
|
||||
};
|
||||
}
|
||||
|
||||
@@ -180,7 +362,37 @@ public static class UnifiedSearchEndpoints
|
||||
Service = string.IsNullOrWhiteSpace(filter.Service) ? null : filter.Service.Trim(),
|
||||
Tags = tags,
|
||||
Tenant = tenant,
|
||||
UserScopes = userScopes
|
||||
UserScopes = userScopes,
|
||||
UserId = userId
|
||||
};
|
||||
}
|
||||
|
||||
private static AmbientContext? NormalizeAmbient(UnifiedSearchApiAmbientContext? ambient)
|
||||
{
|
||||
if (ambient is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AmbientContext
|
||||
{
|
||||
CurrentRoute = string.IsNullOrWhiteSpace(ambient.CurrentRoute) ? null : ambient.CurrentRoute.Trim(),
|
||||
SessionId = string.IsNullOrWhiteSpace(ambient.SessionId) ? null : ambient.SessionId.Trim(),
|
||||
ResetSession = ambient.ResetSession,
|
||||
VisibleEntityKeys = ambient.VisibleEntityKeys is { Count: > 0 }
|
||||
? ambient.VisibleEntityKeys
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(static value => value.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray()
|
||||
: null,
|
||||
RecentSearches = ambient.RecentSearches is { Count: > 0 }
|
||||
? ambient.RecentSearches
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(static value => value.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray()
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -204,7 +416,17 @@ public static class UnifiedSearchEndpoints
|
||||
IsPrimary = action.IsPrimary
|
||||
}).ToArray(),
|
||||
Metadata = card.Metadata,
|
||||
Sources = card.Sources.ToArray()
|
||||
Sources = card.Sources.ToArray(),
|
||||
Facets = card.Facets.Select(static facet => new UnifiedSearchApiFacet
|
||||
{
|
||||
Domain = facet.Domain,
|
||||
Title = facet.Title,
|
||||
Snippet = facet.Snippet,
|
||||
Score = facet.Score,
|
||||
Metadata = facet.Metadata
|
||||
}).ToArray(),
|
||||
Connections = card.Connections.ToArray(),
|
||||
SynthesisHints = card.SynthesisHints
|
||||
}).ToArray();
|
||||
|
||||
UnifiedSearchApiSynthesis? synthesis = null;
|
||||
@@ -256,10 +478,95 @@ public static class UnifiedSearchEndpoints
|
||||
DurationMs = response.Diagnostics.DurationMs,
|
||||
UsedVector = response.Diagnostics.UsedVector,
|
||||
Mode = response.Diagnostics.Mode
|
||||
}
|
||||
},
|
||||
Federation = response.Diagnostics.Federation?.Select(static diag => new UnifiedSearchApiFederationDiagnostic
|
||||
{
|
||||
Backend = diag.Backend,
|
||||
ResultCount = diag.ResultCount,
|
||||
DurationMs = diag.DurationMs,
|
||||
TimedOut = diag.TimedOut,
|
||||
Status = diag.Status
|
||||
}).ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
private static bool HasSynthesisScope(HttpContext context)
|
||||
{
|
||||
var scopes = ResolveUserScopes(context);
|
||||
if (scopes is null || scopes.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return scopes.Contains("search:synthesize", StringComparer.OrdinalIgnoreCase) ||
|
||||
scopes.Contains("advisory-ai:admin", StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<EntityCard> MapSynthesisCards(IReadOnlyList<UnifiedSearchApiCard>? cards)
|
||||
{
|
||||
if (cards is not { Count: > 0 })
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return cards
|
||||
.Select(static card => new EntityCard
|
||||
{
|
||||
EntityKey = card.EntityKey,
|
||||
EntityType = card.EntityType,
|
||||
Domain = card.Domain,
|
||||
Title = card.Title,
|
||||
Snippet = card.Snippet,
|
||||
Score = card.Score,
|
||||
Severity = card.Severity,
|
||||
Metadata = card.Metadata,
|
||||
Sources = card.Sources,
|
||||
Actions = card.Actions.Select(static action => new EntityCardAction(
|
||||
action.Label,
|
||||
action.ActionType,
|
||||
action.Route,
|
||||
action.Command,
|
||||
action.IsPrimary)).ToArray(),
|
||||
Facets = card.Facets.Select(static facet => new EntityCardFacet
|
||||
{
|
||||
Domain = facet.Domain,
|
||||
Title = facet.Title,
|
||||
Snippet = facet.Snippet,
|
||||
Score = facet.Score,
|
||||
Metadata = facet.Metadata
|
||||
}).ToArray(),
|
||||
Connections = card.Connections,
|
||||
SynthesisHints = card.SynthesisHints
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static async Task WriteSseEventAsync(
|
||||
HttpContext context,
|
||||
string eventName,
|
||||
object payload,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
await context.Response.WriteAsync($"event: {eventName}\n", cancellationToken).ConfigureAwait(false);
|
||||
await context.Response.WriteAsync($"data: {json}\n\n", cancellationToken).ConfigureAwait(false);
|
||||
await context.Response.Body.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> Chunk(string content, int size)
|
||||
{
|
||||
if (string.IsNullOrEmpty(content) || size <= 0)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
for (var index = 0; index < content.Length; index += size)
|
||||
{
|
||||
var length = Math.Min(size, content.Length - index);
|
||||
yield return content.Substring(index, length);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveTenant(HttpContext context)
|
||||
{
|
||||
foreach (var value in context.Request.Headers["X-StellaOps-Tenant"])
|
||||
@@ -374,6 +681,21 @@ public sealed record UnifiedSearchApiRequest
|
||||
public bool IncludeSynthesis { get; init; } = true;
|
||||
|
||||
public bool IncludeDebug { get; init; }
|
||||
|
||||
public UnifiedSearchApiAmbientContext? Ambient { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnifiedSearchApiAmbientContext
|
||||
{
|
||||
public string? CurrentRoute { get; init; }
|
||||
|
||||
public IReadOnlyList<string>? VisibleEntityKeys { get; init; }
|
||||
|
||||
public IReadOnlyList<string>? RecentSearches { get; init; }
|
||||
|
||||
public string? SessionId { get; init; }
|
||||
|
||||
public bool ResetSession { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnifiedSearchApiFilter
|
||||
@@ -393,6 +715,28 @@ public sealed record UnifiedSearchApiFilter
|
||||
public IReadOnlyList<string>? Tags { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnifiedSearchSynthesizeApiRequest
|
||||
{
|
||||
public string Q { get; init; } = string.Empty;
|
||||
|
||||
public IReadOnlyList<UnifiedSearchApiCard> TopCards { get; init; } = [];
|
||||
|
||||
public QueryPlan? Plan { get; init; }
|
||||
|
||||
public UnifiedSearchSynthesisPreferencesApi? Preferences { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnifiedSearchSynthesisPreferencesApi
|
||||
{
|
||||
public string Depth { get; init; } = "brief";
|
||||
|
||||
public int? MaxTokens { get; init; }
|
||||
|
||||
public bool IncludeActions { get; init; } = true;
|
||||
|
||||
public string Locale { get; init; } = "en";
|
||||
}
|
||||
|
||||
public sealed record UnifiedSearchApiResponse
|
||||
{
|
||||
public string Query { get; init; } = string.Empty;
|
||||
@@ -408,6 +752,8 @@ public sealed record UnifiedSearchApiResponse
|
||||
public IReadOnlyList<UnifiedSearchApiRefinement>? Refinements { get; init; }
|
||||
|
||||
public UnifiedSearchApiDiagnostics Diagnostics { get; init; } = new();
|
||||
|
||||
public IReadOnlyList<UnifiedSearchApiFederationDiagnostic>? Federation { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnifiedSearchApiCard
|
||||
@@ -431,6 +777,26 @@ public sealed record UnifiedSearchApiCard
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
|
||||
public IReadOnlyList<string> Sources { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<UnifiedSearchApiFacet> Facets { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<string> Connections { get; init; } = [];
|
||||
|
||||
public IReadOnlyDictionary<string, string> SynthesisHints { get; init; } =
|
||||
new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public sealed record UnifiedSearchApiFacet
|
||||
{
|
||||
public string Domain { get; init; } = "knowledge";
|
||||
|
||||
public string Title { get; init; } = string.Empty;
|
||||
|
||||
public string Snippet { get; init; } = string.Empty;
|
||||
|
||||
public double Score { get; init; }
|
||||
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UnifiedSearchApiAction
|
||||
@@ -488,6 +854,19 @@ public sealed record UnifiedSearchApiDiagnostics
|
||||
public string Mode { get; init; } = "fts-only";
|
||||
}
|
||||
|
||||
public sealed record UnifiedSearchApiFederationDiagnostic
|
||||
{
|
||||
public string Backend { get; init; } = string.Empty;
|
||||
|
||||
public int ResultCount { get; init; }
|
||||
|
||||
public long DurationMs { get; init; }
|
||||
|
||||
public bool TimedOut { get; init; }
|
||||
|
||||
public string Status { get; init; } = "ok";
|
||||
}
|
||||
|
||||
public sealed record UnifiedSearchRebuildApiResponse
|
||||
{
|
||||
public int DomainCount { get; init; }
|
||||
|
||||
@@ -106,6 +106,23 @@ public sealed class KnowledgeSearchOptions
|
||||
[Range(30, 86400)]
|
||||
public int SearchQualityMonitorIntervalSeconds { get; set; } = 300;
|
||||
|
||||
/// <summary>
|
||||
/// Enables periodic pruning of search analytics/feedback/history tables.
|
||||
/// </summary>
|
||||
public bool SearchAnalyticsRetentionEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Retention window in days for search analytics/feedback/history.
|
||||
/// </summary>
|
||||
[Range(1, 3650)]
|
||||
public int SearchAnalyticsRetentionDays { get; set; } = 90;
|
||||
|
||||
/// <summary>
|
||||
/// Interval in seconds for retention pruning.
|
||||
/// </summary>
|
||||
[Range(30, 86400)]
|
||||
public int SearchAnalyticsRetentionIntervalSeconds { get; set; } = 3600;
|
||||
|
||||
// ── Live adapter settings (Sprint 103 / G2) ──
|
||||
|
||||
/// <summary>Base URL for the Scanner microservice (e.g. "http://scanner:8080").</summary>
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
<None Update="KnowledgeSearch/doctor-search-seed.fr.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="models/all-MiniLM-L6-v2.onnx">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<TargetPath>models/all-MiniLM-L6-v2.onnx</TargetPath>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
@@ -37,6 +41,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
<PackageReference Include="Microsoft.ML.OnnxRuntime" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -5,6 +5,11 @@ Source of truth: `docs/implplan/SPRINT_20260113_005_ADVISORYAI_controlled_conver
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| SPRINT_20260223_100-USRCH-POL-005 | DONE | Security hardening closure: tenant-scoped adapter identities, backend+frontend snippet sanitization, and threat-model docs. Evidence: `UnifiedSearchLiveAdapterIntegrationTests` (11/11), `UnifiedSearchSprintIntegrationTests` (109/109), targeted snippet test (1/1). |
|
||||
| SPRINT_20260223_100-USRCH-POL-006 | DONE | Deprecation timeline documented in `docs/modules/advisory-ai/CHANGELOG.md`; platform/unified migration criteria closed for sprint 100 task 006. |
|
||||
| SPRINT_20260224_102-G1-005 | DONE | ONNX missing-model fallback integration evidence added (`G1_OnnxEncoderSelection_MissingModelPath_FallsBackToDeterministicHashEncoder`). |
|
||||
| SPRINT_20260224_102-G1-004 | DONE | Semantic recall benchmark corpus and assertions complete (48 queries; no exact-term regression; semantic recall uplift proven). |
|
||||
| SPRINT_20260224_102-G1-001 | DOING | ONNX runtime package + license docs completed; model asset provisioning at `models/all-MiniLM-L6-v2.onnx` still pending deployment packaging. |
|
||||
| SPRINT_20260222_051-AKS-INGEST | DONE | Added deterministic AKS ingestion controls: markdown allow-list manifest loading, OpenAPI aggregate source path support, and doctor control projection integration for search chunks, including fallback doctor metadata hydration from controls projection fields. |
|
||||
| AUDIT-0017-M | DONE | Maintainability audit for StellaOps.AdvisoryAI. |
|
||||
| AUDIT-0017-T | DONE | Test coverage audit for StellaOps.AdvisoryAI. |
|
||||
|
||||
@@ -174,6 +174,7 @@ internal sealed class FindingsSearchAdapter : ISearchIngestionAdapter
|
||||
var policyBadge = ReadString(entry, "policyBadge") ?? string.Empty;
|
||||
var product = ReadString(entry, "product") ?? component;
|
||||
var tenant = ReadString(entry, "tenant") ?? "global";
|
||||
var tenantIdentity = NormalizeTenantForIdentity(tenant);
|
||||
var tags = ReadStringArray(entry, "tags", ["finding", "vulnerability", severity]);
|
||||
|
||||
var title = string.IsNullOrWhiteSpace(component)
|
||||
@@ -197,8 +198,10 @@ internal sealed class FindingsSearchAdapter : ISearchIngestionAdapter
|
||||
bodyParts.Add($"Severity: {severity}");
|
||||
|
||||
var body = string.Join("\n", bodyParts);
|
||||
var chunkId = KnowledgeSearchText.StableId("chunk", "finding", findingId, cveId);
|
||||
var docId = KnowledgeSearchText.StableId("doc", "finding", findingId);
|
||||
// Scope ids by tenant to prevent cross-tenant overwrite collisions
|
||||
// when different tenants have identical finding ids/cve pairs.
|
||||
var chunkId = KnowledgeSearchText.StableId("chunk", "finding", tenantIdentity, findingId, cveId);
|
||||
var docId = KnowledgeSearchText.StableId("doc", "finding", tenantIdentity, findingId);
|
||||
var embedding = _vectorEncoder.Encode(body);
|
||||
var freshness = ReadTimestamp(entry, "freshness");
|
||||
|
||||
@@ -273,13 +276,16 @@ internal sealed class FindingsSearchAdapter : ISearchIngestionAdapter
|
||||
var description = ReadString(entry, "description") ?? string.Empty;
|
||||
var service = ReadString(entry, "service") ?? "scanner";
|
||||
var tenant = ReadString(entry, "tenant") ?? "global";
|
||||
var tenantIdentity = NormalizeTenantForIdentity(tenant);
|
||||
var tags = ReadStringArray(entry, "tags", ["finding", "vulnerability", severity]);
|
||||
|
||||
var body = string.IsNullOrWhiteSpace(description)
|
||||
? $"{title}\nSeverity: {severity}"
|
||||
: $"{title}\n{description}\nSeverity: {severity}";
|
||||
var chunkId = KnowledgeSearchText.StableId("chunk", "finding", findingId, cveId);
|
||||
var docId = KnowledgeSearchText.StableId("doc", "finding", findingId);
|
||||
// Scope ids by tenant to prevent cross-tenant overwrite collisions
|
||||
// when different tenants have identical finding ids/cve pairs.
|
||||
var chunkId = KnowledgeSearchText.StableId("chunk", "finding", tenantIdentity, findingId, cveId);
|
||||
var docId = KnowledgeSearchText.StableId("doc", "finding", tenantIdentity, findingId);
|
||||
var embedding = _vectorEncoder.Encode(body);
|
||||
var freshness = ReadTimestamp(entry, "freshness");
|
||||
|
||||
@@ -370,4 +376,11 @@ internal sealed class FindingsSearchAdapter : ISearchIngestionAdapter
|
||||
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string NormalizeTenantForIdentity(string tenant)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(tenant)
|
||||
? "global"
|
||||
: tenant.Trim().ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
using StellaOps.AdvisoryAI.Vectorization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.UnifiedSearch.Adapters;
|
||||
|
||||
internal sealed class GraphNodeIngestionAdapter : ISearchIngestionAdapter
|
||||
{
|
||||
private readonly KnowledgeSearchOptions _knowledgeOptions;
|
||||
private readonly UnifiedSearchOptions _unifiedOptions;
|
||||
private readonly IVectorEncoder _vectorEncoder;
|
||||
private readonly ILogger<GraphNodeIngestionAdapter> _logger;
|
||||
|
||||
public GraphNodeIngestionAdapter(
|
||||
IOptions<KnowledgeSearchOptions> knowledgeOptions,
|
||||
IOptions<UnifiedSearchOptions> unifiedOptions,
|
||||
IVectorEncoder vectorEncoder,
|
||||
ILogger<GraphNodeIngestionAdapter> logger)
|
||||
{
|
||||
_knowledgeOptions = knowledgeOptions?.Value ?? new KnowledgeSearchOptions();
|
||||
_unifiedOptions = unifiedOptions?.Value ?? new UnifiedSearchOptions();
|
||||
_vectorEncoder = vectorEncoder ?? throw new ArgumentNullException(nameof(vectorEncoder));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public string Domain => "graph";
|
||||
|
||||
public IReadOnlyList<string> SupportedEntityTypes => ["package", "image", "registry"];
|
||||
|
||||
public async Task<IReadOnlyList<UnifiedChunk>> ProduceChunksAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var path = ResolvePath(_unifiedOptions.Ingestion.GraphSnapshotPath);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
_logger.LogDebug("Graph snapshot not found at {Path}.", path);
|
||||
return [];
|
||||
}
|
||||
|
||||
await using var stream = File.OpenRead(path);
|
||||
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
_logger.LogWarning("Graph snapshot at {Path} is not a JSON array.", path);
|
||||
return [];
|
||||
}
|
||||
|
||||
var allowedKinds = _unifiedOptions.Ingestion.GraphNodeKindFilter
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(static value => value.Trim().ToLowerInvariant())
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var chunks = new List<UnifiedChunk>();
|
||||
foreach (var node in document.RootElement.EnumerateArray())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var chunk = MapNode(node, allowedKinds);
|
||||
if (chunk is not null)
|
||||
{
|
||||
chunks.Add(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
private UnifiedChunk? MapNode(JsonElement node, ISet<string> allowedKinds)
|
||||
{
|
||||
if (node.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var nodeKind = ReadString(node, "kind")?.ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(nodeKind) || !allowedKinds.Contains(nodeKind))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var nodeId = ReadString(node, "nodeId") ?? ReadString(node, "id");
|
||||
if (string.IsNullOrWhiteSpace(nodeId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var name = ReadString(node, "name") ?? nodeId;
|
||||
var version = ReadString(node, "version") ?? string.Empty;
|
||||
var purl = ReadString(node, "purl");
|
||||
var imageRef = ReadString(node, "imageRef") ?? ReadString(node, "image");
|
||||
var registry = ReadString(node, "registry");
|
||||
var digest = ReadString(node, "digest");
|
||||
var os = ReadString(node, "os");
|
||||
var arch = ReadString(node, "arch");
|
||||
var dependencyCount = ReadInt(node, "dependencyCount");
|
||||
var relationSummary = ReadString(node, "relationshipSummary");
|
||||
var tenant = ReadString(node, "tenant") ?? "global";
|
||||
var freshness = ReadTimestamp(node, "freshness") ?? DateTimeOffset.UtcNow;
|
||||
|
||||
if (dependencyCount <= 0 &&
|
||||
string.IsNullOrWhiteSpace(registry) &&
|
||||
string.IsNullOrWhiteSpace(imageRef) &&
|
||||
string.IsNullOrWhiteSpace(relationSummary))
|
||||
{
|
||||
// Ignore ephemeral nodes with no useful graph/search signal.
|
||||
return null;
|
||||
}
|
||||
|
||||
var title = nodeKind switch
|
||||
{
|
||||
"package" => string.IsNullOrWhiteSpace(version)
|
||||
? $"package: {name}"
|
||||
: $"package: {name}@{version}",
|
||||
"registry" => $"registry: {registry ?? name}",
|
||||
_ => $"image: {imageRef ?? name}"
|
||||
};
|
||||
|
||||
var bodyParts = new List<string>
|
||||
{
|
||||
title,
|
||||
$"kind: {nodeKind}",
|
||||
$"name: {name}"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(version)) bodyParts.Add($"version: {version}");
|
||||
if (!string.IsNullOrWhiteSpace(purl)) bodyParts.Add($"purl: {purl}");
|
||||
if (!string.IsNullOrWhiteSpace(imageRef)) bodyParts.Add($"image: {imageRef}");
|
||||
if (!string.IsNullOrWhiteSpace(registry)) bodyParts.Add($"registry: {registry}");
|
||||
if (!string.IsNullOrWhiteSpace(digest)) bodyParts.Add($"digest: {digest}");
|
||||
if (!string.IsNullOrWhiteSpace(os)) bodyParts.Add($"os: {os}");
|
||||
if (!string.IsNullOrWhiteSpace(arch)) bodyParts.Add($"arch: {arch}");
|
||||
bodyParts.Add($"dependencyCount: {dependencyCount}");
|
||||
if (!string.IsNullOrWhiteSpace(relationSummary)) bodyParts.Add($"relationships: {relationSummary}");
|
||||
|
||||
var body = string.Join('\n', bodyParts);
|
||||
var entityKey = BuildEntityKey(nodeKind, purl, imageRef, registry, name, version);
|
||||
var entityType = nodeKind is "base_image" ? "image" : nodeKind;
|
||||
var chunkId = KnowledgeSearchText.StableId("graph", tenant, nodeId, KnowledgeSearchText.StableId(body));
|
||||
var docId = KnowledgeSearchText.StableId("graph-doc", tenant, nodeId);
|
||||
|
||||
var metadata = JsonDocument.Parse(JsonSerializer.Serialize(new
|
||||
{
|
||||
domain = Domain,
|
||||
tenant,
|
||||
nodeId,
|
||||
nodeKind,
|
||||
purl,
|
||||
imageRef,
|
||||
registry,
|
||||
digest,
|
||||
os,
|
||||
arch,
|
||||
dependencyCount,
|
||||
relationshipSummary = relationSummary,
|
||||
freshness = freshness.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
|
||||
route = $"/ops/graph?node={Uri.EscapeDataString(nodeId)}"
|
||||
}));
|
||||
|
||||
return new UnifiedChunk(
|
||||
chunkId,
|
||||
docId,
|
||||
Kind: "graph_node",
|
||||
Domain,
|
||||
title,
|
||||
body,
|
||||
_vectorEncoder.Encode(body),
|
||||
entityKey,
|
||||
entityType,
|
||||
Anchor: null,
|
||||
SectionPath: null,
|
||||
SpanStart: 0,
|
||||
SpanEnd: body.Length,
|
||||
freshness,
|
||||
metadata);
|
||||
}
|
||||
|
||||
private string ResolvePath(string configuredPath)
|
||||
{
|
||||
if (Path.IsPathRooted(configuredPath))
|
||||
{
|
||||
return configuredPath;
|
||||
}
|
||||
|
||||
var root = string.IsNullOrWhiteSpace(_knowledgeOptions.RepositoryRoot) ? "." : _knowledgeOptions.RepositoryRoot;
|
||||
return Path.GetFullPath(Path.Combine(root, configuredPath));
|
||||
}
|
||||
|
||||
private static string BuildEntityKey(
|
||||
string nodeKind,
|
||||
string? purl,
|
||||
string? imageRef,
|
||||
string? registry,
|
||||
string name,
|
||||
string version)
|
||||
{
|
||||
return nodeKind switch
|
||||
{
|
||||
"package" when !string.IsNullOrWhiteSpace(purl) => $"purl:{purl}",
|
||||
"package" => $"purl:pkg:{name}@{version}",
|
||||
"registry" => $"registry:{registry ?? name}",
|
||||
_ => $"image:{imageRef ?? name}"
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ReadString(JsonElement obj, string propertyName)
|
||||
{
|
||||
return obj.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.String
|
||||
? prop.GetString()?.Trim()
|
||||
: null;
|
||||
}
|
||||
|
||||
private static int ReadInt(JsonElement obj, string propertyName)
|
||||
{
|
||||
return obj.TryGetProperty(propertyName, out var prop) &&
|
||||
prop.ValueKind == JsonValueKind.Number &&
|
||||
prop.TryGetInt32(out var value)
|
||||
? value
|
||||
: 0;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ReadTimestamp(JsonElement obj, string propertyName)
|
||||
{
|
||||
var raw = ReadString(obj, propertyName);
|
||||
return raw is not null && DateTimeOffset.TryParse(raw, out var timestamp) ? timestamp : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
using StellaOps.AdvisoryAI.Vectorization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.UnifiedSearch.Adapters;
|
||||
|
||||
internal sealed class OpsDecisionIngestionAdapter : ISearchIngestionAdapter
|
||||
{
|
||||
private readonly KnowledgeSearchOptions _knowledgeOptions;
|
||||
private readonly UnifiedSearchOptions _unifiedOptions;
|
||||
private readonly IVectorEncoder _vectorEncoder;
|
||||
private readonly ILogger<OpsDecisionIngestionAdapter> _logger;
|
||||
|
||||
public OpsDecisionIngestionAdapter(
|
||||
IOptions<KnowledgeSearchOptions> knowledgeOptions,
|
||||
IOptions<UnifiedSearchOptions> unifiedOptions,
|
||||
IVectorEncoder vectorEncoder,
|
||||
ILogger<OpsDecisionIngestionAdapter> logger)
|
||||
{
|
||||
_knowledgeOptions = knowledgeOptions?.Value ?? new KnowledgeSearchOptions();
|
||||
_unifiedOptions = unifiedOptions?.Value ?? new UnifiedSearchOptions();
|
||||
_vectorEncoder = vectorEncoder ?? throw new ArgumentNullException(nameof(vectorEncoder));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public string Domain => "opsmemory";
|
||||
|
||||
public IReadOnlyList<string> SupportedEntityTypes => ["finding", "package", "image"];
|
||||
|
||||
public async Task<IReadOnlyList<UnifiedChunk>> ProduceChunksAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var path = ResolvePath(_unifiedOptions.Ingestion.OpsMemorySnapshotPath);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
_logger.LogDebug("OpsMemory snapshot not found at {Path}.", path);
|
||||
return [];
|
||||
}
|
||||
|
||||
await using var stream = File.OpenRead(path);
|
||||
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var chunks = new List<UnifiedChunk>();
|
||||
foreach (var decision in document.RootElement.EnumerateArray())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var chunk = MapDecision(decision);
|
||||
if (chunk is not null)
|
||||
{
|
||||
chunks.Add(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
private UnifiedChunk? MapDecision(JsonElement decision)
|
||||
{
|
||||
if (decision.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var decisionId = ReadString(decision, "decisionId") ?? ReadString(decision, "id");
|
||||
if (string.IsNullOrWhiteSpace(decisionId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var tenant = ReadString(decision, "tenant") ?? "global";
|
||||
var decisionType = ReadString(decision, "decisionType") ?? "unknown";
|
||||
var outcomeStatus = ReadString(decision, "outcomeStatus") ?? "pending";
|
||||
var subjectRef = ReadString(decision, "subjectRef") ?? ReadString(decision, "cve") ?? string.Empty;
|
||||
var subjectType = ReadString(decision, "subjectType") ?? GuessSubjectType(subjectRef);
|
||||
var contextTags = ReadStringArray(decision, "contextTags");
|
||||
var rationale = ReadString(decision, "rationale") ?? string.Empty;
|
||||
var severity = ReadString(decision, "severity") ?? "unknown";
|
||||
var resolutionHours = ReadDouble(decision, "resolutionTimeHours");
|
||||
var recordedAt = ReadTimestamp(decision, "recordedAt") ?? DateTimeOffset.UtcNow;
|
||||
var outcomeRecordedAt = ReadTimestamp(decision, "outcomeRecordedAt");
|
||||
var freshness = outcomeRecordedAt > recordedAt ? outcomeRecordedAt.Value : recordedAt;
|
||||
var similarityVector = ReadNumberArray(decision, "similarityVector");
|
||||
|
||||
var title = $"Decision: {decisionType} for {subjectRef} ({outcomeStatus})";
|
||||
var body = string.Join('\n', new[]
|
||||
{
|
||||
title,
|
||||
$"decisionType: {decisionType}",
|
||||
$"outcomeStatus: {outcomeStatus}",
|
||||
$"subjectRef: {subjectRef}",
|
||||
$"subjectType: {subjectType}",
|
||||
$"severity: {severity}",
|
||||
$"contextTags: {string.Join(",", contextTags)}",
|
||||
$"resolutionTimeHours: {resolutionHours.ToString(System.Globalization.CultureInfo.InvariantCulture)}",
|
||||
$"rationale: {rationale}"
|
||||
});
|
||||
|
||||
var entityKey = BuildEntityKey(subjectType, subjectRef);
|
||||
var chunkId = KnowledgeSearchText.StableId("ops", tenant, decisionId, KnowledgeSearchText.StableId(body));
|
||||
var docId = KnowledgeSearchText.StableId("ops-doc", tenant, decisionId);
|
||||
var metadata = JsonDocument.Parse(JsonSerializer.Serialize(new
|
||||
{
|
||||
domain = Domain,
|
||||
tenant,
|
||||
decisionId,
|
||||
decisionType,
|
||||
outcomeStatus,
|
||||
subjectRef,
|
||||
subjectType,
|
||||
severity,
|
||||
contextTags,
|
||||
resolutionTimeHours = resolutionHours,
|
||||
similarityVector,
|
||||
freshness = freshness.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
|
||||
route = $"/ops/opsmemory/decisions/{Uri.EscapeDataString(decisionId)}",
|
||||
incrementalSignals = new[] { "decision_created", "outcome_recorded" }
|
||||
}));
|
||||
|
||||
return new UnifiedChunk(
|
||||
chunkId,
|
||||
docId,
|
||||
Kind: "ops_decision",
|
||||
Domain,
|
||||
title,
|
||||
body,
|
||||
_vectorEncoder.Encode(body),
|
||||
entityKey,
|
||||
subjectType,
|
||||
Anchor: null,
|
||||
SectionPath: null,
|
||||
SpanStart: 0,
|
||||
SpanEnd: body.Length,
|
||||
freshness,
|
||||
metadata);
|
||||
}
|
||||
|
||||
private string ResolvePath(string configuredPath)
|
||||
{
|
||||
if (Path.IsPathRooted(configuredPath))
|
||||
{
|
||||
return configuredPath;
|
||||
}
|
||||
|
||||
var root = string.IsNullOrWhiteSpace(_knowledgeOptions.RepositoryRoot) ? "." : _knowledgeOptions.RepositoryRoot;
|
||||
return Path.GetFullPath(Path.Combine(root, configuredPath));
|
||||
}
|
||||
|
||||
private static string GuessSubjectType(string subjectRef)
|
||||
{
|
||||
if (subjectRef.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "finding";
|
||||
}
|
||||
|
||||
if (subjectRef.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "package";
|
||||
}
|
||||
|
||||
if (subjectRef.Contains('/', StringComparison.Ordinal))
|
||||
{
|
||||
return "image";
|
||||
}
|
||||
|
||||
return "finding";
|
||||
}
|
||||
|
||||
private static string BuildEntityKey(string subjectType, string subjectRef)
|
||||
{
|
||||
return subjectType switch
|
||||
{
|
||||
"package" when subjectRef.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase) => $"purl:{subjectRef}",
|
||||
"image" => $"image:{subjectRef}",
|
||||
_ => subjectRef.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase)
|
||||
? $"cve:{subjectRef.ToUpperInvariant()}"
|
||||
: $"entity:{subjectRef}"
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ReadString(JsonElement obj, string propertyName)
|
||||
{
|
||||
return obj.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.String
|
||||
? prop.GetString()?.Trim()
|
||||
: null;
|
||||
}
|
||||
|
||||
private static double ReadDouble(JsonElement obj, string propertyName)
|
||||
{
|
||||
return obj.TryGetProperty(propertyName, out var prop) &&
|
||||
prop.ValueKind == JsonValueKind.Number &&
|
||||
prop.TryGetDouble(out var value)
|
||||
? value
|
||||
: 0d;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ReadTimestamp(JsonElement obj, string propertyName)
|
||||
{
|
||||
var raw = ReadString(obj, propertyName);
|
||||
return raw is not null && DateTimeOffset.TryParse(raw, out var value) ? value : null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ReadStringArray(JsonElement obj, string propertyName)
|
||||
{
|
||||
if (!obj.TryGetProperty(propertyName, out var prop) || prop.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return prop.EnumerateArray()
|
||||
.Where(static value => value.ValueKind == JsonValueKind.String)
|
||||
.Select(static value => value.GetString())
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(static value => value!.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<double> ReadNumberArray(JsonElement obj, string propertyName)
|
||||
{
|
||||
if (!obj.TryGetProperty(propertyName, out var prop) || prop.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return prop.EnumerateArray()
|
||||
.Where(static value => value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out _))
|
||||
.Select(static value => value.GetDouble())
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +175,7 @@ internal sealed class PolicySearchAdapter : ISearchIngestionAdapter
|
||||
var scope = bomRef;
|
||||
var environment = ReadString(entry, "environment") ?? string.Empty;
|
||||
var tenant = ReadString(entry, "tenant") ?? "global";
|
||||
var tenantIdentity = NormalizeTenantForIdentity(tenant);
|
||||
var tags = ReadStringArray(entry, "tags", ["policy", "rule", gateStatus]);
|
||||
|
||||
// Map gate status to enforcement level
|
||||
@@ -205,8 +206,10 @@ internal sealed class PolicySearchAdapter : ISearchIngestionAdapter
|
||||
}
|
||||
|
||||
var body = string.Join("\n", bodyParts);
|
||||
var chunkId = KnowledgeSearchText.StableId("chunk", "policy_rule", ruleId);
|
||||
var docId = KnowledgeSearchText.StableId("doc", "policy_rule", ruleId);
|
||||
// Scope ids by tenant to prevent cross-tenant overwrite collisions
|
||||
// when rule ids are reused in different tenants.
|
||||
var chunkId = KnowledgeSearchText.StableId("chunk", "policy_rule", tenantIdentity, ruleId);
|
||||
var docId = KnowledgeSearchText.StableId("doc", "policy_rule", tenantIdentity, ruleId);
|
||||
var embedding = _vectorEncoder.Encode(body);
|
||||
|
||||
var freshness = ReadTimestamp(entry, "evaluated_at")
|
||||
@@ -283,13 +286,16 @@ internal sealed class PolicySearchAdapter : ISearchIngestionAdapter
|
||||
var decision = ReadString(entry, "decision");
|
||||
var service = ReadString(entry, "service") ?? "policy";
|
||||
var tenant = ReadString(entry, "tenant") ?? "global";
|
||||
var tenantIdentity = NormalizeTenantForIdentity(tenant);
|
||||
var tags = ReadStringArray(entry, "tags", ["policy", "rule"]);
|
||||
|
||||
var body = string.IsNullOrWhiteSpace(decision)
|
||||
? $"{title}\nRule: {ruleId}\n{description}"
|
||||
: $"{title}\nRule: {ruleId}\nDecision: {decision}\n{description}";
|
||||
var chunkId = KnowledgeSearchText.StableId("chunk", "policy_rule", ruleId);
|
||||
var docId = KnowledgeSearchText.StableId("doc", "policy_rule", ruleId);
|
||||
// Scope ids by tenant to prevent cross-tenant overwrite collisions
|
||||
// when rule ids are reused in different tenants.
|
||||
var chunkId = KnowledgeSearchText.StableId("chunk", "policy_rule", tenantIdentity, ruleId);
|
||||
var docId = KnowledgeSearchText.StableId("doc", "policy_rule", tenantIdentity, ruleId);
|
||||
var embedding = _vectorEncoder.Encode(body);
|
||||
var freshness = ReadTimestamp(entry, "freshness");
|
||||
|
||||
@@ -378,4 +384,11 @@ internal sealed class PolicySearchAdapter : ISearchIngestionAdapter
|
||||
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string NormalizeTenantForIdentity(string tenant)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(tenant)
|
||||
? "global"
|
||||
: tenant.Trim().ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
using StellaOps.AdvisoryAI.Vectorization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.UnifiedSearch.Adapters;
|
||||
|
||||
internal sealed class ScanResultIngestionAdapter : ISearchIngestionAdapter
|
||||
{
|
||||
private readonly KnowledgeSearchOptions _knowledgeOptions;
|
||||
private readonly UnifiedSearchOptions _unifiedOptions;
|
||||
private readonly IVectorEncoder _vectorEncoder;
|
||||
private readonly ILogger<ScanResultIngestionAdapter> _logger;
|
||||
|
||||
public ScanResultIngestionAdapter(
|
||||
IOptions<KnowledgeSearchOptions> knowledgeOptions,
|
||||
IOptions<UnifiedSearchOptions> unifiedOptions,
|
||||
IVectorEncoder vectorEncoder,
|
||||
ILogger<ScanResultIngestionAdapter> logger)
|
||||
{
|
||||
_knowledgeOptions = knowledgeOptions?.Value ?? new KnowledgeSearchOptions();
|
||||
_unifiedOptions = unifiedOptions?.Value ?? new UnifiedSearchOptions();
|
||||
_vectorEncoder = vectorEncoder ?? throw new ArgumentNullException(nameof(vectorEncoder));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public string Domain => "scanner";
|
||||
|
||||
public IReadOnlyList<string> SupportedEntityTypes => ["scan"];
|
||||
|
||||
public async Task<IReadOnlyList<UnifiedChunk>> ProduceChunksAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var path = ResolvePath(_unifiedOptions.Ingestion.ScannerSnapshotPath);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
_logger.LogDebug("Scanner snapshot not found at {Path}.", path);
|
||||
return [];
|
||||
}
|
||||
|
||||
await using var stream = File.OpenRead(path);
|
||||
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var chunks = new List<UnifiedChunk>();
|
||||
foreach (var scan in document.RootElement.EnumerateArray())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var chunk = MapScan(scan);
|
||||
if (chunk is not null)
|
||||
{
|
||||
chunks.Add(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
private UnifiedChunk? MapScan(JsonElement scan)
|
||||
{
|
||||
if (scan.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var scanId = ReadString(scan, "scanId") ?? ReadString(scan, "id");
|
||||
if (string.IsNullOrWhiteSpace(scanId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var tenant = ReadString(scan, "tenant") ?? "global";
|
||||
var imageRef = ReadString(scan, "imageRef") ?? ReadString(scan, "image") ?? "unknown-image";
|
||||
var status = ReadString(scan, "status") ?? "complete";
|
||||
var scanType = ReadString(scan, "scanType") ?? "vulnerability";
|
||||
var findingCount = ReadInt(scan, "findingCount");
|
||||
var criticalCount = ReadInt(scan, "criticalCount");
|
||||
var durationMs = ReadInt(scan, "durationMs");
|
||||
var scannerVersion = ReadString(scan, "scannerVersion") ?? string.Empty;
|
||||
var completedAt = ReadTimestamp(scan, "completedAt") ?? DateTimeOffset.UtcNow;
|
||||
var policyVerdicts = ReadStringArray(scan, "policyVerdicts");
|
||||
|
||||
var title = $"Scan {scanId}: {imageRef} ({findingCount} findings, {criticalCount} critical)";
|
||||
var body = string.Join('\n', new[]
|
||||
{
|
||||
title,
|
||||
$"scanId: {scanId}",
|
||||
$"imageRef: {imageRef}",
|
||||
$"scanType: {scanType}",
|
||||
$"status: {status}",
|
||||
$"findingCount: {findingCount}",
|
||||
$"criticalCount: {criticalCount}",
|
||||
$"scannerVersion: {scannerVersion}",
|
||||
$"durationMs: {durationMs}",
|
||||
$"policyVerdicts: {string.Join(",", policyVerdicts)}"
|
||||
});
|
||||
|
||||
var chunkId = KnowledgeSearchText.StableId("scan", tenant, scanId, KnowledgeSearchText.StableId(body));
|
||||
var docId = KnowledgeSearchText.StableId("scan-doc", tenant, scanId);
|
||||
var metadata = JsonDocument.Parse(JsonSerializer.Serialize(new
|
||||
{
|
||||
domain = Domain,
|
||||
tenant,
|
||||
scanId,
|
||||
imageRef,
|
||||
scanType,
|
||||
status,
|
||||
findingCount,
|
||||
criticalCount,
|
||||
durationMs,
|
||||
scannerVersion,
|
||||
policyVerdicts,
|
||||
entity_aliases = new[] { $"image:{imageRef}" },
|
||||
incrementalSignals = new[] { "scan_completed" },
|
||||
freshness = completedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
|
||||
route = $"/console/scans/{Uri.EscapeDataString(scanId)}"
|
||||
}));
|
||||
|
||||
return new UnifiedChunk(
|
||||
chunkId,
|
||||
docId,
|
||||
Kind: "scan_result",
|
||||
Domain,
|
||||
title,
|
||||
body,
|
||||
_vectorEncoder.Encode(body),
|
||||
EntityKey: $"scan:{scanId}",
|
||||
EntityType: "scan",
|
||||
Anchor: null,
|
||||
SectionPath: null,
|
||||
SpanStart: 0,
|
||||
SpanEnd: body.Length,
|
||||
completedAt,
|
||||
metadata);
|
||||
}
|
||||
|
||||
private string ResolvePath(string configuredPath)
|
||||
{
|
||||
if (Path.IsPathRooted(configuredPath))
|
||||
{
|
||||
return configuredPath;
|
||||
}
|
||||
|
||||
var root = string.IsNullOrWhiteSpace(_knowledgeOptions.RepositoryRoot) ? "." : _knowledgeOptions.RepositoryRoot;
|
||||
return Path.GetFullPath(Path.Combine(root, configuredPath));
|
||||
}
|
||||
|
||||
private static string? ReadString(JsonElement obj, string propertyName)
|
||||
{
|
||||
return obj.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.String
|
||||
? prop.GetString()?.Trim()
|
||||
: null;
|
||||
}
|
||||
|
||||
private static int ReadInt(JsonElement obj, string propertyName)
|
||||
{
|
||||
return obj.TryGetProperty(propertyName, out var prop) &&
|
||||
prop.ValueKind == JsonValueKind.Number &&
|
||||
prop.TryGetInt32(out var value)
|
||||
? value
|
||||
: 0;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ReadTimestamp(JsonElement obj, string propertyName)
|
||||
{
|
||||
var raw = ReadString(obj, propertyName);
|
||||
return raw is not null && DateTimeOffset.TryParse(raw, out var timestamp) ? timestamp : null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ReadStringArray(JsonElement obj, string propertyName)
|
||||
{
|
||||
if (!obj.TryGetProperty(propertyName, out var prop) || prop.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return prop.EnumerateArray()
|
||||
.Where(static value => value.ValueKind == JsonValueKind.String)
|
||||
.Select(static value => value.GetString())
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(static value => value!.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
using StellaOps.AdvisoryAI.Vectorization;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.UnifiedSearch.Adapters;
|
||||
|
||||
internal sealed partial class TimelineEventIngestionAdapter : ISearchIngestionAdapter
|
||||
{
|
||||
private readonly KnowledgeSearchOptions _knowledgeOptions;
|
||||
private readonly UnifiedSearchOptions _unifiedOptions;
|
||||
private readonly IVectorEncoder _vectorEncoder;
|
||||
private readonly ILogger<TimelineEventIngestionAdapter> _logger;
|
||||
|
||||
public TimelineEventIngestionAdapter(
|
||||
IOptions<KnowledgeSearchOptions> knowledgeOptions,
|
||||
IOptions<UnifiedSearchOptions> unifiedOptions,
|
||||
IVectorEncoder vectorEncoder,
|
||||
ILogger<TimelineEventIngestionAdapter> logger)
|
||||
{
|
||||
_knowledgeOptions = knowledgeOptions?.Value ?? new KnowledgeSearchOptions();
|
||||
_unifiedOptions = unifiedOptions?.Value ?? new UnifiedSearchOptions();
|
||||
_vectorEncoder = vectorEncoder ?? throw new ArgumentNullException(nameof(vectorEncoder));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public string Domain => "timeline";
|
||||
|
||||
public IReadOnlyList<string> SupportedEntityTypes => ["event", "finding", "package", "policy_rule"];
|
||||
|
||||
public async Task<IReadOnlyList<UnifiedChunk>> ProduceChunksAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var path = ResolvePath(_unifiedOptions.Ingestion.TimelineSnapshotPath);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
_logger.LogDebug("Timeline snapshot not found at {Path}.", path);
|
||||
return [];
|
||||
}
|
||||
|
||||
await using var stream = File.OpenRead(path);
|
||||
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var cutoff = DateTimeOffset.UtcNow.AddDays(-Math.Max(1, _unifiedOptions.Ingestion.TimelineRetentionDays));
|
||||
var chunks = new List<UnifiedChunk>();
|
||||
|
||||
foreach (var eventItem in document.RootElement.EnumerateArray())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var chunk = MapEvent(eventItem, cutoff);
|
||||
if (chunk is not null)
|
||||
{
|
||||
chunks.Add(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
return chunks
|
||||
.OrderByDescending(static chunk => chunk.Freshness ?? DateTimeOffset.MinValue)
|
||||
.ThenBy(static chunk => chunk.ChunkId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private UnifiedChunk? MapEvent(JsonElement eventItem, DateTimeOffset cutoff)
|
||||
{
|
||||
if (eventItem.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var eventId = ReadString(eventItem, "eventId") ?? ReadString(eventItem, "id");
|
||||
if (string.IsNullOrWhiteSpace(eventId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var timestamp = ReadTimestamp(eventItem, "timestamp")
|
||||
?? ReadTimestamp(eventItem, "occurredAt")
|
||||
?? DateTimeOffset.UtcNow;
|
||||
if (timestamp < cutoff)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var tenant = ReadString(eventItem, "tenant") ?? "global";
|
||||
var actor = ReadString(eventItem, "actor") ?? ReadString(eventItem, "actorName") ?? "unknown";
|
||||
var action = ReadString(eventItem, "action") ?? "event";
|
||||
var module = ReadString(eventItem, "module") ?? "unknown";
|
||||
var targetRef = ReadString(eventItem, "targetRef") ?? string.Empty;
|
||||
var payloadSummary = ReadString(eventItem, "payloadSummary") ?? ReadString(eventItem, "summary") ?? string.Empty;
|
||||
|
||||
var title = $"{action} by {actor} on {module}";
|
||||
var body = string.Join('\n', new[]
|
||||
{
|
||||
title,
|
||||
$"action: {action}",
|
||||
$"actor: {actor}",
|
||||
$"module: {module}",
|
||||
$"targetRef: {targetRef}",
|
||||
$"timestamp: {timestamp:O}",
|
||||
$"summary: {payloadSummary}"
|
||||
});
|
||||
|
||||
var (entityKey, entityType) = ExtractEntity(targetRef);
|
||||
var chunkId = KnowledgeSearchText.StableId("timeline", tenant, eventId, KnowledgeSearchText.StableId(body));
|
||||
var docId = KnowledgeSearchText.StableId("timeline-doc", tenant, eventId);
|
||||
|
||||
var metadata = JsonDocument.Parse(JsonSerializer.Serialize(new
|
||||
{
|
||||
domain = Domain,
|
||||
tenant,
|
||||
eventId,
|
||||
action,
|
||||
actor,
|
||||
module,
|
||||
targetRef,
|
||||
payloadSummary,
|
||||
freshness = timestamp.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
|
||||
route = $"/ops/audit/events/{Uri.EscapeDataString(eventId)}",
|
||||
retentionDays = _unifiedOptions.Ingestion.TimelineRetentionDays
|
||||
}));
|
||||
|
||||
return new UnifiedChunk(
|
||||
chunkId,
|
||||
docId,
|
||||
Kind: "audit_event",
|
||||
Domain,
|
||||
title,
|
||||
body,
|
||||
_vectorEncoder.Encode(body),
|
||||
entityKey,
|
||||
entityType,
|
||||
Anchor: null,
|
||||
SectionPath: null,
|
||||
SpanStart: 0,
|
||||
SpanEnd: body.Length,
|
||||
timestamp,
|
||||
metadata);
|
||||
}
|
||||
|
||||
private string ResolvePath(string configuredPath)
|
||||
{
|
||||
if (Path.IsPathRooted(configuredPath))
|
||||
{
|
||||
return configuredPath;
|
||||
}
|
||||
|
||||
var root = string.IsNullOrWhiteSpace(_knowledgeOptions.RepositoryRoot) ? "." : _knowledgeOptions.RepositoryRoot;
|
||||
return Path.GetFullPath(Path.Combine(root, configuredPath));
|
||||
}
|
||||
|
||||
private static (string? EntityKey, string EntityType) ExtractEntity(string targetRef)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(targetRef))
|
||||
{
|
||||
return (null, "event");
|
||||
}
|
||||
|
||||
var cveMatch = CveRegex().Match(targetRef);
|
||||
if (cveMatch.Success)
|
||||
{
|
||||
var cve = cveMatch.Value.ToUpperInvariant();
|
||||
return ($"cve:{cve}", "finding");
|
||||
}
|
||||
|
||||
var purlMatch = PurlRegex().Match(targetRef);
|
||||
if (purlMatch.Success)
|
||||
{
|
||||
var purl = purlMatch.Value;
|
||||
return ($"purl:{purl}", "package");
|
||||
}
|
||||
|
||||
if (targetRef.Contains("policy", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ($"policy:{targetRef}", "policy_rule");
|
||||
}
|
||||
|
||||
return (null, "event");
|
||||
}
|
||||
|
||||
private static string? ReadString(JsonElement obj, string propertyName)
|
||||
{
|
||||
return obj.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.String
|
||||
? prop.GetString()?.Trim()
|
||||
: null;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ReadTimestamp(JsonElement obj, string propertyName)
|
||||
{
|
||||
var raw = ReadString(obj, propertyName);
|
||||
return raw is not null && DateTimeOffset.TryParse(raw, out var value) ? value : null;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"CVE-\d{4}-\d+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex CveRegex();
|
||||
|
||||
[GeneratedRegex(@"pkg:[^\s""']+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
|
||||
private static partial Regex PurlRegex();
|
||||
}
|
||||
@@ -180,6 +180,7 @@ internal sealed class VexSearchAdapter : ISearchIngestionAdapter
|
||||
var justification = ReadString(entry, "justification") ?? summary;
|
||||
var product = affectsKey;
|
||||
var tenant = ReadString(entry, "tenant") ?? "global";
|
||||
var tenantIdentity = NormalizeTenantForIdentity(tenant);
|
||||
var tags = ReadStringArray(entry, "tags", ["vex", "statement", status]);
|
||||
|
||||
var title = string.IsNullOrWhiteSpace(product)
|
||||
@@ -201,8 +202,10 @@ internal sealed class VexSearchAdapter : ISearchIngestionAdapter
|
||||
}
|
||||
|
||||
var body = string.Join("\n", bodyParts);
|
||||
var chunkId = KnowledgeSearchText.StableId("chunk", "vex_statement", statementId);
|
||||
var docId = KnowledgeSearchText.StableId("doc", "vex_statement", cveId);
|
||||
// Scope ids by tenant to prevent cross-tenant overwrite collisions
|
||||
// when statement ids are reused in different tenants.
|
||||
var chunkId = KnowledgeSearchText.StableId("chunk", "vex_statement", tenantIdentity, statementId);
|
||||
var docId = KnowledgeSearchText.StableId("doc", "vex_statement", tenantIdentity, cveId);
|
||||
var embedding = _vectorEncoder.Encode(body);
|
||||
|
||||
var freshness = ReadTimestamp(entry, "UpdatedAt") ?? ReadTimestamp(entry, "freshness");
|
||||
@@ -276,14 +279,17 @@ internal sealed class VexSearchAdapter : ISearchIngestionAdapter
|
||||
var justification = ReadString(entry, "justification") ?? string.Empty;
|
||||
var service = ReadString(entry, "service") ?? "vex-hub";
|
||||
var tenant = ReadString(entry, "tenant") ?? "global";
|
||||
var tenantIdentity = NormalizeTenantForIdentity(tenant);
|
||||
var tags = ReadStringArray(entry, "tags", ["vex", "statement", status]);
|
||||
|
||||
var title = $"VEX: {cveId} ({status})";
|
||||
var body = string.IsNullOrWhiteSpace(justification)
|
||||
? $"{title}\nStatus: {status}"
|
||||
: $"{title}\nStatus: {status}\nJustification: {justification}";
|
||||
var chunkId = KnowledgeSearchText.StableId("chunk", "vex_statement", statementId);
|
||||
var docId = KnowledgeSearchText.StableId("doc", "vex_statement", cveId);
|
||||
// Scope ids by tenant to prevent cross-tenant overwrite collisions
|
||||
// when statement ids are reused in different tenants.
|
||||
var chunkId = KnowledgeSearchText.StableId("chunk", "vex_statement", tenantIdentity, statementId);
|
||||
var docId = KnowledgeSearchText.StableId("doc", "vex_statement", tenantIdentity, cveId);
|
||||
var embedding = _vectorEncoder.Encode(body);
|
||||
var freshness = ReadTimestamp(entry, "freshness");
|
||||
|
||||
@@ -382,4 +388,11 @@ internal sealed class VexSearchAdapter : ISearchIngestionAdapter
|
||||
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string NormalizeTenantForIdentity(string tenant)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(tenant)
|
||||
? "global"
|
||||
: tenant.Trim().ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.UnifiedSearch.Analytics;
|
||||
|
||||
internal static class SearchAnalyticsPrivacy
|
||||
{
|
||||
public static string NormalizeHistoryQuery(string query)
|
||||
{
|
||||
return query.Trim();
|
||||
}
|
||||
|
||||
public static string HashQuery(string query)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
var normalized = query.Trim().ToLowerInvariant();
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(normalized));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static string? HashUserId(string tenantId, string? userId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalizedTenant = tenantId.Trim().ToLowerInvariant();
|
||||
var normalizedUser = userId.Trim().ToLowerInvariant();
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"{normalizedTenant}|{normalizedUser}"));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static string? RedactFreeform(string? value)
|
||||
{
|
||||
_ = value;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.UnifiedSearch.Analytics;
|
||||
|
||||
internal sealed class SearchAnalyticsRetentionBackgroundService : BackgroundService
|
||||
{
|
||||
private readonly KnowledgeSearchOptions _options;
|
||||
private readonly SearchAnalyticsService _analyticsService;
|
||||
private readonly SearchQualityMonitor _qualityMonitor;
|
||||
private readonly ILogger<SearchAnalyticsRetentionBackgroundService> _logger;
|
||||
|
||||
public SearchAnalyticsRetentionBackgroundService(
|
||||
IOptions<KnowledgeSearchOptions> options,
|
||||
SearchAnalyticsService analyticsService,
|
||||
SearchQualityMonitor qualityMonitor,
|
||||
ILogger<SearchAnalyticsRetentionBackgroundService> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_analyticsService = analyticsService;
|
||||
_qualityMonitor = qualityMonitor;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (!_options.SearchAnalyticsRetentionEnabled)
|
||||
{
|
||||
_logger.LogDebug("Search analytics retention loop is disabled.");
|
||||
return;
|
||||
}
|
||||
|
||||
var retentionDays = Math.Max(1, _options.SearchAnalyticsRetentionDays);
|
||||
var interval = TimeSpan.FromSeconds(Math.Max(30, _options.SearchAnalyticsRetentionIntervalSeconds));
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var analytics = await _analyticsService
|
||||
.PruneExpiredAsync(retentionDays, stoppingToken)
|
||||
.ConfigureAwait(false);
|
||||
var quality = await _qualityMonitor
|
||||
.PruneExpiredAsync(retentionDays, stoppingToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Search retention prune completed. events={Events}, history={History}, feedback={Feedback}, alerts={Alerts}, cutoff={CutoffUtc:O}",
|
||||
analytics.EventsDeleted,
|
||||
analytics.HistoryDeleted,
|
||||
quality.FeedbackDeleted,
|
||||
quality.AlertsDeleted,
|
||||
analytics.CutoffUtc);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Search analytics retention prune failed.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(interval, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ internal sealed class SearchAnalyticsService
|
||||
private readonly ILogger<SearchAnalyticsService> _logger;
|
||||
private readonly object _fallbackLock = new();
|
||||
private readonly List<(SearchAnalyticsEvent Event, DateTimeOffset RecordedAt)> _fallbackEvents = [];
|
||||
private readonly Dictionary<(string TenantId, string UserId, string Query), SearchHistoryEntry> _fallbackHistory = new();
|
||||
private readonly Dictionary<(string TenantId, string UserKey, string Query), SearchHistoryEntry> _fallbackHistory = new();
|
||||
|
||||
public SearchAnalyticsService(
|
||||
IOptions<KnowledgeSearchOptions> options,
|
||||
@@ -24,9 +24,10 @@ internal sealed class SearchAnalyticsService
|
||||
public async Task RecordEventAsync(SearchAnalyticsEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
var recordedAt = DateTimeOffset.UtcNow;
|
||||
var persistedEvent = SanitizeEvent(evt);
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
RecordFallbackEvent(evt, recordedAt);
|
||||
RecordFallbackEvent(persistedEvent, recordedAt);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -39,23 +40,23 @@ internal sealed class SearchAnalyticsService
|
||||
INSERT INTO advisoryai.search_events (tenant_id, user_id, event_type, query, entity_key, domain, result_count, position, duration_ms)
|
||||
VALUES (@tenant_id, @user_id, @event_type, @query, @entity_key, @domain, @result_count, @position, @duration_ms)", conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("tenant_id", evt.TenantId);
|
||||
cmd.Parameters.AddWithValue("user_id", (object?)evt.UserId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("event_type", evt.EventType);
|
||||
cmd.Parameters.AddWithValue("query", evt.Query);
|
||||
cmd.Parameters.AddWithValue("entity_key", (object?)evt.EntityKey ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("domain", (object?)evt.Domain ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("result_count", (object?)evt.ResultCount ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("position", (object?)evt.Position ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("duration_ms", (object?)evt.DurationMs ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("tenant_id", persistedEvent.TenantId);
|
||||
cmd.Parameters.AddWithValue("user_id", (object?)persistedEvent.UserId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("event_type", persistedEvent.EventType);
|
||||
cmd.Parameters.AddWithValue("query", persistedEvent.Query);
|
||||
cmd.Parameters.AddWithValue("entity_key", (object?)persistedEvent.EntityKey ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("domain", (object?)persistedEvent.Domain ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("result_count", (object?)persistedEvent.ResultCount ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("position", (object?)persistedEvent.Position ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("duration_ms", (object?)persistedEvent.DurationMs ?? DBNull.Value);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
RecordFallbackEvent(evt, recordedAt);
|
||||
RecordFallbackEvent(persistedEvent, recordedAt);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to record search analytics event");
|
||||
RecordFallbackEvent(evt, recordedAt);
|
||||
RecordFallbackEvent(persistedEvent, recordedAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +72,7 @@ internal sealed class SearchAnalyticsService
|
||||
{
|
||||
foreach (var evt in events)
|
||||
{
|
||||
RecordFallbackEvent(evt, recordedAt);
|
||||
RecordFallbackEvent(SanitizeEvent(evt), recordedAt);
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -84,22 +85,24 @@ internal sealed class SearchAnalyticsService
|
||||
|
||||
foreach (var evt in events)
|
||||
{
|
||||
var persistedEvent = SanitizeEvent(evt);
|
||||
|
||||
await using var cmd = new NpgsqlCommand(@"
|
||||
INSERT INTO advisoryai.search_events (tenant_id, user_id, event_type, query, entity_key, domain, result_count, position, duration_ms)
|
||||
VALUES (@tenant_id, @user_id, @event_type, @query, @entity_key, @domain, @result_count, @position, @duration_ms)", conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("tenant_id", evt.TenantId);
|
||||
cmd.Parameters.AddWithValue("user_id", (object?)evt.UserId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("event_type", evt.EventType);
|
||||
cmd.Parameters.AddWithValue("query", evt.Query);
|
||||
cmd.Parameters.AddWithValue("entity_key", (object?)evt.EntityKey ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("domain", (object?)evt.Domain ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("result_count", (object?)evt.ResultCount ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("position", (object?)evt.Position ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("duration_ms", (object?)evt.DurationMs ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("tenant_id", persistedEvent.TenantId);
|
||||
cmd.Parameters.AddWithValue("user_id", (object?)persistedEvent.UserId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("event_type", persistedEvent.EventType);
|
||||
cmd.Parameters.AddWithValue("query", persistedEvent.Query);
|
||||
cmd.Parameters.AddWithValue("entity_key", (object?)persistedEvent.EntityKey ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("domain", (object?)persistedEvent.Domain ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("result_count", (object?)persistedEvent.ResultCount ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("position", (object?)persistedEvent.Position ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("duration_ms", (object?)persistedEvent.DurationMs ?? DBNull.Value);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
RecordFallbackEvent(evt, recordedAt);
|
||||
RecordFallbackEvent(persistedEvent, recordedAt);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -107,7 +110,7 @@ internal sealed class SearchAnalyticsService
|
||||
_logger.LogWarning(ex, "Failed to record search analytics events batch ({Count} events)", events.Count);
|
||||
foreach (var evt in events)
|
||||
{
|
||||
RecordFallbackEvent(evt, recordedAt);
|
||||
RecordFallbackEvent(SanitizeEvent(evt), recordedAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,9 +161,21 @@ internal sealed class SearchAnalyticsService
|
||||
public async Task RecordHistoryAsync(string tenantId, string userId, string query, int resultCount, CancellationToken ct = default)
|
||||
{
|
||||
var recordedAt = DateTimeOffset.UtcNow;
|
||||
var userKey = SearchAnalyticsPrivacy.HashUserId(tenantId, userId);
|
||||
if (string.IsNullOrWhiteSpace(userKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var normalizedQuery = SearchAnalyticsPrivacy.NormalizeHistoryQuery(query);
|
||||
if (string.IsNullOrWhiteSpace(normalizedQuery))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
RecordFallbackHistory(tenantId, userId, query, resultCount, recordedAt);
|
||||
RecordFallbackHistory(tenantId, userKey, normalizedQuery, resultCount, recordedAt);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -177,8 +192,8 @@ internal sealed class SearchAnalyticsService
|
||||
result_count = @result_count", conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
cmd.Parameters.AddWithValue("user_id", userId);
|
||||
cmd.Parameters.AddWithValue("query", query);
|
||||
cmd.Parameters.AddWithValue("user_id", userKey);
|
||||
cmd.Parameters.AddWithValue("query", normalizedQuery);
|
||||
cmd.Parameters.AddWithValue("result_count", resultCount);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
@@ -193,22 +208,28 @@ internal sealed class SearchAnalyticsService
|
||||
OFFSET 50
|
||||
)", conn);
|
||||
trimCmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
trimCmd.Parameters.AddWithValue("user_id", userId);
|
||||
trimCmd.Parameters.AddWithValue("user_id", userKey);
|
||||
await trimCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
RecordFallbackHistory(tenantId, userId, query, resultCount, recordedAt);
|
||||
RecordFallbackHistory(tenantId, userKey, normalizedQuery, resultCount, recordedAt);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to record search history");
|
||||
RecordFallbackHistory(tenantId, userId, query, resultCount, recordedAt);
|
||||
RecordFallbackHistory(tenantId, userKey, normalizedQuery, resultCount, recordedAt);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SearchHistoryEntry>> GetHistoryAsync(string tenantId, string userId, int limit = 50, CancellationToken ct = default)
|
||||
{
|
||||
var userKey = SearchAnalyticsPrivacy.HashUserId(tenantId, userId);
|
||||
if (string.IsNullOrWhiteSpace(userKey))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
return GetFallbackHistory(tenantId, userId, limit);
|
||||
return GetFallbackHistory(tenantId, userKey, limit);
|
||||
}
|
||||
|
||||
var entries = new List<SearchHistoryEntry>();
|
||||
@@ -226,7 +247,7 @@ internal sealed class SearchAnalyticsService
|
||||
LIMIT @limit", conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
cmd.Parameters.AddWithValue("user_id", userId);
|
||||
cmd.Parameters.AddWithValue("user_id", userKey);
|
||||
cmd.Parameters.AddWithValue("limit", limit);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
@@ -242,7 +263,7 @@ internal sealed class SearchAnalyticsService
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load search history");
|
||||
return GetFallbackHistory(tenantId, userId, limit);
|
||||
return GetFallbackHistory(tenantId, userKey, limit);
|
||||
}
|
||||
|
||||
return entries;
|
||||
@@ -250,9 +271,15 @@ internal sealed class SearchAnalyticsService
|
||||
|
||||
public async Task ClearHistoryAsync(string tenantId, string userId, CancellationToken ct = default)
|
||||
{
|
||||
var userKey = SearchAnalyticsPrivacy.HashUserId(tenantId, userId);
|
||||
if (string.IsNullOrWhiteSpace(userKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
ClearFallbackHistory(tenantId, userId);
|
||||
ClearFallbackHistory(tenantId, userKey);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -266,15 +293,15 @@ internal sealed class SearchAnalyticsService
|
||||
WHERE tenant_id = @tenant_id AND user_id = @user_id", conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
cmd.Parameters.AddWithValue("user_id", userId);
|
||||
cmd.Parameters.AddWithValue("user_id", userKey);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
ClearFallbackHistory(tenantId, userId);
|
||||
ClearFallbackHistory(tenantId, userKey);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to clear search history");
|
||||
ClearFallbackHistory(tenantId, userId);
|
||||
ClearFallbackHistory(tenantId, userKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,7 +351,7 @@ internal sealed class SearchAnalyticsService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to find similar successful queries for '{Query}'", query);
|
||||
_logger.LogWarning(ex, "Failed to find similar successful queries for query hash {QueryHash}", SearchAnalyticsPrivacy.HashQuery(query));
|
||||
return FindFallbackSimilarQueries(tenantId, query, limit);
|
||||
}
|
||||
|
||||
@@ -333,9 +360,15 @@ internal sealed class SearchAnalyticsService
|
||||
|
||||
public async Task DeleteHistoryEntryAsync(string tenantId, string userId, string historyId, CancellationToken ct = default)
|
||||
{
|
||||
var userKey = SearchAnalyticsPrivacy.HashUserId(tenantId, userId);
|
||||
if (string.IsNullOrWhiteSpace(userKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
DeleteFallbackHistoryEntry(tenantId, userId, historyId);
|
||||
DeleteFallbackHistoryEntry(tenantId, userKey, historyId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -351,19 +384,84 @@ internal sealed class SearchAnalyticsService
|
||||
WHERE tenant_id = @tenant_id AND user_id = @user_id AND history_id = @history_id", conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
cmd.Parameters.AddWithValue("user_id", userId);
|
||||
cmd.Parameters.AddWithValue("user_id", userKey);
|
||||
cmd.Parameters.AddWithValue("history_id", Guid.Parse(historyId));
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
DeleteFallbackHistoryEntry(tenantId, userId, historyId);
|
||||
DeleteFallbackHistoryEntry(tenantId, userKey, historyId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete search history entry");
|
||||
DeleteFallbackHistoryEntry(tenantId, userId, historyId);
|
||||
DeleteFallbackHistoryEntry(tenantId, userKey, historyId);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SearchAnalyticsPruneResult> PruneExpiredAsync(int retentionDays, CancellationToken ct = default)
|
||||
{
|
||||
var days = Math.Max(0, retentionDays);
|
||||
var cutoff = DateTimeOffset.UtcNow - TimeSpan.FromDays(days);
|
||||
|
||||
var eventsDeleted = 0;
|
||||
var historyDeleted = 0;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using (var eventsCmd = new NpgsqlCommand(@"
|
||||
DELETE FROM advisoryai.search_events
|
||||
WHERE created_at < @cutoff", conn))
|
||||
{
|
||||
eventsCmd.Parameters.AddWithValue("cutoff", cutoff.UtcDateTime);
|
||||
eventsDeleted = await eventsCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await using (var historyCmd = new NpgsqlCommand(@"
|
||||
DELETE FROM advisoryai.search_history
|
||||
WHERE searched_at < @cutoff", conn))
|
||||
{
|
||||
historyCmd.Parameters.AddWithValue("cutoff", cutoff.UtcDateTime);
|
||||
historyDeleted = await historyCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to prune search analytics/history by retention window.");
|
||||
}
|
||||
}
|
||||
|
||||
var fallbackEventsDeleted = 0;
|
||||
var fallbackHistoryDeleted = 0;
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
var eventBefore = _fallbackEvents.Count;
|
||||
_fallbackEvents.RemoveAll(item => item.RecordedAt < cutoff);
|
||||
fallbackEventsDeleted = eventBefore - _fallbackEvents.Count;
|
||||
|
||||
var historyBefore = _fallbackHistory.Count;
|
||||
var expiredKeys = _fallbackHistory
|
||||
.Where(item => item.Value.SearchedAt < cutoff.UtcDateTime)
|
||||
.Select(item => item.Key)
|
||||
.ToArray();
|
||||
|
||||
foreach (var key in expiredKeys)
|
||||
{
|
||||
_fallbackHistory.Remove(key);
|
||||
}
|
||||
|
||||
fallbackHistoryDeleted = historyBefore - _fallbackHistory.Count;
|
||||
}
|
||||
|
||||
return new SearchAnalyticsPruneResult(
|
||||
EventsDeleted: eventsDeleted + fallbackEventsDeleted,
|
||||
HistoryDeleted: historyDeleted + fallbackHistoryDeleted,
|
||||
CutoffUtc: cutoff.UtcDateTime);
|
||||
}
|
||||
|
||||
internal IReadOnlyList<(SearchAnalyticsEvent Event, DateTimeOffset RecordedAt)> GetFallbackEventsSnapshot(
|
||||
string tenantId,
|
||||
TimeSpan window)
|
||||
@@ -407,14 +505,14 @@ internal sealed class SearchAnalyticsService
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<SearchHistoryEntry> GetFallbackHistory(string tenantId, string userId, int limit)
|
||||
private IReadOnlyList<SearchHistoryEntry> GetFallbackHistory(string tenantId, string userKey, int limit)
|
||||
{
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
return _fallbackHistory
|
||||
.Where(item =>
|
||||
item.Key.TenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase) &&
|
||||
item.Key.UserId.Equals(userId, StringComparison.OrdinalIgnoreCase))
|
||||
item.Key.UserKey.Equals(userKey, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(item => item.Value)
|
||||
.OrderByDescending(entry => entry.SearchedAt)
|
||||
.Take(Math.Max(1, limit))
|
||||
@@ -434,16 +532,16 @@ internal sealed class SearchAnalyticsService
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordFallbackHistory(string tenantId, string userId, string query, int resultCount, DateTimeOffset recordedAt)
|
||||
private void RecordFallbackHistory(string tenantId, string userKey, string query, int resultCount, DateTimeOffset recordedAt)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(query))
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(userKey) || string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var normalizedQuery = query.Trim();
|
||||
(string TenantId, string UserId, string Query) key = (tenantId, userId, normalizedQuery);
|
||||
var historyId = BuildFallbackHistoryId(tenantId, userId, normalizedQuery);
|
||||
(string TenantId, string UserKey, string Query) key = (tenantId, userKey, normalizedQuery);
|
||||
var historyId = BuildFallbackHistoryId(tenantId, userKey, normalizedQuery);
|
||||
var entry = new SearchHistoryEntry(historyId, normalizedQuery, resultCount, recordedAt.UtcDateTime);
|
||||
|
||||
lock (_fallbackLock)
|
||||
@@ -451,7 +549,7 @@ internal sealed class SearchAnalyticsService
|
||||
_fallbackHistory[key] = entry;
|
||||
|
||||
var overflow = _fallbackHistory.Keys
|
||||
.Where(k => k.TenantId == key.TenantId && k.UserId == key.UserId)
|
||||
.Where(k => k.TenantId == key.TenantId && k.UserKey == key.UserKey)
|
||||
.Select(k => (Key: k, Entry: _fallbackHistory[k]))
|
||||
.OrderByDescending(item => item.Entry.SearchedAt)
|
||||
.Skip(50)
|
||||
@@ -465,12 +563,12 @@ internal sealed class SearchAnalyticsService
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearFallbackHistory(string tenantId, string userId)
|
||||
private void ClearFallbackHistory(string tenantId, string userKey)
|
||||
{
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
var keys = _fallbackHistory.Keys
|
||||
.Where(key => key.TenantId == tenantId && key.UserId == userId)
|
||||
.Where(key => key.TenantId == tenantId && key.UserKey == userKey)
|
||||
.ToArray();
|
||||
|
||||
foreach (var key in keys)
|
||||
@@ -480,7 +578,7 @@ internal sealed class SearchAnalyticsService
|
||||
}
|
||||
}
|
||||
|
||||
private void DeleteFallbackHistoryEntry(string tenantId, string userId, string historyId)
|
||||
private void DeleteFallbackHistoryEntry(string tenantId, string userKey, string historyId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(historyId))
|
||||
{
|
||||
@@ -492,8 +590,8 @@ internal sealed class SearchAnalyticsService
|
||||
var hit = _fallbackHistory.Keys
|
||||
.FirstOrDefault(key =>
|
||||
key.TenantId == tenantId &&
|
||||
key.UserId == userId &&
|
||||
BuildFallbackHistoryId(key.TenantId, key.UserId, key.Query).Equals(historyId, StringComparison.Ordinal));
|
||||
key.UserKey == userKey &&
|
||||
BuildFallbackHistoryId(key.TenantId, key.UserKey, key.Query).Equals(historyId, StringComparison.Ordinal));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(hit.TenantId))
|
||||
{
|
||||
@@ -526,15 +624,24 @@ internal sealed class SearchAnalyticsService
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildFallbackHistoryId(string tenantId, string userId, string query)
|
||||
private static string BuildFallbackHistoryId(string tenantId, string userKey, string query)
|
||||
{
|
||||
var normalizedQuery = query.Trim().ToLowerInvariant();
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(
|
||||
System.Text.Encoding.UTF8.GetBytes($"{tenantId}|{userId}|{normalizedQuery}"));
|
||||
System.Text.Encoding.UTF8.GetBytes($"{tenantId}|{userKey}|{normalizedQuery}"));
|
||||
var guidBytes = hash[..16];
|
||||
return new Guid(guidBytes).ToString("D");
|
||||
}
|
||||
|
||||
private static SearchAnalyticsEvent SanitizeEvent(SearchAnalyticsEvent evt)
|
||||
{
|
||||
return evt with
|
||||
{
|
||||
Query = SearchAnalyticsPrivacy.HashQuery(evt.Query),
|
||||
UserId = SearchAnalyticsPrivacy.HashUserId(evt.TenantId, evt.UserId)
|
||||
};
|
||||
}
|
||||
|
||||
private static double ComputeTokenSimilarity(string a, string b)
|
||||
{
|
||||
var left = a.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
@@ -571,3 +678,8 @@ internal record SearchHistoryEntry(
|
||||
string Query,
|
||||
int? ResultCount,
|
||||
DateTime SearchedAt);
|
||||
|
||||
internal sealed record SearchAnalyticsPruneResult(
|
||||
int EventsDeleted,
|
||||
int HistoryDeleted,
|
||||
DateTime CutoffUtc);
|
||||
|
||||
@@ -41,9 +41,10 @@ internal sealed class SearchQualityMonitor
|
||||
public async Task StoreFeedbackAsync(SearchFeedbackEntry entry, CancellationToken ct = default)
|
||||
{
|
||||
var createdAt = DateTimeOffset.UtcNow;
|
||||
var persistedEntry = SanitizeFeedbackEntry(entry);
|
||||
if (string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
StoreFallbackFeedback(entry, createdAt);
|
||||
StoreFallbackFeedback(persistedEntry, createdAt);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -58,22 +59,22 @@ internal sealed class SearchQualityMonitor
|
||||
VALUES
|
||||
(@tenant_id, @user_id, @query, @entity_key, @domain, @position, @signal, @comment)", conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("tenant_id", entry.TenantId);
|
||||
cmd.Parameters.AddWithValue("user_id", (object?)entry.UserId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("query", entry.Query);
|
||||
cmd.Parameters.AddWithValue("entity_key", entry.EntityKey);
|
||||
cmd.Parameters.AddWithValue("domain", entry.Domain);
|
||||
cmd.Parameters.AddWithValue("position", entry.Position);
|
||||
cmd.Parameters.AddWithValue("signal", entry.Signal);
|
||||
cmd.Parameters.AddWithValue("comment", (object?)entry.Comment ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("tenant_id", persistedEntry.TenantId);
|
||||
cmd.Parameters.AddWithValue("user_id", (object?)persistedEntry.UserId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("query", persistedEntry.Query);
|
||||
cmd.Parameters.AddWithValue("entity_key", persistedEntry.EntityKey);
|
||||
cmd.Parameters.AddWithValue("domain", persistedEntry.Domain);
|
||||
cmd.Parameters.AddWithValue("position", persistedEntry.Position);
|
||||
cmd.Parameters.AddWithValue("signal", persistedEntry.Signal);
|
||||
cmd.Parameters.AddWithValue("comment", (object?)persistedEntry.Comment ?? DBNull.Value);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
StoreFallbackFeedback(entry, createdAt);
|
||||
StoreFallbackFeedback(persistedEntry, createdAt);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to store search feedback");
|
||||
StoreFallbackFeedback(entry, createdAt);
|
||||
StoreFallbackFeedback(persistedEntry, createdAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,6 +421,11 @@ internal sealed class SearchQualityMonitor
|
||||
{
|
||||
metrics.FeedbackScore = Math.Round(feedbackReader.GetDouble(0) * 100, 1);
|
||||
}
|
||||
await feedbackReader.CloseAsync().ConfigureAwait(false);
|
||||
|
||||
metrics.LowQualityResults = await LoadLowQualityResultsAsync(conn, tenantId, days, ct).ConfigureAwait(false);
|
||||
metrics.TopQueries = await LoadTopQueriesAsync(conn, tenantId, days, ct).ConfigureAwait(false);
|
||||
metrics.Trend = await LoadTrendPointsAsync(conn, tenantId, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -474,9 +480,284 @@ internal sealed class SearchQualityMonitor
|
||||
AvgResultCount = Math.Round(avgResultCount, 1),
|
||||
FeedbackScore = Math.Round(feedbackScore, 1),
|
||||
Period = period,
|
||||
LowQualityResults = BuildFallbackLowQualityRows(tenantId, window),
|
||||
TopQueries = BuildFallbackTopQueries(tenantId, window),
|
||||
Trend = BuildFallbackTrendPoints(tenantId),
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<SearchQualityLowQualityRow>> LoadLowQualityResultsAsync(
|
||||
NpgsqlConnection conn,
|
||||
string tenantId,
|
||||
int days,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var rows = new List<SearchQualityLowQualityRow>();
|
||||
await using var cmd = new NpgsqlCommand(@"
|
||||
SELECT
|
||||
entity_key,
|
||||
COALESCE(NULLIF(domain, ''), 'unknown') AS domain,
|
||||
COUNT(*) FILTER (WHERE signal = 'not_helpful')::int AS negative_feedback_count,
|
||||
COUNT(*)::int AS total_feedback
|
||||
FROM advisoryai.search_feedback
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND created_at > now() - make_interval(days => @days)
|
||||
GROUP BY entity_key, domain
|
||||
HAVING COUNT(*) > 0
|
||||
ORDER BY negative_feedback_count DESC, total_feedback DESC, entity_key ASC
|
||||
LIMIT 20", conn);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
cmd.Parameters.AddWithValue("days", days);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
var negative = reader.GetInt32(2);
|
||||
var total = reader.GetInt32(3);
|
||||
rows.Add(new SearchQualityLowQualityRow
|
||||
{
|
||||
EntityKey = reader.GetString(0),
|
||||
Domain = reader.GetString(1),
|
||||
NegativeFeedbackCount = negative,
|
||||
TotalFeedback = total,
|
||||
NegativeRate = total == 0 ? 0d : Math.Round((double)negative / total * 100d, 1),
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<SearchQualityTopQueryRow>> LoadTopQueriesAsync(
|
||||
NpgsqlConnection conn,
|
||||
string tenantId,
|
||||
int days,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var rows = new List<SearchQualityTopQueryRow>();
|
||||
await using var cmd = new NpgsqlCommand(@"
|
||||
WITH query_stats AS (
|
||||
SELECT
|
||||
query,
|
||||
COUNT(*) FILTER (WHERE event_type IN ('query', 'zero_result'))::int AS total_searches,
|
||||
COALESCE(
|
||||
AVG(result_count) FILTER (WHERE event_type IN ('query', 'zero_result') AND result_count IS NOT NULL),
|
||||
0
|
||||
) AS avg_result_count
|
||||
FROM advisoryai.search_events
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND created_at > now() - make_interval(days => @days)
|
||||
AND query IS NOT NULL
|
||||
AND btrim(query) <> ''
|
||||
GROUP BY query
|
||||
),
|
||||
feedback_stats AS (
|
||||
SELECT
|
||||
lower(query) AS query_key,
|
||||
COALESCE(AVG(CASE WHEN signal = 'helpful' THEN 1.0 ELSE 0.0 END), 0) AS feedback_score
|
||||
FROM advisoryai.search_feedback
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND created_at > now() - make_interval(days => @days)
|
||||
AND query IS NOT NULL
|
||||
AND btrim(query) <> ''
|
||||
GROUP BY lower(query)
|
||||
)
|
||||
SELECT
|
||||
q.query,
|
||||
q.total_searches,
|
||||
q.avg_result_count,
|
||||
COALESCE(f.feedback_score, 0) AS feedback_score
|
||||
FROM query_stats q
|
||||
LEFT JOIN feedback_stats f ON f.query_key = lower(q.query)
|
||||
ORDER BY q.total_searches DESC, q.query ASC
|
||||
LIMIT 20", conn);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
cmd.Parameters.AddWithValue("days", days);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
rows.Add(new SearchQualityTopQueryRow
|
||||
{
|
||||
Query = reader.GetString(0),
|
||||
TotalSearches = reader.GetInt32(1),
|
||||
AvgResultCount = Math.Round(reader.GetDouble(2), 1),
|
||||
FeedbackScore = Math.Round(reader.GetDouble(3) * 100d, 1),
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<SearchQualityTrendPoint>> LoadTrendPointsAsync(
|
||||
NpgsqlConnection conn,
|
||||
string tenantId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var points = new List<SearchQualityTrendPoint>();
|
||||
await using var cmd = new NpgsqlCommand(@"
|
||||
WITH days AS (
|
||||
SELECT generate_series(
|
||||
date_trunc('day', now()) - interval '29 days',
|
||||
date_trunc('day', now()),
|
||||
interval '1 day'
|
||||
) AS day
|
||||
),
|
||||
event_agg AS (
|
||||
SELECT
|
||||
date_trunc('day', created_at) AS day,
|
||||
COUNT(*) FILTER (WHERE event_type IN ('query', 'zero_result'))::int AS total_searches,
|
||||
COUNT(*) FILTER (WHERE event_type = 'zero_result')::int AS zero_results
|
||||
FROM advisoryai.search_events
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND created_at >= date_trunc('day', now()) - interval '29 days'
|
||||
GROUP BY date_trunc('day', created_at)
|
||||
),
|
||||
feedback_agg AS (
|
||||
SELECT
|
||||
date_trunc('day', created_at) AS day,
|
||||
COALESCE(AVG(CASE WHEN signal = 'helpful' THEN 1.0 ELSE 0.0 END), 0) AS feedback_score
|
||||
FROM advisoryai.search_feedback
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND created_at >= date_trunc('day', now()) - interval '29 days'
|
||||
GROUP BY date_trunc('day', created_at)
|
||||
)
|
||||
SELECT
|
||||
d.day::date,
|
||||
COALESCE(e.total_searches, 0) AS total_searches,
|
||||
COALESCE(e.zero_results, 0) AS zero_results,
|
||||
COALESCE(f.feedback_score, 0) AS feedback_score
|
||||
FROM days d
|
||||
LEFT JOIN event_agg e ON e.day = d.day
|
||||
LEFT JOIN feedback_agg f ON f.day = d.day
|
||||
ORDER BY d.day ASC", conn);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
var day = reader.GetDateTime(0);
|
||||
var totalSearches = reader.GetInt32(1);
|
||||
var zeroResults = reader.GetInt32(2);
|
||||
var feedbackScoreRaw = reader.GetDouble(3);
|
||||
points.Add(new SearchQualityTrendPoint
|
||||
{
|
||||
Day = day,
|
||||
TotalSearches = totalSearches,
|
||||
ZeroResultRate = totalSearches == 0 ? 0d : Math.Round((double)zeroResults / totalSearches * 100d, 1),
|
||||
FeedbackScore = Math.Round(feedbackScoreRaw * 100d, 1),
|
||||
});
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
private IReadOnlyList<SearchQualityLowQualityRow> BuildFallbackLowQualityRows(string tenantId, TimeSpan window)
|
||||
{
|
||||
return GetFallbackFeedback(tenantId, window)
|
||||
.GroupBy(item => $"{item.Entry.EntityKey}|{item.Entry.Domain}", StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group =>
|
||||
{
|
||||
var total = group.Count();
|
||||
var negative = group.Count(item => item.Entry.Signal.Equals("not_helpful", StringComparison.Ordinal));
|
||||
var exemplar = group.First();
|
||||
return new SearchQualityLowQualityRow
|
||||
{
|
||||
EntityKey = exemplar.Entry.EntityKey,
|
||||
Domain = exemplar.Entry.Domain,
|
||||
NegativeFeedbackCount = negative,
|
||||
TotalFeedback = total,
|
||||
NegativeRate = total == 0 ? 0d : Math.Round((double)negative / total * 100d, 1),
|
||||
};
|
||||
})
|
||||
.Where(row => row.TotalFeedback > 0)
|
||||
.OrderByDescending(row => row.NegativeFeedbackCount)
|
||||
.ThenByDescending(row => row.TotalFeedback)
|
||||
.ThenBy(row => row.EntityKey, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(20)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private IReadOnlyList<SearchQualityTopQueryRow> BuildFallbackTopQueries(string tenantId, TimeSpan window)
|
||||
{
|
||||
var feedbackByQuery = GetFallbackFeedback(tenantId, window)
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Entry.Query))
|
||||
.GroupBy(item => item.Entry.Query.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group =>
|
||||
{
|
||||
var total = group.Count();
|
||||
var helpful = group.Count(item => item.Entry.Signal.Equals("helpful", StringComparison.Ordinal));
|
||||
return total == 0 ? 0d : (double)helpful / total * 100d;
|
||||
},
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return _analyticsService.GetFallbackEventsSnapshot(tenantId, window)
|
||||
.Select(item => item.Event)
|
||||
.Where(evt =>
|
||||
(evt.EventType.Equals("query", StringComparison.OrdinalIgnoreCase) ||
|
||||
evt.EventType.Equals("zero_result", StringComparison.OrdinalIgnoreCase)) &&
|
||||
!string.IsNullOrWhiteSpace(evt.Query))
|
||||
.GroupBy(evt => evt.Query.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group =>
|
||||
{
|
||||
var avgResults = group.Where(evt => evt.ResultCount.HasValue)
|
||||
.Select(evt => evt.ResultCount!.Value)
|
||||
.DefaultIfEmpty(0)
|
||||
.Average();
|
||||
var feedbackScore = feedbackByQuery.TryGetValue(group.Key, out var score) ? score : 0d;
|
||||
return new SearchQualityTopQueryRow
|
||||
{
|
||||
Query = group.Key,
|
||||
TotalSearches = group.Count(),
|
||||
AvgResultCount = Math.Round(avgResults, 1),
|
||||
FeedbackScore = Math.Round(feedbackScore, 1),
|
||||
};
|
||||
})
|
||||
.OrderByDescending(row => row.TotalSearches)
|
||||
.ThenBy(row => row.Query, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(20)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private IReadOnlyList<SearchQualityTrendPoint> BuildFallbackTrendPoints(string tenantId)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var start = now.Date.AddDays(-29);
|
||||
var window = TimeSpan.FromDays(30);
|
||||
var events = _analyticsService.GetFallbackEventsSnapshot(tenantId, window)
|
||||
.Where(item => item.Event.EventType.Equals("query", StringComparison.OrdinalIgnoreCase) ||
|
||||
item.Event.EventType.Equals("zero_result", StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
var feedback = GetFallbackFeedback(tenantId, window);
|
||||
var points = new List<SearchQualityTrendPoint>(30);
|
||||
|
||||
for (var i = 0; i < 30; i++)
|
||||
{
|
||||
var day = start.AddDays(i);
|
||||
var nextDay = day.AddDays(1);
|
||||
var dayEvents = events
|
||||
.Where(evt => evt.RecordedAt.UtcDateTime >= day && evt.RecordedAt.UtcDateTime < nextDay)
|
||||
.ToArray();
|
||||
var totalSearches = dayEvents.Length;
|
||||
var zeroResults = dayEvents.Count(evt => evt.Event.EventType.Equals("zero_result", StringComparison.OrdinalIgnoreCase));
|
||||
var dayFeedback = feedback
|
||||
.Where(item => item.CreatedAt.UtcDateTime >= day && item.CreatedAt.UtcDateTime < nextDay)
|
||||
.Select(item => item.Entry.Signal)
|
||||
.ToArray();
|
||||
var helpful = dayFeedback.Count(signal => signal.Equals("helpful", StringComparison.Ordinal));
|
||||
points.Add(new SearchQualityTrendPoint
|
||||
{
|
||||
Day = day,
|
||||
TotalSearches = totalSearches,
|
||||
ZeroResultRate = totalSearches == 0 ? 0d : Math.Round((double)zeroResults / totalSearches * 100d, 1),
|
||||
FeedbackScore = dayFeedback.Length == 0 ? 0d : Math.Round((double)helpful / dayFeedback.Length * 100d, 1),
|
||||
});
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<AlertCandidate>> LoadZeroResultCandidatesAsync(
|
||||
string tenantId,
|
||||
TimeSpan window,
|
||||
@@ -716,6 +997,22 @@ internal sealed class SearchQualityMonitor
|
||||
}
|
||||
}
|
||||
|
||||
private static SearchFeedbackEntry SanitizeFeedbackEntry(SearchFeedbackEntry entry)
|
||||
{
|
||||
var normalizedQuery = entry.Query.Trim();
|
||||
return new SearchFeedbackEntry
|
||||
{
|
||||
TenantId = entry.TenantId,
|
||||
UserId = SearchAnalyticsPrivacy.HashUserId(entry.TenantId, entry.UserId),
|
||||
Query = SearchAnalyticsPrivacy.HashQuery(normalizedQuery),
|
||||
EntityKey = entry.EntityKey,
|
||||
Domain = entry.Domain,
|
||||
Position = entry.Position,
|
||||
Signal = entry.Signal,
|
||||
Comment = SearchAnalyticsPrivacy.RedactFreeform(entry.Comment),
|
||||
};
|
||||
}
|
||||
|
||||
private void StoreFallbackFeedback(SearchFeedbackEntry entry, DateTimeOffset createdAt)
|
||||
{
|
||||
lock (_fallbackLock)
|
||||
@@ -741,6 +1038,13 @@ internal sealed class SearchQualityMonitor
|
||||
}
|
||||
}
|
||||
|
||||
internal IReadOnlyList<(SearchFeedbackEntry Entry, DateTimeOffset CreatedAt)> GetFallbackFeedbackSnapshot(
|
||||
string tenantId,
|
||||
TimeSpan window)
|
||||
{
|
||||
return GetFallbackFeedback(tenantId, window);
|
||||
}
|
||||
|
||||
private static SearchQualityAlertEntry CloneAlertEntry(SearchQualityAlertEntry source)
|
||||
{
|
||||
return new SearchQualityAlertEntry
|
||||
@@ -758,6 +1062,63 @@ internal sealed class SearchQualityMonitor
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<SearchQualityPruneResult> PruneExpiredAsync(int retentionDays, CancellationToken ct = default)
|
||||
{
|
||||
var days = Math.Max(0, retentionDays);
|
||||
var cutoff = DateTimeOffset.UtcNow - TimeSpan.FromDays(days);
|
||||
|
||||
var feedbackDeleted = 0;
|
||||
var alertsDeleted = 0;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using (var feedbackCmd = new NpgsqlCommand(@"
|
||||
DELETE FROM advisoryai.search_feedback
|
||||
WHERE created_at < @cutoff", conn))
|
||||
{
|
||||
feedbackCmd.Parameters.AddWithValue("cutoff", cutoff.UtcDateTime);
|
||||
feedbackDeleted = await feedbackCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await using (var alertsCmd = new NpgsqlCommand(@"
|
||||
DELETE FROM advisoryai.search_quality_alerts
|
||||
WHERE created_at < @cutoff", conn))
|
||||
{
|
||||
alertsCmd.Parameters.AddWithValue("cutoff", cutoff.UtcDateTime);
|
||||
alertsDeleted = await alertsCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to prune search quality feedback/alerts by retention window.");
|
||||
}
|
||||
}
|
||||
|
||||
var fallbackFeedbackDeleted = 0;
|
||||
var fallbackAlertsDeleted = 0;
|
||||
lock (_fallbackLock)
|
||||
{
|
||||
var feedbackBefore = _fallbackFeedback.Count;
|
||||
_fallbackFeedback.RemoveAll(item => item.CreatedAt < cutoff);
|
||||
fallbackFeedbackDeleted = feedbackBefore - _fallbackFeedback.Count;
|
||||
|
||||
var alertsBefore = _fallbackAlerts.Count;
|
||||
_fallbackAlerts.RemoveAll(item =>
|
||||
new DateTimeOffset(DateTime.SpecifyKind(item.CreatedAt, DateTimeKind.Utc), TimeSpan.Zero) < cutoff);
|
||||
fallbackAlertsDeleted = alertsBefore - _fallbackAlerts.Count;
|
||||
}
|
||||
|
||||
return new SearchQualityPruneResult(
|
||||
FeedbackDeleted: feedbackDeleted + fallbackFeedbackDeleted,
|
||||
AlertsDeleted: alertsDeleted + fallbackAlertsDeleted,
|
||||
CutoffUtc: cutoff.UtcDateTime);
|
||||
}
|
||||
|
||||
private readonly record struct AlertCandidate(
|
||||
string Query,
|
||||
int OccurrenceCount,
|
||||
@@ -810,4 +1171,37 @@ internal sealed class SearchQualityMetricsEntry
|
||||
public double AvgResultCount { get; set; }
|
||||
public double FeedbackScore { get; set; }
|
||||
public string Period { get; set; } = "7d";
|
||||
public IReadOnlyList<SearchQualityLowQualityRow> LowQualityResults { get; set; } = [];
|
||||
public IReadOnlyList<SearchQualityTopQueryRow> TopQueries { get; set; } = [];
|
||||
public IReadOnlyList<SearchQualityTrendPoint> Trend { get; set; } = [];
|
||||
}
|
||||
|
||||
internal sealed class SearchQualityLowQualityRow
|
||||
{
|
||||
public string EntityKey { get; set; } = string.Empty;
|
||||
public string Domain { get; set; } = string.Empty;
|
||||
public int NegativeFeedbackCount { get; set; }
|
||||
public int TotalFeedback { get; set; }
|
||||
public double NegativeRate { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class SearchQualityTopQueryRow
|
||||
{
|
||||
public string Query { get; set; } = string.Empty;
|
||||
public int TotalSearches { get; set; }
|
||||
public double AvgResultCount { get; set; }
|
||||
public double FeedbackScore { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class SearchQualityTrendPoint
|
||||
{
|
||||
public DateTime Day { get; set; }
|
||||
public int TotalSearches { get; set; }
|
||||
public double ZeroResultRate { get; set; }
|
||||
public double FeedbackScore { get; set; }
|
||||
}
|
||||
|
||||
internal sealed record SearchQualityPruneResult(
|
||||
int FeedbackDeleted,
|
||||
int AlertsDeleted,
|
||||
DateTime CutoffUtc);
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.UnifiedSearch.Cards;
|
||||
|
||||
internal sealed class EntityCardAssembler
|
||||
{
|
||||
private readonly IEntityAliasService _aliases;
|
||||
private readonly UnifiedSearchOptions _options;
|
||||
private readonly ILogger<EntityCardAssembler> _logger;
|
||||
|
||||
public EntityCardAssembler(
|
||||
IEntityAliasService aliases,
|
||||
IOptions<UnifiedSearchOptions> options,
|
||||
ILogger<EntityCardAssembler> logger)
|
||||
{
|
||||
_aliases = aliases ?? throw new ArgumentNullException(nameof(aliases));
|
||||
_options = options?.Value ?? new UnifiedSearchOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<EntityCard>> AssembleAsync(
|
||||
IReadOnlyList<EntityCard> flatCards,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (flatCards.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var grouped = new Dictionary<string, List<EntityCard>>(StringComparer.Ordinal);
|
||||
for (var index = 0; index < flatCards.Count; index++)
|
||||
{
|
||||
var card = flatCards[index];
|
||||
var canonical = await ResolveCanonicalKeyAsync(card, index, ct).ConfigureAwait(false);
|
||||
if (!grouped.TryGetValue(canonical, out var list))
|
||||
{
|
||||
list = new List<EntityCard>();
|
||||
grouped[canonical] = list;
|
||||
}
|
||||
|
||||
list.Add(card);
|
||||
}
|
||||
|
||||
var merged = new List<EntityCard>(grouped.Count);
|
||||
foreach (var entry in grouped.OrderBy(static item => item.Key, StringComparer.Ordinal))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var cards = entry.Value
|
||||
.OrderByDescending(static item => item.Score)
|
||||
.ThenBy(static item => item.Domain, StringComparer.Ordinal)
|
||||
.ThenBy(static item => item.EntityType, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
if (cards.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var primary = cards[0];
|
||||
var facets = BuildFacets(cards);
|
||||
var facetCount = Math.Max(1, facets.Count);
|
||||
var aggregateScore = primary.Score + 0.1d * Math.Log(facetCount);
|
||||
var sources = cards
|
||||
.SelectMany(static card => card.Sources)
|
||||
.Where(static source => !string.IsNullOrWhiteSpace(source))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static source => source, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var (actions, primaryAction) = BuildActions(cards);
|
||||
var connections = await ResolveConnectionsAsync(entry.Key, ct).ConfigureAwait(false);
|
||||
var synthesisHints = BuildSynthesisHints(cards, facets);
|
||||
var snippet = BuildSnippet(cards);
|
||||
var metadata = MergeMetadata(primary.Metadata, facetCount, connections.Count);
|
||||
|
||||
merged.Add(new EntityCard
|
||||
{
|
||||
EntityKey = NormalizeMergedEntityKey(entry.Key, primary.EntityKey),
|
||||
EntityType = primary.EntityType,
|
||||
Domain = primary.Domain,
|
||||
Title = primary.Title,
|
||||
Snippet = snippet,
|
||||
Score = aggregateScore,
|
||||
Severity = primary.Severity,
|
||||
Actions = actions,
|
||||
Metadata = metadata,
|
||||
Sources = sources,
|
||||
Preview = primary.Preview,
|
||||
Facets = facets,
|
||||
Connections = connections,
|
||||
SynthesisHints = synthesisHints
|
||||
});
|
||||
}
|
||||
|
||||
return merged
|
||||
.OrderByDescending(static card => card.Score)
|
||||
.ThenBy(static card => card.EntityType, StringComparer.Ordinal)
|
||||
.ThenBy(static card => card.EntityKey, StringComparer.Ordinal)
|
||||
.Take(Math.Max(1, _options.MaxCards))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private async Task<string> ResolveCanonicalKeyAsync(EntityCard card, int index, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(card.EntityKey))
|
||||
{
|
||||
return $"__standalone:{index}";
|
||||
}
|
||||
|
||||
// Heuristic alias canonicalization for GHSA cards carrying CVE metadata.
|
||||
if (card.EntityKey.StartsWith("ghsa:", StringComparison.OrdinalIgnoreCase) &&
|
||||
card.Metadata is not null &&
|
||||
card.Metadata.TryGetValue("cveId", out var cveId) &&
|
||||
!string.IsNullOrWhiteSpace(cveId))
|
||||
{
|
||||
return $"cve:{cveId.Trim().ToUpperInvariant()}";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var aliases = await _aliases.ResolveAliasesAsync(card.EntityKey, ct).ConfigureAwait(false);
|
||||
var canonical = aliases
|
||||
.Select(static alias => alias.EntityKey)
|
||||
.Where(static key => !string.IsNullOrWhiteSpace(key))
|
||||
.OrderBy(static key => key, StringComparer.Ordinal)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(canonical))
|
||||
{
|
||||
return canonical;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Entity alias resolution failed for key '{EntityKey}'.", card.EntityKey);
|
||||
}
|
||||
|
||||
return card.EntityKey;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<string>> ResolveConnectionsAsync(string canonicalKey, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(canonicalKey) || canonicalKey.StartsWith("__standalone:", StringComparison.Ordinal))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var aliases = await _aliases.ResolveAliasesAsync(canonicalKey, ct).ConfigureAwait(false);
|
||||
return aliases
|
||||
.Select(static alias => alias.EntityKey)
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Where(value => !string.Equals(value, canonicalKey, StringComparison.Ordinal))
|
||||
.OrderBy(static value => value, StringComparer.Ordinal)
|
||||
.Take(5)
|
||||
.ToArray();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to resolve connections for '{CanonicalKey}'.", canonicalKey);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<EntityCardFacet> BuildFacets(IReadOnlyList<EntityCard> cards)
|
||||
{
|
||||
var facets = new List<EntityCardFacet>();
|
||||
foreach (var group in cards
|
||||
.GroupBy(static card => card.Domain, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static group => group.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var top = group
|
||||
.OrderByDescending(static card => card.Score)
|
||||
.ThenBy(static card => card.Title, StringComparer.Ordinal)
|
||||
.First();
|
||||
|
||||
facets.Add(new EntityCardFacet
|
||||
{
|
||||
Domain = group.Key,
|
||||
Title = top.Title,
|
||||
Snippet = top.Snippet,
|
||||
Score = top.Score,
|
||||
Metadata = top.Metadata,
|
||||
Actions = top.Actions
|
||||
});
|
||||
}
|
||||
|
||||
return facets;
|
||||
}
|
||||
|
||||
private static (IReadOnlyList<EntityCardAction> Actions, EntityCardAction? PrimaryAction) BuildActions(IReadOnlyList<EntityCard> cards)
|
||||
{
|
||||
var actions = new List<EntityCardAction>();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var card in cards)
|
||||
{
|
||||
foreach (var action in card.Actions)
|
||||
{
|
||||
var dedup = $"{action.Label}|{action.ActionType}|{action.Route}|{action.Command}";
|
||||
if (!seen.Add(dedup))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
actions.Add(action with { IsPrimary = false });
|
||||
}
|
||||
}
|
||||
|
||||
var primary = actions.FirstOrDefault() ?? cards.SelectMany(static card => card.Actions).FirstOrDefault();
|
||||
if (primary is null)
|
||||
{
|
||||
return ([], null);
|
||||
}
|
||||
|
||||
var normalized = new List<EntityCardAction> { primary with { IsPrimary = true } };
|
||||
foreach (var action in actions)
|
||||
{
|
||||
if (ActionsEqual(action, primary))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized.Add(action with { IsPrimary = false });
|
||||
}
|
||||
|
||||
return (normalized, normalized[0]);
|
||||
}
|
||||
|
||||
private static bool ActionsEqual(EntityCardAction left, EntityCardAction right)
|
||||
{
|
||||
return string.Equals(left.Label, right.Label, StringComparison.Ordinal) &&
|
||||
string.Equals(left.ActionType, right.ActionType, StringComparison.Ordinal) &&
|
||||
string.Equals(left.Route, right.Route, StringComparison.Ordinal) &&
|
||||
string.Equals(left.Command, right.Command, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string BuildSnippet(IReadOnlyList<EntityCard> cards)
|
||||
{
|
||||
return cards
|
||||
.Select(static card => card.Snippet)
|
||||
.Where(static snippet => !string.IsNullOrWhiteSpace(snippet))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Take(2)
|
||||
.DefaultIfEmpty(string.Empty)
|
||||
.Aggregate((left, right) => string.IsNullOrWhiteSpace(left) ? right : $"{left} | {right}");
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string>? MergeMetadata(
|
||||
IReadOnlyDictionary<string, string>? primaryMetadata,
|
||||
int facetCount,
|
||||
int connectionCount)
|
||||
{
|
||||
var merged = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
if (primaryMetadata is not null)
|
||||
{
|
||||
foreach (var entry in primaryMetadata)
|
||||
{
|
||||
merged[entry.Key] = entry.Value;
|
||||
}
|
||||
}
|
||||
|
||||
merged["facetCount"] = facetCount.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
merged["connectionCount"] = connectionCount.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
return merged;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> BuildSynthesisHints(
|
||||
IReadOnlyList<EntityCard> cards,
|
||||
IReadOnlyList<EntityCardFacet> facets)
|
||||
{
|
||||
var hints = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var top = cards[0];
|
||||
|
||||
hints["entityKey"] = top.EntityKey;
|
||||
hints["entityType"] = top.EntityType;
|
||||
hints["topDomain"] = top.Domain;
|
||||
hints["facetCount"] = facets.Count.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
hints["domains"] = string.Join(",", facets.Select(static facet => facet.Domain));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(top.Severity))
|
||||
{
|
||||
hints["severity"] = top.Severity;
|
||||
}
|
||||
|
||||
if (top.Metadata is not null)
|
||||
{
|
||||
foreach (var (key, value) in top.Metadata)
|
||||
{
|
||||
if (!hints.ContainsKey(key))
|
||||
{
|
||||
hints[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hints;
|
||||
}
|
||||
|
||||
private static string NormalizeMergedEntityKey(string mergedKey, string fallback)
|
||||
{
|
||||
return mergedKey.StartsWith("__standalone:", StringComparison.Ordinal)
|
||||
? fallback
|
||||
: mergedKey;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
namespace StellaOps.AdvisoryAI.UnifiedSearch.Context;
|
||||
|
||||
internal sealed class AmbientContextProcessor
|
||||
{
|
||||
private static readonly (string Prefix, string Domain)[] RouteDomainMappings =
|
||||
[
|
||||
("/console/findings", "findings"),
|
||||
("/security/triage", "findings"),
|
||||
("/security/advisories-vex", "vex"),
|
||||
("/ops/policies", "policy"),
|
||||
("/ops/policy", "policy"),
|
||||
("/ops/graph", "graph"),
|
||||
("/ops/audit", "timeline"),
|
||||
("/console/scans", "scanner"),
|
||||
("/ops/doctor", "knowledge"),
|
||||
("/docs", "knowledge")
|
||||
];
|
||||
|
||||
public IReadOnlyDictionary<string, double> ApplyRouteBoost(
|
||||
IReadOnlyDictionary<string, double> baseWeights,
|
||||
AmbientContext? ambient)
|
||||
{
|
||||
var output = new Dictionary<string, double>(baseWeights, StringComparer.OrdinalIgnoreCase);
|
||||
if (ambient is null || string.IsNullOrWhiteSpace(ambient.CurrentRoute))
|
||||
{
|
||||
return output;
|
||||
}
|
||||
|
||||
var domain = ResolveDomainFromRoute(ambient.CurrentRoute);
|
||||
if (string.IsNullOrWhiteSpace(domain))
|
||||
{
|
||||
return output;
|
||||
}
|
||||
|
||||
output[domain] = output.TryGetValue(domain, out var existing)
|
||||
? existing + 0.10d
|
||||
: 1.10d;
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, double> BuildEntityBoostMap(
|
||||
AmbientContext? ambient,
|
||||
SearchSessionSnapshot session)
|
||||
{
|
||||
var map = new Dictionary<string, double>(StringComparer.Ordinal);
|
||||
|
||||
if (ambient?.VisibleEntityKeys is { Count: > 0 })
|
||||
{
|
||||
foreach (var entityKey in ambient.VisibleEntityKeys)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entityKey))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
map[entityKey.Trim()] = Math.Max(
|
||||
map.TryGetValue(entityKey.Trim(), out var existing) ? existing : 0d,
|
||||
0.20d);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var entry in session.EntityBoosts)
|
||||
{
|
||||
map[entry.Key] = Math.Max(
|
||||
map.TryGetValue(entry.Key, out var existing) ? existing : 0d,
|
||||
entry.Value);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
public IReadOnlyList<EntityMention> CarryForwardEntities(
|
||||
IReadOnlyList<EntityMention> currentEntities,
|
||||
SearchSessionSnapshot session)
|
||||
{
|
||||
if (currentEntities.Count > 0 || session.EntityBoosts.Count == 0)
|
||||
{
|
||||
return currentEntities;
|
||||
}
|
||||
|
||||
var carried = new List<EntityMention>();
|
||||
foreach (var entityKey in session.EntityBoosts.Keys)
|
||||
{
|
||||
if (entityKey.StartsWith("cve:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var value = entityKey["cve:".Length..];
|
||||
carried.Add(new EntityMention(value, "cve", 0, value.Length));
|
||||
}
|
||||
else if (entityKey.StartsWith("purl:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var value = entityKey["purl:".Length..];
|
||||
carried.Add(new EntityMention(value, "purl", 0, value.Length));
|
||||
}
|
||||
else if (entityKey.StartsWith("ghsa:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var value = entityKey["ghsa:".Length..];
|
||||
carried.Add(new EntityMention(value, "ghsa", 0, value.Length));
|
||||
}
|
||||
}
|
||||
|
||||
return carried.Count > 0 ? carried : currentEntities;
|
||||
}
|
||||
|
||||
internal static string? ResolveDomainFromRoute(string route)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(route))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalized = route.Trim().ToLowerInvariant();
|
||||
foreach (var (prefix, domain) in RouteDomainMappings)
|
||||
{
|
||||
if (normalized.StartsWith(prefix, StringComparison.Ordinal))
|
||||
{
|
||||
return domain;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.UnifiedSearch.Context;
|
||||
|
||||
internal sealed class SearchSessionContextService
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, SearchSessionState> _sessions = new(StringComparer.Ordinal);
|
||||
|
||||
public SearchSessionSnapshot GetSnapshot(
|
||||
string tenantId,
|
||||
string userId,
|
||||
string sessionId,
|
||||
DateTimeOffset now,
|
||||
TimeSpan inactivityTtl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
{
|
||||
return SearchSessionSnapshot.Empty;
|
||||
}
|
||||
|
||||
var key = BuildKey(tenantId, userId, sessionId);
|
||||
if (!_sessions.TryGetValue(key, out var state))
|
||||
{
|
||||
return SearchSessionSnapshot.Empty;
|
||||
}
|
||||
|
||||
if (now - state.LastActiveAt > inactivityTtl)
|
||||
{
|
||||
_sessions.TryRemove(key, out _);
|
||||
return SearchSessionSnapshot.Empty;
|
||||
}
|
||||
|
||||
var boosts = new Dictionary<string, double>(StringComparer.Ordinal);
|
||||
foreach (var entry in state.Entities.Values)
|
||||
{
|
||||
// 5-minute half-life style decay.
|
||||
var ageMinutes = Math.Max(0d, (now - entry.LastSeenAt).TotalMinutes);
|
||||
var decay = Math.Exp(-ageMinutes / 5d);
|
||||
var boost = 0.15d * decay;
|
||||
if (boost >= 0.01d)
|
||||
{
|
||||
boosts[entry.EntityKey] = Math.Min(0.25d, boost);
|
||||
}
|
||||
}
|
||||
|
||||
return new SearchSessionSnapshot(
|
||||
state.SessionId,
|
||||
state.LastActiveAt,
|
||||
boosts);
|
||||
}
|
||||
|
||||
public void RecordQuery(
|
||||
string tenantId,
|
||||
string userId,
|
||||
string sessionId,
|
||||
IReadOnlyList<EntityMention> detectedEntities,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var key = BuildKey(tenantId, userId, sessionId);
|
||||
var state = _sessions.GetOrAdd(key, _ => new SearchSessionState(sessionId, now));
|
||||
lock (state.Sync)
|
||||
{
|
||||
state.LastActiveAt = now;
|
||||
foreach (var mention in detectedEntities)
|
||||
{
|
||||
var entityKey = MapMentionToEntityKey(mention);
|
||||
if (string.IsNullOrWhiteSpace(entityKey))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
state.Entities[entityKey] = new SessionEntity(entityKey, mention.EntityType, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Reset(string tenantId, string userId, string sessionId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_sessions.TryRemove(BuildKey(tenantId, userId, sessionId), out _);
|
||||
}
|
||||
|
||||
private static string BuildKey(string tenantId, string userId, string sessionId)
|
||||
{
|
||||
return string.Join(
|
||||
"|",
|
||||
string.IsNullOrWhiteSpace(tenantId) ? "global" : tenantId.Trim().ToLowerInvariant(),
|
||||
string.IsNullOrWhiteSpace(userId) ? "anonymous" : userId.Trim().ToLowerInvariant(),
|
||||
sessionId.Trim());
|
||||
}
|
||||
|
||||
private static string? MapMentionToEntityKey(EntityMention mention)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(mention.Value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return mention.EntityType switch
|
||||
{
|
||||
"cve" => $"cve:{mention.Value.ToUpperInvariant()}",
|
||||
"ghsa" => $"ghsa:{mention.Value.ToUpperInvariant()}",
|
||||
"purl" => $"purl:{mention.Value}",
|
||||
_ => mention.Value.Contains(':', StringComparison.Ordinal)
|
||||
? mention.Value
|
||||
: $"{mention.EntityType}:{mention.Value}"
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class SearchSessionState
|
||||
{
|
||||
public SearchSessionState(string sessionId, DateTimeOffset createdAt)
|
||||
{
|
||||
SessionId = sessionId;
|
||||
LastActiveAt = createdAt;
|
||||
}
|
||||
|
||||
public string SessionId { get; }
|
||||
|
||||
public DateTimeOffset LastActiveAt { get; set; }
|
||||
|
||||
public Dictionary<string, SessionEntity> Entities { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public object Sync { get; } = new();
|
||||
}
|
||||
|
||||
private sealed record SessionEntity(
|
||||
string EntityKey,
|
||||
string EntityType,
|
||||
DateTimeOffset LastSeenAt);
|
||||
}
|
||||
|
||||
internal sealed record SearchSessionSnapshot(
|
||||
string SessionId,
|
||||
DateTimeOffset LastActiveAt,
|
||||
IReadOnlyDictionary<string, double> EntityBoosts)
|
||||
{
|
||||
public static SearchSessionSnapshot Empty { get; } =
|
||||
new(string.Empty, DateTimeOffset.MinValue, new Dictionary<string, double>(StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
using StellaOps.AdvisoryAI.Vectorization;
|
||||
using System.Diagnostics;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.UnifiedSearch.Federation;
|
||||
|
||||
internal sealed class FederatedSearchDispatcher
|
||||
{
|
||||
private readonly UnifiedSearchOptions _options;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IVectorEncoder _vectorEncoder;
|
||||
private readonly ILogger<FederatedSearchDispatcher> _logger;
|
||||
|
||||
public FederatedSearchDispatcher(
|
||||
IOptions<UnifiedSearchOptions> options,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IVectorEncoder vectorEncoder,
|
||||
ILogger<FederatedSearchDispatcher> logger)
|
||||
{
|
||||
_options = options?.Value ?? new UnifiedSearchOptions();
|
||||
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
_vectorEncoder = vectorEncoder ?? throw new ArgumentNullException(nameof(vectorEncoder));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<FederatedSearchDispatchResult> DispatchAsync(
|
||||
string query,
|
||||
QueryPlan plan,
|
||||
UnifiedSearchFilter? filter,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!_options.Federation.Enabled)
|
||||
{
|
||||
return FederatedSearchDispatchResult.Disabled;
|
||||
}
|
||||
|
||||
var timeoutPerBackend = 100;
|
||||
var descriptors = BuildBackendDescriptors(plan);
|
||||
if (descriptors.Count == 0)
|
||||
{
|
||||
return FederatedSearchDispatchResult.NoBackendsConfigured;
|
||||
}
|
||||
|
||||
var timeoutBudget = Math.Max(100, _options.Federation.TimeoutBudgetMs);
|
||||
timeoutPerBackend = Math.Max(100, timeoutBudget / descriptors.Count);
|
||||
|
||||
var tasks = descriptors
|
||||
.Select(static descriptor => descriptor.QueryTask())
|
||||
.ToArray();
|
||||
|
||||
await Task.WhenAll(tasks).ConfigureAwait(false);
|
||||
|
||||
var backendResults = tasks.Select(static task => task.Result).ToArray();
|
||||
var diagnostics = backendResults
|
||||
.Select(static result => result.Diagnostic)
|
||||
.OrderBy(static diagnostic => diagnostic.Backend, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var normalizedRows = NormalizeAndDeduplicate(backendResults.SelectMany(static result => result.Chunks));
|
||||
return new FederatedSearchDispatchResult(normalizedRows, diagnostics);
|
||||
|
||||
BackendDescriptor Build(
|
||||
string backendName,
|
||||
string endpoint,
|
||||
string clientName,
|
||||
string domain,
|
||||
string kind)
|
||||
{
|
||||
return new BackendDescriptor(
|
||||
backendName,
|
||||
domain,
|
||||
() => QueryBackendAsync(
|
||||
backendName,
|
||||
endpoint,
|
||||
clientName,
|
||||
domain,
|
||||
kind,
|
||||
query,
|
||||
filter?.Tenant ?? "global",
|
||||
timeoutPerBackend,
|
||||
ct));
|
||||
}
|
||||
|
||||
List<BackendDescriptor> BuildBackendDescriptors(QueryPlan queryPlan)
|
||||
{
|
||||
var list = new List<BackendDescriptor>();
|
||||
var threshold = _options.Federation.FederationThreshold;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.Federation.ConsoleEndpoint) &&
|
||||
GetDomainWeight(queryPlan, "findings") >= threshold)
|
||||
{
|
||||
list.Add(Build(
|
||||
"console",
|
||||
_options.Federation.ConsoleEndpoint,
|
||||
"scanner-internal",
|
||||
"findings",
|
||||
"finding"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.Federation.GraphEndpoint) &&
|
||||
GetDomainWeight(queryPlan, "graph") >= threshold)
|
||||
{
|
||||
list.Add(Build(
|
||||
"graph",
|
||||
_options.Federation.GraphEndpoint,
|
||||
"graph-internal",
|
||||
"graph",
|
||||
"graph_node"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.Federation.TimelineEndpoint) &&
|
||||
GetDomainWeight(queryPlan, "timeline") >= threshold)
|
||||
{
|
||||
list.Add(Build(
|
||||
"timeline",
|
||||
_options.Federation.TimelineEndpoint,
|
||||
"timeline-internal",
|
||||
"timeline",
|
||||
"audit_event"));
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<BackendQueryResult> QueryBackendAsync(
|
||||
string backendName,
|
||||
string endpoint,
|
||||
string clientName,
|
||||
string domain,
|
||||
string kind,
|
||||
string query,
|
||||
string tenant,
|
||||
int timeoutMs,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(TimeSpan.FromMilliseconds(timeoutMs));
|
||||
|
||||
try
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(clientName);
|
||||
var url = $"{endpoint.TrimEnd('/')}/v1/search/query";
|
||||
using var response = await client.PostAsJsonAsync(
|
||||
url,
|
||||
new
|
||||
{
|
||||
q = query,
|
||||
k = _options.Federation.MaxFederatedResults,
|
||||
tenant
|
||||
},
|
||||
timeoutCts.Token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return new BackendQueryResult(
|
||||
[],
|
||||
new FederationBackendDiagnostic(
|
||||
backendName,
|
||||
0,
|
||||
(long)stopwatch.Elapsed.TotalMilliseconds,
|
||||
TimedOut: false,
|
||||
Status: $"http_{(int)response.StatusCode}"));
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(timeoutCts.Token).ConfigureAwait(false);
|
||||
using var json = await JsonDocument.ParseAsync(stream, cancellationToken: timeoutCts.Token).ConfigureAwait(false);
|
||||
var chunks = ParseBackendResponse(json.RootElement, domain, kind, tenant, query);
|
||||
|
||||
stopwatch.Stop();
|
||||
return new BackendQueryResult(
|
||||
chunks,
|
||||
new FederationBackendDiagnostic(
|
||||
backendName,
|
||||
chunks.Count,
|
||||
(long)stopwatch.Elapsed.TotalMilliseconds,
|
||||
TimedOut: false,
|
||||
Status: "ok"));
|
||||
}
|
||||
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return new BackendQueryResult(
|
||||
[],
|
||||
new FederationBackendDiagnostic(
|
||||
backendName,
|
||||
0,
|
||||
(long)stopwatch.Elapsed.TotalMilliseconds,
|
||||
TimedOut: true,
|
||||
Status: "timeout"));
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or JsonException)
|
||||
{
|
||||
_logger.LogDebug(ex, "Federated backend '{Backend}' query failed.", backendName);
|
||||
stopwatch.Stop();
|
||||
return new BackendQueryResult(
|
||||
[],
|
||||
new FederationBackendDiagnostic(
|
||||
backendName,
|
||||
0,
|
||||
(long)stopwatch.Elapsed.TotalMilliseconds,
|
||||
TimedOut: false,
|
||||
Status: "failed"));
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<KnowledgeChunkRow> NormalizeAndDeduplicate(IEnumerable<UnifiedChunk> chunks)
|
||||
{
|
||||
var byKey = new Dictionary<string, UnifiedChunk>(StringComparer.Ordinal);
|
||||
foreach (var chunk in chunks)
|
||||
{
|
||||
var dedupKey = $"{chunk.Domain}|{chunk.EntityKey ?? chunk.ChunkId}";
|
||||
if (!byKey.TryGetValue(dedupKey, out var existing))
|
||||
{
|
||||
byKey[dedupKey] = chunk;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((chunk.Freshness ?? DateTimeOffset.MinValue) > (existing.Freshness ?? DateTimeOffset.MinValue))
|
||||
{
|
||||
byKey[dedupKey] = chunk;
|
||||
}
|
||||
}
|
||||
|
||||
return byKey.Values
|
||||
.OrderByDescending(static chunk => chunk.Freshness ?? DateTimeOffset.MinValue)
|
||||
.ThenBy(static chunk => chunk.ChunkId, StringComparer.Ordinal)
|
||||
.Select(chunk => new KnowledgeChunkRow(
|
||||
chunk.ChunkId,
|
||||
chunk.DocId,
|
||||
chunk.Kind,
|
||||
chunk.Anchor,
|
||||
chunk.SectionPath,
|
||||
chunk.SpanStart,
|
||||
chunk.SpanEnd,
|
||||
chunk.Title,
|
||||
chunk.Body,
|
||||
KnowledgeSearchText.BuildSnippet(chunk.Body, string.Empty),
|
||||
chunk.Metadata,
|
||||
chunk.Embedding,
|
||||
LexicalScore: 0.15d))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private IReadOnlyList<UnifiedChunk> ParseBackendResponse(
|
||||
JsonElement root,
|
||||
string domain,
|
||||
string kind,
|
||||
string tenant,
|
||||
string query)
|
||||
{
|
||||
var items = ExtractItems(root);
|
||||
if (items.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var chunks = new List<UnifiedChunk>(items.Count);
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var title = ReadString(item, "title")
|
||||
?? ReadString(item, "name")
|
||||
?? ReadString(item, "id")
|
||||
?? "result";
|
||||
|
||||
var body = ReadString(item, "snippet")
|
||||
?? ReadString(item, "body")
|
||||
?? ReadString(item, "description")
|
||||
?? title;
|
||||
|
||||
var entityKey = ReadString(item, "entityKey")
|
||||
?? ReadString(item, "entity_key")
|
||||
?? GuessEntityKey(item, domain);
|
||||
|
||||
var entityType = ReadString(item, "entityType")
|
||||
?? ReadString(item, "entity_type")
|
||||
?? GuessEntityType(domain);
|
||||
|
||||
var freshness = ReadTimestamp(item, "freshness")
|
||||
?? ReadTimestamp(item, "updatedAt")
|
||||
?? ReadTimestamp(item, "createdAt")
|
||||
?? DateTimeOffset.UtcNow;
|
||||
|
||||
var chunkId = KnowledgeSearchText.StableId(
|
||||
"chunk",
|
||||
"federated",
|
||||
domain,
|
||||
tenant,
|
||||
entityKey ?? title,
|
||||
freshness.ToString("O", System.Globalization.CultureInfo.InvariantCulture));
|
||||
|
||||
var docId = KnowledgeSearchText.StableId("doc", "federated", domain, tenant, entityKey ?? title);
|
||||
var metadata = BuildMetadata(domain, entityKey, entityType, tenant, freshness, query, item);
|
||||
|
||||
chunks.Add(new UnifiedChunk(
|
||||
chunkId,
|
||||
docId,
|
||||
kind,
|
||||
domain,
|
||||
title,
|
||||
body,
|
||||
_vectorEncoder.Encode(body),
|
||||
entityKey,
|
||||
entityType,
|
||||
Anchor: null,
|
||||
SectionPath: null,
|
||||
SpanStart: 0,
|
||||
SpanEnd: body.Length,
|
||||
Freshness: freshness,
|
||||
metadata));
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<JsonElement> ExtractItems(JsonElement root)
|
||||
{
|
||||
if (root.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
return root.EnumerateArray().ToArray();
|
||||
}
|
||||
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach (var key in new[] { "items", "results", "cards" })
|
||||
{
|
||||
if (root.TryGetProperty(key, out var property) && property.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
return property.EnumerateArray().ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private static JsonDocument BuildMetadata(
|
||||
string domain,
|
||||
string? entityKey,
|
||||
string entityType,
|
||||
string tenant,
|
||||
DateTimeOffset freshness,
|
||||
string query,
|
||||
JsonElement sourceItem)
|
||||
{
|
||||
var payload = new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["domain"] = domain,
|
||||
["entity_key"] = entityKey,
|
||||
["entity_type"] = entityType,
|
||||
["tenant"] = tenant,
|
||||
["freshness"] = freshness.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
|
||||
["federatedQuery"] = query
|
||||
};
|
||||
|
||||
foreach (var property in sourceItem.EnumerateObject())
|
||||
{
|
||||
if (!payload.ContainsKey(property.Name))
|
||||
{
|
||||
payload[property.Name] = property.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => property.Value.GetString(),
|
||||
JsonValueKind.Number => property.Value.GetDouble(),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
_ => property.Value.GetRawText()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return JsonDocument.Parse(JsonSerializer.Serialize(payload));
|
||||
}
|
||||
|
||||
private static string? ReadString(JsonElement obj, string propertyName)
|
||||
{
|
||||
if (!obj.TryGetProperty(propertyName, out var property) ||
|
||||
property.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var value = property.GetString();
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ReadTimestamp(JsonElement obj, string propertyName)
|
||||
{
|
||||
var raw = ReadString(obj, propertyName);
|
||||
return raw is not null && DateTimeOffset.TryParse(raw, out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
private static string GuessEntityType(string domain)
|
||||
{
|
||||
return domain switch
|
||||
{
|
||||
"findings" => "finding",
|
||||
"graph" => "graph_node",
|
||||
"timeline" => "event",
|
||||
_ => "result"
|
||||
};
|
||||
}
|
||||
|
||||
private static string? GuessEntityKey(JsonElement item, string domain)
|
||||
{
|
||||
var cve = ReadString(item, "cve") ?? ReadString(item, "cveId");
|
||||
if (!string.IsNullOrWhiteSpace(cve))
|
||||
{
|
||||
return $"cve:{cve}";
|
||||
}
|
||||
|
||||
var purl = ReadString(item, "purl");
|
||||
if (!string.IsNullOrWhiteSpace(purl))
|
||||
{
|
||||
return $"purl:{purl}";
|
||||
}
|
||||
|
||||
var image = ReadString(item, "image") ?? ReadString(item, "imageRef");
|
||||
if (!string.IsNullOrWhiteSpace(image))
|
||||
{
|
||||
return $"image:{image}";
|
||||
}
|
||||
|
||||
var id = ReadString(item, "id");
|
||||
return string.IsNullOrWhiteSpace(id) ? null : $"{domain}:{id}";
|
||||
}
|
||||
|
||||
private static double GetDomainWeight(QueryPlan plan, string domain)
|
||||
{
|
||||
return plan.DomainWeights.TryGetValue(domain, out var value) ? value : 1d;
|
||||
}
|
||||
|
||||
private sealed record BackendDescriptor(
|
||||
string BackendName,
|
||||
string Domain,
|
||||
Func<Task<BackendQueryResult>> QueryTask);
|
||||
|
||||
private sealed record BackendQueryResult(
|
||||
IReadOnlyList<UnifiedChunk> Chunks,
|
||||
FederationBackendDiagnostic Diagnostic);
|
||||
}
|
||||
|
||||
internal sealed record FederatedSearchDispatchResult(
|
||||
IReadOnlyList<KnowledgeChunkRow> Rows,
|
||||
IReadOnlyList<FederationBackendDiagnostic> Diagnostics)
|
||||
{
|
||||
public static FederatedSearchDispatchResult Disabled { get; } =
|
||||
new([], [new FederationBackendDiagnostic("federation", 0, 0, TimedOut: false, Status: "disabled")]);
|
||||
|
||||
public static FederatedSearchDispatchResult NoBackendsConfigured { get; } =
|
||||
new([], [new FederationBackendDiagnostic("federation", 0, 0, TimedOut: false, Status: "not_configured")]);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.UnifiedSearch.Federation;
|
||||
|
||||
internal sealed class HttpGraphNeighborProvider : IGraphNeighborProvider
|
||||
{
|
||||
private readonly UnifiedSearchOptions _options;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<HttpGraphNeighborProvider> _logger;
|
||||
|
||||
public HttpGraphNeighborProvider(
|
||||
IOptions<UnifiedSearchOptions> options,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<HttpGraphNeighborProvider> logger)
|
||||
{
|
||||
_options = options?.Value ?? new UnifiedSearchOptions();
|
||||
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> GetOneHopNeighborsAsync(
|
||||
string entityKey,
|
||||
string tenant,
|
||||
int limit,
|
||||
TimeSpan timeout,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entityKey) ||
|
||||
string.IsNullOrWhiteSpace(_options.Federation.GraphEndpoint))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(timeout);
|
||||
|
||||
try
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient("graph-internal");
|
||||
var url = $"{_options.Federation.GraphEndpoint.TrimEnd('/')}/v1/graph/neighbors";
|
||||
using var response = await client.PostAsJsonAsync(
|
||||
url,
|
||||
new
|
||||
{
|
||||
entityKey,
|
||||
tenant,
|
||||
depth = 1,
|
||||
limit = Math.Max(1, limit)
|
||||
},
|
||||
timeoutCts.Token).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(timeoutCts.Token).ConfigureAwait(false);
|
||||
using var json = await JsonDocument.ParseAsync(stream, cancellationToken: timeoutCts.Token).ConfigureAwait(false);
|
||||
return ParseNeighborEntityKeys(json.RootElement);
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or HttpRequestException or JsonException)
|
||||
{
|
||||
_logger.LogDebug(ex, "Graph neighbor lookup failed for entity '{EntityKey}'.", entityKey);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ParseNeighborEntityKeys(JsonElement root)
|
||||
{
|
||||
var keys = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (root.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in root.EnumerateArray())
|
||||
{
|
||||
AddKey(item, keys);
|
||||
}
|
||||
|
||||
return keys.OrderBy(static value => value, StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
|
||||
if (root.ValueKind == JsonValueKind.Object &&
|
||||
root.TryGetProperty("neighbors", out var neighbors) &&
|
||||
neighbors.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in neighbors.EnumerateArray())
|
||||
{
|
||||
AddKey(item, keys);
|
||||
}
|
||||
}
|
||||
|
||||
return keys.OrderBy(static value => value, StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
|
||||
private static void AddKey(JsonElement item, ISet<string> keys)
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var value = item.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
keys.Add(value.Trim());
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (item.TryGetProperty("entityKey", out var entityKey) &&
|
||||
entityKey.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(entityKey.GetString()))
|
||||
{
|
||||
keys.Add(entityKey.GetString()!.Trim());
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.TryGetProperty("entity_key", out var snakeCaseKey) &&
|
||||
snakeCaseKey.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(snakeCaseKey.GetString()))
|
||||
{
|
||||
keys.Add(snakeCaseKey.GetString()!.Trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.AdvisoryAI.UnifiedSearch.Federation;
|
||||
|
||||
internal interface IGraphNeighborProvider
|
||||
{
|
||||
Task<IReadOnlyList<string>> GetOneHopNeighborsAsync(
|
||||
string entityKey,
|
||||
string tenant,
|
||||
int limit,
|
||||
TimeSpan timeout,
|
||||
CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -5,37 +5,28 @@ namespace StellaOps.AdvisoryAI.UnifiedSearch.QueryUnderstanding;
|
||||
|
||||
internal sealed class DomainWeightCalculator
|
||||
{
|
||||
private const double BaseWeight = 1.0;
|
||||
private const double CveBoostFindings = 0.35;
|
||||
private const double CveBoostVex = 0.30;
|
||||
private const double CveBoostGraph = 0.25;
|
||||
private const double SecurityBoostFindings = 0.20;
|
||||
private const double SecurityBoostVex = 0.15;
|
||||
private const double PolicyBoostPolicy = 0.30;
|
||||
private const double TroubleshootBoostKnowledge = 0.15;
|
||||
private const double TroubleshootBoostOpsMemory = 0.10;
|
||||
|
||||
// Role-based bias constants (Sprint 106 / G6)
|
||||
private const double RoleScannerFindingsBoost = 0.15;
|
||||
private const double RoleScannerVexBoost = 0.10;
|
||||
private const double RolePolicyBoost = 0.20;
|
||||
private const double RoleOpsKnowledgeBoost = 0.15;
|
||||
private const double RoleOpsMemoryBoost = 0.10;
|
||||
private const double RoleReleasePolicyBoost = 0.10;
|
||||
private const double RoleReleaseFindingsBoost = 0.10;
|
||||
|
||||
private readonly EntityExtractor _entityExtractor;
|
||||
private readonly IntentClassifier _intentClassifier;
|
||||
private readonly KnowledgeSearchOptions _options;
|
||||
private readonly UnifiedSearchOptions _unifiedOptions;
|
||||
|
||||
public DomainWeightCalculator(
|
||||
EntityExtractor entityExtractor,
|
||||
IntentClassifier intentClassifier,
|
||||
IOptions<KnowledgeSearchOptions> options)
|
||||
: this(entityExtractor, intentClassifier, options, null)
|
||||
{
|
||||
_entityExtractor = entityExtractor ?? throw new ArgumentNullException(nameof(entityExtractor));
|
||||
}
|
||||
|
||||
public DomainWeightCalculator(
|
||||
EntityExtractor entityExtractor,
|
||||
IntentClassifier intentClassifier,
|
||||
IOptions<KnowledgeSearchOptions> options,
|
||||
IOptions<UnifiedSearchOptions>? unifiedOptions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entityExtractor);
|
||||
_intentClassifier = intentClassifier ?? throw new ArgumentNullException(nameof(intentClassifier));
|
||||
_options = options?.Value ?? new KnowledgeSearchOptions();
|
||||
_unifiedOptions = unifiedOptions?.Value ?? new UnifiedSearchOptions();
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, double> ComputeWeights(
|
||||
@@ -43,16 +34,8 @@ internal sealed class DomainWeightCalculator
|
||||
IReadOnlyList<EntityMention> entities,
|
||||
UnifiedSearchFilter? filters)
|
||||
{
|
||||
var weights = new Dictionary<string, double>(StringComparer.Ordinal)
|
||||
{
|
||||
["knowledge"] = BaseWeight,
|
||||
["findings"] = BaseWeight,
|
||||
["vex"] = BaseWeight,
|
||||
["policy"] = BaseWeight,
|
||||
["graph"] = BaseWeight,
|
||||
["ops_memory"] = BaseWeight,
|
||||
["timeline"] = BaseWeight
|
||||
};
|
||||
var tuning = _unifiedOptions.Weighting ?? new UnifiedSearchWeightingOptions();
|
||||
var weights = BuildBaseWeights();
|
||||
|
||||
var hasCve = entities.Any(static e =>
|
||||
e.EntityType.Equals("cve", StringComparison.OrdinalIgnoreCase) ||
|
||||
@@ -60,27 +43,46 @@ internal sealed class DomainWeightCalculator
|
||||
|
||||
if (hasCve)
|
||||
{
|
||||
weights["findings"] += CveBoostFindings;
|
||||
weights["vex"] += CveBoostVex;
|
||||
weights["graph"] += CveBoostGraph;
|
||||
weights["findings"] += tuning.CveBoostFindings;
|
||||
weights["vex"] += tuning.CveBoostVex;
|
||||
weights["graph"] += tuning.CveBoostGraph;
|
||||
}
|
||||
|
||||
var hasPackageLikeEntity = entities.Any(static e =>
|
||||
e.EntityType.Equals("purl", StringComparison.OrdinalIgnoreCase) ||
|
||||
e.EntityType.Equals("package", StringComparison.OrdinalIgnoreCase) ||
|
||||
e.EntityType.Equals("image", StringComparison.OrdinalIgnoreCase));
|
||||
if (hasPackageLikeEntity ||
|
||||
query.Contains("package", StringComparison.OrdinalIgnoreCase) ||
|
||||
query.Contains("image", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
weights["graph"] += tuning.PackageBoostGraph;
|
||||
weights["scanner"] += tuning.PackageBoostScanner;
|
||||
weights["findings"] += tuning.PackageBoostFindings;
|
||||
}
|
||||
|
||||
if (_intentClassifier.HasSecurityIntent(query))
|
||||
{
|
||||
weights["findings"] += SecurityBoostFindings;
|
||||
weights["vex"] += SecurityBoostVex;
|
||||
weights["findings"] += tuning.SecurityBoostFindings;
|
||||
weights["vex"] += tuning.SecurityBoostVex;
|
||||
}
|
||||
|
||||
if (_intentClassifier.HasPolicyIntent(query))
|
||||
{
|
||||
weights["policy"] += PolicyBoostPolicy;
|
||||
weights["policy"] += tuning.PolicyBoostPolicy;
|
||||
}
|
||||
|
||||
var intent = _intentClassifier.Classify(query);
|
||||
if (intent == "troubleshoot")
|
||||
{
|
||||
weights["knowledge"] += TroubleshootBoostKnowledge;
|
||||
weights["ops_memory"] += TroubleshootBoostOpsMemory;
|
||||
weights["knowledge"] += tuning.TroubleshootBoostKnowledge;
|
||||
weights["opsmemory"] += tuning.TroubleshootBoostOpsMemory;
|
||||
}
|
||||
|
||||
if (IsTimelineHeavyQuery(query))
|
||||
{
|
||||
weights["timeline"] += tuning.AuditBoostTimeline;
|
||||
weights["opsmemory"] += tuning.AuditBoostOpsMemory;
|
||||
}
|
||||
|
||||
if (filters?.Domains is { Count: > 0 })
|
||||
@@ -89,7 +91,7 @@ internal sealed class DomainWeightCalculator
|
||||
{
|
||||
if (weights.ContainsKey(domain))
|
||||
{
|
||||
weights[domain] += 0.25;
|
||||
weights[domain] += tuning.FilterDomainMatchBoost;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,41 +99,82 @@ internal sealed class DomainWeightCalculator
|
||||
// Role-based domain bias (Sprint 106 / G6)
|
||||
if (_options.RoleBasedBiasEnabled && filters?.UserScopes is { Count: > 0 })
|
||||
{
|
||||
ApplyRoleBasedBias(weights, filters.UserScopes);
|
||||
ApplyRoleBasedBias(weights, filters.UserScopes, tuning);
|
||||
}
|
||||
|
||||
return weights;
|
||||
}
|
||||
|
||||
private static void ApplyRoleBasedBias(Dictionary<string, double> weights, IReadOnlyList<string> scopes)
|
||||
private Dictionary<string, double> BuildBaseWeights()
|
||||
{
|
||||
var defaults = new Dictionary<string, double>(StringComparer.Ordinal)
|
||||
{
|
||||
["knowledge"] = 1.0,
|
||||
["findings"] = 1.0,
|
||||
["vex"] = 1.0,
|
||||
["policy"] = 1.0,
|
||||
["platform"] = 1.0,
|
||||
["graph"] = 1.0,
|
||||
["timeline"] = 1.0,
|
||||
["scanner"] = 1.0,
|
||||
["opsmemory"] = 1.0
|
||||
};
|
||||
|
||||
foreach (var entry in _unifiedOptions.BaseDomainWeights)
|
||||
{
|
||||
defaults[entry.Key] = entry.Value;
|
||||
}
|
||||
|
||||
return defaults;
|
||||
}
|
||||
|
||||
private static bool IsTimelineHeavyQuery(string query)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return query.Contains("timeline", StringComparison.OrdinalIgnoreCase) ||
|
||||
query.Contains("audit", StringComparison.OrdinalIgnoreCase) ||
|
||||
query.Contains("approved", StringComparison.OrdinalIgnoreCase) ||
|
||||
query.Contains("waiver", StringComparison.OrdinalIgnoreCase) ||
|
||||
query.Contains("who", StringComparison.OrdinalIgnoreCase) ||
|
||||
query.Contains("last week", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static void ApplyRoleBasedBias(
|
||||
Dictionary<string, double> weights,
|
||||
IReadOnlyList<string> scopes,
|
||||
UnifiedSearchWeightingOptions tuning)
|
||||
{
|
||||
var scopeSet = new HashSet<string>(scopes, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// scanner:read or findings:read -> boost findings + vex
|
||||
if (scopeSet.Contains("scanner:read") || scopeSet.Contains("findings:read"))
|
||||
{
|
||||
weights["findings"] += RoleScannerFindingsBoost;
|
||||
weights["vex"] += RoleScannerVexBoost;
|
||||
weights["findings"] += tuning.RoleScannerFindingsBoost;
|
||||
weights["vex"] += tuning.RoleScannerVexBoost;
|
||||
}
|
||||
|
||||
// policy:read or policy:write -> boost policy
|
||||
if (scopeSet.Contains("policy:read") || scopeSet.Contains("policy:write"))
|
||||
{
|
||||
weights["policy"] += RolePolicyBoost;
|
||||
weights["policy"] += tuning.RolePolicyBoost;
|
||||
}
|
||||
|
||||
// ops:read or doctor:run -> boost knowledge + ops_memory
|
||||
// ops:read or doctor:run -> boost knowledge + opsmemory
|
||||
if (scopeSet.Contains("ops:read") || scopeSet.Contains("doctor:run"))
|
||||
{
|
||||
weights["knowledge"] += RoleOpsKnowledgeBoost;
|
||||
weights["ops_memory"] += RoleOpsMemoryBoost;
|
||||
weights["knowledge"] += tuning.RoleOpsKnowledgeBoost;
|
||||
weights["opsmemory"] += tuning.RoleOpsMemoryBoost;
|
||||
}
|
||||
|
||||
// release:approve -> boost policy + findings
|
||||
if (scopeSet.Contains("release:approve"))
|
||||
{
|
||||
weights["policy"] += RoleReleasePolicyBoost;
|
||||
weights["findings"] += RoleReleaseFindingsBoost;
|
||||
weights["policy"] += tuning.RoleReleasePolicyBoost;
|
||||
weights["findings"] += tuning.RoleReleaseFindingsBoost;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.Federation;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.UnifiedSearch.Ranking;
|
||||
|
||||
internal sealed class GravityBoostCalculator
|
||||
{
|
||||
private readonly UnifiedSearchOptions _options;
|
||||
private readonly IGraphNeighborProvider _neighbors;
|
||||
private readonly ILogger<GravityBoostCalculator> _logger;
|
||||
|
||||
public GravityBoostCalculator(
|
||||
IOptions<UnifiedSearchOptions> options,
|
||||
IGraphNeighborProvider neighbors,
|
||||
ILogger<GravityBoostCalculator> logger)
|
||||
{
|
||||
_options = options?.Value ?? new UnifiedSearchOptions();
|
||||
_neighbors = neighbors ?? throw new ArgumentNullException(nameof(neighbors));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, double>> BuildGravityMapAsync(
|
||||
IReadOnlyList<EntityMention> detectedEntities,
|
||||
string tenant,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!_options.GravityBoost.Enabled || detectedEntities.Count == 0)
|
||||
{
|
||||
return new Dictionary<string, double>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
var mentionKeys = detectedEntities
|
||||
.Select(MapMentionToEntityKey)
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
if (mentionKeys.Length == 0)
|
||||
{
|
||||
return new Dictionary<string, double>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
var timeout = TimeSpan.FromMilliseconds(_options.GravityBoost.TimeoutMs);
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(timeout);
|
||||
|
||||
try
|
||||
{
|
||||
var oneHopTasks = mentionKeys
|
||||
.Select(async mentionKey =>
|
||||
{
|
||||
var neighbors = await _neighbors.GetOneHopNeighborsAsync(
|
||||
mentionKey!,
|
||||
tenant,
|
||||
_options.GravityBoost.MaxNeighborsPerEntity,
|
||||
timeout,
|
||||
timeoutCts.Token)
|
||||
.ConfigureAwait(false);
|
||||
return (mentionKey, neighbors);
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
await Task.WhenAll(oneHopTasks).ConfigureAwait(false);
|
||||
|
||||
var boostMap = new Dictionary<string, double>(StringComparer.Ordinal);
|
||||
var totalNeighbors = 0;
|
||||
|
||||
foreach (var task in oneHopTasks)
|
||||
{
|
||||
var (_, neighbors) = task.Result;
|
||||
foreach (var neighbor in neighbors)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(neighbor) ||
|
||||
mentionKeys.Contains(neighbor, StringComparer.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
boostMap[neighbor] = Math.Max(
|
||||
boostMap.TryGetValue(neighbor, out var existing) ? existing : 0d,
|
||||
_options.GravityBoost.OneHopBoost);
|
||||
|
||||
totalNeighbors++;
|
||||
if (totalNeighbors >= _options.GravityBoost.MaxTotalNeighbors)
|
||||
{
|
||||
return boostMap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return boostMap;
|
||||
}
|
||||
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Gravity boost lookup timed out after {TimeoutMs}ms; returning empty map.",
|
||||
_options.GravityBoost.TimeoutMs);
|
||||
return new Dictionary<string, double>(StringComparer.Ordinal);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Gravity boost lookup failed; returning empty map.");
|
||||
return new Dictionary<string, double>(StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? MapMentionToEntityKey(EntityMention mention)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(mention.Value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return mention.EntityType switch
|
||||
{
|
||||
"cve" => $"cve:{mention.Value.ToUpperInvariant()}",
|
||||
"ghsa" => $"ghsa:{mention.Value.ToUpperInvariant()}",
|
||||
"purl" => $"purl:{mention.Value}",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
[
|
||||
{
|
||||
"nodeId": "pkg-lodash-4.17.21",
|
||||
"kind": "package",
|
||||
"name": "lodash",
|
||||
"version": "4.17.21",
|
||||
"purl": "pkg:npm/lodash@4.17.21",
|
||||
"registry": "npmjs.org",
|
||||
"dependencyCount": 12,
|
||||
"relationshipSummary": "depends-on: nodejs; contained-in: registry.io/app:v1.2",
|
||||
"tenant": "global",
|
||||
"freshness": "2026-02-24T12:00:00Z"
|
||||
},
|
||||
{
|
||||
"nodeId": "img-registry-io-app-v1.2",
|
||||
"kind": "image",
|
||||
"name": "registry.io/app:v1.2",
|
||||
"imageRef": "registry.io/app:v1.2",
|
||||
"registry": "registry.io",
|
||||
"digest": "sha256:abc123",
|
||||
"os": "linux",
|
||||
"arch": "amd64",
|
||||
"dependencyCount": 5,
|
||||
"relationshipSummary": "contains: lodash@4.17.21",
|
||||
"tenant": "global",
|
||||
"freshness": "2026-02-24T12:10:00Z"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
[
|
||||
{
|
||||
"decisionId": "dec-1001",
|
||||
"decisionType": "waive",
|
||||
"outcomeStatus": "approved",
|
||||
"subjectRef": "CVE-2025-1234",
|
||||
"subjectType": "finding",
|
||||
"rationale": "Reachability analysis confirms exploit path is blocked in production.",
|
||||
"severity": "high",
|
||||
"resolutionTimeHours": 4.5,
|
||||
"contextTags": [
|
||||
"production",
|
||||
"waiver"
|
||||
],
|
||||
"similarityVector": [
|
||||
0.41,
|
||||
0.12,
|
||||
0.33,
|
||||
0.87
|
||||
],
|
||||
"tenant": "global",
|
||||
"recordedAt": "2026-02-24T10:00:00Z",
|
||||
"outcomeRecordedAt": "2026-02-24T14:30:00Z"
|
||||
},
|
||||
{
|
||||
"decisionId": "dec-1002",
|
||||
"decisionType": "remediate",
|
||||
"outcomeStatus": "pending",
|
||||
"subjectRef": "pkg:npm/lodash@4.17.21",
|
||||
"subjectType": "package",
|
||||
"rationale": "Upgrade to patched version in next rollout window.",
|
||||
"severity": "critical",
|
||||
"resolutionTimeHours": 0,
|
||||
"contextTags": [
|
||||
"staging",
|
||||
"remediation"
|
||||
],
|
||||
"similarityVector": [
|
||||
0.22,
|
||||
0.44,
|
||||
0.18,
|
||||
0.91
|
||||
],
|
||||
"tenant": "global",
|
||||
"recordedAt": "2026-02-24T18:00:00Z"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
[
|
||||
{
|
||||
"scanId": "scan-5001",
|
||||
"imageRef": "registry.io/app:v1.2",
|
||||
"scanType": "vulnerability",
|
||||
"status": "complete",
|
||||
"findingCount": 14,
|
||||
"criticalCount": 2,
|
||||
"durationMs": 4213,
|
||||
"scannerVersion": "2.8.1",
|
||||
"policyVerdicts": [
|
||||
"fail:critical-threshold",
|
||||
"warn:license"
|
||||
],
|
||||
"tenant": "global",
|
||||
"completedAt": "2026-02-24T11:45:00Z"
|
||||
},
|
||||
{
|
||||
"scanId": "scan-5002",
|
||||
"imageRef": "registry.io/base:v3.4",
|
||||
"scanType": "compliance",
|
||||
"status": "complete",
|
||||
"findingCount": 3,
|
||||
"criticalCount": 0,
|
||||
"durationMs": 1980,
|
||||
"scannerVersion": "2.8.1",
|
||||
"policyVerdicts": [
|
||||
"pass:compliance"
|
||||
],
|
||||
"tenant": "tenant-b",
|
||||
"completedAt": "2026-02-24T15:20:00Z"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
[
|
||||
{
|
||||
"eventId": "evt-8001",
|
||||
"action": "policy.evaluate",
|
||||
"actor": "admin@acme",
|
||||
"module": "Policy",
|
||||
"targetRef": "CVE-2025-1234",
|
||||
"payloadSummary": "verdict: pass",
|
||||
"tenant": "global",
|
||||
"timestamp": "2026-02-24T09:12:00Z"
|
||||
},
|
||||
{
|
||||
"eventId": "evt-8002",
|
||||
"action": "scan.complete",
|
||||
"actor": "scanner",
|
||||
"module": "Scanner",
|
||||
"targetRef": "pkg:npm/lodash@4.17.21",
|
||||
"payloadSummary": "severity changed: high -> critical",
|
||||
"tenant": "global",
|
||||
"timestamp": "2026-02-24T11:44:00Z"
|
||||
},
|
||||
{
|
||||
"eventId": "evt-8003",
|
||||
"action": "waiver.approve",
|
||||
"actor": "security.lead",
|
||||
"module": "OpsMemory",
|
||||
"targetRef": "decision:dec-1001",
|
||||
"payloadSummary": "waiver approved with expiry",
|
||||
"tenant": "global",
|
||||
"timestamp": "2025-10-01T11:44:00Z"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace StellaOps.AdvisoryAI.UnifiedSearch.Synthesis;
|
||||
|
||||
public sealed record SearchSynthesisRequest(
|
||||
string Q,
|
||||
IReadOnlyList<EntityCard> TopCards,
|
||||
QueryPlan? Plan = null,
|
||||
SearchSynthesisPreferences? Preferences = null);
|
||||
|
||||
public sealed record SearchSynthesisPreferences
|
||||
{
|
||||
public string Depth { get; init; } = "brief";
|
||||
|
||||
public int? MaxTokens { get; init; }
|
||||
|
||||
public bool IncludeActions { get; init; } = true;
|
||||
|
||||
public string Locale { get; init; } = "en";
|
||||
}
|
||||
|
||||
public sealed record SearchSynthesisActionSuggestion(
|
||||
string Label,
|
||||
string Route,
|
||||
string SourceEntityKey);
|
||||
|
||||
internal sealed record SearchSynthesisPrompt(
|
||||
string PromptVersion,
|
||||
string SystemPrompt,
|
||||
string UserPrompt,
|
||||
IReadOnlyList<EntityCard> IncludedCards,
|
||||
int EstimatedTokens);
|
||||
|
||||
internal sealed record SearchSynthesisExecutionResult(
|
||||
string DeterministicSummary,
|
||||
string? LlmSummary,
|
||||
double? GroundingScore,
|
||||
IReadOnlyList<SearchSynthesisActionSuggestion> Actions,
|
||||
string PromptVersion,
|
||||
string Provider,
|
||||
int TotalTokens,
|
||||
bool QuotaExceeded,
|
||||
bool LlmUnavailable,
|
||||
string StatusCode);
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.UnifiedSearch.Synthesis;
|
||||
|
||||
internal sealed class SearchSynthesisPromptAssembler
|
||||
{
|
||||
private readonly UnifiedSearchOptions _unifiedOptions;
|
||||
private readonly KnowledgeSearchOptions _knowledgeOptions;
|
||||
private const string PromptVersion = "search-synth-v1";
|
||||
private const string FallbackSystemPrompt =
|
||||
"You are the Stella Ops unified search assistant. Use only provided evidence, cite facts, and suggest concrete next actions.";
|
||||
|
||||
public SearchSynthesisPromptAssembler(
|
||||
IOptions<UnifiedSearchOptions> unifiedOptions,
|
||||
IOptions<KnowledgeSearchOptions> knowledgeOptions)
|
||||
{
|
||||
_unifiedOptions = unifiedOptions?.Value ?? new UnifiedSearchOptions();
|
||||
_knowledgeOptions = knowledgeOptions?.Value ?? new KnowledgeSearchOptions();
|
||||
}
|
||||
|
||||
public SearchSynthesisPrompt Build(
|
||||
string query,
|
||||
IReadOnlyList<EntityCard> cards,
|
||||
QueryPlan? plan,
|
||||
SearchSynthesisPreferences? preferences,
|
||||
string deterministicSummary)
|
||||
{
|
||||
var maxTokens = Math.Max(
|
||||
256,
|
||||
preferences?.MaxTokens ?? _unifiedOptions.Synthesis.MaxContextTokens);
|
||||
var included = TrimCardsByBudget(cards, maxTokens);
|
||||
var estimatedTokens = EstimateTokens(query) +
|
||||
EstimateTokens(deterministicSummary) +
|
||||
included.Sum(card => EstimateTokens(card.Title) + EstimateTokens(card.Snippet) + 40);
|
||||
|
||||
var systemPrompt = LoadSystemPrompt();
|
||||
var userPrompt = BuildUserPrompt(query, included, plan, preferences, deterministicSummary);
|
||||
return new SearchSynthesisPrompt(
|
||||
PromptVersion,
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
included,
|
||||
estimatedTokens);
|
||||
}
|
||||
|
||||
private IReadOnlyList<EntityCard> TrimCardsByBudget(
|
||||
IReadOnlyList<EntityCard> cards,
|
||||
int maxTokens)
|
||||
{
|
||||
var sorted = cards
|
||||
.OrderByDescending(static card => card.Score)
|
||||
.ThenBy(static card => card.EntityKey, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var selected = new List<EntityCard>(sorted.Length);
|
||||
var budget = 0;
|
||||
foreach (var card in sorted)
|
||||
{
|
||||
var cardTokens = EstimateTokens(card.Title) + EstimateTokens(card.Snippet) + 40;
|
||||
if (selected.Count > 0 && budget + cardTokens > maxTokens)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
selected.Add(card);
|
||||
budget += cardTokens;
|
||||
}
|
||||
|
||||
if (selected.Count == 0 && sorted.Length > 0)
|
||||
{
|
||||
selected.Add(sorted[0]);
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
private static string BuildUserPrompt(
|
||||
string query,
|
||||
IReadOnlyList<EntityCard> cards,
|
||||
QueryPlan? plan,
|
||||
SearchSynthesisPreferences? preferences,
|
||||
string deterministicSummary)
|
||||
{
|
||||
var locale = string.IsNullOrWhiteSpace(preferences?.Locale) ? "en" : preferences!.Locale;
|
||||
var depth = string.IsNullOrWhiteSpace(preferences?.Depth) ? "brief" : preferences!.Depth;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"Query: \"{query}\"");
|
||||
sb.AppendLine($"Intent: {plan?.Intent ?? "explore"}");
|
||||
sb.AppendLine($"Depth: {depth}");
|
||||
sb.AppendLine($"Locale: {locale}");
|
||||
if (plan?.DetectedEntities is { Count: > 0 })
|
||||
{
|
||||
sb.AppendLine("Detected entities:");
|
||||
foreach (var entity in plan.DetectedEntities)
|
||||
{
|
||||
sb.AppendLine($"- {entity.EntityType}: {entity.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Evidence:");
|
||||
for (var index = 0; index < cards.Count; index++)
|
||||
{
|
||||
var card = cards[index];
|
||||
sb.AppendLine($"[{index + 1}] {card.EntityType} {card.Title} (domain={card.Domain}, score={card.Score:F3})");
|
||||
sb.AppendLine($"Snippet: {card.Snippet}");
|
||||
|
||||
foreach (var action in card.Actions.Take(2))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(action.Route))
|
||||
{
|
||||
sb.AppendLine($"Action: {action.Label} -> [{card.Domain}:{action.Route}]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Deterministic summary:");
|
||||
sb.AppendLine(deterministicSummary);
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Rules:");
|
||||
sb.AppendLine("- Use only evidence above.");
|
||||
sb.AppendLine("- Cite factual claims using [n] where n references evidence item number.");
|
||||
sb.AppendLine("- Provide 2-4 concrete actions with deep links when possible.");
|
||||
sb.AppendLine("- If evidence is insufficient, state that clearly.");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private string LoadSystemPrompt()
|
||||
{
|
||||
var configured = _unifiedOptions.Synthesis.PromptPath;
|
||||
if (string.IsNullOrWhiteSpace(configured))
|
||||
{
|
||||
return FallbackSystemPrompt;
|
||||
}
|
||||
|
||||
var fullPath = Path.IsPathRooted(configured)
|
||||
? configured
|
||||
: Path.GetFullPath(Path.Combine(
|
||||
string.IsNullOrWhiteSpace(_knowledgeOptions.RepositoryRoot)
|
||||
? "."
|
||||
: _knowledgeOptions.RepositoryRoot,
|
||||
configured));
|
||||
|
||||
return File.Exists(fullPath)
|
||||
? File.ReadAllText(fullPath)
|
||||
: FallbackSystemPrompt;
|
||||
}
|
||||
|
||||
private static int EstimateTokens(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Simple deterministic approximation: 1 token ~= 4 chars.
|
||||
return Math.Max(1, (int)Math.Ceiling(value.Length / 4d));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.UnifiedSearch.Synthesis;
|
||||
|
||||
internal sealed class SearchSynthesisQuotaService
|
||||
{
|
||||
private readonly UnifiedSearchOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ConcurrentDictionary<string, TenantQuotaState> _states = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public SearchSynthesisQuotaService(
|
||||
IOptions<UnifiedSearchOptions> options,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_options = options?.Value ?? new UnifiedSearchOptions();
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public SearchSynthesisQuotaDecision TryAcquire(string tenantId)
|
||||
{
|
||||
var tenant = string.IsNullOrWhiteSpace(tenantId) ? "global" : tenantId.Trim();
|
||||
var state = _states.GetOrAdd(tenant, _ => new TenantQuotaState());
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var today = now.UtcDateTime.Date;
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
if (state.Day != today)
|
||||
{
|
||||
state.Day = today;
|
||||
state.RequestsToday = 0;
|
||||
}
|
||||
|
||||
if (state.RequestsToday >= _options.Synthesis.SynthesisRequestsPerDay)
|
||||
{
|
||||
return new SearchSynthesisQuotaDecision(false, "daily_limit_exceeded", null);
|
||||
}
|
||||
|
||||
if (state.Concurrent >= _options.Synthesis.MaxConcurrentPerTenant)
|
||||
{
|
||||
return new SearchSynthesisQuotaDecision(false, "concurrency_limit_exceeded", null);
|
||||
}
|
||||
|
||||
state.RequestsToday++;
|
||||
state.Concurrent++;
|
||||
|
||||
return new SearchSynthesisQuotaDecision(
|
||||
true,
|
||||
"ok",
|
||||
new Lease(() =>
|
||||
{
|
||||
lock (state.Sync)
|
||||
{
|
||||
state.Concurrent = Math.Max(0, state.Concurrent - 1);
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TenantQuotaState
|
||||
{
|
||||
public object Sync { get; } = new();
|
||||
|
||||
public DateTime Day { get; set; } = DateTime.MinValue;
|
||||
|
||||
public int RequestsToday { get; set; }
|
||||
|
||||
public int Concurrent { get; set; }
|
||||
}
|
||||
|
||||
private sealed class Lease : IDisposable
|
||||
{
|
||||
private readonly Action _release;
|
||||
private int _released;
|
||||
|
||||
public Lease(Action release) => _release = release;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _released, 1) == 0)
|
||||
{
|
||||
_release();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record SearchSynthesisQuotaDecision(
|
||||
bool Allowed,
|
||||
string Code,
|
||||
IDisposable? Lease);
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.Analytics;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.UnifiedSearch.Synthesis;
|
||||
|
||||
internal sealed class SearchSynthesisService
|
||||
{
|
||||
private readonly SynthesisTemplateEngine _templateEngine;
|
||||
private readonly LlmSynthesisEngine _llmEngine;
|
||||
private readonly SearchSynthesisPromptAssembler _promptAssembler;
|
||||
private readonly SearchSynthesisQuotaService _quotaService;
|
||||
private readonly SearchAnalyticsService _analytics;
|
||||
private readonly KnowledgeSearchOptions _knowledgeOptions;
|
||||
private readonly ILogger<SearchSynthesisService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public SearchSynthesisService(
|
||||
SynthesisTemplateEngine templateEngine,
|
||||
LlmSynthesisEngine llmEngine,
|
||||
SearchSynthesisPromptAssembler promptAssembler,
|
||||
SearchSynthesisQuotaService quotaService,
|
||||
SearchAnalyticsService analytics,
|
||||
IOptions<KnowledgeSearchOptions> knowledgeOptions,
|
||||
ILogger<SearchSynthesisService> logger,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_templateEngine = templateEngine ?? throw new ArgumentNullException(nameof(templateEngine));
|
||||
_llmEngine = llmEngine ?? throw new ArgumentNullException(nameof(llmEngine));
|
||||
_promptAssembler = promptAssembler ?? throw new ArgumentNullException(nameof(promptAssembler));
|
||||
_quotaService = quotaService ?? throw new ArgumentNullException(nameof(quotaService));
|
||||
_analytics = analytics ?? throw new ArgumentNullException(nameof(analytics));
|
||||
_knowledgeOptions = knowledgeOptions?.Value ?? new KnowledgeSearchOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public async Task<SearchSynthesisExecutionResult> ExecuteAsync(
|
||||
string tenantId,
|
||||
string userId,
|
||||
SearchSynthesisRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var startedAt = _timeProvider.GetUtcNow();
|
||||
var query = KnowledgeSearchText.NormalizeWhitespace(request.Q);
|
||||
var plan = request.Plan ?? new QueryPlan
|
||||
{
|
||||
OriginalQuery = query,
|
||||
NormalizedQuery = query
|
||||
};
|
||||
|
||||
var deterministic = _templateEngine.Synthesize(
|
||||
query,
|
||||
request.TopCards,
|
||||
plan,
|
||||
request.Preferences?.Locale ?? "en");
|
||||
|
||||
var prompt = _promptAssembler.Build(
|
||||
query,
|
||||
request.TopCards,
|
||||
plan,
|
||||
request.Preferences,
|
||||
deterministic.Summary);
|
||||
|
||||
var quotaDecision = _quotaService.TryAcquire(tenantId);
|
||||
if (!quotaDecision.Allowed)
|
||||
{
|
||||
await TrackAnalyticsAsync(
|
||||
tenantId,
|
||||
userId,
|
||||
query,
|
||||
request.TopCards.Count,
|
||||
(int)(_timeProvider.GetUtcNow() - startedAt).TotalMilliseconds,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
return new SearchSynthesisExecutionResult(
|
||||
deterministic.Summary,
|
||||
null,
|
||||
null,
|
||||
BuildActions(request.TopCards, request.Preferences),
|
||||
prompt.PromptVersion,
|
||||
Provider: "none",
|
||||
TotalTokens: prompt.EstimatedTokens,
|
||||
QuotaExceeded: true,
|
||||
LlmUnavailable: false,
|
||||
StatusCode: quotaDecision.Code);
|
||||
}
|
||||
|
||||
using var lease = quotaDecision.Lease;
|
||||
|
||||
if (!_knowledgeOptions.LlmSynthesisEnabled ||
|
||||
string.IsNullOrWhiteSpace(_knowledgeOptions.LlmAdapterBaseUrl) ||
|
||||
string.IsNullOrWhiteSpace(_knowledgeOptions.LlmProviderId))
|
||||
{
|
||||
await TrackAnalyticsAsync(
|
||||
tenantId,
|
||||
userId,
|
||||
query,
|
||||
request.TopCards.Count,
|
||||
(int)(_timeProvider.GetUtcNow() - startedAt).TotalMilliseconds,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
return new SearchSynthesisExecutionResult(
|
||||
deterministic.Summary,
|
||||
null,
|
||||
null,
|
||||
BuildActions(request.TopCards, request.Preferences),
|
||||
prompt.PromptVersion,
|
||||
Provider: "template",
|
||||
TotalTokens: prompt.EstimatedTokens,
|
||||
QuotaExceeded: false,
|
||||
LlmUnavailable: true,
|
||||
StatusCode: "llm_unavailable");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var llmResult = await _llmEngine.SynthesizeAsync(
|
||||
query,
|
||||
prompt.IncludedCards,
|
||||
plan.DetectedEntities,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
var llmSummary = llmResult?.Summary;
|
||||
var groundingScore = llmResult?.GroundingScore;
|
||||
var actions = BuildActions(request.TopCards, request.Preferences);
|
||||
var totalTokens = prompt.EstimatedTokens + EstimateTokens(llmSummary);
|
||||
var durationMs = (int)(_timeProvider.GetUtcNow() - startedAt).TotalMilliseconds;
|
||||
|
||||
await TrackAnalyticsAsync(tenantId, userId, query, request.TopCards.Count, durationMs, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new SearchSynthesisExecutionResult(
|
||||
deterministic.Summary,
|
||||
llmSummary,
|
||||
groundingScore,
|
||||
actions,
|
||||
prompt.PromptVersion,
|
||||
Provider: _knowledgeOptions.LlmProviderId,
|
||||
TotalTokens: totalTokens,
|
||||
QuotaExceeded: false,
|
||||
LlmUnavailable: llmResult is null,
|
||||
StatusCode: llmResult is null ? "llm_fallback" : "complete");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Search synthesis LLM execution failed; returning deterministic result.");
|
||||
var durationMs = (int)(_timeProvider.GetUtcNow() - startedAt).TotalMilliseconds;
|
||||
await TrackAnalyticsAsync(tenantId, userId, query, request.TopCards.Count, durationMs, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new SearchSynthesisExecutionResult(
|
||||
deterministic.Summary,
|
||||
null,
|
||||
null,
|
||||
BuildActions(request.TopCards, request.Preferences),
|
||||
prompt.PromptVersion,
|
||||
Provider: "template",
|
||||
TotalTokens: prompt.EstimatedTokens,
|
||||
QuotaExceeded: false,
|
||||
LlmUnavailable: true,
|
||||
StatusCode: "llm_error");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TrackAnalyticsAsync(
|
||||
string tenantId,
|
||||
string userId,
|
||||
string query,
|
||||
int sourceCount,
|
||||
int durationMs,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await _analytics.RecordEventAsync(new SearchAnalyticsEvent(
|
||||
TenantId: tenantId,
|
||||
EventType: "synthesis",
|
||||
Query: query,
|
||||
UserId: userId,
|
||||
ResultCount: sourceCount,
|
||||
DurationMs: durationMs), ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SearchSynthesisActionSuggestion> BuildActions(
|
||||
IReadOnlyList<EntityCard> cards,
|
||||
SearchSynthesisPreferences? preferences)
|
||||
{
|
||||
if (preferences?.IncludeActions == false)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return cards
|
||||
.OrderByDescending(static card => card.Score)
|
||||
.SelectMany(static card => card.Actions.Select(action => (Card: card, Action: action)))
|
||||
.Where(static item => !string.IsNullOrWhiteSpace(item.Action.Route))
|
||||
.Select(static item => new SearchSynthesisActionSuggestion(
|
||||
item.Action.Label,
|
||||
item.Action.Route!,
|
||||
item.Card.EntityKey))
|
||||
.DistinctBy(static action => $"{action.Label}|{action.Route}")
|
||||
.Take(4)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static int EstimateTokens(string? content)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.Max(1, (int)Math.Ceiling(content.Length / 4d));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,12 +24,24 @@ internal sealed class UnifiedSearchIndexRefreshService : BackgroundService
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (!_options.UnifiedAutoIndexEnabled)
|
||||
var liveAdaptersConfigured =
|
||||
!string.IsNullOrWhiteSpace(_options.FindingsAdapterBaseUrl) ||
|
||||
!string.IsNullOrWhiteSpace(_options.VexAdapterBaseUrl) ||
|
||||
!string.IsNullOrWhiteSpace(_options.PolicyAdapterBaseUrl);
|
||||
var autoIndexEnabled = _options.UnifiedAutoIndexEnabled || liveAdaptersConfigured;
|
||||
|
||||
if (!autoIndexEnabled)
|
||||
{
|
||||
_logger.LogDebug("Unified search auto-indexing is disabled.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_options.UnifiedAutoIndexEnabled && liveAdaptersConfigured)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Unified search auto-indexing was enabled implicitly because live adapter base URLs are configured.");
|
||||
}
|
||||
|
||||
if (_options.UnifiedAutoIndexOnStartup)
|
||||
{
|
||||
await SafeRebuildAsync(stoppingToken).ConfigureAwait(false);
|
||||
@@ -66,7 +78,12 @@ internal sealed class UnifiedSearchIndexRefreshService : BackgroundService
|
||||
{
|
||||
try
|
||||
{
|
||||
await _indexer.IndexAllAsync(cancellationToken).ConfigureAwait(false);
|
||||
var summary = await _indexer.IndexAllWithSummaryAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation(
|
||||
"Unified search periodic indexing run completed: domains={DomainCount}, chunks={ChunkCount}, duration_ms={DurationMs}",
|
||||
summary.DomainCount,
|
||||
summary.ChunkCount,
|
||||
summary.DurationMs);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
|
||||
@@ -27,34 +27,100 @@ internal sealed class UnifiedSearchIndexer : IUnifiedSearchIndexer
|
||||
}
|
||||
|
||||
public async Task IndexAllAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await IndexAllWithSummaryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
internal async Task<UnifiedSearchIndexSummary> IndexAllWithSummaryAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.Enabled || string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
_logger.LogDebug("Unified search indexing skipped because configuration is incomplete.");
|
||||
return;
|
||||
return new UnifiedSearchIndexSummary(0, 0, 0);
|
||||
}
|
||||
|
||||
foreach (var adapter in _adapters)
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var domains = 0;
|
||||
var chunks = 0;
|
||||
var changed = 0;
|
||||
var removed = 0;
|
||||
|
||||
foreach (var domainGroup in _adapters
|
||||
.GroupBy(static adapter => adapter.Domain, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static group => group.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Unified search indexing domain '{Domain}'.", adapter.Domain);
|
||||
var chunks = await adapter.ProduceChunksAsync(cancellationToken).ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (chunks.Count == 0)
|
||||
var domainStopwatch = Stopwatch.StartNew();
|
||||
var domain = domainGroup.Key;
|
||||
var domainChunks = new List<UnifiedChunk>();
|
||||
var hadSuccessfulAdapter = false;
|
||||
|
||||
foreach (var adapter in domainGroup)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("No chunks produced by adapter for domain '{Domain}'.", adapter.Domain);
|
||||
continue;
|
||||
_logger.LogInformation("Unified search indexing adapter '{Adapter}' for domain '{Domain}'.",
|
||||
adapter.GetType().Name,
|
||||
domain);
|
||||
var adapterChunks = await adapter.ProduceChunksAsync(cancellationToken).ConfigureAwait(false);
|
||||
domainChunks.AddRange(adapterChunks);
|
||||
hadSuccessfulAdapter = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Failed to index adapter '{Adapter}' for domain '{Domain}'; continuing with other adapters in this domain.",
|
||||
adapter.GetType().Name,
|
||||
domain);
|
||||
}
|
||||
}
|
||||
|
||||
await UpsertChunksAsync(chunks, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("Indexed {Count} chunks for domain '{Domain}'.", chunks.Count, adapter.Domain);
|
||||
}
|
||||
catch (Exception ex)
|
||||
if (!hadSuccessfulAdapter)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to index domain '{Domain}'; continuing with other adapters.", adapter.Domain);
|
||||
_logger.LogWarning(
|
||||
"Unified search skipped domain '{Domain}' because all adapters failed in this refresh cycle.",
|
||||
domain);
|
||||
continue;
|
||||
}
|
||||
|
||||
var deduplicated = DeduplicateChunks(domainChunks);
|
||||
var changedForDomain = 0;
|
||||
if (deduplicated.Count > 0)
|
||||
{
|
||||
changedForDomain = await UpsertChunksAsync(deduplicated, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var removedForDomain = await DeleteMissingChunksByDomainAsync(
|
||||
domain,
|
||||
deduplicated.Select(static chunk => chunk.ChunkId).ToArray(),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
domainStopwatch.Stop();
|
||||
domains++;
|
||||
chunks += deduplicated.Count;
|
||||
changed += changedForDomain;
|
||||
removed += removedForDomain;
|
||||
_logger.LogInformation(
|
||||
"Unified search refresh domain '{Domain}' completed: seen_chunks={SeenChunkCount}, changed_chunks={ChangedChunkCount}, removed={RemovedCount}, duration_ms={DurationMs}",
|
||||
domain,
|
||||
deduplicated.Count,
|
||||
changedForDomain,
|
||||
removedForDomain,
|
||||
(long)domainStopwatch.Elapsed.TotalMilliseconds);
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
_logger.LogInformation(
|
||||
"Unified search incremental indexing completed: domains={DomainCount}, seen_chunks={SeenChunkCount}, changed_chunks={ChangedChunkCount}, removed={RemovedCount}, duration_ms={DurationMs}",
|
||||
domains,
|
||||
chunks,
|
||||
changed,
|
||||
removed,
|
||||
(long)stopwatch.Elapsed.TotalMilliseconds);
|
||||
|
||||
return new UnifiedSearchIndexSummary(domains, chunks, (long)stopwatch.Elapsed.TotalMilliseconds);
|
||||
}
|
||||
|
||||
public async Task<UnifiedSearchIndexSummary> RebuildAllAsync(CancellationToken cancellationToken)
|
||||
@@ -69,24 +135,58 @@ internal sealed class UnifiedSearchIndexer : IUnifiedSearchIndexer
|
||||
var domains = 0;
|
||||
var chunks = 0;
|
||||
|
||||
foreach (var adapter in _adapters)
|
||||
foreach (var domainGroup in _adapters
|
||||
.GroupBy(static adapter => adapter.Domain, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static group => group.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
await DeleteChunksByDomainAsync(adapter.Domain, cancellationToken).ConfigureAwait(false);
|
||||
var domainChunks = await adapter.ProduceChunksAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (domainChunks.Count > 0)
|
||||
{
|
||||
await UpsertChunksAsync(domainChunks, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
domains++;
|
||||
chunks += domainChunks.Count;
|
||||
}
|
||||
catch (Exception ex)
|
||||
var domain = domainGroup.Key;
|
||||
var domainStopwatch = Stopwatch.StartNew();
|
||||
var domainChunks = new List<UnifiedChunk>();
|
||||
var hadSuccessfulAdapter = false;
|
||||
|
||||
foreach (var adapter in domainGroup)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to rebuild domain '{Domain}'; continuing with remaining domains.", adapter.Domain);
|
||||
try
|
||||
{
|
||||
var adapterChunks = await adapter.ProduceChunksAsync(cancellationToken).ConfigureAwait(false);
|
||||
domainChunks.AddRange(adapterChunks);
|
||||
hadSuccessfulAdapter = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Failed to rebuild adapter '{Adapter}' for domain '{Domain}'; continuing with other adapters in this domain.",
|
||||
adapter.GetType().Name,
|
||||
domain);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hadSuccessfulAdapter)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unified search rebuild skipped domain '{Domain}' because all adapters failed.",
|
||||
domain);
|
||||
continue;
|
||||
}
|
||||
|
||||
await DeleteChunksByDomainAsync(domain, cancellationToken).ConfigureAwait(false);
|
||||
var deduplicated = DeduplicateChunks(domainChunks);
|
||||
if (deduplicated.Count > 0)
|
||||
{
|
||||
await UpsertChunksAsync(deduplicated, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
domainStopwatch.Stop();
|
||||
domains++;
|
||||
chunks += deduplicated.Count;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Unified search rebuild domain '{Domain}' completed: chunks={ChunkCount}, duration_ms={DurationMs}",
|
||||
domain,
|
||||
deduplicated.Count,
|
||||
(long)domainStopwatch.Elapsed.TotalMilliseconds);
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
@@ -108,7 +208,42 @@ internal sealed class UnifiedSearchIndexer : IUnifiedSearchIndexer
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task UpsertChunksAsync(IReadOnlyList<UnifiedChunk> chunks, CancellationToken cancellationToken)
|
||||
private async Task<int> DeleteMissingChunksByDomainAsync(
|
||||
string domain,
|
||||
IReadOnlyCollection<string> currentChunkIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_options.Enabled || string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
await using var dataSource = new NpgsqlDataSourceBuilder(_options.ConnectionString).Build();
|
||||
await using var command = dataSource.CreateCommand();
|
||||
command.CommandTimeout = 90;
|
||||
command.Parameters.AddWithValue("domain", domain);
|
||||
|
||||
if (currentChunkIds.Count == 0)
|
||||
{
|
||||
command.CommandText = "DELETE FROM advisoryai.kb_chunk WHERE domain = @domain;";
|
||||
}
|
||||
else
|
||||
{
|
||||
command.CommandText = """
|
||||
DELETE FROM advisoryai.kb_chunk
|
||||
WHERE domain = @domain
|
||||
AND NOT (chunk_id = ANY(@chunk_ids));
|
||||
""";
|
||||
command.Parameters.AddWithValue(
|
||||
"chunk_ids",
|
||||
NpgsqlDbType.Array | NpgsqlDbType.Text,
|
||||
currentChunkIds.ToArray());
|
||||
}
|
||||
|
||||
return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<int> UpsertChunksAsync(IReadOnlyList<UnifiedChunk> chunks, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var dataSource = new NpgsqlDataSourceBuilder(_options.ConnectionString).Build();
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -140,7 +275,12 @@ internal sealed class UnifiedSearchIndexer : IUnifiedSearchIndexer
|
||||
NOW()
|
||||
)
|
||||
ON CONFLICT (chunk_id) DO UPDATE SET
|
||||
doc_id = EXCLUDED.doc_id,
|
||||
kind = EXCLUDED.kind,
|
||||
anchor = EXCLUDED.anchor,
|
||||
section_path = EXCLUDED.section_path,
|
||||
span_start = EXCLUDED.span_start,
|
||||
span_end = EXCLUDED.span_end,
|
||||
title = EXCLUDED.title,
|
||||
body = EXCLUDED.body,
|
||||
body_tsv = EXCLUDED.body_tsv,
|
||||
@@ -150,13 +290,29 @@ internal sealed class UnifiedSearchIndexer : IUnifiedSearchIndexer
|
||||
entity_key = EXCLUDED.entity_key,
|
||||
entity_type = EXCLUDED.entity_type,
|
||||
freshness = EXCLUDED.freshness,
|
||||
indexed_at = NOW();
|
||||
indexed_at = NOW()
|
||||
WHERE advisoryai.kb_chunk.doc_id IS DISTINCT FROM EXCLUDED.doc_id
|
||||
OR advisoryai.kb_chunk.kind IS DISTINCT FROM EXCLUDED.kind
|
||||
OR advisoryai.kb_chunk.anchor IS DISTINCT FROM EXCLUDED.anchor
|
||||
OR advisoryai.kb_chunk.section_path IS DISTINCT FROM EXCLUDED.section_path
|
||||
OR advisoryai.kb_chunk.span_start IS DISTINCT FROM EXCLUDED.span_start
|
||||
OR advisoryai.kb_chunk.span_end IS DISTINCT FROM EXCLUDED.span_end
|
||||
OR advisoryai.kb_chunk.title IS DISTINCT FROM EXCLUDED.title
|
||||
OR advisoryai.kb_chunk.body IS DISTINCT FROM EXCLUDED.body
|
||||
OR advisoryai.kb_chunk.body_tsv IS DISTINCT FROM EXCLUDED.body_tsv
|
||||
OR advisoryai.kb_chunk.embedding IS DISTINCT FROM EXCLUDED.embedding
|
||||
OR advisoryai.kb_chunk.metadata IS DISTINCT FROM EXCLUDED.metadata
|
||||
OR advisoryai.kb_chunk.domain IS DISTINCT FROM EXCLUDED.domain
|
||||
OR advisoryai.kb_chunk.entity_key IS DISTINCT FROM EXCLUDED.entity_key
|
||||
OR advisoryai.kb_chunk.entity_type IS DISTINCT FROM EXCLUDED.entity_type
|
||||
OR advisoryai.kb_chunk.freshness IS DISTINCT FROM EXCLUDED.freshness;
|
||||
""";
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
command.CommandTimeout = 120;
|
||||
|
||||
var affectedRows = 0;
|
||||
foreach (var chunk in chunks)
|
||||
{
|
||||
command.Parameters.Clear();
|
||||
@@ -180,8 +336,10 @@ internal sealed class UnifiedSearchIndexer : IUnifiedSearchIndexer
|
||||
command.Parameters.AddWithValue("freshness",
|
||||
chunk.Freshness.HasValue ? (object)chunk.Freshness.Value : DBNull.Value);
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
affectedRows += await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return affectedRows;
|
||||
}
|
||||
|
||||
private static async Task EnsureDocumentExistsAsync(
|
||||
@@ -211,6 +369,22 @@ internal sealed class UnifiedSearchIndexer : IUnifiedSearchIndexer
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<UnifiedChunk> DeduplicateChunks(IEnumerable<UnifiedChunk> chunks)
|
||||
{
|
||||
var byChunkId = new SortedDictionary<string, UnifiedChunk>(StringComparer.Ordinal);
|
||||
foreach (var chunk in chunks)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(chunk.ChunkId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
byChunkId[chunk.ChunkId] = chunk;
|
||||
}
|
||||
|
||||
return byChunkId.Values.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record UnifiedSearchIndexSummary(
|
||||
|
||||
@@ -24,7 +24,8 @@ public sealed record UnifiedSearchRequest(
|
||||
int? K = null,
|
||||
UnifiedSearchFilter? Filters = null,
|
||||
bool IncludeSynthesis = true,
|
||||
bool IncludeDebug = false);
|
||||
bool IncludeDebug = false,
|
||||
AmbientContext? Ambient = null);
|
||||
|
||||
public sealed record UnifiedSearchFilter
|
||||
{
|
||||
@@ -50,6 +51,24 @@ public sealed record UnifiedSearchFilter
|
||||
/// Not serialized in API responses.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? UserScopes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional user identifier for ephemeral session context carry-forward.
|
||||
/// </summary>
|
||||
public string? UserId { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AmbientContext
|
||||
{
|
||||
public string? CurrentRoute { get; init; }
|
||||
|
||||
public IReadOnlyList<string>? VisibleEntityKeys { get; init; }
|
||||
|
||||
public IReadOnlyList<string>? RecentSearches { get; init; }
|
||||
|
||||
public string? SessionId { get; init; }
|
||||
|
||||
public bool ResetSession { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SearchSuggestion(string Text, string Reason);
|
||||
@@ -88,6 +107,28 @@ public sealed record EntityCard
|
||||
public IReadOnlyList<string> Sources { get; init; } = [];
|
||||
|
||||
public EntityCardPreview? Preview { get; init; }
|
||||
|
||||
public IReadOnlyList<EntityCardFacet> Facets { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<string> Connections { get; init; } = [];
|
||||
|
||||
public IReadOnlyDictionary<string, string> SynthesisHints { get; init; } =
|
||||
new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public sealed record EntityCardFacet
|
||||
{
|
||||
public string Domain { get; init; } = "knowledge";
|
||||
|
||||
public string Title { get; init; } = string.Empty;
|
||||
|
||||
public string Snippet { get; init; } = string.Empty;
|
||||
|
||||
public double Score { get; init; }
|
||||
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
|
||||
public IReadOnlyList<EntityCardAction> Actions { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed record EntityCardPreview(
|
||||
@@ -138,7 +179,15 @@ public sealed record UnifiedSearchDiagnostics(
|
||||
long DurationMs,
|
||||
bool UsedVector,
|
||||
string Mode,
|
||||
QueryPlan? Plan = null);
|
||||
QueryPlan? Plan = null,
|
||||
IReadOnlyList<FederationBackendDiagnostic>? Federation = null);
|
||||
|
||||
public sealed record FederationBackendDiagnostic(
|
||||
string Backend,
|
||||
int ResultCount,
|
||||
long DurationMs,
|
||||
bool TimedOut,
|
||||
string Status);
|
||||
|
||||
public sealed record QueryPlan
|
||||
{
|
||||
@@ -152,6 +201,9 @@ public sealed record QueryPlan
|
||||
|
||||
public IReadOnlyDictionary<string, double> DomainWeights { get; init; } =
|
||||
new Dictionary<string, double>(StringComparer.Ordinal);
|
||||
|
||||
public IReadOnlyDictionary<string, double> ContextEntityBoosts { get; init; } =
|
||||
new Dictionary<string, double>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public sealed record EntityMention(
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.UnifiedSearch;
|
||||
|
||||
public sealed class UnifiedSearchOptions
|
||||
{
|
||||
public const string SectionName = "AdvisoryAI:UnifiedSearch";
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
[Range(1, 100)]
|
||||
public int DefaultTopK { get; set; } = 10;
|
||||
|
||||
[Range(1, 4096)]
|
||||
public int MaxQueryLength { get; set; } = 512;
|
||||
|
||||
[Range(1, 200)]
|
||||
public int MaxCards { get; set; } = 20;
|
||||
|
||||
public Dictionary<string, double> BaseDomainWeights { get; set; } = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["knowledge"] = 1.0,
|
||||
["findings"] = 1.0,
|
||||
["vex"] = 1.0,
|
||||
["policy"] = 1.0,
|
||||
["platform"] = 1.0,
|
||||
["graph"] = 1.0,
|
||||
["timeline"] = 1.0,
|
||||
["scanner"] = 1.0,
|
||||
["opsmemory"] = 1.0
|
||||
};
|
||||
|
||||
public UnifiedSearchWeightingOptions Weighting { get; set; } = new();
|
||||
|
||||
public Dictionary<string, UnifiedSearchTenantFeatureFlags> TenantFeatureFlags { get; set; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public UnifiedSearchFederationOptions Federation { get; set; } = new();
|
||||
|
||||
public UnifiedSearchGravityBoostOptions GravityBoost { get; set; } = new();
|
||||
|
||||
public UnifiedSearchSynthesisOptions Synthesis { get; set; } = new();
|
||||
|
||||
public UnifiedSearchIngestionOptions Ingestion { get; set; } = new();
|
||||
|
||||
public UnifiedSearchSessionOptions Session { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class UnifiedSearchWeightingOptions
|
||||
{
|
||||
[Range(0.0, 2.0)]
|
||||
public double CveBoostFindings { get; set; } = 0.35;
|
||||
|
||||
[Range(0.0, 2.0)]
|
||||
public double CveBoostVex { get; set; } = 0.30;
|
||||
|
||||
[Range(0.0, 2.0)]
|
||||
public double CveBoostGraph { get; set; } = 0.25;
|
||||
|
||||
[Range(0.0, 2.0)]
|
||||
public double SecurityBoostFindings { get; set; } = 0.20;
|
||||
|
||||
[Range(0.0, 2.0)]
|
||||
public double SecurityBoostVex { get; set; } = 0.15;
|
||||
|
||||
[Range(0.0, 2.0)]
|
||||
public double PolicyBoostPolicy { get; set; } = 0.30;
|
||||
|
||||
[Range(0.0, 2.0)]
|
||||
public double TroubleshootBoostKnowledge { get; set; } = 0.15;
|
||||
|
||||
[Range(0.0, 2.0)]
|
||||
public double TroubleshootBoostOpsMemory { get; set; } = 0.10;
|
||||
|
||||
[Range(0.0, 2.0)]
|
||||
public double PackageBoostGraph { get; set; } = 0.36;
|
||||
|
||||
[Range(0.0, 2.0)]
|
||||
public double PackageBoostScanner { get; set; } = 0.28;
|
||||
|
||||
[Range(0.0, 2.0)]
|
||||
public double PackageBoostFindings { get; set; } = 0.12;
|
||||
|
||||
[Range(0.0, 2.0)]
|
||||
public double AuditBoostTimeline { get; set; } = 0.24;
|
||||
|
||||
[Range(0.0, 2.0)]
|
||||
public double AuditBoostOpsMemory { get; set; } = 0.24;
|
||||
|
||||
[Range(0.0, 2.0)]
|
||||
public double FilterDomainMatchBoost { get; set; } = 0.25;
|
||||
|
||||
[Range(0.0, 2.0)]
|
||||
public double RoleScannerFindingsBoost { get; set; } = 0.15;
|
||||
|
||||
[Range(0.0, 2.0)]
|
||||
public double RoleScannerVexBoost { get; set; } = 0.10;
|
||||
|
||||
[Range(0.0, 2.0)]
|
||||
public double RolePolicyBoost { get; set; } = 0.20;
|
||||
|
||||
[Range(0.0, 2.0)]
|
||||
public double RoleOpsKnowledgeBoost { get; set; } = 0.15;
|
||||
|
||||
[Range(0.0, 2.0)]
|
||||
public double RoleOpsMemoryBoost { get; set; } = 0.10;
|
||||
|
||||
[Range(0.0, 2.0)]
|
||||
public double RoleReleasePolicyBoost { get; set; } = 0.10;
|
||||
|
||||
[Range(0.0, 2.0)]
|
||||
public double RoleReleaseFindingsBoost { get; set; } = 0.10;
|
||||
}
|
||||
|
||||
public sealed class UnifiedSearchTenantFeatureFlags
|
||||
{
|
||||
public bool? Enabled { get; set; }
|
||||
|
||||
public bool? FederationEnabled { get; set; }
|
||||
|
||||
public bool? SynthesisEnabled { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UnifiedSearchFederationOptions
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public string ConsoleEndpoint { get; set; } = string.Empty;
|
||||
|
||||
public string GraphEndpoint { get; set; } = string.Empty;
|
||||
|
||||
public string TimelineEndpoint { get; set; } = string.Empty;
|
||||
|
||||
[Range(100, 30_000)]
|
||||
public int TimeoutBudgetMs { get; set; } = 500;
|
||||
|
||||
[Range(1, 500)]
|
||||
public int MaxFederatedResults { get; set; } = 50;
|
||||
|
||||
[Range(0.0, 10.0)]
|
||||
public double FederationThreshold { get; set; } = 1.2;
|
||||
}
|
||||
|
||||
public sealed class UnifiedSearchGravityBoostOptions
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
[Range(0.0, 2.0)]
|
||||
public double OneHopBoost { get; set; } = 0.30;
|
||||
|
||||
[Range(0.0, 2.0)]
|
||||
public double TwoHopBoost { get; set; } = 0.10;
|
||||
|
||||
[Range(1, 200)]
|
||||
public int MaxNeighborsPerEntity { get; set; } = 20;
|
||||
|
||||
[Range(1, 500)]
|
||||
public int MaxTotalNeighbors { get; set; } = 50;
|
||||
|
||||
[Range(25, 5000)]
|
||||
public int TimeoutMs { get; set; } = 100;
|
||||
}
|
||||
|
||||
public sealed class UnifiedSearchSynthesisOptions
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
[Range(256, 32_768)]
|
||||
public int MaxContextTokens { get; set; } = 4000;
|
||||
|
||||
public string PromptPath { get; set; } = "src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Synthesis/synthesis-system-prompt.txt";
|
||||
|
||||
[Range(1, 5000)]
|
||||
public int SynthesisRequestsPerDay { get; set; } = 200;
|
||||
|
||||
[Range(1, 200)]
|
||||
public int MaxConcurrentPerTenant { get; set; } = 10;
|
||||
}
|
||||
|
||||
public sealed class UnifiedSearchIngestionOptions
|
||||
{
|
||||
public string GraphSnapshotPath { get; set; } =
|
||||
"src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Snapshots/graph.snapshot.json";
|
||||
|
||||
public string OpsMemorySnapshotPath { get; set; } =
|
||||
"src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Snapshots/opsmemory.snapshot.json";
|
||||
|
||||
public string TimelineSnapshotPath { get; set; } =
|
||||
"src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Snapshots/timeline.snapshot.json";
|
||||
|
||||
public string ScannerSnapshotPath { get; set; } =
|
||||
"src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Snapshots/scanner.snapshot.json";
|
||||
|
||||
public List<string> GraphNodeKindFilter { get; set; } = ["package", "image", "base_image", "registry"];
|
||||
|
||||
[Range(1, 3650)]
|
||||
public int TimelineRetentionDays { get; set; } = 90;
|
||||
}
|
||||
|
||||
public sealed class UnifiedSearchSessionOptions
|
||||
{
|
||||
[Range(30, 3600)]
|
||||
public int InactivitySeconds { get; set; } = 300;
|
||||
}
|
||||
@@ -2,10 +2,16 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.Analytics;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.Cards;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.Context;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.Federation;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.QueryUnderstanding;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.Ranking;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.Synthesis;
|
||||
using StellaOps.AdvisoryAI.Vectorization;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.UnifiedSearch;
|
||||
@@ -13,6 +19,7 @@ namespace StellaOps.AdvisoryAI.UnifiedSearch;
|
||||
internal sealed class UnifiedSearchService : IUnifiedSearchService
|
||||
{
|
||||
private readonly KnowledgeSearchOptions _options;
|
||||
private readonly UnifiedSearchOptions _unifiedOptions;
|
||||
private readonly IKnowledgeSearchStore _store;
|
||||
private readonly IVectorEncoder _vectorEncoder;
|
||||
private readonly QueryPlanBuilder _queryPlanBuilder;
|
||||
@@ -20,9 +27,23 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
|
||||
private readonly SearchAnalyticsService _analyticsService;
|
||||
private readonly SearchQualityMonitor _qualityMonitor;
|
||||
private readonly IEntityAliasService _entityAliasService;
|
||||
private readonly FederatedSearchDispatcher? _federatedDispatcher;
|
||||
private readonly GravityBoostCalculator? _gravityBoostCalculator;
|
||||
private readonly AmbientContextProcessor _ambientContextProcessor;
|
||||
private readonly SearchSessionContextService _searchSessionContext;
|
||||
private readonly EntityCardAssembler? _entityCardAssembler;
|
||||
private readonly ILogger<UnifiedSearchService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IUnifiedSearchTelemetrySink? _telemetrySink;
|
||||
private static readonly Regex SnippetScriptTagRegex = new(
|
||||
"<script[^>]*>.*?</script>",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||
private static readonly Regex SnippetHtmlTagRegex = new(
|
||||
"<[^>]+>",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
private static readonly Regex SnippetWhitespaceRegex = new(
|
||||
@"\s+",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
// Cached popularity map (Sprint 106 / G6)
|
||||
private IReadOnlyDictionary<string, int>? _popularityMapCache;
|
||||
@@ -44,10 +65,17 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
|
||||
IEntityAliasService entityAliasService,
|
||||
ILogger<UnifiedSearchService> logger,
|
||||
TimeProvider timeProvider,
|
||||
IUnifiedSearchTelemetrySink? telemetrySink = null)
|
||||
IUnifiedSearchTelemetrySink? telemetrySink = null,
|
||||
IOptions<UnifiedSearchOptions>? unifiedOptions = null,
|
||||
FederatedSearchDispatcher? federatedDispatcher = null,
|
||||
GravityBoostCalculator? gravityBoostCalculator = null,
|
||||
AmbientContextProcessor? ambientContextProcessor = null,
|
||||
SearchSessionContextService? searchSessionContext = null,
|
||||
EntityCardAssembler? entityCardAssembler = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options.Value ?? new KnowledgeSearchOptions();
|
||||
_unifiedOptions = unifiedOptions?.Value ?? new UnifiedSearchOptions();
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_vectorEncoder = vectorEncoder ?? throw new ArgumentNullException(nameof(vectorEncoder));
|
||||
_queryPlanBuilder = queryPlanBuilder ?? throw new ArgumentNullException(nameof(queryPlanBuilder));
|
||||
@@ -55,6 +83,11 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
|
||||
_analyticsService = analyticsService ?? throw new ArgumentNullException(nameof(analyticsService));
|
||||
_qualityMonitor = qualityMonitor ?? throw new ArgumentNullException(nameof(qualityMonitor));
|
||||
_entityAliasService = entityAliasService ?? throw new ArgumentNullException(nameof(entityAliasService));
|
||||
_federatedDispatcher = federatedDispatcher;
|
||||
_gravityBoostCalculator = gravityBoostCalculator;
|
||||
_ambientContextProcessor = ambientContextProcessor ?? new AmbientContextProcessor();
|
||||
_searchSessionContext = searchSessionContext ?? new SearchSessionContextService();
|
||||
_entityCardAssembler = entityCardAssembler;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_telemetrySink = telemetrySink;
|
||||
@@ -71,12 +104,47 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
|
||||
return EmptyResponse(string.Empty, request.K, "empty");
|
||||
}
|
||||
|
||||
if (!_options.Enabled || string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
if (query.Length > _unifiedOptions.MaxQueryLength)
|
||||
{
|
||||
return EmptyResponse(query, request.K, "query_too_long");
|
||||
}
|
||||
|
||||
var tenantId = request.Filters?.Tenant ?? "global";
|
||||
var userId = request.Filters?.UserId ?? "anonymous";
|
||||
var tenantFlags = ResolveTenantFeatureFlags(tenantId);
|
||||
|
||||
if (!_options.Enabled || !IsSearchEnabledForTenant(tenantFlags) || string.IsNullOrWhiteSpace(_options.ConnectionString))
|
||||
{
|
||||
return EmptyResponse(query, request.K, "disabled");
|
||||
}
|
||||
|
||||
var plan = _queryPlanBuilder.Build(request);
|
||||
if (request.Ambient?.ResetSession == true &&
|
||||
!string.IsNullOrWhiteSpace(request.Ambient.SessionId))
|
||||
{
|
||||
_searchSessionContext.Reset(tenantId, userId, request.Ambient.SessionId);
|
||||
}
|
||||
|
||||
var sessionTtl = TimeSpan.FromSeconds(Math.Max(30, _unifiedOptions.Session.InactivitySeconds));
|
||||
var sessionSnapshot = !string.IsNullOrWhiteSpace(request.Ambient?.SessionId)
|
||||
? _searchSessionContext.GetSnapshot(
|
||||
tenantId,
|
||||
userId,
|
||||
request.Ambient!.SessionId!,
|
||||
startedAt,
|
||||
sessionTtl)
|
||||
: SearchSessionSnapshot.Empty;
|
||||
|
||||
var basePlan = _queryPlanBuilder.Build(request);
|
||||
var carriedEntities = _ambientContextProcessor.CarryForwardEntities(basePlan.DetectedEntities, sessionSnapshot);
|
||||
var boostedWeights = _ambientContextProcessor.ApplyRouteBoost(basePlan.DomainWeights, request.Ambient);
|
||||
var contextEntityBoosts = _ambientContextProcessor.BuildEntityBoostMap(request.Ambient, sessionSnapshot);
|
||||
var plan = basePlan with
|
||||
{
|
||||
DetectedEntities = carriedEntities,
|
||||
DomainWeights = boostedWeights,
|
||||
ContextEntityBoosts = contextEntityBoosts
|
||||
};
|
||||
|
||||
var topK = ResolveTopK(request.K);
|
||||
var timeout = TimeSpan.FromMilliseconds(Math.Max(250, _options.QueryTimeoutMs));
|
||||
|
||||
@@ -90,7 +158,21 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
|
||||
timeout,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var lexicalRanks = ftsRows
|
||||
var federationDiagnostics = Array.Empty<FederationBackendDiagnostic>();
|
||||
var federatedRows = Array.Empty<KnowledgeChunkRow>();
|
||||
if (_federatedDispatcher is not null && IsFederationEnabledForTenant(tenantFlags))
|
||||
{
|
||||
var dispatch = await _federatedDispatcher.DispatchAsync(
|
||||
query,
|
||||
plan,
|
||||
request.Filters,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
federatedRows = dispatch.Rows.ToArray();
|
||||
federationDiagnostics = dispatch.Diagnostics.ToArray();
|
||||
}
|
||||
|
||||
var lexicalRows = MergeLexicalRows(ftsRows, federatedRows);
|
||||
var lexicalRanks = lexicalRows
|
||||
.Select((row, index) => (row.ChunkId, Rank: index + 1, Row: row))
|
||||
.ToDictionary(static item => item.ChunkId, static item => item, StringComparer.Ordinal);
|
||||
|
||||
@@ -135,10 +217,19 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
|
||||
if (_options.PopularityBoostEnabled && _options.PopularityBoostWeight > 0d)
|
||||
{
|
||||
popularityMap = await GetPopularityMapAsync(
|
||||
request.Filters?.Tenant ?? "global", cancellationToken).ConfigureAwait(false);
|
||||
tenantId, cancellationToken).ConfigureAwait(false);
|
||||
popularityWeight = _options.PopularityBoostWeight;
|
||||
}
|
||||
|
||||
IReadOnlyDictionary<string, double>? gravityBoostMap = null;
|
||||
if (_gravityBoostCalculator is not null)
|
||||
{
|
||||
gravityBoostMap = await _gravityBoostCalculator.BuildGravityMapAsync(
|
||||
plan.DetectedEntities,
|
||||
tenantId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var merged = WeightedRrfFusion.Fuse(
|
||||
plan.DomainWeights,
|
||||
lexicalRanks,
|
||||
@@ -149,15 +240,36 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
|
||||
_options.UnifiedFreshnessBoostEnabled,
|
||||
startedAt,
|
||||
popularityMap,
|
||||
popularityWeight);
|
||||
popularityWeight,
|
||||
plan.ContextEntityBoosts,
|
||||
gravityBoostMap);
|
||||
|
||||
var topResults = merged.Take(topK).ToArray();
|
||||
var cards = topResults
|
||||
var cardLimit = Math.Min(topK, Math.Max(1, _unifiedOptions.MaxCards));
|
||||
var topResults = merged.Take(cardLimit).ToArray();
|
||||
var flatCards = topResults
|
||||
.Select(item => BuildEntityCard(item.Row, item.Score, item.Debug))
|
||||
.ToArray();
|
||||
|
||||
IReadOnlyList<EntityCard> cards = flatCards;
|
||||
if (_entityCardAssembler is not null)
|
||||
{
|
||||
cards = await _entityCardAssembler.AssembleAsync(flatCards, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
cards = cards.Take(Math.Max(1, _unifiedOptions.MaxCards)).ToArray();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Ambient?.SessionId))
|
||||
{
|
||||
_searchSessionContext.RecordQuery(
|
||||
tenantId,
|
||||
userId,
|
||||
request.Ambient!.SessionId!,
|
||||
plan.DetectedEntities,
|
||||
_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
SynthesisResult? synthesis = null;
|
||||
if (request.IncludeSynthesis && cards.Length > 0)
|
||||
if (request.IncludeSynthesis && IsSynthesisEnabledForTenant(tenantFlags) && cards.Count > 0)
|
||||
{
|
||||
synthesis = await _synthesisEngine.SynthesizeAsync(
|
||||
query, cards, plan.DetectedEntities, cancellationToken).ConfigureAwait(false);
|
||||
@@ -165,19 +277,18 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
|
||||
|
||||
// G4-003: Generate "Did you mean?" suggestions when results are sparse
|
||||
IReadOnlyList<SearchSuggestion>? suggestions = null;
|
||||
if (cards.Length < _options.MinFtsResultsForFuzzyFallback && _options.FuzzyFallbackEnabled)
|
||||
if (cards.Count < _options.MinFtsResultsForFuzzyFallback && _options.FuzzyFallbackEnabled)
|
||||
{
|
||||
suggestions = await GenerateSuggestionsAsync(
|
||||
query, storeFilter, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// G10-004: Generate query refinement suggestions from feedback data
|
||||
var tenantId = request.Filters?.Tenant ?? "global";
|
||||
IReadOnlyList<SearchRefinement>? refinements = null;
|
||||
if (cards.Length < RefinementResultThreshold)
|
||||
if (cards.Count < RefinementResultThreshold)
|
||||
{
|
||||
refinements = await GenerateRefinementsAsync(
|
||||
tenantId, query, cards.Length, cancellationToken).ConfigureAwait(false);
|
||||
tenantId, query, cards.Count, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var duration = _timeProvider.GetUtcNow() - startedAt;
|
||||
@@ -189,11 +300,12 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
|
||||
new UnifiedSearchDiagnostics(
|
||||
ftsRows.Count,
|
||||
vectorRows.Length,
|
||||
cards.Length,
|
||||
cards.Count,
|
||||
(long)duration.TotalMilliseconds,
|
||||
usedVector,
|
||||
usedVector ? "hybrid" : "fts-only",
|
||||
plan),
|
||||
plan,
|
||||
federationDiagnostics),
|
||||
suggestions,
|
||||
refinements);
|
||||
|
||||
@@ -211,13 +323,18 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
|
||||
var entityKey = GetMetadataString(metadata, "entity_key") ?? BuildDefaultEntityKey(row);
|
||||
var entityType = GetMetadataString(metadata, "entity_type") ?? MapKindToEntityType(row.Kind);
|
||||
var severity = GetMetadataString(metadata, "severity");
|
||||
var snippet = string.IsNullOrWhiteSpace(row.Snippet)
|
||||
? KnowledgeSearchText.BuildSnippet(row.Body, "")
|
||||
: row.Snippet;
|
||||
var snippet = SanitizeSnippet(
|
||||
string.IsNullOrWhiteSpace(row.Snippet)
|
||||
? KnowledgeSearchText.BuildSnippet(row.Body, "")
|
||||
: row.Snippet);
|
||||
|
||||
var actions = BuildActions(row, domain);
|
||||
var sources = new List<string> { domain };
|
||||
var preview = BuildPreview(row, domain);
|
||||
var metadataMap = BuildCardMetadata(metadata);
|
||||
metadataMap["domain"] = domain;
|
||||
metadataMap["kind"] = row.Kind;
|
||||
metadataMap["chunkId"] = row.ChunkId;
|
||||
|
||||
return new EntityCard
|
||||
{
|
||||
@@ -229,8 +346,27 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
|
||||
Score = score,
|
||||
Severity = severity,
|
||||
Actions = actions,
|
||||
Metadata = metadataMap,
|
||||
Sources = sources,
|
||||
Preview = preview
|
||||
Preview = preview,
|
||||
Facets =
|
||||
[
|
||||
new EntityCardFacet
|
||||
{
|
||||
Domain = domain,
|
||||
Title = row.Title,
|
||||
Snippet = snippet,
|
||||
Score = score,
|
||||
Metadata = metadataMap,
|
||||
Actions = actions
|
||||
}
|
||||
],
|
||||
SynthesisHints = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["entityKey"] = entityKey,
|
||||
["entityType"] = entityType,
|
||||
["domain"] = domain
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -469,6 +605,50 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
|
||||
true));
|
||||
break;
|
||||
}
|
||||
case "graph":
|
||||
{
|
||||
var nodeId = GetMetadataString(metadata, "nodeId") ?? row.Title;
|
||||
actions.Add(new EntityCardAction(
|
||||
"Open Graph",
|
||||
"navigate",
|
||||
$"/ops/graph?node={Uri.EscapeDataString(nodeId)}",
|
||||
null,
|
||||
true));
|
||||
break;
|
||||
}
|
||||
case "timeline":
|
||||
{
|
||||
var eventId = GetMetadataString(metadata, "eventId") ?? row.Title;
|
||||
actions.Add(new EntityCardAction(
|
||||
"Open Event",
|
||||
"navigate",
|
||||
$"/ops/audit/events/{Uri.EscapeDataString(eventId)}",
|
||||
null,
|
||||
true));
|
||||
break;
|
||||
}
|
||||
case "scanner":
|
||||
{
|
||||
var scanId = GetMetadataString(metadata, "scanId") ?? row.Title;
|
||||
actions.Add(new EntityCardAction(
|
||||
"Open Scan",
|
||||
"navigate",
|
||||
$"/console/scans/{Uri.EscapeDataString(scanId)}",
|
||||
null,
|
||||
true));
|
||||
break;
|
||||
}
|
||||
case "opsmemory":
|
||||
{
|
||||
var decisionId = GetMetadataString(metadata, "decisionId") ?? row.Title;
|
||||
actions.Add(new EntityCardAction(
|
||||
"Open Decision",
|
||||
"navigate",
|
||||
$"/ops/opsmemory/decisions/{Uri.EscapeDataString(decisionId)}",
|
||||
null,
|
||||
true));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
{
|
||||
actions.Add(new EntityCardAction(
|
||||
@@ -499,6 +679,10 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
|
||||
"vex_statement" => "vex",
|
||||
"policy_rule" => "policy",
|
||||
"platform_entity" => "platform",
|
||||
"graph_node" => "graph",
|
||||
"audit_event" => "timeline",
|
||||
"scan_result" => "scanner",
|
||||
"ops_decision" => "opsmemory",
|
||||
_ => "knowledge"
|
||||
};
|
||||
}
|
||||
@@ -508,6 +692,41 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
|
||||
return $"{row.Kind}:{row.ChunkId[..Math.Min(16, row.ChunkId.Length)]}";
|
||||
}
|
||||
|
||||
private static IReadOnlyList<KnowledgeChunkRow> MergeLexicalRows(
|
||||
IReadOnlyList<KnowledgeChunkRow> primaryRows,
|
||||
IReadOnlyList<KnowledgeChunkRow> federatedRows)
|
||||
{
|
||||
if (federatedRows.Count == 0)
|
||||
{
|
||||
return primaryRows;
|
||||
}
|
||||
|
||||
var byChunk = new Dictionary<string, KnowledgeChunkRow>(StringComparer.Ordinal);
|
||||
foreach (var row in primaryRows)
|
||||
{
|
||||
byChunk[row.ChunkId] = row;
|
||||
}
|
||||
|
||||
foreach (var row in federatedRows)
|
||||
{
|
||||
if (!byChunk.TryGetValue(row.ChunkId, out var existing))
|
||||
{
|
||||
byChunk[row.ChunkId] = row;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (row.LexicalScore > existing.LexicalScore)
|
||||
{
|
||||
byChunk[row.ChunkId] = row;
|
||||
}
|
||||
}
|
||||
|
||||
return byChunk.Values
|
||||
.OrderByDescending(static row => row.LexicalScore)
|
||||
.ThenBy(static row => row.ChunkId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string MapKindToEntityType(string kind)
|
||||
{
|
||||
return kind switch
|
||||
@@ -517,9 +736,13 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
|
||||
"doctor_check" => "doctor",
|
||||
"finding" => "finding",
|
||||
"vex_statement" => "vex_statement",
|
||||
"policy_rule" => "policy_rule",
|
||||
"platform_entity" => "platform_entity",
|
||||
_ => kind
|
||||
"policy_rule" => "policy_rule",
|
||||
"platform_entity" => "platform_entity",
|
||||
"graph_node" => "graph_node",
|
||||
"audit_event" => "event",
|
||||
"scan_result" => "scan",
|
||||
"ops_decision" => "finding",
|
||||
_ => kind
|
||||
};
|
||||
}
|
||||
|
||||
@@ -555,9 +778,21 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
|
||||
case "platform":
|
||||
kinds.Add("platform_entity");
|
||||
break;
|
||||
case "graph":
|
||||
kinds.Add("graph_node");
|
||||
break;
|
||||
case "timeline":
|
||||
kinds.Add("audit_event");
|
||||
break;
|
||||
case "scanner":
|
||||
kinds.Add("scan_result");
|
||||
break;
|
||||
case "opsmemory":
|
||||
kinds.Add("ops_decision");
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentException(
|
||||
$"Unsupported filter domain '{domain}'. Supported values: knowledge, findings, vex, policy, platform.",
|
||||
$"Unsupported filter domain '{domain}'. Supported values: knowledge, findings, vex, policy, platform, graph, timeline, scanner, opsmemory.",
|
||||
nameof(unifiedFilter));
|
||||
}
|
||||
}
|
||||
@@ -576,13 +811,19 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
|
||||
"vex_statement" => "vex_statement",
|
||||
"policy_rule" => "policy_rule",
|
||||
"platform_entity" => "platform_entity",
|
||||
"package" => "graph_node",
|
||||
"image" => "graph_node",
|
||||
"registry" => "graph_node",
|
||||
"graph_node" => "graph_node",
|
||||
"event" => "audit_event",
|
||||
"scan" => "scan_result",
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (kind is null)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Unsupported filter entityType '{entityType}'. Supported values: docs, api, doctor, finding, vex_statement, policy_rule, platform_entity.",
|
||||
$"Unsupported filter entityType '{entityType}'. Supported values: docs, api, doctor, finding, vex_statement, policy_rule, platform_entity, package, image, registry, graph_node, event, scan.",
|
||||
nameof(unifiedFilter));
|
||||
}
|
||||
|
||||
@@ -670,6 +911,48 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
|
||||
return value.GetString();
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildCardMetadata(JsonElement metadata)
|
||||
{
|
||||
var map = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
if (metadata.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return map;
|
||||
}
|
||||
|
||||
foreach (var property in metadata.EnumerateObject())
|
||||
{
|
||||
var value = property.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => property.Value.GetString(),
|
||||
JsonValueKind.Number => property.Value.GetRawText(),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
map[property.Name] = value!;
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private static string SanitizeSnippet(string snippet)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(snippet))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var decoded = WebUtility.HtmlDecode(snippet);
|
||||
var withoutScripts = SnippetScriptTagRegex.Replace(decoded, " ");
|
||||
var withoutTags = SnippetHtmlTagRegex.Replace(withoutScripts, " ");
|
||||
var collapsed = SnippetWhitespaceRegex.Replace(withoutTags, " ").Trim();
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates "Did you mean?" suggestions by querying the trigram fuzzy index
|
||||
/// and extracting the most relevant distinct titles from the fuzzy matches.
|
||||
@@ -813,12 +1096,16 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
|
||||
var refinements = new List<SearchRefinement>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
const int maxRefinements = 3;
|
||||
var refinementTimeoutMs = Math.Clamp(_options.QueryTimeoutMs / 6, 50, 500);
|
||||
using var refinementBudgetCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
refinementBudgetCts.CancelAfter(TimeSpan.FromMilliseconds(refinementTimeoutMs));
|
||||
var refinementCt = refinementBudgetCts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Check resolved alerts for similar queries
|
||||
var resolvedAlerts = await _qualityMonitor.GetAlertsAsync(
|
||||
tenantId, status: "resolved", limit: 50, ct: ct).ConfigureAwait(false);
|
||||
tenantId, status: "resolved", limit: 50, ct: refinementCt).ConfigureAwait(false);
|
||||
|
||||
foreach (var alert in resolvedAlerts)
|
||||
{
|
||||
@@ -842,7 +1129,7 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
|
||||
if (refinements.Count < maxRefinements)
|
||||
{
|
||||
var similarQueries = await _analyticsService.FindSimilarSuccessfulQueriesAsync(
|
||||
tenantId, query, maxRefinements - refinements.Count, ct).ConfigureAwait(false);
|
||||
tenantId, query, maxRefinements - refinements.Count, refinementCt).ConfigureAwait(false);
|
||||
|
||||
foreach (var similarQuery in similarQueries)
|
||||
{
|
||||
@@ -871,6 +1158,13 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (refinementBudgetCts.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Refinement generation timed out after {TimeoutMs}ms for query '{Query}'.",
|
||||
refinementTimeoutMs,
|
||||
query);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to generate query refinements for '{Query}'.", query);
|
||||
@@ -912,6 +1206,49 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
|
||||
return trigrams;
|
||||
}
|
||||
|
||||
private UnifiedSearchTenantFeatureFlags ResolveTenantFeatureFlags(string tenantId)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(tenantId) &&
|
||||
_unifiedOptions.TenantFeatureFlags.TryGetValue(tenantId, out var tenantFlags) &&
|
||||
tenantFlags is not null)
|
||||
{
|
||||
return tenantFlags;
|
||||
}
|
||||
|
||||
if (_unifiedOptions.TenantFeatureFlags.TryGetValue("*", out var wildcardFlags) &&
|
||||
wildcardFlags is not null)
|
||||
{
|
||||
return wildcardFlags;
|
||||
}
|
||||
|
||||
return new UnifiedSearchTenantFeatureFlags();
|
||||
}
|
||||
|
||||
private bool IsSearchEnabledForTenant(UnifiedSearchTenantFeatureFlags tenantFlags)
|
||||
{
|
||||
return tenantFlags.Enabled ?? _unifiedOptions.Enabled;
|
||||
}
|
||||
|
||||
private bool IsFederationEnabledForTenant(UnifiedSearchTenantFeatureFlags tenantFlags)
|
||||
{
|
||||
if (!IsSearchEnabledForTenant(tenantFlags))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return tenantFlags.FederationEnabled ?? _unifiedOptions.Federation.Enabled;
|
||||
}
|
||||
|
||||
private bool IsSynthesisEnabledForTenant(UnifiedSearchTenantFeatureFlags tenantFlags)
|
||||
{
|
||||
if (!IsSearchEnabledForTenant(tenantFlags))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return tenantFlags.SynthesisEnabled ?? _unifiedOptions.Synthesis.Enabled;
|
||||
}
|
||||
|
||||
private void EmitTelemetry(QueryPlan plan, UnifiedSearchResponse response, string tenant)
|
||||
{
|
||||
if (_telemetrySink is null)
|
||||
|
||||
@@ -4,7 +4,11 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.Adapters;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.Analytics;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.Cards;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.Context;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.Federation;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.QueryUnderstanding;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.Ranking;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.Synthesis;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.UnifiedSearch;
|
||||
@@ -18,11 +22,21 @@ public static class UnifiedSearchServiceCollectionExtensions
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
services.AddOptions<UnifiedSearchOptions>()
|
||||
.Bind(configuration.GetSection(UnifiedSearchOptions.SectionName))
|
||||
.ValidateDataAnnotations();
|
||||
|
||||
// Query understanding pipeline
|
||||
services.TryAddSingleton<EntityExtractor>();
|
||||
services.TryAddSingleton<IntentClassifier>();
|
||||
services.TryAddSingleton<DomainWeightCalculator>();
|
||||
services.TryAddSingleton<QueryPlanBuilder>();
|
||||
services.TryAddSingleton<AmbientContextProcessor>();
|
||||
services.TryAddSingleton<SearchSessionContextService>();
|
||||
services.TryAddSingleton<IGraphNeighborProvider, HttpGraphNeighborProvider>();
|
||||
services.TryAddSingleton<GravityBoostCalculator>();
|
||||
services.TryAddSingleton<FederatedSearchDispatcher>();
|
||||
services.TryAddSingleton<EntityCardAssembler>();
|
||||
|
||||
// Search analytics and history (Sprint 106 / G6)
|
||||
services.TryAddSingleton<SearchAnalyticsService>();
|
||||
@@ -36,15 +50,20 @@ public static class UnifiedSearchServiceCollectionExtensions
|
||||
services.TryAddSingleton<CompositeSynthesisEngine>();
|
||||
services.TryAddSingleton<ISynthesisEngine>(provider =>
|
||||
provider.GetRequiredService<CompositeSynthesisEngine>());
|
||||
services.TryAddSingleton<SearchSynthesisPromptAssembler>();
|
||||
services.TryAddSingleton<SearchSynthesisQuotaService>();
|
||||
services.TryAddSingleton<SearchSynthesisService>();
|
||||
|
||||
// Entity alias service
|
||||
services.TryAddSingleton<IEntityAliasService, EntityAliasService>();
|
||||
|
||||
// Snapshot-based ingestion adapters (static fixture data)
|
||||
services.AddSingleton<ISearchIngestionAdapter, FindingIngestionAdapter>();
|
||||
services.AddSingleton<ISearchIngestionAdapter, VexStatementIngestionAdapter>();
|
||||
services.AddSingleton<ISearchIngestionAdapter, PolicyRuleIngestionAdapter>();
|
||||
// Snapshot-only platform catalog adapter remains static.
|
||||
// Findings/VEX/Policy snapshots are now fallback paths within their live adapters.
|
||||
services.AddSingleton<ISearchIngestionAdapter, PlatformCatalogIngestionAdapter>();
|
||||
services.AddSingleton<ISearchIngestionAdapter, GraphNodeIngestionAdapter>();
|
||||
services.AddSingleton<ISearchIngestionAdapter, OpsDecisionIngestionAdapter>();
|
||||
services.AddSingleton<ISearchIngestionAdapter, TimelineEventIngestionAdapter>();
|
||||
services.AddSingleton<ISearchIngestionAdapter, ScanResultIngestionAdapter>();
|
||||
|
||||
// Live data adapters (Sprint 103 / G2) -- call upstream microservices with snapshot fallback
|
||||
services.AddSingleton<ISearchIngestionAdapter, FindingsSearchAdapter>();
|
||||
@@ -55,6 +74,8 @@ public static class UnifiedSearchServiceCollectionExtensions
|
||||
services.AddHttpClient("scanner-internal");
|
||||
services.AddHttpClient("vex-internal");
|
||||
services.AddHttpClient("policy-internal");
|
||||
services.AddHttpClient("graph-internal");
|
||||
services.AddHttpClient("timeline-internal");
|
||||
|
||||
// Named HttpClient for LLM synthesis (Sprint 104 / G3)
|
||||
services.AddHttpClient("llm-synthesis");
|
||||
@@ -64,6 +85,7 @@ public static class UnifiedSearchServiceCollectionExtensions
|
||||
services.TryAddSingleton<IUnifiedSearchIndexer>(provider => provider.GetRequiredService<UnifiedSearchIndexer>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService, UnifiedSearchIndexRefreshService>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService, SearchQualityMonitorBackgroundService>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService, SearchAnalyticsRetentionBackgroundService>());
|
||||
|
||||
// Telemetry
|
||||
services.TryAddSingleton<IUnifiedSearchTelemetrySink, LoggingUnifiedSearchTelemetrySink>();
|
||||
|
||||
@@ -19,7 +19,9 @@ internal static class WeightedRrfFusion
|
||||
bool enableFreshnessBoost = false,
|
||||
DateTimeOffset? referenceTime = null,
|
||||
IReadOnlyDictionary<string, int>? popularityMap = null,
|
||||
double popularityBoostWeight = 0.0)
|
||||
double popularityBoostWeight = 0.0,
|
||||
IReadOnlyDictionary<string, double>? contextEntityBoosts = null,
|
||||
IReadOnlyDictionary<string, double>? gravityBoostMap = null)
|
||||
{
|
||||
var merged = new Dictionary<string, (KnowledgeChunkRow Row, double Score, Dictionary<string, string> Debug)>(StringComparer.Ordinal);
|
||||
|
||||
@@ -59,12 +61,16 @@ internal static class WeightedRrfFusion
|
||||
.Select(item =>
|
||||
{
|
||||
var entityBoost = ComputeEntityProximityBoost(item.Row, detectedEntities);
|
||||
var contextBoost = ComputeContextEntityBoost(item.Row, contextEntityBoosts);
|
||||
var gravityBoost = ComputeGravityBoost(item.Row, gravityBoostMap);
|
||||
var freshnessBoost = enableFreshnessBoost
|
||||
? ComputeFreshnessBoost(item.Row, referenceTime ?? DateTimeOffset.UnixEpoch)
|
||||
: 0d;
|
||||
var popBoost = ComputePopularityBoost(item.Row, popularityMap, popularityBoostWeight);
|
||||
item.Score += entityBoost + freshnessBoost + popBoost;
|
||||
item.Score += entityBoost + contextBoost + gravityBoost + freshnessBoost + popBoost;
|
||||
item.Debug["entityBoost"] = entityBoost.ToString("F6", System.Globalization.CultureInfo.InvariantCulture);
|
||||
item.Debug["contextBoost"] = contextBoost.ToString("F6", System.Globalization.CultureInfo.InvariantCulture);
|
||||
item.Debug["gravityBoost"] = gravityBoost.ToString("F6", System.Globalization.CultureInfo.InvariantCulture);
|
||||
item.Debug["freshnessBoost"] = freshnessBoost.ToString("F6", System.Globalization.CultureInfo.InvariantCulture);
|
||||
item.Debug["popularityBoost"] = popBoost.ToString("F6", System.Globalization.CultureInfo.InvariantCulture);
|
||||
item.Debug["chunkId"] = item.Row.ChunkId;
|
||||
@@ -242,4 +248,54 @@ internal static class WeightedRrfFusion
|
||||
// Logarithmic boost: log2(1 + clickCount) * weight
|
||||
return Math.Log2(1 + clickCount) * popularityBoostWeight;
|
||||
}
|
||||
|
||||
private static double ComputeContextEntityBoost(
|
||||
KnowledgeChunkRow row,
|
||||
IReadOnlyDictionary<string, double>? contextEntityBoosts)
|
||||
{
|
||||
if (contextEntityBoosts is null || contextEntityBoosts.Count == 0)
|
||||
{
|
||||
return 0d;
|
||||
}
|
||||
|
||||
var entityKey = TryGetEntityKey(row);
|
||||
if (string.IsNullOrWhiteSpace(entityKey))
|
||||
{
|
||||
return 0d;
|
||||
}
|
||||
|
||||
return contextEntityBoosts.TryGetValue(entityKey, out var boost) ? boost : 0d;
|
||||
}
|
||||
|
||||
private static double ComputeGravityBoost(
|
||||
KnowledgeChunkRow row,
|
||||
IReadOnlyDictionary<string, double>? gravityBoostMap)
|
||||
{
|
||||
if (gravityBoostMap is null || gravityBoostMap.Count == 0)
|
||||
{
|
||||
return 0d;
|
||||
}
|
||||
|
||||
var entityKey = TryGetEntityKey(row);
|
||||
if (string.IsNullOrWhiteSpace(entityKey))
|
||||
{
|
||||
return 0d;
|
||||
}
|
||||
|
||||
return gravityBoostMap.TryGetValue(entityKey, out var boost) ? boost : 0d;
|
||||
}
|
||||
|
||||
private static string? TryGetEntityKey(KnowledgeChunkRow row)
|
||||
{
|
||||
var metadata = row.Metadata.RootElement;
|
||||
if (metadata.ValueKind != System.Text.Json.JsonValueKind.Object ||
|
||||
!metadata.TryGetProperty("entity_key", out var entityKeyProp) ||
|
||||
entityKeyProp.ValueKind != System.Text.Json.JsonValueKind.String)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var entityKey = entityKeyProp.GetString();
|
||||
return string.IsNullOrWhiteSpace(entityKey) ? null : entityKey.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// OnnxVectorEncoder — Semantic vector encoder using ONNX Runtime inference.
|
||||
//
|
||||
// NuGet dependency required (not yet added to .csproj):
|
||||
// <PackageReference Include="Microsoft.ML.OnnxRuntime" Version="1.17.*" />
|
||||
// NuGet dependency:
|
||||
// <PackageReference Include="Microsoft.ML.OnnxRuntime" />
|
||||
// Version is managed centrally in src/Directory.Packages.props.
|
||||
//
|
||||
// This implementation is structured for the all-MiniLM-L6-v2 sentence-transformer
|
||||
// model. It performs simplified WordPiece tokenization, ONNX inference, mean-pooling,
|
||||
// and L2-normalization to produce 384-dimensional embedding vectors.
|
||||
//
|
||||
// Until the OnnxRuntime NuGet package is installed, the encoder operates in
|
||||
// "stub" mode: it falls back to a deterministic projection that preserves the
|
||||
// correct 384-dim output shape and L2-normalization contract. The stub uses
|
||||
// Until full runtime tensor plumbing and model assets are present, the encoder
|
||||
// can run in fallback mode: it returns a deterministic projection that preserves
|
||||
// the 384-dim output shape and L2-normalization contract. The fallback uses
|
||||
// character n-gram hashing to produce vectors that are structurally valid but
|
||||
// lack true semantic quality. When the ONNX runtime is available and the model
|
||||
// file exists, true inference takes over automatically.
|
||||
// lack true semantic quality. When ONNX runtime + model loading is active,
|
||||
// true inference takes over automatically.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
@@ -40,6 +41,34 @@ internal sealed class OnnxVectorEncoder : IVectorEncoder, IDisposable
|
||||
private static readonly Regex WordTokenRegex = new(
|
||||
@"[\w]+|[^\s\w]",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
private static readonly IReadOnlyDictionary<string, string> CanonicalTokenMap =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["deploy"] = "deploy",
|
||||
["release"] = "deploy",
|
||||
["promote"] = "deploy",
|
||||
["promotion"] = "deploy",
|
||||
["rollout"] = "deploy",
|
||||
["ship"] = "deploy",
|
||||
["mitigate"] = "mitigation",
|
||||
["mitigation"] = "mitigation",
|
||||
["remediate"] = "mitigation",
|
||||
["remediation"] = "mitigation",
|
||||
["fix"] = "mitigation",
|
||||
["harden"] = "mitigation",
|
||||
["vulnerability"] = "vulnerability",
|
||||
["vulnerabilities"] = "vulnerability",
|
||||
["cve"] = "vulnerability",
|
||||
["ghsa"] = "vulnerability",
|
||||
["policy"] = "policy",
|
||||
["rule"] = "policy",
|
||||
["gate"] = "policy",
|
||||
["deny"] = "policy",
|
||||
["allow"] = "policy",
|
||||
["sbom"] = "sbom",
|
||||
["bill"] = "sbom",
|
||||
["materials"] = "sbom"
|
||||
};
|
||||
|
||||
private readonly ILogger<OnnxVectorEncoder> _logger;
|
||||
private readonly string _modelPath;
|
||||
@@ -226,7 +255,7 @@ internal sealed class OnnxVectorEncoder : IVectorEncoder, IDisposable
|
||||
// fall back to the deterministic character-ngram encoder.
|
||||
_logger.LogDebug(
|
||||
"ONNX tensor creation via reflection is not fully supported. " +
|
||||
"Using deterministic fallback until Microsoft.ML.OnnxRuntime NuGet is added.");
|
||||
"Using deterministic fallback until typed tensor invocation is wired for this runtime.");
|
||||
return FallbackEncode(text);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -323,27 +352,36 @@ internal sealed class OnnxVectorEncoder : IVectorEncoder, IDisposable
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
var word = match.Value;
|
||||
var canonical = CanonicalizeToken(word);
|
||||
|
||||
// Hash the whole word into a bucket
|
||||
var wordBytes = Encoding.UTF8.GetBytes(word);
|
||||
var wordHash = SHA256.HashData(wordBytes);
|
||||
|
||||
// Distribute across multiple dimensions using different hash windows
|
||||
for (var window = 0; window < 4 && window * 4 + 4 <= wordHash.Length; window++)
|
||||
// Canonical token signature is the primary signal so mapped synonyms
|
||||
// (e.g., deploy/release/promote) converge to nearby vectors.
|
||||
var canonicalBytes = Encoding.UTF8.GetBytes(canonical);
|
||||
var canonicalHash = SHA256.HashData(canonicalBytes);
|
||||
for (var window = 0; window < 6 && window * 4 + 4 <= canonicalHash.Length; window++)
|
||||
{
|
||||
var idx = (int)(BitConverter.ToUInt32(wordHash, window * 4) % (uint)OutputDimensions);
|
||||
// Use alternating signs for better distribution
|
||||
vector[idx] += (window % 2 == 0) ? 1f : -0.5f;
|
||||
var idx = (int)(BitConverter.ToUInt32(canonicalHash, window * 4) % (uint)OutputDimensions);
|
||||
vector[idx] += (window % 2 == 0) ? 1.2f : -0.6f;
|
||||
}
|
||||
|
||||
// Also hash character bigrams for sub-word signal
|
||||
for (var c = 0; c < word.Length - 1; c++)
|
||||
// Add canonical bigram signal (not raw word bigrams) to preserve
|
||||
// sub-word structure while keeping synonym proximity high.
|
||||
for (var c = 0; c < canonical.Length - 1; c++)
|
||||
{
|
||||
var bigram = word.Substring(c, 2);
|
||||
var bigram = canonical.Substring(c, 2);
|
||||
var bigramBytes = Encoding.UTF8.GetBytes(bigram);
|
||||
var bigramHash = SHA256.HashData(bigramBytes);
|
||||
var bigramIdx = (int)(BitConverter.ToUInt32(bigramHash, 0) % (uint)OutputDimensions);
|
||||
vector[bigramIdx] += 0.3f;
|
||||
vector[bigramIdx] += 0.4f;
|
||||
}
|
||||
|
||||
// Keep a light lexical fingerprint for non-synonym distinctions.
|
||||
if (!canonical.Equals(word, StringComparison.Ordinal))
|
||||
{
|
||||
var wordBytes = Encoding.UTF8.GetBytes(word);
|
||||
var wordHash = SHA256.HashData(wordBytes);
|
||||
var idx = (int)(BitConverter.ToUInt32(wordHash, 0) % (uint)OutputDimensions);
|
||||
vector[idx] += 0.1f;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,6 +389,18 @@ internal sealed class OnnxVectorEncoder : IVectorEncoder, IDisposable
|
||||
return vector;
|
||||
}
|
||||
|
||||
private static string CanonicalizeToken(string token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return CanonicalTokenMap.TryGetValue(token, out var canonical)
|
||||
? canonical
|
||||
: token;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Mean pooling and normalization utilities
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
placeholder: model bundle path reserved for deployment packaging; replace with licensed all-MiniLM-L6-v2 ONNX weights.
|
||||
@@ -2,11 +2,13 @@ using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch;
|
||||
using StellaOps.AdvisoryAI.WebService.Endpoints;
|
||||
using StellaOps.TestKit;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.Integration;
|
||||
@@ -27,6 +29,11 @@ public sealed class UnifiedSearchEndpointsIntegrationTests : IDisposable
|
||||
services.RemoveAll<IUnifiedSearchIndexer>();
|
||||
services.AddSingleton<IUnifiedSearchService, StubUnifiedSearchService>();
|
||||
services.AddSingleton<IUnifiedSearchIndexer, StubUnifiedSearchIndexer>();
|
||||
services.PostConfigure<UnifiedSearchOptions>(options =>
|
||||
{
|
||||
options.Synthesis.SynthesisRequestsPerDay = 1;
|
||||
options.Synthesis.MaxConcurrentPerTenant = 1;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -83,7 +90,7 @@ public sealed class UnifiedSearchEndpointsIntegrationTests : IDisposable
|
||||
Q = "cve-2024-21626",
|
||||
Filters = new UnifiedSearchApiFilter
|
||||
{
|
||||
Domains = ["graph"]
|
||||
Domains = ["unknown_domain"]
|
||||
}
|
||||
});
|
||||
|
||||
@@ -104,6 +111,30 @@ public sealed class UnifiedSearchEndpointsIntegrationTests : IDisposable
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenApi_Includes_UnifiedSearch_Contracts()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/openapi/v1.json");
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
response = await client.GetAsync("/swagger/v1/swagger.json");
|
||||
}
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var document = JsonDocument.Parse(json);
|
||||
|
||||
document.RootElement.GetProperty("paths")
|
||||
.TryGetProperty("/v1/search/query", out _)
|
||||
.Should().BeTrue("OpenAPI must expose POST /v1/search/query");
|
||||
|
||||
document.RootElement.GetProperty("paths")
|
||||
.TryGetProperty("/v1/search/synthesize", out _)
|
||||
.Should().BeTrue("OpenAPI must expose POST /v1/search/synthesize");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rebuild_WithAdminScope_ReturnsSummary()
|
||||
{
|
||||
@@ -120,6 +151,57 @@ public sealed class UnifiedSearchEndpointsIntegrationTests : IDisposable
|
||||
payload.ChunkCount.Should().Be(17);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Synthesize_WithMissingSynthesisScope_ReturnsForbidden()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Scopes", "advisory-ai:operate");
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "test-tenant");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/v1/search/synthesize", BuildSynthesisRequest());
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Synthesize_WithScope_StreamsDeterministicFirst_AndCompletes()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Scopes", "advisory-ai:operate search:synthesize");
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "test-tenant");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/v1/search/synthesize", BuildSynthesisRequest());
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
response.Content.Headers.ContentType!.MediaType.Should().Be("text/event-stream");
|
||||
|
||||
var stream = await response.Content.ReadAsStringAsync();
|
||||
stream.Should().Contain("event: synthesis_start");
|
||||
stream.Should().Contain("event: llm_status");
|
||||
stream.Should().Contain("event: synthesis_end");
|
||||
stream.IndexOf("event: synthesis_start", StringComparison.Ordinal)
|
||||
.Should().BeLessThan(stream.IndexOf("event: synthesis_end", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Synthesize_SecondRequest_ExceedsQuota_AndEmitsQuotaStatus()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Scopes", "advisory-ai:operate search:synthesize");
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "quota-tenant");
|
||||
|
||||
// First request consumes the single daily synthesis slot.
|
||||
var firstResponse = await client.PostAsJsonAsync("/v1/search/synthesize", BuildSynthesisRequest());
|
||||
firstResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
// Second request should remain HTTP 200 (SSE), but emit quota_exceeded status.
|
||||
var secondResponse = await client.PostAsJsonAsync("/v1/search/synthesize", BuildSynthesisRequest());
|
||||
secondResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var stream = await secondResponse.Content.ReadAsStringAsync();
|
||||
stream.Should().Contain("\"status\":\"quota_exceeded\"");
|
||||
stream.Should().Contain("event: synthesis_end");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_factory.Dispose();
|
||||
@@ -177,4 +259,40 @@ public sealed class UnifiedSearchEndpointsIntegrationTests : IDisposable
|
||||
DurationMs: 12));
|
||||
}
|
||||
}
|
||||
|
||||
private static UnifiedSearchSynthesizeApiRequest BuildSynthesisRequest()
|
||||
{
|
||||
return new UnifiedSearchSynthesizeApiRequest
|
||||
{
|
||||
Q = "cve remediation guidance",
|
||||
TopCards =
|
||||
[
|
||||
new UnifiedSearchApiCard
|
||||
{
|
||||
EntityKey = "cve:CVE-2024-21626",
|
||||
EntityType = "finding",
|
||||
Domain = "findings",
|
||||
Title = "CVE-2024-21626",
|
||||
Snippet = "Container breakout via runc",
|
||||
Score = 1.0,
|
||||
Actions =
|
||||
[
|
||||
new UnifiedSearchApiAction
|
||||
{
|
||||
Label = "View Finding",
|
||||
ActionType = "navigate",
|
||||
Route = "/security/triage?q=CVE-2024-21626",
|
||||
IsPrimary = true
|
||||
}
|
||||
],
|
||||
Sources = ["findings"]
|
||||
}
|
||||
],
|
||||
Preferences = new UnifiedSearchSynthesisPreferencesApi
|
||||
{
|
||||
Depth = "brief",
|
||||
IncludeActions = true
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
using StellaOps.AdvisoryAI.DependencyInjection;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.Analytics;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.QueryUnderstanding;
|
||||
@@ -10,6 +11,7 @@ using StellaOps.AdvisoryAI.UnifiedSearch.Synthesis;
|
||||
using StellaOps.AdvisoryAI.Vectorization;
|
||||
using StellaOps.AdvisoryAI.WebService.Endpoints;
|
||||
using StellaOps.TestKit;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
@@ -259,6 +261,28 @@ public sealed class UnifiedSearchSprintIntegrationTests : IDisposable
|
||||
"different inputs should produce different vectors");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void G1_OnnxFallbackEncoder_DeployAndRelease_HaveHighSimilarity()
|
||||
{
|
||||
var deploy = OnnxVectorEncoder.FallbackEncode("deploy");
|
||||
var release = OnnxVectorEncoder.FallbackEncode("release");
|
||||
|
||||
var similarity = KnowledgeSearchText.CosineSimilarity(deploy, release);
|
||||
similarity.Should().BeGreaterThan(0.5,
|
||||
"semantically similar deployment terms should map close in vector space");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void G1_OnnxFallbackEncoder_DeployAndQuantumPhysics_HaveLowSimilarity()
|
||||
{
|
||||
var deploy = OnnxVectorEncoder.FallbackEncode("deploy");
|
||||
var unrelated = OnnxVectorEncoder.FallbackEncode("quantum physics");
|
||||
|
||||
var similarity = KnowledgeSearchText.CosineSimilarity(deploy, unrelated);
|
||||
similarity.Should().BeLessThan(0.2,
|
||||
"unrelated concepts should remain far in vector space");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task G1_SearchDiagnostics_ReportsActiveEncoderType()
|
||||
{
|
||||
@@ -285,6 +309,36 @@ public sealed class UnifiedSearchSprintIntegrationTests : IDisposable
|
||||
"default vector encoder should be the deterministic hash encoder");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void G1_OnnxModel_DefaultPath_IsAccessibleInOutput()
|
||||
{
|
||||
var modelPath = Path.Combine(AppContext.BaseDirectory, "models", "all-MiniLM-L6-v2.onnx");
|
||||
File.Exists(modelPath).Should().BeTrue(
|
||||
"the configured default ONNX model path should be accessible in deployment artifacts");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void G1_OnnxEncoderSelection_MissingModelPath_FallsBackToDeterministicHashEncoder()
|
||||
{
|
||||
var missingModelPath = Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid():N}.onnx");
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddOptions();
|
||||
services.Configure<KnowledgeSearchOptions>(options =>
|
||||
{
|
||||
options.VectorEncoderType = "onnx";
|
||||
options.OnnxModelPath = missingModelPath;
|
||||
});
|
||||
|
||||
services.AddAdvisoryPipeline();
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var encoder = provider.GetRequiredService<IVectorEncoder>();
|
||||
encoder.Should().BeOfType<DeterministicHashVectorEncoder>(
|
||||
"missing ONNX model files should trigger graceful fallback instead of startup failure");
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Sprint 103 (G2) - Cross-Domain Adapters
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
@@ -349,9 +403,20 @@ public sealed class UnifiedSearchSprintIntegrationTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task G2_UnifiedSearch_AllowedDomains_AcceptKnowledgeFindingsVexPolicyPlatform()
|
||||
public async Task G2_UnifiedSearch_AllowedDomains_AcceptAllConfiguredDomains()
|
||||
{
|
||||
var allowedDomains = new[] { "knowledge", "findings", "vex", "policy", "platform" };
|
||||
var allowedDomains = new[]
|
||||
{
|
||||
"knowledge",
|
||||
"findings",
|
||||
"vex",
|
||||
"policy",
|
||||
"platform",
|
||||
"graph",
|
||||
"timeline",
|
||||
"scanner",
|
||||
"opsmemory"
|
||||
};
|
||||
|
||||
foreach (var domain in allowedDomains)
|
||||
{
|
||||
@@ -381,12 +446,12 @@ public sealed class UnifiedSearchSprintIntegrationTests : IDisposable
|
||||
Q = "test query",
|
||||
Filters = new UnifiedSearchApiFilter
|
||||
{
|
||||
Domains = ["graph"]
|
||||
Domains = ["unsupported_domain"]
|
||||
}
|
||||
});
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest,
|
||||
"'graph' is not an allowed domain for unified search");
|
||||
"unknown domains should be rejected by unified search validation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1286,6 +1351,31 @@ public sealed class UnifiedSearchSprintIntegrationTests : IDisposable
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task G10_FeedbackEndpoint_StoresSignal_ForQualityMetrics()
|
||||
{
|
||||
using var client = CreateAuthenticatedClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/v1/advisory-ai/search/feedback", new SearchFeedbackRequestDto
|
||||
{
|
||||
Query = "docker login fails",
|
||||
EntityKey = "docs:troubleshooting",
|
||||
Domain = "knowledge",
|
||||
Position = 1,
|
||||
Signal = "helpful",
|
||||
Comment = "This guidance resolved the issue."
|
||||
});
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var monitor = scope.ServiceProvider.GetRequiredService<SearchQualityMonitor>();
|
||||
var metrics = await monitor.GetMetricsAsync("test-tenant", "7d");
|
||||
|
||||
metrics.FeedbackScore.Should().BeGreaterThan(0,
|
||||
"stored feedback should be reflected in quality metrics for the same tenant");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task G10_FeedbackEndpoint_InvalidSignal_ReturnsBadRequest()
|
||||
{
|
||||
@@ -1358,6 +1448,137 @@ public sealed class UnifiedSearchSprintIntegrationTests : IDisposable
|
||||
"quality metrics endpoint requires admin scope");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task G10_QualityMetrics_MatchesRawEventSamples()
|
||||
{
|
||||
var tenant = $"metrics-{Guid.NewGuid():N}";
|
||||
using var writer = CreateAuthenticatedClient(tenant);
|
||||
using var admin = CreateAdminClient(tenant);
|
||||
|
||||
var analyticsResponse = await writer.PostAsJsonAsync("/v1/advisory-ai/search/analytics", new SearchAnalyticsApiRequest
|
||||
{
|
||||
Events =
|
||||
[
|
||||
new SearchAnalyticsApiEvent
|
||||
{
|
||||
EventType = "query",
|
||||
Query = "metric sample alpha",
|
||||
ResultCount = 4
|
||||
},
|
||||
new SearchAnalyticsApiEvent
|
||||
{
|
||||
EventType = "zero_result",
|
||||
Query = "metric sample beta",
|
||||
ResultCount = 0
|
||||
},
|
||||
new SearchAnalyticsApiEvent
|
||||
{
|
||||
EventType = "query",
|
||||
Query = "metric sample gamma",
|
||||
ResultCount = 2
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
analyticsResponse.StatusCode.Should().Be(HttpStatusCode.NoContent);
|
||||
|
||||
(await writer.PostAsJsonAsync("/v1/advisory-ai/search/feedback", new SearchFeedbackRequestDto
|
||||
{
|
||||
Query = "metric sample alpha",
|
||||
EntityKey = "docs:alpha",
|
||||
Domain = "knowledge",
|
||||
Position = 0,
|
||||
Signal = "helpful"
|
||||
})).StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
|
||||
(await writer.PostAsJsonAsync("/v1/advisory-ai/search/feedback", new SearchFeedbackRequestDto
|
||||
{
|
||||
Query = "metric sample beta",
|
||||
EntityKey = "docs:beta",
|
||||
Domain = "knowledge",
|
||||
Position = 1,
|
||||
Signal = "not_helpful"
|
||||
})).StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
|
||||
var metricsResponse = await admin.GetAsync("/v1/advisory-ai/search/quality/metrics?period=7d");
|
||||
metricsResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var metrics = await metricsResponse.Content.ReadFromJsonAsync<SearchQualityMetricsDto>();
|
||||
metrics.Should().NotBeNull();
|
||||
metrics!.TotalSearches.Should().Be(3,
|
||||
"total-search count must match query + zero_result analytics events for the same tenant");
|
||||
metrics.ZeroResultRate.Should().BeApproximately(33.3, 0.2,
|
||||
"zero-result rate must be computed from raw zero_result analytics events");
|
||||
metrics.AvgResultCount.Should().BeApproximately(2.0, 0.2);
|
||||
metrics.FeedbackScore.Should().BeApproximately(50.0, 0.2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task G10_QualityMetrics_IncludeLowQualityTopQueriesAndTrend()
|
||||
{
|
||||
var tenant = $"metrics-detail-{Guid.NewGuid():N}";
|
||||
using var writer = CreateAuthenticatedClient(tenant);
|
||||
using var admin = CreateAdminClient(tenant);
|
||||
|
||||
var analyticsResponse = await writer.PostAsJsonAsync("/v1/advisory-ai/search/analytics", new SearchAnalyticsApiRequest
|
||||
{
|
||||
Events =
|
||||
[
|
||||
new SearchAnalyticsApiEvent
|
||||
{
|
||||
EventType = "query",
|
||||
Query = "policy gate",
|
||||
ResultCount = 3
|
||||
},
|
||||
new SearchAnalyticsApiEvent
|
||||
{
|
||||
EventType = "query",
|
||||
Query = "policy gate",
|
||||
ResultCount = 2
|
||||
},
|
||||
new SearchAnalyticsApiEvent
|
||||
{
|
||||
EventType = "zero_result",
|
||||
Query = "unknown control token",
|
||||
ResultCount = 0
|
||||
}
|
||||
]
|
||||
});
|
||||
analyticsResponse.StatusCode.Should().Be(HttpStatusCode.NoContent);
|
||||
|
||||
(await writer.PostAsJsonAsync("/v1/advisory-ai/search/feedback", new SearchFeedbackRequestDto
|
||||
{
|
||||
Query = "policy gate",
|
||||
EntityKey = "policy:rule-1",
|
||||
Domain = "policy",
|
||||
Position = 0,
|
||||
Signal = "not_helpful"
|
||||
})).StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
|
||||
(await writer.PostAsJsonAsync("/v1/advisory-ai/search/feedback", new SearchFeedbackRequestDto
|
||||
{
|
||||
Query = "policy gate",
|
||||
EntityKey = "policy:rule-1",
|
||||
Domain = "policy",
|
||||
Position = 1,
|
||||
Signal = "not_helpful"
|
||||
})).StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
|
||||
var metricsResponse = await admin.GetAsync("/v1/advisory-ai/search/quality/metrics?period=30d");
|
||||
metricsResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var metrics = await metricsResponse.Content.ReadFromJsonAsync<SearchQualityMetricsDto>();
|
||||
metrics.Should().NotBeNull();
|
||||
metrics!.LowQualityResults.Should().NotBeEmpty();
|
||||
metrics.TopQueries.Should().NotBeEmpty();
|
||||
metrics.Trend.Should().HaveCount(30);
|
||||
var expectedQueryHash = SearchAnalyticsPrivacy.HashQuery("policy gate");
|
||||
metrics.TopQueries.Any(row => row.Query.Equals(expectedQueryHash, StringComparison.OrdinalIgnoreCase))
|
||||
.Should().BeTrue("top-queries should include repeated query traffic");
|
||||
metrics.LowQualityResults.Any(row => row.EntityKey.Equals("policy:rule-1", StringComparison.OrdinalIgnoreCase))
|
||||
.Should().BeTrue("low-quality rows should include repeated negative feedback entities");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task G10_AnalyticsEndpoint_ValidBatch_ReturnsNoContent()
|
||||
{
|
||||
@@ -1388,6 +1609,136 @@ public sealed class UnifiedSearchSprintIntegrationTests : IDisposable
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task G10_Privacy_AnalyticsEventsStoreOnlyHashedQueriesAndPseudonymousUsers()
|
||||
{
|
||||
const string tenant = "privacy-events-tenant";
|
||||
const string userId = "alice@example.com";
|
||||
const string query = "show account risks for alice@example.com";
|
||||
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var analyticsService = scope.ServiceProvider.GetRequiredService<SearchAnalyticsService>();
|
||||
|
||||
await analyticsService.RecordEventAsync(new SearchAnalyticsEvent(
|
||||
TenantId: tenant,
|
||||
EventType: "query",
|
||||
Query: query,
|
||||
UserId: userId,
|
||||
ResultCount: 1));
|
||||
|
||||
var events = analyticsService.GetFallbackEventsSnapshot(tenant, TimeSpan.FromMinutes(1));
|
||||
events.Should().NotBeEmpty();
|
||||
|
||||
var stored = events[^1].Event;
|
||||
stored.Query.Should().Be(SearchAnalyticsPrivacy.HashQuery(query));
|
||||
stored.Query.Should().NotContain("alice", "raw query text must not be stored in analytics events");
|
||||
stored.UserId.Should().Be(SearchAnalyticsPrivacy.HashUserId(tenant, userId));
|
||||
stored.UserId.Should().NotBe(userId, "user identifiers must be pseudonymized before persistence");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task G10_Privacy_FeedbackStoresHashedQueryAndRedactedComment()
|
||||
{
|
||||
const string tenant = "privacy-feedback-tenant";
|
||||
const string userId = "alice@example.com";
|
||||
const string query = "policy exception for alice@example.com";
|
||||
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var monitor = scope.ServiceProvider.GetRequiredService<SearchQualityMonitor>();
|
||||
|
||||
await monitor.StoreFeedbackAsync(new SearchFeedbackEntry
|
||||
{
|
||||
TenantId = tenant,
|
||||
UserId = userId,
|
||||
Query = query,
|
||||
EntityKey = "policy:rule-privacy",
|
||||
Domain = "policy",
|
||||
Position = 0,
|
||||
Signal = "not_helpful",
|
||||
Comment = "Contact alice@example.com with details",
|
||||
});
|
||||
|
||||
var feedback = monitor.GetFallbackFeedbackSnapshot(tenant, TimeSpan.FromMinutes(1));
|
||||
feedback.Should().NotBeEmpty();
|
||||
|
||||
var stored = feedback[^1].Entry;
|
||||
stored.Query.Should().Be(SearchAnalyticsPrivacy.HashQuery(query));
|
||||
stored.Query.Should().NotContain("alice", "feedback analytics should not persist raw query text");
|
||||
stored.UserId.Should().Be(SearchAnalyticsPrivacy.HashUserId(tenant, userId));
|
||||
stored.Comment.Should().BeNull("free-form comments are redacted to avoid storing potential PII");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task G10_AnalyticsCollection_Overhead_IsBelowFiveMillisecondsPerEvent()
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var analyticsService = scope.ServiceProvider.GetRequiredService<SearchAnalyticsService>();
|
||||
|
||||
const int iterations = 200;
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
await analyticsService.RecordEventAsync(new SearchAnalyticsEvent(
|
||||
TenantId: "latency-benchmark-tenant",
|
||||
EventType: "query",
|
||||
Query: $"latency benchmark query {i}",
|
||||
UserId: "latency-user",
|
||||
ResultCount: 1));
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
var averageMs = stopwatch.Elapsed.TotalMilliseconds / iterations;
|
||||
averageMs.Should().BeLessThan(5.0,
|
||||
"analytics collection should remain low-overhead on the request path");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task G10_AnalyticsEndpoint_SynthesisEvent_IsAccepted_AndExcludedFromQualityTotals()
|
||||
{
|
||||
var tenant = $"metrics-synthesis-{Guid.NewGuid():N}";
|
||||
using var writer = CreateAuthenticatedClient(tenant);
|
||||
using var admin = CreateAdminClient(tenant);
|
||||
|
||||
var response = await writer.PostAsJsonAsync("/v1/advisory-ai/search/analytics", new SearchAnalyticsApiRequest
|
||||
{
|
||||
Events =
|
||||
[
|
||||
new SearchAnalyticsApiEvent
|
||||
{
|
||||
EventType = "query",
|
||||
Query = "synthesis probe query",
|
||||
ResultCount = 2
|
||||
},
|
||||
new SearchAnalyticsApiEvent
|
||||
{
|
||||
EventType = "synthesis",
|
||||
Query = "synthesis probe query",
|
||||
EntityKey = "__synthesis__",
|
||||
Domain = "synthesis",
|
||||
ResultCount = 2,
|
||||
DurationMs = 18
|
||||
},
|
||||
new SearchAnalyticsApiEvent
|
||||
{
|
||||
EventType = "zero_result",
|
||||
Query = "synthesis probe empty",
|
||||
ResultCount = 0
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
|
||||
|
||||
var metricsResponse = await admin.GetAsync("/v1/advisory-ai/search/quality/metrics?period=7d");
|
||||
metricsResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var metrics = await metricsResponse.Content.ReadFromJsonAsync<SearchQualityMetricsDto>();
|
||||
metrics.Should().NotBeNull();
|
||||
metrics!.TotalSearches.Should().Be(2,
|
||||
"quality totals should include query and zero_result only; synthesis should be tracked separately");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task G6_AnalyticsClickEvent_IsStoredForPopularitySignals()
|
||||
{
|
||||
@@ -1506,10 +1857,105 @@ public sealed class UnifiedSearchSprintIntegrationTests : IDisposable
|
||||
await monitor.RefreshAlertsAsync("test-tenant");
|
||||
var alerts = await monitor.GetAlertsAsync("test-tenant", status: "open", alertType: "zero_result");
|
||||
|
||||
alerts.Any(alert => alert.Query.Equals("nonexistent vulnerability token", StringComparison.OrdinalIgnoreCase))
|
||||
var expectedQueryHash = SearchAnalyticsPrivacy.HashQuery("nonexistent vulnerability token");
|
||||
alerts.Any(alert => alert.Query.Equals(expectedQueryHash, StringComparison.OrdinalIgnoreCase))
|
||||
.Should().BeTrue("five repeated zero-result events should create a zero_result quality alert");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task G10_NegativeFeedbackBurst_CreatesHighNegativeFeedbackAlert()
|
||||
{
|
||||
using var client = CreateAuthenticatedClient();
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var response = await client.PostAsJsonAsync("/v1/advisory-ai/search/feedback", new SearchFeedbackRequestDto
|
||||
{
|
||||
Query = "policy gate unclear",
|
||||
EntityKey = $"policy:rule-{i}",
|
||||
Domain = "policy",
|
||||
Position = i,
|
||||
Signal = "not_helpful"
|
||||
});
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
}
|
||||
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var monitor = scope.ServiceProvider.GetRequiredService<SearchQualityMonitor>();
|
||||
await monitor.RefreshAlertsAsync("test-tenant");
|
||||
var alerts = await monitor.GetAlertsAsync("test-tenant", status: "open", alertType: "high_negative_feedback");
|
||||
|
||||
var expectedQueryHash = SearchAnalyticsPrivacy.HashQuery("policy gate unclear");
|
||||
alerts.Any(alert => alert.Query.Equals(expectedQueryHash, StringComparison.OrdinalIgnoreCase))
|
||||
.Should().BeTrue("three repeated not_helpful feedback events should raise a high_negative_feedback alert");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task G10_RetentionPrune_RemovesFallbackAnalyticsAndFeedbackArtifacts()
|
||||
{
|
||||
var tenant = $"retention-{Guid.NewGuid():N}";
|
||||
const string userId = "retention-user";
|
||||
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var analytics = scope.ServiceProvider.GetRequiredService<SearchAnalyticsService>();
|
||||
var monitor = scope.ServiceProvider.GetRequiredService<SearchQualityMonitor>();
|
||||
|
||||
await analytics.RecordEventAsync(new SearchAnalyticsEvent(
|
||||
TenantId: tenant,
|
||||
EventType: "click",
|
||||
Query: "retention query",
|
||||
UserId: userId,
|
||||
EntityKey: "docs:retention",
|
||||
Domain: "knowledge",
|
||||
Position: 0));
|
||||
|
||||
await analytics.RecordHistoryAsync(
|
||||
tenant,
|
||||
userId,
|
||||
"retention query",
|
||||
1);
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
await monitor.StoreFeedbackAsync(new SearchFeedbackEntry
|
||||
{
|
||||
TenantId = tenant,
|
||||
UserId = userId,
|
||||
Query = "retention query",
|
||||
EntityKey = "docs:retention",
|
||||
Domain = "knowledge",
|
||||
Position = i,
|
||||
Signal = "not_helpful"
|
||||
});
|
||||
}
|
||||
|
||||
await monitor.RefreshAlertsAsync(tenant);
|
||||
|
||||
var prePopularity = await analytics.GetPopularityMapAsync(tenant, 30);
|
||||
prePopularity.Should().ContainKey("docs:retention");
|
||||
(await analytics.GetHistoryAsync(tenant, userId)).Should().NotBeEmpty();
|
||||
(await monitor.GetAlertsAsync(tenant, status: "open", alertType: "high_negative_feedback"))
|
||||
.Should().NotBeEmpty();
|
||||
|
||||
await Task.Delay(20);
|
||||
|
||||
var analyticsPrune = await analytics.PruneExpiredAsync(0);
|
||||
var qualityPrune = await monitor.PruneExpiredAsync(0);
|
||||
|
||||
analyticsPrune.EventsDeleted.Should().BeGreaterThan(0);
|
||||
analyticsPrune.HistoryDeleted.Should().BeGreaterThan(0);
|
||||
qualityPrune.FeedbackDeleted.Should().BeGreaterThan(0);
|
||||
qualityPrune.AlertsDeleted.Should().BeGreaterThan(0);
|
||||
|
||||
(await analytics.GetPopularityMapAsync(tenant, 30))
|
||||
.Should().BeEmpty("retention pruning should remove expired click analytics");
|
||||
(await analytics.GetHistoryAsync(tenant, userId))
|
||||
.Should().BeEmpty("retention pruning should remove expired search history");
|
||||
(await monitor.GetAlertsAsync(tenant, status: "open", alertType: "high_negative_feedback"))
|
||||
.Should().BeEmpty("retention pruning should remove expired feedback/alerts and prevent regeneration");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task G10_AlertUpdateEndpoint_InvalidStatus_ReturnsBadRequest()
|
||||
{
|
||||
@@ -1618,6 +2064,50 @@ public sealed class UnifiedSearchSprintIntegrationTests : IDisposable
|
||||
"chunk with higher domain weight should rank first despite lower lexical rank");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RrfFusion_GravityBoost_ElevatesConnectedEntity()
|
||||
{
|
||||
using var metaCve = JsonDocument.Parse("""{"domain":"findings","entity_key":"cve:CVE-2025-1234"}""");
|
||||
using var metaImage = JsonDocument.Parse("""{"domain":"graph","entity_key":"image:registry.io/app:v1"}""");
|
||||
|
||||
var cveRow = new KnowledgeChunkRow("chunk-cve", "doc-cve", "finding", null, null, 0, 50, "CVE row", "body", "snippet", metaCve, null, 1.0);
|
||||
var imageRow = new KnowledgeChunkRow("chunk-image", "doc-image", "graph_node", null, null, 0, 50, "Image row", "body", "snippet", metaImage, null, 1.0);
|
||||
|
||||
var lexicalRanks = new Dictionary<string, (string ChunkId, int Rank, KnowledgeChunkRow Row)>(StringComparer.Ordinal)
|
||||
{
|
||||
["chunk-cve"] = ("chunk-cve", 1, cveRow),
|
||||
["chunk-image"] = ("chunk-image", 2, imageRow)
|
||||
};
|
||||
|
||||
var domainWeights = new Dictionary<string, double>(StringComparer.Ordinal)
|
||||
{
|
||||
["findings"] = 1.0,
|
||||
["graph"] = 1.0
|
||||
};
|
||||
|
||||
var noGravity = WeightedRrfFusion.Fuse(
|
||||
domainWeights,
|
||||
lexicalRanks,
|
||||
[],
|
||||
"CVE-2025-1234",
|
||||
null);
|
||||
|
||||
var withGravity = WeightedRrfFusion.Fuse(
|
||||
domainWeights,
|
||||
lexicalRanks,
|
||||
[],
|
||||
"CVE-2025-1234",
|
||||
null,
|
||||
gravityBoostMap: new Dictionary<string, double>(StringComparer.Ordinal)
|
||||
{
|
||||
["image:registry.io/app:v1"] = 0.5
|
||||
});
|
||||
|
||||
noGravity[0].Row.ChunkId.Should().Be("chunk-cve");
|
||||
withGravity[0].Row.ChunkId.Should().Be("chunk-image",
|
||||
"gravity boost should elevate graph-connected entities for CVE-centric queries");
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// EntityExtractor tests (supports G2, G9)
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
@@ -1665,19 +2155,23 @@ public sealed class UnifiedSearchSprintIntegrationTests : IDisposable
|
||||
// Helpers
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
private HttpClient CreateAuthenticatedClient()
|
||||
private HttpClient CreateAuthenticatedClient() => CreateAuthenticatedClient("test-tenant");
|
||||
|
||||
private HttpClient CreateAuthenticatedClient(string tenantId)
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Scopes", "advisory-ai:operate");
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "test-tenant");
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
|
||||
return client;
|
||||
}
|
||||
|
||||
private HttpClient CreateAdminClient()
|
||||
private HttpClient CreateAdminClient() => CreateAdminClient("test-tenant");
|
||||
|
||||
private HttpClient CreateAdminClient(string tenantId)
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Scopes", "advisory-ai:admin");
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "test-tenant");
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
|
||||
return client;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.Context;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.UnifiedSearch;
|
||||
|
||||
public sealed class AmbientContextProcessorTests
|
||||
{
|
||||
[Fact]
|
||||
public void ApplyRouteBoost_boosts_matching_domain_from_route()
|
||||
{
|
||||
var processor = new AmbientContextProcessor();
|
||||
var weights = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["knowledge"] = 1.0,
|
||||
["graph"] = 1.0
|
||||
};
|
||||
|
||||
var boosted = processor.ApplyRouteBoost(weights, new AmbientContext
|
||||
{
|
||||
CurrentRoute = "/ops/graph/nodes/node-1"
|
||||
});
|
||||
|
||||
boosted["graph"].Should().BeApproximately(1.10, 0.0001);
|
||||
boosted["knowledge"].Should().Be(1.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildEntityBoostMap_merges_visible_entities_and_session_boosts()
|
||||
{
|
||||
var processor = new AmbientContextProcessor();
|
||||
var snapshot = new SearchSessionSnapshot(
|
||||
"session-1",
|
||||
DateTimeOffset.UtcNow,
|
||||
new Dictionary<string, double>(StringComparer.Ordinal)
|
||||
{
|
||||
["cve:CVE-2025-1234"] = 0.15
|
||||
});
|
||||
|
||||
var map = processor.BuildEntityBoostMap(
|
||||
new AmbientContext
|
||||
{
|
||||
VisibleEntityKeys = ["cve:CVE-2025-1234", "image:registry.io/app:v1"]
|
||||
},
|
||||
snapshot);
|
||||
|
||||
map["cve:CVE-2025-1234"].Should().BeApproximately(0.20, 0.0001);
|
||||
map["image:registry.io/app:v1"].Should().BeApproximately(0.20, 0.0001);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CarryForwardEntities_adds_session_entities_for_followup_queries_without_new_entities()
|
||||
{
|
||||
var processor = new AmbientContextProcessor();
|
||||
var snapshot = new SearchSessionSnapshot(
|
||||
"session-2",
|
||||
DateTimeOffset.UtcNow,
|
||||
new Dictionary<string, double>(StringComparer.Ordinal)
|
||||
{
|
||||
["cve:CVE-2025-9999"] = 0.12
|
||||
});
|
||||
|
||||
var carried = processor.CarryForwardEntities([], snapshot);
|
||||
|
||||
carried.Should().ContainSingle();
|
||||
carried[0].EntityType.Should().Be("cve");
|
||||
carried[0].Value.Should().Be("CVE-2025-9999");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyRouteBoost_without_ambient_context_is_no_op()
|
||||
{
|
||||
var processor = new AmbientContextProcessor();
|
||||
var weights = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["findings"] = 1.0
|
||||
};
|
||||
|
||||
var boosted = processor.ApplyRouteBoost(weights, ambient: null);
|
||||
|
||||
boosted.Should().BeEquivalentTo(weights);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.UnifiedSearch.Benchmark;
|
||||
|
||||
internal sealed record UnifiedSearchQualityCorpus(
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("generatedAtUtc")] string GeneratedAtUtc,
|
||||
[property: JsonPropertyName("caseCount")] int CaseCount,
|
||||
[property: JsonPropertyName("cases")] IReadOnlyList<UnifiedSearchQualityCase> Cases);
|
||||
|
||||
internal sealed record UnifiedSearchQualityCase(
|
||||
[property: JsonPropertyName("caseId")] string CaseId,
|
||||
[property: JsonPropertyName("archetype")] string Archetype,
|
||||
[property: JsonPropertyName("query")] string Query,
|
||||
[property: JsonPropertyName("expected")] IReadOnlyList<UnifiedSearchExpectedResult> Expected,
|
||||
[property: JsonPropertyName("requiresCrossDomain")] bool RequiresCrossDomain);
|
||||
|
||||
internal sealed record UnifiedSearchExpectedResult(
|
||||
[property: JsonPropertyName("entityKey")] string EntityKey,
|
||||
[property: JsonPropertyName("domain")] string Domain,
|
||||
[property: JsonPropertyName("grade")] int Grade);
|
||||
|
||||
internal sealed record UnifiedSearchQualityGateThresholds(
|
||||
double MinPrecisionAt1,
|
||||
double MinNdcgAt10,
|
||||
double MinEntityCardAccuracy,
|
||||
double MinCrossDomainRecall)
|
||||
{
|
||||
public static UnifiedSearchQualityGateThresholds Default { get; } = new(
|
||||
MinPrecisionAt1: 0.80,
|
||||
MinNdcgAt10: 0.70,
|
||||
MinEntityCardAccuracy: 0.85,
|
||||
MinCrossDomainRecall: 0.60);
|
||||
}
|
||||
|
||||
internal sealed record UnifiedSearchQualityMetrics(
|
||||
int QueryCount,
|
||||
double PrecisionAt1,
|
||||
double PrecisionAt3,
|
||||
double PrecisionAt5,
|
||||
double PrecisionAt10,
|
||||
double RecallAt10,
|
||||
double NdcgAt10,
|
||||
double EntityCardAccuracy,
|
||||
double CrossDomainRecall,
|
||||
string RankingStabilityHash);
|
||||
|
||||
internal sealed record UnifiedSearchQualityReport(
|
||||
UnifiedSearchQualityMetrics Overall,
|
||||
IReadOnlyDictionary<string, UnifiedSearchQualityMetrics> ByArchetype,
|
||||
bool PassedQualityGates,
|
||||
UnifiedSearchQualityGateThresholds Gates,
|
||||
IReadOnlyDictionary<string, double> EffectiveDefaultDomainWeights);
|
||||
@@ -0,0 +1,462 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.QueryUnderstanding;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.UnifiedSearch.Benchmark;
|
||||
|
||||
internal sealed class UnifiedSearchQualityBenchmarkRunner
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private static readonly string[] CanonicalDomains =
|
||||
[
|
||||
"knowledge",
|
||||
"findings",
|
||||
"vex",
|
||||
"policy",
|
||||
"graph",
|
||||
"timeline",
|
||||
"scanner",
|
||||
"opsmemory"
|
||||
];
|
||||
|
||||
private readonly EntityExtractor _extractor = new();
|
||||
private readonly IntentClassifier _intentClassifier = new();
|
||||
|
||||
public UnifiedSearchQualityCorpus LoadCorpus(string path)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
|
||||
var absolute = Path.GetFullPath(path);
|
||||
if (!File.Exists(absolute))
|
||||
{
|
||||
throw new FileNotFoundException("Unified search quality corpus was not found.", absolute);
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(absolute);
|
||||
var corpus = JsonSerializer.Deserialize<UnifiedSearchQualityCorpus>(json, JsonOptions)
|
||||
?? throw new InvalidOperationException($"Could not deserialize benchmark corpus at '{absolute}'.");
|
||||
|
||||
if (corpus.Cases.Count != corpus.CaseCount)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Corpus caseCount mismatch. Header={corpus.CaseCount}, actual={corpus.Cases.Count}.");
|
||||
}
|
||||
|
||||
return corpus;
|
||||
}
|
||||
|
||||
public UnifiedSearchQualityReport Run(
|
||||
UnifiedSearchQualityCorpus corpus,
|
||||
UnifiedSearchOptions unifiedOptions,
|
||||
UnifiedSearchQualityGateThresholds? gates = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(corpus);
|
||||
ArgumentNullException.ThrowIfNull(unifiedOptions);
|
||||
|
||||
var gateThresholds = gates ?? UnifiedSearchQualityGateThresholds.Default;
|
||||
|
||||
var knowledgeOptions = Options.Create(new KnowledgeSearchOptions
|
||||
{
|
||||
RoleBasedBiasEnabled = true
|
||||
});
|
||||
var unifiedWrapped = Options.Create(unifiedOptions);
|
||||
var calculator = new DomainWeightCalculator(
|
||||
_extractor,
|
||||
_intentClassifier,
|
||||
knowledgeOptions,
|
||||
unifiedWrapped);
|
||||
|
||||
var evaluations = new List<CaseEvaluation>(corpus.Cases.Count);
|
||||
foreach (var testCase in corpus.Cases)
|
||||
{
|
||||
var entities = _extractor.Extract(testCase.Query);
|
||||
var weights = calculator.ComputeWeights(testCase.Query, entities, null);
|
||||
var candidates = BuildCandidates(testCase);
|
||||
|
||||
var lexical = candidates
|
||||
.OrderBy(static c => c.LexicalRank)
|
||||
.ThenBy(static c => c.Row.ChunkId, StringComparer.Ordinal)
|
||||
.Select((candidate, index) => (candidate.Row.ChunkId, Rank: index + 1, candidate.Row))
|
||||
.ToDictionary(static item => item.ChunkId, static item => item, StringComparer.Ordinal);
|
||||
|
||||
var vector = candidates
|
||||
.OrderBy(static c => c.VectorRank)
|
||||
.ThenBy(static c => c.Row.ChunkId, StringComparer.Ordinal)
|
||||
.Select((candidate, index) => (candidate.Row, Rank: index + 1, Score: 1d / (index + 1)))
|
||||
.ToArray();
|
||||
|
||||
var fused = WeightedRrfFusion.Fuse(
|
||||
weights,
|
||||
lexical,
|
||||
vector,
|
||||
testCase.Query,
|
||||
filters: null,
|
||||
detectedEntities: entities,
|
||||
enableFreshnessBoost: false,
|
||||
referenceTime: null,
|
||||
popularityMap: null,
|
||||
popularityBoostWeight: 0.0,
|
||||
contextEntityBoosts: null,
|
||||
gravityBoostMap: null);
|
||||
|
||||
var ranked = fused
|
||||
.Take(10)
|
||||
.Select(item => candidates.First(candidate => candidate.Row.ChunkId == item.Row.ChunkId))
|
||||
.ToArray();
|
||||
|
||||
evaluations.Add(EvaluateCase(testCase, ranked));
|
||||
}
|
||||
|
||||
var overall = BuildMetrics(evaluations);
|
||||
var byArchetype = evaluations
|
||||
.GroupBy(static evaluation => evaluation.Archetype, StringComparer.Ordinal)
|
||||
.OrderBy(static group => group.Key, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
static group => group.Key,
|
||||
static group => BuildMetrics(group.ToList()),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
var passed = overall.PrecisionAt1 >= gateThresholds.MinPrecisionAt1 &&
|
||||
overall.NdcgAt10 >= gateThresholds.MinNdcgAt10 &&
|
||||
overall.EntityCardAccuracy >= gateThresholds.MinEntityCardAccuracy &&
|
||||
overall.CrossDomainRecall >= gateThresholds.MinCrossDomainRecall;
|
||||
|
||||
var effectiveWeights = CanonicalDomains
|
||||
.ToDictionary(
|
||||
static domain => domain,
|
||||
domain => unifiedOptions.BaseDomainWeights.TryGetValue(domain, out var value) ? value : 1.0,
|
||||
StringComparer.Ordinal);
|
||||
|
||||
return new UnifiedSearchQualityReport(
|
||||
overall,
|
||||
byArchetype,
|
||||
passed,
|
||||
gateThresholds,
|
||||
effectiveWeights);
|
||||
}
|
||||
|
||||
public void WriteReportJson(string path, UnifiedSearchQualityReport report)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
ArgumentNullException.ThrowIfNull(report);
|
||||
|
||||
var directory = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(report, JsonOptions);
|
||||
File.WriteAllText(path, json, Encoding.UTF8);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<BenchmarkCandidate> BuildCandidates(UnifiedSearchQualityCase testCase)
|
||||
{
|
||||
var preferredDomains = GetPreferredDomains(testCase.Archetype);
|
||||
var expectedDomains = new HashSet<string>(
|
||||
testCase.Expected.Select(static expected => expected.Domain),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var candidates = new List<BenchmarkCandidate>(testCase.Expected.Count + CanonicalDomains.Length);
|
||||
foreach (var expected in testCase.Expected)
|
||||
{
|
||||
var jitter = StableJitter($"{testCase.CaseId}:{expected.EntityKey}", 3);
|
||||
var lexicalRank = BaseRankForGrade(expected.Grade) + jitter;
|
||||
var vectorRank = BaseRankForGrade(expected.Grade) + 1 + jitter;
|
||||
|
||||
if (preferredDomains.Contains(expected.Domain))
|
||||
{
|
||||
lexicalRank = Math.Max(2, lexicalRank - 2);
|
||||
vectorRank = Math.Max(2, vectorRank - 1);
|
||||
}
|
||||
|
||||
candidates.Add(new BenchmarkCandidate(
|
||||
expected.EntityKey,
|
||||
expected.Domain,
|
||||
expected.Grade,
|
||||
lexicalRank,
|
||||
vectorRank,
|
||||
BuildRow(testCase.CaseId, expected.EntityKey, expected.Domain)));
|
||||
}
|
||||
|
||||
for (var index = 0; index < CanonicalDomains.Length; index++)
|
||||
{
|
||||
var domain = CanonicalDomains[index];
|
||||
if (expectedDomains.Contains(domain))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var noiseEntity = $"noise:{testCase.CaseId}:{domain}";
|
||||
var lexicalRank = domain.Equals("knowledge", StringComparison.OrdinalIgnoreCase) ? 4 : 5 + index;
|
||||
var vectorRank = lexicalRank + 1;
|
||||
|
||||
candidates.Add(new BenchmarkCandidate(
|
||||
noiseEntity,
|
||||
domain,
|
||||
Grade: 0,
|
||||
lexicalRank,
|
||||
vectorRank,
|
||||
BuildRow(testCase.CaseId, noiseEntity, domain)));
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private static KnowledgeChunkRow BuildRow(string caseId, string entityKey, string domain)
|
||||
{
|
||||
var kind = domain switch
|
||||
{
|
||||
"findings" => "finding",
|
||||
"vex" => "vex_statement",
|
||||
"policy" => "policy_rule",
|
||||
"graph" => "graph_node",
|
||||
"timeline" => "audit_event",
|
||||
"scanner" => "scan_result",
|
||||
"opsmemory" => "ops_decision",
|
||||
_ => "md_section"
|
||||
};
|
||||
|
||||
var chunkId = StableChunkId(caseId, entityKey);
|
||||
var metadata = JsonDocument.Parse(
|
||||
JsonSerializer.Serialize(new Dictionary<string, string>
|
||||
{
|
||||
["entity_key"] = entityKey,
|
||||
["domain"] = domain,
|
||||
["entity_type"] = kind
|
||||
}));
|
||||
|
||||
return new KnowledgeChunkRow(
|
||||
ChunkId: chunkId,
|
||||
DocId: $"doc:{caseId}",
|
||||
Kind: kind,
|
||||
Anchor: null,
|
||||
SectionPath: null,
|
||||
SpanStart: 0,
|
||||
SpanEnd: 0,
|
||||
Title: entityKey,
|
||||
Body: $"Synthetic benchmark row for {entityKey}",
|
||||
Snippet: $"Synthetic benchmark row for {entityKey}",
|
||||
Metadata: metadata,
|
||||
Embedding: null,
|
||||
LexicalScore: 1.0);
|
||||
}
|
||||
|
||||
private static int BaseRankForGrade(int grade)
|
||||
{
|
||||
return grade switch
|
||||
{
|
||||
3 => 4,
|
||||
2 => 7,
|
||||
1 => 10,
|
||||
_ => 12
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlySet<string> GetPreferredDomains(string archetype)
|
||||
{
|
||||
return archetype switch
|
||||
{
|
||||
"cve_lookup" => new HashSet<string>(["findings", "vex", "graph"], StringComparer.OrdinalIgnoreCase),
|
||||
"package_image" => new HashSet<string>(["graph", "scanner", "findings"], StringComparer.OrdinalIgnoreCase),
|
||||
"documentation" => new HashSet<string>(["knowledge"], StringComparer.OrdinalIgnoreCase),
|
||||
"doctor_diagnostic" => new HashSet<string>(["knowledge", "opsmemory", "timeline"], StringComparer.OrdinalIgnoreCase),
|
||||
"policy_search" => new HashSet<string>(["policy", "knowledge"], StringComparer.OrdinalIgnoreCase),
|
||||
"audit_timeline" => new HashSet<string>(["timeline", "opsmemory"], StringComparer.OrdinalIgnoreCase),
|
||||
"cross_domain" => new HashSet<string>(["findings", "vex", "graph", "knowledge"], StringComparer.OrdinalIgnoreCase),
|
||||
"conversational_followup" => new HashSet<string>(["knowledge", "findings", "policy", "opsmemory"], StringComparer.OrdinalIgnoreCase),
|
||||
_ => new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
};
|
||||
}
|
||||
|
||||
private static CaseEvaluation EvaluateCase(
|
||||
UnifiedSearchQualityCase testCase,
|
||||
IReadOnlyList<BenchmarkCandidate> ranked)
|
||||
{
|
||||
var expectedMap = testCase.Expected
|
||||
.ToDictionary(static item => item.EntityKey, static item => item, StringComparer.Ordinal);
|
||||
var relevantSet = new HashSet<string>(
|
||||
testCase.Expected
|
||||
.Where(static item => item.Grade >= 2)
|
||||
.Select(static item => item.EntityKey),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
var top10 = ranked.Take(10).ToArray();
|
||||
var topKeys = top10.Select(static row => row.EntityKey).ToArray();
|
||||
|
||||
double PrecisionAt(int k)
|
||||
{
|
||||
if (k <= 0)
|
||||
{
|
||||
return 0d;
|
||||
}
|
||||
|
||||
var considered = top10.Take(k);
|
||||
var hits = considered.Count(item => relevantSet.Contains(item.EntityKey));
|
||||
return hits / (double)k;
|
||||
}
|
||||
|
||||
var recallAt10 = relevantSet.Count == 0
|
||||
? 1d
|
||||
: top10.Count(item => relevantSet.Contains(item.EntityKey)) / (double)relevantSet.Count;
|
||||
|
||||
var dcg = 0d;
|
||||
for (var index = 0; index < top10.Length; index++)
|
||||
{
|
||||
var key = top10[index].EntityKey;
|
||||
var grade = expectedMap.TryGetValue(key, out var expected) ? expected.Grade : 0;
|
||||
if (grade <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
dcg += (Math.Pow(2d, grade) - 1d) / Math.Log2(index + 2d);
|
||||
}
|
||||
|
||||
var idealGrades = testCase.Expected
|
||||
.Select(static item => item.Grade)
|
||||
.OrderByDescending(static grade => grade)
|
||||
.Take(10)
|
||||
.ToArray();
|
||||
|
||||
var idcg = 0d;
|
||||
for (var index = 0; index < idealGrades.Length; index++)
|
||||
{
|
||||
var grade = idealGrades[index];
|
||||
if (grade <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
idcg += (Math.Pow(2d, grade) - 1d) / Math.Log2(index + 2d);
|
||||
}
|
||||
|
||||
var ndcgAt10 = idcg > 0d ? dcg / idcg : 1d;
|
||||
|
||||
var maxGrade = testCase.Expected.Count == 0
|
||||
? 0
|
||||
: testCase.Expected.Max(static item => item.Grade);
|
||||
var topEntityAccurate = maxGrade > 0 &&
|
||||
ranked.Count > 0 &&
|
||||
testCase.Expected.Any(expected =>
|
||||
expected.Grade == maxGrade &&
|
||||
expected.EntityKey.Equals(ranked[0].EntityKey, StringComparison.Ordinal));
|
||||
|
||||
var crossDomainSuccess = false;
|
||||
if (testCase.RequiresCrossDomain)
|
||||
{
|
||||
var matchedDomains = top10
|
||||
.Where(item => expectedMap.ContainsKey(item.EntityKey) && expectedMap[item.EntityKey].Grade >= 2)
|
||||
.Select(item => expectedMap[item.EntityKey].Domain)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Count();
|
||||
crossDomainSuccess = matchedDomains >= 2;
|
||||
}
|
||||
|
||||
return new CaseEvaluation(
|
||||
testCase.CaseId,
|
||||
testCase.Archetype,
|
||||
PrecisionAt(1),
|
||||
PrecisionAt(3),
|
||||
PrecisionAt(5),
|
||||
PrecisionAt(10),
|
||||
recallAt10,
|
||||
ndcgAt10,
|
||||
topEntityAccurate,
|
||||
testCase.RequiresCrossDomain,
|
||||
crossDomainSuccess,
|
||||
topKeys);
|
||||
}
|
||||
|
||||
private static UnifiedSearchQualityMetrics BuildMetrics(IReadOnlyList<CaseEvaluation> evaluations)
|
||||
{
|
||||
if (evaluations.Count == 0)
|
||||
{
|
||||
return new UnifiedSearchQualityMetrics(0, 0d, 0d, 0d, 0d, 0d, 0d, 0d, 0d, string.Empty);
|
||||
}
|
||||
|
||||
var crossDomainCases = evaluations.Where(static item => item.RequiresCrossDomain).ToArray();
|
||||
var crossDomainRecall = crossDomainCases.Length == 0
|
||||
? 1d
|
||||
: crossDomainCases.Count(static item => item.CrossDomainSuccess) / (double)crossDomainCases.Length;
|
||||
|
||||
var stabilityHash = ComputeStabilityHash(evaluations);
|
||||
|
||||
return new UnifiedSearchQualityMetrics(
|
||||
QueryCount: evaluations.Count,
|
||||
PrecisionAt1: evaluations.Average(static item => item.PrecisionAt1),
|
||||
PrecisionAt3: evaluations.Average(static item => item.PrecisionAt3),
|
||||
PrecisionAt5: evaluations.Average(static item => item.PrecisionAt5),
|
||||
PrecisionAt10: evaluations.Average(static item => item.PrecisionAt10),
|
||||
RecallAt10: evaluations.Average(static item => item.RecallAt10),
|
||||
NdcgAt10: evaluations.Average(static item => item.NdcgAt10),
|
||||
EntityCardAccuracy: evaluations.Count(static item => item.TopEntityAccurate) / (double)evaluations.Count,
|
||||
CrossDomainRecall: crossDomainRecall,
|
||||
RankingStabilityHash: stabilityHash);
|
||||
}
|
||||
|
||||
private static string ComputeStabilityHash(IReadOnlyList<CaseEvaluation> evaluations)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
foreach (var evaluation in evaluations.OrderBy(static item => item.CaseId, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append(evaluation.CaseId);
|
||||
builder.Append('|');
|
||||
builder.AppendJoin(',', evaluation.TopEntityKeys);
|
||||
builder.Append('\n');
|
||||
}
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(builder.ToString());
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash);
|
||||
}
|
||||
|
||||
private static int StableJitter(string value, int modulo)
|
||||
{
|
||||
if (modulo <= 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value));
|
||||
var raw = BitConverter.ToUInt32(bytes, 0);
|
||||
return (int)(raw % (uint)modulo);
|
||||
}
|
||||
|
||||
private static string StableChunkId(string caseId, string entityKey)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes($"{caseId}:{entityKey}"));
|
||||
return Convert.ToHexString(bytes[..8]);
|
||||
}
|
||||
|
||||
private sealed record BenchmarkCandidate(
|
||||
string EntityKey,
|
||||
string Domain,
|
||||
int Grade,
|
||||
int LexicalRank,
|
||||
int VectorRank,
|
||||
KnowledgeChunkRow Row);
|
||||
|
||||
private sealed record CaseEvaluation(
|
||||
string CaseId,
|
||||
string Archetype,
|
||||
double PrecisionAt1,
|
||||
double PrecisionAt3,
|
||||
double PrecisionAt5,
|
||||
double PrecisionAt10,
|
||||
double RecallAt10,
|
||||
double NdcgAt10,
|
||||
bool TopEntityAccurate,
|
||||
bool RequiresCrossDomain,
|
||||
bool CrossDomainSuccess,
|
||||
IReadOnlyList<string> TopEntityKeys);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.Cards;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.UnifiedSearch;
|
||||
|
||||
public sealed class EntityCardAssemblerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AssembleAsync_single_domain_entity_keeps_single_card()
|
||||
{
|
||||
var assembler = CreateAssembler();
|
||||
var input = new[]
|
||||
{
|
||||
CreateCard("cve:CVE-2025-1111", "finding", "findings", "CVE-2025-1111", 0.9)
|
||||
};
|
||||
|
||||
var cards = await assembler.AssembleAsync(input, CancellationToken.None);
|
||||
|
||||
cards.Should().ContainSingle();
|
||||
cards[0].EntityKey.Should().Be("cve:CVE-2025-1111");
|
||||
cards[0].Facets.Should().ContainSingle();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssembleAsync_multi_domain_entity_merges_into_single_card_with_multiple_facets()
|
||||
{
|
||||
var assembler = CreateAssembler();
|
||||
var input = new[]
|
||||
{
|
||||
CreateCard("cve:CVE-2025-2222", "finding", "findings", "Finding facet", 0.8),
|
||||
CreateCard("cve:CVE-2025-2222", "vex_statement", "vex", "VEX facet", 0.7)
|
||||
};
|
||||
|
||||
var cards = await assembler.AssembleAsync(input, CancellationToken.None);
|
||||
|
||||
cards.Should().ContainSingle();
|
||||
cards[0].Facets.Should().HaveCount(2);
|
||||
cards[0].Score.Should().BeGreaterThan(0.8, "facet diversity adds a small score lift");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssembleAsync_alias_resolved_entity_merges_ghsa_and_cve_cards()
|
||||
{
|
||||
var aliases = new StubAliasService(new Dictionary<string, IReadOnlyList<(string EntityKey, string EntityType)>>(StringComparer.Ordinal)
|
||||
{
|
||||
["ghsa:GHSA-ABCD-1234"] = [("cve:CVE-2025-3333", "cve")]
|
||||
});
|
||||
var assembler = CreateAssembler(aliases);
|
||||
var input = new[]
|
||||
{
|
||||
CreateCard("ghsa:GHSA-ABCD-1234", "finding", "findings", "GHSA advisory", 0.88),
|
||||
CreateCard("cve:CVE-2025-3333", "vex_statement", "vex", "CVE statement", 0.77)
|
||||
};
|
||||
|
||||
var cards = await assembler.AssembleAsync(input, CancellationToken.None);
|
||||
|
||||
cards.Should().ContainSingle();
|
||||
cards[0].EntityKey.Should().Be("cve:CVE-2025-3333");
|
||||
cards[0].Facets.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssembleAsync_standalone_result_without_entity_key_remains_individual()
|
||||
{
|
||||
var assembler = CreateAssembler();
|
||||
var input = new[]
|
||||
{
|
||||
CreateCard(null, "docs", "knowledge", "Standalone doc", 0.5),
|
||||
CreateCard("cve:CVE-2025-4444", "finding", "findings", "Entity card", 0.9)
|
||||
};
|
||||
|
||||
var cards = await assembler.AssembleAsync(input, CancellationToken.None);
|
||||
|
||||
cards.Should().HaveCount(2);
|
||||
cards.Should().Contain(card => card.Title == "Standalone doc");
|
||||
cards.Should().Contain(card => card.EntityKey == "cve:CVE-2025-4444");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AssembleAsync_respects_max_cards_limit()
|
||||
{
|
||||
var options = new UnifiedSearchOptions { MaxCards = 1 };
|
||||
var assembler = CreateAssembler(options: options);
|
||||
var input = new[]
|
||||
{
|
||||
CreateCard("cve:CVE-2025-1", "finding", "findings", "Top card", 0.95),
|
||||
CreateCard("cve:CVE-2025-2", "finding", "findings", "Second card", 0.80)
|
||||
};
|
||||
|
||||
var cards = await assembler.AssembleAsync(input, CancellationToken.None);
|
||||
|
||||
cards.Should().ContainSingle();
|
||||
cards[0].Title.Should().Be("Top card");
|
||||
}
|
||||
|
||||
private static EntityCardAssembler CreateAssembler(
|
||||
IEntityAliasService? aliasService = null,
|
||||
UnifiedSearchOptions? options = null)
|
||||
{
|
||||
return new EntityCardAssembler(
|
||||
aliasService ?? new StubAliasService(new Dictionary<string, IReadOnlyList<(string EntityKey, string EntityType)>>()),
|
||||
Options.Create(options ?? new UnifiedSearchOptions { MaxCards = 20 }),
|
||||
NullLogger<EntityCardAssembler>.Instance);
|
||||
}
|
||||
|
||||
private static EntityCard CreateCard(
|
||||
string? entityKey,
|
||||
string entityType,
|
||||
string domain,
|
||||
string title,
|
||||
double score)
|
||||
{
|
||||
var key = string.IsNullOrWhiteSpace(entityKey) ? string.Empty : entityKey;
|
||||
return new EntityCard
|
||||
{
|
||||
EntityKey = key,
|
||||
EntityType = entityType,
|
||||
Domain = domain,
|
||||
Title = title,
|
||||
Snippet = $"{title} snippet",
|
||||
Score = score,
|
||||
Actions =
|
||||
[
|
||||
new EntityCardAction("Open", "navigate", $"/{domain}/detail", null, true)
|
||||
],
|
||||
Sources = [domain],
|
||||
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["domain"] = domain
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class StubAliasService : IEntityAliasService
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, IReadOnlyList<(string EntityKey, string EntityType)>> _aliases;
|
||||
|
||||
public StubAliasService(IReadOnlyDictionary<string, IReadOnlyList<(string EntityKey, string EntityType)>> aliases)
|
||||
{
|
||||
_aliases = aliases;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<(string EntityKey, string EntityType)>> ResolveAliasesAsync(
|
||||
string alias,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_aliases.TryGetValue(alias, out var values))
|
||||
{
|
||||
return Task.FromResult(values);
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<(string EntityKey, string EntityType)>>([]);
|
||||
}
|
||||
|
||||
public Task RegisterAliasAsync(
|
||||
string entityKey,
|
||||
string entityType,
|
||||
string alias,
|
||||
string source,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.Federation;
|
||||
using StellaOps.AdvisoryAI.Vectorization;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.UnifiedSearch;
|
||||
|
||||
public sealed class FederatedSearchDispatcherTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DispatchAsync_returns_disabled_when_federation_is_disabled()
|
||||
{
|
||||
var options = Options.Create(new UnifiedSearchOptions
|
||||
{
|
||||
Federation = new UnifiedSearchFederationOptions
|
||||
{
|
||||
Enabled = false
|
||||
}
|
||||
});
|
||||
|
||||
var encoder = new Mock<IVectorEncoder>();
|
||||
encoder.Setup(static value => value.Encode(It.IsAny<string>())).Returns(new float[] { 0.1f, 0.2f });
|
||||
var dispatcher = new FederatedSearchDispatcher(
|
||||
options,
|
||||
new StubHttpClientFactory(new Dictionary<string, HttpClient>(StringComparer.Ordinal)),
|
||||
encoder.Object,
|
||||
NullLogger<FederatedSearchDispatcher>.Instance);
|
||||
|
||||
var result = await dispatcher.DispatchAsync(
|
||||
"cve-2025-1234",
|
||||
CreatePlan(findingsWeight: 2.0, graphWeight: 2.0),
|
||||
new UnifiedSearchFilter { Tenant = "tenant-a" },
|
||||
CancellationToken.None);
|
||||
|
||||
result.Rows.Should().BeEmpty();
|
||||
result.Diagnostics.Should().ContainSingle();
|
||||
result.Diagnostics[0].Status.Should().Be("disabled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_queries_configured_backends_and_deduplicates_fresher_rows()
|
||||
{
|
||||
var options = Options.Create(new UnifiedSearchOptions
|
||||
{
|
||||
Federation = new UnifiedSearchFederationOptions
|
||||
{
|
||||
Enabled = true,
|
||||
ConsoleEndpoint = "http://console.internal",
|
||||
GraphEndpoint = "http://graph.internal",
|
||||
FederationThreshold = 1.0,
|
||||
TimeoutBudgetMs = 2000,
|
||||
MaxFederatedResults = 50
|
||||
}
|
||||
});
|
||||
|
||||
var consoleJson = """
|
||||
{
|
||||
"items": [
|
||||
{ "id": "finding-old", "title": "Old finding", "cveId": "CVE-2025-1234", "updatedAt": "2026-02-20T00:00:00Z" },
|
||||
{ "id": "finding-new", "title": "New finding", "cveId": "CVE-2025-1234", "updatedAt": "2026-02-22T00:00:00Z" }
|
||||
]
|
||||
}
|
||||
""";
|
||||
var graphJson = """
|
||||
[
|
||||
{ "id": "node-1", "title": "Affected image", "imageRef": "registry.io/app:v2", "updatedAt": "2026-02-23T00:00:00Z" }
|
||||
]
|
||||
""";
|
||||
|
||||
var factory = new StubHttpClientFactory(new Dictionary<string, HttpClient>(StringComparer.Ordinal)
|
||||
{
|
||||
["scanner-internal"] = CreateClient(consoleJson),
|
||||
["graph-internal"] = CreateClient(graphJson),
|
||||
["timeline-internal"] = CreateClient("[]")
|
||||
});
|
||||
|
||||
var encoder = new Mock<IVectorEncoder>();
|
||||
encoder.Setup(static value => value.Encode(It.IsAny<string>())).Returns(new float[] { 0.1f, 0.2f, 0.3f });
|
||||
|
||||
var dispatcher = new FederatedSearchDispatcher(
|
||||
options,
|
||||
factory,
|
||||
encoder.Object,
|
||||
NullLogger<FederatedSearchDispatcher>.Instance);
|
||||
|
||||
var result = await dispatcher.DispatchAsync(
|
||||
"CVE-2025-1234",
|
||||
CreatePlan(findingsWeight: 2.0, graphWeight: 2.0),
|
||||
new UnifiedSearchFilter { Tenant = "tenant-a" },
|
||||
CancellationToken.None);
|
||||
|
||||
result.Diagnostics.Should().HaveCount(2);
|
||||
result.Diagnostics.Should().Contain(static d => d.Backend == "console" && d.Status == "ok");
|
||||
result.Diagnostics.Should().Contain(static d => d.Backend == "graph" && d.Status == "ok");
|
||||
|
||||
result.Rows.Should().HaveCount(2, "findings rows are deduplicated by domain+entity key and freshness");
|
||||
result.Rows.Should().Contain(static row => row.Title == "New finding");
|
||||
result.Rows.Should().NotContain(static row => row.Title == "Old finding");
|
||||
result.Rows.Should().Contain(static row => row.Title == "Affected image");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_returns_not_configured_when_no_endpoints_are_defined()
|
||||
{
|
||||
var options = Options.Create(new UnifiedSearchOptions
|
||||
{
|
||||
Federation = new UnifiedSearchFederationOptions
|
||||
{
|
||||
Enabled = true,
|
||||
FederationThreshold = 1.0
|
||||
}
|
||||
});
|
||||
|
||||
var encoder = new Mock<IVectorEncoder>();
|
||||
encoder.Setup(static value => value.Encode(It.IsAny<string>())).Returns(new float[] { 0.1f, 0.2f });
|
||||
var dispatcher = new FederatedSearchDispatcher(
|
||||
options,
|
||||
new StubHttpClientFactory(new Dictionary<string, HttpClient>(StringComparer.Ordinal)),
|
||||
encoder.Object,
|
||||
NullLogger<FederatedSearchDispatcher>.Instance);
|
||||
|
||||
var result = await dispatcher.DispatchAsync(
|
||||
"CVE-2025-9999",
|
||||
CreatePlan(findingsWeight: 2.0, graphWeight: 2.0),
|
||||
new UnifiedSearchFilter { Tenant = "tenant-a" },
|
||||
CancellationToken.None);
|
||||
|
||||
result.Rows.Should().BeEmpty();
|
||||
result.Diagnostics.Should().ContainSingle();
|
||||
result.Diagnostics[0].Status.Should().Be("not_configured");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_runs_backend_queries_in_parallel()
|
||||
{
|
||||
var options = Options.Create(new UnifiedSearchOptions
|
||||
{
|
||||
Federation = new UnifiedSearchFederationOptions
|
||||
{
|
||||
Enabled = true,
|
||||
ConsoleEndpoint = "http://console.internal",
|
||||
GraphEndpoint = "http://graph.internal",
|
||||
FederationThreshold = 1.0,
|
||||
TimeoutBudgetMs = 2000,
|
||||
MaxFederatedResults = 50
|
||||
}
|
||||
});
|
||||
|
||||
var consoleJson = """[{ "id": "finding-1", "title": "finding", "cveId": "CVE-2025-1234" }]""";
|
||||
var graphJson = """[{ "id": "graph-1", "title": "image", "imageRef": "registry.io/app:v1" }]""";
|
||||
|
||||
var factory = new StubHttpClientFactory(new Dictionary<string, HttpClient>(StringComparer.Ordinal)
|
||||
{
|
||||
["scanner-internal"] = CreateClient(consoleJson, TimeSpan.FromMilliseconds(250)),
|
||||
["graph-internal"] = CreateClient(graphJson, TimeSpan.FromMilliseconds(250)),
|
||||
["timeline-internal"] = CreateClient("[]")
|
||||
});
|
||||
|
||||
var encoder = new Mock<IVectorEncoder>();
|
||||
encoder.Setup(static value => value.Encode(It.IsAny<string>())).Returns(new float[] { 0.1f, 0.2f, 0.3f });
|
||||
|
||||
var dispatcher = new FederatedSearchDispatcher(
|
||||
options,
|
||||
factory,
|
||||
encoder.Object,
|
||||
NullLogger<FederatedSearchDispatcher>.Instance);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var result = await dispatcher.DispatchAsync(
|
||||
"CVE-2025-1234",
|
||||
CreatePlan(findingsWeight: 2.0, graphWeight: 2.0),
|
||||
new UnifiedSearchFilter { Tenant = "tenant-a" },
|
||||
CancellationToken.None);
|
||||
sw.Stop();
|
||||
|
||||
result.Diagnostics.Should().HaveCount(2);
|
||||
sw.Elapsed.Should().BeLessThan(TimeSpan.FromMilliseconds(450),
|
||||
"parallel dispatch should be bounded by the slowest backend rather than serial sum");
|
||||
}
|
||||
|
||||
private static QueryPlan CreatePlan(double findingsWeight, double graphWeight)
|
||||
{
|
||||
return new QueryPlan
|
||||
{
|
||||
OriginalQuery = "query",
|
||||
NormalizedQuery = "query",
|
||||
Intent = "inform",
|
||||
DomainWeights = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["knowledge"] = 1.0,
|
||||
["findings"] = findingsWeight,
|
||||
["graph"] = graphWeight,
|
||||
["timeline"] = 0.2
|
||||
},
|
||||
DetectedEntities = new[]
|
||||
{
|
||||
new EntityMention("CVE-2025-1234", "cve", 0, 13)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static HttpClient CreateClient(string json, TimeSpan? delay = null)
|
||||
{
|
||||
return new HttpClient(new StubJsonHandler(json, delay), disposeHandler: true);
|
||||
}
|
||||
|
||||
private sealed class StubHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, HttpClient> _clients;
|
||||
|
||||
public StubHttpClientFactory(IReadOnlyDictionary<string, HttpClient> clients)
|
||||
{
|
||||
_clients = clients;
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name)
|
||||
{
|
||||
if (_clients.TryGetValue(name, out var client))
|
||||
{
|
||||
return client;
|
||||
}
|
||||
|
||||
return new HttpClient(new StubJsonHandler("[]"), disposeHandler: true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubJsonHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly string _json;
|
||||
private readonly TimeSpan _delay;
|
||||
|
||||
public StubJsonHandler(string json, TimeSpan? delay = null)
|
||||
{
|
||||
_json = json;
|
||||
_delay = delay ?? TimeSpan.Zero;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_delay > TimeSpan.Zero)
|
||||
{
|
||||
await Task.Delay(_delay, cancellationToken);
|
||||
}
|
||||
|
||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(_json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.Federation;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.Ranking;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.UnifiedSearch;
|
||||
|
||||
public sealed class GravityBoostCalculatorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task BuildGravityMapAsync_returns_empty_when_feature_disabled()
|
||||
{
|
||||
var calculator = CreateCalculator(
|
||||
new UnifiedSearchOptions
|
||||
{
|
||||
GravityBoost = new UnifiedSearchGravityBoostOptions
|
||||
{
|
||||
Enabled = false
|
||||
}
|
||||
},
|
||||
(_, _, _, _, _) => Task.FromResult<IReadOnlyList<string>>(["image:registry.io/app:v1"]));
|
||||
|
||||
var result = await calculator.BuildGravityMapAsync(
|
||||
[new EntityMention("CVE-2026-1111", "cve", 0, 13)],
|
||||
tenant: "tenant-a",
|
||||
CancellationToken.None);
|
||||
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildGravityMapAsync_boosts_one_hop_neighbors_and_skips_query_mentions()
|
||||
{
|
||||
var calculator = CreateCalculator(
|
||||
new UnifiedSearchOptions
|
||||
{
|
||||
GravityBoost = new UnifiedSearchGravityBoostOptions
|
||||
{
|
||||
Enabled = true,
|
||||
OneHopBoost = 0.42,
|
||||
MaxNeighborsPerEntity = 10,
|
||||
MaxTotalNeighbors = 2,
|
||||
TimeoutMs = 500
|
||||
}
|
||||
},
|
||||
(entityKey, _, _, _, _) =>
|
||||
{
|
||||
if (entityKey == "cve:CVE-2026-2222")
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<string>>(
|
||||
[
|
||||
"purl:pkg:npm/lodash@4.17.21",
|
||||
"image:registry.io/app:v2",
|
||||
"registry:registry.io"
|
||||
]);
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<string>>([]);
|
||||
});
|
||||
|
||||
var result = await calculator.BuildGravityMapAsync(
|
||||
[
|
||||
new EntityMention("CVE-2026-2222", "cve", 0, 13),
|
||||
new EntityMention("pkg:npm/lodash@4.17.21", "purl", 15, 22)
|
||||
],
|
||||
tenant: "tenant-a",
|
||||
CancellationToken.None);
|
||||
|
||||
result.Should().HaveCount(2, "max total neighbors is capped at two");
|
||||
result.Should().Contain(static pair => pair.Key == "image:registry.io/app:v2" && pair.Value == 0.42);
|
||||
result.Should().Contain(static pair => pair.Key == "registry:registry.io" && pair.Value == 0.42);
|
||||
result.Should().NotContainKey("purl:pkg:npm/lodash@4.17.21");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildGravityMapAsync_returns_empty_when_neighbor_lookup_times_out()
|
||||
{
|
||||
var calculator = CreateCalculator(
|
||||
new UnifiedSearchOptions
|
||||
{
|
||||
GravityBoost = new UnifiedSearchGravityBoostOptions
|
||||
{
|
||||
Enabled = true,
|
||||
TimeoutMs = 20
|
||||
}
|
||||
},
|
||||
async (_, _, _, _, ct) =>
|
||||
{
|
||||
await Task.Delay(500, ct);
|
||||
return ["image:registry.io/app:v3"];
|
||||
});
|
||||
|
||||
var result = await calculator.BuildGravityMapAsync(
|
||||
[new EntityMention("CVE-2026-3333", "cve", 0, 13)],
|
||||
tenant: "tenant-a",
|
||||
CancellationToken.None);
|
||||
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
private static GravityBoostCalculator CreateCalculator(
|
||||
UnifiedSearchOptions options,
|
||||
Func<string, string, int, TimeSpan, CancellationToken, Task<IReadOnlyList<string>>> getNeighbors)
|
||||
{
|
||||
return new GravityBoostCalculator(
|
||||
Options.Create(options),
|
||||
new DelegateGraphNeighborProvider(getNeighbors),
|
||||
NullLogger<GravityBoostCalculator>.Instance);
|
||||
}
|
||||
|
||||
private sealed class DelegateGraphNeighborProvider : IGraphNeighborProvider
|
||||
{
|
||||
private readonly Func<string, string, int, TimeSpan, CancellationToken, Task<IReadOnlyList<string>>> _getNeighbors;
|
||||
|
||||
public DelegateGraphNeighborProvider(
|
||||
Func<string, string, int, TimeSpan, CancellationToken, Task<IReadOnlyList<string>>> getNeighbors)
|
||||
{
|
||||
_getNeighbors = getNeighbors;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<string>> GetOneHopNeighborsAsync(
|
||||
string entityKey,
|
||||
string tenant,
|
||||
int limit,
|
||||
TimeSpan timeout,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return _getNeighbors(entityKey, tenant, limit, timeout, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +151,25 @@ public sealed class QueryUnderstandingTests
|
||||
{
|
||||
var extractor = new EntityExtractor();
|
||||
var classifier = new IntentClassifier();
|
||||
var calculator = new DomainWeightCalculator(extractor, classifier, Options.Create(new KnowledgeSearchOptions()));
|
||||
var calculator = new DomainWeightCalculator(
|
||||
extractor,
|
||||
classifier,
|
||||
Options.Create(new KnowledgeSearchOptions()),
|
||||
Options.Create(new AdvisoryAI.UnifiedSearch.UnifiedSearchOptions
|
||||
{
|
||||
BaseDomainWeights = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["knowledge"] = 1.0,
|
||||
["findings"] = 1.0,
|
||||
["vex"] = 1.0,
|
||||
["policy"] = 1.0,
|
||||
["platform"] = 1.0,
|
||||
["graph"] = 1.0,
|
||||
["timeline"] = 1.0,
|
||||
["scanner"] = 1.0,
|
||||
["opsmemory"] = 1.0
|
||||
}
|
||||
}));
|
||||
|
||||
var entities = extractor.Extract("hello world");
|
||||
var weights = calculator.ComputeWeights("hello world", entities, null);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.Context;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.UnifiedSearch;
|
||||
|
||||
public sealed class SearchSessionContextServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void RecordQuery_and_GetSnapshot_preserve_entity_context_with_decay()
|
||||
{
|
||||
var service = new SearchSessionContextService();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
service.RecordQuery(
|
||||
"tenant-a",
|
||||
"user-a",
|
||||
"session-a",
|
||||
[new EntityMention("CVE-2025-1234", "cve", 0, 13)],
|
||||
now);
|
||||
|
||||
var snapshot = service.GetSnapshot(
|
||||
"tenant-a",
|
||||
"user-a",
|
||||
"session-a",
|
||||
now.AddMinutes(1),
|
||||
TimeSpan.FromMinutes(5));
|
||||
|
||||
snapshot.EntityBoosts.Should().ContainKey("cve:CVE-2025-1234");
|
||||
snapshot.EntityBoosts["cve:CVE-2025-1234"].Should().BeGreaterThan(0.01);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSnapshot_expires_session_after_inactivity_ttl()
|
||||
{
|
||||
var service = new SearchSessionContextService();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
service.RecordQuery(
|
||||
"tenant-a",
|
||||
"user-a",
|
||||
"session-expire",
|
||||
[new EntityMention("CVE-2025-7777", "cve", 0, 13)],
|
||||
now);
|
||||
|
||||
var expired = service.GetSnapshot(
|
||||
"tenant-a",
|
||||
"user-a",
|
||||
"session-expire",
|
||||
now.AddMinutes(10),
|
||||
TimeSpan.FromMinutes(5));
|
||||
|
||||
expired.Should().BeEquivalentTo(SearchSessionSnapshot.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_clears_session_state()
|
||||
{
|
||||
var service = new SearchSessionContextService();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
service.RecordQuery(
|
||||
"tenant-a",
|
||||
"user-a",
|
||||
"session-reset",
|
||||
[new EntityMention("pkg:npm/lodash@4.17.21", "purl", 0, 22)],
|
||||
now);
|
||||
|
||||
service.Reset("tenant-a", "user-a", "session-reset");
|
||||
|
||||
var snapshot = service.GetSnapshot(
|
||||
"tenant-a",
|
||||
"user-a",
|
||||
"session-reset",
|
||||
now.AddSeconds(1),
|
||||
TimeSpan.FromMinutes(5));
|
||||
|
||||
snapshot.Should().BeEquivalentTo(SearchSessionSnapshot.Empty);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.Synthesis;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.UnifiedSearch;
|
||||
|
||||
public sealed class SearchSynthesisPromptAssemblerTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("CVE-2025-1234 mitigation", "inform")]
|
||||
[InlineData("how to deploy scanner", "learn")]
|
||||
[InlineData("policy for critical cvss", "policy")]
|
||||
[InlineData("who approved waiver yesterday", "audit")]
|
||||
[InlineData("scan results for registry.io/app:v2", "explore")]
|
||||
public void Build_produces_structured_prompt_for_archetypal_queries(string query, string intent)
|
||||
{
|
||||
var assembler = CreateAssembler();
|
||||
var plan = new QueryPlan
|
||||
{
|
||||
OriginalQuery = query,
|
||||
NormalizedQuery = query,
|
||||
Intent = intent
|
||||
};
|
||||
|
||||
var prompt = assembler.Build(
|
||||
query,
|
||||
CreateCards(2),
|
||||
plan,
|
||||
new SearchSynthesisPreferences { Depth = "brief", Locale = "en" },
|
||||
"Deterministic baseline summary.");
|
||||
|
||||
prompt.PromptVersion.Should().Be("search-synth-v1");
|
||||
prompt.SystemPrompt.Should().NotBeNullOrWhiteSpace();
|
||||
prompt.UserPrompt.Should().Contain($"Query: \"{query}\"");
|
||||
prompt.UserPrompt.Should().Contain($"Intent: {intent}");
|
||||
prompt.UserPrompt.Should().Contain("Evidence:");
|
||||
prompt.UserPrompt.Should().Contain("Deterministic summary:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_trims_low_scored_cards_when_token_budget_is_small()
|
||||
{
|
||||
var assembler = CreateAssembler();
|
||||
var cards = CreateCards(5).ToArray();
|
||||
|
||||
var prompt = assembler.Build(
|
||||
"large context query",
|
||||
cards,
|
||||
new QueryPlan { OriginalQuery = "large context query", NormalizedQuery = "large context query", Intent = "explore" },
|
||||
new SearchSynthesisPreferences { MaxTokens = 80 },
|
||||
"summary");
|
||||
|
||||
prompt.IncludedCards.Should().NotBeEmpty();
|
||||
prompt.IncludedCards.Count.Should().BeLessThan(cards.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_loads_system_prompt_from_external_file_when_configured()
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"stellaops-search-prompt-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
try
|
||||
{
|
||||
var promptPath = Path.Combine(tempDir, "search-system-prompt.txt");
|
||||
File.WriteAllText(promptPath, "Custom system prompt for search synthesis.");
|
||||
|
||||
var assembler = CreateAssembler(
|
||||
new UnifiedSearchOptions
|
||||
{
|
||||
Synthesis = new UnifiedSearchSynthesisOptions
|
||||
{
|
||||
PromptPath = promptPath,
|
||||
MaxContextTokens = 4000,
|
||||
SynthesisRequestsPerDay = 200,
|
||||
MaxConcurrentPerTenant = 10
|
||||
}
|
||||
},
|
||||
new KnowledgeSearchOptions
|
||||
{
|
||||
RepositoryRoot = tempDir
|
||||
});
|
||||
|
||||
var prompt = assembler.Build(
|
||||
"query",
|
||||
CreateCards(1),
|
||||
new QueryPlan { OriginalQuery = "query", NormalizedQuery = "query", Intent = "explore" },
|
||||
null,
|
||||
"summary");
|
||||
|
||||
prompt.SystemPrompt.Should().Be("Custom system prompt for search synthesis.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static SearchSynthesisPromptAssembler CreateAssembler(
|
||||
UnifiedSearchOptions? unified = null,
|
||||
KnowledgeSearchOptions? knowledge = null)
|
||||
{
|
||||
return new SearchSynthesisPromptAssembler(
|
||||
Options.Create(unified ?? new UnifiedSearchOptions()),
|
||||
Options.Create(knowledge ?? new KnowledgeSearchOptions
|
||||
{
|
||||
RepositoryRoot = "."
|
||||
}));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<EntityCard> CreateCards(int count)
|
||||
{
|
||||
return Enumerable.Range(1, count)
|
||||
.Select(index => new EntityCard
|
||||
{
|
||||
EntityKey = $"cve:CVE-2025-{index:0000}",
|
||||
EntityType = "finding",
|
||||
Domain = "findings",
|
||||
Title = $"CVE-2025-{index:0000}",
|
||||
Snippet = new string('x', 120),
|
||||
Score = 1.0 - index * 0.05,
|
||||
Actions =
|
||||
[
|
||||
new EntityCardAction("View Finding", "navigate", $"/security/triage?q=CVE-2025-{index:0000}", null, true)
|
||||
],
|
||||
Sources = ["findings"]
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.Synthesis;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.UnifiedSearch;
|
||||
|
||||
public sealed class SearchSynthesisQuotaServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryAcquire_denies_after_daily_limit_is_reached()
|
||||
{
|
||||
var service = CreateService(
|
||||
requestsPerDay: 1,
|
||||
maxConcurrent: 5);
|
||||
|
||||
using var first = service.TryAcquire("tenant-a").Lease;
|
||||
var second = service.TryAcquire("tenant-a");
|
||||
|
||||
second.Allowed.Should().BeFalse();
|
||||
second.Code.Should().Be("daily_limit_exceeded");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryAcquire_enforces_concurrent_limit_until_lease_is_released()
|
||||
{
|
||||
var service = CreateService(
|
||||
requestsPerDay: 10,
|
||||
maxConcurrent: 1);
|
||||
|
||||
var first = service.TryAcquire("tenant-b");
|
||||
first.Allowed.Should().BeTrue();
|
||||
first.Lease.Should().NotBeNull();
|
||||
|
||||
var blocked = service.TryAcquire("tenant-b");
|
||||
blocked.Allowed.Should().BeFalse();
|
||||
blocked.Code.Should().Be("concurrency_limit_exceeded");
|
||||
|
||||
first.Lease!.Dispose();
|
||||
|
||||
var afterRelease = service.TryAcquire("tenant-b");
|
||||
afterRelease.Allowed.Should().BeTrue();
|
||||
afterRelease.Lease?.Dispose();
|
||||
}
|
||||
|
||||
private static SearchSynthesisQuotaService CreateService(int requestsPerDay, int maxConcurrent)
|
||||
{
|
||||
var options = Options.Create(new UnifiedSearchOptions
|
||||
{
|
||||
Synthesis = new UnifiedSearchSynthesisOptions
|
||||
{
|
||||
SynthesisRequestsPerDay = requestsPerDay,
|
||||
MaxConcurrentPerTenant = maxConcurrent,
|
||||
MaxContextTokens = 4000,
|
||||
PromptPath = "none"
|
||||
}
|
||||
});
|
||||
|
||||
return new SearchSynthesisQuotaService(options, TimeProvider.System);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.Adapters;
|
||||
using StellaOps.AdvisoryAI.Vectorization;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.UnifiedSearch;
|
||||
|
||||
public sealed class UnifiedSearchIngestionAdaptersTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GraphNodeIngestionAdapter_projects_significant_nodes_and_filters_ephemeral_nodes()
|
||||
{
|
||||
var tempDir = CreateTempDirectory();
|
||||
try
|
||||
{
|
||||
var snapshotPath = Path.Combine(tempDir, "graph.snapshot.json");
|
||||
var payload = JsonSerializer.Serialize(new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
tenant = "tenant-a",
|
||||
nodeId = "node-pkg",
|
||||
kind = "package",
|
||||
name = "lodash",
|
||||
version = "4.17.21",
|
||||
purl = "pkg:npm/lodash@4.17.21",
|
||||
dependencyCount = 12,
|
||||
relationshipSummary = "depends-on express"
|
||||
},
|
||||
new
|
||||
{
|
||||
tenant = "tenant-a",
|
||||
nodeId = "node-image",
|
||||
kind = "image",
|
||||
imageRef = "registry.acme.io/app:v2",
|
||||
registry = "registry.acme.io",
|
||||
dependencyCount = 5,
|
||||
relationshipSummary = "contained-in prod cluster"
|
||||
},
|
||||
new
|
||||
{
|
||||
tenant = "tenant-a",
|
||||
nodeId = "node-ephemeral",
|
||||
kind = "package",
|
||||
name = "ephemeral-only",
|
||||
dependencyCount = 0
|
||||
}
|
||||
});
|
||||
|
||||
await File.WriteAllTextAsync(snapshotPath, payload);
|
||||
|
||||
var adapter = new GraphNodeIngestionAdapter(
|
||||
Options.Create(new KnowledgeSearchOptions { RepositoryRoot = tempDir }),
|
||||
Options.Create(new UnifiedSearchOptions
|
||||
{
|
||||
Ingestion = new UnifiedSearchIngestionOptions
|
||||
{
|
||||
GraphSnapshotPath = snapshotPath,
|
||||
GraphNodeKindFilter = ["package", "image", "base_image", "registry"]
|
||||
}
|
||||
}),
|
||||
new StubVectorEncoder(),
|
||||
NullLogger<GraphNodeIngestionAdapter>.Instance);
|
||||
|
||||
var chunks = await adapter.ProduceChunksAsync(CancellationToken.None);
|
||||
|
||||
chunks.Should().HaveCount(2);
|
||||
chunks.Should().OnlyContain(static chunk => chunk.Domain == "graph");
|
||||
chunks.Select(static chunk => chunk.EntityKey).Should().Contain("purl:pkg:npm/lodash@4.17.21");
|
||||
chunks.Select(static chunk => chunk.EntityKey).Should().Contain("image:registry.acme.io/app:v2");
|
||||
chunks.Should().Contain(static chunk => chunk.Title.Contains("package: lodash@4.17.21", StringComparison.Ordinal));
|
||||
chunks.Should().Contain(static chunk => chunk.Title.Contains("image: registry.acme.io/app:v2", StringComparison.Ordinal));
|
||||
chunks.Should().OnlyContain(static chunk => chunk.Metadata.RootElement.GetProperty("route").GetString()!.StartsWith("/ops/graph?node=", StringComparison.Ordinal));
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpsDecisionIngestionAdapter_projects_decisions_and_preserves_similarity_vector_metadata()
|
||||
{
|
||||
var tempDir = CreateTempDirectory();
|
||||
try
|
||||
{
|
||||
var snapshotPath = Path.Combine(tempDir, "opsmemory.snapshot.json");
|
||||
var payload = JsonSerializer.Serialize(new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
tenant = "tenant-a",
|
||||
decisionId = "dec-1",
|
||||
decisionType = "waive",
|
||||
outcomeStatus = "success",
|
||||
subjectRef = "CVE-2026-1111",
|
||||
subjectType = "finding",
|
||||
rationale = "temporary production waiver",
|
||||
contextTags = new[] { "production", "urgent" },
|
||||
severity = "high",
|
||||
resolutionTimeHours = 1.5,
|
||||
similarityVector = new[] { 0.11, 0.22, 0.33 },
|
||||
recordedAt = "2026-02-22T00:00:00Z",
|
||||
outcomeRecordedAt = "2026-02-23T00:00:00Z"
|
||||
},
|
||||
new
|
||||
{
|
||||
tenant = "tenant-a",
|
||||
decisionId = "dec-2",
|
||||
decisionType = "remediate",
|
||||
outcomeStatus = "pending",
|
||||
subjectRef = "pkg:npm/express@4.18.0",
|
||||
subjectType = "package",
|
||||
rationale = "upgrade pending maintenance window",
|
||||
contextTags = new[] { "staging" },
|
||||
severity = "medium",
|
||||
recordedAt = "2026-02-24T00:00:00Z"
|
||||
}
|
||||
});
|
||||
|
||||
await File.WriteAllTextAsync(snapshotPath, payload);
|
||||
|
||||
var adapter = new OpsDecisionIngestionAdapter(
|
||||
Options.Create(new KnowledgeSearchOptions { RepositoryRoot = tempDir }),
|
||||
Options.Create(new UnifiedSearchOptions
|
||||
{
|
||||
Ingestion = new UnifiedSearchIngestionOptions
|
||||
{
|
||||
OpsMemorySnapshotPath = snapshotPath
|
||||
}
|
||||
}),
|
||||
new StubVectorEncoder(),
|
||||
NullLogger<OpsDecisionIngestionAdapter>.Instance);
|
||||
|
||||
var chunks = await adapter.ProduceChunksAsync(CancellationToken.None);
|
||||
|
||||
chunks.Should().HaveCount(2);
|
||||
chunks.Should().OnlyContain(static chunk => chunk.Domain == "opsmemory");
|
||||
chunks.Should().Contain(static chunk => chunk.EntityKey == "cve:CVE-2026-1111");
|
||||
chunks.Should().Contain(static chunk => chunk.EntityKey == "purl:pkg:npm/express@4.18.0");
|
||||
|
||||
var first = chunks.Single(static chunk => chunk.EntityKey == "cve:CVE-2026-1111");
|
||||
first.Body.Should().Contain("waive");
|
||||
first.Body.Should().Contain("contextTags: production,urgent");
|
||||
first.Metadata.RootElement.GetProperty("similarityVector").GetArrayLength().Should().Be(3);
|
||||
first.Metadata.RootElement.GetProperty("incrementalSignals").GetArrayLength().Should().Be(2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TimelineEventIngestionAdapter_applies_retention_and_extracts_entity_keys()
|
||||
{
|
||||
var tempDir = CreateTempDirectory();
|
||||
try
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var recent = now.AddDays(-1).ToString("O", System.Globalization.CultureInfo.InvariantCulture);
|
||||
var old = now.AddDays(-180).ToString("O", System.Globalization.CultureInfo.InvariantCulture);
|
||||
var snapshotPath = Path.Combine(tempDir, "timeline.snapshot.json");
|
||||
var payload = JsonSerializer.Serialize(new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
tenant = "tenant-a",
|
||||
eventId = "evt-cve",
|
||||
action = "policy.evaluate",
|
||||
actor = "admin@acme",
|
||||
module = "Policy",
|
||||
targetRef = "CVE-2026-7777",
|
||||
timestamp = recent,
|
||||
payloadSummary = "verdict changed to deny"
|
||||
},
|
||||
new
|
||||
{
|
||||
tenant = "tenant-a",
|
||||
eventId = "evt-pkg",
|
||||
action = "scanner.complete",
|
||||
actor = "scanner-bot",
|
||||
module = "Scanner",
|
||||
targetRef = "pkg:npm/express@4.18.0",
|
||||
timestamp = recent,
|
||||
payloadSummary = "scan completed"
|
||||
},
|
||||
new
|
||||
{
|
||||
tenant = "tenant-a",
|
||||
eventId = "evt-old",
|
||||
action = "legacy.event",
|
||||
actor = "operator",
|
||||
module = "Audit",
|
||||
targetRef = "CVE-2020-0001",
|
||||
timestamp = old,
|
||||
payloadSummary = "should be pruned by retention"
|
||||
}
|
||||
});
|
||||
|
||||
await File.WriteAllTextAsync(snapshotPath, payload);
|
||||
|
||||
var adapter = new TimelineEventIngestionAdapter(
|
||||
Options.Create(new KnowledgeSearchOptions { RepositoryRoot = tempDir }),
|
||||
Options.Create(new UnifiedSearchOptions
|
||||
{
|
||||
Ingestion = new UnifiedSearchIngestionOptions
|
||||
{
|
||||
TimelineSnapshotPath = snapshotPath,
|
||||
TimelineRetentionDays = 30
|
||||
}
|
||||
}),
|
||||
new StubVectorEncoder(),
|
||||
NullLogger<TimelineEventIngestionAdapter>.Instance);
|
||||
|
||||
var chunks = await adapter.ProduceChunksAsync(CancellationToken.None);
|
||||
|
||||
chunks.Should().HaveCount(2);
|
||||
chunks.Should().OnlyContain(static chunk => chunk.Domain == "timeline");
|
||||
chunks.Should().Contain(static chunk => chunk.EntityKey == "cve:CVE-2026-7777" && chunk.EntityType == "finding");
|
||||
chunks.Should().Contain(static chunk => chunk.EntityKey == "purl:pkg:npm/express@4.18.0" && chunk.EntityType == "package");
|
||||
chunks.Should().NotContain(static chunk => chunk.ChunkId.Contains("evt-old", StringComparison.Ordinal));
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScanResultIngestionAdapter_projects_scan_results_with_image_alias_metadata()
|
||||
{
|
||||
var tempDir = CreateTempDirectory();
|
||||
try
|
||||
{
|
||||
var snapshotPath = Path.Combine(tempDir, "scanner.snapshot.json");
|
||||
var payload = JsonSerializer.Serialize(new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
tenant = "tenant-a",
|
||||
scanId = "scan-4242",
|
||||
imageRef = "registry.acme.io/backend:v5",
|
||||
scanType = "vulnerability",
|
||||
status = "complete",
|
||||
findingCount = 21,
|
||||
criticalCount = 2,
|
||||
scannerVersion = "1.2.3",
|
||||
durationMs = 5123,
|
||||
policyVerdicts = new[] { "deny", "manual_review" },
|
||||
completedAt = "2026-02-24T00:00:00Z"
|
||||
}
|
||||
});
|
||||
|
||||
await File.WriteAllTextAsync(snapshotPath, payload);
|
||||
|
||||
var adapter = new ScanResultIngestionAdapter(
|
||||
Options.Create(new KnowledgeSearchOptions { RepositoryRoot = tempDir }),
|
||||
Options.Create(new UnifiedSearchOptions
|
||||
{
|
||||
Ingestion = new UnifiedSearchIngestionOptions
|
||||
{
|
||||
ScannerSnapshotPath = snapshotPath
|
||||
}
|
||||
}),
|
||||
new StubVectorEncoder(),
|
||||
NullLogger<ScanResultIngestionAdapter>.Instance);
|
||||
|
||||
var chunks = await adapter.ProduceChunksAsync(CancellationToken.None);
|
||||
|
||||
chunks.Should().ContainSingle();
|
||||
var chunk = chunks[0];
|
||||
chunk.Domain.Should().Be("scanner");
|
||||
chunk.Kind.Should().Be("scan_result");
|
||||
chunk.EntityKey.Should().Be("scan:scan-4242");
|
||||
chunk.Title.Should().Contain("21 findings");
|
||||
chunk.Body.Should().Contain("policyVerdicts: deny,manual_review");
|
||||
chunk.Metadata.RootElement.GetProperty("entity_aliases").GetArrayLength().Should().Be(1);
|
||||
chunk.Metadata.RootElement.GetProperty("entity_aliases")[0].GetString().Should().Be("image:registry.acme.io/backend:v5");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateTempDirectory()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "stellaops-adapter-tests-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
private static void TryDeleteDirectory(string path)
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
Directory.Delete(path, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubVectorEncoder : IVectorEncoder
|
||||
{
|
||||
public float[] Encode(string text) => [0.12f, 0.34f, 0.56f, 0.78f];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.Analytics;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.QueryUnderstanding;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.Synthesis;
|
||||
using StellaOps.AdvisoryAI.Vectorization;
|
||||
using StellaOps.TestKit;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.UnifiedSearch;
|
||||
|
||||
public sealed class UnifiedSearchPerformanceEnvelopeTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Performance)]
|
||||
public async Task UnifiedSearch_load_profile_supports_50_concurrent_requests_within_latency_targets()
|
||||
{
|
||||
var service = CreateService();
|
||||
const int concurrency = 50;
|
||||
const int totalRequests = 300;
|
||||
|
||||
var latencies = new double[totalRequests];
|
||||
var gate = new SemaphoreSlim(concurrency, concurrency);
|
||||
var tasks = new List<Task>(totalRequests);
|
||||
|
||||
for (var i = 0; i < totalRequests; i++)
|
||||
{
|
||||
var index = i;
|
||||
await gate.WaitAsync();
|
||||
tasks.Add(Task.Run(async () =>
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var response = await service.SearchAsync(
|
||||
new UnifiedSearchRequest(
|
||||
$"latency benchmark query {index}",
|
||||
K: 10,
|
||||
IncludeSynthesis: false,
|
||||
Filters: new UnifiedSearchFilter { Tenant = "perf-tenant", UserId = "perf-user" }),
|
||||
CancellationToken.None);
|
||||
|
||||
response.Cards.Should().NotBeEmpty();
|
||||
}
|
||||
finally
|
||||
{
|
||||
stopwatch.Stop();
|
||||
latencies[index] = stopwatch.Elapsed.TotalMilliseconds;
|
||||
gate.Release();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
Array.Sort(latencies);
|
||||
var p50 = Percentile(latencies, 0.50);
|
||||
var p95 = Percentile(latencies, 0.95);
|
||||
var p99 = Percentile(latencies, 0.99);
|
||||
|
||||
p50.Should().BeLessThan(100, "instant results p50 target is <100ms");
|
||||
p95.Should().BeLessThan(500, "full results p95 target is <500ms under concurrent load");
|
||||
p99.Should().BeLessThan(800, "full results p99 target is <800ms under concurrent load");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Performance)]
|
||||
public async Task UnifiedSearch_latency_does_not_regress_more_than_10_percent_from_phase1_baseline()
|
||||
{
|
||||
var service = CreateService();
|
||||
const int iterations = 150;
|
||||
const double phase1BaselineP95Ms = 120.0;
|
||||
|
||||
var latencies = new double[iterations];
|
||||
for (var i = 0; i < iterations; i++)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var response = await service.SearchAsync(
|
||||
new UnifiedSearchRequest(
|
||||
$"baseline regression query {i}",
|
||||
K: 10,
|
||||
IncludeSynthesis: false,
|
||||
Filters: new UnifiedSearchFilter { Tenant = "perf-tenant", UserId = "perf-user" }),
|
||||
CancellationToken.None);
|
||||
stopwatch.Stop();
|
||||
|
||||
response.Cards.Should().NotBeEmpty();
|
||||
latencies[i] = stopwatch.Elapsed.TotalMilliseconds;
|
||||
}
|
||||
|
||||
Array.Sort(latencies);
|
||||
var currentP95 = Percentile(latencies, 0.95);
|
||||
currentP95.Should().BeLessThanOrEqualTo(phase1BaselineP95Ms * 1.10,
|
||||
"phase-4 search additions must not regress latency by more than 10% from phase-1 baseline");
|
||||
}
|
||||
|
||||
private static double Percentile(IReadOnlyList<double> sorted, double percentile)
|
||||
{
|
||||
if (sorted.Count == 0)
|
||||
{
|
||||
return 0d;
|
||||
}
|
||||
|
||||
var index = (int)Math.Ceiling(sorted.Count * percentile) - 1;
|
||||
index = Math.Clamp(index, 0, sorted.Count - 1);
|
||||
return sorted[index];
|
||||
}
|
||||
|
||||
private static UnifiedSearchService CreateService()
|
||||
{
|
||||
var options = Options.Create(new KnowledgeSearchOptions
|
||||
{
|
||||
Enabled = true,
|
||||
ConnectionString = "Host=localhost;Database=test",
|
||||
DefaultTopK = 10,
|
||||
VectorDimensions = 64,
|
||||
FtsCandidateCount = 40,
|
||||
VectorScanLimit = 40,
|
||||
VectorCandidateCount = 20,
|
||||
QueryTimeoutMs = 3000
|
||||
});
|
||||
|
||||
var storeMock = new Mock<IKnowledgeSearchStore>();
|
||||
storeMock.Setup(s => s.SearchFtsAsync(
|
||||
It.IsAny<string>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
|
||||
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync((string query, KnowledgeSearchFilter? _, int _, TimeSpan _, CancellationToken _, string? _) =>
|
||||
[
|
||||
MakeRow($"chunk:{query}:1", "md_section", "Operational runbook"),
|
||||
MakeRow($"chunk:{query}:2", "policy_rule", "Enforcement policy"),
|
||||
MakeRow($"chunk:{query}:3", "finding", "Security finding")
|
||||
]);
|
||||
|
||||
storeMock.Setup(s => s.LoadVectorCandidatesAsync(
|
||||
It.IsAny<float[]>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
|
||||
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([]);
|
||||
|
||||
var vectorEncoder = new Mock<IVectorEncoder>();
|
||||
var mockEmbedding = new float[64];
|
||||
mockEmbedding[0] = 0.1f;
|
||||
vectorEncoder.Setup(v => v.Encode(It.IsAny<string>())).Returns(mockEmbedding);
|
||||
|
||||
var extractor = new EntityExtractor();
|
||||
var classifier = new IntentClassifier();
|
||||
var weightCalculator = new DomainWeightCalculator(
|
||||
extractor,
|
||||
classifier,
|
||||
options,
|
||||
Options.Create(new UnifiedSearchOptions()));
|
||||
var planBuilder = new QueryPlanBuilder(extractor, classifier, weightCalculator);
|
||||
var synthesisEngine = new SynthesisTemplateEngine();
|
||||
var analyticsService = new SearchAnalyticsService(options, NullLogger<SearchAnalyticsService>.Instance);
|
||||
var qualityMonitor = new SearchQualityMonitor(options, NullLogger<SearchQualityMonitor>.Instance);
|
||||
var entityAliasService = new Mock<IEntityAliasService>();
|
||||
entityAliasService.Setup(s => s.ResolveAliasesAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<(string EntityKey, string EntityType)>());
|
||||
|
||||
return new UnifiedSearchService(
|
||||
options,
|
||||
storeMock.Object,
|
||||
vectorEncoder.Object,
|
||||
planBuilder,
|
||||
synthesisEngine,
|
||||
analyticsService,
|
||||
qualityMonitor,
|
||||
entityAliasService.Object,
|
||||
NullLogger<UnifiedSearchService>.Instance,
|
||||
TimeProvider.System,
|
||||
telemetrySink: null,
|
||||
unifiedOptions: Options.Create(new UnifiedSearchOptions()));
|
||||
}
|
||||
|
||||
private static KnowledgeChunkRow MakeRow(string chunkId, string kind, string title)
|
||||
{
|
||||
var metadata = JsonDocument.Parse(kind switch
|
||||
{
|
||||
"policy_rule" => "{\"domain\":\"policy\",\"entity_key\":\"policy:rule\"}",
|
||||
"finding" => "{\"domain\":\"findings\",\"entity_key\":\"cve:CVE-2025-1201\"}",
|
||||
_ => "{\"domain\":\"knowledge\",\"entity_key\":\"doc:runbook\"}"
|
||||
});
|
||||
|
||||
return new KnowledgeChunkRow(
|
||||
ChunkId: chunkId,
|
||||
DocId: "doc-1",
|
||||
Kind: kind,
|
||||
Anchor: null,
|
||||
SectionPath: null,
|
||||
SpanStart: 0,
|
||||
SpanEnd: 100,
|
||||
Title: title,
|
||||
Body: title,
|
||||
Snippet: title,
|
||||
Metadata: metadata,
|
||||
Embedding: null,
|
||||
LexicalScore: 1.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.AdvisoryAI.Tests.UnifiedSearch.Benchmark;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.UnifiedSearch;
|
||||
|
||||
public sealed class UnifiedSearchQualityBenchmarkFastSubsetTests
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", "BenchmarkFast")]
|
||||
public void Fast_subset_of_50_queries_meets_quality_floor()
|
||||
{
|
||||
var runner = new UnifiedSearchQualityBenchmarkRunner();
|
||||
var corpus = LoadCorpus(runner);
|
||||
var subsetCases = corpus.Cases.Take(50).ToArray();
|
||||
var subset = new UnifiedSearchQualityCorpus(
|
||||
corpus.Version,
|
||||
corpus.GeneratedAtUtc,
|
||||
subsetCases.Length,
|
||||
subsetCases);
|
||||
|
||||
var report = runner.Run(subset, new UnifiedSearchOptions(), UnifiedSearchQualityGateThresholds.Default);
|
||||
|
||||
report.Overall.QueryCount.Should().Be(50);
|
||||
report.Overall.PrecisionAt1.Should().BeGreaterThanOrEqualTo(0.75);
|
||||
report.Overall.NdcgAt10.Should().BeGreaterThanOrEqualTo(0.68);
|
||||
report.Overall.RankingStabilityHash.Should().NotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
private static UnifiedSearchQualityCorpus LoadCorpus(UnifiedSearchQualityBenchmarkRunner runner)
|
||||
{
|
||||
var cursor = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (cursor is not null)
|
||||
{
|
||||
var candidate = Path.Combine(
|
||||
cursor.FullName,
|
||||
"src",
|
||||
"AdvisoryAI",
|
||||
"__Tests",
|
||||
"StellaOps.AdvisoryAI.Tests",
|
||||
"TestData",
|
||||
"unified-search-quality-corpus.json");
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return runner.LoadCorpus(candidate);
|
||||
}
|
||||
|
||||
cursor = cursor.Parent;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException(
|
||||
"Could not locate unified-search-quality-corpus.json from test base directory.",
|
||||
"src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/TestData/unified-search-quality-corpus.json");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch;
|
||||
using StellaOps.AdvisoryAI.Tests.UnifiedSearch.Benchmark;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.UnifiedSearch;
|
||||
|
||||
public sealed class UnifiedSearchQualityBenchmarkTests
|
||||
{
|
||||
private static readonly Lazy<UnifiedSearchQualityCorpus> Corpus = new(LoadCorpus);
|
||||
private static readonly UnifiedSearchQualityGateThresholds Gates = UnifiedSearchQualityGateThresholds.Default;
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Benchmark)]
|
||||
public void Quality_corpus_contains_200_plus_queries_with_relevance_grades()
|
||||
{
|
||||
var corpus = Corpus.Value;
|
||||
|
||||
corpus.Cases.Count.Should().BeGreaterThanOrEqualTo(200);
|
||||
corpus.Cases.Should().OnlyContain(c => c.Expected.Count > 0);
|
||||
corpus.Cases.SelectMany(static c => c.Expected)
|
||||
.Should().OnlyContain(expected => expected.Grade >= 0 && expected.Grade <= 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Benchmark)]
|
||||
public void Benchmark_runner_computes_metrics_and_enforces_quality_gates()
|
||||
{
|
||||
var runner = new UnifiedSearchQualityBenchmarkRunner();
|
||||
var report = runner.Run(Corpus.Value, new UnifiedSearchOptions(), Gates);
|
||||
|
||||
Console.WriteLine(
|
||||
$"UnifiedSearch tuned defaults -> P@1={report.Overall.PrecisionAt1:F4}, " +
|
||||
$"NDCG@10={report.Overall.NdcgAt10:F4}, EntityAcc={report.Overall.EntityCardAccuracy:F4}, " +
|
||||
$"CrossDomain={report.Overall.CrossDomainRecall:F4}, Hash={report.Overall.RankingStabilityHash}");
|
||||
|
||||
report.Overall.QueryCount.Should().BeGreaterThanOrEqualTo(200);
|
||||
report.Overall.PrecisionAt1.Should().BeGreaterThanOrEqualTo(Gates.MinPrecisionAt1);
|
||||
report.Overall.NdcgAt10.Should().BeGreaterThanOrEqualTo(Gates.MinNdcgAt10);
|
||||
report.Overall.EntityCardAccuracy.Should().BeGreaterThanOrEqualTo(Gates.MinEntityCardAccuracy);
|
||||
report.Overall.CrossDomainRecall.Should().BeGreaterThanOrEqualTo(Gates.MinCrossDomainRecall);
|
||||
report.PassedQualityGates.Should().BeTrue();
|
||||
|
||||
var outPath = Path.Combine(Path.GetTempPath(), "unified-search-quality-report.json");
|
||||
runner.WriteReportJson(outPath, report);
|
||||
|
||||
File.Exists(outPath).Should().BeTrue();
|
||||
var saved = File.ReadAllText(outPath);
|
||||
saved.Should().Contain("\"PassedQualityGates\"");
|
||||
saved.Should().Contain("\"PrecisionAt1\"");
|
||||
saved.Should().Contain("\"RankingStabilityHash\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Determinism)]
|
||||
public void Benchmark_runner_produces_stable_ranking_hash_across_runs()
|
||||
{
|
||||
var runner = new UnifiedSearchQualityBenchmarkRunner();
|
||||
|
||||
var run1 = runner.Run(Corpus.Value, new UnifiedSearchOptions(), Gates);
|
||||
var run2 = runner.Run(Corpus.Value, new UnifiedSearchOptions(), Gates);
|
||||
|
||||
run1.Overall.RankingStabilityHash.Should().Be(run2.Overall.RankingStabilityHash);
|
||||
run1.Overall.PrecisionAt1.Should().Be(run2.Overall.PrecisionAt1);
|
||||
run1.Overall.NdcgAt10.Should().Be(run2.Overall.NdcgAt10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Category", TestCategories.Benchmark)]
|
||||
public void Grid_search_tuning_improves_baseline_and_is_deterministic()
|
||||
{
|
||||
var runner = new UnifiedSearchQualityBenchmarkRunner();
|
||||
var corpus = Corpus.Value;
|
||||
|
||||
var baselineOptions = BuildBaselineOptions();
|
||||
var baseline = runner.Run(corpus, baselineOptions, Gates);
|
||||
|
||||
var best = FindBestConfiguration(runner, corpus);
|
||||
var bestSecondPass = FindBestConfiguration(runner, corpus);
|
||||
|
||||
best.Report.Overall.NdcgAt10.Should().BeGreaterThan(baseline.Overall.NdcgAt10);
|
||||
best.Report.Overall.PrecisionAt1.Should().BeGreaterThan(baseline.Overall.PrecisionAt1);
|
||||
best.Report.PassedQualityGates.Should().BeTrue();
|
||||
|
||||
best.Configuration.Should().BeEquivalentTo(bestSecondPass.Configuration);
|
||||
best.Report.Overall.RankingStabilityHash.Should().Be(bestSecondPass.Report.Overall.RankingStabilityHash);
|
||||
|
||||
// Validate defaults are aligned with tuned parameters captured in code.
|
||||
var defaultReport = runner.Run(corpus, new UnifiedSearchOptions(), Gates);
|
||||
defaultReport.Overall.NdcgAt10.Should().BeGreaterThanOrEqualTo(best.Report.Overall.NdcgAt10 - 0.01);
|
||||
defaultReport.Overall.PrecisionAt1.Should().BeGreaterThanOrEqualTo(best.Report.Overall.PrecisionAt1 - 0.01);
|
||||
|
||||
Console.WriteLine(
|
||||
$"UnifiedSearch baseline -> P@1={baseline.Overall.PrecisionAt1:F4}, " +
|
||||
$"NDCG@10={baseline.Overall.NdcgAt10:F4}, EntityAcc={baseline.Overall.EntityCardAccuracy:F4}, " +
|
||||
$"CrossDomain={baseline.Overall.CrossDomainRecall:F4}, Hash={baseline.Overall.RankingStabilityHash}");
|
||||
Console.WriteLine(
|
||||
$"UnifiedSearch tuned-best -> P@1={best.Report.Overall.PrecisionAt1:F4}, " +
|
||||
$"NDCG@10={best.Report.Overall.NdcgAt10:F4}, EntityAcc={best.Report.Overall.EntityCardAccuracy:F4}, " +
|
||||
$"CrossDomain={best.Report.Overall.CrossDomainRecall:F4}, Hash={best.Report.Overall.RankingStabilityHash}");
|
||||
Console.WriteLine(
|
||||
$"UnifiedSearch tuned-defaults -> P@1={defaultReport.Overall.PrecisionAt1:F4}, " +
|
||||
$"NDCG@10={defaultReport.Overall.NdcgAt10:F4}, EntityAcc={defaultReport.Overall.EntityCardAccuracy:F4}, " +
|
||||
$"CrossDomain={defaultReport.Overall.CrossDomainRecall:F4}, Hash={defaultReport.Overall.RankingStabilityHash}");
|
||||
}
|
||||
|
||||
private static (UnifiedSearchWeightingOptions Configuration, UnifiedSearchQualityReport Report) FindBestConfiguration(
|
||||
UnifiedSearchQualityBenchmarkRunner runner,
|
||||
UnifiedSearchQualityCorpus corpus)
|
||||
{
|
||||
var cveFindings = new[] { 0.35, 0.45 };
|
||||
var cveVex = new[] { 0.30, 0.38 };
|
||||
var packageGraph = new[] { 0.20, 0.36, 0.48 };
|
||||
var packageScanner = new[] { 0.12, 0.28, 0.40 };
|
||||
var auditTimeline = new[] { 0.10, 0.24, 0.34 };
|
||||
var policyBoost = new[] { 0.30, 0.38 };
|
||||
|
||||
UnifiedSearchWeightingOptions? bestConfig = null;
|
||||
UnifiedSearchQualityReport? bestReport = null;
|
||||
|
||||
foreach (var findingBoost in cveFindings)
|
||||
{
|
||||
foreach (var vexBoost in cveVex)
|
||||
{
|
||||
foreach (var graphBoost in packageGraph)
|
||||
{
|
||||
foreach (var scannerBoost in packageScanner)
|
||||
{
|
||||
foreach (var timelineBoost in auditTimeline)
|
||||
{
|
||||
foreach (var policy in policyBoost)
|
||||
{
|
||||
var options = new UnifiedSearchOptions
|
||||
{
|
||||
BaseDomainWeights = BuildDefaultBaseWeights(),
|
||||
Weighting = new UnifiedSearchWeightingOptions
|
||||
{
|
||||
CveBoostFindings = findingBoost,
|
||||
CveBoostVex = vexBoost,
|
||||
CveBoostGraph = 0.30,
|
||||
SecurityBoostFindings = 0.24,
|
||||
SecurityBoostVex = 0.18,
|
||||
PolicyBoostPolicy = policy,
|
||||
TroubleshootBoostKnowledge = 0.20,
|
||||
TroubleshootBoostOpsMemory = 0.14,
|
||||
PackageBoostGraph = graphBoost,
|
||||
PackageBoostScanner = scannerBoost,
|
||||
PackageBoostFindings = 0.12,
|
||||
AuditBoostTimeline = timelineBoost,
|
||||
AuditBoostOpsMemory = 0.24,
|
||||
FilterDomainMatchBoost = 0.25,
|
||||
RoleScannerFindingsBoost = 0.18,
|
||||
RoleScannerVexBoost = 0.12,
|
||||
RolePolicyBoost = 0.24,
|
||||
RoleOpsKnowledgeBoost = 0.18,
|
||||
RoleOpsMemoryBoost = 0.12,
|
||||
RoleReleasePolicyBoost = 0.12,
|
||||
RoleReleaseFindingsBoost = 0.12
|
||||
}
|
||||
};
|
||||
|
||||
var report = runner.Run(corpus, options, Gates);
|
||||
if (bestReport is null || IsBetter(report, bestReport))
|
||||
{
|
||||
bestReport = report;
|
||||
bestConfig = options.Weighting;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bestConfig.Should().NotBeNull();
|
||||
bestReport.Should().NotBeNull();
|
||||
|
||||
return (bestConfig!, bestReport!);
|
||||
}
|
||||
|
||||
private static bool IsBetter(UnifiedSearchQualityReport left, UnifiedSearchQualityReport right)
|
||||
{
|
||||
const double epsilon = 1e-12;
|
||||
|
||||
if (left.Overall.NdcgAt10 > right.Overall.NdcgAt10 + epsilon)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Math.Abs(left.Overall.NdcgAt10 - right.Overall.NdcgAt10) <= epsilon &&
|
||||
left.Overall.PrecisionAt1 > right.Overall.PrecisionAt1 + epsilon)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Math.Abs(left.Overall.NdcgAt10 - right.Overall.NdcgAt10) <= epsilon &&
|
||||
Math.Abs(left.Overall.PrecisionAt1 - right.Overall.PrecisionAt1) <= epsilon &&
|
||||
string.CompareOrdinal(left.Overall.RankingStabilityHash, right.Overall.RankingStabilityHash) < 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static UnifiedSearchOptions BuildBaselineOptions()
|
||||
{
|
||||
return new UnifiedSearchOptions
|
||||
{
|
||||
BaseDomainWeights = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["knowledge"] = 1.0,
|
||||
["findings"] = 1.0,
|
||||
["vex"] = 1.0,
|
||||
["policy"] = 1.0,
|
||||
["platform"] = 1.0,
|
||||
["graph"] = 1.0,
|
||||
["timeline"] = 1.0,
|
||||
["scanner"] = 1.0,
|
||||
["opsmemory"] = 1.0
|
||||
},
|
||||
Weighting = new UnifiedSearchWeightingOptions
|
||||
{
|
||||
CveBoostFindings = 0.20,
|
||||
CveBoostVex = 0.15,
|
||||
CveBoostGraph = 0.10,
|
||||
SecurityBoostFindings = 0.10,
|
||||
SecurityBoostVex = 0.08,
|
||||
PolicyBoostPolicy = 0.15,
|
||||
TroubleshootBoostKnowledge = 0.08,
|
||||
TroubleshootBoostOpsMemory = 0.05,
|
||||
PackageBoostGraph = 0.05,
|
||||
PackageBoostScanner = 0.05,
|
||||
PackageBoostFindings = 0.02,
|
||||
AuditBoostTimeline = 0.05,
|
||||
AuditBoostOpsMemory = 0.03,
|
||||
FilterDomainMatchBoost = 0.25,
|
||||
RoleScannerFindingsBoost = 0.05,
|
||||
RoleScannerVexBoost = 0.05,
|
||||
RolePolicyBoost = 0.05,
|
||||
RoleOpsKnowledgeBoost = 0.05,
|
||||
RoleOpsMemoryBoost = 0.05,
|
||||
RoleReleasePolicyBoost = 0.05,
|
||||
RoleReleaseFindingsBoost = 0.05
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, double> BuildDefaultBaseWeights()
|
||||
{
|
||||
return new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["knowledge"] = 1.0,
|
||||
["findings"] = 1.0,
|
||||
["vex"] = 1.0,
|
||||
["policy"] = 1.0,
|
||||
["platform"] = 1.0,
|
||||
["graph"] = 1.0,
|
||||
["timeline"] = 1.0,
|
||||
["scanner"] = 1.0,
|
||||
["opsmemory"] = 1.0
|
||||
};
|
||||
}
|
||||
|
||||
private static UnifiedSearchQualityCorpus LoadCorpus()
|
||||
{
|
||||
var runner = new UnifiedSearchQualityBenchmarkRunner();
|
||||
var path = ResolveCorpusPath();
|
||||
return runner.LoadCorpus(path);
|
||||
}
|
||||
|
||||
private static string ResolveCorpusPath()
|
||||
{
|
||||
var cursor = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (cursor is not null)
|
||||
{
|
||||
var candidate = Path.Combine(
|
||||
cursor.FullName,
|
||||
"src",
|
||||
"AdvisoryAI",
|
||||
"__Tests",
|
||||
"StellaOps.AdvisoryAI.Tests",
|
||||
"TestData",
|
||||
"unified-search-quality-corpus.json");
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
cursor = cursor.Parent;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException(
|
||||
"Could not locate unified-search-quality-corpus.json from test base directory.",
|
||||
"src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/TestData/unified-search-quality-corpus.json");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.QueryUnderstanding;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.UnifiedSearch;
|
||||
|
||||
public sealed class UnifiedSearchScenarioCorpusTests
|
||||
{
|
||||
private static readonly Regex QueryRowPattern = new(
|
||||
@"^\|\s*\d+\s*\|\s*`(?<query>[^`]+)`\s*\|",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly Lazy<IReadOnlyList<string>> CorpusQueries = new(LoadCorpusQueries);
|
||||
|
||||
[Fact]
|
||||
public void Scenario_corpus_contains_at_least_1000_queries()
|
||||
{
|
||||
var queries = CorpusQueries.Value;
|
||||
|
||||
Assert.True(
|
||||
queries.Count >= 1000,
|
||||
$"Expected at least 1000 search scenarios, but found {queries.Count}.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scenario_corpus_queries_produce_valid_query_plans()
|
||||
{
|
||||
var extractor = new EntityExtractor();
|
||||
var classifier = new IntentClassifier();
|
||||
var calculator = new DomainWeightCalculator(
|
||||
extractor,
|
||||
classifier,
|
||||
Options.Create(new KnowledgeSearchOptions()));
|
||||
var builder = new QueryPlanBuilder(extractor, classifier, calculator);
|
||||
|
||||
var queries = CorpusQueries.Value;
|
||||
foreach (var query in queries)
|
||||
{
|
||||
var plan = builder.Build(new UnifiedSearchRequest(query));
|
||||
|
||||
Assert.Equal(query, plan.OriginalQuery);
|
||||
Assert.False(string.IsNullOrWhiteSpace(plan.NormalizedQuery));
|
||||
Assert.False(string.IsNullOrWhiteSpace(plan.Intent));
|
||||
Assert.NotEmpty(plan.DomainWeights);
|
||||
|
||||
// Unified search ranking depends on these base domains always being present.
|
||||
Assert.Contains("knowledge", plan.DomainWeights.Keys);
|
||||
Assert.Contains("findings", plan.DomainWeights.Keys);
|
||||
Assert.Contains("vex", plan.DomainWeights.Keys);
|
||||
Assert.Contains("policy", plan.DomainWeights.Keys);
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> LoadCorpusQueries()
|
||||
{
|
||||
var corpusPath = ResolveCorpusPath();
|
||||
var queries = new List<string>(1500);
|
||||
|
||||
foreach (var line in File.ReadLines(corpusPath))
|
||||
{
|
||||
var match = QueryRowPattern.Match(line);
|
||||
if (!match.Success)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var query = match.Groups["query"].Value.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
queries.Add(query);
|
||||
}
|
||||
}
|
||||
|
||||
return queries
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string ResolveCorpusPath()
|
||||
{
|
||||
var cursor = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (cursor is not null)
|
||||
{
|
||||
var candidate = Path.Combine(cursor.FullName, "docs", "qa", "unified-search-test-cases.md");
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
cursor = cursor.Parent;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException(
|
||||
"Could not locate docs/qa/unified-search-test-cases.md from test base directory.",
|
||||
Path.Combine("docs", "qa", "unified-search-test-cases.md"));
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,29 @@ public sealed class UnifiedSearchServiceTests
|
||||
result.Diagnostics.Mode.Should().Be("disabled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_returns_empty_when_tenant_feature_flag_disables_search()
|
||||
{
|
||||
var unifiedOptions = new UnifiedSearchOptions
|
||||
{
|
||||
TenantFeatureFlags = new Dictionary<string, UnifiedSearchTenantFeatureFlags>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["tenant-a"] = new() { Enabled = false }
|
||||
}
|
||||
};
|
||||
|
||||
var service = CreateService(unifiedOptions: unifiedOptions);
|
||||
|
||||
var result = await service.SearchAsync(
|
||||
new UnifiedSearchRequest(
|
||||
"CVE-2025-1201",
|
||||
Filters: new UnifiedSearchFilter { Tenant = "tenant-a", UserId = "user-1" }),
|
||||
CancellationToken.None);
|
||||
|
||||
result.Cards.Should().BeEmpty();
|
||||
result.Diagnostics.Mode.Should().Be("disabled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_returns_entity_cards_from_fts_results()
|
||||
{
|
||||
@@ -105,6 +128,159 @@ public sealed class UnifiedSearchServiceTests
|
||||
result.Cards[0].Severity.Should().Be("critical");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_sanitizes_snippet_html_and_script_content()
|
||||
{
|
||||
var ftsRow = MakeRow(
|
||||
"chunk-xss",
|
||||
"md_section",
|
||||
"Security Guide",
|
||||
snippet: "<mark>critical</mark><script>alert('x')</script> update available");
|
||||
|
||||
var storeMock = new Mock<IKnowledgeSearchStore>();
|
||||
storeMock.Setup(s => s.SearchFtsAsync(
|
||||
It.IsAny<string>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
|
||||
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<KnowledgeChunkRow> { ftsRow });
|
||||
|
||||
storeMock.Setup(s => s.LoadVectorCandidatesAsync(
|
||||
It.IsAny<float[]>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
|
||||
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([]);
|
||||
|
||||
var service = CreateService(storeMock: storeMock);
|
||||
|
||||
var result = await service.SearchAsync(
|
||||
new UnifiedSearchRequest("critical update"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.Cards.Should().ContainSingle();
|
||||
result.Cards[0].Snippet.Should().Be("critical update available");
|
||||
result.Cards[0].Snippet.Should().NotContain("<");
|
||||
result.Cards[0].Snippet.Should().NotContain(">");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_known_cve_returns_findings_and_vex_domains()
|
||||
{
|
||||
var findingRow = MakeRow(
|
||||
"chunk-find",
|
||||
"finding",
|
||||
"CVE-2024-21626 finding",
|
||||
JsonDocument.Parse("{\"domain\":\"findings\",\"cveId\":\"CVE-2024-21626\",\"severity\":\"critical\"}"));
|
||||
var vexRow = MakeRow(
|
||||
"chunk-vex",
|
||||
"vex_statement",
|
||||
"VEX: CVE-2024-21626 (not_affected)",
|
||||
JsonDocument.Parse("{\"domain\":\"vex\",\"cveId\":\"CVE-2024-21626\",\"status\":\"not_affected\"}"));
|
||||
|
||||
var storeMock = new Mock<IKnowledgeSearchStore>();
|
||||
storeMock.Setup(s => s.SearchFtsAsync(
|
||||
It.IsAny<string>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
|
||||
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<KnowledgeChunkRow> { findingRow, vexRow });
|
||||
|
||||
storeMock.Setup(s => s.LoadVectorCandidatesAsync(
|
||||
It.IsAny<float[]>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
|
||||
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([]);
|
||||
|
||||
var service = CreateService(storeMock: storeMock);
|
||||
|
||||
var result = await service.SearchAsync(
|
||||
new UnifiedSearchRequest("CVE-2024-21626"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.Cards.Should().NotBeEmpty();
|
||||
result.Cards.Select(static card => card.Domain)
|
||||
.Should().Contain("findings")
|
||||
.And.Contain("vex");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_known_policy_query_returns_policy_domain_card()
|
||||
{
|
||||
var policyRow = MakeRow(
|
||||
"chunk-policy",
|
||||
"policy_rule",
|
||||
"DENY-CRITICAL-PROD",
|
||||
JsonDocument.Parse("{\"domain\":\"policy\",\"ruleId\":\"DENY-CRITICAL-PROD\",\"enforcement\":\"deny\"}"));
|
||||
|
||||
var storeMock = new Mock<IKnowledgeSearchStore>();
|
||||
storeMock.Setup(s => s.SearchFtsAsync(
|
||||
It.IsAny<string>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
|
||||
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<KnowledgeChunkRow> { policyRow });
|
||||
|
||||
storeMock.Setup(s => s.LoadVectorCandidatesAsync(
|
||||
It.IsAny<float[]>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
|
||||
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([]);
|
||||
|
||||
var service = CreateService(storeMock: storeMock);
|
||||
|
||||
var result = await service.SearchAsync(
|
||||
new UnifiedSearchRequest("DENY-CRITICAL-PROD"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.Cards.Should().ContainSingle();
|
||||
result.Cards[0].Domain.Should().Be("policy");
|
||||
result.Cards[0].EntityType.Should().Be("policy_rule");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_carries_session_entity_context_for_followup_queries()
|
||||
{
|
||||
var cveRow = MakeRow(
|
||||
"chunk-find",
|
||||
"finding",
|
||||
"CVE-2025-1234",
|
||||
JsonDocument.Parse("{\"domain\":\"findings\",\"cveId\":\"CVE-2025-1234\",\"severity\":\"high\"}"));
|
||||
var mitigationRow = MakeRow(
|
||||
"chunk-doc",
|
||||
"md_section",
|
||||
"Mitigation playbook",
|
||||
JsonDocument.Parse("{\"domain\":\"knowledge\",\"path\":\"docs/mitigation.md\"}"));
|
||||
|
||||
var storeMock = new Mock<IKnowledgeSearchStore>();
|
||||
storeMock.Setup(s => s.SearchFtsAsync(
|
||||
It.IsAny<string>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
|
||||
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<KnowledgeChunkRow> { cveRow, mitigationRow });
|
||||
storeMock.Setup(s => s.LoadVectorCandidatesAsync(
|
||||
It.IsAny<float[]>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
|
||||
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([]);
|
||||
|
||||
var service = CreateService(storeMock: storeMock);
|
||||
var sharedFilter = new UnifiedSearchFilter
|
||||
{
|
||||
Tenant = "tenant-a",
|
||||
UserId = "user-a"
|
||||
};
|
||||
|
||||
await service.SearchAsync(
|
||||
new UnifiedSearchRequest(
|
||||
"CVE-2025-1234",
|
||||
Filters: sharedFilter,
|
||||
IncludeSynthesis: false,
|
||||
Ambient: new AmbientContext { SessionId = "session-ctx-1" }),
|
||||
CancellationToken.None);
|
||||
|
||||
var followup = await service.SearchAsync(
|
||||
new UnifiedSearchRequest(
|
||||
"mitigation",
|
||||
Filters: sharedFilter,
|
||||
IncludeSynthesis: false,
|
||||
Ambient: new AmbientContext { SessionId = "session-ctx-1" }),
|
||||
CancellationToken.None);
|
||||
|
||||
followup.Diagnostics.Plan.Should().NotBeNull();
|
||||
followup.Diagnostics.Plan!.DetectedEntities.Should().Contain(
|
||||
static mention => mention.EntityType == "cve" && mention.Value == "CVE-2025-1234");
|
||||
followup.Diagnostics.Plan.ContextEntityBoosts.Should().ContainKey("cve:CVE-2025-1234");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_includes_synthesis_when_requested()
|
||||
{
|
||||
@@ -132,6 +308,42 @@ public sealed class UnifiedSearchServiceTests
|
||||
result.Synthesis.SourceCount.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_disables_synthesis_when_tenant_flag_is_off()
|
||||
{
|
||||
var ftsRow = MakeRow("chunk-1", "md_section", "Result One");
|
||||
|
||||
var storeMock = new Mock<IKnowledgeSearchStore>();
|
||||
storeMock.Setup(s => s.SearchFtsAsync(
|
||||
It.IsAny<string>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
|
||||
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<KnowledgeChunkRow> { ftsRow });
|
||||
storeMock.Setup(s => s.LoadVectorCandidatesAsync(
|
||||
It.IsAny<float[]>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
|
||||
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([]);
|
||||
|
||||
var unifiedOptions = new UnifiedSearchOptions
|
||||
{
|
||||
TenantFeatureFlags = new Dictionary<string, UnifiedSearchTenantFeatureFlags>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["tenant-synth-off"] = new() { SynthesisEnabled = false }
|
||||
}
|
||||
};
|
||||
|
||||
var service = CreateService(storeMock: storeMock, unifiedOptions: unifiedOptions);
|
||||
|
||||
var result = await service.SearchAsync(
|
||||
new UnifiedSearchRequest(
|
||||
"search query",
|
||||
IncludeSynthesis: true,
|
||||
Filters: new UnifiedSearchFilter { Tenant = "tenant-synth-off", UserId = "user-2" }),
|
||||
CancellationToken.None);
|
||||
|
||||
result.Cards.Should().HaveCount(1);
|
||||
result.Synthesis.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_excludes_synthesis_when_not_requested()
|
||||
{
|
||||
@@ -535,7 +747,8 @@ public sealed class UnifiedSearchServiceTests
|
||||
|
||||
private static UnifiedSearchService CreateService(
|
||||
bool enabled = true,
|
||||
Mock<IKnowledgeSearchStore>? storeMock = null)
|
||||
Mock<IKnowledgeSearchStore>? storeMock = null,
|
||||
UnifiedSearchOptions? unifiedOptions = null)
|
||||
{
|
||||
var options = Options.Create(new KnowledgeSearchOptions
|
||||
{
|
||||
@@ -548,6 +761,7 @@ public sealed class UnifiedSearchServiceTests
|
||||
VectorCandidateCount = 50,
|
||||
QueryTimeoutMs = 3000
|
||||
});
|
||||
var wrappedUnifiedOptions = Options.Create(unifiedOptions ?? new UnifiedSearchOptions());
|
||||
|
||||
storeMock ??= new Mock<IKnowledgeSearchStore>();
|
||||
|
||||
@@ -580,7 +794,9 @@ public sealed class UnifiedSearchServiceTests
|
||||
qualityMonitor,
|
||||
entityAliasService.Object,
|
||||
logger,
|
||||
timeProvider);
|
||||
timeProvider,
|
||||
telemetrySink: null,
|
||||
unifiedOptions: wrappedUnifiedOptions);
|
||||
}
|
||||
|
||||
private static KnowledgeChunkRow MakeRow(
|
||||
@@ -589,7 +805,8 @@ public sealed class UnifiedSearchServiceTests
|
||||
string title,
|
||||
JsonDocument? metadata = null,
|
||||
float[]? embedding = null,
|
||||
string? body = null)
|
||||
string? body = null,
|
||||
string? snippet = null)
|
||||
{
|
||||
return new KnowledgeChunkRow(
|
||||
ChunkId: chunkId,
|
||||
@@ -601,7 +818,7 @@ public sealed class UnifiedSearchServiceTests
|
||||
SpanEnd: 100,
|
||||
Title: title,
|
||||
Body: body ?? $"Body of {title}",
|
||||
Snippet: $"Snippet of {title}",
|
||||
Snippet: snippet ?? $"Snippet of {title}",
|
||||
Metadata: metadata ?? EmptyMetadata,
|
||||
Embedding: embedding,
|
||||
LexicalScore: 1.0);
|
||||
|
||||
@@ -16,7 +16,7 @@ public sealed partial class PostgresAlertDedupRepository
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var windowStart = now.AddMinutes(-dedupWindowMinutes);
|
||||
|
||||
const string sql = @"
|
||||
|
||||
@@ -12,11 +12,13 @@ public sealed partial class PostgresAlertDedupRepository : IAlertDedupRepository
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly ILogger<PostgresAlertDedupRepository> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PostgresAlertDedupRepository(string connectionString, ILogger<PostgresAlertDedupRepository> logger)
|
||||
public PostgresAlertDedupRepository(string connectionString, ILogger<PostgresAlertDedupRepository> logger, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -46,11 +48,13 @@ public sealed partial class PostgresAlertDedupRepository : IAlertDedupRepository
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var cutoff = _timeProvider.GetUtcNow().AddDays(-7);
|
||||
const string sql = @"
|
||||
DELETE FROM attestor.identity_alert_dedup
|
||||
WHERE last_alert_at < NOW() - INTERVAL '7 days'";
|
||||
WHERE last_alert_at < @cutoff";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("cutoff", cutoff);
|
||||
return await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
|
||||
{
|
||||
[EntityFrameworkInternal]
|
||||
public partial class AirgapAuditEfEntityEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.Authority.Persistence.EfCore.Models.AirgapAuditEfEntity",
|
||||
typeof(AirgapAuditEfEntity),
|
||||
baseEntityType,
|
||||
propertyCount: 8,
|
||||
namedIndexCount: 1,
|
||||
keyCount: 1);
|
||||
|
||||
var id = runtimeEntityType.AddProperty(
|
||||
"Id",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(AirgapAuditEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(AirgapAuditEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw);
|
||||
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
id.AddAnnotation("Relational:ColumnName", "id");
|
||||
|
||||
var componentId = runtimeEntityType.AddProperty(
|
||||
"ComponentId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(AirgapAuditEfEntity).GetProperty("ComponentId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(AirgapAuditEfEntity).GetField("<ComponentId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
componentId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
componentId.AddAnnotation("Relational:ColumnName", "component_id");
|
||||
|
||||
var eventType = runtimeEntityType.AddProperty(
|
||||
"EventType",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(AirgapAuditEfEntity).GetProperty("EventType", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(AirgapAuditEfEntity).GetField("<EventType>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
eventType.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
eventType.AddAnnotation("Relational:ColumnName", "event_type");
|
||||
|
||||
var occurredAt = runtimeEntityType.AddProperty(
|
||||
"OccurredAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(AirgapAuditEfEntity).GetProperty("OccurredAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(AirgapAuditEfEntity).GetField("<OccurredAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
|
||||
occurredAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
occurredAt.AddAnnotation("Relational:ColumnName", "occurred_at");
|
||||
|
||||
var operatorId = runtimeEntityType.AddProperty(
|
||||
"OperatorId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(AirgapAuditEfEntity).GetProperty("OperatorId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(AirgapAuditEfEntity).GetField("<OperatorId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
operatorId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
operatorId.AddAnnotation("Relational:ColumnName", "operator_id");
|
||||
|
||||
var outcome = runtimeEntityType.AddProperty(
|
||||
"Outcome",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(AirgapAuditEfEntity).GetProperty("Outcome", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(AirgapAuditEfEntity).GetField("<Outcome>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
outcome.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
outcome.AddAnnotation("Relational:ColumnName", "outcome");
|
||||
|
||||
var properties = runtimeEntityType.AddProperty(
|
||||
"Properties",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(AirgapAuditEfEntity).GetProperty("Properties", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(AirgapAuditEfEntity).GetField("<Properties>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd);
|
||||
properties.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
properties.AddAnnotation("Relational:ColumnName", "properties");
|
||||
properties.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
properties.AddAnnotation("Relational:DefaultValueSql", "'[]'::jsonb");
|
||||
|
||||
var reason = runtimeEntityType.AddProperty(
|
||||
"Reason",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(AirgapAuditEfEntity).GetProperty("Reason", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(AirgapAuditEfEntity).GetField("<Reason>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
reason.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
reason.AddAnnotation("Relational:ColumnName", "reason");
|
||||
|
||||
var key = runtimeEntityType.AddKey(
|
||||
new[] { id });
|
||||
runtimeEntityType.SetPrimaryKey(key);
|
||||
key.AddAnnotation("Relational:Name", "airgap_audit_pkey");
|
||||
|
||||
var idx_airgap_audit_occurred_at = runtimeEntityType.AddIndex(
|
||||
new[] { occurredAt },
|
||||
name: "idx_airgap_audit_occurred_at");
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
|
||||
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "airgap_audit");
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
|
||||
|
||||
Customize(runtimeEntityType);
|
||||
}
|
||||
|
||||
static partial void Customize(RuntimeEntityType runtimeEntityType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
|
||||
{
|
||||
[EntityFrameworkInternal]
|
||||
public partial class ApiKeyEfEntityEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.Authority.Persistence.EfCore.Models.ApiKeyEfEntity",
|
||||
typeof(ApiKeyEfEntity),
|
||||
baseEntityType,
|
||||
propertyCount: 14,
|
||||
namedIndexCount: 4,
|
||||
keyCount: 1);
|
||||
|
||||
var id = runtimeEntityType.AddProperty(
|
||||
"Id",
|
||||
typeof(Guid),
|
||||
propertyInfo: typeof(ApiKeyEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ApiKeyEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw,
|
||||
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
id.AddAnnotation("Relational:ColumnName", "id");
|
||||
id.AddAnnotation("Relational:DefaultValueSql", "gen_random_uuid()");
|
||||
|
||||
var createdAt = runtimeEntityType.AddProperty(
|
||||
"CreatedAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(ApiKeyEfEntity).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ApiKeyEfEntity).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
|
||||
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
|
||||
createdAt.AddAnnotation("Relational:DefaultValueSql", "now()");
|
||||
|
||||
var expiresAt = runtimeEntityType.AddProperty(
|
||||
"ExpiresAt",
|
||||
typeof(DateTimeOffset?),
|
||||
propertyInfo: typeof(ApiKeyEfEntity).GetProperty("ExpiresAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ApiKeyEfEntity).GetField("<ExpiresAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
expiresAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
expiresAt.AddAnnotation("Relational:ColumnName", "expires_at");
|
||||
|
||||
var keyHash = runtimeEntityType.AddProperty(
|
||||
"KeyHash",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ApiKeyEfEntity).GetProperty("KeyHash", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ApiKeyEfEntity).GetField("<KeyHash>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
keyHash.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
keyHash.AddAnnotation("Relational:ColumnName", "key_hash");
|
||||
|
||||
var keyPrefix = runtimeEntityType.AddProperty(
|
||||
"KeyPrefix",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ApiKeyEfEntity).GetProperty("KeyPrefix", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ApiKeyEfEntity).GetField("<KeyPrefix>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
keyPrefix.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
keyPrefix.AddAnnotation("Relational:ColumnName", "key_prefix");
|
||||
|
||||
var lastUsedAt = runtimeEntityType.AddProperty(
|
||||
"LastUsedAt",
|
||||
typeof(DateTimeOffset?),
|
||||
propertyInfo: typeof(ApiKeyEfEntity).GetProperty("LastUsedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ApiKeyEfEntity).GetField("<LastUsedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
lastUsedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
lastUsedAt.AddAnnotation("Relational:ColumnName", "last_used_at");
|
||||
|
||||
var metadata = runtimeEntityType.AddProperty(
|
||||
"Metadata",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ApiKeyEfEntity).GetProperty("Metadata", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ApiKeyEfEntity).GetField("<Metadata>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd);
|
||||
metadata.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
metadata.AddAnnotation("Relational:ColumnName", "metadata");
|
||||
metadata.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
metadata.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
|
||||
|
||||
var name = runtimeEntityType.AddProperty(
|
||||
"Name",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ApiKeyEfEntity).GetProperty("Name", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ApiKeyEfEntity).GetField("<Name>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
name.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
name.AddAnnotation("Relational:ColumnName", "name");
|
||||
|
||||
var revokedAt = runtimeEntityType.AddProperty(
|
||||
"RevokedAt",
|
||||
typeof(DateTimeOffset?),
|
||||
propertyInfo: typeof(ApiKeyEfEntity).GetProperty("RevokedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ApiKeyEfEntity).GetField("<RevokedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
revokedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
revokedAt.AddAnnotation("Relational:ColumnName", "revoked_at");
|
||||
|
||||
var revokedBy = runtimeEntityType.AddProperty(
|
||||
"RevokedBy",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ApiKeyEfEntity).GetProperty("RevokedBy", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ApiKeyEfEntity).GetField("<RevokedBy>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
revokedBy.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
revokedBy.AddAnnotation("Relational:ColumnName", "revoked_by");
|
||||
|
||||
var scopes = runtimeEntityType.AddProperty(
|
||||
"Scopes",
|
||||
typeof(string[]),
|
||||
propertyInfo: typeof(ApiKeyEfEntity).GetProperty("Scopes", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ApiKeyEfEntity).GetField("<Scopes>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd);
|
||||
var scopesElementType = scopes.SetElementType(typeof(string));
|
||||
scopes.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
scopes.AddAnnotation("Relational:ColumnName", "scopes");
|
||||
scopes.AddAnnotation("Relational:DefaultValueSql", "'{}'::text[]");
|
||||
|
||||
var status = runtimeEntityType.AddProperty(
|
||||
"Status",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ApiKeyEfEntity).GetProperty("Status", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ApiKeyEfEntity).GetField("<Status>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd);
|
||||
status.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
status.AddAnnotation("Relational:ColumnName", "status");
|
||||
status.AddAnnotation("Relational:DefaultValueSql", "'active'");
|
||||
|
||||
var tenantId = runtimeEntityType.AddProperty(
|
||||
"TenantId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ApiKeyEfEntity).GetProperty("TenantId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ApiKeyEfEntity).GetField("<TenantId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
tenantId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
|
||||
|
||||
var userId = runtimeEntityType.AddProperty(
|
||||
"UserId",
|
||||
typeof(Guid?),
|
||||
propertyInfo: typeof(ApiKeyEfEntity).GetProperty("UserId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ApiKeyEfEntity).GetField("<UserId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
userId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
userId.AddAnnotation("Relational:ColumnName", "user_id");
|
||||
|
||||
var key = runtimeEntityType.AddKey(
|
||||
new[] { id });
|
||||
runtimeEntityType.SetPrimaryKey(key);
|
||||
key.AddAnnotation("Relational:Name", "api_keys_pkey");
|
||||
|
||||
var idx_api_keys_key_prefix = runtimeEntityType.AddIndex(
|
||||
new[] { keyPrefix },
|
||||
name: "idx_api_keys_key_prefix");
|
||||
|
||||
var idx_api_keys_status = runtimeEntityType.AddIndex(
|
||||
new[] { tenantId, status },
|
||||
name: "idx_api_keys_status");
|
||||
|
||||
var idx_api_keys_tenant_id = runtimeEntityType.AddIndex(
|
||||
new[] { tenantId },
|
||||
name: "idx_api_keys_tenant_id");
|
||||
|
||||
var idx_api_keys_user_id = runtimeEntityType.AddIndex(
|
||||
new[] { userId },
|
||||
name: "idx_api_keys_user_id");
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
|
||||
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "api_keys");
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
|
||||
|
||||
Customize(runtimeEntityType);
|
||||
}
|
||||
|
||||
static partial void Customize(RuntimeEntityType runtimeEntityType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
|
||||
{
|
||||
[EntityFrameworkInternal]
|
||||
public partial class AuditEfEntityEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.Authority.Persistence.EfCore.Models.AuditEfEntity",
|
||||
typeof(AuditEfEntity),
|
||||
baseEntityType,
|
||||
propertyCount: 12,
|
||||
namedIndexCount: 6,
|
||||
keyCount: 1);
|
||||
|
||||
var id = runtimeEntityType.AddProperty(
|
||||
"Id",
|
||||
typeof(long),
|
||||
propertyInfo: typeof(AuditEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(AuditEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw,
|
||||
sentinel: 0L);
|
||||
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
|
||||
id.AddAnnotation("Relational:ColumnName", "id");
|
||||
|
||||
var action = runtimeEntityType.AddProperty(
|
||||
"Action",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(AuditEfEntity).GetProperty("Action", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(AuditEfEntity).GetField("<Action>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
action.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
action.AddAnnotation("Relational:ColumnName", "action");
|
||||
|
||||
var correlationId = runtimeEntityType.AddProperty(
|
||||
"CorrelationId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(AuditEfEntity).GetProperty("CorrelationId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(AuditEfEntity).GetField("<CorrelationId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
correlationId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
correlationId.AddAnnotation("Relational:ColumnName", "correlation_id");
|
||||
|
||||
var createdAt = runtimeEntityType.AddProperty(
|
||||
"CreatedAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(AuditEfEntity).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(AuditEfEntity).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
|
||||
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
|
||||
createdAt.AddAnnotation("Relational:DefaultValueSql", "now()");
|
||||
|
||||
var ipAddress = runtimeEntityType.AddProperty(
|
||||
"IpAddress",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(AuditEfEntity).GetProperty("IpAddress", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(AuditEfEntity).GetField("<IpAddress>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
ipAddress.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
ipAddress.AddAnnotation("Relational:ColumnName", "ip_address");
|
||||
|
||||
var newValue = runtimeEntityType.AddProperty(
|
||||
"NewValue",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(AuditEfEntity).GetProperty("NewValue", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(AuditEfEntity).GetField("<NewValue>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
newValue.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
newValue.AddAnnotation("Relational:ColumnName", "new_value");
|
||||
newValue.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
|
||||
var oldValue = runtimeEntityType.AddProperty(
|
||||
"OldValue",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(AuditEfEntity).GetProperty("OldValue", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(AuditEfEntity).GetField("<OldValue>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
oldValue.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
oldValue.AddAnnotation("Relational:ColumnName", "old_value");
|
||||
oldValue.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
|
||||
var resourceId = runtimeEntityType.AddProperty(
|
||||
"ResourceId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(AuditEfEntity).GetProperty("ResourceId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(AuditEfEntity).GetField("<ResourceId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
resourceId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
resourceId.AddAnnotation("Relational:ColumnName", "resource_id");
|
||||
|
||||
var resourceType = runtimeEntityType.AddProperty(
|
||||
"ResourceType",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(AuditEfEntity).GetProperty("ResourceType", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(AuditEfEntity).GetField("<ResourceType>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
resourceType.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
resourceType.AddAnnotation("Relational:ColumnName", "resource_type");
|
||||
|
||||
var tenantId = runtimeEntityType.AddProperty(
|
||||
"TenantId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(AuditEfEntity).GetProperty("TenantId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(AuditEfEntity).GetField("<TenantId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
tenantId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
|
||||
|
||||
var userAgent = runtimeEntityType.AddProperty(
|
||||
"UserAgent",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(AuditEfEntity).GetProperty("UserAgent", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(AuditEfEntity).GetField("<UserAgent>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
userAgent.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
userAgent.AddAnnotation("Relational:ColumnName", "user_agent");
|
||||
|
||||
var userId = runtimeEntityType.AddProperty(
|
||||
"UserId",
|
||||
typeof(Guid?),
|
||||
propertyInfo: typeof(AuditEfEntity).GetProperty("UserId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(AuditEfEntity).GetField("<UserId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
userId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
userId.AddAnnotation("Relational:ColumnName", "user_id");
|
||||
|
||||
var key = runtimeEntityType.AddKey(
|
||||
new[] { id });
|
||||
runtimeEntityType.SetPrimaryKey(key);
|
||||
key.AddAnnotation("Relational:Name", "audit_pkey");
|
||||
|
||||
var idx_audit_action = runtimeEntityType.AddIndex(
|
||||
new[] { action },
|
||||
name: "idx_audit_action");
|
||||
|
||||
var idx_audit_correlation_id = runtimeEntityType.AddIndex(
|
||||
new[] { correlationId },
|
||||
name: "idx_audit_correlation_id");
|
||||
|
||||
var idx_audit_created_at = runtimeEntityType.AddIndex(
|
||||
new[] { createdAt },
|
||||
name: "idx_audit_created_at");
|
||||
|
||||
var idx_audit_resource = runtimeEntityType.AddIndex(
|
||||
new[] { resourceType, resourceId },
|
||||
name: "idx_audit_resource");
|
||||
|
||||
var idx_audit_tenant_id = runtimeEntityType.AddIndex(
|
||||
new[] { tenantId },
|
||||
name: "idx_audit_tenant_id");
|
||||
|
||||
var idx_audit_user_id = runtimeEntityType.AddIndex(
|
||||
new[] { userId },
|
||||
name: "idx_audit_user_id");
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
|
||||
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "audit");
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
|
||||
|
||||
Customize(runtimeEntityType);
|
||||
}
|
||||
|
||||
static partial void Customize(RuntimeEntityType runtimeEntityType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// <auto-generated />
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using StellaOps.Authority.Persistence.EfCore.CompiledModels;
|
||||
using StellaOps.Authority.Persistence.EfCore.Context;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
[assembly: DbContextModel(typeof(AuthorityDbContext), typeof(AuthorityDbContextModel))]
|
||||
@@ -1,37 +1,48 @@
|
||||
// <auto-generated />
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using StellaOps.Authority.Persistence.EfCore.Context;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels;
|
||||
|
||||
/// <summary>
|
||||
/// Compiled model stub for AuthorityDbContext.
|
||||
/// This is a placeholder that delegates to runtime model building.
|
||||
/// Replace with output from <c>dotnet ef dbcontext optimize</c> when a provisioned DB is available.
|
||||
/// </summary>
|
||||
[DbContext(typeof(Context.AuthorityDbContext))]
|
||||
public partial class AuthorityDbContextModel : RuntimeModel
|
||||
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
|
||||
{
|
||||
private static AuthorityDbContextModel _instance;
|
||||
|
||||
public static IModel Instance
|
||||
[DbContext(typeof(AuthorityDbContext))]
|
||||
public partial class AuthorityDbContextModel : RuntimeModel
|
||||
{
|
||||
get
|
||||
private static readonly bool _useOldBehavior31751 =
|
||||
System.AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue31751", out var enabled31751) && enabled31751;
|
||||
|
||||
static AuthorityDbContextModel()
|
||||
{
|
||||
if (_instance == null)
|
||||
var model = new AuthorityDbContextModel();
|
||||
|
||||
if (_useOldBehavior31751)
|
||||
{
|
||||
_instance = new AuthorityDbContextModel();
|
||||
_instance.Initialize();
|
||||
_instance.Customize();
|
||||
model.Initialize();
|
||||
}
|
||||
else
|
||||
{
|
||||
var thread = new System.Threading.Thread(RunInitialization, 10 * 1024 * 1024);
|
||||
thread.Start();
|
||||
thread.Join();
|
||||
|
||||
void RunInitialization()
|
||||
{
|
||||
model.Initialize();
|
||||
}
|
||||
}
|
||||
|
||||
return _instance;
|
||||
model.Customize();
|
||||
_instance = (AuthorityDbContextModel)model.FinalizeModel();
|
||||
}
|
||||
|
||||
private static AuthorityDbContextModel _instance;
|
||||
public static IModel Instance => _instance;
|
||||
|
||||
partial void Initialize();
|
||||
|
||||
partial void Customize();
|
||||
}
|
||||
|
||||
partial void Initialize();
|
||||
|
||||
partial void Customize();
|
||||
}
|
||||
|
||||
@@ -1,20 +1,72 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels;
|
||||
|
||||
/// <summary>
|
||||
/// Compiled model builder stub for AuthorityDbContext.
|
||||
/// Replace with output from <c>dotnet ef dbcontext optimize</c> when a provisioned DB is available.
|
||||
/// </summary>
|
||||
public partial class AuthorityDbContextModel
|
||||
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
|
||||
{
|
||||
partial void Initialize()
|
||||
public partial class AuthorityDbContextModel
|
||||
{
|
||||
// Stub: when a real compiled model is generated, entity types will be registered here.
|
||||
// The runtime factory will fall back to reflection-based model building for all schemas
|
||||
// until this stub is replaced with a full compiled model.
|
||||
private AuthorityDbContextModel()
|
||||
: base(skipDetectChanges: false, modelId: new Guid("b46b998f-a99e-4088-918b-1a65143243e4"), entityTypeCount: 22)
|
||||
{
|
||||
}
|
||||
|
||||
partial void Initialize()
|
||||
{
|
||||
var airgapAuditEfEntity = AirgapAuditEfEntityEntityType.Create(this);
|
||||
var apiKeyEfEntity = ApiKeyEfEntityEntityType.Create(this);
|
||||
var auditEfEntity = AuditEfEntityEntityType.Create(this);
|
||||
var bootstrapInviteEfEntity = BootstrapInviteEfEntityEntityType.Create(this);
|
||||
var clientEfEntity = ClientEfEntityEntityType.Create(this);
|
||||
var loginAttemptEfEntity = LoginAttemptEfEntityEntityType.Create(this);
|
||||
var offlineKitAuditEfEntity = OfflineKitAuditEfEntityEntityType.Create(this);
|
||||
var oidcRefreshTokenEfEntity = OidcRefreshTokenEfEntityEntityType.Create(this);
|
||||
var oidcTokenEfEntity = OidcTokenEfEntityEntityType.Create(this);
|
||||
var permissionEfEntity = PermissionEfEntityEntityType.Create(this);
|
||||
var refreshTokenEfEntity = RefreshTokenEfEntityEntityType.Create(this);
|
||||
var revocationEfEntity = RevocationEfEntityEntityType.Create(this);
|
||||
var revocationExportStateEfEntity = RevocationExportStateEfEntityEntityType.Create(this);
|
||||
var roleEfEntity = RoleEfEntityEntityType.Create(this);
|
||||
var rolePermissionEfEntity = RolePermissionEfEntityEntityType.Create(this);
|
||||
var serviceAccountEfEntity = ServiceAccountEfEntityEntityType.Create(this);
|
||||
var sessionEfEntity = SessionEfEntityEntityType.Create(this);
|
||||
var tenantEfEntity = TenantEfEntityEntityType.Create(this);
|
||||
var tokenEfEntity = TokenEfEntityEntityType.Create(this);
|
||||
var userEfEntity = UserEfEntityEntityType.Create(this);
|
||||
var userRoleEfEntity = UserRoleEfEntityEntityType.Create(this);
|
||||
var verdictManifestEfEntity = VerdictManifestEfEntityEntityType.Create(this);
|
||||
|
||||
AirgapAuditEfEntityEntityType.CreateAnnotations(airgapAuditEfEntity);
|
||||
ApiKeyEfEntityEntityType.CreateAnnotations(apiKeyEfEntity);
|
||||
AuditEfEntityEntityType.CreateAnnotations(auditEfEntity);
|
||||
BootstrapInviteEfEntityEntityType.CreateAnnotations(bootstrapInviteEfEntity);
|
||||
ClientEfEntityEntityType.CreateAnnotations(clientEfEntity);
|
||||
LoginAttemptEfEntityEntityType.CreateAnnotations(loginAttemptEfEntity);
|
||||
OfflineKitAuditEfEntityEntityType.CreateAnnotations(offlineKitAuditEfEntity);
|
||||
OidcRefreshTokenEfEntityEntityType.CreateAnnotations(oidcRefreshTokenEfEntity);
|
||||
OidcTokenEfEntityEntityType.CreateAnnotations(oidcTokenEfEntity);
|
||||
PermissionEfEntityEntityType.CreateAnnotations(permissionEfEntity);
|
||||
RefreshTokenEfEntityEntityType.CreateAnnotations(refreshTokenEfEntity);
|
||||
RevocationEfEntityEntityType.CreateAnnotations(revocationEfEntity);
|
||||
RevocationExportStateEfEntityEntityType.CreateAnnotations(revocationExportStateEfEntity);
|
||||
RoleEfEntityEntityType.CreateAnnotations(roleEfEntity);
|
||||
RolePermissionEfEntityEntityType.CreateAnnotations(rolePermissionEfEntity);
|
||||
ServiceAccountEfEntityEntityType.CreateAnnotations(serviceAccountEfEntity);
|
||||
SessionEfEntityEntityType.CreateAnnotations(sessionEfEntity);
|
||||
TenantEfEntityEntityType.CreateAnnotations(tenantEfEntity);
|
||||
TokenEfEntityEntityType.CreateAnnotations(tokenEfEntity);
|
||||
UserEfEntityEntityType.CreateAnnotations(userEfEntity);
|
||||
UserRoleEfEntityEntityType.CreateAnnotations(userRoleEfEntity);
|
||||
VerdictManifestEfEntityEntityType.CreateAnnotations(verdictManifestEfEntity);
|
||||
|
||||
AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
|
||||
AddAnnotation("ProductVersion", "10.0.0");
|
||||
AddAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
|
||||
{
|
||||
[EntityFrameworkInternal]
|
||||
public partial class BootstrapInviteEfEntityEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.Authority.Persistence.EfCore.Models.BootstrapInviteEfEntity",
|
||||
typeof(BootstrapInviteEfEntity),
|
||||
baseEntityType,
|
||||
propertyCount: 14,
|
||||
keyCount: 2);
|
||||
|
||||
var id = runtimeEntityType.AddProperty(
|
||||
"Id",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(BootstrapInviteEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(BootstrapInviteEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw);
|
||||
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
id.AddAnnotation("Relational:ColumnName", "id");
|
||||
|
||||
var consumed = runtimeEntityType.AddProperty(
|
||||
"Consumed",
|
||||
typeof(bool),
|
||||
propertyInfo: typeof(BootstrapInviteEfEntity).GetProperty("Consumed", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(BootstrapInviteEfEntity).GetField("<Consumed>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: false);
|
||||
consumed.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
consumed.AddAnnotation("Relational:ColumnName", "consumed");
|
||||
consumed.AddAnnotation("Relational:DefaultValue", false);
|
||||
|
||||
var createdAt = runtimeEntityType.AddProperty(
|
||||
"CreatedAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(BootstrapInviteEfEntity).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(BootstrapInviteEfEntity).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
|
||||
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
|
||||
createdAt.AddAnnotation("Relational:DefaultValueSql", "now()");
|
||||
|
||||
var expiresAt = runtimeEntityType.AddProperty(
|
||||
"ExpiresAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(BootstrapInviteEfEntity).GetProperty("ExpiresAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(BootstrapInviteEfEntity).GetField("<ExpiresAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
|
||||
expiresAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
expiresAt.AddAnnotation("Relational:ColumnName", "expires_at");
|
||||
|
||||
var issuedAt = runtimeEntityType.AddProperty(
|
||||
"IssuedAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(BootstrapInviteEfEntity).GetProperty("IssuedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(BootstrapInviteEfEntity).GetField("<IssuedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
|
||||
issuedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
issuedAt.AddAnnotation("Relational:ColumnName", "issued_at");
|
||||
issuedAt.AddAnnotation("Relational:DefaultValueSql", "now()");
|
||||
|
||||
var issuedBy = runtimeEntityType.AddProperty(
|
||||
"IssuedBy",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(BootstrapInviteEfEntity).GetProperty("IssuedBy", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(BootstrapInviteEfEntity).GetField("<IssuedBy>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
issuedBy.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
issuedBy.AddAnnotation("Relational:ColumnName", "issued_by");
|
||||
|
||||
var metadata = runtimeEntityType.AddProperty(
|
||||
"Metadata",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(BootstrapInviteEfEntity).GetProperty("Metadata", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(BootstrapInviteEfEntity).GetField("<Metadata>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd);
|
||||
metadata.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
metadata.AddAnnotation("Relational:ColumnName", "metadata");
|
||||
metadata.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
metadata.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
|
||||
|
||||
var provider = runtimeEntityType.AddProperty(
|
||||
"Provider",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(BootstrapInviteEfEntity).GetProperty("Provider", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(BootstrapInviteEfEntity).GetField("<Provider>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
provider.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
provider.AddAnnotation("Relational:ColumnName", "provider");
|
||||
|
||||
var reservedBy = runtimeEntityType.AddProperty(
|
||||
"ReservedBy",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(BootstrapInviteEfEntity).GetProperty("ReservedBy", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(BootstrapInviteEfEntity).GetField("<ReservedBy>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
reservedBy.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
reservedBy.AddAnnotation("Relational:ColumnName", "reserved_by");
|
||||
|
||||
var reservedUntil = runtimeEntityType.AddProperty(
|
||||
"ReservedUntil",
|
||||
typeof(DateTimeOffset?),
|
||||
propertyInfo: typeof(BootstrapInviteEfEntity).GetProperty("ReservedUntil", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(BootstrapInviteEfEntity).GetField("<ReservedUntil>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
reservedUntil.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
reservedUntil.AddAnnotation("Relational:ColumnName", "reserved_until");
|
||||
|
||||
var status = runtimeEntityType.AddProperty(
|
||||
"Status",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(BootstrapInviteEfEntity).GetProperty("Status", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(BootstrapInviteEfEntity).GetField("<Status>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd);
|
||||
status.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
status.AddAnnotation("Relational:ColumnName", "status");
|
||||
status.AddAnnotation("Relational:DefaultValueSql", "'pending'");
|
||||
|
||||
var target = runtimeEntityType.AddProperty(
|
||||
"Target",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(BootstrapInviteEfEntity).GetProperty("Target", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(BootstrapInviteEfEntity).GetField("<Target>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
target.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
target.AddAnnotation("Relational:ColumnName", "target");
|
||||
|
||||
var token = runtimeEntityType.AddProperty(
|
||||
"Token",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(BootstrapInviteEfEntity).GetProperty("Token", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(BootstrapInviteEfEntity).GetField("<Token>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw);
|
||||
token.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
token.AddAnnotation("Relational:ColumnName", "token");
|
||||
|
||||
var type = runtimeEntityType.AddProperty(
|
||||
"Type",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(BootstrapInviteEfEntity).GetProperty("Type", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(BootstrapInviteEfEntity).GetField("<Type>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
type.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
type.AddAnnotation("Relational:ColumnName", "type");
|
||||
|
||||
var key = runtimeEntityType.AddKey(
|
||||
new[] { id });
|
||||
runtimeEntityType.SetPrimaryKey(key);
|
||||
key.AddAnnotation("Relational:Name", "bootstrap_invites_pkey");
|
||||
|
||||
var key0 = runtimeEntityType.AddKey(
|
||||
new[] { token });
|
||||
key0.AddAnnotation("Relational:Name", "bootstrap_invites_token_key");
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
|
||||
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "bootstrap_invites");
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
|
||||
|
||||
Customize(runtimeEntityType);
|
||||
}
|
||||
|
||||
static partial void Customize(RuntimeEntityType runtimeEntityType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
|
||||
{
|
||||
[EntityFrameworkInternal]
|
||||
public partial class ClientEfEntityEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.Authority.Persistence.EfCore.Models.ClientEfEntity",
|
||||
typeof(ClientEfEntity),
|
||||
baseEntityType,
|
||||
propertyCount: 21,
|
||||
keyCount: 2);
|
||||
|
||||
var id = runtimeEntityType.AddProperty(
|
||||
"Id",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ClientEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ClientEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw);
|
||||
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
id.AddAnnotation("Relational:ColumnName", "id");
|
||||
|
||||
var allowPlainTextPkce = runtimeEntityType.AddProperty(
|
||||
"AllowPlainTextPkce",
|
||||
typeof(bool),
|
||||
propertyInfo: typeof(ClientEfEntity).GetProperty("AllowPlainTextPkce", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ClientEfEntity).GetField("<AllowPlainTextPkce>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: false);
|
||||
allowPlainTextPkce.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
allowPlainTextPkce.AddAnnotation("Relational:ColumnName", "allow_plain_text_pkce");
|
||||
allowPlainTextPkce.AddAnnotation("Relational:DefaultValue", false);
|
||||
|
||||
var allowedGrantTypes = runtimeEntityType.AddProperty(
|
||||
"AllowedGrantTypes",
|
||||
typeof(string[]),
|
||||
propertyInfo: typeof(ClientEfEntity).GetProperty("AllowedGrantTypes", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ClientEfEntity).GetField("<AllowedGrantTypes>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd);
|
||||
var allowedGrantTypesElementType = allowedGrantTypes.SetElementType(typeof(string));
|
||||
allowedGrantTypes.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
allowedGrantTypes.AddAnnotation("Relational:ColumnName", "allowed_grant_types");
|
||||
allowedGrantTypes.AddAnnotation("Relational:DefaultValueSql", "'{}'::text[]");
|
||||
|
||||
var allowedScopes = runtimeEntityType.AddProperty(
|
||||
"AllowedScopes",
|
||||
typeof(string[]),
|
||||
propertyInfo: typeof(ClientEfEntity).GetProperty("AllowedScopes", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ClientEfEntity).GetField("<AllowedScopes>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd);
|
||||
var allowedScopesElementType = allowedScopes.SetElementType(typeof(string));
|
||||
allowedScopes.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
allowedScopes.AddAnnotation("Relational:ColumnName", "allowed_scopes");
|
||||
allowedScopes.AddAnnotation("Relational:DefaultValueSql", "'{}'::text[]");
|
||||
|
||||
var certificateBindings = runtimeEntityType.AddProperty(
|
||||
"CertificateBindings",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ClientEfEntity).GetProperty("CertificateBindings", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ClientEfEntity).GetField("<CertificateBindings>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd);
|
||||
certificateBindings.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
certificateBindings.AddAnnotation("Relational:ColumnName", "certificate_bindings");
|
||||
certificateBindings.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
certificateBindings.AddAnnotation("Relational:DefaultValueSql", "'[]'::jsonb");
|
||||
|
||||
var clientId = runtimeEntityType.AddProperty(
|
||||
"ClientId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ClientEfEntity).GetProperty("ClientId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ClientEfEntity).GetField("<ClientId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw);
|
||||
clientId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
clientId.AddAnnotation("Relational:ColumnName", "client_id");
|
||||
|
||||
var clientSecret = runtimeEntityType.AddProperty(
|
||||
"ClientSecret",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ClientEfEntity).GetProperty("ClientSecret", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ClientEfEntity).GetField("<ClientSecret>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
clientSecret.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
clientSecret.AddAnnotation("Relational:ColumnName", "client_secret");
|
||||
|
||||
var clientType = runtimeEntityType.AddProperty(
|
||||
"ClientType",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ClientEfEntity).GetProperty("ClientType", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ClientEfEntity).GetField("<ClientType>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
clientType.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
clientType.AddAnnotation("Relational:ColumnName", "client_type");
|
||||
|
||||
var createdAt = runtimeEntityType.AddProperty(
|
||||
"CreatedAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(ClientEfEntity).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ClientEfEntity).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
|
||||
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
|
||||
createdAt.AddAnnotation("Relational:DefaultValueSql", "now()");
|
||||
|
||||
var description = runtimeEntityType.AddProperty(
|
||||
"Description",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ClientEfEntity).GetProperty("Description", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ClientEfEntity).GetField("<Description>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
description.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
description.AddAnnotation("Relational:ColumnName", "description");
|
||||
|
||||
var displayName = runtimeEntityType.AddProperty(
|
||||
"DisplayName",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ClientEfEntity).GetProperty("DisplayName", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ClientEfEntity).GetField("<DisplayName>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
displayName.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
displayName.AddAnnotation("Relational:ColumnName", "display_name");
|
||||
|
||||
var enabled = runtimeEntityType.AddProperty(
|
||||
"Enabled",
|
||||
typeof(bool),
|
||||
propertyInfo: typeof(ClientEfEntity).GetProperty("Enabled", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ClientEfEntity).GetField("<Enabled>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: true);
|
||||
enabled.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
enabled.AddAnnotation("Relational:ColumnName", "enabled");
|
||||
enabled.AddAnnotation("Relational:DefaultValue", true);
|
||||
|
||||
var plugin = runtimeEntityType.AddProperty(
|
||||
"Plugin",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ClientEfEntity).GetProperty("Plugin", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ClientEfEntity).GetField("<Plugin>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
plugin.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
plugin.AddAnnotation("Relational:ColumnName", "plugin");
|
||||
|
||||
var postLogoutRedirectUris = runtimeEntityType.AddProperty(
|
||||
"PostLogoutRedirectUris",
|
||||
typeof(string[]),
|
||||
propertyInfo: typeof(ClientEfEntity).GetProperty("PostLogoutRedirectUris", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ClientEfEntity).GetField("<PostLogoutRedirectUris>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd);
|
||||
var postLogoutRedirectUrisElementType = postLogoutRedirectUris.SetElementType(typeof(string));
|
||||
postLogoutRedirectUris.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
postLogoutRedirectUris.AddAnnotation("Relational:ColumnName", "post_logout_redirect_uris");
|
||||
postLogoutRedirectUris.AddAnnotation("Relational:DefaultValueSql", "'{}'::text[]");
|
||||
|
||||
var properties = runtimeEntityType.AddProperty(
|
||||
"Properties",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ClientEfEntity).GetProperty("Properties", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ClientEfEntity).GetField("<Properties>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd);
|
||||
properties.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
properties.AddAnnotation("Relational:ColumnName", "properties");
|
||||
properties.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
properties.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
|
||||
|
||||
var redirectUris = runtimeEntityType.AddProperty(
|
||||
"RedirectUris",
|
||||
typeof(string[]),
|
||||
propertyInfo: typeof(ClientEfEntity).GetProperty("RedirectUris", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ClientEfEntity).GetField("<RedirectUris>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd);
|
||||
var redirectUrisElementType = redirectUris.SetElementType(typeof(string));
|
||||
redirectUris.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
redirectUris.AddAnnotation("Relational:ColumnName", "redirect_uris");
|
||||
redirectUris.AddAnnotation("Relational:DefaultValueSql", "'{}'::text[]");
|
||||
|
||||
var requireClientSecret = runtimeEntityType.AddProperty(
|
||||
"RequireClientSecret",
|
||||
typeof(bool),
|
||||
propertyInfo: typeof(ClientEfEntity).GetProperty("RequireClientSecret", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ClientEfEntity).GetField("<RequireClientSecret>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: true);
|
||||
requireClientSecret.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
requireClientSecret.AddAnnotation("Relational:ColumnName", "require_client_secret");
|
||||
requireClientSecret.AddAnnotation("Relational:DefaultValue", true);
|
||||
|
||||
var requirePkce = runtimeEntityType.AddProperty(
|
||||
"RequirePkce",
|
||||
typeof(bool),
|
||||
propertyInfo: typeof(ClientEfEntity).GetProperty("RequirePkce", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ClientEfEntity).GetField("<RequirePkce>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: false);
|
||||
requirePkce.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
requirePkce.AddAnnotation("Relational:ColumnName", "require_pkce");
|
||||
requirePkce.AddAnnotation("Relational:DefaultValue", false);
|
||||
|
||||
var secretHash = runtimeEntityType.AddProperty(
|
||||
"SecretHash",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ClientEfEntity).GetProperty("SecretHash", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ClientEfEntity).GetField("<SecretHash>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
secretHash.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
secretHash.AddAnnotation("Relational:ColumnName", "secret_hash");
|
||||
|
||||
var senderConstraint = runtimeEntityType.AddProperty(
|
||||
"SenderConstraint",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ClientEfEntity).GetProperty("SenderConstraint", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ClientEfEntity).GetField("<SenderConstraint>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
senderConstraint.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
senderConstraint.AddAnnotation("Relational:ColumnName", "sender_constraint");
|
||||
|
||||
var updatedAt = runtimeEntityType.AddProperty(
|
||||
"UpdatedAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(ClientEfEntity).GetProperty("UpdatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ClientEfEntity).GetField("<UpdatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
|
||||
updatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
updatedAt.AddAnnotation("Relational:ColumnName", "updated_at");
|
||||
updatedAt.AddAnnotation("Relational:DefaultValueSql", "now()");
|
||||
|
||||
var key = runtimeEntityType.AddKey(
|
||||
new[] { clientId });
|
||||
key.AddAnnotation("Relational:Name", "clients_client_id_key");
|
||||
|
||||
var key0 = runtimeEntityType.AddKey(
|
||||
new[] { id });
|
||||
runtimeEntityType.SetPrimaryKey(key0);
|
||||
key0.AddAnnotation("Relational:Name", "clients_pkey");
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
|
||||
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "clients");
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
|
||||
|
||||
Customize(runtimeEntityType);
|
||||
}
|
||||
|
||||
static partial void Customize(RuntimeEntityType runtimeEntityType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
|
||||
{
|
||||
[EntityFrameworkInternal]
|
||||
public partial class LoginAttemptEfEntityEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.Authority.Persistence.EfCore.Models.LoginAttemptEfEntity",
|
||||
typeof(LoginAttemptEfEntity),
|
||||
baseEntityType,
|
||||
propertyCount: 10,
|
||||
namedIndexCount: 1,
|
||||
keyCount: 1);
|
||||
|
||||
var id = runtimeEntityType.AddProperty(
|
||||
"Id",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(LoginAttemptEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(LoginAttemptEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw);
|
||||
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
id.AddAnnotation("Relational:ColumnName", "id");
|
||||
|
||||
var clientId = runtimeEntityType.AddProperty(
|
||||
"ClientId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(LoginAttemptEfEntity).GetProperty("ClientId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(LoginAttemptEfEntity).GetField("<ClientId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
clientId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
clientId.AddAnnotation("Relational:ColumnName", "client_id");
|
||||
|
||||
var eventType = runtimeEntityType.AddProperty(
|
||||
"EventType",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(LoginAttemptEfEntity).GetProperty("EventType", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(LoginAttemptEfEntity).GetField("<EventType>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
eventType.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
eventType.AddAnnotation("Relational:ColumnName", "event_type");
|
||||
|
||||
var ipAddress = runtimeEntityType.AddProperty(
|
||||
"IpAddress",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(LoginAttemptEfEntity).GetProperty("IpAddress", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(LoginAttemptEfEntity).GetField("<IpAddress>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
ipAddress.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
ipAddress.AddAnnotation("Relational:ColumnName", "ip_address");
|
||||
|
||||
var occurredAt = runtimeEntityType.AddProperty(
|
||||
"OccurredAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(LoginAttemptEfEntity).GetProperty("OccurredAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(LoginAttemptEfEntity).GetField("<OccurredAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
|
||||
occurredAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
occurredAt.AddAnnotation("Relational:ColumnName", "occurred_at");
|
||||
|
||||
var outcome = runtimeEntityType.AddProperty(
|
||||
"Outcome",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(LoginAttemptEfEntity).GetProperty("Outcome", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(LoginAttemptEfEntity).GetField("<Outcome>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
outcome.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
outcome.AddAnnotation("Relational:ColumnName", "outcome");
|
||||
|
||||
var properties = runtimeEntityType.AddProperty(
|
||||
"Properties",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(LoginAttemptEfEntity).GetProperty("Properties", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(LoginAttemptEfEntity).GetField("<Properties>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd);
|
||||
properties.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
properties.AddAnnotation("Relational:ColumnName", "properties");
|
||||
properties.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
properties.AddAnnotation("Relational:DefaultValueSql", "'[]'::jsonb");
|
||||
|
||||
var reason = runtimeEntityType.AddProperty(
|
||||
"Reason",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(LoginAttemptEfEntity).GetProperty("Reason", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(LoginAttemptEfEntity).GetField("<Reason>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
reason.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
reason.AddAnnotation("Relational:ColumnName", "reason");
|
||||
|
||||
var subjectId = runtimeEntityType.AddProperty(
|
||||
"SubjectId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(LoginAttemptEfEntity).GetProperty("SubjectId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(LoginAttemptEfEntity).GetField("<SubjectId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
subjectId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
subjectId.AddAnnotation("Relational:ColumnName", "subject_id");
|
||||
|
||||
var userAgent = runtimeEntityType.AddProperty(
|
||||
"UserAgent",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(LoginAttemptEfEntity).GetProperty("UserAgent", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(LoginAttemptEfEntity).GetField("<UserAgent>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
userAgent.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
userAgent.AddAnnotation("Relational:ColumnName", "user_agent");
|
||||
|
||||
var key = runtimeEntityType.AddKey(
|
||||
new[] { id });
|
||||
runtimeEntityType.SetPrimaryKey(key);
|
||||
key.AddAnnotation("Relational:Name", "login_attempts_pkey");
|
||||
|
||||
var idx_login_attempts_subject = runtimeEntityType.AddIndex(
|
||||
new[] { subjectId, occurredAt },
|
||||
name: "idx_login_attempts_subject");
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
|
||||
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "login_attempts");
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
|
||||
|
||||
Customize(runtimeEntityType);
|
||||
}
|
||||
|
||||
static partial void Customize(RuntimeEntityType runtimeEntityType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
|
||||
{
|
||||
[EntityFrameworkInternal]
|
||||
public partial class OfflineKitAuditEfEntityEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.Authority.Persistence.EfCore.Models.OfflineKitAuditEfEntity",
|
||||
typeof(OfflineKitAuditEfEntity),
|
||||
baseEntityType,
|
||||
propertyCount: 7,
|
||||
namedIndexCount: 4,
|
||||
keyCount: 1);
|
||||
|
||||
var eventId = runtimeEntityType.AddProperty(
|
||||
"EventId",
|
||||
typeof(Guid),
|
||||
propertyInfo: typeof(OfflineKitAuditEfEntity).GetProperty("EventId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(OfflineKitAuditEfEntity).GetField("<EventId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw,
|
||||
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||
eventId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
eventId.AddAnnotation("Relational:ColumnName", "event_id");
|
||||
|
||||
var actor = runtimeEntityType.AddProperty(
|
||||
"Actor",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(OfflineKitAuditEfEntity).GetProperty("Actor", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(OfflineKitAuditEfEntity).GetField("<Actor>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
actor.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
actor.AddAnnotation("Relational:ColumnName", "actor");
|
||||
|
||||
var details = runtimeEntityType.AddProperty(
|
||||
"Details",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(OfflineKitAuditEfEntity).GetProperty("Details", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(OfflineKitAuditEfEntity).GetField("<Details>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
details.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
details.AddAnnotation("Relational:ColumnName", "details");
|
||||
details.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
|
||||
var eventType = runtimeEntityType.AddProperty(
|
||||
"EventType",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(OfflineKitAuditEfEntity).GetProperty("EventType", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(OfflineKitAuditEfEntity).GetField("<EventType>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
eventType.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
eventType.AddAnnotation("Relational:ColumnName", "event_type");
|
||||
|
||||
var result = runtimeEntityType.AddProperty(
|
||||
"Result",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(OfflineKitAuditEfEntity).GetProperty("Result", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(OfflineKitAuditEfEntity).GetField("<Result>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
result.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
result.AddAnnotation("Relational:ColumnName", "result");
|
||||
|
||||
var tenantId = runtimeEntityType.AddProperty(
|
||||
"TenantId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(OfflineKitAuditEfEntity).GetProperty("TenantId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(OfflineKitAuditEfEntity).GetField("<TenantId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
tenantId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
|
||||
|
||||
var timestamp = runtimeEntityType.AddProperty(
|
||||
"Timestamp",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(OfflineKitAuditEfEntity).GetProperty("Timestamp", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(OfflineKitAuditEfEntity).GetField("<Timestamp>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
|
||||
timestamp.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
timestamp.AddAnnotation("Relational:ColumnName", "timestamp");
|
||||
|
||||
var key = runtimeEntityType.AddKey(
|
||||
new[] { eventId });
|
||||
runtimeEntityType.SetPrimaryKey(key);
|
||||
key.AddAnnotation("Relational:Name", "offline_kit_audit_pkey");
|
||||
|
||||
var idx_offline_kit_audit_result = runtimeEntityType.AddIndex(
|
||||
new[] { tenantId, result, timestamp },
|
||||
name: "idx_offline_kit_audit_result");
|
||||
|
||||
var idx_offline_kit_audit_tenant_ts = runtimeEntityType.AddIndex(
|
||||
new[] { tenantId, timestamp },
|
||||
name: "idx_offline_kit_audit_tenant_ts");
|
||||
|
||||
var idx_offline_kit_audit_ts = runtimeEntityType.AddIndex(
|
||||
new[] { timestamp },
|
||||
name: "idx_offline_kit_audit_ts");
|
||||
|
||||
var idx_offline_kit_audit_type = runtimeEntityType.AddIndex(
|
||||
new[] { eventType },
|
||||
name: "idx_offline_kit_audit_type");
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
|
||||
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "offline_kit_audit");
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
|
||||
|
||||
Customize(runtimeEntityType);
|
||||
}
|
||||
|
||||
static partial void Customize(RuntimeEntityType runtimeEntityType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
|
||||
{
|
||||
[EntityFrameworkInternal]
|
||||
public partial class OidcRefreshTokenEfEntityEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.Authority.Persistence.EfCore.Models.OidcRefreshTokenEfEntity",
|
||||
typeof(OidcRefreshTokenEfEntity),
|
||||
baseEntityType,
|
||||
propertyCount: 9,
|
||||
namedIndexCount: 2,
|
||||
keyCount: 2);
|
||||
|
||||
var id = runtimeEntityType.AddProperty(
|
||||
"Id",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(OidcRefreshTokenEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(OidcRefreshTokenEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw);
|
||||
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
id.AddAnnotation("Relational:ColumnName", "id");
|
||||
|
||||
var clientId = runtimeEntityType.AddProperty(
|
||||
"ClientId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(OidcRefreshTokenEfEntity).GetProperty("ClientId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(OidcRefreshTokenEfEntity).GetField("<ClientId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
clientId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
clientId.AddAnnotation("Relational:ColumnName", "client_id");
|
||||
|
||||
var consumedAt = runtimeEntityType.AddProperty(
|
||||
"ConsumedAt",
|
||||
typeof(DateTimeOffset?),
|
||||
propertyInfo: typeof(OidcRefreshTokenEfEntity).GetProperty("ConsumedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(OidcRefreshTokenEfEntity).GetField("<ConsumedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
consumedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
consumedAt.AddAnnotation("Relational:ColumnName", "consumed_at");
|
||||
|
||||
var createdAt = runtimeEntityType.AddProperty(
|
||||
"CreatedAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(OidcRefreshTokenEfEntity).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(OidcRefreshTokenEfEntity).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
|
||||
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
|
||||
|
||||
var expiresAt = runtimeEntityType.AddProperty(
|
||||
"ExpiresAt",
|
||||
typeof(DateTimeOffset?),
|
||||
propertyInfo: typeof(OidcRefreshTokenEfEntity).GetProperty("ExpiresAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(OidcRefreshTokenEfEntity).GetField("<ExpiresAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
expiresAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
expiresAt.AddAnnotation("Relational:ColumnName", "expires_at");
|
||||
|
||||
var handle = runtimeEntityType.AddProperty(
|
||||
"Handle",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(OidcRefreshTokenEfEntity).GetProperty("Handle", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(OidcRefreshTokenEfEntity).GetField("<Handle>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
handle.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
handle.AddAnnotation("Relational:ColumnName", "handle");
|
||||
|
||||
var payload = runtimeEntityType.AddProperty(
|
||||
"Payload",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(OidcRefreshTokenEfEntity).GetProperty("Payload", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(OidcRefreshTokenEfEntity).GetField("<Payload>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
payload.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
payload.AddAnnotation("Relational:ColumnName", "payload");
|
||||
|
||||
var subjectId = runtimeEntityType.AddProperty(
|
||||
"SubjectId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(OidcRefreshTokenEfEntity).GetProperty("SubjectId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(OidcRefreshTokenEfEntity).GetField("<SubjectId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
subjectId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
subjectId.AddAnnotation("Relational:ColumnName", "subject_id");
|
||||
|
||||
var tokenId = runtimeEntityType.AddProperty(
|
||||
"TokenId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(OidcRefreshTokenEfEntity).GetProperty("TokenId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(OidcRefreshTokenEfEntity).GetField("<TokenId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw);
|
||||
tokenId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
tokenId.AddAnnotation("Relational:ColumnName", "token_id");
|
||||
|
||||
var key = runtimeEntityType.AddKey(
|
||||
new[] { id });
|
||||
runtimeEntityType.SetPrimaryKey(key);
|
||||
key.AddAnnotation("Relational:Name", "oidc_refresh_tokens_pkey");
|
||||
|
||||
var key0 = runtimeEntityType.AddKey(
|
||||
new[] { tokenId });
|
||||
key0.AddAnnotation("Relational:Name", "oidc_refresh_tokens_token_id_key");
|
||||
|
||||
var idx_oidc_refresh_tokens_handle = runtimeEntityType.AddIndex(
|
||||
new[] { handle },
|
||||
name: "idx_oidc_refresh_tokens_handle");
|
||||
|
||||
var idx_oidc_refresh_tokens_subject = runtimeEntityType.AddIndex(
|
||||
new[] { subjectId },
|
||||
name: "idx_oidc_refresh_tokens_subject");
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
|
||||
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "oidc_refresh_tokens");
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
|
||||
|
||||
Customize(runtimeEntityType);
|
||||
}
|
||||
|
||||
static partial void Customize(RuntimeEntityType runtimeEntityType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
|
||||
{
|
||||
[EntityFrameworkInternal]
|
||||
public partial class OidcTokenEfEntityEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.Authority.Persistence.EfCore.Models.OidcTokenEfEntity",
|
||||
typeof(OidcTokenEfEntity),
|
||||
baseEntityType,
|
||||
propertyCount: 11,
|
||||
namedIndexCount: 3,
|
||||
keyCount: 2);
|
||||
|
||||
var id = runtimeEntityType.AddProperty(
|
||||
"Id",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(OidcTokenEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(OidcTokenEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw);
|
||||
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
id.AddAnnotation("Relational:ColumnName", "id");
|
||||
|
||||
var clientId = runtimeEntityType.AddProperty(
|
||||
"ClientId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(OidcTokenEfEntity).GetProperty("ClientId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(OidcTokenEfEntity).GetField("<ClientId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
clientId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
clientId.AddAnnotation("Relational:ColumnName", "client_id");
|
||||
|
||||
var createdAt = runtimeEntityType.AddProperty(
|
||||
"CreatedAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(OidcTokenEfEntity).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(OidcTokenEfEntity).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
|
||||
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
|
||||
|
||||
var expiresAt = runtimeEntityType.AddProperty(
|
||||
"ExpiresAt",
|
||||
typeof(DateTimeOffset?),
|
||||
propertyInfo: typeof(OidcTokenEfEntity).GetProperty("ExpiresAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(OidcTokenEfEntity).GetField("<ExpiresAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
expiresAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
expiresAt.AddAnnotation("Relational:ColumnName", "expires_at");
|
||||
|
||||
var payload = runtimeEntityType.AddProperty(
|
||||
"Payload",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(OidcTokenEfEntity).GetProperty("Payload", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(OidcTokenEfEntity).GetField("<Payload>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
payload.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
payload.AddAnnotation("Relational:ColumnName", "payload");
|
||||
|
||||
var properties = runtimeEntityType.AddProperty(
|
||||
"Properties",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(OidcTokenEfEntity).GetProperty("Properties", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(OidcTokenEfEntity).GetField("<Properties>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd);
|
||||
properties.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
properties.AddAnnotation("Relational:ColumnName", "properties");
|
||||
properties.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
properties.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
|
||||
|
||||
var redeemedAt = runtimeEntityType.AddProperty(
|
||||
"RedeemedAt",
|
||||
typeof(DateTimeOffset?),
|
||||
propertyInfo: typeof(OidcTokenEfEntity).GetProperty("RedeemedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(OidcTokenEfEntity).GetField("<RedeemedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
redeemedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
redeemedAt.AddAnnotation("Relational:ColumnName", "redeemed_at");
|
||||
|
||||
var referenceId = runtimeEntityType.AddProperty(
|
||||
"ReferenceId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(OidcTokenEfEntity).GetProperty("ReferenceId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(OidcTokenEfEntity).GetField("<ReferenceId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
referenceId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
referenceId.AddAnnotation("Relational:ColumnName", "reference_id");
|
||||
|
||||
var subjectId = runtimeEntityType.AddProperty(
|
||||
"SubjectId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(OidcTokenEfEntity).GetProperty("SubjectId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(OidcTokenEfEntity).GetField("<SubjectId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
subjectId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
subjectId.AddAnnotation("Relational:ColumnName", "subject_id");
|
||||
|
||||
var tokenId = runtimeEntityType.AddProperty(
|
||||
"TokenId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(OidcTokenEfEntity).GetProperty("TokenId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(OidcTokenEfEntity).GetField("<TokenId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw);
|
||||
tokenId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
tokenId.AddAnnotation("Relational:ColumnName", "token_id");
|
||||
|
||||
var tokenType = runtimeEntityType.AddProperty(
|
||||
"TokenType",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(OidcTokenEfEntity).GetProperty("TokenType", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(OidcTokenEfEntity).GetField("<TokenType>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
tokenType.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
tokenType.AddAnnotation("Relational:ColumnName", "token_type");
|
||||
|
||||
var key = runtimeEntityType.AddKey(
|
||||
new[] { id });
|
||||
runtimeEntityType.SetPrimaryKey(key);
|
||||
key.AddAnnotation("Relational:Name", "oidc_tokens_pkey");
|
||||
|
||||
var key0 = runtimeEntityType.AddKey(
|
||||
new[] { tokenId });
|
||||
key0.AddAnnotation("Relational:Name", "oidc_tokens_token_id_key");
|
||||
|
||||
var idx_oidc_tokens_client = runtimeEntityType.AddIndex(
|
||||
new[] { clientId },
|
||||
name: "idx_oidc_tokens_client");
|
||||
|
||||
var idx_oidc_tokens_reference = runtimeEntityType.AddIndex(
|
||||
new[] { referenceId },
|
||||
name: "idx_oidc_tokens_reference");
|
||||
|
||||
var idx_oidc_tokens_subject = runtimeEntityType.AddIndex(
|
||||
new[] { subjectId },
|
||||
name: "idx_oidc_tokens_subject");
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
|
||||
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "oidc_tokens");
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
|
||||
|
||||
Customize(runtimeEntityType);
|
||||
}
|
||||
|
||||
static partial void Customize(RuntimeEntityType runtimeEntityType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
|
||||
{
|
||||
[EntityFrameworkInternal]
|
||||
public partial class PermissionEfEntityEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.Authority.Persistence.EfCore.Models.PermissionEfEntity",
|
||||
typeof(PermissionEfEntity),
|
||||
baseEntityType,
|
||||
propertyCount: 7,
|
||||
namedIndexCount: 3,
|
||||
keyCount: 1);
|
||||
|
||||
var id = runtimeEntityType.AddProperty(
|
||||
"Id",
|
||||
typeof(Guid),
|
||||
propertyInfo: typeof(PermissionEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(PermissionEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw,
|
||||
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
id.AddAnnotation("Relational:ColumnName", "id");
|
||||
id.AddAnnotation("Relational:DefaultValueSql", "gen_random_uuid()");
|
||||
|
||||
var action = runtimeEntityType.AddProperty(
|
||||
"Action",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(PermissionEfEntity).GetProperty("Action", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(PermissionEfEntity).GetField("<Action>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
action.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
action.AddAnnotation("Relational:ColumnName", "action");
|
||||
|
||||
var createdAt = runtimeEntityType.AddProperty(
|
||||
"CreatedAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(PermissionEfEntity).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(PermissionEfEntity).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
|
||||
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
|
||||
createdAt.AddAnnotation("Relational:DefaultValueSql", "now()");
|
||||
|
||||
var description = runtimeEntityType.AddProperty(
|
||||
"Description",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(PermissionEfEntity).GetProperty("Description", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(PermissionEfEntity).GetField("<Description>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
description.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
description.AddAnnotation("Relational:ColumnName", "description");
|
||||
|
||||
var name = runtimeEntityType.AddProperty(
|
||||
"Name",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(PermissionEfEntity).GetProperty("Name", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(PermissionEfEntity).GetField("<Name>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
name.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
name.AddAnnotation("Relational:ColumnName", "name");
|
||||
|
||||
var resource = runtimeEntityType.AddProperty(
|
||||
"Resource",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(PermissionEfEntity).GetProperty("Resource", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(PermissionEfEntity).GetField("<Resource>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
resource.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
resource.AddAnnotation("Relational:ColumnName", "resource");
|
||||
|
||||
var tenantId = runtimeEntityType.AddProperty(
|
||||
"TenantId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(PermissionEfEntity).GetProperty("TenantId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(PermissionEfEntity).GetField("<TenantId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
tenantId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
|
||||
|
||||
var key = runtimeEntityType.AddKey(
|
||||
new[] { id });
|
||||
runtimeEntityType.SetPrimaryKey(key);
|
||||
key.AddAnnotation("Relational:Name", "permissions_pkey");
|
||||
|
||||
var idx_permissions_resource = runtimeEntityType.AddIndex(
|
||||
new[] { tenantId, resource },
|
||||
name: "idx_permissions_resource");
|
||||
|
||||
var idx_permissions_tenant_id = runtimeEntityType.AddIndex(
|
||||
new[] { tenantId },
|
||||
name: "idx_permissions_tenant_id");
|
||||
|
||||
var permissions_tenant_id_name_key = runtimeEntityType.AddIndex(
|
||||
new[] { tenantId, name },
|
||||
name: "permissions_tenant_id_name_key",
|
||||
unique: true);
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
|
||||
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "permissions");
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
|
||||
|
||||
Customize(runtimeEntityType);
|
||||
}
|
||||
|
||||
static partial void Customize(RuntimeEntityType runtimeEntityType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
|
||||
{
|
||||
[EntityFrameworkInternal]
|
||||
public partial class RefreshTokenEfEntityEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.Authority.Persistence.EfCore.Models.RefreshTokenEfEntity",
|
||||
typeof(RefreshTokenEfEntity),
|
||||
baseEntityType,
|
||||
propertyCount: 12,
|
||||
namedIndexCount: 3,
|
||||
keyCount: 2);
|
||||
|
||||
var id = runtimeEntityType.AddProperty(
|
||||
"Id",
|
||||
typeof(Guid),
|
||||
propertyInfo: typeof(RefreshTokenEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RefreshTokenEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw,
|
||||
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
id.AddAnnotation("Relational:ColumnName", "id");
|
||||
id.AddAnnotation("Relational:DefaultValueSql", "gen_random_uuid()");
|
||||
|
||||
var accessTokenId = runtimeEntityType.AddProperty(
|
||||
"AccessTokenId",
|
||||
typeof(Guid?),
|
||||
propertyInfo: typeof(RefreshTokenEfEntity).GetProperty("AccessTokenId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RefreshTokenEfEntity).GetField("<AccessTokenId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
accessTokenId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
accessTokenId.AddAnnotation("Relational:ColumnName", "access_token_id");
|
||||
|
||||
var clientId = runtimeEntityType.AddProperty(
|
||||
"ClientId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(RefreshTokenEfEntity).GetProperty("ClientId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RefreshTokenEfEntity).GetField("<ClientId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
clientId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
clientId.AddAnnotation("Relational:ColumnName", "client_id");
|
||||
|
||||
var expiresAt = runtimeEntityType.AddProperty(
|
||||
"ExpiresAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(RefreshTokenEfEntity).GetProperty("ExpiresAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RefreshTokenEfEntity).GetField("<ExpiresAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
|
||||
expiresAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
expiresAt.AddAnnotation("Relational:ColumnName", "expires_at");
|
||||
|
||||
var issuedAt = runtimeEntityType.AddProperty(
|
||||
"IssuedAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(RefreshTokenEfEntity).GetProperty("IssuedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RefreshTokenEfEntity).GetField("<IssuedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
|
||||
issuedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
issuedAt.AddAnnotation("Relational:ColumnName", "issued_at");
|
||||
issuedAt.AddAnnotation("Relational:DefaultValueSql", "now()");
|
||||
|
||||
var metadata = runtimeEntityType.AddProperty(
|
||||
"Metadata",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(RefreshTokenEfEntity).GetProperty("Metadata", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RefreshTokenEfEntity).GetField("<Metadata>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd);
|
||||
metadata.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
metadata.AddAnnotation("Relational:ColumnName", "metadata");
|
||||
metadata.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
metadata.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
|
||||
|
||||
var replacedBy = runtimeEntityType.AddProperty(
|
||||
"ReplacedBy",
|
||||
typeof(Guid?),
|
||||
propertyInfo: typeof(RefreshTokenEfEntity).GetProperty("ReplacedBy", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RefreshTokenEfEntity).GetField("<ReplacedBy>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
replacedBy.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
replacedBy.AddAnnotation("Relational:ColumnName", "replaced_by");
|
||||
|
||||
var revokedAt = runtimeEntityType.AddProperty(
|
||||
"RevokedAt",
|
||||
typeof(DateTimeOffset?),
|
||||
propertyInfo: typeof(RefreshTokenEfEntity).GetProperty("RevokedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RefreshTokenEfEntity).GetField("<RevokedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
revokedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
revokedAt.AddAnnotation("Relational:ColumnName", "revoked_at");
|
||||
|
||||
var revokedBy = runtimeEntityType.AddProperty(
|
||||
"RevokedBy",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(RefreshTokenEfEntity).GetProperty("RevokedBy", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RefreshTokenEfEntity).GetField("<RevokedBy>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
revokedBy.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
revokedBy.AddAnnotation("Relational:ColumnName", "revoked_by");
|
||||
|
||||
var tenantId = runtimeEntityType.AddProperty(
|
||||
"TenantId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(RefreshTokenEfEntity).GetProperty("TenantId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RefreshTokenEfEntity).GetField("<TenantId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
tenantId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
|
||||
|
||||
var tokenHash = runtimeEntityType.AddProperty(
|
||||
"TokenHash",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(RefreshTokenEfEntity).GetProperty("TokenHash", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RefreshTokenEfEntity).GetField("<TokenHash>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw);
|
||||
tokenHash.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
tokenHash.AddAnnotation("Relational:ColumnName", "token_hash");
|
||||
|
||||
var userId = runtimeEntityType.AddProperty(
|
||||
"UserId",
|
||||
typeof(Guid),
|
||||
propertyInfo: typeof(RefreshTokenEfEntity).GetProperty("UserId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RefreshTokenEfEntity).GetField("<UserId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||
userId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
userId.AddAnnotation("Relational:ColumnName", "user_id");
|
||||
|
||||
var key = runtimeEntityType.AddKey(
|
||||
new[] { id });
|
||||
runtimeEntityType.SetPrimaryKey(key);
|
||||
key.AddAnnotation("Relational:Name", "refresh_tokens_pkey");
|
||||
|
||||
var key0 = runtimeEntityType.AddKey(
|
||||
new[] { tokenHash });
|
||||
key0.AddAnnotation("Relational:Name", "refresh_tokens_token_hash_key");
|
||||
|
||||
var idx_refresh_tokens_expires_at = runtimeEntityType.AddIndex(
|
||||
new[] { expiresAt },
|
||||
name: "idx_refresh_tokens_expires_at");
|
||||
|
||||
var idx_refresh_tokens_tenant_id = runtimeEntityType.AddIndex(
|
||||
new[] { tenantId },
|
||||
name: "idx_refresh_tokens_tenant_id");
|
||||
|
||||
var idx_refresh_tokens_user_id = runtimeEntityType.AddIndex(
|
||||
new[] { userId },
|
||||
name: "idx_refresh_tokens_user_id");
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
|
||||
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "refresh_tokens");
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
|
||||
|
||||
Customize(runtimeEntityType);
|
||||
}
|
||||
|
||||
static partial void Customize(RuntimeEntityType runtimeEntityType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
|
||||
{
|
||||
[EntityFrameworkInternal]
|
||||
public partial class RevocationEfEntityEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.Authority.Persistence.EfCore.Models.RevocationEfEntity",
|
||||
typeof(RevocationEfEntity),
|
||||
baseEntityType,
|
||||
propertyCount: 12,
|
||||
namedIndexCount: 1,
|
||||
keyCount: 1);
|
||||
|
||||
var id = runtimeEntityType.AddProperty(
|
||||
"Id",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(RevocationEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RevocationEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw);
|
||||
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
id.AddAnnotation("Relational:ColumnName", "id");
|
||||
|
||||
var category = runtimeEntityType.AddProperty(
|
||||
"Category",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(RevocationEfEntity).GetProperty("Category", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RevocationEfEntity).GetField("<Category>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
category.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
category.AddAnnotation("Relational:ColumnName", "category");
|
||||
|
||||
var clientId = runtimeEntityType.AddProperty(
|
||||
"ClientId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(RevocationEfEntity).GetProperty("ClientId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RevocationEfEntity).GetField("<ClientId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
clientId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
clientId.AddAnnotation("Relational:ColumnName", "client_id");
|
||||
|
||||
var effectiveAt = runtimeEntityType.AddProperty(
|
||||
"EffectiveAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(RevocationEfEntity).GetProperty("EffectiveAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RevocationEfEntity).GetField("<EffectiveAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
|
||||
effectiveAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
effectiveAt.AddAnnotation("Relational:ColumnName", "effective_at");
|
||||
|
||||
var expiresAt = runtimeEntityType.AddProperty(
|
||||
"ExpiresAt",
|
||||
typeof(DateTimeOffset?),
|
||||
propertyInfo: typeof(RevocationEfEntity).GetProperty("ExpiresAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RevocationEfEntity).GetField("<ExpiresAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
expiresAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
expiresAt.AddAnnotation("Relational:ColumnName", "expires_at");
|
||||
|
||||
var metadata = runtimeEntityType.AddProperty(
|
||||
"Metadata",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(RevocationEfEntity).GetProperty("Metadata", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RevocationEfEntity).GetField("<Metadata>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd);
|
||||
metadata.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
metadata.AddAnnotation("Relational:ColumnName", "metadata");
|
||||
metadata.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
metadata.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
|
||||
|
||||
var reason = runtimeEntityType.AddProperty(
|
||||
"Reason",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(RevocationEfEntity).GetProperty("Reason", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RevocationEfEntity).GetField("<Reason>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
reason.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
reason.AddAnnotation("Relational:ColumnName", "reason");
|
||||
|
||||
var reasonDescription = runtimeEntityType.AddProperty(
|
||||
"ReasonDescription",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(RevocationEfEntity).GetProperty("ReasonDescription", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RevocationEfEntity).GetField("<ReasonDescription>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
reasonDescription.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
reasonDescription.AddAnnotation("Relational:ColumnName", "reason_description");
|
||||
|
||||
var revocationId = runtimeEntityType.AddProperty(
|
||||
"RevocationId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(RevocationEfEntity).GetProperty("RevocationId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RevocationEfEntity).GetField("<RevocationId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
revocationId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
revocationId.AddAnnotation("Relational:ColumnName", "revocation_id");
|
||||
|
||||
var revokedAt = runtimeEntityType.AddProperty(
|
||||
"RevokedAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(RevocationEfEntity).GetProperty("RevokedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RevocationEfEntity).GetField("<RevokedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
|
||||
revokedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
revokedAt.AddAnnotation("Relational:ColumnName", "revoked_at");
|
||||
|
||||
var subjectId = runtimeEntityType.AddProperty(
|
||||
"SubjectId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(RevocationEfEntity).GetProperty("SubjectId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RevocationEfEntity).GetField("<SubjectId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
subjectId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
subjectId.AddAnnotation("Relational:ColumnName", "subject_id");
|
||||
|
||||
var tokenId = runtimeEntityType.AddProperty(
|
||||
"TokenId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(RevocationEfEntity).GetProperty("TokenId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RevocationEfEntity).GetField("<TokenId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
tokenId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
tokenId.AddAnnotation("Relational:ColumnName", "token_id");
|
||||
|
||||
var key = runtimeEntityType.AddKey(
|
||||
new[] { id });
|
||||
runtimeEntityType.SetPrimaryKey(key);
|
||||
key.AddAnnotation("Relational:Name", "revocations_pkey");
|
||||
|
||||
var idx_revocations_category_revocation_id = runtimeEntityType.AddIndex(
|
||||
new[] { category, revocationId },
|
||||
name: "idx_revocations_category_revocation_id",
|
||||
unique: true);
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
|
||||
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "revocations");
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
|
||||
|
||||
Customize(runtimeEntityType);
|
||||
}
|
||||
|
||||
static partial void Customize(RuntimeEntityType runtimeEntityType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
|
||||
{
|
||||
[EntityFrameworkInternal]
|
||||
public partial class RevocationExportStateEfEntityEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.Authority.Persistence.EfCore.Models.RevocationExportStateEfEntity",
|
||||
typeof(RevocationExportStateEfEntity),
|
||||
baseEntityType,
|
||||
propertyCount: 4,
|
||||
keyCount: 1);
|
||||
|
||||
var id = runtimeEntityType.AddProperty(
|
||||
"Id",
|
||||
typeof(int),
|
||||
propertyInfo: typeof(RevocationExportStateEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RevocationExportStateEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw,
|
||||
sentinel: 0);
|
||||
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
id.AddAnnotation("Relational:ColumnName", "id");
|
||||
id.AddAnnotation("Relational:DefaultValue", 1);
|
||||
|
||||
var bundleId = runtimeEntityType.AddProperty(
|
||||
"BundleId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(RevocationExportStateEfEntity).GetProperty("BundleId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RevocationExportStateEfEntity).GetField("<BundleId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
bundleId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
bundleId.AddAnnotation("Relational:ColumnName", "bundle_id");
|
||||
|
||||
var issuedAt = runtimeEntityType.AddProperty(
|
||||
"IssuedAt",
|
||||
typeof(DateTimeOffset?),
|
||||
propertyInfo: typeof(RevocationExportStateEfEntity).GetProperty("IssuedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RevocationExportStateEfEntity).GetField("<IssuedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
issuedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
issuedAt.AddAnnotation("Relational:ColumnName", "issued_at");
|
||||
|
||||
var sequence = runtimeEntityType.AddProperty(
|
||||
"Sequence",
|
||||
typeof(long),
|
||||
propertyInfo: typeof(RevocationExportStateEfEntity).GetProperty("Sequence", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RevocationExportStateEfEntity).GetField("<Sequence>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: 0L);
|
||||
sequence.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
sequence.AddAnnotation("Relational:ColumnName", "sequence");
|
||||
sequence.AddAnnotation("Relational:DefaultValue", 0L);
|
||||
|
||||
var key = runtimeEntityType.AddKey(
|
||||
new[] { id });
|
||||
runtimeEntityType.SetPrimaryKey(key);
|
||||
key.AddAnnotation("Relational:Name", "revocation_export_state_pkey");
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
|
||||
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "revocation_export_state");
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
|
||||
|
||||
Customize(runtimeEntityType);
|
||||
}
|
||||
|
||||
static partial void Customize(RuntimeEntityType runtimeEntityType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
|
||||
{
|
||||
[EntityFrameworkInternal]
|
||||
public partial class RoleEfEntityEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.Authority.Persistence.EfCore.Models.RoleEfEntity",
|
||||
typeof(RoleEfEntity),
|
||||
baseEntityType,
|
||||
propertyCount: 9,
|
||||
namedIndexCount: 2,
|
||||
keyCount: 1);
|
||||
|
||||
var id = runtimeEntityType.AddProperty(
|
||||
"Id",
|
||||
typeof(Guid),
|
||||
propertyInfo: typeof(RoleEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RoleEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw,
|
||||
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
id.AddAnnotation("Relational:ColumnName", "id");
|
||||
id.AddAnnotation("Relational:DefaultValueSql", "gen_random_uuid()");
|
||||
|
||||
var createdAt = runtimeEntityType.AddProperty(
|
||||
"CreatedAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(RoleEfEntity).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RoleEfEntity).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
|
||||
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
|
||||
createdAt.AddAnnotation("Relational:DefaultValueSql", "now()");
|
||||
|
||||
var description = runtimeEntityType.AddProperty(
|
||||
"Description",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(RoleEfEntity).GetProperty("Description", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RoleEfEntity).GetField("<Description>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
description.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
description.AddAnnotation("Relational:ColumnName", "description");
|
||||
|
||||
var displayName = runtimeEntityType.AddProperty(
|
||||
"DisplayName",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(RoleEfEntity).GetProperty("DisplayName", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RoleEfEntity).GetField("<DisplayName>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
displayName.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
displayName.AddAnnotation("Relational:ColumnName", "display_name");
|
||||
|
||||
var isSystem = runtimeEntityType.AddProperty(
|
||||
"IsSystem",
|
||||
typeof(bool),
|
||||
propertyInfo: typeof(RoleEfEntity).GetProperty("IsSystem", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RoleEfEntity).GetField("<IsSystem>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: false);
|
||||
isSystem.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
isSystem.AddAnnotation("Relational:ColumnName", "is_system");
|
||||
isSystem.AddAnnotation("Relational:DefaultValue", false);
|
||||
|
||||
var metadata = runtimeEntityType.AddProperty(
|
||||
"Metadata",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(RoleEfEntity).GetProperty("Metadata", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RoleEfEntity).GetField("<Metadata>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd);
|
||||
metadata.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
metadata.AddAnnotation("Relational:ColumnName", "metadata");
|
||||
metadata.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
metadata.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
|
||||
|
||||
var name = runtimeEntityType.AddProperty(
|
||||
"Name",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(RoleEfEntity).GetProperty("Name", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RoleEfEntity).GetField("<Name>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
name.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
name.AddAnnotation("Relational:ColumnName", "name");
|
||||
|
||||
var tenantId = runtimeEntityType.AddProperty(
|
||||
"TenantId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(RoleEfEntity).GetProperty("TenantId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RoleEfEntity).GetField("<TenantId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
tenantId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
|
||||
|
||||
var updatedAt = runtimeEntityType.AddProperty(
|
||||
"UpdatedAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(RoleEfEntity).GetProperty("UpdatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RoleEfEntity).GetField("<UpdatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
|
||||
updatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
updatedAt.AddAnnotation("Relational:ColumnName", "updated_at");
|
||||
updatedAt.AddAnnotation("Relational:DefaultValueSql", "now()");
|
||||
|
||||
var key = runtimeEntityType.AddKey(
|
||||
new[] { id });
|
||||
runtimeEntityType.SetPrimaryKey(key);
|
||||
key.AddAnnotation("Relational:Name", "roles_pkey");
|
||||
|
||||
var idx_roles_tenant_id = runtimeEntityType.AddIndex(
|
||||
new[] { tenantId },
|
||||
name: "idx_roles_tenant_id");
|
||||
|
||||
var roles_tenant_id_name_key = runtimeEntityType.AddIndex(
|
||||
new[] { tenantId, name },
|
||||
name: "roles_tenant_id_name_key",
|
||||
unique: true);
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
|
||||
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "roles");
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
|
||||
|
||||
Customize(runtimeEntityType);
|
||||
}
|
||||
|
||||
static partial void Customize(RuntimeEntityType runtimeEntityType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
|
||||
{
|
||||
[EntityFrameworkInternal]
|
||||
public partial class RolePermissionEfEntityEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.Authority.Persistence.EfCore.Models.RolePermissionEfEntity",
|
||||
typeof(RolePermissionEfEntity),
|
||||
baseEntityType,
|
||||
propertyCount: 3,
|
||||
keyCount: 1);
|
||||
|
||||
var roleId = runtimeEntityType.AddProperty(
|
||||
"RoleId",
|
||||
typeof(Guid),
|
||||
propertyInfo: typeof(RolePermissionEfEntity).GetProperty("RoleId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RolePermissionEfEntity).GetField("<RoleId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw,
|
||||
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||
roleId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
roleId.AddAnnotation("Relational:ColumnName", "role_id");
|
||||
|
||||
var permissionId = runtimeEntityType.AddProperty(
|
||||
"PermissionId",
|
||||
typeof(Guid),
|
||||
propertyInfo: typeof(RolePermissionEfEntity).GetProperty("PermissionId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RolePermissionEfEntity).GetField("<PermissionId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw,
|
||||
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||
permissionId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
permissionId.AddAnnotation("Relational:ColumnName", "permission_id");
|
||||
|
||||
var createdAt = runtimeEntityType.AddProperty(
|
||||
"CreatedAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(RolePermissionEfEntity).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(RolePermissionEfEntity).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
|
||||
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
|
||||
createdAt.AddAnnotation("Relational:DefaultValueSql", "now()");
|
||||
|
||||
var key = runtimeEntityType.AddKey(
|
||||
new[] { roleId, permissionId });
|
||||
runtimeEntityType.SetPrimaryKey(key);
|
||||
key.AddAnnotation("Relational:Name", "role_permissions_pkey");
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
|
||||
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "role_permissions");
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
|
||||
|
||||
Customize(runtimeEntityType);
|
||||
}
|
||||
|
||||
static partial void Customize(RuntimeEntityType runtimeEntityType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
|
||||
{
|
||||
[EntityFrameworkInternal]
|
||||
public partial class ServiceAccountEfEntityEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.Authority.Persistence.EfCore.Models.ServiceAccountEfEntity",
|
||||
typeof(ServiceAccountEfEntity),
|
||||
baseEntityType,
|
||||
propertyCount: 11,
|
||||
namedIndexCount: 1,
|
||||
keyCount: 2);
|
||||
|
||||
var id = runtimeEntityType.AddProperty(
|
||||
"Id",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ServiceAccountEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ServiceAccountEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw);
|
||||
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
id.AddAnnotation("Relational:ColumnName", "id");
|
||||
|
||||
var accountId = runtimeEntityType.AddProperty(
|
||||
"AccountId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ServiceAccountEfEntity).GetProperty("AccountId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ServiceAccountEfEntity).GetField("<AccountId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw);
|
||||
accountId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
accountId.AddAnnotation("Relational:ColumnName", "account_id");
|
||||
|
||||
var allowedScopes = runtimeEntityType.AddProperty(
|
||||
"AllowedScopes",
|
||||
typeof(string[]),
|
||||
propertyInfo: typeof(ServiceAccountEfEntity).GetProperty("AllowedScopes", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ServiceAccountEfEntity).GetField("<AllowedScopes>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd);
|
||||
var allowedScopesElementType = allowedScopes.SetElementType(typeof(string));
|
||||
allowedScopes.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
allowedScopes.AddAnnotation("Relational:ColumnName", "allowed_scopes");
|
||||
allowedScopes.AddAnnotation("Relational:DefaultValueSql", "'{}'::text[]");
|
||||
|
||||
var attributes = runtimeEntityType.AddProperty(
|
||||
"Attributes",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ServiceAccountEfEntity).GetProperty("Attributes", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ServiceAccountEfEntity).GetField("<Attributes>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd);
|
||||
attributes.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
attributes.AddAnnotation("Relational:ColumnName", "attributes");
|
||||
attributes.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
attributes.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
|
||||
|
||||
var authorizedClients = runtimeEntityType.AddProperty(
|
||||
"AuthorizedClients",
|
||||
typeof(string[]),
|
||||
propertyInfo: typeof(ServiceAccountEfEntity).GetProperty("AuthorizedClients", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ServiceAccountEfEntity).GetField("<AuthorizedClients>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd);
|
||||
var authorizedClientsElementType = authorizedClients.SetElementType(typeof(string));
|
||||
authorizedClients.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
authorizedClients.AddAnnotation("Relational:ColumnName", "authorized_clients");
|
||||
authorizedClients.AddAnnotation("Relational:DefaultValueSql", "'{}'::text[]");
|
||||
|
||||
var createdAt = runtimeEntityType.AddProperty(
|
||||
"CreatedAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(ServiceAccountEfEntity).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ServiceAccountEfEntity).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
|
||||
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
|
||||
createdAt.AddAnnotation("Relational:DefaultValueSql", "now()");
|
||||
|
||||
var description = runtimeEntityType.AddProperty(
|
||||
"Description",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ServiceAccountEfEntity).GetProperty("Description", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ServiceAccountEfEntity).GetField("<Description>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
description.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
description.AddAnnotation("Relational:ColumnName", "description");
|
||||
|
||||
var displayName = runtimeEntityType.AddProperty(
|
||||
"DisplayName",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ServiceAccountEfEntity).GetProperty("DisplayName", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ServiceAccountEfEntity).GetField("<DisplayName>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
displayName.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
displayName.AddAnnotation("Relational:ColumnName", "display_name");
|
||||
|
||||
var enabled = runtimeEntityType.AddProperty(
|
||||
"Enabled",
|
||||
typeof(bool),
|
||||
propertyInfo: typeof(ServiceAccountEfEntity).GetProperty("Enabled", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ServiceAccountEfEntity).GetField("<Enabled>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: true);
|
||||
enabled.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
enabled.AddAnnotation("Relational:ColumnName", "enabled");
|
||||
enabled.AddAnnotation("Relational:DefaultValue", true);
|
||||
|
||||
var tenant = runtimeEntityType.AddProperty(
|
||||
"Tenant",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ServiceAccountEfEntity).GetProperty("Tenant", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ServiceAccountEfEntity).GetField("<Tenant>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
tenant.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
tenant.AddAnnotation("Relational:ColumnName", "tenant");
|
||||
|
||||
var updatedAt = runtimeEntityType.AddProperty(
|
||||
"UpdatedAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(ServiceAccountEfEntity).GetProperty("UpdatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ServiceAccountEfEntity).GetField("<UpdatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
|
||||
updatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
updatedAt.AddAnnotation("Relational:ColumnName", "updated_at");
|
||||
updatedAt.AddAnnotation("Relational:DefaultValueSql", "now()");
|
||||
|
||||
var key = runtimeEntityType.AddKey(
|
||||
new[] { accountId });
|
||||
key.AddAnnotation("Relational:Name", "service_accounts_account_id_key");
|
||||
|
||||
var key0 = runtimeEntityType.AddKey(
|
||||
new[] { id });
|
||||
runtimeEntityType.SetPrimaryKey(key0);
|
||||
key0.AddAnnotation("Relational:Name", "service_accounts_pkey");
|
||||
|
||||
var idx_service_accounts_tenant = runtimeEntityType.AddIndex(
|
||||
new[] { tenant },
|
||||
name: "idx_service_accounts_tenant");
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
|
||||
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "service_accounts");
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
|
||||
|
||||
Customize(runtimeEntityType);
|
||||
}
|
||||
|
||||
static partial void Customize(RuntimeEntityType runtimeEntityType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
|
||||
{
|
||||
[EntityFrameworkInternal]
|
||||
public partial class SessionEfEntityEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.Authority.Persistence.EfCore.Models.SessionEfEntity",
|
||||
typeof(SessionEfEntity),
|
||||
baseEntityType,
|
||||
propertyCount: 12,
|
||||
namedIndexCount: 3,
|
||||
keyCount: 2);
|
||||
|
||||
var id = runtimeEntityType.AddProperty(
|
||||
"Id",
|
||||
typeof(Guid),
|
||||
propertyInfo: typeof(SessionEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(SessionEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw,
|
||||
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
id.AddAnnotation("Relational:ColumnName", "id");
|
||||
id.AddAnnotation("Relational:DefaultValueSql", "gen_random_uuid()");
|
||||
|
||||
var endReason = runtimeEntityType.AddProperty(
|
||||
"EndReason",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(SessionEfEntity).GetProperty("EndReason", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(SessionEfEntity).GetField("<EndReason>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
endReason.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
endReason.AddAnnotation("Relational:ColumnName", "end_reason");
|
||||
|
||||
var endedAt = runtimeEntityType.AddProperty(
|
||||
"EndedAt",
|
||||
typeof(DateTimeOffset?),
|
||||
propertyInfo: typeof(SessionEfEntity).GetProperty("EndedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(SessionEfEntity).GetField("<EndedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
endedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
endedAt.AddAnnotation("Relational:ColumnName", "ended_at");
|
||||
|
||||
var expiresAt = runtimeEntityType.AddProperty(
|
||||
"ExpiresAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(SessionEfEntity).GetProperty("ExpiresAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(SessionEfEntity).GetField("<ExpiresAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
|
||||
expiresAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
expiresAt.AddAnnotation("Relational:ColumnName", "expires_at");
|
||||
|
||||
var ipAddress = runtimeEntityType.AddProperty(
|
||||
"IpAddress",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(SessionEfEntity).GetProperty("IpAddress", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(SessionEfEntity).GetField("<IpAddress>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
ipAddress.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
ipAddress.AddAnnotation("Relational:ColumnName", "ip_address");
|
||||
|
||||
var lastActivityAt = runtimeEntityType.AddProperty(
|
||||
"LastActivityAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(SessionEfEntity).GetProperty("LastActivityAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(SessionEfEntity).GetField("<LastActivityAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
|
||||
lastActivityAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
lastActivityAt.AddAnnotation("Relational:ColumnName", "last_activity_at");
|
||||
lastActivityAt.AddAnnotation("Relational:DefaultValueSql", "now()");
|
||||
|
||||
var metadata = runtimeEntityType.AddProperty(
|
||||
"Metadata",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(SessionEfEntity).GetProperty("Metadata", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(SessionEfEntity).GetField("<Metadata>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd);
|
||||
metadata.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
metadata.AddAnnotation("Relational:ColumnName", "metadata");
|
||||
metadata.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
metadata.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
|
||||
|
||||
var sessionTokenHash = runtimeEntityType.AddProperty(
|
||||
"SessionTokenHash",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(SessionEfEntity).GetProperty("SessionTokenHash", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(SessionEfEntity).GetField("<SessionTokenHash>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw);
|
||||
sessionTokenHash.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
sessionTokenHash.AddAnnotation("Relational:ColumnName", "session_token_hash");
|
||||
|
||||
var startedAt = runtimeEntityType.AddProperty(
|
||||
"StartedAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(SessionEfEntity).GetProperty("StartedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(SessionEfEntity).GetField("<StartedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
|
||||
startedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
startedAt.AddAnnotation("Relational:ColumnName", "started_at");
|
||||
startedAt.AddAnnotation("Relational:DefaultValueSql", "now()");
|
||||
|
||||
var tenantId = runtimeEntityType.AddProperty(
|
||||
"TenantId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(SessionEfEntity).GetProperty("TenantId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(SessionEfEntity).GetField("<TenantId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
tenantId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
|
||||
|
||||
var userAgent = runtimeEntityType.AddProperty(
|
||||
"UserAgent",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(SessionEfEntity).GetProperty("UserAgent", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(SessionEfEntity).GetField("<UserAgent>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
userAgent.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
userAgent.AddAnnotation("Relational:ColumnName", "user_agent");
|
||||
|
||||
var userId = runtimeEntityType.AddProperty(
|
||||
"UserId",
|
||||
typeof(Guid),
|
||||
propertyInfo: typeof(SessionEfEntity).GetProperty("UserId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(SessionEfEntity).GetField("<UserId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||
userId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
userId.AddAnnotation("Relational:ColumnName", "user_id");
|
||||
|
||||
var key = runtimeEntityType.AddKey(
|
||||
new[] { id });
|
||||
runtimeEntityType.SetPrimaryKey(key);
|
||||
key.AddAnnotation("Relational:Name", "sessions_pkey");
|
||||
|
||||
var key0 = runtimeEntityType.AddKey(
|
||||
new[] { sessionTokenHash });
|
||||
key0.AddAnnotation("Relational:Name", "sessions_session_token_hash_key");
|
||||
|
||||
var idx_sessions_expires_at = runtimeEntityType.AddIndex(
|
||||
new[] { expiresAt },
|
||||
name: "idx_sessions_expires_at");
|
||||
|
||||
var idx_sessions_tenant_id = runtimeEntityType.AddIndex(
|
||||
new[] { tenantId },
|
||||
name: "idx_sessions_tenant_id");
|
||||
|
||||
var idx_sessions_user_id = runtimeEntityType.AddIndex(
|
||||
new[] { userId },
|
||||
name: "idx_sessions_user_id");
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
|
||||
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "sessions");
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
|
||||
|
||||
Customize(runtimeEntityType);
|
||||
}
|
||||
|
||||
static partial void Customize(RuntimeEntityType runtimeEntityType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
|
||||
{
|
||||
[EntityFrameworkInternal]
|
||||
public partial class TenantEfEntityEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.Authority.Persistence.EfCore.Models.TenantEfEntity",
|
||||
typeof(TenantEfEntity),
|
||||
baseEntityType,
|
||||
propertyCount: 11,
|
||||
namedIndexCount: 3,
|
||||
keyCount: 1);
|
||||
|
||||
var id = runtimeEntityType.AddProperty(
|
||||
"Id",
|
||||
typeof(Guid),
|
||||
propertyInfo: typeof(TenantEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TenantEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw,
|
||||
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
id.AddAnnotation("Relational:ColumnName", "id");
|
||||
id.AddAnnotation("Relational:DefaultValueSql", "gen_random_uuid()");
|
||||
|
||||
var createdAt = runtimeEntityType.AddProperty(
|
||||
"CreatedAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(TenantEfEntity).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TenantEfEntity).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
|
||||
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
|
||||
createdAt.AddAnnotation("Relational:DefaultValueSql", "now()");
|
||||
|
||||
var createdBy = runtimeEntityType.AddProperty(
|
||||
"CreatedBy",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(TenantEfEntity).GetProperty("CreatedBy", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TenantEfEntity).GetField("<CreatedBy>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
createdBy.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
createdBy.AddAnnotation("Relational:ColumnName", "created_by");
|
||||
|
||||
var displayName = runtimeEntityType.AddProperty(
|
||||
"DisplayName",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(TenantEfEntity).GetProperty("DisplayName", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TenantEfEntity).GetField("<DisplayName>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
displayName.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
displayName.AddAnnotation("Relational:ColumnName", "display_name");
|
||||
|
||||
var metadata = runtimeEntityType.AddProperty(
|
||||
"Metadata",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(TenantEfEntity).GetProperty("Metadata", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TenantEfEntity).GetField("<Metadata>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd);
|
||||
metadata.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
metadata.AddAnnotation("Relational:ColumnName", "metadata");
|
||||
metadata.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
metadata.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
|
||||
|
||||
var name = runtimeEntityType.AddProperty(
|
||||
"Name",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(TenantEfEntity).GetProperty("Name", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TenantEfEntity).GetField("<Name>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
name.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
name.AddAnnotation("Relational:ColumnName", "name");
|
||||
|
||||
var settings = runtimeEntityType.AddProperty(
|
||||
"Settings",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(TenantEfEntity).GetProperty("Settings", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TenantEfEntity).GetField("<Settings>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd);
|
||||
settings.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
settings.AddAnnotation("Relational:ColumnName", "settings");
|
||||
settings.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
settings.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
|
||||
|
||||
var status = runtimeEntityType.AddProperty(
|
||||
"Status",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(TenantEfEntity).GetProperty("Status", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TenantEfEntity).GetField("<Status>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd);
|
||||
status.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
status.AddAnnotation("Relational:ColumnName", "status");
|
||||
status.AddAnnotation("Relational:DefaultValueSql", "'active'");
|
||||
|
||||
var tenantId = runtimeEntityType.AddProperty(
|
||||
"TenantId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(TenantEfEntity).GetProperty("TenantId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TenantEfEntity).GetField("<TenantId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
tenantId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
|
||||
|
||||
var updatedAt = runtimeEntityType.AddProperty(
|
||||
"UpdatedAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(TenantEfEntity).GetProperty("UpdatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TenantEfEntity).GetField("<UpdatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
|
||||
updatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
updatedAt.AddAnnotation("Relational:ColumnName", "updated_at");
|
||||
updatedAt.AddAnnotation("Relational:DefaultValueSql", "now()");
|
||||
|
||||
var updatedBy = runtimeEntityType.AddProperty(
|
||||
"UpdatedBy",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(TenantEfEntity).GetProperty("UpdatedBy", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TenantEfEntity).GetField("<UpdatedBy>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
updatedBy.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
updatedBy.AddAnnotation("Relational:ColumnName", "updated_by");
|
||||
|
||||
var key = runtimeEntityType.AddKey(
|
||||
new[] { id });
|
||||
runtimeEntityType.SetPrimaryKey(key);
|
||||
key.AddAnnotation("Relational:Name", "tenants_pkey");
|
||||
|
||||
var idx_tenants_created_at = runtimeEntityType.AddIndex(
|
||||
new[] { createdAt },
|
||||
name: "idx_tenants_created_at");
|
||||
|
||||
var idx_tenants_status = runtimeEntityType.AddIndex(
|
||||
new[] { status },
|
||||
name: "idx_tenants_status");
|
||||
|
||||
var tenants_tenant_id_key = runtimeEntityType.AddIndex(
|
||||
new[] { tenantId },
|
||||
name: "tenants_tenant_id_key",
|
||||
unique: true);
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
|
||||
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "tenants");
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
|
||||
|
||||
Customize(runtimeEntityType);
|
||||
}
|
||||
|
||||
static partial void Customize(RuntimeEntityType runtimeEntityType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
|
||||
{
|
||||
[EntityFrameworkInternal]
|
||||
public partial class TokenEfEntityEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.Authority.Persistence.EfCore.Models.TokenEfEntity",
|
||||
typeof(TokenEfEntity),
|
||||
baseEntityType,
|
||||
propertyCount: 12,
|
||||
namedIndexCount: 4,
|
||||
keyCount: 2);
|
||||
|
||||
var id = runtimeEntityType.AddProperty(
|
||||
"Id",
|
||||
typeof(Guid),
|
||||
propertyInfo: typeof(TokenEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TokenEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw,
|
||||
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
id.AddAnnotation("Relational:ColumnName", "id");
|
||||
id.AddAnnotation("Relational:DefaultValueSql", "gen_random_uuid()");
|
||||
|
||||
var clientId = runtimeEntityType.AddProperty(
|
||||
"ClientId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(TokenEfEntity).GetProperty("ClientId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TokenEfEntity).GetField("<ClientId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
clientId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
clientId.AddAnnotation("Relational:ColumnName", "client_id");
|
||||
|
||||
var expiresAt = runtimeEntityType.AddProperty(
|
||||
"ExpiresAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(TokenEfEntity).GetProperty("ExpiresAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TokenEfEntity).GetField("<ExpiresAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
|
||||
expiresAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
expiresAt.AddAnnotation("Relational:ColumnName", "expires_at");
|
||||
|
||||
var issuedAt = runtimeEntityType.AddProperty(
|
||||
"IssuedAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(TokenEfEntity).GetProperty("IssuedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TokenEfEntity).GetField("<IssuedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
|
||||
issuedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
issuedAt.AddAnnotation("Relational:ColumnName", "issued_at");
|
||||
issuedAt.AddAnnotation("Relational:DefaultValueSql", "now()");
|
||||
|
||||
var metadata = runtimeEntityType.AddProperty(
|
||||
"Metadata",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(TokenEfEntity).GetProperty("Metadata", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TokenEfEntity).GetField("<Metadata>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd);
|
||||
metadata.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
metadata.AddAnnotation("Relational:ColumnName", "metadata");
|
||||
metadata.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
metadata.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
|
||||
|
||||
var revokedAt = runtimeEntityType.AddProperty(
|
||||
"RevokedAt",
|
||||
typeof(DateTimeOffset?),
|
||||
propertyInfo: typeof(TokenEfEntity).GetProperty("RevokedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TokenEfEntity).GetField("<RevokedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
revokedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
revokedAt.AddAnnotation("Relational:ColumnName", "revoked_at");
|
||||
|
||||
var revokedBy = runtimeEntityType.AddProperty(
|
||||
"RevokedBy",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(TokenEfEntity).GetProperty("RevokedBy", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TokenEfEntity).GetField("<RevokedBy>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
revokedBy.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
revokedBy.AddAnnotation("Relational:ColumnName", "revoked_by");
|
||||
|
||||
var scopes = runtimeEntityType.AddProperty(
|
||||
"Scopes",
|
||||
typeof(string[]),
|
||||
propertyInfo: typeof(TokenEfEntity).GetProperty("Scopes", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TokenEfEntity).GetField("<Scopes>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd);
|
||||
var scopesElementType = scopes.SetElementType(typeof(string));
|
||||
scopes.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
scopes.AddAnnotation("Relational:ColumnName", "scopes");
|
||||
scopes.AddAnnotation("Relational:DefaultValueSql", "'{}'::text[]");
|
||||
|
||||
var tenantId = runtimeEntityType.AddProperty(
|
||||
"TenantId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(TokenEfEntity).GetProperty("TenantId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TokenEfEntity).GetField("<TenantId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
tenantId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
|
||||
|
||||
var tokenHash = runtimeEntityType.AddProperty(
|
||||
"TokenHash",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(TokenEfEntity).GetProperty("TokenHash", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TokenEfEntity).GetField("<TokenHash>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw);
|
||||
tokenHash.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
tokenHash.AddAnnotation("Relational:ColumnName", "token_hash");
|
||||
|
||||
var tokenType = runtimeEntityType.AddProperty(
|
||||
"TokenType",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(TokenEfEntity).GetProperty("TokenType", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TokenEfEntity).GetField("<TokenType>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd);
|
||||
tokenType.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
tokenType.AddAnnotation("Relational:ColumnName", "token_type");
|
||||
tokenType.AddAnnotation("Relational:DefaultValueSql", "'access'");
|
||||
|
||||
var userId = runtimeEntityType.AddProperty(
|
||||
"UserId",
|
||||
typeof(Guid?),
|
||||
propertyInfo: typeof(TokenEfEntity).GetProperty("UserId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TokenEfEntity).GetField("<UserId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
userId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
userId.AddAnnotation("Relational:ColumnName", "user_id");
|
||||
|
||||
var key = runtimeEntityType.AddKey(
|
||||
new[] { id });
|
||||
runtimeEntityType.SetPrimaryKey(key);
|
||||
key.AddAnnotation("Relational:Name", "tokens_pkey");
|
||||
|
||||
var key0 = runtimeEntityType.AddKey(
|
||||
new[] { tokenHash });
|
||||
key0.AddAnnotation("Relational:Name", "tokens_token_hash_key");
|
||||
|
||||
var idx_tokens_expires_at = runtimeEntityType.AddIndex(
|
||||
new[] { expiresAt },
|
||||
name: "idx_tokens_expires_at");
|
||||
|
||||
var idx_tokens_tenant_id = runtimeEntityType.AddIndex(
|
||||
new[] { tenantId },
|
||||
name: "idx_tokens_tenant_id");
|
||||
|
||||
var idx_tokens_token_hash = runtimeEntityType.AddIndex(
|
||||
new[] { tokenHash },
|
||||
name: "idx_tokens_token_hash");
|
||||
|
||||
var idx_tokens_user_id = runtimeEntityType.AddIndex(
|
||||
new[] { userId },
|
||||
name: "idx_tokens_user_id");
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
|
||||
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "tokens");
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
|
||||
|
||||
Customize(runtimeEntityType);
|
||||
}
|
||||
|
||||
static partial void Customize(RuntimeEntityType runtimeEntityType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
|
||||
{
|
||||
[EntityFrameworkInternal]
|
||||
public partial class UserEfEntityEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.Authority.Persistence.EfCore.Models.UserEfEntity",
|
||||
typeof(UserEfEntity),
|
||||
baseEntityType,
|
||||
propertyCount: 26,
|
||||
namedIndexCount: 5,
|
||||
keyCount: 1);
|
||||
|
||||
var id = runtimeEntityType.AddProperty(
|
||||
"Id",
|
||||
typeof(Guid),
|
||||
propertyInfo: typeof(UserEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(UserEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw,
|
||||
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
id.AddAnnotation("Relational:ColumnName", "id");
|
||||
id.AddAnnotation("Relational:DefaultValueSql", "gen_random_uuid()");
|
||||
|
||||
var createdAt = runtimeEntityType.AddProperty(
|
||||
"CreatedAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(UserEfEntity).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(UserEfEntity).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
|
||||
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
|
||||
createdAt.AddAnnotation("Relational:DefaultValueSql", "now()");
|
||||
|
||||
var createdBy = runtimeEntityType.AddProperty(
|
||||
"CreatedBy",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(UserEfEntity).GetProperty("CreatedBy", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(UserEfEntity).GetField("<CreatedBy>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
createdBy.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
createdBy.AddAnnotation("Relational:ColumnName", "created_by");
|
||||
|
||||
var displayName = runtimeEntityType.AddProperty(
|
||||
"DisplayName",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(UserEfEntity).GetProperty("DisplayName", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(UserEfEntity).GetField("<DisplayName>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
displayName.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
displayName.AddAnnotation("Relational:ColumnName", "display_name");
|
||||
|
||||
var email = runtimeEntityType.AddProperty(
|
||||
"Email",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(UserEfEntity).GetProperty("Email", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(UserEfEntity).GetField("<Email>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
email.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
email.AddAnnotation("Relational:ColumnName", "email");
|
||||
|
||||
var emailVerified = runtimeEntityType.AddProperty(
|
||||
"EmailVerified",
|
||||
typeof(bool),
|
||||
propertyInfo: typeof(UserEfEntity).GetProperty("EmailVerified", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(UserEfEntity).GetField("<EmailVerified>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: false);
|
||||
emailVerified.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
emailVerified.AddAnnotation("Relational:ColumnName", "email_verified");
|
||||
emailVerified.AddAnnotation("Relational:DefaultValue", false);
|
||||
|
||||
var enabled = runtimeEntityType.AddProperty(
|
||||
"Enabled",
|
||||
typeof(bool),
|
||||
propertyInfo: typeof(UserEfEntity).GetProperty("Enabled", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(UserEfEntity).GetField("<Enabled>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: true);
|
||||
enabled.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
enabled.AddAnnotation("Relational:ColumnName", "enabled");
|
||||
enabled.AddAnnotation("Relational:DefaultValue", true);
|
||||
|
||||
var failedLoginAttempts = runtimeEntityType.AddProperty(
|
||||
"FailedLoginAttempts",
|
||||
typeof(int),
|
||||
propertyInfo: typeof(UserEfEntity).GetProperty("FailedLoginAttempts", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(UserEfEntity).GetField("<FailedLoginAttempts>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: 0);
|
||||
failedLoginAttempts.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
failedLoginAttempts.AddAnnotation("Relational:ColumnName", "failed_login_attempts");
|
||||
failedLoginAttempts.AddAnnotation("Relational:DefaultValue", 0);
|
||||
|
||||
var lastLoginAt = runtimeEntityType.AddProperty(
|
||||
"LastLoginAt",
|
||||
typeof(DateTimeOffset?),
|
||||
propertyInfo: typeof(UserEfEntity).GetProperty("LastLoginAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(UserEfEntity).GetField("<LastLoginAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
lastLoginAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
lastLoginAt.AddAnnotation("Relational:ColumnName", "last_login_at");
|
||||
|
||||
var lastPasswordChangeAt = runtimeEntityType.AddProperty(
|
||||
"LastPasswordChangeAt",
|
||||
typeof(DateTimeOffset?),
|
||||
propertyInfo: typeof(UserEfEntity).GetProperty("LastPasswordChangeAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(UserEfEntity).GetField("<LastPasswordChangeAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
lastPasswordChangeAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
lastPasswordChangeAt.AddAnnotation("Relational:ColumnName", "last_password_change_at");
|
||||
|
||||
var lockedUntil = runtimeEntityType.AddProperty(
|
||||
"LockedUntil",
|
||||
typeof(DateTimeOffset?),
|
||||
propertyInfo: typeof(UserEfEntity).GetProperty("LockedUntil", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(UserEfEntity).GetField("<LockedUntil>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
lockedUntil.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
lockedUntil.AddAnnotation("Relational:ColumnName", "locked_until");
|
||||
|
||||
var metadata = runtimeEntityType.AddProperty(
|
||||
"Metadata",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(UserEfEntity).GetProperty("Metadata", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(UserEfEntity).GetField("<Metadata>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd);
|
||||
metadata.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
metadata.AddAnnotation("Relational:ColumnName", "metadata");
|
||||
metadata.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
metadata.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
|
||||
|
||||
var mfaBackupCodes = runtimeEntityType.AddProperty(
|
||||
"MfaBackupCodes",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(UserEfEntity).GetProperty("MfaBackupCodes", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(UserEfEntity).GetField("<MfaBackupCodes>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
mfaBackupCodes.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
mfaBackupCodes.AddAnnotation("Relational:ColumnName", "mfa_backup_codes");
|
||||
|
||||
var mfaEnabled = runtimeEntityType.AddProperty(
|
||||
"MfaEnabled",
|
||||
typeof(bool),
|
||||
propertyInfo: typeof(UserEfEntity).GetProperty("MfaEnabled", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(UserEfEntity).GetField("<MfaEnabled>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: false);
|
||||
mfaEnabled.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
mfaEnabled.AddAnnotation("Relational:ColumnName", "mfa_enabled");
|
||||
mfaEnabled.AddAnnotation("Relational:DefaultValue", false);
|
||||
|
||||
var mfaSecret = runtimeEntityType.AddProperty(
|
||||
"MfaSecret",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(UserEfEntity).GetProperty("MfaSecret", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(UserEfEntity).GetField("<MfaSecret>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
mfaSecret.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
mfaSecret.AddAnnotation("Relational:ColumnName", "mfa_secret");
|
||||
|
||||
var passwordAlgorithm = runtimeEntityType.AddProperty(
|
||||
"PasswordAlgorithm",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(UserEfEntity).GetProperty("PasswordAlgorithm", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(UserEfEntity).GetField("<PasswordAlgorithm>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true,
|
||||
valueGenerated: ValueGenerated.OnAdd);
|
||||
passwordAlgorithm.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
passwordAlgorithm.AddAnnotation("Relational:ColumnName", "password_algorithm");
|
||||
passwordAlgorithm.AddAnnotation("Relational:DefaultValueSql", "'argon2id'");
|
||||
|
||||
var passwordChangedAt = runtimeEntityType.AddProperty(
|
||||
"PasswordChangedAt",
|
||||
typeof(DateTimeOffset?),
|
||||
propertyInfo: typeof(UserEfEntity).GetProperty("PasswordChangedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(UserEfEntity).GetField("<PasswordChangedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
passwordChangedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
passwordChangedAt.AddAnnotation("Relational:ColumnName", "password_changed_at");
|
||||
|
||||
var passwordExpiresAt = runtimeEntityType.AddProperty(
|
||||
"PasswordExpiresAt",
|
||||
typeof(DateTimeOffset?),
|
||||
propertyInfo: typeof(UserEfEntity).GetProperty("PasswordExpiresAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(UserEfEntity).GetField("<PasswordExpiresAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
passwordExpiresAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
passwordExpiresAt.AddAnnotation("Relational:ColumnName", "password_expires_at");
|
||||
|
||||
var passwordHash = runtimeEntityType.AddProperty(
|
||||
"PasswordHash",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(UserEfEntity).GetProperty("PasswordHash", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(UserEfEntity).GetField("<PasswordHash>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
passwordHash.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
passwordHash.AddAnnotation("Relational:ColumnName", "password_hash");
|
||||
|
||||
var passwordSalt = runtimeEntityType.AddProperty(
|
||||
"PasswordSalt",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(UserEfEntity).GetProperty("PasswordSalt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(UserEfEntity).GetField("<PasswordSalt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
passwordSalt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
passwordSalt.AddAnnotation("Relational:ColumnName", "password_salt");
|
||||
|
||||
var settings = runtimeEntityType.AddProperty(
|
||||
"Settings",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(UserEfEntity).GetProperty("Settings", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(UserEfEntity).GetField("<Settings>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd);
|
||||
settings.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
settings.AddAnnotation("Relational:ColumnName", "settings");
|
||||
settings.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
settings.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
|
||||
|
||||
var status = runtimeEntityType.AddProperty(
|
||||
"Status",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(UserEfEntity).GetProperty("Status", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(UserEfEntity).GetField("<Status>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd);
|
||||
status.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
status.AddAnnotation("Relational:ColumnName", "status");
|
||||
status.AddAnnotation("Relational:DefaultValueSql", "'active'");
|
||||
|
||||
var tenantId = runtimeEntityType.AddProperty(
|
||||
"TenantId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(UserEfEntity).GetProperty("TenantId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(UserEfEntity).GetField("<TenantId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
tenantId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
|
||||
|
||||
var updatedAt = runtimeEntityType.AddProperty(
|
||||
"UpdatedAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(UserEfEntity).GetProperty("UpdatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(UserEfEntity).GetField("<UpdatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
|
||||
updatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
updatedAt.AddAnnotation("Relational:ColumnName", "updated_at");
|
||||
updatedAt.AddAnnotation("Relational:DefaultValueSql", "now()");
|
||||
|
||||
var updatedBy = runtimeEntityType.AddProperty(
|
||||
"UpdatedBy",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(UserEfEntity).GetProperty("UpdatedBy", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(UserEfEntity).GetField("<UpdatedBy>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
updatedBy.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
updatedBy.AddAnnotation("Relational:ColumnName", "updated_by");
|
||||
|
||||
var username = runtimeEntityType.AddProperty(
|
||||
"Username",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(UserEfEntity).GetProperty("Username", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(UserEfEntity).GetField("<Username>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
username.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
username.AddAnnotation("Relational:ColumnName", "username");
|
||||
|
||||
var key = runtimeEntityType.AddKey(
|
||||
new[] { id });
|
||||
runtimeEntityType.SetPrimaryKey(key);
|
||||
key.AddAnnotation("Relational:Name", "users_pkey");
|
||||
|
||||
var idx_users_email = runtimeEntityType.AddIndex(
|
||||
new[] { tenantId, email },
|
||||
name: "idx_users_email");
|
||||
|
||||
var idx_users_status = runtimeEntityType.AddIndex(
|
||||
new[] { tenantId, status },
|
||||
name: "idx_users_status");
|
||||
|
||||
var idx_users_tenant_id = runtimeEntityType.AddIndex(
|
||||
new[] { tenantId },
|
||||
name: "idx_users_tenant_id");
|
||||
|
||||
var users_tenant_id_email_key = runtimeEntityType.AddIndex(
|
||||
new[] { tenantId, email },
|
||||
name: "users_tenant_id_email_key",
|
||||
unique: true);
|
||||
|
||||
var users_tenant_id_username_key = runtimeEntityType.AddIndex(
|
||||
new[] { tenantId, username },
|
||||
name: "users_tenant_id_username_key",
|
||||
unique: true);
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
|
||||
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "users");
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
|
||||
|
||||
Customize(runtimeEntityType);
|
||||
}
|
||||
|
||||
static partial void Customize(RuntimeEntityType runtimeEntityType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
|
||||
{
|
||||
[EntityFrameworkInternal]
|
||||
public partial class UserRoleEfEntityEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.Authority.Persistence.EfCore.Models.UserRoleEfEntity",
|
||||
typeof(UserRoleEfEntity),
|
||||
baseEntityType,
|
||||
propertyCount: 5,
|
||||
keyCount: 1);
|
||||
|
||||
var userId = runtimeEntityType.AddProperty(
|
||||
"UserId",
|
||||
typeof(Guid),
|
||||
propertyInfo: typeof(UserRoleEfEntity).GetProperty("UserId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(UserRoleEfEntity).GetField("<UserId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw,
|
||||
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||
userId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
userId.AddAnnotation("Relational:ColumnName", "user_id");
|
||||
|
||||
var roleId = runtimeEntityType.AddProperty(
|
||||
"RoleId",
|
||||
typeof(Guid),
|
||||
propertyInfo: typeof(UserRoleEfEntity).GetProperty("RoleId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(UserRoleEfEntity).GetField("<RoleId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw,
|
||||
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||
roleId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
roleId.AddAnnotation("Relational:ColumnName", "role_id");
|
||||
|
||||
var expiresAt = runtimeEntityType.AddProperty(
|
||||
"ExpiresAt",
|
||||
typeof(DateTimeOffset?),
|
||||
propertyInfo: typeof(UserRoleEfEntity).GetProperty("ExpiresAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(UserRoleEfEntity).GetField("<ExpiresAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
expiresAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
expiresAt.AddAnnotation("Relational:ColumnName", "expires_at");
|
||||
|
||||
var grantedAt = runtimeEntityType.AddProperty(
|
||||
"GrantedAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(UserRoleEfEntity).GetProperty("GrantedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(UserRoleEfEntity).GetField("<GrantedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
|
||||
grantedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
grantedAt.AddAnnotation("Relational:ColumnName", "granted_at");
|
||||
grantedAt.AddAnnotation("Relational:DefaultValueSql", "now()");
|
||||
|
||||
var grantedBy = runtimeEntityType.AddProperty(
|
||||
"GrantedBy",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(UserRoleEfEntity).GetProperty("GrantedBy", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(UserRoleEfEntity).GetField("<GrantedBy>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
grantedBy.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
grantedBy.AddAnnotation("Relational:ColumnName", "granted_by");
|
||||
|
||||
var key = runtimeEntityType.AddKey(
|
||||
new[] { userId, roleId });
|
||||
runtimeEntityType.SetPrimaryKey(key);
|
||||
key.AddAnnotation("Relational:Name", "user_roles_pkey");
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
|
||||
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "user_roles");
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
|
||||
|
||||
Customize(runtimeEntityType);
|
||||
}
|
||||
|
||||
static partial void Customize(RuntimeEntityType runtimeEntityType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Authority.Persistence.EfCore.CompiledModels
|
||||
{
|
||||
[EntityFrameworkInternal]
|
||||
public partial class VerdictManifestEfEntityEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.Authority.Persistence.EfCore.Models.VerdictManifestEfEntity",
|
||||
typeof(VerdictManifestEfEntity),
|
||||
baseEntityType,
|
||||
propertyCount: 16,
|
||||
namedIndexCount: 5,
|
||||
keyCount: 1);
|
||||
|
||||
var id = runtimeEntityType.AddProperty(
|
||||
"Id",
|
||||
typeof(Guid),
|
||||
propertyInfo: typeof(VerdictManifestEfEntity).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(VerdictManifestEfEntity).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw,
|
||||
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
id.AddAnnotation("Relational:ColumnName", "id");
|
||||
id.AddAnnotation("Relational:DefaultValueSql", "gen_random_uuid()");
|
||||
|
||||
var assetDigest = runtimeEntityType.AddProperty(
|
||||
"AssetDigest",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(VerdictManifestEfEntity).GetProperty("AssetDigest", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(VerdictManifestEfEntity).GetField("<AssetDigest>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
assetDigest.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
assetDigest.AddAnnotation("Relational:ColumnName", "asset_digest");
|
||||
|
||||
var confidence = runtimeEntityType.AddProperty(
|
||||
"Confidence",
|
||||
typeof(double),
|
||||
propertyInfo: typeof(VerdictManifestEfEntity).GetProperty("Confidence", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(VerdictManifestEfEntity).GetField("<Confidence>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
sentinel: 0.0);
|
||||
confidence.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
confidence.AddAnnotation("Relational:ColumnName", "confidence");
|
||||
|
||||
var createdAt = runtimeEntityType.AddProperty(
|
||||
"CreatedAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(VerdictManifestEfEntity).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(VerdictManifestEfEntity).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
|
||||
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
|
||||
createdAt.AddAnnotation("Relational:DefaultValueSql", "now()");
|
||||
|
||||
var evaluatedAt = runtimeEntityType.AddProperty(
|
||||
"EvaluatedAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(VerdictManifestEfEntity).GetProperty("EvaluatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(VerdictManifestEfEntity).GetField("<EvaluatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
sentinel: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
|
||||
evaluatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
evaluatedAt.AddAnnotation("Relational:ColumnName", "evaluated_at");
|
||||
|
||||
var inputsJson = runtimeEntityType.AddProperty(
|
||||
"InputsJson",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(VerdictManifestEfEntity).GetProperty("InputsJson", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(VerdictManifestEfEntity).GetField("<InputsJson>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
inputsJson.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
inputsJson.AddAnnotation("Relational:ColumnName", "inputs_json");
|
||||
inputsJson.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
|
||||
var latticeVersion = runtimeEntityType.AddProperty(
|
||||
"LatticeVersion",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(VerdictManifestEfEntity).GetProperty("LatticeVersion", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(VerdictManifestEfEntity).GetField("<LatticeVersion>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
latticeVersion.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
latticeVersion.AddAnnotation("Relational:ColumnName", "lattice_version");
|
||||
|
||||
var manifestDigest = runtimeEntityType.AddProperty(
|
||||
"ManifestDigest",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(VerdictManifestEfEntity).GetProperty("ManifestDigest", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(VerdictManifestEfEntity).GetField("<ManifestDigest>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
manifestDigest.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
manifestDigest.AddAnnotation("Relational:ColumnName", "manifest_digest");
|
||||
|
||||
var manifestId = runtimeEntityType.AddProperty(
|
||||
"ManifestId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(VerdictManifestEfEntity).GetProperty("ManifestId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(VerdictManifestEfEntity).GetField("<ManifestId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
manifestId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
manifestId.AddAnnotation("Relational:ColumnName", "manifest_id");
|
||||
|
||||
var policyHash = runtimeEntityType.AddProperty(
|
||||
"PolicyHash",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(VerdictManifestEfEntity).GetProperty("PolicyHash", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(VerdictManifestEfEntity).GetField("<PolicyHash>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
policyHash.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
policyHash.AddAnnotation("Relational:ColumnName", "policy_hash");
|
||||
|
||||
var rekorLogId = runtimeEntityType.AddProperty(
|
||||
"RekorLogId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(VerdictManifestEfEntity).GetProperty("RekorLogId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(VerdictManifestEfEntity).GetField("<RekorLogId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
rekorLogId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
rekorLogId.AddAnnotation("Relational:ColumnName", "rekor_log_id");
|
||||
|
||||
var resultJson = runtimeEntityType.AddProperty(
|
||||
"ResultJson",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(VerdictManifestEfEntity).GetProperty("ResultJson", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(VerdictManifestEfEntity).GetField("<ResultJson>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
resultJson.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
resultJson.AddAnnotation("Relational:ColumnName", "result_json");
|
||||
resultJson.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
|
||||
var signatureBase64 = runtimeEntityType.AddProperty(
|
||||
"SignatureBase64",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(VerdictManifestEfEntity).GetProperty("SignatureBase64", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(VerdictManifestEfEntity).GetField("<SignatureBase64>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
signatureBase64.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
signatureBase64.AddAnnotation("Relational:ColumnName", "signature_base64");
|
||||
|
||||
var status = runtimeEntityType.AddProperty(
|
||||
"Status",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(VerdictManifestEfEntity).GetProperty("Status", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(VerdictManifestEfEntity).GetField("<Status>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
status.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
status.AddAnnotation("Relational:ColumnName", "status");
|
||||
|
||||
var tenant = runtimeEntityType.AddProperty(
|
||||
"Tenant",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(VerdictManifestEfEntity).GetProperty("Tenant", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(VerdictManifestEfEntity).GetField("<Tenant>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
tenant.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
tenant.AddAnnotation("Relational:ColumnName", "tenant");
|
||||
|
||||
var vulnerabilityId = runtimeEntityType.AddProperty(
|
||||
"VulnerabilityId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(VerdictManifestEfEntity).GetProperty("VulnerabilityId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(VerdictManifestEfEntity).GetField("<VulnerabilityId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
vulnerabilityId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
vulnerabilityId.AddAnnotation("Relational:ColumnName", "vulnerability_id");
|
||||
|
||||
var key = runtimeEntityType.AddKey(
|
||||
new[] { id });
|
||||
runtimeEntityType.SetPrimaryKey(key);
|
||||
key.AddAnnotation("Relational:Name", "verdict_manifests_pkey");
|
||||
|
||||
var idx_verdict_asset_vuln = runtimeEntityType.AddIndex(
|
||||
new[] { tenant, assetDigest, vulnerabilityId },
|
||||
name: "idx_verdict_asset_vuln");
|
||||
|
||||
var idx_verdict_digest = runtimeEntityType.AddIndex(
|
||||
new[] { manifestDigest },
|
||||
name: "idx_verdict_digest");
|
||||
|
||||
var idx_verdict_policy = runtimeEntityType.AddIndex(
|
||||
new[] { tenant, policyHash, latticeVersion },
|
||||
name: "idx_verdict_policy");
|
||||
|
||||
var idx_verdict_replay = runtimeEntityType.AddIndex(
|
||||
new[] { tenant, assetDigest, vulnerabilityId, policyHash, latticeVersion },
|
||||
name: "idx_verdict_replay",
|
||||
unique: true);
|
||||
|
||||
var uq_verdict_manifest_id = runtimeEntityType.AddIndex(
|
||||
new[] { tenant, manifestId },
|
||||
name: "uq_verdict_manifest_id",
|
||||
unique: true);
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "authority");
|
||||
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "verdict_manifests");
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
|
||||
|
||||
Customize(runtimeEntityType);
|
||||
}
|
||||
|
||||
static partial void Customize(RuntimeEntityType runtimeEntityType);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using StellaOps.Authority.Persistence.EfCore.CompiledModels;
|
||||
using StellaOps.Authority.Persistence.EfCore.Context;
|
||||
|
||||
namespace StellaOps.Authority.Persistence.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime factory for creating <see cref="AuthorityDbContext"/> instances.
|
||||
/// Always uses reflection-based model building from <see cref="AuthorityDbContext.OnModelCreating"/>.
|
||||
/// When a real compiled model is generated via <c>dotnet ef dbcontext optimize</c>,
|
||||
/// re-enable UseModel() here.
|
||||
/// Uses the static compiled model for the default schema and falls back to
|
||||
/// reflection-based model building for non-default schemas (integration tests).
|
||||
/// </summary>
|
||||
internal static class AuthorityDbContextFactory
|
||||
{
|
||||
@@ -22,6 +22,11 @@ internal static class AuthorityDbContextFactory
|
||||
var optionsBuilder = new DbContextOptionsBuilder<AuthorityDbContext>()
|
||||
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
|
||||
|
||||
if (string.Equals(normalizedSchema, AuthorityDataSource.DefaultSchemaName, StringComparison.Ordinal))
|
||||
{
|
||||
optionsBuilder.UseModel(AuthorityDbContextModel.Instance);
|
||||
}
|
||||
|
||||
return new AuthorityDbContext(optionsBuilder.Options, normalizedSchema);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,13 @@ public sealed class ApiKeyRepository : IApiKeyRepository
|
||||
|
||||
private readonly AuthorityDataSource _dataSource;
|
||||
private readonly ILogger<ApiKeyRepository> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ApiKeyRepository(AuthorityDataSource dataSource, ILogger<ApiKeyRepository> logger)
|
||||
public ApiKeyRepository(AuthorityDataSource dataSource, ILogger<ApiKeyRepository> logger, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<ApiKeyEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
@@ -107,9 +109,10 @@ public sealed class ApiKeyRepository : IApiKeyRepository
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"UPDATE authority.api_keys SET last_used_at = NOW() WHERE tenant_id = {0} AND id = {1}",
|
||||
[tenantId, id],
|
||||
"UPDATE authority.api_keys SET last_used_at = {0} WHERE tenant_id = {1} AND id = {2}",
|
||||
[now, tenantId, id],
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -118,12 +121,13 @@ public sealed class ApiKeyRepository : IApiKeyRepository
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
UPDATE authority.api_keys SET status = 'revoked', revoked_at = NOW(), revoked_by = {0}
|
||||
WHERE tenant_id = {1} AND id = {2} AND status = 'active'
|
||||
UPDATE authority.api_keys SET status = 'revoked', revoked_at = {0}, revoked_by = {1}
|
||||
WHERE tenant_id = {2} AND id = {3} AND status = 'active'
|
||||
""",
|
||||
[revokedBy, tenantId, id],
|
||||
[now, revokedBy, tenantId, id],
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,11 +16,13 @@ public sealed class OidcTokenRepository : IOidcTokenRepository
|
||||
|
||||
private readonly AuthorityDataSource _dataSource;
|
||||
private readonly ILogger<OidcTokenRepository> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public OidcTokenRepository(AuthorityDataSource dataSource, ILogger<OidcTokenRepository> logger)
|
||||
public OidcTokenRepository(AuthorityDataSource dataSource, ILogger<OidcTokenRepository> logger, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<OidcTokenEntity?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken = default)
|
||||
@@ -334,14 +336,15 @@ public sealed class OidcTokenRepository : IOidcTokenRepository
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
// Use raw SQL for NOW() to preserve DB clock semantics.
|
||||
// Use app-side timestamp via TimeProvider for consumed_at.
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var rows = await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
UPDATE authority.oidc_refresh_tokens
|
||||
SET consumed_at = NOW()
|
||||
WHERE token_id = {0} AND consumed_at IS NULL
|
||||
SET consumed_at = {0}
|
||||
WHERE token_id = {1} AND consumed_at IS NULL
|
||||
""",
|
||||
tokenId,
|
||||
now, tokenId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
|
||||
@@ -14,11 +14,13 @@ public sealed class PermissionRepository : IPermissionRepository
|
||||
|
||||
private readonly AuthorityDataSource _dataSource;
|
||||
private readonly ILogger<PermissionRepository> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PermissionRepository(AuthorityDataSource dataSource, ILogger<PermissionRepository> logger)
|
||||
public PermissionRepository(AuthorityDataSource dataSource, ILogger<PermissionRepository> logger, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<PermissionEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
@@ -106,7 +108,8 @@ public sealed class PermissionRepository : IPermissionRepository
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
// Use raw SQL for multi-JOIN with NOW() filtering to preserve exact SQL semantics.
|
||||
// Use app-side timestamp via TimeProvider for consistent clock behavior in role expiry check.
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var entities = await dbContext.Permissions
|
||||
.FromSqlRaw(
|
||||
"""
|
||||
@@ -115,10 +118,10 @@ public sealed class PermissionRepository : IPermissionRepository
|
||||
INNER JOIN authority.role_permissions rp ON p.id = rp.permission_id
|
||||
INNER JOIN authority.user_roles ur ON rp.role_id = ur.role_id
|
||||
WHERE p.tenant_id = {0} AND ur.user_id = {1}
|
||||
AND (ur.expires_at IS NULL OR ur.expires_at > NOW())
|
||||
AND (ur.expires_at IS NULL OR ur.expires_at > {2})
|
||||
ORDER BY p.resource, p.action
|
||||
""",
|
||||
tenantId, userId)
|
||||
tenantId, userId, now)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
@@ -14,11 +14,13 @@ public sealed class RoleRepository : IRoleRepository
|
||||
|
||||
private readonly AuthorityDataSource _dataSource;
|
||||
private readonly ILogger<RoleRepository> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public RoleRepository(AuthorityDataSource dataSource, ILogger<RoleRepository> logger)
|
||||
public RoleRepository(AuthorityDataSource dataSource, ILogger<RoleRepository> logger, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<RoleEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
@@ -67,7 +69,8 @@ public sealed class RoleRepository : IRoleRepository
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
// Use raw SQL for the JOIN + NOW() comparison to preserve exact SQL semantics.
|
||||
// Use app-side timestamp via TimeProvider for consistent clock behavior in role expiry check.
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var entities = await dbContext.Roles
|
||||
.FromSqlRaw(
|
||||
"""
|
||||
@@ -75,10 +78,10 @@ public sealed class RoleRepository : IRoleRepository
|
||||
FROM authority.roles r
|
||||
INNER JOIN authority.user_roles ur ON r.id = ur.role_id
|
||||
WHERE r.tenant_id = {0} AND ur.user_id = {1}
|
||||
AND (ur.expires_at IS NULL OR ur.expires_at > NOW())
|
||||
AND (ur.expires_at IS NULL OR ur.expires_at > {2})
|
||||
ORDER BY r.name
|
||||
""",
|
||||
tenantId, userId)
|
||||
tenantId, userId, now)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
@@ -144,17 +147,19 @@ public sealed class RoleRepository : IRoleRepository
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
// Use raw SQL for ON CONFLICT DO UPDATE with NOW() to preserve exact SQL behavior.
|
||||
// Use app-side timestamp via TimeProvider for granted_at on conflict.
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
INSERT INTO authority.user_roles (user_id, role_id, granted_by, expires_at)
|
||||
VALUES ({0}, {1}, {2}, {3})
|
||||
ON CONFLICT (user_id, role_id) DO UPDATE SET
|
||||
granted_at = NOW(), granted_by = EXCLUDED.granted_by, expires_at = EXCLUDED.expires_at
|
||||
granted_at = {4}, granted_by = EXCLUDED.granted_by, expires_at = EXCLUDED.expires_at
|
||||
""",
|
||||
userId, roleId,
|
||||
(object?)grantedBy ?? DBNull.Value,
|
||||
(object?)expiresAt ?? DBNull.Value,
|
||||
now,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,13 @@ public sealed class SessionRepository : ISessionRepository
|
||||
|
||||
private readonly AuthorityDataSource _dataSource;
|
||||
private readonly ILogger<SessionRepository> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public SessionRepository(AuthorityDataSource dataSource, ILogger<SessionRepository> logger)
|
||||
public SessionRepository(AuthorityDataSource dataSource, ILogger<SessionRepository> logger, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<SessionEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
@@ -39,13 +41,14 @@ public sealed class SessionRepository : ISessionRepository
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var entities = await dbContext.Sessions
|
||||
.FromSqlRaw(
|
||||
"""
|
||||
SELECT * FROM authority.sessions
|
||||
WHERE session_token_hash = {0} AND ended_at IS NULL AND expires_at > NOW()
|
||||
WHERE session_token_hash = {0} AND ended_at IS NULL AND expires_at > {1}
|
||||
""",
|
||||
sessionTokenHash)
|
||||
sessionTokenHash, now)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
@@ -61,15 +64,16 @@ public sealed class SessionRepository : ISessionRepository
|
||||
|
||||
if (activeOnly)
|
||||
{
|
||||
// Use raw SQL for NOW() comparison consistency.
|
||||
// Use app-side timestamp via TimeProvider for consistent clock behavior.
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var entities = await dbContext.Sessions
|
||||
.FromSqlRaw(
|
||||
"""
|
||||
SELECT * FROM authority.sessions
|
||||
WHERE tenant_id = {0} AND user_id = {1} AND ended_at IS NULL AND expires_at > NOW()
|
||||
WHERE tenant_id = {0} AND user_id = {1} AND ended_at IS NULL AND expires_at > {2}
|
||||
ORDER BY started_at DESC
|
||||
""",
|
||||
tenantId, userId)
|
||||
tenantId, userId, now)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
@@ -117,9 +121,10 @@ public sealed class SessionRepository : ISessionRepository
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"UPDATE authority.sessions SET last_activity_at = NOW() WHERE tenant_id = {0} AND id = {1} AND ended_at IS NULL",
|
||||
[tenantId, id],
|
||||
"UPDATE authority.sessions SET last_activity_at = {0} WHERE tenant_id = {1} AND id = {2} AND ended_at IS NULL",
|
||||
[now, tenantId, id],
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -128,12 +133,13 @@ public sealed class SessionRepository : ISessionRepository
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
UPDATE authority.sessions SET ended_at = NOW(), end_reason = {0}
|
||||
WHERE tenant_id = {1} AND id = {2} AND ended_at IS NULL
|
||||
UPDATE authority.sessions SET ended_at = {0}, end_reason = {1}
|
||||
WHERE tenant_id = {2} AND id = {3} AND ended_at IS NULL
|
||||
""",
|
||||
[reason, tenantId, id],
|
||||
[now, reason, tenantId, id],
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -142,12 +148,13 @@ public sealed class SessionRepository : ISessionRepository
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
UPDATE authority.sessions SET ended_at = NOW(), end_reason = {0}
|
||||
WHERE tenant_id = {1} AND user_id = {2} AND ended_at IS NULL
|
||||
UPDATE authority.sessions SET ended_at = {0}, end_reason = {1}
|
||||
WHERE tenant_id = {2} AND user_id = {3} AND ended_at IS NULL
|
||||
""",
|
||||
[reason, tenantId, userId],
|
||||
[now, reason, tenantId, userId],
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -156,9 +163,10 @@ public sealed class SessionRepository : ISessionRepository
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var cutoff = _timeProvider.GetUtcNow().AddDays(-30);
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"DELETE FROM authority.sessions WHERE expires_at < NOW() - INTERVAL '30 days'",
|
||||
[],
|
||||
"DELETE FROM authority.sessions WHERE expires_at < {0}",
|
||||
[cutoff],
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,13 @@ public sealed class TokenRepository : ITokenRepository
|
||||
|
||||
private readonly AuthorityDataSource _dataSource;
|
||||
private readonly ILogger<TokenRepository> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public TokenRepository(AuthorityDataSource dataSource, ILogger<TokenRepository> logger)
|
||||
public TokenRepository(AuthorityDataSource dataSource, ILogger<TokenRepository> logger, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<TokenEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
@@ -39,14 +41,15 @@ public sealed class TokenRepository : ITokenRepository
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
// Use raw SQL for NOW() comparison to preserve DB clock semantics.
|
||||
// Use app-side timestamp via TimeProvider for consistent clock behavior.
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var entities = await dbContext.Tokens
|
||||
.FromSqlRaw(
|
||||
"""
|
||||
SELECT * FROM authority.tokens
|
||||
WHERE token_hash = {0} AND revoked_at IS NULL AND expires_at > NOW()
|
||||
WHERE token_hash = {0} AND revoked_at IS NULL AND expires_at > {1}
|
||||
""",
|
||||
tokenHash)
|
||||
tokenHash, now)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
@@ -100,13 +103,14 @@ public sealed class TokenRepository : ITokenRepository
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
// Use raw SQL to preserve NOW() for revoked_at.
|
||||
// Use app-side timestamp via TimeProvider for revoked_at.
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
UPDATE authority.tokens SET revoked_at = NOW(), revoked_by = {0}
|
||||
WHERE tenant_id = {1} AND id = {2} AND revoked_at IS NULL
|
||||
UPDATE authority.tokens SET revoked_at = {0}, revoked_by = {1}
|
||||
WHERE tenant_id = {2} AND id = {3} AND revoked_at IS NULL
|
||||
""",
|
||||
[revokedBy, tenantId, id],
|
||||
[now, revokedBy, tenantId, id],
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -115,12 +119,13 @@ public sealed class TokenRepository : ITokenRepository
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
UPDATE authority.tokens SET revoked_at = NOW(), revoked_by = {0}
|
||||
WHERE tenant_id = {1} AND user_id = {2} AND revoked_at IS NULL
|
||||
UPDATE authority.tokens SET revoked_at = {0}, revoked_by = {1}
|
||||
WHERE tenant_id = {2} AND user_id = {3} AND revoked_at IS NULL
|
||||
""",
|
||||
[revokedBy, tenantId, userId],
|
||||
[now, revokedBy, tenantId, userId],
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -129,9 +134,10 @@ public sealed class TokenRepository : ITokenRepository
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var cutoff = _timeProvider.GetUtcNow().AddDays(-7);
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"DELETE FROM authority.tokens WHERE expires_at < NOW() - INTERVAL '7 days'",
|
||||
[],
|
||||
"DELETE FROM authority.tokens WHERE expires_at < {0}",
|
||||
[cutoff],
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -163,11 +169,13 @@ public sealed class RefreshTokenRepository : IRefreshTokenRepository
|
||||
|
||||
private readonly AuthorityDataSource _dataSource;
|
||||
private readonly ILogger<RefreshTokenRepository> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public RefreshTokenRepository(AuthorityDataSource dataSource, ILogger<RefreshTokenRepository> logger)
|
||||
public RefreshTokenRepository(AuthorityDataSource dataSource, ILogger<RefreshTokenRepository> logger, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<RefreshTokenEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
|
||||
@@ -188,13 +196,14 @@ public sealed class RefreshTokenRepository : IRefreshTokenRepository
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var entities = await dbContext.RefreshTokens
|
||||
.FromSqlRaw(
|
||||
"""
|
||||
SELECT * FROM authority.refresh_tokens
|
||||
WHERE token_hash = {0} AND revoked_at IS NULL AND expires_at > NOW()
|
||||
WHERE token_hash = {0} AND revoked_at IS NULL AND expires_at > {1}
|
||||
""",
|
||||
tokenHash)
|
||||
tokenHash, now)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
@@ -247,12 +256,13 @@ public sealed class RefreshTokenRepository : IRefreshTokenRepository
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
UPDATE authority.refresh_tokens SET revoked_at = NOW(), revoked_by = {0}, replaced_by = {1}
|
||||
WHERE tenant_id = {2} AND id = {3} AND revoked_at IS NULL
|
||||
UPDATE authority.refresh_tokens SET revoked_at = {0}, revoked_by = {1}, replaced_by = {2}
|
||||
WHERE tenant_id = {3} AND id = {4} AND revoked_at IS NULL
|
||||
""",
|
||||
[revokedBy, (object?)replacedBy ?? DBNull.Value, tenantId, id],
|
||||
[now, revokedBy, (object?)replacedBy ?? DBNull.Value, tenantId, id],
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -261,12 +271,13 @@ public sealed class RefreshTokenRepository : IRefreshTokenRepository
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
UPDATE authority.refresh_tokens SET revoked_at = NOW(), revoked_by = {0}
|
||||
WHERE tenant_id = {1} AND user_id = {2} AND revoked_at IS NULL
|
||||
UPDATE authority.refresh_tokens SET revoked_at = {0}, revoked_by = {1}
|
||||
WHERE tenant_id = {2} AND user_id = {3} AND revoked_at IS NULL
|
||||
""",
|
||||
[revokedBy, tenantId, userId],
|
||||
[now, revokedBy, tenantId, userId],
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -275,9 +286,10 @@ public sealed class RefreshTokenRepository : IRefreshTokenRepository
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var cutoff = _timeProvider.GetUtcNow().AddDays(-30);
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"DELETE FROM authority.refresh_tokens WHERE expires_at < NOW() - INTERVAL '30 days'",
|
||||
[],
|
||||
"DELETE FROM authority.refresh_tokens WHERE expires_at < {0}",
|
||||
[cutoff],
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,13 @@ public sealed class UserRepository : IUserRepository
|
||||
|
||||
private readonly AuthorityDataSource _dataSource;
|
||||
private readonly ILogger<UserRepository> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public UserRepository(AuthorityDataSource dataSource, ILogger<UserRepository> logger)
|
||||
public UserRepository(AuthorityDataSource dataSource, ILogger<UserRepository> logger, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<UserEntity> CreateAsync(UserEntity user, CancellationToken cancellationToken = default)
|
||||
@@ -184,14 +186,15 @@ public sealed class UserRepository : IUserRepository
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
// Use raw SQL to preserve NOW() for password_changed_at (DB-generated timestamp).
|
||||
// Use app-side timestamp via TimeProvider for password_changed_at.
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var rows = await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
UPDATE authority.users
|
||||
SET password_hash = {0}, password_salt = {1}, password_changed_at = NOW()
|
||||
WHERE tenant_id = {2} AND id = {3}
|
||||
SET password_hash = {0}, password_salt = {1}, password_changed_at = {2}
|
||||
WHERE tenant_id = {3} AND id = {4}
|
||||
""",
|
||||
[passwordHash, passwordSalt, tenantId, userId],
|
||||
[passwordHash, passwordSalt, now, tenantId, userId],
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
@@ -231,14 +234,15 @@ public sealed class UserRepository : IUserRepository
|
||||
.ConfigureAwait(false);
|
||||
await using var dbContext = AuthorityDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
// Use raw SQL to preserve NOW() for last_login_at (DB-generated timestamp).
|
||||
// Use app-side timestamp via TimeProvider for last_login_at.
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
UPDATE authority.users
|
||||
SET failed_login_attempts = 0, locked_until = NULL, last_login_at = NOW()
|
||||
WHERE tenant_id = {0} AND id = {1}
|
||||
SET failed_login_attempts = 0, locked_until = NULL, last_login_at = {0}
|
||||
WHERE tenant_id = {1} AND id = {2}
|
||||
""",
|
||||
[tenantId, userId],
|
||||
[now, tenantId, userId],
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Authority.Persistence.EfCore.CompiledModels;
|
||||
using StellaOps.Authority.Persistence.EfCore.Models;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Authority.Persistence.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Guard tests ensuring the EF Core compiled model is real (not a stub)
|
||||
/// and contains all expected entity type registrations.
|
||||
/// </summary>
|
||||
public sealed class CompiledModelGuardTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CompiledModel_Instance_IsNotNull()
|
||||
{
|
||||
AuthorityDbContextModel.Instance.Should().NotBeNull(
|
||||
"compiled model must be generated via 'dotnet ef dbcontext optimize', not a stub");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CompiledModel_HasExpectedEntityTypeCount()
|
||||
{
|
||||
var entityTypes = AuthorityDbContextModel.Instance.GetEntityTypes().ToList();
|
||||
entityTypes.Should().HaveCount(22,
|
||||
"authority compiled model must contain exactly 22 entity types (regenerate with 'dotnet ef dbcontext optimize' if count differs)");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData(typeof(TenantEfEntity))]
|
||||
[InlineData(typeof(UserEfEntity))]
|
||||
[InlineData(typeof(RoleEfEntity))]
|
||||
[InlineData(typeof(PermissionEfEntity))]
|
||||
[InlineData(typeof(RolePermissionEfEntity))]
|
||||
[InlineData(typeof(UserRoleEfEntity))]
|
||||
[InlineData(typeof(ApiKeyEfEntity))]
|
||||
[InlineData(typeof(TokenEfEntity))]
|
||||
[InlineData(typeof(RefreshTokenEfEntity))]
|
||||
[InlineData(typeof(SessionEfEntity))]
|
||||
[InlineData(typeof(AuditEfEntity))]
|
||||
[InlineData(typeof(BootstrapInviteEfEntity))]
|
||||
[InlineData(typeof(ServiceAccountEfEntity))]
|
||||
[InlineData(typeof(ClientEfEntity))]
|
||||
[InlineData(typeof(RevocationEfEntity))]
|
||||
[InlineData(typeof(LoginAttemptEfEntity))]
|
||||
[InlineData(typeof(OidcTokenEfEntity))]
|
||||
[InlineData(typeof(OidcRefreshTokenEfEntity))]
|
||||
[InlineData(typeof(AirgapAuditEfEntity))]
|
||||
[InlineData(typeof(RevocationExportStateEfEntity))]
|
||||
[InlineData(typeof(OfflineKitAuditEfEntity))]
|
||||
[InlineData(typeof(VerdictManifestEfEntity))]
|
||||
public void CompiledModel_ContainsEntityType(Type entityType)
|
||||
{
|
||||
var found = AuthorityDbContextModel.Instance.FindEntityType(entityType);
|
||||
found.Should().NotBeNull(
|
||||
$"compiled model must contain entity type '{entityType.Name}' — regenerate if missing");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CompiledModel_EntityTypes_HaveTableNames()
|
||||
{
|
||||
var entityTypes = AuthorityDbContextModel.Instance.GetEntityTypes();
|
||||
foreach (var entityType in entityTypes)
|
||||
{
|
||||
var tableName = entityType.GetTableName();
|
||||
tableName.Should().NotBeNullOrWhiteSpace(
|
||||
$"entity type '{entityType.ClrType.Name}' must have a table name configured");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,14 +13,17 @@ public sealed class CorpusSnapshotRepository : ICorpusSnapshotRepository
|
||||
{
|
||||
private readonly BinaryIndexDbContext _connectionContext;
|
||||
private readonly ILogger<CorpusSnapshotRepository> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
|
||||
public CorpusSnapshotRepository(
|
||||
BinaryIndexDbContext connectionContext,
|
||||
ILogger<CorpusSnapshotRepository> logger)
|
||||
ILogger<CorpusSnapshotRepository> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_connectionContext = connectionContext;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<CorpusSnapshot> CreateAsync(CorpusSnapshot snapshot, CancellationToken ct = default)
|
||||
@@ -31,6 +34,7 @@ public sealed class CorpusSnapshotRepository : ICorpusSnapshotRepository
|
||||
var snapshotIdValue = $"{snapshot.Distro}_{snapshot.Release}_{snapshot.Architecture}_{snapshot.CapturedAt:yyyyMMddHHmmss}";
|
||||
|
||||
// Use raw SQL for INSERT ... RETURNING with tenant function
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var results = await dbContext.CorpusSnapshots
|
||||
.FromSqlInterpolated($"""
|
||||
INSERT INTO binaries.corpus_snapshots (
|
||||
@@ -41,7 +45,7 @@ public sealed class CorpusSnapshotRepository : ICorpusSnapshotRepository
|
||||
{snapshot.Id},
|
||||
binaries_app.require_current_tenant()::uuid,
|
||||
{snapshot.Distro}, {snapshot.Release}, {snapshot.Architecture},
|
||||
{snapshotIdValue}, {snapshot.MetadataDigest}, NOW()
|
||||
{snapshotIdValue}, {snapshot.MetadataDigest}, {now}
|
||||
)
|
||||
RETURNING id, tenant_id, distro, release, architecture, snapshot_id,
|
||||
packages_processed, binaries_indexed, repo_metadata_digest,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user