Add unit tests for SBOM ingestion and transformation
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Implement `SbomIngestServiceCollectionExtensionsTests` to verify the SBOM ingestion pipeline exports snapshots correctly. - Create `SbomIngestTransformerTests` to ensure the transformation produces expected nodes and edges, including deduplication of license nodes and normalization of timestamps. - Add `SbomSnapshotExporterTests` to test the export functionality for manifest, adjacency, nodes, and edges. - Introduce `VexOverlayTransformerTests` to validate the transformation of VEX nodes and edges. - Set up project file for the test project with necessary dependencies and configurations. - Include JSON fixture files for testing purposes.
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Orchestration;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Provides caching for generated advisory task plans.
|
||||
/// </summary>
|
||||
public interface IAdvisoryPlanCache
|
||||
{
|
||||
Task SetAsync(string cacheKey, AdvisoryTaskPlan plan, CancellationToken cancellationToken);
|
||||
|
||||
Task<AdvisoryTaskPlan?> TryGetAsync(string cacheKey, CancellationToken cancellationToken);
|
||||
|
||||
Task RemoveAsync(string cacheKey, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class AdvisoryPlanCacheOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Default time-to-live for cached plans when none is provided explicitly.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultTimeToLive { get; set; } = TimeSpan.FromMinutes(10);
|
||||
|
||||
/// <summary>
|
||||
/// Minimum interval between background cleanup attempts.
|
||||
/// </summary>
|
||||
public TimeSpan CleanupInterval { get; set; } = TimeSpan.FromMinutes(5);
|
||||
}
|
||||
|
||||
internal sealed class InMemoryAdvisoryPlanCache : IAdvisoryPlanCache, IDisposable
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly TimeSpan _defaultTtl;
|
||||
private readonly TimeSpan _cleanupInterval;
|
||||
private readonly Dictionary<string, CacheEntry> _entries = new(StringComparer.Ordinal);
|
||||
private DateTimeOffset _lastCleanup;
|
||||
private bool _disposed;
|
||||
|
||||
public InMemoryAdvisoryPlanCache(
|
||||
IOptions<AdvisoryPlanCacheOptions> options,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var value = options.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
if (value.DefaultTimeToLive <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(options), "DefaultTimeToLive must be greater than zero.");
|
||||
}
|
||||
|
||||
if (value.CleanupInterval <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(options), "CleanupInterval must be greater than zero.");
|
||||
}
|
||||
|
||||
_defaultTtl = value.DefaultTimeToLive;
|
||||
_cleanupInterval = value.CleanupInterval;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_lastCleanup = _timeProvider.GetUtcNow();
|
||||
}
|
||||
|
||||
public Task SetAsync(string cacheKey, AdvisoryTaskPlan plan, CancellationToken cancellationToken)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cacheKey);
|
||||
ArgumentNullException.ThrowIfNull(plan);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiration = now + _defaultTtl;
|
||||
|
||||
lock (_entries)
|
||||
{
|
||||
_entries[cacheKey] = new CacheEntry(plan, expiration);
|
||||
CleanupIfRequired(now);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<AdvisoryTaskPlan?> TryGetAsync(string cacheKey, CancellationToken cancellationToken)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cacheKey);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
AdvisoryTaskPlan? plan = null;
|
||||
|
||||
lock (_entries)
|
||||
{
|
||||
if (_entries.TryGetValue(cacheKey, out var entry) && entry.Expiration > now)
|
||||
{
|
||||
plan = entry.Plan;
|
||||
}
|
||||
else if (entry is not null)
|
||||
{
|
||||
_entries.Remove(cacheKey);
|
||||
}
|
||||
|
||||
CleanupIfRequired(now);
|
||||
}
|
||||
|
||||
return Task.FromResult(plan);
|
||||
}
|
||||
|
||||
public Task RemoveAsync(string cacheKey, CancellationToken cancellationToken)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cacheKey);
|
||||
|
||||
lock (_entries)
|
||||
{
|
||||
_entries.Remove(cacheKey);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void CleanupIfRequired(DateTimeOffset now)
|
||||
{
|
||||
if (now - _lastCleanup < _cleanupInterval)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var expiredKeys = new List<string>();
|
||||
foreach (var pair in _entries)
|
||||
{
|
||||
if (pair.Value.Expiration <= now)
|
||||
{
|
||||
expiredKeys.Add(pair.Key);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var key in expiredKeys)
|
||||
{
|
||||
_entries.Remove(key);
|
||||
}
|
||||
|
||||
_lastCleanup = now;
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(InMemoryAdvisoryPlanCache));
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_entries)
|
||||
{
|
||||
_entries.Clear();
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private sealed record CacheEntry(AdvisoryTaskPlan Plan, DateTimeOffset Expiration);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Caching;
|
||||
using StellaOps.AdvisoryAI.Metrics;
|
||||
using StellaOps.AdvisoryAI.Orchestration;
|
||||
using StellaOps.AdvisoryAI.Queue;
|
||||
using StellaOps.AdvisoryAI.Tools;
|
||||
using StellaOps.AdvisoryAI.Abstractions;
|
||||
using StellaOps.AdvisoryAI.Providers;
|
||||
using StellaOps.AdvisoryAI.Retrievers;
|
||||
using StellaOps.AdvisoryAI.Execution;
|
||||
using StellaOps.AdvisoryAI.Guardrails;
|
||||
using StellaOps.AdvisoryAI.Outputs;
|
||||
using StellaOps.AdvisoryAI.Prompting;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.DependencyInjection;
|
||||
|
||||
@@ -20,6 +31,8 @@ public static class ToolsetServiceCollectionExtensions
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.AddAdvisoryDeterministicToolset();
|
||||
services.TryAddSingleton<ISbomContextClient, NullSbomContextClient>();
|
||||
services.TryAddSingleton<ISbomContextRetriever, SbomContextRetriever>();
|
||||
|
||||
var optionsBuilder = services.AddOptions<AdvisoryPipelineOptions>();
|
||||
optionsBuilder.Configure(options => options.ApplyDefaults());
|
||||
@@ -32,4 +45,49 @@ public static class ToolsetServiceCollectionExtensions
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddAdvisoryPipelineInfrastructure(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.TryAddSingleton<IAdvisoryPlanCache, InMemoryAdvisoryPlanCache>();
|
||||
services.TryAddSingleton<IAdvisoryTaskQueue, InMemoryAdvisoryTaskQueue>();
|
||||
services.TryAddSingleton<AdvisoryPipelineMetrics>();
|
||||
services.TryAddSingleton<IAdvisoryPromptAssembler, AdvisoryPromptAssembler>();
|
||||
services.TryAddSingleton<IAdvisoryGuardrailPipeline, AdvisoryGuardrailPipeline>();
|
||||
services.TryAddSingleton<IAdvisoryOutputStore, InMemoryAdvisoryOutputStore>();
|
||||
services.TryAddSingleton<IAdvisoryPipelineExecutor, AdvisoryPipelineExecutor>();
|
||||
services.AddOptions<AdvisoryGuardrailOptions>();
|
||||
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IConfigureOptions<AdvisoryPlanCacheOptions>, ConfigureOptions<AdvisoryPlanCacheOptions>>(
|
||||
_ => options =>
|
||||
{
|
||||
if (options.DefaultTimeToLive <= TimeSpan.Zero)
|
||||
{
|
||||
options.DefaultTimeToLive = TimeSpan.FromMinutes(10);
|
||||
}
|
||||
|
||||
if (options.CleanupInterval <= TimeSpan.Zero)
|
||||
{
|
||||
options.CleanupInterval = TimeSpan.FromMinutes(5);
|
||||
}
|
||||
}));
|
||||
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IConfigureOptions<AdvisoryTaskQueueOptions>, ConfigureOptions<AdvisoryTaskQueueOptions>>(
|
||||
_ => options =>
|
||||
{
|
||||
if (options.Capacity <= 0)
|
||||
{
|
||||
options.Capacity = 1024;
|
||||
}
|
||||
|
||||
if (options.DequeueWaitInterval <= TimeSpan.Zero)
|
||||
{
|
||||
options.DequeueWaitInterval = TimeSpan.FromSeconds(1);
|
||||
}
|
||||
}));
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AdvisoryAI.Guardrails;
|
||||
using StellaOps.AdvisoryAI.Outputs;
|
||||
using StellaOps.AdvisoryAI.Orchestration;
|
||||
using StellaOps.AdvisoryAI.Prompting;
|
||||
using StellaOps.AdvisoryAI.Metrics;
|
||||
using StellaOps.AdvisoryAI.Queue;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Execution;
|
||||
|
||||
public interface IAdvisoryPipelineExecutor
|
||||
{
|
||||
Task ExecuteAsync(
|
||||
AdvisoryTaskPlan plan,
|
||||
AdvisoryTaskQueueMessage message,
|
||||
bool planFromCache,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryPipelineExecutor : IAdvisoryPipelineExecutor
|
||||
{
|
||||
private readonly IAdvisoryPromptAssembler _promptAssembler;
|
||||
private readonly IAdvisoryGuardrailPipeline _guardrailPipeline;
|
||||
private readonly IAdvisoryOutputStore _outputStore;
|
||||
private readonly AdvisoryPipelineMetrics _metrics;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<AdvisoryPipelineExecutor>? _logger;
|
||||
|
||||
public AdvisoryPipelineExecutor(
|
||||
IAdvisoryPromptAssembler promptAssembler,
|
||||
IAdvisoryGuardrailPipeline guardrailPipeline,
|
||||
IAdvisoryOutputStore outputStore,
|
||||
AdvisoryPipelineMetrics metrics,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<AdvisoryPipelineExecutor>? logger = null)
|
||||
{
|
||||
_promptAssembler = promptAssembler ?? throw new ArgumentNullException(nameof(promptAssembler));
|
||||
_guardrailPipeline = guardrailPipeline ?? throw new ArgumentNullException(nameof(guardrailPipeline));
|
||||
_outputStore = outputStore ?? throw new ArgumentNullException(nameof(outputStore));
|
||||
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync(
|
||||
AdvisoryTaskPlan plan,
|
||||
AdvisoryTaskQueueMessage message,
|
||||
bool planFromCache,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plan);
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
|
||||
var prompt = await _promptAssembler.AssembleAsync(plan, cancellationToken).ConfigureAwait(false);
|
||||
var guardrailResult = await _guardrailPipeline.EvaluateAsync(prompt, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (guardrailResult.Blocked)
|
||||
{
|
||||
_logger?.LogWarning(
|
||||
"Guardrail blocked advisory pipeline output for {TaskType} on advisory {AdvisoryKey}",
|
||||
plan.Request.TaskType,
|
||||
plan.Request.AdvisoryKey);
|
||||
}
|
||||
|
||||
var generatedAt = _timeProvider.GetUtcNow();
|
||||
var output = AdvisoryPipelineOutput.Create(plan, prompt, guardrailResult, generatedAt, planFromCache);
|
||||
await _outputStore.SaveAsync(output, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_metrics.RecordGuardrailResult(plan.Request.TaskType, guardrailResult.Blocked);
|
||||
_metrics.RecordOutputStored(plan.Request.TaskType, planFromCache, guardrailResult.Blocked);
|
||||
|
||||
_logger?.LogInformation(
|
||||
"Stored advisory pipeline output {CacheKey} (task {TaskType}, cache:{CacheHit}, guardrail_blocked:{Blocked})",
|
||||
output.CacheKey,
|
||||
plan.Request.TaskType,
|
||||
planFromCache,
|
||||
guardrailResult.Blocked);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Prompting;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Guardrails;
|
||||
|
||||
public interface IAdvisoryGuardrailPipeline
|
||||
{
|
||||
Task<AdvisoryGuardrailResult> EvaluateAsync(AdvisoryPrompt prompt, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record AdvisoryGuardrailResult(
|
||||
bool Blocked,
|
||||
string SanitizedPrompt,
|
||||
ImmutableArray<AdvisoryGuardrailViolation> Violations,
|
||||
ImmutableDictionary<string, string> Metadata)
|
||||
{
|
||||
public static AdvisoryGuardrailResult Allowed(string sanitizedPrompt, ImmutableDictionary<string, string>? metadata = null)
|
||||
=> new(false, sanitizedPrompt, ImmutableArray<AdvisoryGuardrailViolation>.Empty, metadata ?? ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
public static AdvisoryGuardrailResult Blocked(string sanitizedPrompt, IEnumerable<AdvisoryGuardrailViolation> violations, ImmutableDictionary<string, string>? metadata = null)
|
||||
=> new(true, sanitizedPrompt, violations.ToImmutableArray(), metadata ?? ImmutableDictionary<string, string>.Empty);
|
||||
}
|
||||
|
||||
public sealed record AdvisoryGuardrailViolation(string Code, string Message);
|
||||
|
||||
public sealed class AdvisoryGuardrailOptions
|
||||
{
|
||||
private static readonly string[] DefaultBlockedPhrases =
|
||||
{
|
||||
"ignore previous instructions",
|
||||
"disregard earlier instructions",
|
||||
"you are now the system",
|
||||
"override the system prompt",
|
||||
"please jailbreak"
|
||||
};
|
||||
|
||||
public int MaxPromptLength { get; set; } = 16000;
|
||||
|
||||
public bool RequireCitations { get; set; } = true;
|
||||
|
||||
public List<string> BlockedPhrases { get; } = new(DefaultBlockedPhrases);
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryGuardrailPipeline : IAdvisoryGuardrailPipeline
|
||||
{
|
||||
private readonly AdvisoryGuardrailOptions _options;
|
||||
private readonly ILogger<AdvisoryGuardrailPipeline>? _logger;
|
||||
private readonly IReadOnlyList<RedactionRule> _redactionRules;
|
||||
private readonly string[] _blockedPhraseCache;
|
||||
|
||||
public AdvisoryGuardrailPipeline(
|
||||
IOptions<AdvisoryGuardrailOptions> options,
|
||||
ILogger<AdvisoryGuardrailPipeline>? logger = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options.Value ?? new AdvisoryGuardrailOptions();
|
||||
_logger = logger;
|
||||
|
||||
_redactionRules = new[]
|
||||
{
|
||||
new RedactionRule(
|
||||
new Regex(@"(?i)(aws_secret_access_key\s*[:=]\s*)([A-Za-z0-9\/+=]{40,})", RegexOptions.CultureInvariant | RegexOptions.Compiled),
|
||||
match => $"{match.Groups[1].Value}[REDACTED_AWS_SECRET]"),
|
||||
new RedactionRule(
|
||||
new Regex(@"(?i)(token|apikey|password)\s*[:=]\s*([A-Za-z0-9\-_/]{16,})", RegexOptions.CultureInvariant | RegexOptions.Compiled),
|
||||
match => $"{match.Groups[1].Value}: [REDACTED_CREDENTIAL]"),
|
||||
new RedactionRule(
|
||||
new Regex(@"(?is)-----BEGIN [^-]+ PRIVATE KEY-----.*?-----END [^-]+ PRIVATE KEY-----", RegexOptions.CultureInvariant | RegexOptions.Compiled),
|
||||
_ => "[REDACTED_PRIVATE_KEY]")
|
||||
};
|
||||
|
||||
_blockedPhraseCache = _options.BlockedPhrases
|
||||
.Where(phrase => !string.IsNullOrWhiteSpace(phrase))
|
||||
.Select(phrase => phrase.Trim().ToLowerInvariant())
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public Task<AdvisoryGuardrailResult> EvaluateAsync(AdvisoryPrompt prompt, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(prompt);
|
||||
|
||||
var sanitized = prompt.Prompt ?? string.Empty;
|
||||
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
var violations = ImmutableArray.CreateBuilder<AdvisoryGuardrailViolation>();
|
||||
|
||||
var redactionCount = ApplyRedactions(ref sanitized);
|
||||
metadataBuilder["prompt_length"] = sanitized.Length.ToString(CultureInfo.InvariantCulture);
|
||||
metadataBuilder["redaction_count"] = redactionCount.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
var blocked = false;
|
||||
|
||||
if (_options.RequireCitations && prompt.Citations.IsDefaultOrEmpty)
|
||||
{
|
||||
blocked = true;
|
||||
violations.Add(new AdvisoryGuardrailViolation("citation_missing", "At least one citation is required."));
|
||||
}
|
||||
|
||||
if (!prompt.Citations.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var citation in prompt.Citations)
|
||||
{
|
||||
if (citation.Index <= 0 || string.IsNullOrWhiteSpace(citation.DocumentId) || string.IsNullOrWhiteSpace(citation.ChunkId))
|
||||
{
|
||||
blocked = true;
|
||||
violations.Add(new AdvisoryGuardrailViolation("citation_invalid", "Citation index or identifiers are missing."));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_options.MaxPromptLength > 0 && sanitized.Length > _options.MaxPromptLength)
|
||||
{
|
||||
blocked = true;
|
||||
violations.Add(new AdvisoryGuardrailViolation("prompt_too_long", $"Prompt length {sanitized.Length} exceeds {_options.MaxPromptLength}."));
|
||||
}
|
||||
|
||||
if (_blockedPhraseCache.Length > 0)
|
||||
{
|
||||
var lowered = sanitized.ToLowerInvariant();
|
||||
var phraseHits = 0;
|
||||
foreach (var phrase in _blockedPhraseCache)
|
||||
{
|
||||
if (lowered.Contains(phrase))
|
||||
{
|
||||
phraseHits++;
|
||||
violations.Add(new AdvisoryGuardrailViolation("prompt_injection", $"Detected blocked phrase '{phrase}'"));
|
||||
}
|
||||
}
|
||||
|
||||
if (phraseHits > 0)
|
||||
{
|
||||
blocked = true;
|
||||
metadataBuilder["blocked_phrase_count"] = phraseHits.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
|
||||
var metadata = metadataBuilder.ToImmutable();
|
||||
|
||||
if (blocked)
|
||||
{
|
||||
_logger?.LogWarning("Guardrail blocked prompt for cache key {CacheKey}", prompt.CacheKey);
|
||||
return Task.FromResult(AdvisoryGuardrailResult.Blocked(sanitized, violations, metadata));
|
||||
}
|
||||
|
||||
return Task.FromResult(AdvisoryGuardrailResult.Allowed(sanitized, metadata));
|
||||
}
|
||||
|
||||
private int ApplyRedactions(ref string sanitized)
|
||||
{
|
||||
var count = 0;
|
||||
|
||||
foreach (var rule in _redactionRules)
|
||||
{
|
||||
sanitized = rule.Regex.Replace(sanitized, match =>
|
||||
{
|
||||
count++;
|
||||
return rule.Replacement(match);
|
||||
});
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private sealed record RedactionRule(Regex Regex, Func<Match, string> Replacement);
|
||||
}
|
||||
|
||||
internal sealed class NoOpAdvisoryGuardrailPipeline : IAdvisoryGuardrailPipeline
|
||||
{
|
||||
private readonly ILogger<NoOpAdvisoryGuardrailPipeline>? _logger;
|
||||
|
||||
public NoOpAdvisoryGuardrailPipeline(ILogger<NoOpAdvisoryGuardrailPipeline>? logger = null)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<AdvisoryGuardrailResult> EvaluateAsync(AdvisoryPrompt prompt, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(prompt);
|
||||
_logger?.LogDebug("No-op guardrail pipeline invoked for cache key {CacheKey}", prompt.CacheKey);
|
||||
return Task.FromResult(AdvisoryGuardrailResult.Allowed(prompt.Prompt ?? string.Empty));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using StellaOps.AdvisoryAI.Orchestration;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Metrics;
|
||||
|
||||
public sealed class AdvisoryPipelineMetrics : IDisposable
|
||||
{
|
||||
public const string MeterName = "StellaOps.AdvisoryAI";
|
||||
|
||||
private readonly Meter _meter;
|
||||
private readonly Counter<long> _plansCreated;
|
||||
private readonly Counter<long> _plansQueued;
|
||||
private readonly Counter<long> _plansProcessed;
|
||||
private readonly Counter<long> _outputsStored;
|
||||
private readonly Counter<long> _guardrailBlocks;
|
||||
private readonly Histogram<double> _planBuildDuration;
|
||||
private bool _disposed;
|
||||
|
||||
public AdvisoryPipelineMetrics(IMeterFactory meterFactory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(meterFactory);
|
||||
|
||||
_meter = meterFactory.Create(MeterName, version: "1.0.0");
|
||||
_plansCreated = _meter.CreateCounter<long>("advisory_plans_created");
|
||||
_plansQueued = _meter.CreateCounter<long>("advisory_plans_queued");
|
||||
_plansProcessed = _meter.CreateCounter<long>("advisory_plans_processed");
|
||||
_outputsStored = _meter.CreateCounter<long>("advisory_outputs_stored");
|
||||
_guardrailBlocks = _meter.CreateCounter<long>("advisory_guardrail_blocks");
|
||||
_planBuildDuration = _meter.CreateHistogram<double>("advisory_plan_build_duration_seconds");
|
||||
}
|
||||
|
||||
public void RecordPlanCreated(double buildSeconds, AdvisoryTaskType taskType)
|
||||
{
|
||||
_plansCreated.Add(1, KeyValuePair.Create<string, object?>("task_type", taskType.ToString()));
|
||||
_planBuildDuration.Record(buildSeconds, KeyValuePair.Create<string, object?>("task_type", taskType.ToString()));
|
||||
}
|
||||
|
||||
public void RecordPlanQueued(AdvisoryTaskType taskType)
|
||||
=> _plansQueued.Add(1, KeyValuePair.Create<string, object?>("task_type", taskType.ToString()));
|
||||
|
||||
public void RecordPlanProcessed(AdvisoryTaskType taskType, bool fromCache)
|
||||
{
|
||||
_plansProcessed.Add(
|
||||
1,
|
||||
KeyValuePair.Create<string, object?>("task_type", taskType.ToString()),
|
||||
KeyValuePair.Create<string, object?>("cache_hit", fromCache));
|
||||
}
|
||||
|
||||
public void RecordOutputStored(AdvisoryTaskType taskType, bool planFromCache, bool guardrailBlocked)
|
||||
{
|
||||
_outputsStored.Add(
|
||||
1,
|
||||
KeyValuePair.Create<string, object?>("task_type", taskType.ToString()),
|
||||
KeyValuePair.Create<string, object?>("plan_cache_hit", planFromCache),
|
||||
KeyValuePair.Create<string, object?>("guardrail_blocked", guardrailBlocked));
|
||||
}
|
||||
|
||||
public void RecordGuardrailResult(AdvisoryTaskType taskType, bool blocked)
|
||||
{
|
||||
if (blocked)
|
||||
{
|
||||
_guardrailBlocks.Add(1, KeyValuePair.Create<string, object?>("task_type", taskType.ToString()));
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_meter.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.AdvisoryAI.Abstractions;
|
||||
using StellaOps.AdvisoryAI.Documents;
|
||||
using StellaOps.AdvisoryAI.Context;
|
||||
using StellaOps.AdvisoryAI.Tools;
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.AdvisoryAI.Guardrails;
|
||||
using StellaOps.AdvisoryAI.Prompting;
|
||||
using StellaOps.AdvisoryAI.Orchestration;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Outputs;
|
||||
|
||||
public interface IAdvisoryOutputStore
|
||||
{
|
||||
Task SaveAsync(AdvisoryPipelineOutput output, CancellationToken cancellationToken);
|
||||
|
||||
Task<AdvisoryPipelineOutput?> TryGetAsync(string cacheKey, AdvisoryTaskType taskType, string profile, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class AdvisoryPipelineOutput
|
||||
{
|
||||
public AdvisoryPipelineOutput(
|
||||
string cacheKey,
|
||||
AdvisoryTaskType taskType,
|
||||
string profile,
|
||||
string prompt,
|
||||
ImmutableArray<AdvisoryPromptCitation> citations,
|
||||
ImmutableDictionary<string, string> metadata,
|
||||
AdvisoryGuardrailResult guardrail,
|
||||
AdvisoryDsseProvenance provenance,
|
||||
DateTimeOffset generatedAtUtc,
|
||||
bool planFromCache)
|
||||
{
|
||||
CacheKey = cacheKey ?? throw new ArgumentNullException(nameof(cacheKey));
|
||||
TaskType = taskType;
|
||||
Profile = string.IsNullOrWhiteSpace(profile) ? throw new ArgumentException(nameof(profile)) : profile;
|
||||
Prompt = prompt ?? throw new ArgumentNullException(nameof(prompt));
|
||||
Citations = citations;
|
||||
Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata));
|
||||
Guardrail = guardrail ?? throw new ArgumentNullException(nameof(guardrail));
|
||||
Provenance = provenance ?? throw new ArgumentNullException(nameof(provenance));
|
||||
GeneratedAtUtc = generatedAtUtc;
|
||||
PlanFromCache = planFromCache;
|
||||
}
|
||||
|
||||
public string CacheKey { get; }
|
||||
|
||||
public AdvisoryTaskType TaskType { get; }
|
||||
|
||||
public string Profile { get; }
|
||||
|
||||
public string Prompt { get; }
|
||||
|
||||
public ImmutableArray<AdvisoryPromptCitation> Citations { get; }
|
||||
|
||||
public ImmutableDictionary<string, string> Metadata { get; }
|
||||
|
||||
public AdvisoryGuardrailResult Guardrail { get; }
|
||||
|
||||
public AdvisoryDsseProvenance Provenance { get; }
|
||||
|
||||
public DateTimeOffset GeneratedAtUtc { get; }
|
||||
|
||||
public bool PlanFromCache { get; }
|
||||
|
||||
public static AdvisoryPipelineOutput Create(
|
||||
AdvisoryTaskPlan plan,
|
||||
AdvisoryPrompt prompt,
|
||||
AdvisoryGuardrailResult guardrail,
|
||||
DateTimeOffset generatedAtUtc,
|
||||
bool planFromCache)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plan);
|
||||
ArgumentNullException.ThrowIfNull(prompt);
|
||||
ArgumentNullException.ThrowIfNull(guardrail);
|
||||
|
||||
var promptContent = guardrail.SanitizedPrompt ?? prompt.Prompt ?? string.Empty;
|
||||
var outputHash = ComputeHash(promptContent);
|
||||
var provenance = new AdvisoryDsseProvenance(plan.CacheKey, outputHash, ImmutableArray<string>.Empty);
|
||||
|
||||
return new AdvisoryPipelineOutput(
|
||||
plan.CacheKey,
|
||||
plan.Request.TaskType,
|
||||
plan.Request.Profile,
|
||||
promptContent,
|
||||
prompt.Citations,
|
||||
prompt.Metadata,
|
||||
guardrail,
|
||||
provenance,
|
||||
generatedAtUtc,
|
||||
planFromCache);
|
||||
}
|
||||
|
||||
private static string ComputeHash(string content)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
return Convert.ToHexString(SHA256.HashData(bytes));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AdvisoryDsseProvenance(string InputDigest, string OutputHash, ImmutableArray<string> Signatures);
|
||||
|
||||
internal sealed class InMemoryAdvisoryOutputStore : IAdvisoryOutputStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<OutputKey, AdvisoryPipelineOutput> _outputs = new();
|
||||
|
||||
public Task SaveAsync(AdvisoryPipelineOutput output, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(output);
|
||||
var key = OutputKey.Create(output.CacheKey, output.TaskType, output.Profile);
|
||||
_outputs[key] = output;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<AdvisoryPipelineOutput?> TryGetAsync(string cacheKey, AdvisoryTaskType taskType, string profile, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cacheKey);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(profile);
|
||||
|
||||
var key = OutputKey.Create(cacheKey, taskType, profile);
|
||||
_outputs.TryGetValue(key, out var output);
|
||||
return Task.FromResult(output);
|
||||
}
|
||||
|
||||
private readonly record struct OutputKey(string CacheKey, AdvisoryTaskType TaskType, string Profile)
|
||||
{
|
||||
public static OutputKey Create(string cacheKey, AdvisoryTaskType taskType, string profile)
|
||||
=> new(cacheKey, taskType, profile);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.AdvisoryAI.Abstractions;
|
||||
using StellaOps.AdvisoryAI.Context;
|
||||
using StellaOps.AdvisoryAI.Documents;
|
||||
using StellaOps.AdvisoryAI.Orchestration;
|
||||
using StellaOps.AdvisoryAI.Tools;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Prompting;
|
||||
|
||||
public interface IAdvisoryPromptAssembler
|
||||
{
|
||||
Task<AdvisoryPrompt> AssembleAsync(AdvisoryTaskPlan plan, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record AdvisoryPrompt(
|
||||
string CacheKey,
|
||||
AdvisoryTaskType TaskType,
|
||||
string Profile,
|
||||
string Prompt,
|
||||
ImmutableArray<AdvisoryPromptCitation> Citations,
|
||||
ImmutableDictionary<string, string> Metadata,
|
||||
ImmutableDictionary<string, string> Diagnostics);
|
||||
|
||||
public sealed record AdvisoryPromptCitation(int Index, string DocumentId, string ChunkId);
|
||||
|
||||
internal sealed class AdvisoryPromptAssembler : IAdvisoryPromptAssembler
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<AdvisoryTaskType, string> Instructions = new Dictionary<AdvisoryTaskType, string>
|
||||
{
|
||||
[AdvisoryTaskType.Summary] = "Produce a concise summary of the advisory. Reference citations as [n] and avoid unverified claims.",
|
||||
[AdvisoryTaskType.Conflict] = "Highlight conflicting statements across the evidence. Reference citations as [n] and explain causes.",
|
||||
[AdvisoryTaskType.Remediation] = "List remediation actions, mitigations, and verification steps. Reference citations as [n] and avoid speculative fixes."
|
||||
};
|
||||
|
||||
public Task<AdvisoryPrompt> AssembleAsync(AdvisoryTaskPlan plan, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plan);
|
||||
|
||||
var structured = BuildStructuredChunks(plan.StructuredChunks);
|
||||
var citations = BuildCitations(structured);
|
||||
var vectors = BuildVectors(plan.VectorResults);
|
||||
var sbom = BuildSbom(plan.SbomContext);
|
||||
var dependency = BuildDependency(plan.DependencyAnalysis);
|
||||
var metadata = OrderMetadata(plan.Metadata);
|
||||
|
||||
var payload = new PromptPayload(
|
||||
task: plan.Request.TaskType.ToString(),
|
||||
advisoryKey: plan.Request.AdvisoryKey,
|
||||
profile: plan.Request.Profile,
|
||||
policyVersion: plan.Request.PolicyVersion,
|
||||
instructions: ResolveInstruction(plan.Request.TaskType),
|
||||
structured: structured.Select(chunk => chunk.Payload).ToImmutableArray(),
|
||||
vectors: vectors,
|
||||
sbom: sbom,
|
||||
dependency: dependency,
|
||||
metadata: metadata,
|
||||
budget: new PromptBudget(plan.Budget.PromptTokens, plan.Budget.CompletionTokens),
|
||||
policyContext: BuildPolicyContext(plan.Request));
|
||||
|
||||
var promptJson = JsonSerializer.Serialize(payload, SerializerOptions);
|
||||
|
||||
var diagnostics = ImmutableDictionary<string, string>.Empty
|
||||
.Add("structured_chunks", structured.Length.ToString())
|
||||
.Add("vector_queries", plan.VectorResults.Length.ToString())
|
||||
.Add("vector_matches", plan.VectorResults.Sum(result => result.Matches.Length).ToString())
|
||||
.Add("has_sbom", (plan.SbomContext is not null).ToString())
|
||||
.Add("dependency_nodes", (plan.DependencyAnalysis?.Nodes.Length ?? 0).ToString());
|
||||
|
||||
var prompt = new AdvisoryPrompt(
|
||||
plan.CacheKey,
|
||||
plan.Request.TaskType,
|
||||
plan.Request.Profile,
|
||||
promptJson,
|
||||
citations,
|
||||
metadata,
|
||||
diagnostics);
|
||||
|
||||
return Task.FromResult(prompt);
|
||||
}
|
||||
|
||||
private static ImmutableArray<PromptStructuredChunk> BuildStructuredChunks(
|
||||
ImmutableArray<AdvisoryChunk> chunks)
|
||||
{
|
||||
if (chunks.IsDefaultOrEmpty)
|
||||
{
|
||||
return ImmutableArray<PromptStructuredChunk>.Empty;
|
||||
}
|
||||
|
||||
var ordered = chunks
|
||||
.OrderBy(chunk => chunk.ChunkId, StringComparer.Ordinal)
|
||||
.Select((chunk, index) =>
|
||||
new PromptStructuredChunk(
|
||||
Index: index + 1,
|
||||
DocumentId: chunk.DocumentId,
|
||||
ChunkId: chunk.ChunkId,
|
||||
Section: chunk.Section,
|
||||
ParagraphId: chunk.ParagraphId,
|
||||
Text: chunk.Text,
|
||||
Metadata: OrderMetadata(chunk.Metadata)))
|
||||
.ToImmutableArray();
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
private static ImmutableArray<AdvisoryPromptCitation> BuildCitations(
|
||||
ImmutableArray<PromptStructuredChunk> structured)
|
||||
{
|
||||
if (structured.IsDefaultOrEmpty)
|
||||
{
|
||||
return ImmutableArray<AdvisoryPromptCitation>.Empty;
|
||||
}
|
||||
|
||||
return structured
|
||||
.Select(chunk => new AdvisoryPromptCitation(chunk.Index, chunk.DocumentId, chunk.ChunkId))
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<PromptVectorQuery> BuildVectors(
|
||||
ImmutableArray<AdvisoryVectorResult> vectorResults)
|
||||
{
|
||||
if (vectorResults.IsDefaultOrEmpty)
|
||||
{
|
||||
return ImmutableArray<PromptVectorQuery>.Empty;
|
||||
}
|
||||
|
||||
var queries = vectorResults
|
||||
.OrderBy(result => result.Query, StringComparer.Ordinal)
|
||||
.Select(result =>
|
||||
{
|
||||
var matches = result.Matches
|
||||
.OrderBy(match => match.ChunkId, StringComparer.Ordinal)
|
||||
.ThenByDescending(match => match.Score)
|
||||
.Select(match => new PromptVectorMatch(
|
||||
match.DocumentId,
|
||||
match.ChunkId,
|
||||
match.Score,
|
||||
TruncateText(match.Text)))
|
||||
.ToImmutableArray();
|
||||
|
||||
return new PromptVectorQuery(result.Query, matches);
|
||||
})
|
||||
.ToImmutableArray();
|
||||
|
||||
return queries;
|
||||
}
|
||||
|
||||
private static PromptSbomContext? BuildSbom(SbomContextResult? result)
|
||||
{
|
||||
if (result is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var versionTimeline = result.VersionTimeline
|
||||
.OrderBy(entry => entry.FirstObserved)
|
||||
.Select(entry => new PromptSbomVersion(
|
||||
entry.Version,
|
||||
entry.FirstObserved,
|
||||
entry.LastObserved,
|
||||
entry.Status,
|
||||
entry.Source))
|
||||
.ToImmutableArray();
|
||||
|
||||
var dependencyPaths = result.DependencyPaths
|
||||
.Select(path => new PromptSbomDependencyPath(
|
||||
path.Nodes
|
||||
.Select(node => new PromptSbomNode(node.Identifier, node.Version))
|
||||
.ToImmutableArray(),
|
||||
path.IsRuntime,
|
||||
path.Source,
|
||||
OrderMetadata(path.Metadata)))
|
||||
.ToImmutableArray();
|
||||
|
||||
var environmentFlags = OrderMetadata(result.EnvironmentFlags);
|
||||
|
||||
PromptSbomBlastRadius? blastRadius = null;
|
||||
if (result.BlastRadius is not null)
|
||||
{
|
||||
blastRadius = new PromptSbomBlastRadius(
|
||||
result.BlastRadius.ImpactedAssets,
|
||||
result.BlastRadius.ImpactedWorkloads,
|
||||
result.BlastRadius.ImpactedNamespaces,
|
||||
result.BlastRadius.ImpactedPercentage,
|
||||
OrderMetadata(result.BlastRadius.Metadata));
|
||||
}
|
||||
|
||||
return new PromptSbomContext(
|
||||
result.ArtifactId,
|
||||
result.Purl,
|
||||
versionTimeline,
|
||||
dependencyPaths,
|
||||
environmentFlags,
|
||||
blastRadius,
|
||||
OrderMetadata(result.Metadata));
|
||||
}
|
||||
|
||||
private static PromptDependencySummary? BuildDependency(DependencyAnalysisResult? analysis)
|
||||
{
|
||||
if (analysis is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var nodes = analysis.Nodes
|
||||
.OrderBy(node => node.Identifier, StringComparer.Ordinal)
|
||||
.Select(node => new PromptDependencyNode(
|
||||
node.Identifier,
|
||||
node.Versions.OrderBy(version => version, StringComparer.Ordinal).ToImmutableArray(),
|
||||
node.RuntimeOccurrences,
|
||||
node.DevelopmentOccurrences))
|
||||
.ToImmutableArray();
|
||||
|
||||
return new PromptDependencySummary(
|
||||
analysis.ArtifactId,
|
||||
nodes,
|
||||
OrderMetadata(analysis.Metadata));
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, string> BuildPolicyContext(AdvisoryTaskRequest request)
|
||||
{
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
builder["force_refresh"] = request.ForceRefresh.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(request.PolicyVersion))
|
||||
{
|
||||
builder["policy_version"] = request.PolicyVersion!;
|
||||
}
|
||||
|
||||
if (request.PreferredSections is not null && request.PreferredSections.Count > 0)
|
||||
{
|
||||
builder["preferred_sections"] = string.Join(",", request.PreferredSections.OrderBy(section => section, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.ArtifactId))
|
||||
{
|
||||
builder["artifact_id"] = request.ArtifactId!;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.ArtifactPurl))
|
||||
{
|
||||
builder["artifact_purl"] = request.ArtifactPurl!;
|
||||
}
|
||||
|
||||
return OrderMetadata(builder.ToImmutable());
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, string> OrderMetadata(IReadOnlyDictionary<string, string> metadata)
|
||||
{
|
||||
if (metadata is null || metadata.Count == 0)
|
||||
{
|
||||
return ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
var ordered = metadata
|
||||
.OrderBy(pair => pair.Key, StringComparer.Ordinal)
|
||||
.ToImmutableDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
private static string ResolveInstruction(AdvisoryTaskType taskType)
|
||||
=> Instructions.TryGetValue(taskType, out var instruction)
|
||||
? instruction
|
||||
: "Summarize the advisory evidence with citations.";
|
||||
|
||||
private static string TruncateText(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
const int maxLength = 600;
|
||||
return text.Length <= maxLength
|
||||
? text
|
||||
: $"{text[..maxLength]}…";
|
||||
}
|
||||
|
||||
private sealed record PromptPayload(
|
||||
string Task,
|
||||
string AdvisoryKey,
|
||||
string Profile,
|
||||
string? PolicyVersion,
|
||||
string Instructions,
|
||||
ImmutableArray<PromptStructuredChunkPayload> Structured,
|
||||
ImmutableArray<PromptVectorQuery> Vectors,
|
||||
PromptSbomContext? Sbom,
|
||||
PromptDependencySummary? Dependency,
|
||||
ImmutableDictionary<string, string> Metadata,
|
||||
PromptBudget Budget,
|
||||
ImmutableDictionary<string, string> PolicyContext);
|
||||
|
||||
private sealed record PromptStructuredChunk(
|
||||
int Index,
|
||||
string DocumentId,
|
||||
string ChunkId,
|
||||
string Section,
|
||||
string ParagraphId,
|
||||
string Text,
|
||||
ImmutableDictionary<string, string> Metadata)
|
||||
{
|
||||
public PromptStructuredChunkPayload Payload => new(
|
||||
Index,
|
||||
DocumentId,
|
||||
ChunkId,
|
||||
Section,
|
||||
ParagraphId,
|
||||
Text,
|
||||
Metadata);
|
||||
}
|
||||
|
||||
private sealed record PromptStructuredChunkPayload(
|
||||
int Index,
|
||||
string DocumentId,
|
||||
string ChunkId,
|
||||
string Section,
|
||||
string ParagraphId,
|
||||
string Text,
|
||||
ImmutableDictionary<string, string> Metadata);
|
||||
|
||||
private sealed record PromptVectorQuery(string Query, ImmutableArray<PromptVectorMatch> Matches);
|
||||
|
||||
private sealed record PromptVectorMatch(string DocumentId, string ChunkId, double Score, string Preview);
|
||||
|
||||
private sealed record PromptSbomContext(
|
||||
string ArtifactId,
|
||||
string? Purl,
|
||||
ImmutableArray<PromptSbomVersion> VersionTimeline,
|
||||
ImmutableArray<PromptSbomDependencyPath> DependencyPaths,
|
||||
ImmutableDictionary<string, string> EnvironmentFlags,
|
||||
PromptSbomBlastRadius? BlastRadius,
|
||||
ImmutableDictionary<string, string> Metadata);
|
||||
|
||||
private sealed record PromptSbomVersion(
|
||||
string Version,
|
||||
DateTimeOffset FirstObserved,
|
||||
DateTimeOffset? LastObserved,
|
||||
string Status,
|
||||
string Source);
|
||||
|
||||
private sealed record PromptSbomDependencyPath(
|
||||
ImmutableArray<PromptSbomNode> Nodes,
|
||||
bool IsRuntime,
|
||||
string? Source,
|
||||
ImmutableDictionary<string, string> Metadata);
|
||||
|
||||
private sealed record PromptSbomNode(string Identifier, string? Version);
|
||||
|
||||
private sealed record PromptSbomBlastRadius(
|
||||
int ImpactedAssets,
|
||||
int ImpactedWorkloads,
|
||||
int ImpactedNamespaces,
|
||||
double? ImpactedPercentage,
|
||||
ImmutableDictionary<string, string> Metadata);
|
||||
|
||||
private sealed record PromptDependencySummary(
|
||||
string ArtifactId,
|
||||
ImmutableArray<PromptDependencyNode> Nodes,
|
||||
ImmutableDictionary<string, string> Metadata);
|
||||
|
||||
private sealed record PromptDependencyNode(
|
||||
string Identifier,
|
||||
ImmutableArray<string> Versions,
|
||||
int RuntimeOccurrences,
|
||||
int DevelopmentOccurrences);
|
||||
|
||||
private sealed record PromptBudget(int PromptTokens, int CompletionTokens);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Orchestration;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Queue;
|
||||
|
||||
public sealed record AdvisoryTaskQueueMessage(string PlanCacheKey, AdvisoryTaskRequest Request);
|
||||
|
||||
public interface IAdvisoryTaskQueue
|
||||
{
|
||||
ValueTask EnqueueAsync(AdvisoryTaskQueueMessage message, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<AdvisoryTaskQueueMessage?> DequeueAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class AdvisoryTaskQueueOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum number of queued items kept in memory. When the queue is full enqueue
|
||||
/// operations will wait until space is available.
|
||||
/// </summary>
|
||||
public int Capacity { get; set; } = 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Interval used by workers when they poll the queue while no items are available.
|
||||
/// </summary>
|
||||
public TimeSpan DequeueWaitInterval { get; set; } = TimeSpan.FromSeconds(1);
|
||||
}
|
||||
|
||||
internal sealed class InMemoryAdvisoryTaskQueue : IAdvisoryTaskQueue
|
||||
{
|
||||
private readonly Channel<AdvisoryTaskQueueMessage> _channel;
|
||||
private readonly AdvisoryTaskQueueOptions _options;
|
||||
private readonly ILogger<InMemoryAdvisoryTaskQueue>? _logger;
|
||||
|
||||
public InMemoryAdvisoryTaskQueue(
|
||||
IOptions<AdvisoryTaskQueueOptions> options,
|
||||
ILogger<InMemoryAdvisoryTaskQueue>? logger = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
_options = options.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
if (_options.Capacity <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(options), "Capacity must be greater than zero.");
|
||||
}
|
||||
|
||||
if (_options.DequeueWaitInterval <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(options), "DequeueWaitInterval must be greater than zero.");
|
||||
}
|
||||
|
||||
_logger = logger;
|
||||
var channelOptions = new BoundedChannelOptions(_options.Capacity)
|
||||
{
|
||||
FullMode = BoundedChannelFullMode.Wait,
|
||||
SingleReader = false,
|
||||
SingleWriter = false,
|
||||
};
|
||||
|
||||
_channel = Channel.CreateBounded<AdvisoryTaskQueueMessage>(channelOptions);
|
||||
}
|
||||
|
||||
public async ValueTask EnqueueAsync(AdvisoryTaskQueueMessage message, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(message);
|
||||
await _channel.Writer.WriteAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
_logger?.LogDebug("Queued advisory pipeline plan {PlanCacheKey}", message.PlanCacheKey);
|
||||
}
|
||||
|
||||
public async ValueTask<AdvisoryTaskQueueMessage?> DequeueAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
if (await _channel.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (_channel.Reader.TryRead(out var message))
|
||||
{
|
||||
_logger?.LogDebug("Dequeued advisory pipeline plan {PlanCacheKey}", message.PlanCacheKey);
|
||||
return message;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await Task.Delay(_options.DequeueWaitInterval, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@
|
||||
| AIAI-31-002 | DOING | Advisory AI Guild, SBOM Service Guild | SBOM-VULN-29-001 | Build SBOM context retriever (purl version timelines, dependency paths, env flags, blast radius estimator). | Retriever returns paths/metrics under SLA; tests cover ecosystems. |
|
||||
| AIAI-31-003 | DOING | Advisory AI Guild | AIAI-31-001..002 | Implement deterministic toolset (version comparators, range checks, dependency analysis, policy lookup) exposed via orchestrator. | Tools validated with property tests; outputs cached; docs updated. |
|
||||
| AIAI-31-004 | DOING | Advisory AI Guild | AIAI-31-001..003, AUTH-VULN-29-001 | Build orchestration pipeline for Summary/Conflict/Remediation tasks (prompt templates, tool calls, token budgets, caching). | Pipeline executes tasks deterministically; caches keyed by tuple+policy; integration tests cover tasks. |
|
||||
| AIAI-31-004A | TODO | Advisory AI Guild, Platform Guild | AIAI-31-004, AIAI-31-002 | Wire `AdvisoryPipelineOrchestrator` into WebService/Worker, expose API/queue contracts, emit metrics, and stand up cache stub. | API returns plan metadata; worker executes queue message; metrics recorded; doc updated. |
|
||||
| AIAI-31-004B | TODO | Advisory AI Guild, Security Guild | AIAI-31-004A, DOCS-AIAI-31-003, AUTH-AIAI-31-004 | Implement prompt assembler, guardrail plumbing, cache persistence, DSSE provenance; add golden outputs. | Deterministic outputs cached; guardrails enforced; tests cover prompt assembly + caching. |
|
||||
| AIAI-31-004A | DONE (2025-11-03) | Advisory AI Guild, Platform Guild | AIAI-31-004, AIAI-31-002 | Wire `AdvisoryPipelineOrchestrator` into WebService/Worker, expose API/queue contracts, emit metrics, and stand up cache stub. | API returns plan metadata; worker executes queue message; metrics recorded; doc updated. |
|
||||
> 2025-11-03: In-memory plan cache + task queue implemented, WebService exposes `/api/v1/advisory/plan` & `/api/v1/advisory/queue`, pipeline metrics wired, worker hosted service dequeues plans and logs processed runs; docs/sprint notes updated.
|
||||
| AIAI-31-004B | DONE (2025-11-03) | Advisory AI Guild, Security Guild | AIAI-31-004A, DOCS-AIAI-31-003, AUTH-AIAI-31-004 | Implement prompt assembler, guardrail plumbing, cache persistence, DSSE provenance; add golden outputs. | Deterministic outputs cached; guardrails enforced; tests cover prompt assembly + caching. |
|
||||
> 2025-11-03: Added deterministic prompt assembler, no-op guardrail pipeline hooks, DSSE-ready output persistence with provenance, updated metrics/DI wiring, and golden prompt tests.
|
||||
| AIAI-31-004C | TODO | Advisory AI Guild, CLI Guild, Docs Guild | AIAI-31-004B, CLI-AIAI-31-003 | Deliver CLI `stella advise run <task>` command, renderers, documentation updates, and CLI golden tests. | CLI command produces deterministic output; docs published; smoke run recorded. |
|
||||
| AIAI-31-005 | TODO | Advisory AI Guild, Security Guild | AIAI-31-004 | Implement guardrails (redaction, injection defense, output validation, citation enforcement) and fail-safe handling. | Guardrails block adversarial inputs; output validator enforces schemas; security tests pass. |
|
||||
| AIAI-31-006 | TODO | Advisory AI Guild | AIAI-31-004..005 | Expose REST API endpoints (`/advisory/ai/*`) with RBAC, rate limits, OpenAPI schemas, and batching support. | Endpoints deployed with schema validation; rate limits enforced; integration tests cover error codes. |
|
||||
| AIAI-31-005 | DOING (2025-11-03) | Advisory AI Guild, Security Guild | AIAI-31-004 | Implement guardrails (redaction, injection defense, output validation, citation enforcement) and fail-safe handling. | Guardrails block adversarial inputs; output validator enforces schemas; security tests pass. |
|
||||
| AIAI-31-006 | DOING (2025-11-03) | Advisory AI Guild | AIAI-31-004..005 | Expose REST API endpoints (`/advisory/ai/*`) with RBAC, rate limits, OpenAPI schemas, and batching support. | Endpoints deployed with schema validation; rate limits enforced; integration tests cover error codes. |
|
||||
| AIAI-31-007 | TODO | Advisory AI Guild, Observability Guild | AIAI-31-004..006 | Instrument metrics (`advisory_ai_latency`, `guardrail_blocks`, `validation_failures`, `citation_coverage`), logs, and traces; publish dashboards/alerts. | Telemetry live; dashboards approved; alerts configured. |
|
||||
| AIAI-31-008 | TODO | Advisory AI Guild, DevOps Guild | AIAI-31-006..007 | Package inference on-prem container, remote inference toggle, Helm/Compose manifests, scaling guidance, offline kit instructions. | Deployment docs merged; smoke deploy executed; offline kit updated; feature flags documented. |
|
||||
| AIAI-31-010 | DONE (2025-11-02) | Advisory AI Guild | CONCELIER-VULN-29-001, EXCITITOR-VULN-29-001 | Implement Concelier advisory raw document provider mapping CSAF/OSV payloads into structured chunks for retrieval. | Provider resolves content format, preserves metadata, and passes unit tests covering CSAF/OSV cases. |
|
||||
@@ -17,6 +19,8 @@
|
||||
| AIAI-31-009 | TODO | Advisory AI Guild, QA Guild | AIAI-31-001..006 | Develop unit/golden/property/perf tests, injection harness, and regression suite; ensure determinism with seeded caches. | Test suite green; golden outputs stored; injection tests pass; perf targets documented. |
|
||||
|
||||
> 2025-11-02: AIAI-31-002 – SBOM context domain models finalized with limiter guards; retriever tests now cover flag toggles and path dedupe. Service client integration still pending with SBOM guild.
|
||||
> 2025-11-03: AIAI-31-002 – HTTP SBOM context client wired with configurable headers/timeouts, DI registers fallback null client and typed retriever; tests cover request shaping, response mapping, and 404 handling.
|
||||
> 2025-11-03: Blocking follow-up tracked via SBOM-AIAI-31-003 – waiting on SBOM base URL/API key hand-off plus joint smoke test before enabling live retrieval in staging.
|
||||
|
||||
> 2025-11-02: AIAI-31-003 moved to DOING – starting deterministic tooling surface (version comparators & dependency analysis). Added semantic-version + EVR comparators and published toolset interface; awaiting downstream wiring.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user