wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10
This commit is contained in:
@@ -9,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.AdvisoryAI.Attestation;
|
||||
using StellaOps.AdvisoryAI.Attestation.Models;
|
||||
using StellaOps.AdvisoryAI.Attestation.Storage;
|
||||
using StellaOps.AdvisoryAI.WebService.Security;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.WebService.Endpoints;
|
||||
|
||||
@@ -26,36 +27,48 @@ public static class AttestationEndpoints
|
||||
// GET /v1/advisory-ai/runs/{runId}/attestation
|
||||
app.MapGet("/v1/advisory-ai/runs/{runId}/attestation", HandleGetRunAttestation)
|
||||
.WithName("advisory-ai.runs.attestation.get")
|
||||
.WithSummary("Get the attestation record for a completed AI run")
|
||||
.WithDescription("Returns the AI attestation for a completed investigation run, including the DSSE envelope if the run was cryptographically signed. Tenant isolation is enforced; requests for runs belonging to a different tenant return 404. Returns 404 if the run has not been attested.")
|
||||
.WithTags("Attestations")
|
||||
.Produces<RunAttestationResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
// GET /v1/advisory-ai/runs/{runId}/claims
|
||||
app.MapGet("/v1/advisory-ai/runs/{runId}/claims", HandleGetRunClaims)
|
||||
.WithName("advisory-ai.runs.claims.list")
|
||||
.WithSummary("List AI-generated claims for a run")
|
||||
.WithDescription("Returns all claim-level attestations recorded during an AI investigation run, each describing an individual assertion made by the AI (e.g. reachability verdict, remediation recommendation, risk rating). Claims are linked to the parent run attestation and can be independently verified.")
|
||||
.WithTags("Attestations")
|
||||
.Produces<ClaimsListResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
// GET /v1/advisory-ai/attestations/recent
|
||||
app.MapGet("/v1/advisory-ai/attestations/recent", HandleListRecentAttestations)
|
||||
.WithName("advisory-ai.attestations.recent")
|
||||
.WithSummary("List recent AI attestations for the current tenant")
|
||||
.WithDescription("Returns the most recent AI run attestations for the authenticated tenant, ordered by creation time descending. Limit defaults to 20 and is capped at 100. Use this endpoint to monitor recent AI activity and surface attestations for downstream signing or audit workflows.")
|
||||
.WithTags("Attestations")
|
||||
.Produces<RecentAttestationsResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
// POST /v1/advisory-ai/attestations/verify
|
||||
app.MapPost("/v1/advisory-ai/attestations/verify", HandleVerifyAttestation)
|
||||
.WithName("advisory-ai.attestations.verify")
|
||||
.WithSummary("Verify the cryptographic integrity of an AI run attestation")
|
||||
.WithDescription("Verifies the content digest and DSSE envelope signature of a previously recorded AI run attestation. Returns a structured result including per-component validity flags (digest, signature) and the signing key ID. Returns 400 if the attestation is not found, is tampered, or belongs to a different tenant.")
|
||||
.WithTags("Attestations")
|
||||
.Produces<AttestationVerificationResponse>(StatusCodes.Status200OK)
|
||||
.Produces<AttestationVerificationResponse>(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ using StellaOps.AdvisoryAI.Chat.Routing;
|
||||
using StellaOps.AdvisoryAI.Chat.Services;
|
||||
using StellaOps.AdvisoryAI.Chat.Settings;
|
||||
using StellaOps.AdvisoryAI.WebService.Contracts;
|
||||
using StellaOps.AdvisoryAI.WebService.Security;
|
||||
using System.Collections.Immutable;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
@@ -43,7 +44,8 @@ public static class ChatEndpoints
|
||||
public static RouteGroupBuilder MapChatEndpoints(this IEndpointRouteBuilder builder)
|
||||
{
|
||||
var group = builder.MapGroup("/api/v1/chat")
|
||||
.WithTags("Advisory Chat");
|
||||
.WithTags("Advisory Chat")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
|
||||
|
||||
// Single query endpoint (non-streaming)
|
||||
group.MapPost("/query", ProcessQueryAsync)
|
||||
@@ -68,6 +70,7 @@ public static class ChatEndpoints
|
||||
group.MapPost("/intent", DetectIntentAsync)
|
||||
.WithName("DetectChatIntent")
|
||||
.WithSummary("Detects intent from a user query without generating a full response")
|
||||
.WithDescription("Classifies the user query into one of the advisory chat intents (explain, remediate, assess-risk, compare, etc.) and extracts structured parameters such as finding ID, package PURL, image reference, and environment. Useful for pre-routing or UI intent indicators without consuming LLM quota.")
|
||||
.Produces<IntentDetectionResponse>(StatusCodes.Status200OK)
|
||||
.ProducesValidationProblem();
|
||||
|
||||
@@ -75,6 +78,7 @@ public static class ChatEndpoints
|
||||
group.MapPost("/evidence-preview", PreviewEvidenceBundleAsync)
|
||||
.WithName("PreviewEvidenceBundle")
|
||||
.WithSummary("Previews the evidence bundle that would be assembled for a query")
|
||||
.WithDescription("Assembles and returns a preview of the evidence bundle that would be passed to the LLM for the specified finding, without generating an AI response. Indicates which evidence types are available (VEX, reachability, binary patch, provenance, policy, ops memory, fix options) and their status.")
|
||||
.Produces<EvidenceBundlePreviewResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ErrorResponse>(StatusCodes.Status400BadRequest);
|
||||
|
||||
@@ -82,29 +86,34 @@ public static class ChatEndpoints
|
||||
group.MapGet("/settings", GetChatSettingsAsync)
|
||||
.WithName("GetChatSettings")
|
||||
.WithSummary("Gets effective chat settings for the caller")
|
||||
.WithDescription("Returns the effective advisory chat settings for the current tenant and user, merging global defaults, tenant overrides, and user overrides. Includes quota limits and tool access configuration.")
|
||||
.Produces<ChatSettingsResponse>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapPut("/settings", UpdateChatSettingsAsync)
|
||||
.WithName("UpdateChatSettings")
|
||||
.WithSummary("Updates chat settings overrides (tenant or user)")
|
||||
.WithDescription("Applies quota and tool access overrides for the current tenant (default) or a specific user (scope=user). Overrides are layered on top of global defaults; only fields present in the request body are changed.")
|
||||
.Produces<ChatSettingsResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ErrorResponse>(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapDelete("/settings", ClearChatSettingsAsync)
|
||||
.WithName("ClearChatSettings")
|
||||
.WithSummary("Clears chat settings overrides (tenant or user)")
|
||||
.WithDescription("Removes all tenant-level or user-level chat settings overrides, reverting the affected scope to global defaults. Use scope=user to clear only the user-level override for the current user.")
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
// Doctor endpoint
|
||||
group.MapGet("/doctor", GetChatDoctorAsync)
|
||||
.WithName("GetChatDoctor")
|
||||
.WithSummary("Returns chat limit status and tool access diagnostics")
|
||||
.WithDescription("Returns a diagnostics report for the current tenant and user, including remaining quota across all dimensions (requests/min, requests/day, tokens/day, tool calls/day), tool provider availability, and the last quota denial if any. Referenced by error responses via the doctor action hint.")
|
||||
.Produces<ChatDoctorResponse>(StatusCodes.Status200OK);
|
||||
|
||||
// Health/status endpoint for chat service
|
||||
group.MapGet("/status", GetChatStatusAsync)
|
||||
.WithName("GetChatStatus")
|
||||
.WithSummary("Gets the status of the advisory chat service")
|
||||
.WithDescription("Returns the current operational status of the advisory chat service, including whether chat is enabled, the configured inference provider and model, maximum token limit, and whether guardrails and audit logging are active.")
|
||||
.Produces<ChatServiceStatusResponse>(StatusCodes.Status200OK);
|
||||
|
||||
return group;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.AdvisoryAI.WebService.Security;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Evidence.Pack;
|
||||
using StellaOps.Evidence.Pack.Models;
|
||||
@@ -27,62 +28,83 @@ public static class EvidencePackEndpoints
|
||||
// POST /v1/evidence-packs - Create Evidence Pack
|
||||
app.MapPost("/v1/evidence-packs", HandleCreateEvidencePack)
|
||||
.WithName("evidence-packs.create")
|
||||
.WithSummary("Create an evidence pack")
|
||||
.WithDescription("Creates a new evidence pack containing AI-generated claims and supporting evidence items for a vulnerability subject. Claims are linked to evidence items by ID. The pack is assigned a content digest for tamper detection and can subsequently be signed via the sign endpoint.")
|
||||
.WithTags("EvidencePacks")
|
||||
.Produces<EvidencePackResponse>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
// GET /v1/evidence-packs/{packId} - Get Evidence Pack
|
||||
app.MapGet("/v1/evidence-packs/{packId}", HandleGetEvidencePack)
|
||||
.WithName("evidence-packs.get")
|
||||
.WithSummary("Get an evidence pack by ID")
|
||||
.WithDescription("Returns the full evidence pack record including all claims, evidence items, subject, context, and related links (sign, verify, export). Access is tenant-scoped; packs from other tenants return 404.")
|
||||
.WithTags("EvidencePacks")
|
||||
.Produces<EvidencePackResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
// POST /v1/evidence-packs/{packId}/sign - Sign Evidence Pack
|
||||
app.MapPost("/v1/evidence-packs/{packId}/sign", HandleSignEvidencePack)
|
||||
.WithName("evidence-packs.sign")
|
||||
.WithSummary("Sign an evidence pack")
|
||||
.WithDescription("Signs the specified evidence pack using DSSE (Dead Simple Signing Envelope), producing a cryptographic attestation over the pack's content digest. The resulting signed pack and DSSE envelope are returned and stored for subsequent verification.")
|
||||
.WithTags("EvidencePacks")
|
||||
.Produces<SignedEvidencePackResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
// POST /v1/evidence-packs/{packId}/verify - Verify Evidence Pack
|
||||
app.MapPost("/v1/evidence-packs/{packId}/verify", HandleVerifyEvidencePack)
|
||||
.WithName("evidence-packs.verify")
|
||||
.WithSummary("Verify an evidence pack's signature and integrity")
|
||||
.WithDescription("Verifies the cryptographic signature and content digest of a signed evidence pack. Returns per-evidence URI resolution results, digest match status, and signing key ID. Returns 400 if the pack has not been signed or verification fails.")
|
||||
.WithTags("EvidencePacks")
|
||||
.Produces<EvidencePackVerificationResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
// GET /v1/evidence-packs/{packId}/export - Export Evidence Pack
|
||||
app.MapGet("/v1/evidence-packs/{packId}/export", HandleExportEvidencePack)
|
||||
.WithName("evidence-packs.export")
|
||||
.WithSummary("Export an evidence pack in a specified format")
|
||||
.WithDescription("Exports an evidence pack in the requested format. Supported formats: json (default), markdown, html, pdf, signedjson, evidencecard, and evidencecardcompact. The format query parameter controls the output; the appropriate Content-Type and filename are set in the response.")
|
||||
.WithTags("EvidencePacks")
|
||||
.Produces<byte[]>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
// GET /v1/runs/{runId}/evidence-packs - List Evidence Packs for Run
|
||||
app.MapGet("/v1/runs/{runId}/evidence-packs", HandleListRunEvidencePacks)
|
||||
.WithName("evidence-packs.list-by-run")
|
||||
.WithSummary("List evidence packs for a run")
|
||||
.WithDescription("Returns all evidence packs associated with a specific AI investigation run, filtered to the current tenant. Includes pack summaries with claim count, evidence count, subject type, and CVE ID.")
|
||||
.WithTags("EvidencePacks")
|
||||
.Produces<EvidencePackListResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
// GET /v1/evidence-packs - List Evidence Packs
|
||||
app.MapGet("/v1/evidence-packs", HandleListEvidencePacks)
|
||||
.WithName("evidence-packs.list")
|
||||
.WithSummary("List evidence packs")
|
||||
.WithDescription("Returns a paginated list of evidence packs for the current tenant, optionally filtered by CVE ID or run ID. Supports limit up to 100. Results include pack summaries with subject type, claim count, and evidence count.")
|
||||
.WithTags("EvidencePacks")
|
||||
.Produces<EvidencePackListResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
using StellaOps.AdvisoryAI.WebService.Security;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.WebService.Endpoints;
|
||||
|
||||
@@ -17,11 +18,14 @@ public static class KnowledgeSearchEndpoints
|
||||
public static RouteGroupBuilder MapKnowledgeSearchEndpoints(this IEndpointRouteBuilder builder)
|
||||
{
|
||||
var group = builder.MapGroup("/v1/advisory-ai")
|
||||
.WithTags("Advisory AI - Knowledge Search");
|
||||
.WithTags("Advisory AI - Knowledge Search")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy);
|
||||
|
||||
group.MapPost("/search", SearchAsync)
|
||||
.WithName("AdvisoryAiKnowledgeSearch")
|
||||
.WithSummary("Searches AdvisoryAI deterministic knowledge index (docs/api/doctor).")
|
||||
.WithDescription("Performs a hybrid full-text and vector similarity search over the AdvisoryAI deterministic knowledge index, which is composed of product documentation, OpenAPI specs, and Doctor health check projections. Supports filtering by content type (docs, api, doctor), product, version, service, and tags. Returns ranked result snippets with actionable open-actions for UI navigation.")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
||||
.Produces<AdvisoryKnowledgeSearchResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status403Forbidden);
|
||||
@@ -29,6 +33,8 @@ public static class KnowledgeSearchEndpoints
|
||||
group.MapPost("/index/rebuild", RebuildIndexAsync)
|
||||
.WithName("AdvisoryAiKnowledgeIndexRebuild")
|
||||
.WithSummary("Rebuilds AdvisoryAI knowledge search index from deterministic local sources.")
|
||||
.WithDescription("Triggers a full rebuild of the knowledge search index from local deterministic sources: product documentation files, embedded OpenAPI specs, and Doctor health check metadata. The rebuild is synchronous and returns document, chunk, and operation counts with duration. Requires admin-level scope; does not fetch external content.")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.AdminPolicy)
|
||||
.Produces<AdvisoryKnowledgeRebuildResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status403Forbidden);
|
||||
|
||||
@@ -187,7 +193,10 @@ public static class KnowledgeSearchEndpoints
|
||||
CheckCode = result.Open.Doctor.CheckCode,
|
||||
Severity = result.Open.Doctor.Severity,
|
||||
CanRun = result.Open.Doctor.CanRun,
|
||||
RunCommand = result.Open.Doctor.RunCommand
|
||||
RunCommand = result.Open.Doctor.RunCommand,
|
||||
Control = result.Open.Doctor.Control,
|
||||
RequiresConfirmation = result.Open.Doctor.RequiresConfirmation,
|
||||
IsDestructive = result.Open.Doctor.IsDestructive
|
||||
}
|
||||
};
|
||||
|
||||
@@ -350,6 +359,12 @@ public sealed record AdvisoryKnowledgeOpenDoctorAction
|
||||
public bool CanRun { get; init; } = true;
|
||||
|
||||
public string RunCommand { get; init; } = string.Empty;
|
||||
|
||||
public string Control { get; init; } = "safe";
|
||||
|
||||
public bool RequiresConfirmation { get; init; }
|
||||
|
||||
public bool IsDestructive { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AdvisoryKnowledgeSearchDiagnostics
|
||||
|
||||
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.AdvisoryAI.Inference.LlmProviders;
|
||||
using StellaOps.AdvisoryAI.Plugin.Unified;
|
||||
using StellaOps.AdvisoryAI.WebService.Security;
|
||||
using StellaOps.Plugin.Abstractions.Capabilities;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
@@ -27,17 +28,21 @@ public static class LlmAdapterEndpoints
|
||||
public static RouteGroupBuilder MapLlmAdapterEndpoints(this IEndpointRouteBuilder builder)
|
||||
{
|
||||
var group = builder.MapGroup("/v1/advisory-ai/adapters")
|
||||
.WithTags("Advisory AI - LLM Adapters");
|
||||
.WithTags("Advisory AI - LLM Adapters")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy);
|
||||
|
||||
group.MapGet("/llm/providers", ListProvidersAsync)
|
||||
.WithName("ListLlmProviders")
|
||||
.WithSummary("Lists LLM providers exposed via the unified adapter layer.")
|
||||
.WithDescription("Returns all LLM providers registered in the unified plugin catalog, including their configuration status, validation result, availability, and the completion path to use for each provider. Configured-but-invalid providers are included with error details. Use this endpoint to discover which providers are ready to serve completions before invoking them.")
|
||||
.Produces<IReadOnlyList<LlmProviderExposureResponse>>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status403Forbidden);
|
||||
|
||||
group.MapPost("/llm/{providerId}/chat/completions", CompleteWithProviderAsync)
|
||||
.WithName("LlmProviderChatCompletions")
|
||||
.WithSummary("OpenAI-compatible chat completion for a specific unified provider.")
|
||||
.WithDescription("Submits a chat completion request to the specified LLM provider via the unified adapter layer using an OpenAI-compatible message format. Streaming is not supported; use non-streaming mode only. Returns 404 if the provider is not configured for adapter exposure, 503 if the provider is temporarily unavailable. Caller scopes are validated against gateway-managed headers.")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
||||
.Produces<OpenAiChatCompletionResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
@@ -47,6 +52,8 @@ public static class LlmAdapterEndpoints
|
||||
group.MapPost("/openai/v1/chat/completions", CompleteOpenAiCompatAsync)
|
||||
.WithName("OpenAiAdapterChatCompletions")
|
||||
.WithSummary("OpenAI-compatible chat completion alias backed by providerId=openai.")
|
||||
.WithDescription("Convenience alias that routes chat completion requests to the provider with id 'openai', using the same OpenAI-compatible request/response format as the generic provider endpoint. Intended for drop-in compatibility with clients expecting the standard OpenAI path. Streaming is not supported.")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
||||
.Produces<OpenAiChatCompletionResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
|
||||
@@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.AdvisoryAI.Runs;
|
||||
using StellaOps.AdvisoryAI.WebService.Security;
|
||||
using StellaOps.Determinism;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
@@ -27,64 +28,82 @@ public static class RunEndpoints
|
||||
public static RouteGroupBuilder MapRunEndpoints(this IEndpointRouteBuilder builder)
|
||||
{
|
||||
var group = builder.MapGroup("/api/v1/runs")
|
||||
.WithTags("Runs");
|
||||
.WithTags("Runs")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy);
|
||||
|
||||
group.MapPost("/", CreateRunAsync)
|
||||
.WithName("CreateRun")
|
||||
.WithSummary("Creates a new AI investigation run")
|
||||
.WithDescription("Creates a new AI investigation run scoped to the authenticated tenant, capturing the title, objective, and optional CVE/component/SBOM context. The run begins in the Created state and accumulates events as the investigation progresses. Returns 201 with the initial run state and a Location header.")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
||||
.Produces<RunDto>(StatusCodes.Status201Created)
|
||||
.ProducesValidationProblem();
|
||||
|
||||
group.MapGet("/{runId}", GetRunAsync)
|
||||
.WithName("GetRun")
|
||||
.WithSummary("Gets a run by ID")
|
||||
.WithDescription("Returns the current state of an AI investigation run, including status, event count, artifact count, content digest, attestation flag, context, and approval info. Returns 404 if the run does not exist or belongs to a different tenant.")
|
||||
.Produces<RunDto>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/", QueryRunsAsync)
|
||||
.WithName("QueryRuns")
|
||||
.WithSummary("Queries runs with filters")
|
||||
.WithDescription("Returns a paginated list of AI investigation runs for the current tenant, optionally filtered by initiator, CVE ID, component, and status. Supports skip/take pagination. Results are ordered by creation time descending.")
|
||||
.Produces<RunQueryResultDto>();
|
||||
|
||||
group.MapGet("/{runId}/timeline", GetTimelineAsync)
|
||||
.WithName("GetRunTimeline")
|
||||
.WithSummary("Gets the event timeline for a run")
|
||||
.WithDescription("Returns the ordered event timeline for an AI investigation run, including user turns, assistant turns, proposed actions, approvals, and artifact additions. Supports skip/take pagination over the event sequence. Returns 404 if the run does not exist.")
|
||||
.Produces<ImmutableArray<RunEventDto>>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/{runId}/events", AddEventAsync)
|
||||
.WithName("AddRunEvent")
|
||||
.WithSummary("Adds an event to a run")
|
||||
.WithDescription("Appends a typed event to an active AI investigation run, supporting arbitrary event types with optional content payload, evidence links, and parent event reference for threading. Returns 201 with the created event. Returns 404 if the run does not exist or is in a terminal state.")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
||||
.Produces<RunEventDto>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/{runId}/turns/user", AddUserTurnAsync)
|
||||
.WithName("AddUserTurn")
|
||||
.WithSummary("Adds a user turn to the run")
|
||||
.WithDescription("Appends a user conversational turn to an active AI investigation run, recording the message text, actor ID, and optional evidence links. User turns drive the investigation dialogue and are included in the run content digest for attestation purposes.")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
||||
.Produces<RunEventDto>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/{runId}/turns/assistant", AddAssistantTurnAsync)
|
||||
.WithName("AddAssistantTurn")
|
||||
.WithSummary("Adds an assistant turn to the run")
|
||||
.WithDescription("Appends an AI assistant conversational turn to an active run, recording the generated message and optional evidence links. Assistant turns are included in the run content digest and contribute to the attestable evidence chain for the investigation.")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
||||
.Produces<RunEventDto>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/{runId}/actions", ProposeActionAsync)
|
||||
.WithName("ProposeAction")
|
||||
.WithSummary("Proposes an action in the run")
|
||||
.WithDescription("Records an AI-proposed action in a run, including the action type, subject, rationale, parameters, and whether human approval is required before execution. Actions flagged as requiring approval transition the run to PendingApproval once approval is requested. Returns 404 if the run is not active.")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
||||
.Produces<RunEventDto>(StatusCodes.Status201Created)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/{runId}/approval/request", RequestApprovalAsync)
|
||||
.WithName("RequestApproval")
|
||||
.WithSummary("Requests approval for pending actions")
|
||||
.WithDescription("Transitions a run to the PendingApproval state and notifies the designated approvers. The request body specifies the approver IDs and an optional reason. Returns the updated run state. Returns 404 if the run does not exist or is not in a state that allows approval requests.")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
||||
.Produces<RunDto>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/{runId}/approval/decide", ApproveAsync)
|
||||
.WithName("ApproveRun")
|
||||
.WithSummary("Approves or rejects a run")
|
||||
.WithDescription("Records an approval or rejection decision for a run in PendingApproval state. On approval, the run transitions back to Active so approved actions can be executed. On rejection, the run is cancelled. Returns 400 if the run is not in an approvable state.")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
||||
.Produces<RunDto>()
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
@@ -92,6 +111,8 @@ public static class RunEndpoints
|
||||
group.MapPost("/{runId}/actions/{actionEventId}/execute", ExecuteActionAsync)
|
||||
.WithName("ExecuteAction")
|
||||
.WithSummary("Executes an approved action")
|
||||
.WithDescription("Marks a previously proposed and approved action as executed, recording the execution result in the run timeline. Only actions that have been approved may be executed; attempting to execute a pending or rejected action returns 400. Returns 404 if the run or action event does not exist.")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
||||
.Produces<RunEventDto>()
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
@@ -99,12 +120,16 @@ public static class RunEndpoints
|
||||
group.MapPost("/{runId}/artifacts", AddArtifactAsync)
|
||||
.WithName("AddArtifact")
|
||||
.WithSummary("Adds an artifact to the run")
|
||||
.WithDescription("Attaches an artifact (evidence pack, report, SBOM snippet, or other typed asset) to an active run. The artifact is recorded with its content digest, media type, size, and optional inline content. Adding an artifact updates the run's content digest, contributing to its attestation chain.")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
||||
.Produces<RunDto>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/{runId}/complete", CompleteRunAsync)
|
||||
.WithName("CompleteRun")
|
||||
.WithSummary("Completes a run")
|
||||
.WithDescription("Transitions an active AI investigation run to the Completed terminal state, optionally recording a summary of findings. Once completed, the run is immutable and ready for attestation. Returns 400 if the run is already in a terminal state.")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
||||
.Produces<RunDto>()
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
@@ -112,6 +137,8 @@ public static class RunEndpoints
|
||||
group.MapPost("/{runId}/cancel", CancelRunAsync)
|
||||
.WithName("CancelRun")
|
||||
.WithSummary("Cancels a run")
|
||||
.WithDescription("Transitions an active or pending-approval AI investigation run to the Cancelled terminal state, optionally recording a cancellation reason. Cancelled runs are immutable and excluded from active and pending-approval queries. Returns 400 if the run is already in a terminal state.")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
||||
.Produces<RunDto>()
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
@@ -119,12 +146,16 @@ public static class RunEndpoints
|
||||
group.MapPost("/{runId}/handoff", HandOffRunAsync)
|
||||
.WithName("HandOffRun")
|
||||
.WithSummary("Hands off a run to another user")
|
||||
.WithDescription("Transfers ownership of an active AI investigation run to another user within the same tenant. A hand-off event is recorded in the run timeline with the target user ID and an optional message. Returns 404 if the run does not exist or the target user is not valid.")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
||||
.Produces<RunDto>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/{runId}/attest", AttestRunAsync)
|
||||
.WithName("AttestRun")
|
||||
.WithSummary("Creates an attestation for a completed run")
|
||||
.WithDescription("Generates and persists a cryptographic attestation for a completed AI investigation run, recording the content digest, model metadata, and claim hashes. The attestation can optionally be signed via the attestation sign endpoint. Returns 400 if the run is not in a terminal state or has already been attested.")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
||||
.Produces<RunDto>()
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
@@ -132,11 +163,13 @@ public static class RunEndpoints
|
||||
group.MapGet("/active", GetActiveRunsAsync)
|
||||
.WithName("GetActiveRuns")
|
||||
.WithSummary("Gets active runs for the current user")
|
||||
.WithDescription("Returns up to 50 AI investigation runs in Created, Active, or PendingApproval state that were initiated by the current user within the authenticated tenant. Use this endpoint to resume in-progress investigations or surface runs awaiting user input.")
|
||||
.Produces<ImmutableArray<RunDto>>();
|
||||
|
||||
group.MapGet("/pending-approval", GetPendingApprovalAsync)
|
||||
.WithName("GetPendingApproval")
|
||||
.WithSummary("Gets runs pending approval")
|
||||
.WithDescription("Returns up to 50 AI investigation runs in the PendingApproval state for the authenticated tenant. Use this endpoint to surface runs that are blocked on a human approval decision before their proposed actions can be executed.")
|
||||
.Produces<ImmutableArray<RunDto>>();
|
||||
|
||||
return group;
|
||||
|
||||
@@ -24,7 +24,9 @@ using StellaOps.AdvisoryAI.Queue;
|
||||
using StellaOps.AdvisoryAI.Remediation;
|
||||
using StellaOps.AdvisoryAI.WebService.Contracts;
|
||||
using StellaOps.AdvisoryAI.WebService.Endpoints;
|
||||
using StellaOps.AdvisoryAI.WebService.Security;
|
||||
using StellaOps.AdvisoryAI.WebService.Services;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Evidence.Pack;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Router.AspNet;
|
||||
@@ -87,6 +89,12 @@ builder.Services.TryAddSingleton<IEvidencePackSigner, NullEvidencePackSigner>();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddOpenApi();
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services
|
||||
.AddAuthentication(AdvisoryAiHeaderAuthenticationHandler.SchemeName)
|
||||
.AddScheme<Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions, AdvisoryAiHeaderAuthenticationHandler>(
|
||||
AdvisoryAiHeaderAuthenticationHandler.SchemeName,
|
||||
static _ => { });
|
||||
builder.Services.AddAuthorization(options => options.AddAdvisoryAIPolicies());
|
||||
|
||||
// Stella Router integration
|
||||
var routerEnabled = builder.Services.AddRouterMicroservice(
|
||||
@@ -136,90 +144,115 @@ if (app.Environment.IsDevelopment())
|
||||
}
|
||||
|
||||
app.UseStellaOpsCors();
|
||||
app.UseAuthorization();
|
||||
app.UseRateLimiter();
|
||||
app.TryUseStellaRouter(routerEnabled);
|
||||
|
||||
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
|
||||
|
||||
app.MapPost("/v1/advisory-ai/pipeline/{taskType}", HandleSinglePlan)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
.RequireRateLimiting("advisory-ai")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
|
||||
|
||||
app.MapPost("/v1/advisory-ai/pipeline:batch", HandleBatchPlans)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
.RequireRateLimiting("advisory-ai")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
|
||||
|
||||
app.MapGet("/v1/advisory-ai/outputs/{cacheKey}", HandleGetOutput)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
.RequireRateLimiting("advisory-ai")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy);
|
||||
|
||||
// Explanation endpoints (SPRINT_20251226_015_AI_zastava_companion)
|
||||
app.MapPost("/v1/advisory-ai/explain", HandleExplain)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
.RequireRateLimiting("advisory-ai")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
|
||||
|
||||
app.MapGet("/v1/advisory-ai/explain/{explanationId}/replay", HandleExplanationReplay)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
.RequireRateLimiting("advisory-ai")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy);
|
||||
|
||||
app.MapPost("/v1/advisory-ai/companion/explain", HandleCompanionExplain)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
.RequireRateLimiting("advisory-ai")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
|
||||
|
||||
// Remediation endpoints (SPRINT_20251226_016_AI_remedy_autopilot)
|
||||
app.MapPost("/v1/advisory-ai/remediation/plan", HandleRemediationPlan)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
.RequireRateLimiting("advisory-ai")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
|
||||
|
||||
app.MapPost("/v1/advisory-ai/remediation/apply", HandleApplyRemediation)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
.RequireRateLimiting("advisory-ai")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
|
||||
|
||||
app.MapGet("/v1/advisory-ai/remediation/status/{prId}", HandleRemediationStatus)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
.RequireRateLimiting("advisory-ai")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy);
|
||||
|
||||
// Policy Studio endpoints (SPRINT_20251226_017_AI_policy_copilot)
|
||||
app.MapPost("/v1/advisory-ai/policy/studio/parse", HandlePolicyParse)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
.RequireRateLimiting("advisory-ai")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
|
||||
|
||||
app.MapPost("/v1/advisory-ai/policy/studio/generate", HandlePolicyGenerate)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
.RequireRateLimiting("advisory-ai")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
|
||||
|
||||
app.MapPost("/v1/advisory-ai/policy/studio/validate", HandlePolicyValidate)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
.RequireRateLimiting("advisory-ai")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
|
||||
|
||||
app.MapPost("/v1/advisory-ai/policy/studio/compile", HandlePolicyCompile)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
.RequireRateLimiting("advisory-ai")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
|
||||
|
||||
// VEX-AI-016: Consent endpoints
|
||||
app.MapGet("/v1/advisory-ai/consent", HandleGetConsent)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
.RequireRateLimiting("advisory-ai")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy);
|
||||
|
||||
app.MapPost("/v1/advisory-ai/consent", HandleGrantConsent)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
.RequireRateLimiting("advisory-ai")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
|
||||
|
||||
app.MapDelete("/v1/advisory-ai/consent", HandleRevokeConsent)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
.RequireRateLimiting("advisory-ai")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
|
||||
|
||||
// VEX-AI-016: Justification endpoint
|
||||
app.MapPost("/v1/advisory-ai/justify", HandleJustify)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
.RequireRateLimiting("advisory-ai")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
|
||||
|
||||
// VEX-AI-016: Remediate alias (maps to remediation/plan)
|
||||
app.MapPost("/v1/advisory-ai/remediate", HandleRemediate)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
.RequireRateLimiting("advisory-ai")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
|
||||
|
||||
// VEX-AI-016: Rate limits endpoint
|
||||
app.MapGet("/v1/advisory-ai/rate-limits", HandleGetRateLimits)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
.RequireRateLimiting("advisory-ai")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy);
|
||||
|
||||
// Chat endpoints (SPRINT_20260107_006_003 CH-005)
|
||||
app.MapPost("/v1/advisory-ai/conversations", HandleCreateConversation)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
.RequireRateLimiting("advisory-ai")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
|
||||
|
||||
app.MapGet("/v1/advisory-ai/conversations/{conversationId}", HandleGetConversation)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
.RequireRateLimiting("advisory-ai")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy);
|
||||
|
||||
app.MapPost("/v1/advisory-ai/conversations/{conversationId}/turns", HandleAddTurn)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
.RequireRateLimiting("advisory-ai")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
|
||||
|
||||
app.MapDelete("/v1/advisory-ai/conversations/{conversationId}", HandleDeleteConversation)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
.RequireRateLimiting("advisory-ai")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
|
||||
|
||||
app.MapGet("/v1/advisory-ai/conversations", HandleListConversations)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
.RequireRateLimiting("advisory-ai")
|
||||
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy);
|
||||
|
||||
// Chat gateway endpoints (controlled conversational interface)
|
||||
app.MapChatEndpoints();
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// <copyright file="AdvisoryAIPolicies.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.WebService.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Named authorization policy constants and registration for the AdvisoryAI service.
|
||||
/// Every business endpoint MUST use a named policy from this class via
|
||||
/// <c>.RequireAuthorization(AdvisoryAIPolicies.XxxPolicy)</c>.
|
||||
///
|
||||
/// Scope hierarchy (any of these grants access to the corresponding level):
|
||||
/// - View : advisory-ai:view
|
||||
/// - Operate : advisory-ai:operate, advisory-ai:view (operate implies view)
|
||||
/// - Admin : advisory-ai:admin, advisory-ai:operate (admin implies operate + view)
|
||||
/// </summary>
|
||||
public static class AdvisoryAIPolicies
|
||||
{
|
||||
/// <summary>Policy for read-only access to AI artefacts (outputs, attestations, evidence packs).</summary>
|
||||
public const string ViewPolicy = "advisory-ai.view";
|
||||
|
||||
/// <summary>Policy for inference and workflow execution (pipeline, explain, remediate, chat, search).</summary>
|
||||
public const string OperatePolicy = "advisory-ai.operate";
|
||||
|
||||
/// <summary>Policy for administrative operations (index rebuild, adapter configuration).</summary>
|
||||
public const string AdminPolicy = "advisory-ai.admin";
|
||||
|
||||
/// <summary>
|
||||
/// Registers all AdvisoryAI named policies into the authorization options.
|
||||
/// Call from <c>builder.Services.AddAuthorization(options => options.AddAdvisoryAIPolicies())</c>.
|
||||
/// </summary>
|
||||
public static void AddAdvisoryAIPolicies(this AuthorizationOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
// View: advisory-ai:view OR advisory-ai:operate OR advisory-ai:admin
|
||||
options.AddPolicy(ViewPolicy, policy =>
|
||||
{
|
||||
policy.AddAuthenticationSchemes(AdvisoryAiHeaderAuthenticationHandler.SchemeName);
|
||||
policy.RequireAuthenticatedUser();
|
||||
policy.RequireAssertion(ctx => HasAnyScope(ctx.User,
|
||||
StellaOpsScopes.AdvisoryAiView,
|
||||
StellaOpsScopes.AdvisoryAiOperate,
|
||||
StellaOpsScopes.AdvisoryAiAdmin));
|
||||
});
|
||||
|
||||
// Operate: advisory-ai:operate OR advisory-ai:admin
|
||||
options.AddPolicy(OperatePolicy, policy =>
|
||||
{
|
||||
policy.AddAuthenticationSchemes(AdvisoryAiHeaderAuthenticationHandler.SchemeName);
|
||||
policy.RequireAuthenticatedUser();
|
||||
policy.RequireAssertion(ctx => HasAnyScope(ctx.User,
|
||||
StellaOpsScopes.AdvisoryAiOperate,
|
||||
StellaOpsScopes.AdvisoryAiAdmin));
|
||||
});
|
||||
|
||||
// Admin: advisory-ai:admin only
|
||||
options.AddPolicy(AdminPolicy, policy =>
|
||||
{
|
||||
policy.AddAuthenticationSchemes(AdvisoryAiHeaderAuthenticationHandler.SchemeName);
|
||||
policy.RequireAuthenticatedUser();
|
||||
policy.RequireAssertion(ctx => HasAnyScope(ctx.User,
|
||||
StellaOpsScopes.AdvisoryAiAdmin));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the principal holds at least one of the specified scopes.
|
||||
/// Scopes are read from the <c>scope</c> claim (space-delimited) and individual
|
||||
/// <c>scp</c> claims as set by <see cref="AdvisoryAiHeaderAuthenticationHandler"/>.
|
||||
/// </summary>
|
||||
private static bool HasAnyScope(ClaimsPrincipal user, params string[] allowedScopes)
|
||||
{
|
||||
var allowed = new HashSet<string>(allowedScopes, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var claim in user.FindAll(StellaOpsClaimTypes.Scope))
|
||||
{
|
||||
foreach (var token in claim.Value.Split(
|
||||
' ',
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (allowed.Contains(token))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var claim in user.FindAll(StellaOpsClaimTypes.ScopeItem))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(claim.Value) && allowed.Contains(claim.Value.Trim()))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.WebService.Security;
|
||||
|
||||
internal sealed class AdvisoryAiHeaderAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public const string SchemeName = "AdvisoryAiHeader";
|
||||
|
||||
public AdvisoryAiHeaderAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
}
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var claims = new List<Claim>();
|
||||
|
||||
var actor = FirstHeaderValue("X-StellaOps-Actor")
|
||||
?? FirstHeaderValue("X-User-Id")
|
||||
?? "anonymous";
|
||||
claims.Add(new Claim(ClaimTypes.NameIdentifier, actor));
|
||||
claims.Add(new Claim(ClaimTypes.Name, actor));
|
||||
|
||||
var tenant = FirstHeaderValue("X-StellaOps-Tenant")
|
||||
?? FirstHeaderValue("X-Tenant-Id");
|
||||
if (!string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
claims.Add(new Claim("tenant_id", tenant));
|
||||
}
|
||||
|
||||
AddScopeClaims(claims, Request.Headers["X-StellaOps-Scopes"]);
|
||||
AddScopeClaims(claims, Request.Headers["X-Stella-Scopes"]);
|
||||
|
||||
var identity = new ClaimsIdentity(claims, SchemeName);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, SchemeName);
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
|
||||
private string? FirstHeaderValue(string headerName)
|
||||
{
|
||||
if (!Request.Headers.TryGetValue(headerName, out var values))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void AddScopeClaims(List<Claim> claims, IEnumerable<string> values)
|
||||
{
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var token in value.Split(
|
||||
[' ', ','],
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
claims.Add(new Claim("scope", token));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| SPRINT_20260222_051-AKS-API | DONE | Extended AKS search/open-action endpoint contract and added header-based authentication wiring (`AddAuthentication` + `AddAuthorization` + `UseAuthorization`) so `RequireAuthorization()` endpoints execute without runtime middleware errors. |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/StellaOps.AdvisoryAI.WebService.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
|
||||
@@ -11,7 +11,34 @@ internal sealed record DoctorSearchSeedEntry(
|
||||
string RunCommand,
|
||||
IReadOnlyList<string> Symptoms,
|
||||
IReadOnlyList<string> Tags,
|
||||
IReadOnlyList<string> References);
|
||||
IReadOnlyList<string> References,
|
||||
DoctorSearchControl? Control = null);
|
||||
|
||||
internal sealed record DoctorSearchControl(
|
||||
string Mode,
|
||||
bool RequiresConfirmation,
|
||||
bool IsDestructive,
|
||||
bool RequiresBackup,
|
||||
string? InspectCommand,
|
||||
string? VerificationCommand);
|
||||
|
||||
internal sealed record DoctorControlSeedEntry(
|
||||
string CheckCode,
|
||||
string Control,
|
||||
bool RequiresConfirmation,
|
||||
bool IsDestructive,
|
||||
bool RequiresBackup,
|
||||
string? InspectCommand,
|
||||
string? VerificationCommand,
|
||||
IReadOnlyList<string> Keywords,
|
||||
string? Title = null,
|
||||
string? Severity = null,
|
||||
string? Description = null,
|
||||
string? Remediation = null,
|
||||
string? RunCommand = null,
|
||||
IReadOnlyList<string>? Symptoms = null,
|
||||
IReadOnlyList<string>? Tags = null,
|
||||
IReadOnlyList<string>? References = null);
|
||||
|
||||
internal static class DoctorSearchSeedLoader
|
||||
{
|
||||
@@ -33,3 +60,24 @@ internal static class DoctorSearchSeedLoader
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
internal static class DoctorControlSeedLoader
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public static IReadOnlyList<DoctorControlSeedEntry> Load(string absolutePath)
|
||||
{
|
||||
if (!File.Exists(absolutePath))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
using var stream = File.OpenRead(absolutePath);
|
||||
var entries = JsonSerializer.Deserialize<List<DoctorControlSeedEntry>>(stream, JsonOptions) ?? [];
|
||||
|
||||
return entries
|
||||
.Where(static entry => !string.IsNullOrWhiteSpace(entry.CheckCode))
|
||||
.OrderBy(static entry => entry.CheckCode, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,16 +61,16 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
|
||||
private async Task<KnowledgeIndexSnapshot> BuildSnapshotAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var repositoryRoot = ResolveRepositoryRoot();
|
||||
var effective = ResolveEffectiveOptions();
|
||||
var documents = new Dictionary<string, KnowledgeSourceDocument>(StringComparer.Ordinal);
|
||||
var chunks = new Dictionary<string, KnowledgeChunkDocument>(StringComparer.Ordinal);
|
||||
var apiSpecs = new Dictionary<string, KnowledgeApiSpec>(StringComparer.Ordinal);
|
||||
var apiOperations = new Dictionary<string, KnowledgeApiOperation>(StringComparer.Ordinal);
|
||||
var doctorProjections = new Dictionary<string, KnowledgeDoctorProjection>(StringComparer.Ordinal);
|
||||
|
||||
IngestMarkdown(repositoryRoot, documents, chunks);
|
||||
IngestOpenApi(repositoryRoot, documents, chunks, apiSpecs, apiOperations);
|
||||
await IngestDoctorAsync(repositoryRoot, documents, chunks, doctorProjections, cancellationToken).ConfigureAwait(false);
|
||||
IngestMarkdown(effective, documents, chunks);
|
||||
IngestOpenApi(effective, documents, chunks, apiSpecs, apiOperations);
|
||||
await IngestDoctorAsync(effective, documents, chunks, doctorProjections, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new KnowledgeIndexSnapshot(
|
||||
documents.Values.OrderBy(static item => item.DocId, StringComparer.Ordinal).ToArray(),
|
||||
@@ -80,36 +80,64 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
doctorProjections.Values.OrderBy(static item => item.CheckCode, StringComparer.Ordinal).ToArray());
|
||||
}
|
||||
|
||||
private string ResolveRepositoryRoot()
|
||||
private EffectiveIngestionOptions ResolveEffectiveOptions()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.RepositoryRoot))
|
||||
var repositoryRoot = string.IsNullOrWhiteSpace(_options.RepositoryRoot)
|
||||
? Directory.GetCurrentDirectory()
|
||||
: Path.IsPathRooted(_options.RepositoryRoot)
|
||||
? Path.GetFullPath(_options.RepositoryRoot)
|
||||
: Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), _options.RepositoryRoot));
|
||||
|
||||
var markdownRoots = (_options.MarkdownRoots ?? [])
|
||||
.Where(static root => !string.IsNullOrWhiteSpace(root))
|
||||
.Select(static root => root.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static root => root, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (markdownRoots.Length == 0)
|
||||
{
|
||||
return Directory.GetCurrentDirectory();
|
||||
markdownRoots = ["docs"];
|
||||
}
|
||||
|
||||
if (Path.IsPathRooted(_options.RepositoryRoot))
|
||||
var openApiRoots = (_options.OpenApiRoots ?? [])
|
||||
.Where(static root => !string.IsNullOrWhiteSpace(root))
|
||||
.Select(static root => root.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static root => root, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (openApiRoots.Length == 0)
|
||||
{
|
||||
return Path.GetFullPath(_options.RepositoryRoot);
|
||||
openApiRoots = ["src", "devops/compose"];
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), _options.RepositoryRoot));
|
||||
return new EffectiveIngestionOptions(
|
||||
string.IsNullOrWhiteSpace(_options.Product) ? "stella-ops" : _options.Product.Trim(),
|
||||
string.IsNullOrWhiteSpace(_options.Version) ? "local" : _options.Version.Trim(),
|
||||
repositoryRoot,
|
||||
_options.DoctorChecksEndpoint ?? string.Empty,
|
||||
_options.DoctorSeedPath ?? string.Empty,
|
||||
_options.DoctorControlsPath ?? string.Empty,
|
||||
_options.MarkdownAllowListPath ?? string.Empty,
|
||||
markdownRoots,
|
||||
_options.OpenApiAggregatePath ?? string.Empty,
|
||||
openApiRoots);
|
||||
}
|
||||
|
||||
private void IngestMarkdown(
|
||||
string repositoryRoot,
|
||||
EffectiveIngestionOptions options,
|
||||
IDictionary<string, KnowledgeSourceDocument> documents,
|
||||
IDictionary<string, KnowledgeChunkDocument> chunks)
|
||||
{
|
||||
var markdownFiles = EnumerateMarkdownFiles(repositoryRoot);
|
||||
var markdownFiles = EnumerateMarkdownFiles(options.RepositoryRoot, options);
|
||||
foreach (var filePath in markdownFiles)
|
||||
{
|
||||
var relativePath = ToRelativeRepositoryPath(repositoryRoot, filePath);
|
||||
var relativePath = ToRelativeRepositoryPath(options.RepositoryRoot, filePath);
|
||||
var lines = File.ReadAllLines(filePath);
|
||||
var content = string.Join('\n', lines);
|
||||
var title = ExtractMarkdownDocumentTitle(lines, relativePath);
|
||||
var pathTags = ExtractPathTags(relativePath);
|
||||
|
||||
var docId = KnowledgeSearchText.StableId("doc", "markdown", _options.Product, _options.Version, relativePath);
|
||||
var docId = KnowledgeSearchText.StableId("doc", "markdown", options.Product, options.Version, relativePath);
|
||||
var docMetadata = CreateJsonDocument(new SortedDictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["kind"] = "markdown",
|
||||
@@ -120,8 +148,8 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
documents[docId] = new KnowledgeSourceDocument(
|
||||
docId,
|
||||
"markdown",
|
||||
_options.Product,
|
||||
_options.Version,
|
||||
options.Product,
|
||||
options.Version,
|
||||
"repo",
|
||||
relativePath,
|
||||
title,
|
||||
@@ -158,16 +186,16 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
}
|
||||
|
||||
private void IngestOpenApi(
|
||||
string repositoryRoot,
|
||||
EffectiveIngestionOptions options,
|
||||
IDictionary<string, KnowledgeSourceDocument> documents,
|
||||
IDictionary<string, KnowledgeChunkDocument> chunks,
|
||||
IDictionary<string, KnowledgeApiSpec> apiSpecs,
|
||||
IDictionary<string, KnowledgeApiOperation> apiOperations)
|
||||
{
|
||||
var apiFiles = EnumerateOpenApiFiles(repositoryRoot);
|
||||
var apiFiles = EnumerateOpenApiFiles(options.RepositoryRoot, options);
|
||||
foreach (var filePath in apiFiles)
|
||||
{
|
||||
var relativePath = ToRelativeRepositoryPath(repositoryRoot, filePath);
|
||||
var relativePath = ToRelativeRepositoryPath(options.RepositoryRoot, filePath);
|
||||
JsonDocument document;
|
||||
try
|
||||
{
|
||||
@@ -194,7 +222,7 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
var apiVersion = TryGetNestedString(root, "info", "version");
|
||||
var pathTags = ExtractPathTags(relativePath);
|
||||
|
||||
var docId = KnowledgeSearchText.StableId("doc", "openapi", _options.Product, _options.Version, relativePath);
|
||||
var docId = KnowledgeSearchText.StableId("doc", "openapi", options.Product, options.Version, relativePath);
|
||||
var docMetadata = CreateJsonDocument(new SortedDictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["kind"] = "openapi",
|
||||
@@ -206,15 +234,15 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
documents[docId] = new KnowledgeSourceDocument(
|
||||
docId,
|
||||
"openapi",
|
||||
_options.Product,
|
||||
_options.Version,
|
||||
options.Product,
|
||||
options.Version,
|
||||
"repo",
|
||||
relativePath,
|
||||
title,
|
||||
KnowledgeSearchText.StableId(root.GetRawText()),
|
||||
docMetadata);
|
||||
|
||||
var specId = KnowledgeSearchText.StableId("api-spec", _options.Product, _options.Version, relativePath, service);
|
||||
var specId = KnowledgeSearchText.StableId("api-spec", options.Product, options.Version, relativePath, service);
|
||||
apiSpecs[specId] = new KnowledgeApiSpec(
|
||||
specId,
|
||||
docId,
|
||||
@@ -299,19 +327,24 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
}
|
||||
|
||||
private async Task IngestDoctorAsync(
|
||||
string repositoryRoot,
|
||||
EffectiveIngestionOptions options,
|
||||
IDictionary<string, KnowledgeSourceDocument> documents,
|
||||
IDictionary<string, KnowledgeChunkDocument> chunks,
|
||||
IDictionary<string, KnowledgeDoctorProjection> doctorProjections,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var seedPath = ResolvePath(repositoryRoot, _options.DoctorSeedPath);
|
||||
var seedPath = ResolvePath(options.RepositoryRoot, options.DoctorSeedPath);
|
||||
var seedEntries = DoctorSearchSeedLoader.Load(seedPath)
|
||||
.ToDictionary(static entry => entry.CheckCode, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var endpointEntries = await LoadDoctorEndpointMetadataAsync(cancellationToken).ConfigureAwait(false);
|
||||
var controlsPath = ResolvePath(options.RepositoryRoot, options.DoctorControlsPath);
|
||||
var controlEntries = DoctorControlSeedLoader.Load(controlsPath)
|
||||
.ToDictionary(static entry => entry.CheckCode, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var endpointEntries = await LoadDoctorEndpointMetadataAsync(options.DoctorChecksEndpoint, cancellationToken).ConfigureAwait(false);
|
||||
var checkCodes = seedEntries.Keys
|
||||
.Union(endpointEntries.Keys, StringComparer.OrdinalIgnoreCase)
|
||||
.Union(controlEntries.Keys, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static code => code, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
@@ -319,11 +352,12 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
{
|
||||
seedEntries.TryGetValue(checkCode, out var seeded);
|
||||
endpointEntries.TryGetValue(checkCode, out var endpoint);
|
||||
controlEntries.TryGetValue(checkCode, out var seededControl);
|
||||
|
||||
var title = seeded?.Title ?? endpoint?.Title ?? checkCode;
|
||||
var severity = NormalizeSeverity(seeded?.Severity ?? endpoint?.Severity ?? "warn");
|
||||
var description = seeded?.Description ?? endpoint?.Description ?? string.Empty;
|
||||
var remediation = seeded?.Remediation;
|
||||
var title = seeded?.Title ?? endpoint?.Title ?? seededControl?.Title ?? checkCode;
|
||||
var severity = NormalizeSeverity(seeded?.Severity ?? endpoint?.Severity ?? seededControl?.Severity ?? "warn");
|
||||
var description = seeded?.Description ?? endpoint?.Description ?? seededControl?.Description ?? string.Empty;
|
||||
var remediation = seeded?.Remediation ?? seededControl?.Remediation;
|
||||
if (string.IsNullOrWhiteSpace(remediation))
|
||||
{
|
||||
remediation = !string.IsNullOrWhiteSpace(description)
|
||||
@@ -331,45 +365,69 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
: $"Inspect {checkCode} and run targeted diagnostics.";
|
||||
}
|
||||
|
||||
var runCommand = string.IsNullOrWhiteSpace(seeded?.RunCommand)
|
||||
var seededRunCommand = seeded?.RunCommand;
|
||||
if (string.IsNullOrWhiteSpace(seededRunCommand) && !string.IsNullOrWhiteSpace(seededControl?.RunCommand))
|
||||
{
|
||||
seededRunCommand = seededControl.RunCommand;
|
||||
}
|
||||
|
||||
var runCommand = string.IsNullOrWhiteSpace(seededRunCommand)
|
||||
? $"stella doctor run --check {checkCode}"
|
||||
: seeded!.RunCommand.Trim();
|
||||
: seededRunCommand.Trim();
|
||||
|
||||
var symptoms = MergeOrdered(
|
||||
seeded?.Symptoms ?? [],
|
||||
endpoint?.Symptoms ?? [],
|
||||
seededControl?.Symptoms ?? [],
|
||||
seededControl?.Keywords ?? [],
|
||||
ExpandSymptomsFromText(description),
|
||||
ExpandSymptomsFromText(title));
|
||||
|
||||
var tags = MergeOrdered(
|
||||
seeded?.Tags ?? [],
|
||||
endpoint?.Tags ?? [],
|
||||
seededControl?.Tags ?? [],
|
||||
["doctor", "diagnostics"]);
|
||||
|
||||
var references = MergeOrdered(
|
||||
seeded?.References ?? [],
|
||||
endpoint?.References ?? []);
|
||||
endpoint?.References ?? [],
|
||||
seededControl?.References ?? []);
|
||||
|
||||
var docId = KnowledgeSearchText.StableId("doc", "doctor", _options.Product, _options.Version, checkCode);
|
||||
var control = BuildDoctorControl(
|
||||
checkCode,
|
||||
severity,
|
||||
runCommand,
|
||||
seeded?.Control,
|
||||
seededControl,
|
||||
symptoms,
|
||||
title,
|
||||
description);
|
||||
|
||||
var docId = KnowledgeSearchText.StableId("doc", "doctor", options.Product, options.Version, checkCode);
|
||||
var docMetadata = CreateJsonDocument(new SortedDictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["kind"] = "doctor",
|
||||
["checkCode"] = checkCode,
|
||||
["tags"] = tags
|
||||
["tags"] = tags,
|
||||
["control"] = control.Control,
|
||||
["requiresConfirmation"] = control.RequiresConfirmation,
|
||||
["isDestructive"] = control.IsDestructive,
|
||||
["requiresBackup"] = control.RequiresBackup
|
||||
});
|
||||
|
||||
documents[docId] = new KnowledgeSourceDocument(
|
||||
docId,
|
||||
"doctor",
|
||||
_options.Product,
|
||||
_options.Version,
|
||||
options.Product,
|
||||
options.Version,
|
||||
"doctor",
|
||||
$"doctor://{checkCode}",
|
||||
title,
|
||||
KnowledgeSearchText.StableId(checkCode, title, remediation),
|
||||
docMetadata);
|
||||
|
||||
var body = BuildDoctorSearchBody(checkCode, title, severity, description, remediation, runCommand, symptoms, references);
|
||||
var body = BuildDoctorSearchBody(checkCode, title, severity, description, remediation, runCommand, symptoms, references, control);
|
||||
var anchor = KnowledgeSearchText.Slugify(checkCode);
|
||||
var chunkId = KnowledgeSearchText.StableId("chunk", "doctor", checkCode, severity);
|
||||
var chunkMetadata = CreateJsonDocument(new SortedDictionary<string, object?>(StringComparer.Ordinal)
|
||||
@@ -378,7 +436,14 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
["severity"] = severity,
|
||||
["runCommand"] = runCommand,
|
||||
["tags"] = tags,
|
||||
["service"] = "doctor"
|
||||
["service"] = "doctor",
|
||||
["control"] = control.Control,
|
||||
["requiresConfirmation"] = control.RequiresConfirmation,
|
||||
["isDestructive"] = control.IsDestructive,
|
||||
["requiresBackup"] = control.RequiresBackup,
|
||||
["inspectCommand"] = control.InspectCommand,
|
||||
["verificationCommand"] = control.VerificationCommand,
|
||||
["keywords"] = control.Keywords
|
||||
});
|
||||
|
||||
chunks[chunkId] = new KnowledgeChunkDocument(
|
||||
@@ -407,9 +472,9 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, DoctorEndpointMetadata>> LoadDoctorEndpointMetadataAsync(CancellationToken cancellationToken)
|
||||
private async Task<Dictionary<string, DoctorEndpointMetadata>> LoadDoctorEndpointMetadataAsync(string endpoint, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.DoctorChecksEndpoint))
|
||||
if (string.IsNullOrWhiteSpace(endpoint))
|
||||
{
|
||||
return new Dictionary<string, DoctorEndpointMetadata>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -419,10 +484,10 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
using var client = _httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromMilliseconds(Math.Max(500, _options.QueryTimeoutMs));
|
||||
|
||||
using var response = await client.GetAsync(_options.DoctorChecksEndpoint, cancellationToken).ConfigureAwait(false);
|
||||
using var response = await client.GetAsync(endpoint, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("Doctor check metadata endpoint {Endpoint} returned {StatusCode}.", _options.DoctorChecksEndpoint, (int)response.StatusCode);
|
||||
_logger.LogWarning("Doctor check metadata endpoint {Endpoint} returned {StatusCode}.", endpoint, (int)response.StatusCode);
|
||||
return new Dictionary<string, DoctorEndpointMetadata>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
@@ -482,7 +547,7 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load doctor metadata from {Endpoint}.", _options.DoctorChecksEndpoint);
|
||||
_logger.LogWarning(ex, "Failed to load doctor metadata from {Endpoint}.", endpoint);
|
||||
return new Dictionary<string, DoctorEndpointMetadata>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -603,30 +668,61 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
return true;
|
||||
}
|
||||
|
||||
private IReadOnlyList<string> EnumerateMarkdownFiles(string repositoryRoot)
|
||||
private IReadOnlyList<string> EnumerateMarkdownFiles(string repositoryRoot, EffectiveIngestionOptions options)
|
||||
{
|
||||
var files = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var root in _options.MarkdownRoots.OrderBy(static item => item, StringComparer.Ordinal))
|
||||
{
|
||||
var absoluteRoot = ResolvePath(repositoryRoot, root);
|
||||
if (!Directory.Exists(absoluteRoot))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(absoluteRoot, "*.md", SearchOption.AllDirectories))
|
||||
{
|
||||
files.Add(Path.GetFullPath(file));
|
||||
}
|
||||
var allowListPath = ResolvePath(repositoryRoot, options.MarkdownAllowListPath);
|
||||
var allowListIncludes = MarkdownSourceAllowListLoader.LoadIncludes(allowListPath);
|
||||
foreach (var includePath in allowListIncludes)
|
||||
{
|
||||
AddMarkdownFilesFromPath(repositoryRoot, includePath, files);
|
||||
}
|
||||
|
||||
if (files.Count > 0)
|
||||
{
|
||||
return files.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
}
|
||||
|
||||
foreach (var root in options.MarkdownRoots.OrderBy(static item => item, StringComparer.Ordinal))
|
||||
{
|
||||
AddMarkdownFilesFromPath(repositoryRoot, root, files);
|
||||
}
|
||||
|
||||
return files.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
}
|
||||
|
||||
private IReadOnlyList<string> EnumerateOpenApiFiles(string repositoryRoot)
|
||||
private void AddMarkdownFilesFromPath(string repositoryRoot, string configuredPath, ISet<string> files)
|
||||
{
|
||||
var absolutePath = ResolvePath(repositoryRoot, configuredPath);
|
||||
if (File.Exists(absolutePath) &&
|
||||
Path.GetExtension(absolutePath).Equals(".md", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
files.Add(Path.GetFullPath(absolutePath));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Directory.Exists(absolutePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(absolutePath, "*.md", SearchOption.AllDirectories))
|
||||
{
|
||||
files.Add(Path.GetFullPath(file));
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<string> EnumerateOpenApiFiles(string repositoryRoot, EffectiveIngestionOptions options)
|
||||
{
|
||||
var aggregatePath = ResolvePath(repositoryRoot, options.OpenApiAggregatePath);
|
||||
if (File.Exists(aggregatePath))
|
||||
{
|
||||
return [Path.GetFullPath(aggregatePath)];
|
||||
}
|
||||
|
||||
var files = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var root in _options.OpenApiRoots.OrderBy(static item => item, StringComparer.Ordinal))
|
||||
foreach (var root in options.OpenApiRoots.OrderBy(static item => item, StringComparer.Ordinal))
|
||||
{
|
||||
var absoluteRoot = ResolvePath(repositoryRoot, root);
|
||||
if (!Directory.Exists(absoluteRoot))
|
||||
@@ -764,7 +860,8 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
string remediation,
|
||||
string runCommand,
|
||||
IReadOnlyList<string> symptoms,
|
||||
IReadOnlyList<string> references)
|
||||
IReadOnlyList<string> references,
|
||||
DoctorControlSeedEntry control)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.Append("check: ").Append(checkCode).AppendLine();
|
||||
@@ -778,6 +875,14 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
|
||||
builder.Append("remediation: ").Append(remediation).AppendLine();
|
||||
builder.Append("run: ").Append(runCommand).AppendLine();
|
||||
builder.Append("control: ").Append(control.Control).AppendLine();
|
||||
builder.Append("requiresConfirmation: ").Append(control.RequiresConfirmation ? "true" : "false").AppendLine();
|
||||
builder.Append("isDestructive: ").Append(control.IsDestructive ? "true" : "false").AppendLine();
|
||||
builder.Append("requiresBackup: ").Append(control.RequiresBackup ? "true" : "false").AppendLine();
|
||||
if (!string.IsNullOrWhiteSpace(control.InspectCommand))
|
||||
{
|
||||
builder.Append("inspect: ").Append(control.InspectCommand).AppendLine();
|
||||
}
|
||||
|
||||
if (symptoms.Count > 0)
|
||||
{
|
||||
@@ -789,9 +894,96 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
builder.Append("references: ").Append(string.Join(", ", references)).AppendLine();
|
||||
}
|
||||
|
||||
if (control.Keywords.Count > 0)
|
||||
{
|
||||
builder.Append("keywords: ").Append(string.Join(", ", control.Keywords)).AppendLine();
|
||||
}
|
||||
|
||||
return builder.ToString().Trim();
|
||||
}
|
||||
|
||||
private static DoctorControlSeedEntry BuildDoctorControl(
|
||||
string checkCode,
|
||||
string severity,
|
||||
string runCommand,
|
||||
DoctorSearchControl? embeddedControl,
|
||||
DoctorControlSeedEntry? seededControl,
|
||||
IReadOnlyList<string> symptoms,
|
||||
string title,
|
||||
string description)
|
||||
{
|
||||
var mode = NormalizeControlMode(seededControl?.Control ?? embeddedControl?.Mode ?? InferControlFromSeverity(severity));
|
||||
var requiresConfirmation = seededControl?.RequiresConfirmation ?? embeddedControl?.RequiresConfirmation ?? !mode.Equals("safe", StringComparison.Ordinal);
|
||||
var isDestructive = seededControl?.IsDestructive ?? embeddedControl?.IsDestructive ?? mode.Equals("destructive", StringComparison.Ordinal);
|
||||
var requiresBackup = seededControl?.RequiresBackup ?? embeddedControl?.RequiresBackup ?? isDestructive;
|
||||
|
||||
var inspectCommand = FirstNonEmpty(
|
||||
seededControl?.InspectCommand,
|
||||
embeddedControl?.InspectCommand,
|
||||
$"stella doctor run --check {checkCode} --mode quick");
|
||||
|
||||
var verificationCommand = FirstNonEmpty(
|
||||
seededControl?.VerificationCommand,
|
||||
embeddedControl?.VerificationCommand,
|
||||
runCommand);
|
||||
|
||||
var keywords = MergeOrdered(
|
||||
seededControl?.Keywords ?? [],
|
||||
symptoms,
|
||||
ExpandSymptomsFromText(title),
|
||||
ExpandSymptomsFromText(description));
|
||||
|
||||
return new DoctorControlSeedEntry(
|
||||
checkCode,
|
||||
mode,
|
||||
requiresConfirmation,
|
||||
isDestructive,
|
||||
requiresBackup,
|
||||
inspectCommand,
|
||||
verificationCommand,
|
||||
keywords);
|
||||
}
|
||||
|
||||
private static string FirstNonEmpty(params string?[] values)
|
||||
{
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static string InferControlFromSeverity(string severity)
|
||||
{
|
||||
return NormalizeSeverity(severity) switch
|
||||
{
|
||||
"fail" => "manual",
|
||||
"warn" => "safe",
|
||||
_ => "safe"
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeControlMode(string control)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(control))
|
||||
{
|
||||
return "safe";
|
||||
}
|
||||
|
||||
return control.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"safe" => "safe",
|
||||
"manual" => "manual",
|
||||
"destructive" => "destructive",
|
||||
"disabled" => "disabled",
|
||||
_ => "safe"
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonDocument CloneOrDefault(JsonElement source, string propertyName, string fallbackJson)
|
||||
{
|
||||
if (source.ValueKind == JsonValueKind.Object && source.TryGetProperty(propertyName, out var value))
|
||||
@@ -989,6 +1181,18 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
|
||||
return JsonDocument.Parse(json);
|
||||
}
|
||||
|
||||
private sealed record EffectiveIngestionOptions(
|
||||
string Product,
|
||||
string Version,
|
||||
string RepositoryRoot,
|
||||
string DoctorChecksEndpoint,
|
||||
string DoctorSeedPath,
|
||||
string DoctorControlsPath,
|
||||
string MarkdownAllowListPath,
|
||||
IReadOnlyList<string> MarkdownRoots,
|
||||
string OpenApiAggregatePath,
|
||||
IReadOnlyList<string> OpenApiRoots);
|
||||
|
||||
private sealed record DoctorEndpointMetadata(
|
||||
string Title,
|
||||
string Severity,
|
||||
|
||||
@@ -65,7 +65,10 @@ public sealed record KnowledgeOpenDoctorAction(
|
||||
string CheckCode,
|
||||
string Severity,
|
||||
bool CanRun,
|
||||
string RunCommand);
|
||||
string RunCommand,
|
||||
string Control = "safe",
|
||||
bool RequiresConfirmation = false,
|
||||
bool IsDestructive = false);
|
||||
|
||||
public sealed record KnowledgeSearchDiagnostics(
|
||||
int FtsMatches,
|
||||
|
||||
@@ -42,6 +42,14 @@ public sealed class KnowledgeSearchOptions
|
||||
public string DoctorSeedPath { get; set; } =
|
||||
"src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/doctor-search-seed.json";
|
||||
|
||||
public string DoctorControlsPath { get; set; } =
|
||||
"src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/doctor-search-controls.json";
|
||||
|
||||
public string MarkdownAllowListPath { get; set; } =
|
||||
"src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/knowledge-docs-allowlist.json";
|
||||
|
||||
public string OpenApiAggregatePath { get; set; } = "devops/compose/openapi_current.json";
|
||||
|
||||
public List<string> MarkdownRoots { get; set; } = ["docs"];
|
||||
|
||||
public List<string> OpenApiRoots { get; set; } = ["src", "devops/compose"];
|
||||
|
||||
@@ -11,6 +11,66 @@ internal sealed class KnowledgeSearchService : IKnowledgeSearchService
|
||||
{
|
||||
private const int ReciprocalRankConstant = 60;
|
||||
private static readonly Regex MethodPathPattern = new("\\b(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|TRACE)\\s+(/[^\\s]+)", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
|
||||
private static readonly string[] ApiIntentTerms =
|
||||
[
|
||||
"endpoint",
|
||||
"api",
|
||||
"openapi",
|
||||
"swagger",
|
||||
"operation",
|
||||
"route",
|
||||
"path",
|
||||
"method",
|
||||
"contract"
|
||||
];
|
||||
private static readonly string[] DoctorIntentTerms =
|
||||
[
|
||||
"doctor",
|
||||
"check",
|
||||
"readiness",
|
||||
"health",
|
||||
"diagnostic",
|
||||
"remediation",
|
||||
"symptom"
|
||||
];
|
||||
private static readonly string[] DocsIntentTerms =
|
||||
[
|
||||
"how to",
|
||||
"how do i",
|
||||
"guide",
|
||||
"runbook",
|
||||
"troubleshoot",
|
||||
"documentation",
|
||||
"docs",
|
||||
"playbook",
|
||||
"steps"
|
||||
];
|
||||
private static readonly HashSet<string> NoiseTerms = new(StringComparer.Ordinal)
|
||||
{
|
||||
"a",
|
||||
"an",
|
||||
"and",
|
||||
"api",
|
||||
"do",
|
||||
"endpoint",
|
||||
"for",
|
||||
"how",
|
||||
"i",
|
||||
"in",
|
||||
"is",
|
||||
"method",
|
||||
"of",
|
||||
"on",
|
||||
"operation",
|
||||
"or",
|
||||
"path",
|
||||
"route",
|
||||
"the",
|
||||
"to",
|
||||
"what",
|
||||
"where",
|
||||
"which"
|
||||
};
|
||||
|
||||
private readonly KnowledgeSearchOptions _options;
|
||||
private readonly IKnowledgeSearchStore _store;
|
||||
@@ -181,9 +241,31 @@ internal sealed class KnowledgeSearchService : IKnowledgeSearchService
|
||||
var normalizedQuery = query.Trim();
|
||||
var lowerQuery = normalizedQuery.ToLowerInvariant();
|
||||
var metadata = row.Metadata.RootElement;
|
||||
var isApi = row.Kind.Equals("api_operation", StringComparison.OrdinalIgnoreCase);
|
||||
var isDoctor = row.Kind.Equals("doctor_check", StringComparison.OrdinalIgnoreCase);
|
||||
var isDocs = !isApi && !isDoctor;
|
||||
|
||||
var boost = 0d;
|
||||
if (row.Kind.Equals("doctor_check", StringComparison.OrdinalIgnoreCase))
|
||||
var apiIntent = ContainsAnyTerm(lowerQuery, ApiIntentTerms);
|
||||
var doctorIntent = ContainsAnyTerm(lowerQuery, DoctorIntentTerms);
|
||||
var docsIntent = ContainsAnyTerm(lowerQuery, DocsIntentTerms);
|
||||
|
||||
if (apiIntent)
|
||||
{
|
||||
boost += isApi ? 0.28d : -0.04d;
|
||||
}
|
||||
|
||||
if (doctorIntent && isDoctor)
|
||||
{
|
||||
boost += 0.20d;
|
||||
}
|
||||
|
||||
if (docsIntent && isDocs)
|
||||
{
|
||||
boost += 0.12d;
|
||||
}
|
||||
|
||||
if (isDoctor)
|
||||
{
|
||||
var checkCode = GetMetadataString(metadata, "checkCode");
|
||||
if (!string.IsNullOrWhiteSpace(checkCode) && checkCode.Equals(normalizedQuery, StringComparison.OrdinalIgnoreCase))
|
||||
@@ -192,8 +274,16 @@ internal sealed class KnowledgeSearchService : IKnowledgeSearchService
|
||||
}
|
||||
}
|
||||
|
||||
if (row.Kind.Equals("api_operation", StringComparison.OrdinalIgnoreCase))
|
||||
if (isApi)
|
||||
{
|
||||
var apiText = $"{row.Title} {row.Body}".ToLowerInvariant();
|
||||
var termMatches = ExtractSalientTerms(lowerQuery)
|
||||
.Count(term => apiText.Contains(term, StringComparison.Ordinal));
|
||||
if (termMatches > 0)
|
||||
{
|
||||
boost += Math.Min(0.30d, termMatches * 0.08d);
|
||||
}
|
||||
|
||||
var operationId = GetMetadataString(metadata, "operationId");
|
||||
if (!string.IsNullOrWhiteSpace(operationId) && operationId.Equals(normalizedQuery, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -249,6 +339,40 @@ internal sealed class KnowledgeSearchService : IKnowledgeSearchService
|
||||
return boost;
|
||||
}
|
||||
|
||||
private static bool ContainsAnyTerm(string query, IReadOnlyList<string> terms)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query) || terms.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var term in terms)
|
||||
{
|
||||
if (query.Contains(term, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractSalientTerms(string query)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return query
|
||||
.Split([' ', '\t', '\r', '\n', ':', ';', ',', '.', '/', '\\', '?', '!', '[', ']', '{', '}', '(', ')', '"', '\''], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(static token => token.ToLowerInvariant())
|
||||
.Where(static token => token.Length >= 4)
|
||||
.Where(token => !NoiseTerms.Contains(token))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private KnowledgeSearchResult BuildResult(
|
||||
KnowledgeChunkRow row,
|
||||
string query,
|
||||
@@ -282,8 +406,11 @@ internal sealed class KnowledgeSearchService : IKnowledgeSearchService
|
||||
Doctor: new KnowledgeOpenDoctorAction(
|
||||
GetMetadataString(metadata, "checkCode") ?? row.Title,
|
||||
GetMetadataString(metadata, "severity") ?? "warn",
|
||||
true,
|
||||
GetMetadataString(metadata, "runCommand") ?? $"stella doctor run --check {row.Title}")),
|
||||
!string.Equals(GetMetadataString(metadata, "control"), "disabled", StringComparison.OrdinalIgnoreCase),
|
||||
GetMetadataString(metadata, "runCommand") ?? $"stella doctor run --check {row.Title}",
|
||||
GetMetadataString(metadata, "control") ?? "safe",
|
||||
GetMetadataBoolean(metadata, "requiresConfirmation"),
|
||||
GetMetadataBoolean(metadata, "isDestructive"))),
|
||||
_ => new KnowledgeOpenAction(
|
||||
KnowledgeOpenActionType.Docs,
|
||||
Docs: new KnowledgeOpenDocAction(
|
||||
@@ -360,6 +487,22 @@ internal sealed class KnowledgeSearchService : IKnowledgeSearchService
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static bool GetMetadataBoolean(JsonElement metadata, string propertyName)
|
||||
{
|
||||
if (metadata.ValueKind != JsonValueKind.Object || !metadata.TryGetProperty(propertyName, out var value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.String => bool.TryParse(value.GetString(), out var parsed) && parsed,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private int ResolveTopK(int? requested)
|
||||
{
|
||||
var fallback = Math.Max(1, _options.DefaultTopK);
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
|
||||
internal static class MarkdownSourceAllowListLoader
|
||||
{
|
||||
public static IReadOnlyList<string> LoadIncludes(string absolutePath)
|
||||
{
|
||||
if (!File.Exists(absolutePath))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(absolutePath);
|
||||
using var document = JsonDocument.Parse(stream);
|
||||
return ExtractIncludes(document.RootElement);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractIncludes(JsonElement element)
|
||||
{
|
||||
IEnumerable<string> values = [];
|
||||
if (element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
values = ReadStringArray(element);
|
||||
}
|
||||
else if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (TryGetArray(element, "include", out var include))
|
||||
{
|
||||
values = ReadStringArray(include);
|
||||
}
|
||||
else if (TryGetArray(element, "includes", out include))
|
||||
{
|
||||
values = ReadStringArray(include);
|
||||
}
|
||||
else if (TryGetArray(element, "paths", out include))
|
||||
{
|
||||
values = ReadStringArray(include);
|
||||
}
|
||||
}
|
||||
|
||||
return values
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(static value => value.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static value => value, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static bool TryGetArray(JsonElement element, string propertyName, out JsonElement value)
|
||||
{
|
||||
if (element.TryGetProperty(propertyName, out value) && value.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ReadStringArray(JsonElement array)
|
||||
{
|
||||
foreach (var item in array.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var value = item.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
yield return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
[
|
||||
{
|
||||
"checkCode": "check.airgap.bundle.integrity",
|
||||
"control": "manual",
|
||||
"requiresConfirmation": true,
|
||||
"isDestructive": false,
|
||||
"requiresBackup": false,
|
||||
"inspectCommand": "stella doctor run --check check.airgap.bundle.integrity --mode quick",
|
||||
"verificationCommand": "stella doctor run --check check.airgap.bundle.integrity",
|
||||
"keywords": [
|
||||
"checksum mismatch",
|
||||
"signature invalid",
|
||||
"offline import failed"
|
||||
]
|
||||
},
|
||||
{
|
||||
"checkCode": "check.core.db.connectivity",
|
||||
"control": "manual",
|
||||
"requiresConfirmation": true,
|
||||
"isDestructive": false,
|
||||
"requiresBackup": false,
|
||||
"inspectCommand": "stella doctor run --check check.core.db.connectivity --mode quick",
|
||||
"verificationCommand": "stella doctor run --check check.core.db.connectivity",
|
||||
"keywords": [
|
||||
"connection refused",
|
||||
"database unavailable",
|
||||
"timeout expired"
|
||||
]
|
||||
},
|
||||
{
|
||||
"checkCode": "check.core.disk.space",
|
||||
"control": "manual",
|
||||
"requiresConfirmation": true,
|
||||
"isDestructive": false,
|
||||
"requiresBackup": false,
|
||||
"inspectCommand": "stella doctor run --check check.core.disk.space --mode quick",
|
||||
"verificationCommand": "stella doctor run --check check.core.disk.space",
|
||||
"keywords": [
|
||||
"disk full",
|
||||
"no space left on device",
|
||||
"write failure"
|
||||
]
|
||||
},
|
||||
{
|
||||
"checkCode": "check.integrations.secrets.binding",
|
||||
"control": "safe",
|
||||
"requiresConfirmation": false,
|
||||
"isDestructive": false,
|
||||
"requiresBackup": false,
|
||||
"inspectCommand": "stella doctor run --check check.integrations.secrets.binding --mode quick",
|
||||
"verificationCommand": "stella doctor run --check check.integrations.secrets.binding",
|
||||
"keywords": [
|
||||
"auth failed",
|
||||
"invalid credential",
|
||||
"secret missing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"checkCode": "check.release.policy.gate",
|
||||
"control": "safe",
|
||||
"requiresConfirmation": false,
|
||||
"isDestructive": false,
|
||||
"requiresBackup": false,
|
||||
"inspectCommand": "stella doctor run --check check.release.policy.gate --mode quick",
|
||||
"verificationCommand": "stella doctor run --check check.release.policy.gate",
|
||||
"keywords": [
|
||||
"missing attestation",
|
||||
"policy gate failed",
|
||||
"promotion blocked"
|
||||
]
|
||||
},
|
||||
{
|
||||
"checkCode": "check.router.gateway.routes",
|
||||
"control": "safe",
|
||||
"requiresConfirmation": false,
|
||||
"isDestructive": false,
|
||||
"requiresBackup": false,
|
||||
"inspectCommand": "stella doctor run --check check.router.gateway.routes --mode quick",
|
||||
"verificationCommand": "stella doctor run --check check.router.gateway.routes",
|
||||
"keywords": [
|
||||
"404 on expected endpoint",
|
||||
"gateway routing",
|
||||
"route missing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"checkCode": "check.security.oidc.readiness",
|
||||
"control": "safe",
|
||||
"requiresConfirmation": false,
|
||||
"isDestructive": false,
|
||||
"requiresBackup": false,
|
||||
"inspectCommand": "stella doctor run --check check.security.oidc.readiness --mode quick",
|
||||
"verificationCommand": "stella doctor run --check check.security.oidc.readiness",
|
||||
"keywords": [
|
||||
"invalid issuer",
|
||||
"jwks fetch failed",
|
||||
"oidc setup"
|
||||
]
|
||||
},
|
||||
{
|
||||
"checkCode": "check.telemetry.pipeline.delivery",
|
||||
"control": "safe",
|
||||
"requiresConfirmation": false,
|
||||
"isDestructive": false,
|
||||
"requiresBackup": false,
|
||||
"inspectCommand": "stella doctor run --check check.telemetry.pipeline.delivery --mode quick",
|
||||
"verificationCommand": "stella doctor run --check check.telemetry.pipeline.delivery",
|
||||
"keywords": [
|
||||
"delivery timeout",
|
||||
"queue backlog",
|
||||
"telemetry lag"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"schema": "stellaops.advisoryai.docs-allowlist.v1",
|
||||
"include": [
|
||||
"docs/README.md",
|
||||
"docs/INSTALL_GUIDE.md",
|
||||
"docs/modules/advisory-ai",
|
||||
"docs/modules/authority",
|
||||
"docs/modules/cli",
|
||||
"docs/modules/platform",
|
||||
"docs/modules/policy",
|
||||
"docs/modules/router",
|
||||
"docs/modules/scanner",
|
||||
"docs/operations",
|
||||
"docs/operations/devops/runbooks"
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,11 @@
|
||||
<InternalsVisibleTo Include="StellaOps.AdvisoryAI.Tests" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Storage/Migrations/*.sql" />
|
||||
<EmbeddedResource Include="Storage\Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<!-- Prevent automatic compiled-model binding so non-default schemas can build runtime models. -->
|
||||
<Compile Remove="Storage\EfCore\CompiledModels\AdvisoryAiDbContextAssemblyAttributes.cs" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="KnowledgeSearch/doctor-search-seed.json">
|
||||
@@ -20,10 +24,13 @@
|
||||
</None>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />
|
||||
@@ -36,5 +43,7 @@
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Canonicalization\StellaOps.Canonicalization.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -2,24 +2,25 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.AdvisoryAI.Chat;
|
||||
using StellaOps.AdvisoryAI.Storage.EfCore.Models;
|
||||
using StellaOps.AdvisoryAI.Storage.Postgres;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL-backed conversation storage.
|
||||
/// PostgreSQL-backed conversation storage using EF Core.
|
||||
/// Sprint: SPRINT_20260107_006_003 Task CH-008
|
||||
/// Sprint: SPRINT_20260222_074 (EF Core conversion)
|
||||
/// </summary>
|
||||
public sealed class ConversationStore : IConversationStore, IAsyncDisposable
|
||||
public sealed class ConversationStore : RepositoryBase<AdvisoryAiDataSource>, IConversationStore, IAsyncDisposable
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly ILogger<ConversationStore> _logger;
|
||||
private readonly ConversationStoreOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
@@ -32,13 +33,12 @@ public sealed class ConversationStore : IConversationStore, IAsyncDisposable
|
||||
/// Initializes a new instance of the <see cref="ConversationStore"/> class.
|
||||
/// </summary>
|
||||
public ConversationStore(
|
||||
NpgsqlDataSource dataSource,
|
||||
AdvisoryAiDataSource dataSource,
|
||||
ILogger<ConversationStore> logger,
|
||||
ConversationStoreOptions? options = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
_options = options ?? new ConversationStoreOptions();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
@@ -48,28 +48,37 @@ public sealed class ConversationStore : IConversationStore, IAsyncDisposable
|
||||
Conversation conversation,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO advisoryai.conversations (
|
||||
conversation_id, tenant_id, user_id, created_at, updated_at,
|
||||
context, metadata
|
||||
) VALUES (
|
||||
@conversationId, @tenantId, @userId, @createdAt, @updatedAt,
|
||||
@context::jsonb, @metadata::jsonb
|
||||
)
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(
|
||||
conversation.TenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AdvisoryAiDbContextFactory.Create(
|
||||
connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("conversationId", conversation.ConversationId);
|
||||
cmd.Parameters.AddWithValue("tenantId", conversation.TenantId);
|
||||
cmd.Parameters.AddWithValue("userId", conversation.UserId);
|
||||
cmd.Parameters.AddWithValue("createdAt", conversation.CreatedAt);
|
||||
cmd.Parameters.AddWithValue("updatedAt", conversation.UpdatedAt);
|
||||
cmd.Parameters.AddWithValue("context", JsonSerializer.Serialize(conversation.Context, JsonOptions));
|
||||
cmd.Parameters.AddWithValue("metadata", JsonSerializer.Serialize(conversation.Metadata, JsonOptions));
|
||||
var entity = new ConversationEntity
|
||||
{
|
||||
ConversationId = conversation.ConversationId,
|
||||
TenantId = conversation.TenantId,
|
||||
UserId = conversation.UserId,
|
||||
CreatedAt = conversation.CreatedAt.UtcDateTime,
|
||||
UpdatedAt = conversation.UpdatedAt.UtcDateTime,
|
||||
Context = JsonSerializer.Serialize(conversation.Context, JsonOptions),
|
||||
Metadata = JsonSerializer.Serialize(conversation.Metadata, JsonOptions)
|
||||
};
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
dbContext.Conversations.Add(entity);
|
||||
|
||||
_logger.LogInformation(
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
|
||||
{
|
||||
// Idempotency: conversation already exists, treat as success.
|
||||
Logger.LogDebug(
|
||||
"Conversation {ConversationId} already exists (idempotent create)",
|
||||
conversation.ConversationId);
|
||||
}
|
||||
|
||||
Logger.LogInformation(
|
||||
"Created conversation {ConversationId} for user {UserId}",
|
||||
conversation.ConversationId, conversation.UserId);
|
||||
|
||||
@@ -81,25 +90,32 @@ public sealed class ConversationStore : IConversationStore, IAsyncDisposable
|
||||
string conversationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM advisoryai.conversations
|
||||
WHERE conversation_id = @conversationId
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(
|
||||
string.Empty, "reader", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AdvisoryAiDbContextFactory.Create(
|
||||
connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("conversationId", conversationId);
|
||||
var entity = await dbContext.Conversations
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.ConversationId == conversationId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
if (entity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var conversation = await MapConversationAsync(reader, cancellationToken).ConfigureAwait(false);
|
||||
var conversation = MapConversation(entity);
|
||||
|
||||
// Load turns
|
||||
var turns = await GetTurnsAsync(conversationId, cancellationToken).ConfigureAwait(false);
|
||||
// Load turns ordered by timestamp ASC (preserves original ordering semantics)
|
||||
var turnEntities = await dbContext.Turns
|
||||
.AsNoTracking()
|
||||
.Where(t => t.ConversationId == conversationId)
|
||||
.OrderBy(t => t.Timestamp)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var turns = turnEntities.Select(MapTurn).ToImmutableArray();
|
||||
|
||||
return conversation with { Turns = turns };
|
||||
}
|
||||
@@ -111,26 +127,20 @@ public sealed class ConversationStore : IConversationStore, IAsyncDisposable
|
||||
int limit = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = string.Create(CultureInfo.InvariantCulture, $"""
|
||||
SELECT * FROM advisoryai.conversations
|
||||
WHERE tenant_id = @tenantId AND user_id = @userId
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT {limit}
|
||||
""");
|
||||
await using var connection = await DataSource.OpenConnectionAsync(
|
||||
tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AdvisoryAiDbContextFactory.Create(
|
||||
connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("userId", userId);
|
||||
var entities = await dbContext.Conversations
|
||||
.AsNoTracking()
|
||||
.Where(c => c.TenantId == tenantId && c.UserId == userId)
|
||||
.OrderByDescending(c => c.UpdatedAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var conversations = new List<Conversation>();
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
conversations.Add(await MapConversationAsync(reader, cancellationToken).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
return conversations;
|
||||
return entities.Select(MapConversation).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -139,49 +149,39 @@ public sealed class ConversationStore : IConversationStore, IAsyncDisposable
|
||||
ConversationTurn turn,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string insertSql = """
|
||||
INSERT INTO advisoryai.turns (
|
||||
turn_id, conversation_id, role, content, timestamp,
|
||||
evidence_links, proposed_actions, metadata
|
||||
) VALUES (
|
||||
@turnId, @conversationId, @role, @content, @timestamp,
|
||||
@evidenceLinks::jsonb, @proposedActions::jsonb, @metadata::jsonb
|
||||
)
|
||||
""";
|
||||
|
||||
const string updateSql = """
|
||||
UPDATE advisoryai.conversations
|
||||
SET updated_at = @updatedAt
|
||||
WHERE conversation_id = @conversationId
|
||||
""";
|
||||
|
||||
await using var transaction = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await DataSource.OpenConnectionAsync(
|
||||
string.Empty, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AdvisoryAiDbContextFactory.Create(
|
||||
connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
// Insert turn
|
||||
await using (var insertCmd = _dataSource.CreateCommand(insertSql))
|
||||
var turnEntity = new TurnEntity
|
||||
{
|
||||
insertCmd.Parameters.AddWithValue("turnId", turn.TurnId);
|
||||
insertCmd.Parameters.AddWithValue("conversationId", conversationId);
|
||||
insertCmd.Parameters.AddWithValue("role", turn.Role.ToString());
|
||||
insertCmd.Parameters.AddWithValue("content", turn.Content);
|
||||
insertCmd.Parameters.AddWithValue("timestamp", turn.Timestamp);
|
||||
insertCmd.Parameters.AddWithValue("evidenceLinks", JsonSerializer.Serialize(turn.EvidenceLinks, JsonOptions));
|
||||
insertCmd.Parameters.AddWithValue("proposedActions", JsonSerializer.Serialize(turn.ProposedActions, JsonOptions));
|
||||
insertCmd.Parameters.AddWithValue("metadata", JsonSerializer.Serialize(turn.Metadata, JsonOptions));
|
||||
TurnId = turn.TurnId,
|
||||
ConversationId = conversationId,
|
||||
Role = turn.Role.ToString(),
|
||||
Content = turn.Content,
|
||||
Timestamp = turn.Timestamp.UtcDateTime,
|
||||
EvidenceLinks = JsonSerializer.Serialize(turn.EvidenceLinks, JsonOptions),
|
||||
ProposedActions = JsonSerializer.Serialize(turn.ProposedActions, JsonOptions),
|
||||
Metadata = JsonSerializer.Serialize(turn.Metadata, JsonOptions)
|
||||
};
|
||||
|
||||
await insertCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
dbContext.Turns.Add(turnEntity);
|
||||
|
||||
// Update conversation timestamp
|
||||
await using (var updateCmd = _dataSource.CreateCommand(updateSql))
|
||||
{
|
||||
updateCmd.Parameters.AddWithValue("conversationId", conversationId);
|
||||
updateCmd.Parameters.AddWithValue("updatedAt", turn.Timestamp);
|
||||
var conversation = await dbContext.Conversations
|
||||
.FirstOrDefaultAsync(c => c.ConversationId == conversationId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await updateCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (conversation is not null)
|
||||
{
|
||||
conversation.UpdatedAt = turn.Timestamp.UtcDateTime;
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Logger.LogDebug(
|
||||
"Added turn {TurnId} to conversation {ConversationId}",
|
||||
turn.TurnId, conversationId);
|
||||
|
||||
@@ -193,19 +193,19 @@ public sealed class ConversationStore : IConversationStore, IAsyncDisposable
|
||||
string conversationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
DELETE FROM advisoryai.conversations
|
||||
WHERE conversation_id = @conversationId
|
||||
""";
|
||||
await using var connection = await DataSource.OpenConnectionAsync(
|
||||
string.Empty, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AdvisoryAiDbContextFactory.Create(
|
||||
connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("conversationId", conversationId);
|
||||
|
||||
var rowsAffected = await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
var rowsAffected = await dbContext.Conversations
|
||||
.Where(c => c.ConversationId == conversationId)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (rowsAffected > 0)
|
||||
{
|
||||
_logger.LogInformation("Deleted conversation {ConversationId}", conversationId);
|
||||
Logger.LogInformation("Deleted conversation {ConversationId}", conversationId);
|
||||
}
|
||||
|
||||
return rowsAffected > 0;
|
||||
@@ -216,128 +216,129 @@ public sealed class ConversationStore : IConversationStore, IAsyncDisposable
|
||||
TimeSpan maxAge,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
DELETE FROM advisoryai.conversations
|
||||
WHERE updated_at < @cutoff
|
||||
""";
|
||||
|
||||
var cutoff = _timeProvider.GetUtcNow() - maxAge;
|
||||
var cutoffUtc = cutoff.UtcDateTime;
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("cutoff", cutoff);
|
||||
await using var connection = await DataSource.OpenConnectionAsync(
|
||||
string.Empty, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AdvisoryAiDbContextFactory.Create(
|
||||
connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var rowsDeleted = await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
var rowsDeleted = await dbContext.Conversations
|
||||
.Where(c => c.UpdatedAt < cutoffUtc)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (rowsDeleted > 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
Logger.LogInformation(
|
||||
"Cleaned up {Count} expired conversations older than {MaxAge}",
|
||||
rowsDeleted, maxAge);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
// NpgsqlDataSource is typically managed by DI, so we don't dispose it here
|
||||
await Task.CompletedTask;
|
||||
// DataSource is managed by DI, so we don't dispose it here
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task<ImmutableArray<ConversationTurn>> GetTurnsAsync(
|
||||
string conversationId,
|
||||
CancellationToken cancellationToken)
|
||||
private string GetSchemaName()
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM advisoryai.turns
|
||||
WHERE conversation_id = @conversationId
|
||||
ORDER BY timestamp ASC
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("conversationId", conversationId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var turns = new List<ConversationTurn>();
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
if (!string.IsNullOrWhiteSpace(DataSource.SchemaName))
|
||||
{
|
||||
turns.Add(MapTurn(reader));
|
||||
return DataSource.SchemaName!;
|
||||
}
|
||||
|
||||
return turns.ToImmutableArray();
|
||||
return AdvisoryAiDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
private async Task<Conversation> MapConversationAsync(
|
||||
NpgsqlDataReader reader,
|
||||
CancellationToken cancellationToken)
|
||||
private static Conversation MapConversation(ConversationEntity entity)
|
||||
{
|
||||
_ = cancellationToken; // Suppress unused parameter warning
|
||||
|
||||
var contextJson = reader.IsDBNull(reader.GetOrdinal("context"))
|
||||
? null : reader.GetString(reader.GetOrdinal("context"));
|
||||
var metadataJson = reader.IsDBNull(reader.GetOrdinal("metadata"))
|
||||
? null : reader.GetString(reader.GetOrdinal("metadata"));
|
||||
|
||||
var context = contextJson != null
|
||||
? JsonSerializer.Deserialize<ConversationContext>(contextJson, JsonOptions) ?? new ConversationContext()
|
||||
var context = entity.Context != null
|
||||
? JsonSerializer.Deserialize<ConversationContext>(entity.Context, JsonOptions) ?? new ConversationContext()
|
||||
: new ConversationContext();
|
||||
|
||||
var metadata = metadataJson != null
|
||||
? JsonSerializer.Deserialize<ImmutableDictionary<string, string>>(metadataJson, JsonOptions)
|
||||
var metadata = entity.Metadata != null
|
||||
? JsonSerializer.Deserialize<ImmutableDictionary<string, string>>(entity.Metadata, JsonOptions)
|
||||
?? ImmutableDictionary<string, string>.Empty
|
||||
: ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
return new Conversation
|
||||
{
|
||||
ConversationId = reader.GetString(reader.GetOrdinal("conversation_id")),
|
||||
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
|
||||
UserId = reader.GetString(reader.GetOrdinal("user_id")),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
||||
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("updated_at")),
|
||||
ConversationId = entity.ConversationId,
|
||||
TenantId = entity.TenantId,
|
||||
UserId = entity.UserId,
|
||||
CreatedAt = ToUtcOffset(entity.CreatedAt),
|
||||
UpdatedAt = ToUtcOffset(entity.UpdatedAt),
|
||||
Context = context,
|
||||
Metadata = metadata,
|
||||
Turns = ImmutableArray<ConversationTurn>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static ConversationTurn MapTurn(NpgsqlDataReader reader)
|
||||
private static ConversationTurn MapTurn(TurnEntity entity)
|
||||
{
|
||||
var evidenceLinksJson = reader.IsDBNull(reader.GetOrdinal("evidence_links"))
|
||||
? null : reader.GetString(reader.GetOrdinal("evidence_links"));
|
||||
var proposedActionsJson = reader.IsDBNull(reader.GetOrdinal("proposed_actions"))
|
||||
? null : reader.GetString(reader.GetOrdinal("proposed_actions"));
|
||||
var metadataJson = reader.IsDBNull(reader.GetOrdinal("metadata"))
|
||||
? null : reader.GetString(reader.GetOrdinal("metadata"));
|
||||
|
||||
var evidenceLinks = evidenceLinksJson != null
|
||||
? JsonSerializer.Deserialize<ImmutableArray<EvidenceLink>>(evidenceLinksJson, JsonOptions)
|
||||
var evidenceLinks = entity.EvidenceLinks != null
|
||||
? JsonSerializer.Deserialize<ImmutableArray<EvidenceLink>>(entity.EvidenceLinks, JsonOptions)
|
||||
: ImmutableArray<EvidenceLink>.Empty;
|
||||
|
||||
var proposedActions = proposedActionsJson != null
|
||||
? JsonSerializer.Deserialize<ImmutableArray<ProposedAction>>(proposedActionsJson, JsonOptions)
|
||||
var proposedActions = entity.ProposedActions != null
|
||||
? JsonSerializer.Deserialize<ImmutableArray<ProposedAction>>(entity.ProposedActions, JsonOptions)
|
||||
: ImmutableArray<ProposedAction>.Empty;
|
||||
|
||||
var metadata = metadataJson != null
|
||||
? JsonSerializer.Deserialize<ImmutableDictionary<string, string>>(metadataJson, JsonOptions)
|
||||
var metadata = entity.Metadata != null
|
||||
? JsonSerializer.Deserialize<ImmutableDictionary<string, string>>(entity.Metadata, JsonOptions)
|
||||
?? ImmutableDictionary<string, string>.Empty
|
||||
: ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
var roleStr = reader.GetString(reader.GetOrdinal("role"));
|
||||
var role = Enum.TryParse<TurnRole>(roleStr, ignoreCase: true, out var parsedRole)
|
||||
var role = Enum.TryParse<TurnRole>(entity.Role, ignoreCase: true, out var parsedRole)
|
||||
? parsedRole
|
||||
: TurnRole.User;
|
||||
|
||||
return new ConversationTurn
|
||||
{
|
||||
TurnId = reader.GetString(reader.GetOrdinal("turn_id")),
|
||||
TurnId = entity.TurnId,
|
||||
Role = role,
|
||||
Content = reader.GetString(reader.GetOrdinal("content")),
|
||||
Timestamp = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("timestamp")),
|
||||
Content = entity.Content,
|
||||
Timestamp = ToUtcOffset(entity.Timestamp),
|
||||
EvidenceLinks = evidenceLinks,
|
||||
ProposedActions = proposedActions,
|
||||
Metadata = metadata
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTimeOffset ToUtcOffset(DateTime value)
|
||||
{
|
||||
if (value.Kind == DateTimeKind.Utc)
|
||||
{
|
||||
return new DateTimeOffset(value, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
if (value.Kind == DateTimeKind.Local)
|
||||
{
|
||||
return new DateTimeOffset(value.ToUniversalTime(), TimeSpan.Zero);
|
||||
}
|
||||
|
||||
return new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Utc), TimeSpan.Zero);
|
||||
}
|
||||
|
||||
private static bool IsUniqueViolation(DbUpdateException exception)
|
||||
{
|
||||
Exception? current = exception;
|
||||
while (current is not null)
|
||||
{
|
||||
if (current is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
current = current.InnerException;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// <auto-generated />
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.AdvisoryAI.Storage.EfCore.CompiledModels;
|
||||
using StellaOps.AdvisoryAI.Storage.EfCore.Context;
|
||||
|
||||
[assembly: DbContext(typeof(AdvisoryAiDbContext), optimizedModel: typeof(AdvisoryAiDbContextModel))]
|
||||
@@ -0,0 +1,48 @@
|
||||
// <auto-generated />
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using StellaOps.AdvisoryAI.Storage.EfCore.Context;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Storage.EfCore.CompiledModels
|
||||
{
|
||||
[DbContext(typeof(AdvisoryAiDbContext))]
|
||||
public partial class AdvisoryAiDbContextModel : RuntimeModel
|
||||
{
|
||||
private static readonly bool _useOldBehavior31751 =
|
||||
System.AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue31751", out var enabled31751) && enabled31751;
|
||||
|
||||
static AdvisoryAiDbContextModel()
|
||||
{
|
||||
var model = new AdvisoryAiDbContextModel();
|
||||
|
||||
if (_useOldBehavior31751)
|
||||
{
|
||||
model.Initialize();
|
||||
}
|
||||
else
|
||||
{
|
||||
var thread = new System.Threading.Thread(RunInitialization, 10 * 1024 * 1024);
|
||||
thread.Start();
|
||||
thread.Join();
|
||||
|
||||
void RunInitialization()
|
||||
{
|
||||
model.Initialize();
|
||||
}
|
||||
}
|
||||
|
||||
model.Customize();
|
||||
_instance = (AdvisoryAiDbContextModel)model.FinalizeModel();
|
||||
}
|
||||
|
||||
private static AdvisoryAiDbContextModel _instance;
|
||||
public static IModel Instance => _instance;
|
||||
|
||||
partial void Initialize();
|
||||
|
||||
partial void Customize();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// <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.AdvisoryAI.Storage.EfCore.CompiledModels
|
||||
{
|
||||
public partial class AdvisoryAiDbContextModel
|
||||
{
|
||||
private AdvisoryAiDbContextModel()
|
||||
: base(skipDetectChanges: false, modelId: new Guid("a2b3c4d5-e6f7-4890-ab12-cd34ef567890"), entityTypeCount: 2)
|
||||
{
|
||||
}
|
||||
|
||||
partial void Initialize()
|
||||
{
|
||||
var conversationEntity = ConversationEntityEntityType.Create(this);
|
||||
var turnEntity = TurnEntityEntityType.Create(this);
|
||||
|
||||
ConversationEntityEntityType.CreateAnnotations(conversationEntity);
|
||||
TurnEntityEntityType.CreateAnnotations(turnEntity);
|
||||
|
||||
TurnEntityEntityType.CreateForeignKeys(turnEntity, conversationEntity);
|
||||
|
||||
AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
|
||||
AddAnnotation("ProductVersion", "10.0.0");
|
||||
AddAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.AdvisoryAI.Storage.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Storage.EfCore.CompiledModels
|
||||
{
|
||||
[EntityFrameworkInternal]
|
||||
public partial class ConversationEntityEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.AdvisoryAI.Storage.EfCore.Models.ConversationEntity",
|
||||
typeof(ConversationEntity),
|
||||
baseEntityType,
|
||||
propertyCount: 7,
|
||||
namedIndexCount: 2,
|
||||
keyCount: 1);
|
||||
|
||||
var conversationId = runtimeEntityType.AddProperty(
|
||||
"ConversationId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ConversationEntity).GetProperty("ConversationId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ConversationEntity).GetField("<ConversationId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw);
|
||||
conversationId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
conversationId.AddAnnotation("Relational:ColumnName", "conversation_id");
|
||||
|
||||
var tenantId = runtimeEntityType.AddProperty(
|
||||
"TenantId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ConversationEntity).GetProperty("TenantId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ConversationEntity).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(string),
|
||||
propertyInfo: typeof(ConversationEntity).GetProperty("UserId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ConversationEntity).GetField("<UserId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
userId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
userId.AddAnnotation("Relational:ColumnName", "user_id");
|
||||
|
||||
var createdAt = runtimeEntityType.AddProperty(
|
||||
"CreatedAt",
|
||||
typeof(DateTime),
|
||||
propertyInfo: typeof(ConversationEntity).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ConversationEntity).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
|
||||
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
|
||||
|
||||
var updatedAt = runtimeEntityType.AddProperty(
|
||||
"UpdatedAt",
|
||||
typeof(DateTime),
|
||||
propertyInfo: typeof(ConversationEntity).GetProperty("UpdatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ConversationEntity).GetField("<UpdatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
|
||||
updatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
updatedAt.AddAnnotation("Relational:ColumnName", "updated_at");
|
||||
|
||||
var context = runtimeEntityType.AddProperty(
|
||||
"Context",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ConversationEntity).GetProperty("Context", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ConversationEntity).GetField("<Context>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
context.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
context.AddAnnotation("Relational:ColumnName", "context");
|
||||
context.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
|
||||
var metadata = runtimeEntityType.AddProperty(
|
||||
"Metadata",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ConversationEntity).GetProperty("Metadata", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ConversationEntity).GetField("<Metadata>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
metadata.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
metadata.AddAnnotation("Relational:ColumnName", "metadata");
|
||||
metadata.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
|
||||
var key = runtimeEntityType.AddKey(
|
||||
new[] { conversationId });
|
||||
runtimeEntityType.SetPrimaryKey(key);
|
||||
key.AddAnnotation("Relational:Name", "conversations_pkey");
|
||||
|
||||
var idx_advisoryai_conversations_tenant_updated = runtimeEntityType.AddIndex(
|
||||
new[] { tenantId, updatedAt },
|
||||
name: "idx_advisoryai_conversations_tenant_updated");
|
||||
idx_advisoryai_conversations_tenant_updated.AddAnnotation("Relational:IsDescending", new[] { false, true });
|
||||
|
||||
var idx_advisoryai_conversations_tenant_user = runtimeEntityType.AddIndex(
|
||||
new[] { tenantId, userId },
|
||||
name: "idx_advisoryai_conversations_tenant_user");
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "advisoryai");
|
||||
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "conversations");
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
|
||||
|
||||
Customize(runtimeEntityType);
|
||||
}
|
||||
|
||||
static partial void Customize(RuntimeEntityType runtimeEntityType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.AdvisoryAI.Storage.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Storage.EfCore.CompiledModels
|
||||
{
|
||||
[EntityFrameworkInternal]
|
||||
public partial class TurnEntityEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.AdvisoryAI.Storage.EfCore.Models.TurnEntity",
|
||||
typeof(TurnEntity),
|
||||
baseEntityType,
|
||||
propertyCount: 8,
|
||||
namedIndexCount: 2,
|
||||
foreignKeyCount: 1,
|
||||
keyCount: 1);
|
||||
|
||||
var turnId = runtimeEntityType.AddProperty(
|
||||
"TurnId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(TurnEntity).GetProperty("TurnId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TurnEntity).GetField("<TurnId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw);
|
||||
turnId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
turnId.AddAnnotation("Relational:ColumnName", "turn_id");
|
||||
|
||||
var conversationId = runtimeEntityType.AddProperty(
|
||||
"ConversationId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(TurnEntity).GetProperty("ConversationId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TurnEntity).GetField("<ConversationId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
conversationId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
conversationId.AddAnnotation("Relational:ColumnName", "conversation_id");
|
||||
|
||||
var role = runtimeEntityType.AddProperty(
|
||||
"Role",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(TurnEntity).GetProperty("Role", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TurnEntity).GetField("<Role>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
role.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
role.AddAnnotation("Relational:ColumnName", "role");
|
||||
|
||||
var content = runtimeEntityType.AddProperty(
|
||||
"Content",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(TurnEntity).GetProperty("Content", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TurnEntity).GetField("<Content>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
content.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
content.AddAnnotation("Relational:ColumnName", "content");
|
||||
|
||||
var timestamp = runtimeEntityType.AddProperty(
|
||||
"Timestamp",
|
||||
typeof(DateTime),
|
||||
propertyInfo: typeof(TurnEntity).GetProperty("Timestamp", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TurnEntity).GetField("<Timestamp>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
|
||||
timestamp.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
timestamp.AddAnnotation("Relational:ColumnName", "timestamp");
|
||||
|
||||
var evidenceLinks = runtimeEntityType.AddProperty(
|
||||
"EvidenceLinks",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(TurnEntity).GetProperty("EvidenceLinks", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TurnEntity).GetField("<EvidenceLinks>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
evidenceLinks.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
evidenceLinks.AddAnnotation("Relational:ColumnName", "evidence_links");
|
||||
evidenceLinks.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
|
||||
var proposedActions = runtimeEntityType.AddProperty(
|
||||
"ProposedActions",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(TurnEntity).GetProperty("ProposedActions", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TurnEntity).GetField("<ProposedActions>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
proposedActions.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
proposedActions.AddAnnotation("Relational:ColumnName", "proposed_actions");
|
||||
proposedActions.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
|
||||
var metadata = runtimeEntityType.AddProperty(
|
||||
"Metadata",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(TurnEntity).GetProperty("Metadata", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TurnEntity).GetField("<Metadata>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
metadata.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
metadata.AddAnnotation("Relational:ColumnName", "metadata");
|
||||
metadata.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
|
||||
var key = runtimeEntityType.AddKey(
|
||||
new[] { turnId });
|
||||
runtimeEntityType.SetPrimaryKey(key);
|
||||
key.AddAnnotation("Relational:Name", "turns_pkey");
|
||||
|
||||
var idx_advisoryai_turns_conversation = runtimeEntityType.AddIndex(
|
||||
new[] { conversationId },
|
||||
name: "idx_advisoryai_turns_conversation");
|
||||
|
||||
var idx_advisoryai_turns_conversation_timestamp = runtimeEntityType.AddIndex(
|
||||
new[] { conversationId, timestamp },
|
||||
name: "idx_advisoryai_turns_conversation_timestamp");
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "advisoryai");
|
||||
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "turns");
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
|
||||
|
||||
Customize(runtimeEntityType);
|
||||
}
|
||||
|
||||
public static void CreateForeignKeys(RuntimeEntityType runtimeEntityType, RuntimeEntityType conversationEntityEntityType)
|
||||
{
|
||||
var conversationId = runtimeEntityType.FindProperty("ConversationId");
|
||||
var fk = runtimeEntityType.AddForeignKey(
|
||||
new[] { conversationId },
|
||||
conversationEntityEntityType.FindKey(new[] { conversationEntityEntityType.FindProperty("ConversationId") }),
|
||||
conversationEntityEntityType,
|
||||
deleteBehavior: Microsoft.EntityFrameworkCore.DeleteBehavior.Cascade,
|
||||
required: true);
|
||||
fk.AddAnnotation("Relational:Name", "fk_turns_conversation_id");
|
||||
|
||||
var navigation = runtimeEntityType.AddNavigation(
|
||||
"Conversation",
|
||||
fk,
|
||||
onDependent: true,
|
||||
typeof(ConversationEntity),
|
||||
propertyInfo: typeof(TurnEntity).GetProperty("Conversation", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TurnEntity).GetField("<Conversation>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
|
||||
var inverseNavigation = conversationEntityEntityType.AddNavigation(
|
||||
"Turns",
|
||||
fk,
|
||||
onDependent: false,
|
||||
typeof(System.Collections.Generic.ICollection<TurnEntity>),
|
||||
propertyInfo: typeof(ConversationEntity).GetProperty("Turns", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(ConversationEntity).GetField("<Turns>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
}
|
||||
|
||||
static partial void Customize(RuntimeEntityType runtimeEntityType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// <copyright file="AdvisoryAiDbContext.Partial.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.AdvisoryAI.Storage.EfCore.Models;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Storage.EfCore.Context;
|
||||
|
||||
public partial class AdvisoryAiDbContext
|
||||
{
|
||||
partial void OnModelCreatingPartial(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<TurnEntity>(entity =>
|
||||
{
|
||||
entity.HasOne(e => e.Conversation)
|
||||
.WithMany(c => c.Turns)
|
||||
.HasForeignKey(e => e.ConversationId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// <copyright file="AdvisoryAiDbContext.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.AdvisoryAI.Storage.EfCore.Models;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Storage.EfCore.Context;
|
||||
|
||||
public partial class AdvisoryAiDbContext : DbContext
|
||||
{
|
||||
private readonly string _schemaName;
|
||||
|
||||
public AdvisoryAiDbContext(DbContextOptions<AdvisoryAiDbContext> options, string? schemaName = null)
|
||||
: base(options)
|
||||
{
|
||||
_schemaName = string.IsNullOrWhiteSpace(schemaName)
|
||||
? "advisoryai"
|
||||
: schemaName.Trim();
|
||||
}
|
||||
|
||||
public virtual DbSet<ConversationEntity> Conversations { get; set; }
|
||||
|
||||
public virtual DbSet<TurnEntity> Turns { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
var schemaName = _schemaName;
|
||||
|
||||
modelBuilder.Entity<ConversationEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.ConversationId).HasName("conversations_pkey");
|
||||
|
||||
entity.ToTable("conversations", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.TenantId, e.UpdatedAt }, "idx_advisoryai_conversations_tenant_updated")
|
||||
.IsDescending(false, true);
|
||||
|
||||
entity.HasIndex(e => new { e.TenantId, e.UserId }, "idx_advisoryai_conversations_tenant_user");
|
||||
|
||||
entity.Property(e => e.ConversationId).HasColumnName("conversation_id");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.UserId).HasColumnName("user_id");
|
||||
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
|
||||
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at");
|
||||
entity.Property(e => e.Context)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("context");
|
||||
entity.Property(e => e.Metadata)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("metadata");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<TurnEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.TurnId).HasName("turns_pkey");
|
||||
|
||||
entity.ToTable("turns", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.ConversationId, "idx_advisoryai_turns_conversation");
|
||||
|
||||
entity.HasIndex(e => new { e.ConversationId, e.Timestamp }, "idx_advisoryai_turns_conversation_timestamp");
|
||||
|
||||
entity.Property(e => e.TurnId).HasColumnName("turn_id");
|
||||
entity.Property(e => e.ConversationId).HasColumnName("conversation_id");
|
||||
entity.Property(e => e.Role).HasColumnName("role");
|
||||
entity.Property(e => e.Content).HasColumnName("content");
|
||||
entity.Property(e => e.Timestamp).HasColumnName("timestamp");
|
||||
entity.Property(e => e.EvidenceLinks)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("evidence_links");
|
||||
entity.Property(e => e.ProposedActions)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("proposed_actions");
|
||||
entity.Property(e => e.Metadata)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("metadata");
|
||||
});
|
||||
|
||||
OnModelCreatingPartial(modelBuilder);
|
||||
}
|
||||
|
||||
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// <copyright file="AdvisoryAiDesignTimeDbContextFactory.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Storage.EfCore.Context;
|
||||
|
||||
public sealed class AdvisoryAiDesignTimeDbContextFactory : IDesignTimeDbContextFactory<AdvisoryAiDbContext>
|
||||
{
|
||||
private const string DefaultConnectionString =
|
||||
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=advisoryai,public";
|
||||
private const string ConnectionStringEnvironmentVariable =
|
||||
"STELLAOPS_ADVISORYAI_EF_CONNECTION";
|
||||
|
||||
public AdvisoryAiDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString = ResolveConnectionString();
|
||||
var options = new DbContextOptionsBuilder<AdvisoryAiDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.Options;
|
||||
|
||||
return new AdvisoryAiDbContext(options);
|
||||
}
|
||||
|
||||
private static string ResolveConnectionString()
|
||||
{
|
||||
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
|
||||
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// <copyright file="ConversationEntity.Partials.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Storage.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Navigation properties for ConversationEntity.
|
||||
/// </summary>
|
||||
public partial class ConversationEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Turns belonging to this conversation.
|
||||
/// </summary>
|
||||
public virtual ICollection<TurnEntity> Turns { get; set; } = new List<TurnEntity>();
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// <copyright file="ConversationEntity.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Storage.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for advisoryai.conversations table.
|
||||
/// </summary>
|
||||
public partial class ConversationEntity
|
||||
{
|
||||
public string ConversationId { get; set; } = null!;
|
||||
|
||||
public string TenantId { get; set; } = null!;
|
||||
|
||||
public string UserId { get; set; } = null!;
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
|
||||
public string? Context { get; set; }
|
||||
|
||||
public string? Metadata { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// <copyright file="TurnEntity.Partials.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Storage.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Navigation properties for TurnEntity.
|
||||
/// </summary>
|
||||
public partial class TurnEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Parent conversation.
|
||||
/// </summary>
|
||||
public virtual ConversationEntity? Conversation { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// <copyright file="TurnEntity.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Storage.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for advisoryai.turns table.
|
||||
/// </summary>
|
||||
public partial class TurnEntity
|
||||
{
|
||||
public string TurnId { get; set; } = null!;
|
||||
|
||||
public string ConversationId { get; set; } = null!;
|
||||
|
||||
public string Role { get; set; } = null!;
|
||||
|
||||
public string Content { get; set; } = null!;
|
||||
|
||||
public DateTime Timestamp { get; set; }
|
||||
|
||||
public string? EvidenceLinks { get; set; }
|
||||
|
||||
public string? ProposedActions { get; set; }
|
||||
|
||||
public string? Metadata { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// <copyright file="AdvisoryAiDataSource.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Connections;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Storage.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL data source for AdvisoryAI module.
|
||||
/// </summary>
|
||||
public sealed class AdvisoryAiDataSource : DataSourceBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Default schema name for AdvisoryAI tables.
|
||||
/// </summary>
|
||||
public const string DefaultSchemaName = "advisoryai";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new AdvisoryAI data source.
|
||||
/// </summary>
|
||||
public AdvisoryAiDataSource(IOptions<PostgresOptions> options, ILogger<AdvisoryAiDataSource> logger)
|
||||
: base(CreateOptions(options.Value), logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override string ModuleName => "AdvisoryAI";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void ConfigureDataSourceBuilder(NpgsqlDataSourceBuilder builder)
|
||||
{
|
||||
base.ConfigureDataSourceBuilder(builder);
|
||||
}
|
||||
|
||||
private static PostgresOptions CreateOptions(PostgresOptions baseOptions)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseOptions.SchemaName))
|
||||
{
|
||||
baseOptions.SchemaName = DefaultSchemaName;
|
||||
}
|
||||
return baseOptions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// <copyright file="AdvisoryAiDbContextFactory.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using StellaOps.AdvisoryAI.Storage.EfCore.CompiledModels;
|
||||
using StellaOps.AdvisoryAI.Storage.EfCore.Context;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Storage.Postgres;
|
||||
|
||||
internal static class AdvisoryAiDbContextFactory
|
||||
{
|
||||
public static AdvisoryAiDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
|
||||
{
|
||||
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
|
||||
? AdvisoryAiDataSource.DefaultSchemaName
|
||||
: schemaName.Trim();
|
||||
|
||||
var optionsBuilder = new DbContextOptionsBuilder<AdvisoryAiDbContext>()
|
||||
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
|
||||
|
||||
if (string.Equals(normalizedSchema, AdvisoryAiDataSource.DefaultSchemaName, StringComparison.Ordinal))
|
||||
{
|
||||
// Use the static compiled model when schema mapping matches the default model.
|
||||
optionsBuilder.UseModel(AdvisoryAiDbContextModel.Instance);
|
||||
}
|
||||
|
||||
return new AdvisoryAiDbContext(optionsBuilder.Options, normalizedSchema);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ Source of truth: `docs/implplan/SPRINT_20260113_005_ADVISORYAI_controlled_conver
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| 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. |
|
||||
| AUDIT-0017-A | DONE | Pending approval for changes. |
|
||||
@@ -18,4 +19,5 @@ Source of truth: `docs/implplan/SPRINT_20260113_005_ADVISORYAI_controlled_conver
|
||||
| QA-AIAI-VERIFY-003 | DONE | FLOW verification complete for `ai-action-policy-gate` with Tier 0/1/2 artifacts under `docs/qa/feature-checks/runs/advisoryai/ai-action-policy-gate/run-001/`. |
|
||||
| QA-AIAI-VERIFY-004 | DONE | FLOW verification complete for `ai-codex-zastava-companion` with Tier 0/1/2 artifacts under `docs/qa/feature-checks/runs/advisoryai/ai-codex-zastava-companion/run-002/`. |
|
||||
| QA-AIAI-VERIFY-005 | DONE | FLOW verification complete for `deterministic-ai-artifact-replay` with Tier 0/1/2 artifacts under `docs/qa/feature-checks/runs/advisoryai/deterministic-ai-artifact-replay/run-001/`. |
|
||||
| SPRINT_20260222_074-ADVAI-EF | DONE | AdvisoryAI Storage DAL converted from Npgsql repositories to EF Core v10. Created AdvisoryAiDataSource, AdvisoryAiDbContext, ConversationEntity/TurnEntity models, compiled model artifacts, runtime DbContextFactory with UseModel for default schema. ConversationStore rewritten to use EF Core (per-operation DbContext, AsNoTracking reads, ExecuteDeleteAsync bulk deletes, UniqueViolation idempotency). AdvisoryAiMigrationModulePlugin registered in Platform migration registry. Sequential builds pass (0 errors, 0 warnings). 560/584 tests pass; 24 failures are pre-existing auth-related (403 Forbidden), not storage-related. |
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| SPRINT_20260222_051-AKS-TESTS | DONE | Revalidated AKS tests with xUnit v3 `--filter-class`: `KnowledgeSearchEndpointsIntegrationTests` (3/3) and `*KnowledgeSearch*` suite slice (6/6) on 2026-02-22. |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user