documentation cleanse, sprints work and planning. remaining non EF DAL migration to EF

This commit is contained in:
master
2026-02-25 01:24:07 +02:00
parent b07d27772e
commit 4db038123b
9090 changed files with 4836 additions and 2909 deletions

View File

@@ -79,7 +79,19 @@ public sealed record AddTurnRequest
/// Gets the user message content.
/// </summary>
[JsonPropertyName("content")]
public required string Content { get; init; }
public string? Content { get; init; }
/// <summary>
/// Gets the legacy user message content field.
/// </summary>
[JsonPropertyName("message")]
public string? Message { get; init; }
/// <summary>
/// Gets normalized message content from canonical or legacy payloads.
/// </summary>
[JsonIgnore]
public string? EffectiveContent => !string.IsNullOrWhiteSpace(Content) ? Content : Message;
/// <summary>
/// Gets optional metadata for this turn.

View File

@@ -21,6 +21,8 @@ using StellaOps.AdvisoryAI.WebService.Security;
using StellaOps.Auth.ServerIntegration.Tenancy;
using System.Collections.Immutable;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using static StellaOps.Localization.T;
@@ -166,6 +168,17 @@ public static class ChatEndpoints
if (!result.Success)
{
if (!result.GuardrailBlocked && !result.QuotaBlocked && !result.ToolAccessDenied)
{
logger.LogWarning(
"Chat gateway runtime fallback activated for tenant {TenantId}, user {UserId}. Reason: {Reason}",
tenantId,
userId,
result.Error ?? "processing_failed");
return Results.Ok(CreateDeterministicFallbackQueryResponse(request, result));
}
var statusCode = result.GuardrailBlocked
? StatusCodes.Status400BadRequest
: result.QuotaBlocked
@@ -858,6 +871,60 @@ public static class ChatEndpoints
} : null
};
}
private static AdvisoryChatQueryResponse CreateDeterministicFallbackQueryResponse(
AdvisoryChatQueryRequest request,
AdvisoryChatServiceResult failedResult)
{
var diagnostics = failedResult.Diagnostics is null
? null
: new DiagnosticsResponse
{
IntentRoutingMs = failedResult.Diagnostics.IntentRoutingMs,
EvidenceAssemblyMs = failedResult.Diagnostics.EvidenceAssemblyMs,
InferenceMs = failedResult.Diagnostics.InferenceMs,
TotalMs = failedResult.Diagnostics.TotalMs,
PromptTokens = failedResult.Diagnostics.PromptTokens,
CompletionTokens = failedResult.Diagnostics.CompletionTokens,
};
var reason = string.IsNullOrWhiteSpace(failedResult.Error)
? "chat runtime unavailable"
: failedResult.Error.Trim();
var normalizedQuery = request.Query?.Trim() ?? string.Empty;
var fallbackId = BuildFallbackResponseId(normalizedQuery, reason);
return new AdvisoryChatQueryResponse
{
ResponseId = fallbackId,
BundleId = null,
Intent = (failedResult.Intent ?? AdvisoryChatIntent.General).ToString(),
GeneratedAt = DateTimeOffset.UtcNow,
Summary =
$"Chat runtime is temporarily unavailable. For \"{normalizedQuery}\", start with unified search evidence, verify VEX status, and confirm active policy gates before acting.",
Confidence = new ConfidenceResponse { Level = ConfidenceLevel.Low.ToString(), Score = 0.2d },
EvidenceLinks = [],
Mitigations = [],
ProposedActions = [],
FollowUp = new FollowUpResponse
{
SuggestedQueries = [normalizedQuery],
NextSteps =
[
"Retry this chat query after runtime recovery.",
"Use global search to review findings, VEX, and policy context now."
]
},
Diagnostics = diagnostics,
};
}
private static string BuildFallbackResponseId(string query, string reason)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes($"{query}|{reason}"));
var token = Convert.ToHexString(bytes.AsSpan(0, 8)).ToLowerInvariant();
return $"fallback-{token}";
}
}
#region Request/Response DTOs

View File

@@ -121,6 +121,24 @@ public static class SearchAnalyticsEndpoints
if (events.Count > 0)
{
if (!string.IsNullOrWhiteSpace(userId))
{
foreach (var evt in events)
{
if (!ShouldPersistHistory(evt))
{
continue;
}
await analyticsService.RecordHistoryAsync(
tenant,
userId,
evt.Query,
evt.ResultCount ?? 0,
cancellationToken).ConfigureAwait(false);
}
}
// Fire-and-forget: do not await in the request pipeline to keep latency low.
// The analytics service already swallows exceptions internally.
_ = analyticsService.RecordEventsAsync(events, CancellationToken.None);
@@ -129,6 +147,22 @@ public static class SearchAnalyticsEndpoints
return Results.NoContent();
}
private static bool ShouldPersistHistory(SearchAnalyticsEvent evt)
{
if (string.IsNullOrWhiteSpace(evt.Query))
{
return false;
}
if (evt.Query.StartsWith("__", StringComparison.Ordinal))
{
return false;
}
return string.Equals(evt.EventType, "query", StringComparison.OrdinalIgnoreCase)
|| string.Equals(evt.EventType, "zero_result", StringComparison.OrdinalIgnoreCase);
}
private static async Task<IResult> GetHistoryAsync(
HttpContext httpContext,
SearchAnalyticsService analyticsService,

View File

@@ -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,