1317 lines
48 KiB
C#
1317 lines
48 KiB
C#
using System.Collections.Immutable;
|
|
using System.Diagnostics;
|
|
using System.Linq;
|
|
using System.Net;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Threading.RateLimiting;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.RateLimiting;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
using StellaOps.AdvisoryAI.Attestation;
|
|
using StellaOps.AdvisoryAI.Caching;
|
|
using StellaOps.AdvisoryAI.Chat;
|
|
using StellaOps.Evidence.Pack;
|
|
using StellaOps.AdvisoryAI.Diagnostics;
|
|
using StellaOps.AdvisoryAI.Evidence;
|
|
using StellaOps.AdvisoryAI.Explanation;
|
|
using StellaOps.AdvisoryAI.Hosting;
|
|
using StellaOps.AdvisoryAI.Metrics;
|
|
using StellaOps.AdvisoryAI.Outputs;
|
|
using StellaOps.AdvisoryAI.Orchestration;
|
|
using StellaOps.AdvisoryAI.Queue;
|
|
using StellaOps.AdvisoryAI.PolicyStudio;
|
|
using StellaOps.AdvisoryAI.Remediation;
|
|
using StellaOps.AdvisoryAI.WebService.Contracts;
|
|
using StellaOps.AdvisoryAI.WebService.Endpoints;
|
|
using StellaOps.AdvisoryAI.WebService.Services;
|
|
using StellaOps.Router.AspNet;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
builder.Configuration
|
|
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
|
|
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true)
|
|
.AddEnvironmentVariables(prefix: "ADVISORYAI__");
|
|
|
|
builder.Services.AddAdvisoryAiCore(builder.Configuration);
|
|
builder.Services.AddAdvisoryChat(builder.Configuration);
|
|
|
|
// Authorization service
|
|
builder.Services.AddSingleton<StellaOps.AdvisoryAI.WebService.Services.IAuthorizationService, StellaOps.AdvisoryAI.WebService.Services.HeaderBasedAuthorizationService>();
|
|
|
|
// Rate limits service with configuration
|
|
builder.Services.AddOptions<StellaOps.AdvisoryAI.WebService.Services.RateLimitsOptions>()
|
|
.Bind(builder.Configuration.GetSection(StellaOps.AdvisoryAI.WebService.Services.RateLimitsOptions.SectionName))
|
|
.ValidateOnStart();
|
|
builder.Services.AddSingleton<StellaOps.AdvisoryAI.WebService.Services.IRateLimitsService, StellaOps.AdvisoryAI.WebService.Services.ConfigDrivenRateLimitsService>();
|
|
|
|
// TimeProvider for deterministic timestamps
|
|
builder.Services.AddSingleton(TimeProvider.System);
|
|
|
|
// VEX-AI-016: Consent and justification services
|
|
builder.Services.AddSingleton<IAiConsentStore, InMemoryAiConsentStore>();
|
|
builder.Services.AddSingleton<IAiJustificationGenerator, DefaultAiJustificationGenerator>();
|
|
|
|
// AI Attestations (Sprint: SPRINT_20260109_011_001 Task: AIAT-009)
|
|
builder.Services.AddAiAttestationServices();
|
|
builder.Services.AddInMemoryAiAttestationStore();
|
|
|
|
// Evidence Packs (Sprint: SPRINT_20260109_011_005 Task: EVPK-010)
|
|
builder.Services.AddEvidencePack();
|
|
builder.Services.TryAddSingleton<IEvidencePackSigner, NullEvidencePackSigner>();
|
|
|
|
builder.Services.AddEndpointsApiExplorer();
|
|
builder.Services.AddOpenApi();
|
|
builder.Services.AddProblemDetails();
|
|
|
|
// Stella Router integration
|
|
var routerOptions = builder.Configuration.GetSection("AdvisoryAI:Router").Get<StellaRouterOptionsBase>();
|
|
builder.Services.TryAddStellaRouter(
|
|
serviceName: "advisoryai",
|
|
version: typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0",
|
|
routerOptions: routerOptions);
|
|
|
|
builder.Services.AddRateLimiter(options =>
|
|
{
|
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
|
options.AddPolicy("advisory-ai", context =>
|
|
{
|
|
var clientId = context.Request.Headers.TryGetValue("X-StellaOps-Client", out var value)
|
|
? value.ToString()
|
|
: "anonymous";
|
|
|
|
return RateLimitPartition.GetTokenBucketLimiter(
|
|
clientId,
|
|
_ => new TokenBucketRateLimiterOptions
|
|
{
|
|
TokenLimit = 30,
|
|
TokensPerPeriod = 30,
|
|
ReplenishmentPeriod = TimeSpan.FromMinutes(1),
|
|
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
|
QueueLimit = 0,
|
|
AutoReplenishment = true
|
|
});
|
|
});
|
|
});
|
|
|
|
var app = builder.Build();
|
|
|
|
app.UseExceptionHandler(static options => options.Run(async context =>
|
|
{
|
|
var problem = Results.Problem(statusCode: StatusCodes.Status500InternalServerError);
|
|
await problem.ExecuteAsync(context);
|
|
}));
|
|
|
|
if (app.Environment.IsDevelopment())
|
|
{
|
|
app.MapOpenApi();
|
|
}
|
|
|
|
app.UseRateLimiter();
|
|
app.TryUseStellaRouter(routerOptions);
|
|
|
|
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
|
|
|
|
app.MapPost("/v1/advisory-ai/pipeline/{taskType}", HandleSinglePlan)
|
|
.RequireRateLimiting("advisory-ai");
|
|
|
|
app.MapPost("/v1/advisory-ai/pipeline:batch", HandleBatchPlans)
|
|
.RequireRateLimiting("advisory-ai");
|
|
|
|
app.MapGet("/v1/advisory-ai/outputs/{cacheKey}", HandleGetOutput)
|
|
.RequireRateLimiting("advisory-ai");
|
|
|
|
// Explanation endpoints (SPRINT_20251226_015_AI_zastava_companion)
|
|
app.MapPost("/v1/advisory-ai/explain", HandleExplain)
|
|
.RequireRateLimiting("advisory-ai");
|
|
|
|
app.MapGet("/v1/advisory-ai/explain/{explanationId}/replay", HandleExplanationReplay)
|
|
.RequireRateLimiting("advisory-ai");
|
|
|
|
// Remediation endpoints (SPRINT_20251226_016_AI_remedy_autopilot)
|
|
app.MapPost("/v1/advisory-ai/remediation/plan", HandleRemediationPlan)
|
|
.RequireRateLimiting("advisory-ai");
|
|
|
|
app.MapPost("/v1/advisory-ai/remediation/apply", HandleApplyRemediation)
|
|
.RequireRateLimiting("advisory-ai");
|
|
|
|
app.MapGet("/v1/advisory-ai/remediation/status/{prId}", HandleRemediationStatus)
|
|
.RequireRateLimiting("advisory-ai");
|
|
|
|
// Policy Studio endpoints (SPRINT_20251226_017_AI_policy_copilot)
|
|
app.MapPost("/v1/advisory-ai/policy/studio/parse", HandlePolicyParse)
|
|
.RequireRateLimiting("advisory-ai");
|
|
|
|
app.MapPost("/v1/advisory-ai/policy/studio/generate", HandlePolicyGenerate)
|
|
.RequireRateLimiting("advisory-ai");
|
|
|
|
app.MapPost("/v1/advisory-ai/policy/studio/validate", HandlePolicyValidate)
|
|
.RequireRateLimiting("advisory-ai");
|
|
|
|
app.MapPost("/v1/advisory-ai/policy/studio/compile", HandlePolicyCompile)
|
|
.RequireRateLimiting("advisory-ai");
|
|
|
|
// VEX-AI-016: Consent endpoints
|
|
app.MapGet("/v1/advisory-ai/consent", HandleGetConsent)
|
|
.RequireRateLimiting("advisory-ai");
|
|
|
|
app.MapPost("/v1/advisory-ai/consent", HandleGrantConsent)
|
|
.RequireRateLimiting("advisory-ai");
|
|
|
|
app.MapDelete("/v1/advisory-ai/consent", HandleRevokeConsent)
|
|
.RequireRateLimiting("advisory-ai");
|
|
|
|
// VEX-AI-016: Justification endpoint
|
|
app.MapPost("/v1/advisory-ai/justify", HandleJustify)
|
|
.RequireRateLimiting("advisory-ai");
|
|
|
|
// VEX-AI-016: Remediate alias (maps to remediation/plan)
|
|
app.MapPost("/v1/advisory-ai/remediate", HandleRemediate)
|
|
.RequireRateLimiting("advisory-ai");
|
|
|
|
// VEX-AI-016: Rate limits endpoint
|
|
app.MapGet("/v1/advisory-ai/rate-limits", HandleGetRateLimits)
|
|
.RequireRateLimiting("advisory-ai");
|
|
|
|
// Chat endpoints (SPRINT_20260107_006_003 CH-005)
|
|
app.MapPost("/v1/advisory-ai/conversations", HandleCreateConversation)
|
|
.RequireRateLimiting("advisory-ai");
|
|
|
|
app.MapGet("/v1/advisory-ai/conversations/{conversationId}", HandleGetConversation)
|
|
.RequireRateLimiting("advisory-ai");
|
|
|
|
app.MapPost("/v1/advisory-ai/conversations/{conversationId}/turns", HandleAddTurn)
|
|
.RequireRateLimiting("advisory-ai");
|
|
|
|
app.MapDelete("/v1/advisory-ai/conversations/{conversationId}", HandleDeleteConversation)
|
|
.RequireRateLimiting("advisory-ai");
|
|
|
|
app.MapGet("/v1/advisory-ai/conversations", HandleListConversations)
|
|
.RequireRateLimiting("advisory-ai");
|
|
|
|
// Chat gateway endpoints (controlled conversational interface)
|
|
app.MapChatEndpoints();
|
|
|
|
// AI Attestations endpoints (Sprint: SPRINT_20260109_011_001 Task: AIAT-009)
|
|
app.MapAttestationEndpoints();
|
|
|
|
// Evidence Pack endpoints (Sprint: SPRINT_20260109_011_005 Task: EVPK-010)
|
|
app.MapEvidencePackEndpoints();
|
|
|
|
// Refresh Router endpoint cache
|
|
app.TryRefreshStellaRouterEndpoints(routerOptions);
|
|
|
|
app.Run();
|
|
|
|
static async Task<IResult> HandleSinglePlan(
|
|
HttpContext httpContext,
|
|
string taskType,
|
|
PipelinePlanRequest request,
|
|
IAdvisoryPipelineOrchestrator orchestrator,
|
|
IAdvisoryPlanCache planCache,
|
|
IAdvisoryTaskQueue taskQueue,
|
|
AdvisoryAiMetrics requestMetrics,
|
|
AdvisoryPipelineMetrics pipelineMetrics,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.plan_request", ActivityKind.Server);
|
|
activity?.SetTag("advisory.task_type", taskType);
|
|
activity?.SetTag("advisory.advisory_key", request.AdvisoryKey);
|
|
|
|
if (!Enum.TryParse<AdvisoryTaskType>(taskType, ignoreCase: true, out var parsedType))
|
|
{
|
|
return Results.BadRequest(new { error = $"Unknown task type '{taskType}'." });
|
|
}
|
|
|
|
if (!EnsureAuthorized(httpContext, parsedType))
|
|
{
|
|
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(request.AdvisoryKey))
|
|
{
|
|
return Results.BadRequest(new { error = "AdvisoryKey is required." });
|
|
}
|
|
|
|
var normalizedRequest = request with { TaskType = parsedType };
|
|
var taskRequest = normalizedRequest.ToTaskRequest();
|
|
var plan = await orchestrator.CreatePlanAsync(taskRequest, cancellationToken).ConfigureAwait(false);
|
|
activity?.SetTag("advisory.plan_cache_key", plan.CacheKey);
|
|
|
|
await planCache.SetAsync(plan.CacheKey, plan, cancellationToken).ConfigureAwait(false);
|
|
await taskQueue.EnqueueAsync(new AdvisoryTaskQueueMessage(plan.CacheKey, plan.Request), cancellationToken).ConfigureAwait(false);
|
|
|
|
requestMetrics.RecordRequest(plan.Request.TaskType.ToString());
|
|
requestMetrics.RecordEnqueued(plan.Request.TaskType.ToString());
|
|
pipelineMetrics.RecordPlanQueued(plan.Request.TaskType);
|
|
|
|
var response = AdvisoryPipelinePlanResponse.FromPlan(plan);
|
|
return Results.Ok(response);
|
|
}
|
|
|
|
static async Task<IResult> HandleBatchPlans(
|
|
HttpContext httpContext,
|
|
BatchPipelinePlanRequest batchRequest,
|
|
IAdvisoryPipelineOrchestrator orchestrator,
|
|
IAdvisoryPlanCache planCache,
|
|
IAdvisoryTaskQueue taskQueue,
|
|
AdvisoryAiMetrics requestMetrics,
|
|
AdvisoryPipelineMetrics pipelineMetrics,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.plan_batch", ActivityKind.Server);
|
|
activity?.SetTag("advisory.batch_size", batchRequest.Requests.Count);
|
|
|
|
if (batchRequest.Requests.Count == 0)
|
|
{
|
|
return Results.BadRequest(new { error = "At least one request must be supplied." });
|
|
}
|
|
|
|
var results = new List<AdvisoryPipelinePlanResponse>(batchRequest.Requests.Count);
|
|
|
|
foreach (var item in batchRequest.Requests)
|
|
{
|
|
var taskType = item.TaskType?.ToString() ?? "summary";
|
|
if (!Enum.TryParse<AdvisoryTaskType>(taskType, ignoreCase: true, out var parsedType))
|
|
{
|
|
return Results.BadRequest(new { error = $"Unknown task type '{taskType}' in batch item." });
|
|
}
|
|
|
|
if (!EnsureAuthorized(httpContext, parsedType))
|
|
{
|
|
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(item.AdvisoryKey))
|
|
{
|
|
return Results.BadRequest(new { error = "AdvisoryKey is required for every batch item." });
|
|
}
|
|
|
|
var normalizedRequest = item with { TaskType = parsedType };
|
|
var taskRequest = normalizedRequest.ToTaskRequest();
|
|
var plan = await orchestrator.CreatePlanAsync(taskRequest, cancellationToken).ConfigureAwait(false);
|
|
activity?.AddEvent(new ActivityEvent("advisory.plan.created", tags: new ActivityTagsCollection
|
|
{
|
|
{ "advisory.task_type", plan.Request.TaskType.ToString() },
|
|
{ "advisory.advisory_key", plan.Request.AdvisoryKey },
|
|
{ "advisory.plan_cache_key", plan.CacheKey }
|
|
}));
|
|
|
|
await planCache.SetAsync(plan.CacheKey, plan, cancellationToken).ConfigureAwait(false);
|
|
await taskQueue.EnqueueAsync(new AdvisoryTaskQueueMessage(plan.CacheKey, plan.Request), cancellationToken).ConfigureAwait(false);
|
|
|
|
requestMetrics.RecordRequest(plan.Request.TaskType.ToString());
|
|
requestMetrics.RecordEnqueued(plan.Request.TaskType.ToString());
|
|
pipelineMetrics.RecordPlanQueued(plan.Request.TaskType);
|
|
|
|
results.Add(AdvisoryPipelinePlanResponse.FromPlan(plan));
|
|
}
|
|
|
|
return Results.Ok(results);
|
|
}
|
|
|
|
static async Task<IResult> HandleGetOutput(
|
|
HttpContext httpContext,
|
|
string cacheKey,
|
|
string taskType,
|
|
string? profile,
|
|
IAdvisoryOutputStore outputStore,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(outputStore);
|
|
if (!Enum.TryParse<AdvisoryTaskType>(taskType, ignoreCase: true, out var parsedTaskType))
|
|
{
|
|
return Results.BadRequest(new { error = $"Unknown task type '{taskType}'." });
|
|
}
|
|
|
|
if (!EnsureAuthorized(httpContext, parsedTaskType))
|
|
{
|
|
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
|
}
|
|
|
|
var resolvedProfile = string.IsNullOrWhiteSpace(profile) ? "default" : profile!.Trim();
|
|
var output = await outputStore.TryGetAsync(cacheKey, parsedTaskType, resolvedProfile, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (output is null)
|
|
{
|
|
return Results.NotFound(new { error = "Output not found." });
|
|
}
|
|
|
|
return Results.Ok(AdvisoryOutputResponse.FromDomain(output));
|
|
}
|
|
|
|
static bool EnsureAuthorized(HttpContext context, AdvisoryTaskType taskType)
|
|
{
|
|
if (!context.Request.Headers.TryGetValue("X-StellaOps-Scopes", out var scopes))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var allowed = scopes
|
|
.SelectMany(value => value?.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? [])
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
if (allowed.Contains("advisory:run"))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return allowed.Contains($"advisory:{taskType.ToString().ToLowerInvariant()}");
|
|
}
|
|
|
|
static bool EnsureExplainAuthorized(HttpContext context)
|
|
{
|
|
if (!context.Request.Headers.TryGetValue("X-StellaOps-Scopes", out var scopes))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var allowed = scopes
|
|
.SelectMany(value => value?.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? [])
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
return allowed.Contains("advisory:run") || allowed.Contains("advisory:explain");
|
|
}
|
|
|
|
// ZASTAVA-13: POST /v1/advisory-ai/explain
|
|
static async Task<IResult> HandleExplain(
|
|
HttpContext httpContext,
|
|
ExplainRequest request,
|
|
IExplanationGenerator explanationGenerator,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.explain", ActivityKind.Server);
|
|
activity?.SetTag("advisory.finding_id", request.FindingId);
|
|
activity?.SetTag("advisory.vulnerability_id", request.VulnerabilityId);
|
|
activity?.SetTag("advisory.explanation_type", request.ExplanationType);
|
|
|
|
if (!EnsureExplainAuthorized(httpContext))
|
|
{
|
|
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
|
}
|
|
|
|
try
|
|
{
|
|
var domainRequest = request.ToDomain();
|
|
var result = await explanationGenerator.GenerateAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
|
|
|
activity?.SetTag("advisory.explanation_id", result.ExplanationId);
|
|
activity?.SetTag("advisory.authority", result.Authority.ToString());
|
|
activity?.SetTag("advisory.citation_rate", result.CitationRate);
|
|
|
|
return Results.Ok(ExplainResponse.FromDomain(result));
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.BadRequest(new { error = ex.Message });
|
|
}
|
|
}
|
|
|
|
// ZASTAVA-14: GET /v1/advisory-ai/explain/{explanationId}/replay
|
|
static async Task<IResult> HandleExplanationReplay(
|
|
HttpContext httpContext,
|
|
string explanationId,
|
|
IExplanationGenerator explanationGenerator,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.explain_replay", ActivityKind.Server);
|
|
activity?.SetTag("advisory.explanation_id", explanationId);
|
|
|
|
if (!EnsureExplainAuthorized(httpContext))
|
|
{
|
|
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
|
}
|
|
|
|
try
|
|
{
|
|
var result = await explanationGenerator.ReplayAsync(explanationId, cancellationToken).ConfigureAwait(false);
|
|
|
|
activity?.SetTag("advisory.replayed_explanation_id", result.ExplanationId);
|
|
activity?.SetTag("advisory.authority", result.Authority.ToString());
|
|
|
|
return Results.Ok(ExplainResponse.FromDomain(result));
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.NotFound(new { error = ex.Message });
|
|
}
|
|
}
|
|
|
|
static bool EnsureRemediationAuthorized(HttpContext context)
|
|
{
|
|
if (!context.Request.Headers.TryGetValue("X-StellaOps-Scopes", out var scopes))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var allowed = scopes
|
|
.SelectMany(value => value?.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? [])
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
return allowed.Contains("advisory:run") || allowed.Contains("advisory:remediate");
|
|
}
|
|
|
|
// REMEDY-19: POST /v1/advisory-ai/remediation/plan
|
|
static async Task<IResult> HandleRemediationPlan(
|
|
HttpContext httpContext,
|
|
RemediationPlanApiRequest request,
|
|
IRemediationPlanner remediationPlanner,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.remediation_plan", ActivityKind.Server);
|
|
activity?.SetTag("advisory.finding_id", request.FindingId);
|
|
activity?.SetTag("advisory.vulnerability_id", request.VulnerabilityId);
|
|
activity?.SetTag("advisory.remediation_type", request.RemediationType);
|
|
|
|
if (!EnsureRemediationAuthorized(httpContext))
|
|
{
|
|
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
|
}
|
|
|
|
try
|
|
{
|
|
var domainRequest = request.ToDomain();
|
|
var plan = await remediationPlanner.GeneratePlanAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
|
|
|
activity?.SetTag("advisory.plan_id", plan.PlanId);
|
|
activity?.SetTag("advisory.risk_assessment", plan.RiskAssessment.ToString());
|
|
activity?.SetTag("advisory.pr_ready", plan.PrReady);
|
|
|
|
return Results.Ok(RemediationPlanApiResponse.FromDomain(plan));
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.BadRequest(new { error = ex.Message });
|
|
}
|
|
}
|
|
|
|
// REMEDY-20: POST /v1/advisory-ai/remediation/apply
|
|
static async Task<IResult> HandleApplyRemediation(
|
|
HttpContext httpContext,
|
|
ApplyRemediationRequest request,
|
|
IRemediationPlanner remediationPlanner,
|
|
IEnumerable<IPullRequestGenerator> prGenerators,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.apply_remediation", ActivityKind.Server);
|
|
activity?.SetTag("advisory.plan_id", request.PlanId);
|
|
activity?.SetTag("advisory.scm_type", request.ScmType);
|
|
|
|
if (!EnsureRemediationAuthorized(httpContext))
|
|
{
|
|
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
|
}
|
|
|
|
var plan = await remediationPlanner.GetPlanAsync(request.PlanId, cancellationToken).ConfigureAwait(false);
|
|
if (plan is null)
|
|
{
|
|
return Results.NotFound(new { error = $"Plan {request.PlanId} not found" });
|
|
}
|
|
|
|
var generator = prGenerators.FirstOrDefault(g => g.ScmType.Equals(request.ScmType, StringComparison.OrdinalIgnoreCase));
|
|
if (generator is null)
|
|
{
|
|
return Results.BadRequest(new { error = $"SCM type '{request.ScmType}' not supported" });
|
|
}
|
|
|
|
try
|
|
{
|
|
var prResult = await generator.CreatePullRequestAsync(plan, cancellationToken).ConfigureAwait(false);
|
|
|
|
activity?.SetTag("advisory.pr_id", prResult.PrId);
|
|
activity?.SetTag("advisory.pr_status", prResult.Status.ToString());
|
|
|
|
return Results.Ok(PullRequestApiResponse.FromDomain(prResult));
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.BadRequest(new { error = ex.Message });
|
|
}
|
|
}
|
|
|
|
// REMEDY-21: GET /v1/advisory-ai/remediation/status/{prId}
|
|
static async Task<IResult> HandleRemediationStatus(
|
|
HttpContext httpContext,
|
|
string prId,
|
|
string? scmType,
|
|
IEnumerable<IPullRequestGenerator> prGenerators,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.remediation_status", ActivityKind.Server);
|
|
activity?.SetTag("advisory.pr_id", prId);
|
|
|
|
if (!EnsureRemediationAuthorized(httpContext))
|
|
{
|
|
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
|
}
|
|
|
|
var resolvedScmType = scmType ?? "github";
|
|
var generator = prGenerators.FirstOrDefault(g => g.ScmType.Equals(resolvedScmType, StringComparison.OrdinalIgnoreCase));
|
|
if (generator is null)
|
|
{
|
|
return Results.BadRequest(new { error = $"SCM type '{resolvedScmType}' not supported" });
|
|
}
|
|
|
|
try
|
|
{
|
|
var prResult = await generator.GetStatusAsync(prId, cancellationToken).ConfigureAwait(false);
|
|
|
|
activity?.SetTag("advisory.pr_status", prResult.Status.ToString());
|
|
|
|
return Results.Ok(PullRequestApiResponse.FromDomain(prResult));
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.NotFound(new { error = ex.Message });
|
|
}
|
|
}
|
|
|
|
static bool EnsurePolicyAuthorized(HttpContext context)
|
|
{
|
|
if (!context.Request.Headers.TryGetValue("X-StellaOps-Scopes", out var scopes))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var allowed = scopes
|
|
.SelectMany(value => value?.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? [])
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
return allowed.Contains("advisory:run") || allowed.Contains("policy:write");
|
|
}
|
|
|
|
// POLICY-16: POST /v1/advisory-ai/policy/studio/parse
|
|
static async Task<IResult> HandlePolicyParse(
|
|
HttpContext httpContext,
|
|
PolicyParseApiRequest request,
|
|
IPolicyIntentParser intentParser,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.policy_parse", ActivityKind.Server);
|
|
activity?.SetTag("advisory.input_length", request.Input.Length);
|
|
|
|
if (!EnsurePolicyAuthorized(httpContext))
|
|
{
|
|
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
|
}
|
|
|
|
try
|
|
{
|
|
var result = await intentParser.ParseAsync(request.Input, request.ToContext(), cancellationToken).ConfigureAwait(false);
|
|
|
|
activity?.SetTag("advisory.intent_id", result.Intent.IntentId);
|
|
activity?.SetTag("advisory.confidence", result.Intent.Confidence);
|
|
|
|
return Results.Ok(PolicyParseApiResponse.FromDomain(result));
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.BadRequest(new { error = ex.Message });
|
|
}
|
|
}
|
|
|
|
// POLICY-17: POST /v1/advisory-ai/policy/studio/generate
|
|
static async Task<IResult> HandlePolicyGenerate(
|
|
HttpContext httpContext,
|
|
PolicyGenerateApiRequest request,
|
|
IPolicyIntentStore intentStore,
|
|
IPolicyRuleGenerator ruleGenerator,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.policy_generate", ActivityKind.Server);
|
|
activity?.SetTag("advisory.intent_id", request.IntentId);
|
|
|
|
if (!EnsurePolicyAuthorized(httpContext))
|
|
{
|
|
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
|
}
|
|
|
|
var intent = await intentStore.GetAsync(request.IntentId, cancellationToken).ConfigureAwait(false);
|
|
if (intent is null)
|
|
{
|
|
return Results.NotFound(new { error = $"Intent {request.IntentId} not found" });
|
|
}
|
|
|
|
try
|
|
{
|
|
var result = await ruleGenerator.GenerateAsync(intent, cancellationToken).ConfigureAwait(false);
|
|
|
|
activity?.SetTag("advisory.rule_count", result.Rules.Count);
|
|
|
|
return Results.Ok(RuleGenerationApiResponse.FromDomain(result));
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.BadRequest(new { error = ex.Message });
|
|
}
|
|
}
|
|
|
|
// POLICY-18: POST /v1/advisory-ai/policy/studio/validate
|
|
static async Task<IResult> HandlePolicyValidate(
|
|
HttpContext httpContext,
|
|
PolicyValidateApiRequest request,
|
|
IPolicyRuleGenerator ruleGenerator,
|
|
ITestCaseSynthesizer testSynthesizer,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.policy_validate", ActivityKind.Server);
|
|
activity?.SetTag("advisory.rule_count", request.RuleIds.Count);
|
|
|
|
if (!EnsurePolicyAuthorized(httpContext))
|
|
{
|
|
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
|
}
|
|
|
|
// In a real implementation, we would fetch rules from storage
|
|
// For now, return a mock validation result
|
|
var validation = new RuleValidationResult
|
|
{
|
|
Valid = true,
|
|
Conflicts = Array.Empty<RuleConflict>(),
|
|
UnreachableConditions = Array.Empty<string>(),
|
|
PotentialLoops = Array.Empty<string>(),
|
|
Coverage = 0.85
|
|
};
|
|
|
|
return Results.Ok(new ValidationApiResponse
|
|
{
|
|
Valid = validation.Valid,
|
|
Conflicts = validation.Conflicts.Select(c => new RuleConflictApiResponse
|
|
{
|
|
RuleId1 = c.RuleId1,
|
|
RuleId2 = c.RuleId2,
|
|
Description = c.Description,
|
|
SuggestedResolution = c.SuggestedResolution,
|
|
Severity = c.Severity
|
|
}).ToList(),
|
|
UnreachableConditions = validation.UnreachableConditions.ToList(),
|
|
PotentialLoops = validation.PotentialLoops.ToList(),
|
|
Coverage = validation.Coverage,
|
|
TestCases = Array.Empty<PolicyTestCaseApiResponse>(),
|
|
TestResults = null
|
|
});
|
|
}
|
|
|
|
// POLICY-19: POST /v1/advisory-ai/policy/studio/compile
|
|
// NOTE: This is a stub implementation. In production, this would compile rules into a PolicyBundle.
|
|
// The stub returns experimental markers to indicate incomplete implementation.
|
|
static Task<IResult> HandlePolicyCompile(
|
|
HttpContext httpContext,
|
|
PolicyCompileApiRequest request,
|
|
TimeProvider timeProvider,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.policy_compile", ActivityKind.Server);
|
|
activity?.SetTag("advisory.bundle_name", request.BundleName);
|
|
activity?.SetTag("advisory.rule_count", request.RuleIds.Count);
|
|
|
|
if (!EnsurePolicyAuthorized(httpContext))
|
|
{
|
|
return Task.FromResult(Results.StatusCode(StatusCodes.Status403Forbidden));
|
|
}
|
|
|
|
// STUB: This endpoint is experimental and not wired to real policy compilation.
|
|
// Return a deterministic bundle ID derived from input to avoid nondeterministic output.
|
|
var inputHash = ComputeDeterministicBundleId(request.BundleName, request.RuleIds);
|
|
var bundleId = $"bundle:stub:{inputHash}";
|
|
var now = timeProvider.GetUtcNow();
|
|
|
|
// Compute content hash deterministically from the rule IDs
|
|
var contentHash = ComputeDeterministicContentHash(request.RuleIds);
|
|
|
|
var response = new PolicyBundleApiResponse
|
|
{
|
|
BundleId = bundleId,
|
|
BundleName = request.BundleName,
|
|
Version = "1.0.0",
|
|
RuleCount = request.RuleIds.Count,
|
|
CompiledAt = now.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
|
|
ContentHash = $"sha256:{contentHash}",
|
|
SignatureId = null // Would be signed in production
|
|
};
|
|
|
|
return Task.FromResult(Results.Ok(response));
|
|
}
|
|
|
|
// Deterministic hash computation for stub bundle ID
|
|
static string ComputeDeterministicBundleId(string bundleName, IReadOnlyList<string> ruleIds)
|
|
{
|
|
var input = $"{bundleName}:{string.Join(",", ruleIds.OrderBy(x => x, StringComparer.Ordinal))}";
|
|
var bytes = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input));
|
|
return Convert.ToHexString(bytes)[..32].ToLowerInvariant();
|
|
}
|
|
|
|
// Deterministic content hash for stub bundles
|
|
static string ComputeDeterministicContentHash(IReadOnlyList<string> ruleIds)
|
|
{
|
|
var input = string.Join(",", ruleIds.OrderBy(x => x, StringComparer.Ordinal));
|
|
var bytes = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input));
|
|
return Convert.ToHexString(bytes).ToLowerInvariant();
|
|
}
|
|
|
|
// VEX-AI-016: Consent handler functions
|
|
static string GetTenantId(HttpContext context)
|
|
{
|
|
return context.Request.Headers.TryGetValue("X-StellaOps-Tenant", out var value)
|
|
? value.ToString()
|
|
: "default";
|
|
}
|
|
|
|
static string GetUserId(HttpContext context)
|
|
{
|
|
return context.Request.Headers.TryGetValue("X-StellaOps-User", out var value)
|
|
? value.ToString()
|
|
: "anonymous";
|
|
}
|
|
|
|
static async Task<IResult> HandleGetConsent(
|
|
HttpContext httpContext,
|
|
IAiConsentStore consentStore,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var tenantId = GetTenantId(httpContext);
|
|
var userId = GetUserId(httpContext);
|
|
|
|
var record = await consentStore.GetConsentAsync(tenantId, userId, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (record is null)
|
|
{
|
|
return Results.Ok(new AiConsentStatusResponse
|
|
{
|
|
Consented = false,
|
|
Scope = "all",
|
|
SessionLevel = false
|
|
});
|
|
}
|
|
|
|
return Results.Ok(new AiConsentStatusResponse
|
|
{
|
|
Consented = record.Consented,
|
|
ConsentedAt = record.ConsentedAt?.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
|
|
ConsentedBy = record.UserId,
|
|
Scope = record.Scope,
|
|
ExpiresAt = record.ExpiresAt?.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
|
|
SessionLevel = record.SessionLevel
|
|
});
|
|
}
|
|
|
|
static async Task<IResult> HandleGrantConsent(
|
|
HttpContext httpContext,
|
|
AiConsentGrantRequest request,
|
|
IAiConsentStore consentStore,
|
|
TimeProvider timeProvider,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!request.DataShareAcknowledged)
|
|
{
|
|
return Results.BadRequest(new { error = "Data sharing acknowledgement is required" });
|
|
}
|
|
|
|
var tenantId = GetTenantId(httpContext);
|
|
var userId = GetUserId(httpContext);
|
|
|
|
var grant = new AiConsentGrant
|
|
{
|
|
Scope = request.Scope,
|
|
SessionLevel = request.SessionLevel,
|
|
DataShareAcknowledged = request.DataShareAcknowledged,
|
|
Duration = request.SessionLevel ? TimeSpan.FromHours(24) : null
|
|
};
|
|
|
|
var record = await consentStore.GrantConsentAsync(tenantId, userId, grant, cancellationToken).ConfigureAwait(false);
|
|
|
|
return Results.Ok(new AiConsentGrantResponse
|
|
{
|
|
Consented = record.Consented,
|
|
ConsentedAt = record.ConsentedAt?.ToString("O", System.Globalization.CultureInfo.InvariantCulture) ?? timeProvider.GetUtcNow().ToString("O", System.Globalization.CultureInfo.InvariantCulture),
|
|
ExpiresAt = record.ExpiresAt?.ToString("O", System.Globalization.CultureInfo.InvariantCulture)
|
|
});
|
|
}
|
|
|
|
static async Task<IResult> HandleRevokeConsent(
|
|
HttpContext httpContext,
|
|
IAiConsentStore consentStore,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var tenantId = GetTenantId(httpContext);
|
|
var userId = GetUserId(httpContext);
|
|
|
|
await consentStore.RevokeConsentAsync(tenantId, userId, cancellationToken).ConfigureAwait(false);
|
|
return Results.NoContent();
|
|
}
|
|
|
|
// VEX-AI-016: Justification handler
|
|
static bool EnsureJustifyAuthorized(HttpContext context)
|
|
{
|
|
if (!context.Request.Headers.TryGetValue("X-StellaOps-Scopes", out var scopes))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var allowed = scopes
|
|
.SelectMany(value => value?.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? [])
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
return allowed.Contains("advisory:run") || allowed.Contains("advisory:justify");
|
|
}
|
|
|
|
static async Task<IResult> HandleJustify(
|
|
HttpContext httpContext,
|
|
AiJustifyApiRequest request,
|
|
IAiJustificationGenerator justificationGenerator,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.justify", System.Diagnostics.ActivityKind.Server);
|
|
activity?.SetTag("advisory.cve_id", request.CveId);
|
|
activity?.SetTag("advisory.product_ref", request.ProductRef);
|
|
activity?.SetTag("advisory.proposed_status", request.ProposedStatus);
|
|
|
|
if (!EnsureJustifyAuthorized(httpContext))
|
|
{
|
|
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
|
}
|
|
|
|
var domainRequest = new AiJustificationRequest
|
|
{
|
|
CveId = request.CveId,
|
|
ProductRef = request.ProductRef,
|
|
ProposedStatus = request.ProposedStatus,
|
|
JustificationType = request.JustificationType,
|
|
ReachabilityScore = request.ContextData?.ReachabilityScore,
|
|
CodeSearchResults = request.ContextData?.CodeSearchResults,
|
|
SbomContext = request.ContextData?.SbomContext,
|
|
CallGraphSummary = request.ContextData?.CallGraphSummary,
|
|
RelatedVexStatements = request.ContextData?.RelatedVexStatements,
|
|
CorrelationId = request.CorrelationId
|
|
};
|
|
|
|
try
|
|
{
|
|
var result = await justificationGenerator.GenerateAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
|
|
|
activity?.SetTag("advisory.justification_id", result.JustificationId);
|
|
activity?.SetTag("advisory.confidence", result.ConfidenceScore);
|
|
|
|
return Results.Ok(new AiJustifyApiResponse
|
|
{
|
|
JustificationId = result.JustificationId,
|
|
DraftJustification = result.DraftJustification,
|
|
SuggestedJustificationType = result.SuggestedJustificationType,
|
|
ConfidenceScore = result.ConfidenceScore,
|
|
EvidenceSuggestions = result.EvidenceSuggestions,
|
|
ModelVersion = result.ModelVersion,
|
|
GeneratedAt = result.GeneratedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
|
|
TraceId = result.TraceId
|
|
});
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.BadRequest(new { error = ex.Message });
|
|
}
|
|
}
|
|
|
|
// VEX-AI-016: Remediate alias (delegates to remediation/plan)
|
|
static async Task<IResult> HandleRemediate(
|
|
HttpContext httpContext,
|
|
RemediationPlanApiRequest request,
|
|
IRemediationPlanner remediationPlanner,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.remediate", System.Diagnostics.ActivityKind.Server);
|
|
activity?.SetTag("advisory.finding_id", request.FindingId);
|
|
activity?.SetTag("advisory.vulnerability_id", request.VulnerabilityId);
|
|
|
|
if (!EnsureRemediationAuthorized(httpContext))
|
|
{
|
|
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
|
}
|
|
|
|
try
|
|
{
|
|
var domainRequest = request.ToDomain();
|
|
var plan = await remediationPlanner.GeneratePlanAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
|
|
|
activity?.SetTag("advisory.plan_id", plan.PlanId);
|
|
activity?.SetTag("advisory.risk_assessment", plan.RiskAssessment.ToString());
|
|
|
|
return Results.Ok(RemediationPlanApiResponse.FromDomain(plan));
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.BadRequest(new { error = ex.Message });
|
|
}
|
|
}
|
|
|
|
// VEX-AI-016: Rate limits handler using config-driven service
|
|
static Task<IResult> HandleGetRateLimits(
|
|
HttpContext httpContext,
|
|
StellaOps.AdvisoryAI.WebService.Services.IRateLimitsService rateLimitsService,
|
|
TimeProvider timeProvider,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var limits = rateLimitsService.GetRateLimits(timeProvider);
|
|
|
|
var response = limits.Select(l => new AiRateLimitInfoResponse
|
|
{
|
|
Feature = l.Feature,
|
|
Limit = l.Limit,
|
|
Remaining = l.Remaining,
|
|
ResetsAt = l.ResetsAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture)
|
|
}).ToList();
|
|
|
|
return Task.FromResult(Results.Ok(response));
|
|
}
|
|
|
|
// Chat endpoint handlers (SPRINT_20260107_006_003 CH-005)
|
|
|
|
static async Task<IResult> HandleCreateConversation(
|
|
HttpContext httpContext,
|
|
StellaOps.AdvisoryAI.WebService.Contracts.CreateConversationRequest request,
|
|
IConversationService conversationService,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.create_conversation", ActivityKind.Server);
|
|
activity?.SetTag("advisory.tenant_id", request.TenantId);
|
|
|
|
if (!EnsureChatAuthorized(httpContext))
|
|
{
|
|
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
|
}
|
|
|
|
// Get user ID from header
|
|
var userId = httpContext.Request.Headers.TryGetValue("X-StellaOps-User", out var userHeader)
|
|
? userHeader.ToString()
|
|
: "anonymous";
|
|
|
|
var conversationRequest = new ConversationRequest
|
|
{
|
|
TenantId = request.TenantId,
|
|
UserId = userId,
|
|
InitialContext = request.Context is not null
|
|
? new ConversationContext
|
|
{
|
|
CurrentCveId = request.Context.CurrentCveId,
|
|
CurrentComponent = request.Context.CurrentComponent,
|
|
CurrentImageDigest = request.Context.CurrentImageDigest,
|
|
ScanId = request.Context.ScanId,
|
|
SbomId = request.Context.SbomId
|
|
}
|
|
: null,
|
|
Metadata = request.Metadata?.ToImmutableDictionary()
|
|
};
|
|
|
|
var conversation = await conversationService.CreateAsync(conversationRequest, cancellationToken).ConfigureAwait(false);
|
|
|
|
activity?.SetTag("advisory.conversation_id", conversation.ConversationId);
|
|
|
|
return Results.Created(
|
|
$"/v1/advisory-ai/conversations/{conversation.ConversationId}",
|
|
StellaOps.AdvisoryAI.WebService.Contracts.ConversationResponse.FromConversation(conversation));
|
|
}
|
|
|
|
static async Task<IResult> HandleGetConversation(
|
|
HttpContext httpContext,
|
|
string conversationId,
|
|
IConversationService conversationService,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.get_conversation", ActivityKind.Server);
|
|
activity?.SetTag("advisory.conversation_id", conversationId);
|
|
|
|
if (!EnsureChatAuthorized(httpContext))
|
|
{
|
|
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
|
}
|
|
|
|
var conversation = await conversationService.GetAsync(conversationId, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (conversation is null)
|
|
{
|
|
return Results.NotFound(new { error = $"Conversation '{conversationId}' not found" });
|
|
}
|
|
|
|
return Results.Ok(StellaOps.AdvisoryAI.WebService.Contracts.ConversationResponse.FromConversation(conversation));
|
|
}
|
|
|
|
static async Task<IResult> HandleAddTurn(
|
|
HttpContext httpContext,
|
|
string conversationId,
|
|
StellaOps.AdvisoryAI.WebService.Contracts.AddTurnRequest request,
|
|
IConversationService conversationService,
|
|
ChatPromptAssembler? promptAssembler,
|
|
ChatResponseStreamer? responseStreamer,
|
|
GroundingValidator? groundingValidator,
|
|
ActionProposalParser? actionParser,
|
|
TimeProvider timeProvider,
|
|
ILogger<Program> logger,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.add_turn", ActivityKind.Server);
|
|
activity?.SetTag("advisory.conversation_id", conversationId);
|
|
activity?.SetTag("advisory.stream", request.Stream);
|
|
|
|
if (!EnsureChatAuthorized(httpContext))
|
|
{
|
|
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
|
}
|
|
|
|
var startTime = timeProvider.GetUtcNow();
|
|
|
|
// Add user turn
|
|
try
|
|
{
|
|
var userTurnRequest = new TurnRequest
|
|
{
|
|
Role = TurnRole.User,
|
|
Content = request.Content,
|
|
Metadata = request.Metadata?.ToImmutableDictionary()
|
|
};
|
|
|
|
var userTurn = await conversationService.AddTurnAsync(conversationId, userTurnRequest, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
activity?.SetTag("advisory.user_turn_id", userTurn.TurnId);
|
|
|
|
// For now, return a placeholder response since we don't have the full LLM pipeline
|
|
// In a complete implementation, this would call the prompt assembler, LLM, and validators
|
|
var assistantContent = GeneratePlaceholderResponse(request.Content);
|
|
var assistantTurnRequest = new TurnRequest
|
|
{
|
|
Role = TurnRole.Assistant,
|
|
Content = assistantContent
|
|
};
|
|
|
|
var assistantTurn = await conversationService.AddTurnAsync(conversationId, assistantTurnRequest, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
var elapsed = timeProvider.GetUtcNow() - startTime;
|
|
|
|
var response = new StellaOps.AdvisoryAI.WebService.Contracts.AssistantTurnResponse
|
|
{
|
|
TurnId = assistantTurn.TurnId,
|
|
Content = assistantTurn.Content,
|
|
Timestamp = assistantTurn.Timestamp,
|
|
EvidenceLinks = assistantTurn.EvidenceLinks.IsEmpty
|
|
? null
|
|
: assistantTurn.EvidenceLinks.Select(StellaOps.AdvisoryAI.WebService.Contracts.EvidenceLinkResponse.FromLink).ToList(),
|
|
ProposedActions = assistantTurn.ProposedActions.IsEmpty
|
|
? null
|
|
: assistantTurn.ProposedActions.Select(StellaOps.AdvisoryAI.WebService.Contracts.ProposedActionResponse.FromAction).ToList(),
|
|
GroundingScore = 1.0, // Placeholder
|
|
TokenCount = assistantContent.Split(' ').Length, // Rough estimate
|
|
DurationMs = (long)elapsed.TotalMilliseconds
|
|
};
|
|
|
|
if (request.Stream)
|
|
{
|
|
httpContext.Response.ContentType = "text/event-stream";
|
|
httpContext.Response.Headers.CacheControl = "no-cache";
|
|
httpContext.Response.Headers.Connection = "keep-alive";
|
|
|
|
if (responseStreamer is null)
|
|
{
|
|
await httpContext.Response.WriteAsync(
|
|
"event: token\n" +
|
|
$"data: {assistantContent}\n\n",
|
|
cancellationToken);
|
|
await httpContext.Response.Body.FlushAsync(cancellationToken);
|
|
return Results.Empty;
|
|
}
|
|
|
|
await foreach (var streamEvent in responseStreamer.StreamResponseAsync(
|
|
StreamPlaceholderTokens(assistantContent, cancellationToken),
|
|
conversationId,
|
|
assistantTurn.TurnId,
|
|
cancellationToken))
|
|
{
|
|
var payload = ChatResponseStreamer.FormatAsSSE(streamEvent);
|
|
await httpContext.Response.WriteAsync(payload, cancellationToken);
|
|
await httpContext.Response.Body.FlushAsync(cancellationToken);
|
|
}
|
|
|
|
return Results.Empty;
|
|
}
|
|
|
|
return Results.Ok(response);
|
|
}
|
|
catch (ConversationNotFoundException)
|
|
{
|
|
return Results.NotFound(new { error = $"Conversation '{conversationId}' not found" });
|
|
}
|
|
}
|
|
|
|
static async Task<IResult> HandleDeleteConversation(
|
|
HttpContext httpContext,
|
|
string conversationId,
|
|
IConversationService conversationService,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.delete_conversation", ActivityKind.Server);
|
|
activity?.SetTag("advisory.conversation_id", conversationId);
|
|
|
|
if (!EnsureChatAuthorized(httpContext))
|
|
{
|
|
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
|
}
|
|
|
|
var deleted = await conversationService.DeleteAsync(conversationId, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!deleted)
|
|
{
|
|
return Results.NotFound(new { error = $"Conversation '{conversationId}' not found" });
|
|
}
|
|
|
|
return Results.NoContent();
|
|
}
|
|
|
|
static async Task<IResult> HandleListConversations(
|
|
HttpContext httpContext,
|
|
string? tenantId,
|
|
int? limit,
|
|
IConversationService conversationService,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.list_conversations", ActivityKind.Server);
|
|
|
|
if (!EnsureChatAuthorized(httpContext))
|
|
{
|
|
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
|
}
|
|
|
|
// Get tenant from header if not provided
|
|
var effectiveTenantId = tenantId
|
|
?? (httpContext.Request.Headers.TryGetValue("X-StellaOps-Tenant", out var tenantHeader)
|
|
? tenantHeader.ToString()
|
|
: "default");
|
|
|
|
// Get user from header for filtering
|
|
var userId = httpContext.Request.Headers.TryGetValue("X-StellaOps-User", out var userHeader)
|
|
? userHeader.ToString()
|
|
: null;
|
|
|
|
var conversations = await conversationService.ListAsync(effectiveTenantId, userId, limit, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
var summaries = conversations.Select(c => new StellaOps.AdvisoryAI.WebService.Contracts.ConversationSummary
|
|
{
|
|
ConversationId = c.ConversationId,
|
|
CreatedAt = c.CreatedAt,
|
|
UpdatedAt = c.UpdatedAt,
|
|
TurnCount = c.Turns.Length,
|
|
Preview = c.Turns.FirstOrDefault(t => t.Role == TurnRole.User)?.Content is { } content
|
|
? content.Length > 100 ? content[..100] + "..." : content
|
|
: null
|
|
}).ToList();
|
|
|
|
return Results.Ok(new StellaOps.AdvisoryAI.WebService.Contracts.ConversationListResponse
|
|
{
|
|
Conversations = summaries,
|
|
TotalCount = summaries.Count
|
|
});
|
|
}
|
|
|
|
static bool EnsureChatAuthorized(HttpContext context)
|
|
{
|
|
var tokens = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
if (context.Request.Headers.TryGetValue("X-StellaOps-Scopes", out var scopes))
|
|
{
|
|
AddHeaderTokens(tokens, scopes);
|
|
}
|
|
|
|
if (context.Request.Headers.TryGetValue("X-StellaOps-Roles", out var roles))
|
|
{
|
|
AddHeaderTokens(tokens, roles);
|
|
}
|
|
|
|
return tokens.Contains("advisory:run")
|
|
|| tokens.Contains("advisory:chat")
|
|
|| tokens.Contains("chat:user")
|
|
|| tokens.Contains("chat:admin");
|
|
}
|
|
|
|
static void AddHeaderTokens(HashSet<string> target, IEnumerable<string> values)
|
|
{
|
|
foreach (var value in values)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
foreach (var token in value.Split(
|
|
new[] { ' ', ',' },
|
|
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
|
{
|
|
target.Add(token);
|
|
}
|
|
}
|
|
}
|
|
|
|
static string GeneratePlaceholderResponse(string userMessage)
|
|
{
|
|
// Placeholder implementation - in production this would call the LLM
|
|
return $"I received your message: \"{userMessage}\". This is a placeholder response. " +
|
|
"The full chat functionality with grounded responses will be implemented when the LLM pipeline is connected.";
|
|
}
|
|
|
|
static async IAsyncEnumerable<TokenChunk> StreamPlaceholderTokens(
|
|
string content,
|
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
|
{
|
|
foreach (var token in content.Split(
|
|
' ',
|
|
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
yield return new TokenChunk { Content = token + " " };
|
|
await Task.Yield();
|
|
}
|
|
}
|
|
|
|
internal sealed record PipelinePlanRequest(
|
|
AdvisoryTaskType? TaskType,
|
|
string AdvisoryKey,
|
|
string? ArtifactId,
|
|
string? ArtifactPurl,
|
|
string? PolicyVersion,
|
|
string Profile = "default",
|
|
IReadOnlyCollection<string>? PreferredSections = null,
|
|
bool ForceRefresh = false)
|
|
{
|
|
public AdvisoryTaskRequest ToTaskRequest()
|
|
{
|
|
if (TaskType is null)
|
|
{
|
|
throw new InvalidOperationException("Task type must be specified.");
|
|
}
|
|
|
|
return new AdvisoryTaskRequest(
|
|
TaskType.Value,
|
|
AdvisoryKey,
|
|
ArtifactId,
|
|
ArtifactPurl,
|
|
PolicyVersion,
|
|
Profile,
|
|
PreferredSections,
|
|
ForceRefresh);
|
|
}
|
|
}
|
|
|
|
internal sealed record BatchPipelinePlanRequest
|
|
{
|
|
public IReadOnlyList<PipelinePlanRequest> Requests { get; init; } = Array.Empty<PipelinePlanRequest>();
|
|
}
|
|
|
|
// Make Program class accessible for WebApplicationFactory in tests
|
|
namespace StellaOps.AdvisoryAI.WebService
|
|
{
|
|
public partial class Program { }
|
|
}
|