audit notes work completed, test fixes work (95% done), new sprints, new data sources setup and configuration

This commit is contained in:
master
2026-01-14 10:48:00 +02:00
parent d7be6ba34b
commit 95d5898650
379 changed files with 40695 additions and 19041 deletions

View File

@@ -0,0 +1,235 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under the AGPL-3.0-or-later license.
using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services.Models.Chat;
namespace StellaOps.Cli.Services.Chat;
/// <summary>
/// HTTP client for AdvisoryAI chat operations.
/// </summary>
internal sealed class ChatClient : IChatClient
{
private readonly HttpClient _httpClient;
private readonly StellaOpsCliOptions _options;
private readonly JsonSerializerOptions _jsonOptions;
public ChatClient(HttpClient httpClient, StellaOpsCliOptions options)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_options = options ?? throw new ArgumentNullException(nameof(options));
_jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
}
public async Task<ChatQueryResponse> QueryAsync(
ChatQueryRequest request,
string? tenantId = null,
string? userId = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var url = BuildUrl("/api/v1/chat/query");
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url);
AddHeaders(httpRequest, tenantId, userId);
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
await EnsureSuccessOrThrowAsync(response, cancellationToken).ConfigureAwait(false);
var result = await response.Content.ReadFromJsonAsync<ChatQueryResponse>(_jsonOptions, cancellationToken).ConfigureAwait(false);
return result ?? throw new InvalidOperationException("Chat query returned null response.");
}
public async Task<ChatDoctorResponse> GetDoctorAsync(
string? tenantId = null,
string? userId = null,
CancellationToken cancellationToken = default)
{
var url = BuildUrl("/api/v1/chat/doctor");
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, url);
AddHeaders(httpRequest, tenantId, userId);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
await EnsureSuccessOrThrowAsync(response, cancellationToken).ConfigureAwait(false);
var result = await response.Content.ReadFromJsonAsync<ChatDoctorResponse>(_jsonOptions, cancellationToken).ConfigureAwait(false);
return result ?? throw new InvalidOperationException("Chat doctor returned null response.");
}
public async Task<ChatSettingsResponse> GetSettingsAsync(
string scope = "effective",
string? tenantId = null,
string? userId = null,
CancellationToken cancellationToken = default)
{
var url = BuildUrl($"/api/v1/chat/settings?scope={Uri.EscapeDataString(scope)}");
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, url);
AddHeaders(httpRequest, tenantId, userId);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
await EnsureSuccessOrThrowAsync(response, cancellationToken).ConfigureAwait(false);
var result = await response.Content.ReadFromJsonAsync<ChatSettingsResponse>(_jsonOptions, cancellationToken).ConfigureAwait(false);
return result ?? throw new InvalidOperationException("Chat settings returned null response.");
}
public async Task<ChatSettingsResponse> UpdateSettingsAsync(
ChatSettingsUpdateRequest request,
string scope = "user",
string? tenantId = null,
string? userId = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var url = BuildUrl($"/api/v1/chat/settings?scope={Uri.EscapeDataString(scope)}");
using var httpRequest = new HttpRequestMessage(HttpMethod.Put, url);
AddHeaders(httpRequest, tenantId, userId);
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
await EnsureSuccessOrThrowAsync(response, cancellationToken).ConfigureAwait(false);
var result = await response.Content.ReadFromJsonAsync<ChatSettingsResponse>(_jsonOptions, cancellationToken).ConfigureAwait(false);
return result ?? throw new InvalidOperationException("Chat settings update returned null response.");
}
public async Task ClearSettingsAsync(
string scope = "user",
string? tenantId = null,
string? userId = null,
CancellationToken cancellationToken = default)
{
var url = BuildUrl($"/api/v1/chat/settings?scope={Uri.EscapeDataString(scope)}");
using var httpRequest = new HttpRequestMessage(HttpMethod.Delete, url);
AddHeaders(httpRequest, tenantId, userId);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
await EnsureSuccessOrThrowAsync(response, cancellationToken).ConfigureAwait(false);
}
private string BuildUrl(string path)
{
var baseUrl = _options.BackendUrl?.TrimEnd('/') ?? "http://localhost:5000";
return $"{baseUrl}{path}";
}
private static void AddHeaders(HttpRequestMessage request, string? tenantId, string? userId)
{
if (!string.IsNullOrEmpty(tenantId))
{
request.Headers.TryAddWithoutValidation("X-Tenant-Id", tenantId);
}
if (!string.IsNullOrEmpty(userId))
{
request.Headers.TryAddWithoutValidation("X-User-Id", userId);
}
request.Headers.TryAddWithoutValidation("X-Correlation-Id", Guid.NewGuid().ToString("N"));
}
private async Task EnsureSuccessOrThrowAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (response.IsSuccessStatusCode)
{
return;
}
ChatErrorResponse? errorResponse = null;
try
{
errorResponse = await response.Content.ReadFromJsonAsync<ChatErrorResponse>(_jsonOptions, cancellationToken).ConfigureAwait(false);
}
catch
{
// Ignore JSON parse errors for error response
}
var statusCode = (int)response.StatusCode;
var errorMessage = errorResponse?.Error ?? response.ReasonPhrase ?? "Unknown error";
var errorCode = errorResponse?.Code;
var exception = response.StatusCode switch
{
HttpStatusCode.BadRequest when errorCode == "GUARDRAIL_BLOCKED" =>
new ChatGuardrailException(errorMessage, errorResponse),
HttpStatusCode.Forbidden when errorCode == "TOOL_DENIED" =>
new ChatToolDeniedException(errorMessage, errorResponse),
HttpStatusCode.TooManyRequests =>
new ChatQuotaExceededException(errorMessage, errorResponse),
HttpStatusCode.ServiceUnavailable =>
new ChatServiceUnavailableException(errorMessage, errorResponse),
_ => new ChatException($"Chat API error ({statusCode}): {errorMessage}", errorResponse)
};
throw exception;
}
}
/// <summary>
/// Base exception for chat API errors.
/// </summary>
internal class ChatException : Exception
{
public ChatErrorResponse? ErrorResponse { get; }
public ChatException(string message, ChatErrorResponse? errorResponse = null)
: base(message)
{
ErrorResponse = errorResponse;
}
}
/// <summary>
/// Exception thrown when a guardrail blocks the request.
/// </summary>
internal sealed class ChatGuardrailException : ChatException
{
public ChatGuardrailException(string message, ChatErrorResponse? errorResponse = null)
: base(message, errorResponse)
{
}
}
/// <summary>
/// Exception thrown when tool access is denied.
/// </summary>
internal sealed class ChatToolDeniedException : ChatException
{
public ChatToolDeniedException(string message, ChatErrorResponse? errorResponse = null)
: base(message, errorResponse)
{
}
}
/// <summary>
/// Exception thrown when quota is exceeded.
/// </summary>
internal sealed class ChatQuotaExceededException : ChatException
{
public ChatQuotaExceededException(string message, ChatErrorResponse? errorResponse = null)
: base(message, errorResponse)
{
}
}
/// <summary>
/// Exception thrown when chat service is unavailable.
/// </summary>
internal sealed class ChatServiceUnavailableException : ChatException
{
public ChatServiceUnavailableException(string message, ChatErrorResponse? errorResponse = null)
: base(message, errorResponse)
{
}
}

View File

@@ -0,0 +1,59 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under the AGPL-3.0-or-later license.
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cli.Services.Models.Chat;
namespace StellaOps.Cli.Services.Chat;
/// <summary>
/// Client interface for AdvisoryAI chat operations.
/// </summary>
internal interface IChatClient
{
/// <summary>
/// Send a chat query and receive a response.
/// </summary>
Task<ChatQueryResponse> QueryAsync(
ChatQueryRequest request,
string? tenantId = null,
string? userId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Get chat doctor diagnostics (quota status, tool access, last denial).
/// </summary>
Task<ChatDoctorResponse> GetDoctorAsync(
string? tenantId = null,
string? userId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Get current chat settings.
/// </summary>
Task<ChatSettingsResponse> GetSettingsAsync(
string scope = "effective",
string? tenantId = null,
string? userId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Update chat settings.
/// </summary>
Task<ChatSettingsResponse> UpdateSettingsAsync(
ChatSettingsUpdateRequest request,
string scope = "user",
string? tenantId = null,
string? userId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Clear chat settings overrides.
/// </summary>
Task ClearSettingsAsync(
string scope = "user",
string? tenantId = null,
string? userId = null,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,429 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under the AGPL-3.0-or-later license.
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models.Chat;
/// <summary>
/// Output format for chat commands.
/// </summary>
internal enum ChatOutputFormat
{
Table,
Json,
Markdown
}
/// <summary>
/// Chat query request sent to the AdvisoryAI chat API.
/// </summary>
internal sealed record ChatQueryRequest
{
[JsonPropertyName("query")]
public required string Query { get; init; }
[JsonPropertyName("artifactDigest")]
public string? ArtifactDigest { get; init; }
[JsonPropertyName("imageReference")]
public string? ImageReference { get; init; }
[JsonPropertyName("environment")]
public string? Environment { get; init; }
[JsonPropertyName("conversationId")]
public string? ConversationId { get; init; }
[JsonPropertyName("userRoles")]
public List<string>? UserRoles { get; init; }
[JsonPropertyName("noAction")]
public bool NoAction { get; init; } = true;
[JsonPropertyName("includeEvidence")]
public bool IncludeEvidence { get; init; }
}
/// <summary>
/// Chat query response from the AdvisoryAI chat API.
/// </summary>
internal sealed record ChatQueryResponse
{
[JsonPropertyName("responseId")]
public required string ResponseId { get; init; }
[JsonPropertyName("bundleId")]
public string? BundleId { get; init; }
[JsonPropertyName("intent")]
public required string Intent { get; init; }
[JsonPropertyName("generatedAt")]
public required DateTimeOffset GeneratedAt { get; init; }
[JsonPropertyName("summary")]
public required string Summary { get; init; }
[JsonPropertyName("impact")]
public ChatImpactAssessment? Impact { get; init; }
[JsonPropertyName("reachability")]
public ChatReachabilityAssessment? Reachability { get; init; }
[JsonPropertyName("mitigations")]
public List<ChatMitigationOption> Mitigations { get; init; } = [];
[JsonPropertyName("evidenceLinks")]
public List<ChatEvidenceLink> EvidenceLinks { get; init; } = [];
[JsonPropertyName("confidence")]
public required ChatConfidence Confidence { get; init; }
[JsonPropertyName("proposedActions")]
public List<ChatProposedAction> ProposedActions { get; init; } = [];
[JsonPropertyName("followUp")]
public ChatFollowUp? FollowUp { get; init; }
[JsonPropertyName("diagnostics")]
public ChatDiagnostics? Diagnostics { get; init; }
}
internal sealed record ChatImpactAssessment
{
[JsonPropertyName("severity")]
public string? Severity { get; init; }
[JsonPropertyName("affectedComponents")]
public List<string> AffectedComponents { get; init; } = [];
[JsonPropertyName("description")]
public string? Description { get; init; }
}
internal sealed record ChatReachabilityAssessment
{
[JsonPropertyName("reachable")]
public bool Reachable { get; init; }
[JsonPropertyName("paths")]
public List<string> Paths { get; init; } = [];
[JsonPropertyName("confidence")]
public double Confidence { get; init; }
}
internal sealed record ChatMitigationOption
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("title")]
public required string Title { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("effort")]
public string? Effort { get; init; }
[JsonPropertyName("recommended")]
public bool Recommended { get; init; }
}
internal sealed record ChatEvidenceLink
{
[JsonPropertyName("type")]
public required string Type { get; init; }
[JsonPropertyName("ref")]
public required string Ref { get; init; }
[JsonPropertyName("label")]
public string? Label { get; init; }
[JsonPropertyName("digest")]
public string? Digest { get; init; }
}
internal sealed record ChatConfidence
{
[JsonPropertyName("overall")]
public double Overall { get; init; }
[JsonPropertyName("evidenceQuality")]
public double EvidenceQuality { get; init; }
[JsonPropertyName("modelCertainty")]
public double ModelCertainty { get; init; }
}
internal sealed record ChatProposedAction
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("tool")]
public required string Tool { get; init; }
[JsonPropertyName("description")]
public required string Description { get; init; }
[JsonPropertyName("parameters")]
public Dictionary<string, object>? Parameters { get; init; }
[JsonPropertyName("requiresConfirmation")]
public bool RequiresConfirmation { get; init; }
[JsonPropertyName("denied")]
public bool Denied { get; init; }
[JsonPropertyName("denyReason")]
public string? DenyReason { get; init; }
}
internal sealed record ChatFollowUp
{
[JsonPropertyName("suggestedQueries")]
public List<string> SuggestedQueries { get; init; } = [];
[JsonPropertyName("relatedTopics")]
public List<string> RelatedTopics { get; init; } = [];
}
internal sealed record ChatDiagnostics
{
[JsonPropertyName("tokensUsed")]
public int TokensUsed { get; init; }
[JsonPropertyName("processingTimeMs")]
public long ProcessingTimeMs { get; init; }
[JsonPropertyName("evidenceSourcesQueried")]
public int EvidenceSourcesQueried { get; init; }
}
/// <summary>
/// Chat doctor response with quota and tool access status.
/// </summary>
internal sealed record ChatDoctorResponse
{
[JsonPropertyName("tenantId")]
public required string TenantId { get; init; }
[JsonPropertyName("userId")]
public required string UserId { get; init; }
[JsonPropertyName("quotas")]
public required ChatQuotaStatus Quotas { get; init; }
[JsonPropertyName("tools")]
public required ChatToolAccess Tools { get; init; }
[JsonPropertyName("lastDenied")]
public ChatDenialInfo? LastDenied { get; init; }
}
internal sealed record ChatQuotaStatus
{
[JsonPropertyName("requestsPerMinuteLimit")]
public int RequestsPerMinuteLimit { get; init; }
[JsonPropertyName("requestsPerMinuteRemaining")]
public int RequestsPerMinuteRemaining { get; init; }
[JsonPropertyName("requestsPerMinuteResetsAt")]
public DateTimeOffset RequestsPerMinuteResetsAt { get; init; }
[JsonPropertyName("requestsPerDayLimit")]
public int RequestsPerDayLimit { get; init; }
[JsonPropertyName("requestsPerDayRemaining")]
public int RequestsPerDayRemaining { get; init; }
[JsonPropertyName("requestsPerDayResetsAt")]
public DateTimeOffset RequestsPerDayResetsAt { get; init; }
[JsonPropertyName("tokensPerDayLimit")]
public int TokensPerDayLimit { get; init; }
[JsonPropertyName("tokensPerDayRemaining")]
public int TokensPerDayRemaining { get; init; }
[JsonPropertyName("tokensPerDayResetsAt")]
public DateTimeOffset TokensPerDayResetsAt { get; init; }
}
internal sealed record ChatToolAccess
{
[JsonPropertyName("allowAll")]
public bool AllowAll { get; init; }
[JsonPropertyName("allowedTools")]
public List<string> AllowedTools { get; init; } = [];
[JsonPropertyName("providers")]
public ChatToolProviders? Providers { get; init; }
}
internal sealed record ChatToolProviders
{
[JsonPropertyName("sbom")]
public bool Sbom { get; init; }
[JsonPropertyName("vex")]
public bool Vex { get; init; }
[JsonPropertyName("reachability")]
public bool Reachability { get; init; }
[JsonPropertyName("policy")]
public bool Policy { get; init; }
[JsonPropertyName("findings")]
public bool Findings { get; init; }
}
internal sealed record ChatDenialInfo
{
[JsonPropertyName("timestamp")]
public DateTimeOffset Timestamp { get; init; }
[JsonPropertyName("reason")]
public required string Reason { get; init; }
[JsonPropertyName("code")]
public string? Code { get; init; }
[JsonPropertyName("query")]
public string? Query { get; init; }
}
/// <summary>
/// Chat settings response.
/// </summary>
internal sealed record ChatSettingsResponse
{
[JsonPropertyName("tenantId")]
public required string TenantId { get; init; }
[JsonPropertyName("userId")]
public string? UserId { get; init; }
[JsonPropertyName("scope")]
public required string Scope { get; init; }
[JsonPropertyName("quotas")]
public ChatQuotaSettings? Quotas { get; init; }
[JsonPropertyName("tools")]
public ChatToolSettings? Tools { get; init; }
[JsonPropertyName("effective")]
public ChatEffectiveSettings? Effective { get; init; }
}
internal sealed record ChatQuotaSettings
{
[JsonPropertyName("requestsPerMinute")]
public int? RequestsPerMinute { get; init; }
[JsonPropertyName("requestsPerDay")]
public int? RequestsPerDay { get; init; }
[JsonPropertyName("tokensPerDay")]
public int? TokensPerDay { get; init; }
[JsonPropertyName("toolCallsPerDay")]
public int? ToolCallsPerDay { get; init; }
}
internal sealed record ChatToolSettings
{
[JsonPropertyName("allowAll")]
public bool? AllowAll { get; init; }
[JsonPropertyName("allowedTools")]
public List<string>? AllowedTools { get; init; }
}
internal sealed record ChatEffectiveSettings
{
[JsonPropertyName("quotas")]
public required ChatQuotaSettings Quotas { get; init; }
[JsonPropertyName("tools")]
public required ChatToolSettings Tools { get; init; }
[JsonPropertyName("source")]
public required string Source { get; init; }
}
/// <summary>
/// Chat settings update request.
/// </summary>
internal sealed record ChatSettingsUpdateRequest
{
[JsonPropertyName("quotas")]
public ChatQuotaSettingsUpdate? Quotas { get; init; }
[JsonPropertyName("tools")]
public ChatToolSettingsUpdate? Tools { get; init; }
}
internal sealed record ChatQuotaSettingsUpdate
{
[JsonPropertyName("requestsPerMinute")]
public int? RequestsPerMinute { get; init; }
[JsonPropertyName("requestsPerDay")]
public int? RequestsPerDay { get; init; }
[JsonPropertyName("tokensPerDay")]
public int? TokensPerDay { get; init; }
[JsonPropertyName("toolCallsPerDay")]
public int? ToolCallsPerDay { get; init; }
}
internal sealed record ChatToolSettingsUpdate
{
[JsonPropertyName("allowAll")]
public bool? AllowAll { get; init; }
[JsonPropertyName("allowedTools")]
public List<string>? AllowedTools { get; init; }
}
/// <summary>
/// Error response from chat API.
/// </summary>
internal sealed record ChatErrorResponse
{
[JsonPropertyName("error")]
public required string Error { get; init; }
[JsonPropertyName("code")]
public string? Code { get; init; }
[JsonPropertyName("details")]
public Dictionary<string, object>? Details { get; init; }
[JsonPropertyName("doctor")]
public ChatDoctorAction? Doctor { get; init; }
}
internal sealed record ChatDoctorAction
{
[JsonPropertyName("endpoint")]
public required string Endpoint { get; init; }
[JsonPropertyName("suggestedCommand")]
public required string SuggestedCommand { get; init; }
[JsonPropertyName("reason")]
public required string Reason { get; init; }
}