|
|
|
|
@@ -37,8 +37,12 @@ using System.Diagnostics;
|
|
|
|
|
using System.Linq;
|
|
|
|
|
using System.Net;
|
|
|
|
|
using System.Runtime.CompilerServices;
|
|
|
|
|
using System.Text;
|
|
|
|
|
using System.Text.RegularExpressions;
|
|
|
|
|
using System.Threading.RateLimiting;
|
|
|
|
|
using StellaOps.Localization;
|
|
|
|
|
using AdvisoryChatModels = StellaOps.AdvisoryAI.Chat.Models;
|
|
|
|
|
using AdvisoryChatServices = StellaOps.AdvisoryAI.Chat.Services;
|
|
|
|
|
|
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
|
|
|
|
|
|
@@ -262,23 +266,33 @@ app.MapGet("/v1/advisory-ai/rate-limits", HandleGetRateLimits)
|
|
|
|
|
// Chat endpoints (SPRINT_20260107_006_003 CH-005)
|
|
|
|
|
app.MapPost("/v1/advisory-ai/conversations", HandleCreateConversation)
|
|
|
|
|
.RequireRateLimiting("advisory-ai")
|
|
|
|
|
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
|
|
|
|
|
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
|
|
|
|
.WithSummary("Legacy conversation-creation endpoint (deprecated).")
|
|
|
|
|
.WithDescription("Creates a chat conversation using the legacy conversation surface. This endpoint family is deprecated in favor of /api/v1/chat/* and is scheduled for sunset on 2026-12-31 UTC.");
|
|
|
|
|
|
|
|
|
|
app.MapGet("/v1/advisory-ai/conversations/{conversationId}", HandleGetConversation)
|
|
|
|
|
.RequireRateLimiting("advisory-ai")
|
|
|
|
|
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy);
|
|
|
|
|
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy)
|
|
|
|
|
.WithSummary("Legacy conversation-read endpoint (deprecated).")
|
|
|
|
|
.WithDescription("Returns conversation state from the legacy conversation surface. Migrate readers to /api/v1/chat/* before the 2026-12-31 UTC sunset.");
|
|
|
|
|
|
|
|
|
|
app.MapPost("/v1/advisory-ai/conversations/{conversationId}/turns", HandleAddTurn)
|
|
|
|
|
.RequireRateLimiting("advisory-ai")
|
|
|
|
|
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
|
|
|
|
|
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
|
|
|
|
.WithSummary("Legacy conversation add-turn endpoint (deprecated).")
|
|
|
|
|
.WithDescription("Adds a conversational turn on the legacy conversation surface. Canonical payload field is content. Legacy message is accepted for compatibility only during the migration window ending 2026-12-31 UTC.");
|
|
|
|
|
|
|
|
|
|
app.MapDelete("/v1/advisory-ai/conversations/{conversationId}", HandleDeleteConversation)
|
|
|
|
|
.RequireRateLimiting("advisory-ai")
|
|
|
|
|
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
|
|
|
|
|
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
|
|
|
|
.WithSummary("Legacy conversation delete endpoint (deprecated).")
|
|
|
|
|
.WithDescription("Deletes a conversation on the legacy conversation surface. Migrate clients to /api/v1/chat/* before the 2026-12-31 UTC sunset.");
|
|
|
|
|
|
|
|
|
|
app.MapGet("/v1/advisory-ai/conversations", HandleListConversations)
|
|
|
|
|
.RequireRateLimiting("advisory-ai")
|
|
|
|
|
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy);
|
|
|
|
|
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy)
|
|
|
|
|
.WithSummary("Legacy conversation list endpoint (deprecated).")
|
|
|
|
|
.WithDescription("Lists conversations from the legacy conversation surface. Migrate listing flows to /api/v1/chat/* before the 2026-12-31 UTC sunset.");
|
|
|
|
|
|
|
|
|
|
// Chat gateway endpoints (controlled conversational interface)
|
|
|
|
|
app.MapChatEndpoints();
|
|
|
|
|
@@ -1118,6 +1132,7 @@ static async Task<IResult> HandleCreateConversation(
|
|
|
|
|
IConversationService conversationService,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
ApplyLegacyConversationHeaders(httpContext);
|
|
|
|
|
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.create_conversation", ActivityKind.Server);
|
|
|
|
|
activity?.SetTag("advisory.tenant_id", request.TenantId);
|
|
|
|
|
|
|
|
|
|
@@ -1161,6 +1176,7 @@ static async Task<IResult> HandleGetConversation(
|
|
|
|
|
IConversationService conversationService,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
ApplyLegacyConversationHeaders(httpContext);
|
|
|
|
|
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.get_conversation", ActivityKind.Server);
|
|
|
|
|
activity?.SetTag("advisory.conversation_id", conversationId);
|
|
|
|
|
|
|
|
|
|
@@ -1184,6 +1200,7 @@ static async Task<IResult> HandleAddTurn(
|
|
|
|
|
string conversationId,
|
|
|
|
|
StellaOps.AdvisoryAI.WebService.Contracts.AddTurnRequest request,
|
|
|
|
|
IConversationService conversationService,
|
|
|
|
|
AdvisoryChatServices.IAdvisoryChatService? advisoryChatService,
|
|
|
|
|
ChatPromptAssembler? promptAssembler,
|
|
|
|
|
ChatResponseStreamer? responseStreamer,
|
|
|
|
|
GroundingValidator? groundingValidator,
|
|
|
|
|
@@ -1192,15 +1209,39 @@ static async Task<IResult> HandleAddTurn(
|
|
|
|
|
ILogger<Program> logger,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
ApplyLegacyConversationHeaders(httpContext);
|
|
|
|
|
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.add_turn", ActivityKind.Server);
|
|
|
|
|
activity?.SetTag("advisory.conversation_id", conversationId);
|
|
|
|
|
activity?.SetTag("advisory.stream", request.Stream);
|
|
|
|
|
var normalizedContent = request.EffectiveContent?.Trim();
|
|
|
|
|
var usingLegacyMessage = string.IsNullOrWhiteSpace(request.Content) && !string.IsNullOrWhiteSpace(request.Message);
|
|
|
|
|
activity?.SetTag("advisory.request_shape", usingLegacyMessage ? "legacy_message" : "content");
|
|
|
|
|
|
|
|
|
|
if (usingLegacyMessage)
|
|
|
|
|
{
|
|
|
|
|
var tenantId = ResolveHeaderValue(httpContext, "X-StellaOps-Tenant")
|
|
|
|
|
?? ResolveHeaderValue(httpContext, "X-Tenant-Id")
|
|
|
|
|
?? "unknown";
|
|
|
|
|
logger.LogInformation(
|
|
|
|
|
"Legacy chat payload field 'message' used by tenant {TenantId} on endpoint {Endpoint} conversation {ConversationId}.",
|
|
|
|
|
tenantId,
|
|
|
|
|
httpContext.Request.Path.Value ?? "/v1/advisory-ai/conversations/{conversationId}/turns",
|
|
|
|
|
conversationId);
|
|
|
|
|
httpContext.Response.Headers.Append(
|
|
|
|
|
"Warning",
|
|
|
|
|
"299 - Legacy chat payload field 'message' is deprecated; use 'content'.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!EnsureChatAuthorized(httpContext))
|
|
|
|
|
{
|
|
|
|
|
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(normalizedContent))
|
|
|
|
|
{
|
|
|
|
|
return Results.BadRequest(new { error = "Either 'content' or legacy 'message' must be provided." });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var startTime = timeProvider.GetUtcNow();
|
|
|
|
|
|
|
|
|
|
// Add user turn
|
|
|
|
|
@@ -1209,7 +1250,7 @@ static async Task<IResult> HandleAddTurn(
|
|
|
|
|
var userTurnRequest = new TurnRequest
|
|
|
|
|
{
|
|
|
|
|
Role = TurnRole.User,
|
|
|
|
|
Content = request.Content,
|
|
|
|
|
Content = normalizedContent,
|
|
|
|
|
Metadata = request.Metadata?.ToImmutableDictionary()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
@@ -1217,14 +1258,23 @@ static async Task<IResult> HandleAddTurn(
|
|
|
|
|
.ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
activity?.SetTag("advisory.user_turn_id", userTurn.TurnId);
|
|
|
|
|
var assistantGeneration = await GenerateAssistantTurnAsync(
|
|
|
|
|
httpContext,
|
|
|
|
|
conversationId,
|
|
|
|
|
normalizedContent,
|
|
|
|
|
conversationService,
|
|
|
|
|
advisoryChatService,
|
|
|
|
|
logger,
|
|
|
|
|
cancellationToken).ConfigureAwait(false);
|
|
|
|
|
|
|
|
|
|
// 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 assistantContent = assistantGeneration.Content;
|
|
|
|
|
var assistantTurnRequest = new TurnRequest
|
|
|
|
|
{
|
|
|
|
|
Role = TurnRole.Assistant,
|
|
|
|
|
Content = assistantContent
|
|
|
|
|
Content = assistantContent,
|
|
|
|
|
EvidenceLinks = assistantGeneration.EvidenceLinks,
|
|
|
|
|
ProposedActions = assistantGeneration.ProposedActions,
|
|
|
|
|
Metadata = assistantGeneration.Metadata
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var assistantTurn = await conversationService.AddTurnAsync(conversationId, assistantTurnRequest, cancellationToken)
|
|
|
|
|
@@ -1243,8 +1293,8 @@ static async Task<IResult> HandleAddTurn(
|
|
|
|
|
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
|
|
|
|
|
GroundingScore = assistantGeneration.GroundingScore,
|
|
|
|
|
TokenCount = assistantGeneration.TokenCount,
|
|
|
|
|
DurationMs = (long)elapsed.TotalMilliseconds
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
@@ -1258,14 +1308,18 @@ static async Task<IResult> HandleAddTurn(
|
|
|
|
|
{
|
|
|
|
|
await httpContext.Response.WriteAsync(
|
|
|
|
|
"event: token\n" +
|
|
|
|
|
$"data: {assistantContent}\n\n",
|
|
|
|
|
$"data: {System.Text.Json.JsonSerializer.Serialize(new { content = assistantContent })}\n\n",
|
|
|
|
|
cancellationToken);
|
|
|
|
|
await httpContext.Response.WriteAsync(
|
|
|
|
|
"event: done\n" +
|
|
|
|
|
$"data: {System.Text.Json.JsonSerializer.Serialize(new { turnId = assistantTurn.TurnId, groundingScore = assistantGeneration.GroundingScore })}\n\n",
|
|
|
|
|
cancellationToken);
|
|
|
|
|
await httpContext.Response.Body.FlushAsync(cancellationToken);
|
|
|
|
|
return Results.Empty;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await foreach (var streamEvent in responseStreamer.StreamResponseAsync(
|
|
|
|
|
StreamPlaceholderTokens(assistantContent, cancellationToken),
|
|
|
|
|
StreamContentTokens(assistantContent, cancellationToken),
|
|
|
|
|
conversationId,
|
|
|
|
|
assistantTurn.TurnId,
|
|
|
|
|
cancellationToken))
|
|
|
|
|
@@ -1292,6 +1346,7 @@ static async Task<IResult> HandleDeleteConversation(
|
|
|
|
|
IConversationService conversationService,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
ApplyLegacyConversationHeaders(httpContext);
|
|
|
|
|
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.delete_conversation", ActivityKind.Server);
|
|
|
|
|
activity?.SetTag("advisory.conversation_id", conversationId);
|
|
|
|
|
|
|
|
|
|
@@ -1317,6 +1372,7 @@ static async Task<IResult> HandleListConversations(
|
|
|
|
|
IConversationService conversationService,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
ApplyLegacyConversationHeaders(httpContext);
|
|
|
|
|
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.list_conversations", ActivityKind.Server);
|
|
|
|
|
|
|
|
|
|
if (!EnsureChatAuthorized(httpContext))
|
|
|
|
|
@@ -1357,6 +1413,13 @@ static async Task<IResult> HandleListConversations(
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void ApplyLegacyConversationHeaders(HttpContext context)
|
|
|
|
|
{
|
|
|
|
|
context.Response.Headers["Deprecation"] = "true";
|
|
|
|
|
context.Response.Headers["Sunset"] = "Thu, 31 Dec 2026 23:59:59 GMT";
|
|
|
|
|
context.Response.Headers["Link"] = "</api/v1/chat/query>; rel=\"successor-version\"";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static bool EnsureChatAuthorized(HttpContext context)
|
|
|
|
|
{
|
|
|
|
|
if (!context.Request.Headers.TryGetValue("X-StellaOps-Scopes", out var scopes))
|
|
|
|
|
@@ -1368,20 +1431,427 @@ static bool EnsureChatAuthorized(HttpContext context)
|
|
|
|
|
.SelectMany(value => value?.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? [])
|
|
|
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
|
|
|
|
|
|
return allowed.Contains("advisory:run")
|
|
|
|
|
return allowed.Contains("advisory-ai:view")
|
|
|
|
|
|| allowed.Contains("advisory-ai:operate")
|
|
|
|
|
|| allowed.Contains("advisory-ai:admin")
|
|
|
|
|
|| allowed.Contains("advisoryai:view")
|
|
|
|
|
|| allowed.Contains("advisoryai:operate")
|
|
|
|
|
|| allowed.Contains("advisoryai:admin")
|
|
|
|
|
|| allowed.Contains("advisory:run")
|
|
|
|
|
|| allowed.Contains("advisory:chat")
|
|
|
|
|
|| allowed.Contains("chat:user")
|
|
|
|
|
|| allowed.Contains("chat:admin");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static string GeneratePlaceholderResponse(string userMessage)
|
|
|
|
|
static async Task<AssistantGenerationResult> GenerateAssistantTurnAsync(
|
|
|
|
|
HttpContext httpContext,
|
|
|
|
|
string conversationId,
|
|
|
|
|
string userMessage,
|
|
|
|
|
IConversationService conversationService,
|
|
|
|
|
AdvisoryChatServices.IAdvisoryChatService? advisoryChatService,
|
|
|
|
|
ILogger logger,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
// 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.";
|
|
|
|
|
var conversation = await conversationService.GetAsync(conversationId, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
if (conversation is null)
|
|
|
|
|
{
|
|
|
|
|
return CreateDeterministicFallbackTurn(userMessage, "conversation_not_found");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (advisoryChatService is null)
|
|
|
|
|
{
|
|
|
|
|
return CreateDeterministicFallbackTurn(userMessage, "chat_service_unavailable");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var tenantId = ResolveHeaderValue(httpContext, "X-StellaOps-Tenant")
|
|
|
|
|
?? ResolveHeaderValue(httpContext, "X-Tenant-Id")
|
|
|
|
|
?? conversation.TenantId;
|
|
|
|
|
|
|
|
|
|
var userId = ResolveHeaderValue(httpContext, "X-StellaOps-Actor")
|
|
|
|
|
?? ResolveHeaderValue(httpContext, "X-User-Id")
|
|
|
|
|
?? conversation.UserId;
|
|
|
|
|
|
|
|
|
|
var runtimeRequest = new AdvisoryChatServices.AdvisoryChatRequest
|
|
|
|
|
{
|
|
|
|
|
TenantId = tenantId,
|
|
|
|
|
UserId = string.IsNullOrWhiteSpace(userId) ? "anonymous" : userId,
|
|
|
|
|
UserRoles = ResolveUserRoles(httpContext),
|
|
|
|
|
Query = BuildRuntimeQuery(userMessage, conversation.Context),
|
|
|
|
|
ArtifactDigest = ResolveArtifactDigest(conversation.Context, userMessage),
|
|
|
|
|
ImageReference = null,
|
|
|
|
|
Environment = null,
|
|
|
|
|
CorrelationId = ResolveHeaderValue(httpContext, "X-Correlation-Id"),
|
|
|
|
|
ConversationId = conversationId
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var serviceResult = await advisoryChatService.ProcessQueryAsync(runtimeRequest, cancellationToken).ConfigureAwait(false);
|
|
|
|
|
if (serviceResult.Success && serviceResult.Response is not null)
|
|
|
|
|
{
|
|
|
|
|
return CreateRuntimeAssistantTurn(serviceResult.Response, serviceResult.Diagnostics);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.LogInformation(
|
|
|
|
|
"Chat runtime fell back to deterministic response for conversation {ConversationId}. Reason: {Reason}",
|
|
|
|
|
conversationId,
|
|
|
|
|
serviceResult.Error ?? "runtime_unavailable");
|
|
|
|
|
|
|
|
|
|
return CreateDeterministicFallbackTurn(userMessage, NormalizeReasonCode(serviceResult.Error));
|
|
|
|
|
}
|
|
|
|
|
catch (OperationCanceledException)
|
|
|
|
|
{
|
|
|
|
|
throw;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
logger.LogWarning(
|
|
|
|
|
ex,
|
|
|
|
|
"Chat runtime invocation failed for conversation {ConversationId}; using deterministic fallback.",
|
|
|
|
|
conversationId);
|
|
|
|
|
return CreateDeterministicFallbackTurn(userMessage, "runtime_exception");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static async IAsyncEnumerable<TokenChunk> StreamPlaceholderTokens(
|
|
|
|
|
static AssistantGenerationResult CreateRuntimeAssistantTurn(
|
|
|
|
|
AdvisoryChatModels.AdvisoryChatResponse response,
|
|
|
|
|
AdvisoryChatServices.AdvisoryChatDiagnostics? diagnostics)
|
|
|
|
|
{
|
|
|
|
|
var content = BuildRuntimeAssistantContent(response);
|
|
|
|
|
var evidenceLinks = response.EvidenceLinks
|
|
|
|
|
.Select(MapEvidenceLink)
|
|
|
|
|
.ToImmutableArray();
|
|
|
|
|
var proposedActions = response.ProposedActions
|
|
|
|
|
.Select(MapProposedAction)
|
|
|
|
|
.ToImmutableArray();
|
|
|
|
|
|
|
|
|
|
var metadata = ImmutableDictionary<string, string>.Empty
|
|
|
|
|
.Add("runtime", "advisory_chat_service")
|
|
|
|
|
.Add("response_id", response.ResponseId);
|
|
|
|
|
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(response.BundleId))
|
|
|
|
|
{
|
|
|
|
|
metadata = metadata.Add("bundle_id", response.BundleId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (diagnostics is not null)
|
|
|
|
|
{
|
|
|
|
|
metadata = metadata
|
|
|
|
|
.Add("diagnostics_total_ms", diagnostics.TotalMs.ToString(System.Globalization.CultureInfo.InvariantCulture))
|
|
|
|
|
.Add("diagnostics_inference_ms", diagnostics.InferenceMs.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return new AssistantGenerationResult(
|
|
|
|
|
Content: content,
|
|
|
|
|
EvidenceLinks: evidenceLinks,
|
|
|
|
|
ProposedActions: proposedActions,
|
|
|
|
|
GroundingScore: Clamp01(response.Confidence.Score),
|
|
|
|
|
TokenCount: diagnostics?.CompletionTokens > 0 ? diagnostics.CompletionTokens : CountTokens(content),
|
|
|
|
|
Metadata: metadata);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static AssistantGenerationResult CreateDeterministicFallbackTurn(string userMessage, string reasonCode)
|
|
|
|
|
{
|
|
|
|
|
var normalizedMessage = userMessage.Trim();
|
|
|
|
|
if (normalizedMessage.Length > 140)
|
|
|
|
|
{
|
|
|
|
|
normalizedMessage = normalizedMessage[..140] + "...";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var content =
|
|
|
|
|
$"I couldn't complete grounded AdvisoryAI analysis ({reasonCode}). " +
|
|
|
|
|
"Provide a finding/CVE ID and artifact digest (sha256:...) for evidence-backed guidance. " +
|
|
|
|
|
$"Request noted: \"{normalizedMessage}\". " +
|
|
|
|
|
"[docs:modules/advisory-ai/architecture.md]";
|
|
|
|
|
|
|
|
|
|
var evidence = ImmutableArray.Create(new EvidenceLink
|
|
|
|
|
{
|
|
|
|
|
Type = EvidenceLinkType.Documentation,
|
|
|
|
|
Uri = "docs:modules/advisory-ai/architecture.md",
|
|
|
|
|
Label = "Advisory AI architecture",
|
|
|
|
|
Confidence = 0.2
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
var metadata = ImmutableDictionary<string, string>.Empty
|
|
|
|
|
.Add("runtime", "deterministic_fallback")
|
|
|
|
|
.Add("reason", reasonCode);
|
|
|
|
|
|
|
|
|
|
return new AssistantGenerationResult(
|
|
|
|
|
Content: content,
|
|
|
|
|
EvidenceLinks: evidence,
|
|
|
|
|
ProposedActions: ImmutableArray<ProposedAction>.Empty,
|
|
|
|
|
GroundingScore: 0.25,
|
|
|
|
|
TokenCount: CountTokens(content),
|
|
|
|
|
Metadata: metadata);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static string BuildRuntimeAssistantContent(AdvisoryChatModels.AdvisoryChatResponse response)
|
|
|
|
|
{
|
|
|
|
|
var summary = response.Summary?.Trim();
|
|
|
|
|
if (string.IsNullOrWhiteSpace(summary))
|
|
|
|
|
{
|
|
|
|
|
summary = "I reviewed your request and assembled available evidence.";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var builder = new StringBuilder(summary);
|
|
|
|
|
var suggestions = response.FollowUp?.SuggestedQueries
|
|
|
|
|
.Where(static query => !string.IsNullOrWhiteSpace(query))
|
|
|
|
|
.Take(2)
|
|
|
|
|
.ToArray();
|
|
|
|
|
|
|
|
|
|
if (suggestions is { Length: > 0 })
|
|
|
|
|
{
|
|
|
|
|
builder.AppendLine();
|
|
|
|
|
builder.AppendLine();
|
|
|
|
|
builder.AppendLine("Try next:");
|
|
|
|
|
foreach (var suggestion in suggestions)
|
|
|
|
|
{
|
|
|
|
|
builder.Append("- ").AppendLine(suggestion.Trim());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var citations = response.EvidenceLinks
|
|
|
|
|
.Select(ToCitationToken)
|
|
|
|
|
.Where(static token => !string.IsNullOrWhiteSpace(token))
|
|
|
|
|
.Take(4)
|
|
|
|
|
.ToArray();
|
|
|
|
|
|
|
|
|
|
if (citations.Length > 0)
|
|
|
|
|
{
|
|
|
|
|
builder.AppendLine();
|
|
|
|
|
builder.AppendLine();
|
|
|
|
|
builder.Append("Evidence: ").Append(string.Join(' ', citations));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return builder.ToString().Trim();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static EvidenceLink MapEvidenceLink(AdvisoryChatModels.EvidenceLink link)
|
|
|
|
|
{
|
|
|
|
|
return new EvidenceLink
|
|
|
|
|
{
|
|
|
|
|
Type = link.Type switch
|
|
|
|
|
{
|
|
|
|
|
AdvisoryChatModels.EvidenceLinkType.Sbom => EvidenceLinkType.Sbom,
|
|
|
|
|
AdvisoryChatModels.EvidenceLinkType.Vex => EvidenceLinkType.Vex,
|
|
|
|
|
AdvisoryChatModels.EvidenceLinkType.Reach => EvidenceLinkType.Reachability,
|
|
|
|
|
AdvisoryChatModels.EvidenceLinkType.Runtime => EvidenceLinkType.RuntimeTrace,
|
|
|
|
|
AdvisoryChatModels.EvidenceLinkType.Attest => EvidenceLinkType.Dsse,
|
|
|
|
|
AdvisoryChatModels.EvidenceLinkType.Policy => EvidenceLinkType.Documentation,
|
|
|
|
|
AdvisoryChatModels.EvidenceLinkType.Binpatch => EvidenceLinkType.Other,
|
|
|
|
|
AdvisoryChatModels.EvidenceLinkType.Opsmem => EvidenceLinkType.Documentation,
|
|
|
|
|
_ => EvidenceLinkType.Other
|
|
|
|
|
},
|
|
|
|
|
Uri = string.IsNullOrWhiteSpace(link.Link)
|
|
|
|
|
? $"other:{link.Type.ToString().ToLowerInvariant()}"
|
|
|
|
|
: link.Link.Trim(),
|
|
|
|
|
Label = string.IsNullOrWhiteSpace(link.Description) ? null : link.Description.Trim(),
|
|
|
|
|
Confidence = link.Confidence switch
|
|
|
|
|
{
|
|
|
|
|
AdvisoryChatModels.ConfidenceLevel.High => 0.9,
|
|
|
|
|
AdvisoryChatModels.ConfidenceLevel.Medium => 0.7,
|
|
|
|
|
AdvisoryChatModels.ConfidenceLevel.Low => 0.4,
|
|
|
|
|
AdvisoryChatModels.ConfidenceLevel.InsufficientEvidence => 0.2,
|
|
|
|
|
_ => null
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static ProposedAction MapProposedAction(AdvisoryChatModels.ProposedAction action)
|
|
|
|
|
{
|
|
|
|
|
var mappedActionType = action.ActionType switch
|
|
|
|
|
{
|
|
|
|
|
AdvisoryChatModels.ProposedActionType.CreateVex => "create_vex",
|
|
|
|
|
AdvisoryChatModels.ProposedActionType.GeneratePr => "generate_manifest",
|
|
|
|
|
AdvisoryChatModels.ProposedActionType.CreateTicket => "escalate",
|
|
|
|
|
AdvisoryChatModels.ProposedActionType.Approve => "approve",
|
|
|
|
|
AdvisoryChatModels.ProposedActionType.Quarantine => "quarantine",
|
|
|
|
|
AdvisoryChatModels.ProposedActionType.Defer => "defer",
|
|
|
|
|
AdvisoryChatModels.ProposedActionType.Waive => "defer",
|
|
|
|
|
AdvisoryChatModels.ProposedActionType.Escalate => "escalate",
|
|
|
|
|
_ => "dismiss"
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return new ProposedAction
|
|
|
|
|
{
|
|
|
|
|
ActionType = mappedActionType,
|
|
|
|
|
Label = string.IsNullOrWhiteSpace(action.Label) ? mappedActionType : action.Label.Trim(),
|
|
|
|
|
Rationale = action.Description,
|
|
|
|
|
Parameters = action.Parameters ?? ImmutableDictionary<string, string>.Empty,
|
|
|
|
|
RequiresConfirmation = action.RequiresApproval ?? true,
|
|
|
|
|
PolicyGate = action.RiskLevel?.ToString()
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static string BuildRuntimeQuery(string userMessage, ConversationContext context)
|
|
|
|
|
{
|
|
|
|
|
var normalized = userMessage.Trim();
|
|
|
|
|
var contextHints = new List<string>();
|
|
|
|
|
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(context.CurrentCveId)
|
|
|
|
|
&& normalized.IndexOf(context.CurrentCveId, StringComparison.OrdinalIgnoreCase) < 0)
|
|
|
|
|
{
|
|
|
|
|
contextHints.Add($"CVE: {context.CurrentCveId.Trim()}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(context.FindingId)
|
|
|
|
|
&& normalized.IndexOf(context.FindingId, StringComparison.OrdinalIgnoreCase) < 0)
|
|
|
|
|
{
|
|
|
|
|
contextHints.Add($"Finding: {context.FindingId.Trim()}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (contextHints.Count == 0)
|
|
|
|
|
{
|
|
|
|
|
return normalized;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $"{normalized}{Environment.NewLine}{Environment.NewLine}Context:{Environment.NewLine}- {string.Join(Environment.NewLine + "- ", contextHints)}";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static string? ResolveArtifactDigest(ConversationContext context, string userMessage)
|
|
|
|
|
{
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(context.CurrentImageDigest))
|
|
|
|
|
{
|
|
|
|
|
return context.CurrentImageDigest.Trim();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var match = Regex.Match(userMessage, @"sha256:[a-f0-9]{16,}", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
|
|
|
|
return match.Success ? match.Value.ToLowerInvariant() : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static string? ResolveHeaderValue(HttpContext context, string headerName)
|
|
|
|
|
{
|
|
|
|
|
if (!context.Request.Headers.TryGetValue(headerName, out var values))
|
|
|
|
|
{
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach (var value in values)
|
|
|
|
|
{
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(value))
|
|
|
|
|
{
|
|
|
|
|
return value.Trim();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static ImmutableArray<string> ResolveUserRoles(HttpContext context)
|
|
|
|
|
{
|
|
|
|
|
if (!context.Request.Headers.TryGetValue("X-StellaOps-Roles", out var roleValues))
|
|
|
|
|
{
|
|
|
|
|
return ImmutableArray<string>.Empty;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var roles = roleValues
|
|
|
|
|
.SelectMany(value => value?.Split(new[] { ' ', ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? [])
|
|
|
|
|
.Where(static role => !string.IsNullOrWhiteSpace(role))
|
|
|
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
|
|
|
.ToImmutableArray();
|
|
|
|
|
|
|
|
|
|
return roles;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static string NormalizeReasonCode(string? reason)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrWhiteSpace(reason))
|
|
|
|
|
{
|
|
|
|
|
return "runtime_unavailable";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var normalized = Regex.Replace(
|
|
|
|
|
reason,
|
|
|
|
|
@"[^a-z0-9]+",
|
|
|
|
|
"_",
|
|
|
|
|
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant).Trim('_').ToLowerInvariant();
|
|
|
|
|
|
|
|
|
|
if (normalized.Length > 40)
|
|
|
|
|
{
|
|
|
|
|
normalized = normalized[..40];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return string.IsNullOrWhiteSpace(normalized) ? "runtime_unavailable" : normalized;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static string? ToCitationToken(AdvisoryChatModels.EvidenceLink link)
|
|
|
|
|
{
|
|
|
|
|
var type = link.Type switch
|
|
|
|
|
{
|
|
|
|
|
AdvisoryChatModels.EvidenceLinkType.Sbom => "sbom",
|
|
|
|
|
AdvisoryChatModels.EvidenceLinkType.Reach => "reach",
|
|
|
|
|
AdvisoryChatModels.EvidenceLinkType.Runtime => "runtime",
|
|
|
|
|
AdvisoryChatModels.EvidenceLinkType.Vex => "vex",
|
|
|
|
|
AdvisoryChatModels.EvidenceLinkType.Attest => "attest",
|
|
|
|
|
AdvisoryChatModels.EvidenceLinkType.Policy => "policy",
|
|
|
|
|
AdvisoryChatModels.EvidenceLinkType.Binpatch => "docs",
|
|
|
|
|
AdvisoryChatModels.EvidenceLinkType.Opsmem => "docs",
|
|
|
|
|
_ => "docs"
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var path = ExtractCitationPath(link.Link);
|
|
|
|
|
if (string.IsNullOrWhiteSpace(path))
|
|
|
|
|
{
|
|
|
|
|
path = link.Description;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(path))
|
|
|
|
|
{
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var sanitizedPath = path.Trim().Replace("[", "(").Replace("]", ")");
|
|
|
|
|
return $"[{type}:{sanitizedPath}]";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static string? ExtractCitationPath(string? uri)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrWhiteSpace(uri))
|
|
|
|
|
{
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var trimmed = uri.Trim();
|
|
|
|
|
if (trimmed.Contains("://", StringComparison.Ordinal))
|
|
|
|
|
{
|
|
|
|
|
return trimmed;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var separatorIndex = trimmed.IndexOf(':');
|
|
|
|
|
if (separatorIndex > 0 && separatorIndex < trimmed.Length - 1)
|
|
|
|
|
{
|
|
|
|
|
return trimmed[(separatorIndex + 1)..];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return trimmed;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static double Clamp01(double value)
|
|
|
|
|
{
|
|
|
|
|
if (double.IsNaN(value) || double.IsInfinity(value))
|
|
|
|
|
{
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (value < 0)
|
|
|
|
|
{
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return value > 1 ? 1 : value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static int CountTokens(string content)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrWhiteSpace(content))
|
|
|
|
|
{
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Regex.Split(content.Trim(), @"\s+", RegexOptions.CultureInvariant).Length;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static async IAsyncEnumerable<TokenChunk> StreamContentTokens(
|
|
|
|
|
string content,
|
|
|
|
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
@@ -1395,6 +1865,14 @@ static async IAsyncEnumerable<TokenChunk> StreamPlaceholderTokens(
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
internal sealed record AssistantGenerationResult(
|
|
|
|
|
string Content,
|
|
|
|
|
ImmutableArray<EvidenceLink> EvidenceLinks,
|
|
|
|
|
ImmutableArray<ProposedAction> ProposedActions,
|
|
|
|
|
double GroundingScore,
|
|
|
|
|
int TokenCount,
|
|
|
|
|
ImmutableDictionary<string, string> Metadata);
|
|
|
|
|
|
|
|
|
|
internal sealed record PipelinePlanRequest(
|
|
|
|
|
AdvisoryTaskType? TaskType,
|
|
|
|
|
string AdvisoryKey,
|
|
|
|
|
|