Add unit tests for SBOM ingestion and transformation
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:
master
2025-11-04 07:49:39 +02:00
parent f72c5c513a
commit 2eb6852d34
491 changed files with 39445 additions and 3917 deletions

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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));
}
}

View File

@@ -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;
}
}

View File

@@ -1,5 +1,6 @@
using System.Collections.Immutable;
using StellaOps.AdvisoryAI.Abstractions;
using StellaOps.AdvisoryAI.Documents;
using StellaOps.AdvisoryAI.Context;
using StellaOps.AdvisoryAI.Tools;

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

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