Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using StellaOps.AdvisoryAI.PolicyStudio;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// API request for parsing natural language to policy intent.
|
||||
/// Sprint: SPRINT_20251226_017_AI_policy_copilot
|
||||
/// Task: POLICY-16
|
||||
/// </summary>
|
||||
public sealed record PolicyParseApiRequest
|
||||
{
|
||||
[Required]
|
||||
[MinLength(10)]
|
||||
public required string Input { get; init; }
|
||||
|
||||
public string? DefaultScope { get; init; }
|
||||
public string? OrganizationId { get; init; }
|
||||
public string? PreferredFormat { get; init; }
|
||||
|
||||
public PolicyParseContext ToContext() => new()
|
||||
{
|
||||
DefaultScope = DefaultScope,
|
||||
OrganizationId = OrganizationId,
|
||||
PreferredFormat = PreferredFormat
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// API response for policy parse result.
|
||||
/// </summary>
|
||||
public sealed record PolicyParseApiResponse
|
||||
{
|
||||
public required PolicyIntentApiResponse Intent { get; init; }
|
||||
public required bool Success { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
public required string ModelId { get; init; }
|
||||
public required string ParsedAt { get; init; }
|
||||
|
||||
public static PolicyParseApiResponse FromDomain(PolicyParseResult result) => new()
|
||||
{
|
||||
Intent = PolicyIntentApiResponse.FromDomain(result.Intent),
|
||||
Success = result.Success,
|
||||
ErrorMessage = result.ErrorMessage,
|
||||
ModelId = result.ModelId,
|
||||
ParsedAt = result.ParsedAt
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// API representation of policy intent.
|
||||
/// </summary>
|
||||
public sealed record PolicyIntentApiResponse
|
||||
{
|
||||
public required string IntentId { get; init; }
|
||||
public required string IntentType { get; init; }
|
||||
public required string OriginalInput { get; init; }
|
||||
public required IReadOnlyList<PolicyConditionApiResponse> Conditions { get; init; }
|
||||
public required IReadOnlyList<PolicyActionApiResponse> Actions { get; init; }
|
||||
public required string Scope { get; init; }
|
||||
public string? ScopeId { get; init; }
|
||||
public required int Priority { get; init; }
|
||||
public required double Confidence { get; init; }
|
||||
public IReadOnlyList<string>? ClarifyingQuestions { get; init; }
|
||||
|
||||
public static PolicyIntentApiResponse FromDomain(PolicyIntent intent) => new()
|
||||
{
|
||||
IntentId = intent.IntentId,
|
||||
IntentType = intent.IntentType.ToString(),
|
||||
OriginalInput = intent.OriginalInput,
|
||||
Conditions = intent.Conditions.Select(c => new PolicyConditionApiResponse
|
||||
{
|
||||
Field = c.Field,
|
||||
Operator = c.Operator,
|
||||
Value = c.Value,
|
||||
Connector = c.Connector
|
||||
}).ToList(),
|
||||
Actions = intent.Actions.Select(a => new PolicyActionApiResponse
|
||||
{
|
||||
ActionType = a.ActionType,
|
||||
Parameters = a.Parameters
|
||||
}).ToList(),
|
||||
Scope = intent.Scope,
|
||||
ScopeId = intent.ScopeId,
|
||||
Priority = intent.Priority,
|
||||
Confidence = intent.Confidence,
|
||||
ClarifyingQuestions = intent.ClarifyingQuestions
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record PolicyConditionApiResponse
|
||||
{
|
||||
public required string Field { get; init; }
|
||||
public required string Operator { get; init; }
|
||||
public required object Value { get; init; }
|
||||
public string? Connector { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PolicyActionApiResponse
|
||||
{
|
||||
public required string ActionType { get; init; }
|
||||
public required IReadOnlyDictionary<string, object> Parameters { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// API request for generating rules from intent.
|
||||
/// Task: POLICY-17
|
||||
/// </summary>
|
||||
public sealed record PolicyGenerateApiRequest
|
||||
{
|
||||
[Required]
|
||||
public required string IntentId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// API response for rule generation.
|
||||
/// </summary>
|
||||
public sealed record RuleGenerationApiResponse
|
||||
{
|
||||
public required IReadOnlyList<LatticeRuleApiResponse> Rules { get; init; }
|
||||
public required bool Success { get; init; }
|
||||
public required IReadOnlyList<string> Warnings { get; init; }
|
||||
public IReadOnlyList<string>? Errors { get; init; }
|
||||
public required string IntentId { get; init; }
|
||||
public required string GeneratedAt { get; init; }
|
||||
|
||||
public static RuleGenerationApiResponse FromDomain(RuleGenerationResult result) => new()
|
||||
{
|
||||
Rules = result.Rules.Select(r => new LatticeRuleApiResponse
|
||||
{
|
||||
RuleId = r.RuleId,
|
||||
Name = r.Name,
|
||||
Description = r.Description,
|
||||
LatticeExpression = r.LatticeExpression,
|
||||
Disposition = r.Disposition,
|
||||
Priority = r.Priority,
|
||||
Scope = r.Scope,
|
||||
Enabled = r.Enabled
|
||||
}).ToList(),
|
||||
Success = result.Success,
|
||||
Warnings = result.Warnings,
|
||||
Errors = result.Errors,
|
||||
IntentId = result.IntentId,
|
||||
GeneratedAt = result.GeneratedAt
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record LatticeRuleApiResponse
|
||||
{
|
||||
public required string RuleId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string Description { get; init; }
|
||||
public required string LatticeExpression { get; init; }
|
||||
public required string Disposition { get; init; }
|
||||
public required int Priority { get; init; }
|
||||
public required string Scope { get; init; }
|
||||
public bool Enabled { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// API request for validating rules.
|
||||
/// Task: POLICY-18
|
||||
/// </summary>
|
||||
public sealed record PolicyValidateApiRequest
|
||||
{
|
||||
[Required]
|
||||
public required IReadOnlyList<string> RuleIds { get; init; }
|
||||
|
||||
public IReadOnlyList<string>? ExistingRuleIds { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// API response for validation result.
|
||||
/// </summary>
|
||||
public sealed record ValidationApiResponse
|
||||
{
|
||||
public required bool Valid { get; init; }
|
||||
public required IReadOnlyList<RuleConflictApiResponse> Conflicts { get; init; }
|
||||
public required IReadOnlyList<string> UnreachableConditions { get; init; }
|
||||
public required IReadOnlyList<string> PotentialLoops { get; init; }
|
||||
public required double Coverage { get; init; }
|
||||
public required IReadOnlyList<PolicyTestCaseApiResponse> TestCases { get; init; }
|
||||
public TestRunApiResponse? TestResults { get; init; }
|
||||
}
|
||||
|
||||
public sealed record RuleConflictApiResponse
|
||||
{
|
||||
public required string RuleId1 { get; init; }
|
||||
public required string RuleId2 { get; init; }
|
||||
public required string Description { get; init; }
|
||||
public required string SuggestedResolution { get; init; }
|
||||
public required string Severity { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PolicyTestCaseApiResponse
|
||||
{
|
||||
public required string TestCaseId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required string Type { get; init; }
|
||||
public required IReadOnlyDictionary<string, object> Input { get; init; }
|
||||
public required string ExpectedDisposition { get; init; }
|
||||
public required string Description { get; init; }
|
||||
}
|
||||
|
||||
public sealed record TestRunApiResponse
|
||||
{
|
||||
public required int Total { get; init; }
|
||||
public required int Passed { get; init; }
|
||||
public required int Failed { get; init; }
|
||||
public required bool Success { get; init; }
|
||||
public required string RunAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// API request for compiling policy bundle.
|
||||
/// Task: POLICY-19
|
||||
/// </summary>
|
||||
public sealed record PolicyCompileApiRequest
|
||||
{
|
||||
[Required]
|
||||
public required IReadOnlyList<string> RuleIds { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string BundleName { get; init; }
|
||||
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// API response for compiled policy bundle.
|
||||
/// </summary>
|
||||
public sealed record PolicyBundleApiResponse
|
||||
{
|
||||
public required string BundleId { get; init; }
|
||||
public required string BundleName { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public required int RuleCount { get; init; }
|
||||
public required string CompiledAt { get; init; }
|
||||
public required string ContentHash { get; init; }
|
||||
public string? SignatureId { get; init; }
|
||||
}
|
||||
@@ -17,6 +17,7 @@ using StellaOps.AdvisoryAI.Metrics;
|
||||
using StellaOps.AdvisoryAI.Outputs;
|
||||
using StellaOps.AdvisoryAI.Orchestration;
|
||||
using StellaOps.AdvisoryAI.Queue;
|
||||
using StellaOps.AdvisoryAI.PolicyStudio;
|
||||
using StellaOps.AdvisoryAI.Remediation;
|
||||
using StellaOps.AdvisoryAI.WebService.Contracts;
|
||||
using StellaOps.Router.AspNet;
|
||||
@@ -107,6 +108,19 @@ app.MapPost("/v1/advisory-ai/remediation/apply", HandleApplyRemediation)
|
||||
app.MapGet("/v1/advisory-ai/remediation/status/{prId}", HandleRemediationStatus)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
// Policy Studio endpoints (SPRINT_20251226_017_AI_policy_copilot)
|
||||
app.MapPost("/v1/advisory-ai/policy/studio/parse", HandlePolicyParse)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
app.MapPost("/v1/advisory-ai/policy/studio/generate", HandlePolicyGenerate)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
app.MapPost("/v1/advisory-ai/policy/studio/validate", HandlePolicyValidate)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
app.MapPost("/v1/advisory-ai/policy/studio/compile", HandlePolicyCompile)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
// Refresh Router endpoint cache
|
||||
app.TryRefreshStellaRouterEndpoints(routerOptions);
|
||||
|
||||
@@ -476,6 +490,165 @@ static async Task<IResult> HandleRemediationStatus(
|
||||
}
|
||||
}
|
||||
|
||||
static bool EnsurePolicyAuthorized(HttpContext context)
|
||||
{
|
||||
if (!context.Request.Headers.TryGetValue("X-StellaOps-Scopes", out var scopes))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var allowed = scopes
|
||||
.SelectMany(value => value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return allowed.Contains("advisory:run") || allowed.Contains("policy:write");
|
||||
}
|
||||
|
||||
// POLICY-16: POST /v1/advisory-ai/policy/studio/parse
|
||||
static async Task<IResult> HandlePolicyParse(
|
||||
HttpContext httpContext,
|
||||
PolicyParseApiRequest request,
|
||||
IPolicyIntentParser intentParser,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.policy_parse", ActivityKind.Server);
|
||||
activity?.SetTag("advisory.input_length", request.Input.Length);
|
||||
|
||||
if (!EnsurePolicyAuthorized(httpContext))
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await intentParser.ParseAsync(request.Input, request.ToContext(), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
activity?.SetTag("advisory.intent_id", result.Intent.IntentId);
|
||||
activity?.SetTag("advisory.confidence", result.Intent.Confidence);
|
||||
|
||||
return Results.Ok(PolicyParseApiResponse.FromDomain(result));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
// POLICY-17: POST /v1/advisory-ai/policy/studio/generate
|
||||
static async Task<IResult> HandlePolicyGenerate(
|
||||
HttpContext httpContext,
|
||||
PolicyGenerateApiRequest request,
|
||||
IPolicyIntentStore intentStore,
|
||||
IPolicyRuleGenerator ruleGenerator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.policy_generate", ActivityKind.Server);
|
||||
activity?.SetTag("advisory.intent_id", request.IntentId);
|
||||
|
||||
if (!EnsurePolicyAuthorized(httpContext))
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
var intent = await intentStore.GetAsync(request.IntentId, cancellationToken).ConfigureAwait(false);
|
||||
if (intent is null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Intent {request.IntentId} not found" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await ruleGenerator.GenerateAsync(intent, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
activity?.SetTag("advisory.rule_count", result.Rules.Count);
|
||||
|
||||
return Results.Ok(RuleGenerationApiResponse.FromDomain(result));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
// POLICY-18: POST /v1/advisory-ai/policy/studio/validate
|
||||
static async Task<IResult> HandlePolicyValidate(
|
||||
HttpContext httpContext,
|
||||
PolicyValidateApiRequest request,
|
||||
IPolicyRuleGenerator ruleGenerator,
|
||||
ITestCaseSynthesizer testSynthesizer,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.policy_validate", ActivityKind.Server);
|
||||
activity?.SetTag("advisory.rule_count", request.RuleIds.Count);
|
||||
|
||||
if (!EnsurePolicyAuthorized(httpContext))
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
// In a real implementation, we would fetch rules from storage
|
||||
// For now, return a mock validation result
|
||||
var validation = new RuleValidationResult
|
||||
{
|
||||
Valid = true,
|
||||
Conflicts = Array.Empty<RuleConflict>(),
|
||||
UnreachableConditions = Array.Empty<string>(),
|
||||
PotentialLoops = Array.Empty<string>(),
|
||||
Coverage = 0.85
|
||||
};
|
||||
|
||||
return Results.Ok(new ValidationApiResponse
|
||||
{
|
||||
Valid = validation.Valid,
|
||||
Conflicts = validation.Conflicts.Select(c => new RuleConflictApiResponse
|
||||
{
|
||||
RuleId1 = c.RuleId1,
|
||||
RuleId2 = c.RuleId2,
|
||||
Description = c.Description,
|
||||
SuggestedResolution = c.SuggestedResolution,
|
||||
Severity = c.Severity
|
||||
}).ToList(),
|
||||
UnreachableConditions = validation.UnreachableConditions.ToList(),
|
||||
PotentialLoops = validation.PotentialLoops.ToList(),
|
||||
Coverage = validation.Coverage,
|
||||
TestCases = Array.Empty<PolicyTestCaseApiResponse>(),
|
||||
TestResults = null
|
||||
});
|
||||
}
|
||||
|
||||
// POLICY-19: POST /v1/advisory-ai/policy/studio/compile
|
||||
static Task<IResult> HandlePolicyCompile(
|
||||
HttpContext httpContext,
|
||||
PolicyCompileApiRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.policy_compile", ActivityKind.Server);
|
||||
activity?.SetTag("advisory.bundle_name", request.BundleName);
|
||||
activity?.SetTag("advisory.rule_count", request.RuleIds.Count);
|
||||
|
||||
if (!EnsurePolicyAuthorized(httpContext))
|
||||
{
|
||||
return Task.FromResult(Results.StatusCode(StatusCodes.Status403Forbidden));
|
||||
}
|
||||
|
||||
// In a real implementation, this would compile rules into a PolicyBundle
|
||||
var bundleId = $"bundle:{Guid.NewGuid():N}";
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var response = new PolicyBundleApiResponse
|
||||
{
|
||||
BundleId = bundleId,
|
||||
BundleName = request.BundleName,
|
||||
Version = "1.0.0",
|
||||
RuleCount = request.RuleIds.Count,
|
||||
CompiledAt = now.ToString("O"),
|
||||
ContentHash = $"sha256:{Guid.NewGuid():N}",
|
||||
SignatureId = null // Would be signed in production
|
||||
};
|
||||
|
||||
return Task.FromResult(Results.Ok(response));
|
||||
}
|
||||
|
||||
internal sealed record PipelinePlanRequest(
|
||||
AdvisoryTaskType? TaskType,
|
||||
string AdvisoryKey,
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
namespace StellaOps.AdvisoryAI.Inference;
|
||||
|
||||
/// <summary>
|
||||
/// Result of local LLM inference.
|
||||
/// </summary>
|
||||
public sealed record LocalInferenceResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Generated text content.
|
||||
/// </summary>
|
||||
public required string Content { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of tokens generated.
|
||||
/// </summary>
|
||||
public required int TokensGenerated { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total inference time in milliseconds.
|
||||
/// </summary>
|
||||
public required long InferenceTimeMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Time to first token in milliseconds.
|
||||
/// </summary>
|
||||
public required long TimeToFirstTokenMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tokens per second throughput.
|
||||
/// </summary>
|
||||
public double TokensPerSecond => InferenceTimeMs > 0
|
||||
? TokensGenerated * 1000.0 / InferenceTimeMs
|
||||
: 0;
|
||||
|
||||
/// <summary>
|
||||
/// Model ID used for inference.
|
||||
/// </summary>
|
||||
public required string ModelId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether inference was deterministic.
|
||||
/// </summary>
|
||||
public required bool Deterministic { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Seed used for generation.
|
||||
/// </summary>
|
||||
public required int Seed { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Model status information.
|
||||
/// </summary>
|
||||
public sealed record LocalModelStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether model is loaded.
|
||||
/// </summary>
|
||||
public required bool Loaded { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Model path.
|
||||
/// </summary>
|
||||
public required string ModelPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Verified digest matches expected.
|
||||
/// </summary>
|
||||
public required bool DigestVerified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Memory usage in bytes.
|
||||
/// </summary>
|
||||
public required long MemoryBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Device being used.
|
||||
/// </summary>
|
||||
public required string Device { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Context size in tokens.
|
||||
/// </summary>
|
||||
public required int ContextSize { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for local LLM runtime.
|
||||
/// Sprint: SPRINT_20251226_019_AI_offline_inference
|
||||
/// Task: OFFLINE-04
|
||||
/// </summary>
|
||||
public interface ILocalLlmRuntime : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Runtime type identifier.
|
||||
/// </summary>
|
||||
string RuntimeType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Load a model with the given configuration.
|
||||
/// </summary>
|
||||
/// <param name="config">Model configuration.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task LoadModelAsync(LocalLlmConfig config, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Unload the current model.
|
||||
/// </summary>
|
||||
Task UnloadModelAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get current model status.
|
||||
/// </summary>
|
||||
Task<LocalModelStatus> GetStatusAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Generate text from a prompt.
|
||||
/// </summary>
|
||||
/// <param name="prompt">Input prompt.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task<LocalInferenceResult> GenerateAsync(string prompt, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Generate text with streaming output.
|
||||
/// </summary>
|
||||
/// <param name="prompt">Input prompt.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
IAsyncEnumerable<string> GenerateStreamAsync(string prompt, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verify model digest matches expected.
|
||||
/// </summary>
|
||||
/// <param name="expectedDigest">Expected SHA-256 digest.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task<bool> VerifyDigestAsync(string expectedDigest, CancellationToken cancellationToken = default);
|
||||
}
|
||||
182
src/AdvisoryAI/StellaOps.AdvisoryAI/Inference/LlamaCppRuntime.cs
Normal file
182
src/AdvisoryAI/StellaOps.AdvisoryAI/Inference/LlamaCppRuntime.cs
Normal file
@@ -0,0 +1,182 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Inference;
|
||||
|
||||
/// <summary>
|
||||
/// Local LLM runtime using llama.cpp bindings.
|
||||
/// Sprint: SPRINT_20251226_019_AI_offline_inference
|
||||
/// Task: OFFLINE-05
|
||||
/// </summary>
|
||||
public sealed class LlamaCppRuntime : ILocalLlmRuntime
|
||||
{
|
||||
private LocalLlmConfig? _config;
|
||||
private bool _modelLoaded;
|
||||
private string? _computedDigest;
|
||||
|
||||
public string RuntimeType => "llama.cpp";
|
||||
|
||||
public Task LoadModelAsync(LocalLlmConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_config = config;
|
||||
|
||||
// Verify model file exists
|
||||
if (!File.Exists(config.ModelPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Model file not found: {config.ModelPath}");
|
||||
}
|
||||
|
||||
// In a real implementation, this would:
|
||||
// 1. Load the GGUF/GGML model file
|
||||
// 2. Initialize llama.cpp context with config settings
|
||||
// 3. Verify digest if required
|
||||
|
||||
_modelLoaded = true;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UnloadModelAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
_modelLoaded = false;
|
||||
_config = null;
|
||||
_computedDigest = null;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<LocalModelStatus> GetStatusAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new LocalModelStatus
|
||||
{
|
||||
Loaded = _modelLoaded,
|
||||
ModelPath = _config?.ModelPath ?? string.Empty,
|
||||
DigestVerified = _computedDigest == _config?.WeightsDigest,
|
||||
MemoryBytes = _modelLoaded ? EstimateMemoryUsage() : 0,
|
||||
Device = _config?.Device.ToString() ?? "Unknown",
|
||||
ContextSize = _config?.ContextLength ?? 0
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<LocalInferenceResult> GenerateAsync(string prompt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_modelLoaded || _config is null)
|
||||
{
|
||||
throw new InvalidOperationException("Model not loaded");
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var firstTokenTime = 0L;
|
||||
|
||||
// In a real implementation, this would call llama.cpp inference
|
||||
// For now, return a placeholder response
|
||||
|
||||
await Task.Delay(100, cancellationToken); // Simulate first token
|
||||
firstTokenTime = stopwatch.ElapsedMilliseconds;
|
||||
|
||||
await Task.Delay(400, cancellationToken); // Simulate generation
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
var generatedContent = GeneratePlaceholderResponse(prompt);
|
||||
var tokensGenerated = generatedContent.Split(' ').Length;
|
||||
|
||||
return new LocalInferenceResult
|
||||
{
|
||||
Content = generatedContent,
|
||||
TokensGenerated = tokensGenerated,
|
||||
InferenceTimeMs = stopwatch.ElapsedMilliseconds,
|
||||
TimeToFirstTokenMs = firstTokenTime,
|
||||
ModelId = $"local:{Path.GetFileName(_config.ModelPath)}",
|
||||
Deterministic = _config.Temperature == 0,
|
||||
Seed = _config.Seed
|
||||
};
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<string> GenerateStreamAsync(
|
||||
string prompt,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_modelLoaded || _config is null)
|
||||
{
|
||||
throw new InvalidOperationException("Model not loaded");
|
||||
}
|
||||
|
||||
// Simulate streaming output
|
||||
var words = GeneratePlaceholderResponse(prompt).Split(' ');
|
||||
foreach (var word in words)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
await Task.Delay(50, cancellationToken);
|
||||
yield return word + " ";
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> VerifyDigestAsync(string expectedDigest, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_config is null || !File.Exists(_config.ModelPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
using var sha256 = SHA256.Create();
|
||||
await using var stream = File.OpenRead(_config.ModelPath);
|
||||
var hash = await sha256.ComputeHashAsync(stream, cancellationToken);
|
||||
_computedDigest = Convert.ToHexStringLower(hash);
|
||||
|
||||
return string.Equals(_computedDigest, expectedDigest, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private long EstimateMemoryUsage()
|
||||
{
|
||||
if (_config is null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Rough estimate based on quantization
|
||||
var baseSize = new FileInfo(_config.ModelPath).Length;
|
||||
var contextOverhead = _config.ContextLength * 4096L; // Rough KV cache estimate
|
||||
|
||||
return baseSize + contextOverhead;
|
||||
}
|
||||
|
||||
private static string GeneratePlaceholderResponse(string prompt)
|
||||
{
|
||||
// In a real implementation, this would be actual LLM output
|
||||
if (prompt.Contains("explain", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "This vulnerability affects the component by allowing unauthorized access. " +
|
||||
"The vulnerable code path is reachable from the application entry point. " +
|
||||
"Evidence: [EVIDENCE:sbom-001] Component is present in SBOM. " +
|
||||
"[EVIDENCE:reach-001] Call graph shows reachability.";
|
||||
}
|
||||
|
||||
if (prompt.Contains("remediat", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "Recommended remediation: Upgrade the affected component to the patched version. " +
|
||||
"- Update package.json: dependency@1.0.0 -> dependency@1.0.1 " +
|
||||
"- Run npm install to update lockfile " +
|
||||
"- Verify with npm audit";
|
||||
}
|
||||
|
||||
if (prompt.Contains("policy", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "Parsed policy intent: Override rule for critical severity. " +
|
||||
"Conditions: severity = critical, scope = production. " +
|
||||
"Actions: set_verdict = block.";
|
||||
}
|
||||
|
||||
return "Analysis complete. The finding has been evaluated based on available evidence.";
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_modelLoaded = false;
|
||||
_config = null;
|
||||
_computedDigest = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
namespace StellaOps.AdvisoryAI.Inference;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for local/offline inference.
|
||||
/// Sprint: SPRINT_20251226_019_AI_offline_inference
|
||||
/// Task: OFFLINE-24
|
||||
/// </summary>
|
||||
public sealed class LocalInferenceOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "AdvisoryAI:Inference:Offline";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable local inference.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the model bundle directory.
|
||||
/// </summary>
|
||||
public string? BundlePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Required SHA-256 digest of the model weights.
|
||||
/// </summary>
|
||||
public string? RequiredDigest { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Model to load (filename in bundle).
|
||||
/// </summary>
|
||||
public string? ModelName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Quantization to use.
|
||||
/// </summary>
|
||||
public string Quantization { get; set; } = "Q4_K_M";
|
||||
|
||||
/// <summary>
|
||||
/// Runtime to use (llama.cpp, onnx).
|
||||
/// </summary>
|
||||
public string Runtime { get; set; } = "llama.cpp";
|
||||
|
||||
/// <summary>
|
||||
/// Device for inference.
|
||||
/// </summary>
|
||||
public string Device { get; set; } = "auto";
|
||||
|
||||
/// <summary>
|
||||
/// Number of GPU layers to offload.
|
||||
/// </summary>
|
||||
public int GpuLayers { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Number of threads for CPU inference.
|
||||
/// </summary>
|
||||
public int Threads { get; set; } = 0; // 0 = auto
|
||||
|
||||
/// <summary>
|
||||
/// Context length (max tokens).
|
||||
/// </summary>
|
||||
public int ContextLength { get; set; } = 4096;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum tokens to generate.
|
||||
/// </summary>
|
||||
public int MaxTokens { get; set; } = 2048;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable inference caching.
|
||||
/// </summary>
|
||||
public bool EnableCache { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Cache directory path.
|
||||
/// </summary>
|
||||
public string? CachePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to verify digest at load time.
|
||||
/// </summary>
|
||||
public bool VerifyDigestOnLoad { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enforce airgap mode (disable remote fallback).
|
||||
/// </summary>
|
||||
public bool AirgapMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Crypto scheme for signature verification (eidas, fips, gost, sm).
|
||||
/// </summary>
|
||||
public string? CryptoScheme { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating local LLM runtimes.
|
||||
/// Task: OFFLINE-22
|
||||
/// </summary>
|
||||
public interface ILocalLlmRuntimeFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Create a runtime based on configuration.
|
||||
/// </summary>
|
||||
ILocalLlmRuntime Create(LocalInferenceOptions options);
|
||||
|
||||
/// <summary>
|
||||
/// Get supported runtime types.
|
||||
/// </summary>
|
||||
IReadOnlyList<string> SupportedRuntimes { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default runtime factory implementation.
|
||||
/// </summary>
|
||||
public sealed class LocalLlmRuntimeFactory : ILocalLlmRuntimeFactory
|
||||
{
|
||||
public IReadOnlyList<string> SupportedRuntimes => new[] { "llama.cpp", "onnx" };
|
||||
|
||||
public ILocalLlmRuntime Create(LocalInferenceOptions options)
|
||||
{
|
||||
return options.Runtime.ToLowerInvariant() switch
|
||||
{
|
||||
"llama.cpp" or "llama" or "gguf" => new LlamaCppRuntime(),
|
||||
"onnx" => new OnnxRuntime(),
|
||||
_ => throw new NotSupportedException($"Runtime '{options.Runtime}' not supported")
|
||||
};
|
||||
}
|
||||
}
|
||||
161
src/AdvisoryAI/StellaOps.AdvisoryAI/Inference/LocalLlmConfig.cs
Normal file
161
src/AdvisoryAI/StellaOps.AdvisoryAI/Inference/LocalLlmConfig.cs
Normal file
@@ -0,0 +1,161 @@
|
||||
namespace StellaOps.AdvisoryAI.Inference;
|
||||
|
||||
/// <summary>
|
||||
/// Quantization levels for local LLM models.
|
||||
/// </summary>
|
||||
public enum ModelQuantization
|
||||
{
|
||||
/// <summary>
|
||||
/// Full precision (FP32).
|
||||
/// </summary>
|
||||
FP32,
|
||||
|
||||
/// <summary>
|
||||
/// Half precision (FP16).
|
||||
/// </summary>
|
||||
FP16,
|
||||
|
||||
/// <summary>
|
||||
/// Brain floating point (BF16).
|
||||
/// </summary>
|
||||
BF16,
|
||||
|
||||
/// <summary>
|
||||
/// 8-bit integer quantization.
|
||||
/// </summary>
|
||||
INT8,
|
||||
|
||||
/// <summary>
|
||||
/// 4-bit GGML K-quant (medium).
|
||||
/// </summary>
|
||||
Q4_K_M,
|
||||
|
||||
/// <summary>
|
||||
/// 4-bit GGML K-quant (small).
|
||||
/// </summary>
|
||||
Q4_K_S,
|
||||
|
||||
/// <summary>
|
||||
/// 5-bit GGML K-quant (medium).
|
||||
/// </summary>
|
||||
Q5_K_M,
|
||||
|
||||
/// <summary>
|
||||
/// 8-bit GGML quantization.
|
||||
/// </summary>
|
||||
Q8_0
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Device type for local inference.
|
||||
/// </summary>
|
||||
public enum InferenceDevice
|
||||
{
|
||||
/// <summary>
|
||||
/// CPU inference.
|
||||
/// </summary>
|
||||
CPU,
|
||||
|
||||
/// <summary>
|
||||
/// CUDA GPU inference.
|
||||
/// </summary>
|
||||
CUDA,
|
||||
|
||||
/// <summary>
|
||||
/// AMD ROCm GPU inference.
|
||||
/// </summary>
|
||||
ROCm,
|
||||
|
||||
/// <summary>
|
||||
/// Apple Metal GPU inference.
|
||||
/// </summary>
|
||||
Metal,
|
||||
|
||||
/// <summary>
|
||||
/// Intel NPU inference.
|
||||
/// </summary>
|
||||
NPU,
|
||||
|
||||
/// <summary>
|
||||
/// Vulkan compute.
|
||||
/// </summary>
|
||||
Vulkan,
|
||||
|
||||
/// <summary>
|
||||
/// Auto-detect best available.
|
||||
/// </summary>
|
||||
Auto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for local LLM runtime.
|
||||
/// Sprint: SPRINT_20251226_019_AI_offline_inference
|
||||
/// Task: OFFLINE-03
|
||||
/// </summary>
|
||||
public sealed record LocalLlmConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Path to the model weights file.
|
||||
/// </summary>
|
||||
public required string ModelPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected SHA-256 digest of the weights file.
|
||||
/// </summary>
|
||||
public required string WeightsDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Model quantization level.
|
||||
/// </summary>
|
||||
public ModelQuantization Quantization { get; init; } = ModelQuantization.Q4_K_M;
|
||||
|
||||
/// <summary>
|
||||
/// Context length (max tokens).
|
||||
/// </summary>
|
||||
public int ContextLength { get; init; } = 4096;
|
||||
|
||||
/// <summary>
|
||||
/// Device for inference.
|
||||
/// </summary>
|
||||
public InferenceDevice Device { get; init; } = InferenceDevice.Auto;
|
||||
|
||||
/// <summary>
|
||||
/// Number of GPU layers to offload (0 = all CPU).
|
||||
/// </summary>
|
||||
public int GpuLayers { get; init; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Number of threads for CPU inference.
|
||||
/// </summary>
|
||||
public int Threads { get; init; } = Environment.ProcessorCount / 2;
|
||||
|
||||
/// <summary>
|
||||
/// Batch size for parallel decoding.
|
||||
/// </summary>
|
||||
public int BatchSize { get; init; } = 512;
|
||||
|
||||
/// <summary>
|
||||
/// Temperature for sampling (0 = deterministic).
|
||||
/// </summary>
|
||||
public double Temperature { get; init; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Random seed for deterministic output.
|
||||
/// </summary>
|
||||
public int Seed { get; init; } = 42;
|
||||
|
||||
/// <summary>
|
||||
/// Enable flash attention if available.
|
||||
/// </summary>
|
||||
public bool FlashAttention { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum tokens to generate.
|
||||
/// </summary>
|
||||
public int MaxTokens { get; init; } = 2048;
|
||||
|
||||
/// <summary>
|
||||
/// Enable streaming output.
|
||||
/// </summary>
|
||||
public bool Streaming { get; init; } = false;
|
||||
}
|
||||
280
src/AdvisoryAI/StellaOps.AdvisoryAI/Inference/ModelBundle.cs
Normal file
280
src/AdvisoryAI/StellaOps.AdvisoryAI/Inference/ModelBundle.cs
Normal file
@@ -0,0 +1,280 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Inference;
|
||||
|
||||
/// <summary>
|
||||
/// Model bundle manifest.
|
||||
/// Sprint: SPRINT_20251226_019_AI_offline_inference
|
||||
/// Task: OFFLINE-11, OFFLINE-12
|
||||
/// </summary>
|
||||
public sealed record ModelBundleManifest
|
||||
{
|
||||
/// <summary>
|
||||
/// Bundle format version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; init; } = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Model name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Model description.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Model license.
|
||||
/// </summary>
|
||||
[JsonPropertyName("license")]
|
||||
public required string License { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Model size category.
|
||||
/// </summary>
|
||||
[JsonPropertyName("size_category")]
|
||||
public required string SizeCategory { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Supported quantizations.
|
||||
/// </summary>
|
||||
[JsonPropertyName("quantizations")]
|
||||
public required IReadOnlyList<string> Quantizations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Files in the bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("files")]
|
||||
public required IReadOnlyList<BundleFile> Files { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle creation timestamp.
|
||||
/// </summary>
|
||||
[JsonPropertyName("created_at")]
|
||||
public required string CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signature ID (if signed).
|
||||
/// </summary>
|
||||
[JsonPropertyName("signature_id")]
|
||||
public string? SignatureId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Crypto scheme used for signing.
|
||||
/// </summary>
|
||||
[JsonPropertyName("crypto_scheme")]
|
||||
public string? CryptoScheme { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A file in the model bundle.
|
||||
/// </summary>
|
||||
public sealed record BundleFile
|
||||
{
|
||||
/// <summary>
|
||||
/// Relative path in bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("path")]
|
||||
public required string Path { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 digest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("digest")]
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// File size in bytes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("size")]
|
||||
public required long Size { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// File type.
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing model bundles.
|
||||
/// Task: OFFLINE-11 to OFFLINE-14
|
||||
/// </summary>
|
||||
public interface IModelBundleManager
|
||||
{
|
||||
/// <summary>
|
||||
/// List available bundles.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ModelBundleManifest>> ListBundlesAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get bundle manifest by name.
|
||||
/// </summary>
|
||||
Task<ModelBundleManifest?> GetManifestAsync(string bundleName, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Download a bundle.
|
||||
/// </summary>
|
||||
Task<string> DownloadBundleAsync(string bundleName, string targetPath, IProgress<double>? progress = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verify bundle integrity.
|
||||
/// </summary>
|
||||
Task<BundleVerificationResult> VerifyBundleAsync(string bundlePath, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Extract bundle to target directory.
|
||||
/// </summary>
|
||||
Task<string> ExtractBundleAsync(string bundlePath, string targetDir, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of bundle verification.
|
||||
/// </summary>
|
||||
public sealed record BundleVerificationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether verification passed.
|
||||
/// </summary>
|
||||
public required bool Valid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Files that failed verification.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> FailedFiles { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signature verification result.
|
||||
/// </summary>
|
||||
public required bool SignatureValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if invalid.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of model bundle manager.
|
||||
/// </summary>
|
||||
public sealed class FileSystemModelBundleManager : IModelBundleManager
|
||||
{
|
||||
private readonly string _bundleStorePath;
|
||||
|
||||
public FileSystemModelBundleManager(string bundleStorePath)
|
||||
{
|
||||
_bundleStorePath = bundleStorePath;
|
||||
Directory.CreateDirectory(_bundleStorePath);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ModelBundleManifest>> ListBundlesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var bundles = new List<ModelBundleManifest>();
|
||||
|
||||
foreach (var dir in Directory.GetDirectories(_bundleStorePath))
|
||||
{
|
||||
var manifestPath = Path.Combine(dir, "manifest.json");
|
||||
if (File.Exists(manifestPath))
|
||||
{
|
||||
var json = File.ReadAllText(manifestPath);
|
||||
var manifest = JsonSerializer.Deserialize<ModelBundleManifest>(json);
|
||||
if (manifest != null)
|
||||
{
|
||||
bundles.Add(manifest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ModelBundleManifest>>(bundles);
|
||||
}
|
||||
|
||||
public Task<ModelBundleManifest?> GetManifestAsync(string bundleName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var manifestPath = Path.Combine(_bundleStorePath, bundleName, "manifest.json");
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
return Task.FromResult<ModelBundleManifest?>(null);
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(manifestPath);
|
||||
var manifest = JsonSerializer.Deserialize<ModelBundleManifest>(json);
|
||||
return Task.FromResult(manifest);
|
||||
}
|
||||
|
||||
public Task<string> DownloadBundleAsync(string bundleName, string targetPath, IProgress<double>? progress = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// In a real implementation, this would download from a registry
|
||||
throw new NotImplementedException("Bundle download not implemented - use offline transfer");
|
||||
}
|
||||
|
||||
public async Task<BundleVerificationResult> VerifyBundleAsync(string bundlePath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var manifestPath = Path.Combine(bundlePath, "manifest.json");
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
return new BundleVerificationResult
|
||||
{
|
||||
Valid = false,
|
||||
FailedFiles = Array.Empty<string>(),
|
||||
SignatureValid = false,
|
||||
ErrorMessage = "manifest.json not found"
|
||||
};
|
||||
}
|
||||
|
||||
var json = await File.ReadAllTextAsync(manifestPath, cancellationToken);
|
||||
var manifest = JsonSerializer.Deserialize<ModelBundleManifest>(json);
|
||||
if (manifest is null)
|
||||
{
|
||||
return new BundleVerificationResult
|
||||
{
|
||||
Valid = false,
|
||||
FailedFiles = Array.Empty<string>(),
|
||||
SignatureValid = false,
|
||||
ErrorMessage = "Failed to parse manifest"
|
||||
};
|
||||
}
|
||||
|
||||
var failedFiles = new List<string>();
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
|
||||
foreach (var file in manifest.Files)
|
||||
{
|
||||
var filePath = Path.Combine(bundlePath, file.Path);
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
failedFiles.Add($"{file.Path}: missing");
|
||||
continue;
|
||||
}
|
||||
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
var hash = await sha256.ComputeHashAsync(stream, cancellationToken);
|
||||
var digest = Convert.ToHexStringLower(hash);
|
||||
|
||||
if (!string.Equals(digest, file.Digest, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
failedFiles.Add($"{file.Path}: digest mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
return new BundleVerificationResult
|
||||
{
|
||||
Valid = failedFiles.Count == 0,
|
||||
FailedFiles = failedFiles,
|
||||
SignatureValid = manifest.SignatureId != null, // Would verify signature in production
|
||||
ErrorMessage = failedFiles.Count > 0 ? $"{failedFiles.Count} files failed verification" : null
|
||||
};
|
||||
}
|
||||
|
||||
public Task<string> ExtractBundleAsync(string bundlePath, string targetDir, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Bundles are expected to already be extracted
|
||||
// This would handle .tar.gz extraction in production
|
||||
Directory.CreateDirectory(targetDir);
|
||||
return Task.FromResult(targetDir);
|
||||
}
|
||||
}
|
||||
138
src/AdvisoryAI/StellaOps.AdvisoryAI/Inference/OnnxRuntime.cs
Normal file
138
src/AdvisoryAI/StellaOps.AdvisoryAI/Inference/OnnxRuntime.cs
Normal file
@@ -0,0 +1,138 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Inference;
|
||||
|
||||
/// <summary>
|
||||
/// Local LLM runtime using ONNX Runtime.
|
||||
/// Sprint: SPRINT_20251226_019_AI_offline_inference
|
||||
/// Task: OFFLINE-06
|
||||
/// </summary>
|
||||
public sealed class OnnxRuntime : ILocalLlmRuntime
|
||||
{
|
||||
private LocalLlmConfig? _config;
|
||||
private bool _modelLoaded;
|
||||
private string? _computedDigest;
|
||||
|
||||
public string RuntimeType => "onnx";
|
||||
|
||||
public Task LoadModelAsync(LocalLlmConfig config, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_config = config;
|
||||
|
||||
if (!File.Exists(config.ModelPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Model file not found: {config.ModelPath}");
|
||||
}
|
||||
|
||||
// In a real implementation, this would:
|
||||
// 1. Load the ONNX model file
|
||||
// 2. Initialize ONNX Runtime session with execution providers
|
||||
// 3. Configure GPU/CPU execution based on device setting
|
||||
|
||||
_modelLoaded = true;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UnloadModelAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
_modelLoaded = false;
|
||||
_config = null;
|
||||
_computedDigest = null;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<LocalModelStatus> GetStatusAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new LocalModelStatus
|
||||
{
|
||||
Loaded = _modelLoaded,
|
||||
ModelPath = _config?.ModelPath ?? string.Empty,
|
||||
DigestVerified = _computedDigest == _config?.WeightsDigest,
|
||||
MemoryBytes = _modelLoaded ? EstimateMemoryUsage() : 0,
|
||||
Device = _config?.Device.ToString() ?? "Unknown",
|
||||
ContextSize = _config?.ContextLength ?? 0
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<LocalInferenceResult> GenerateAsync(string prompt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_modelLoaded || _config is null)
|
||||
{
|
||||
throw new InvalidOperationException("Model not loaded");
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
// Simulate ONNX inference
|
||||
await Task.Delay(150, cancellationToken);
|
||||
var firstTokenTime = stopwatch.ElapsedMilliseconds;
|
||||
|
||||
await Task.Delay(350, cancellationToken);
|
||||
stopwatch.Stop();
|
||||
|
||||
var generatedContent = "[ONNX] Analysis based on provided evidence.";
|
||||
var tokensGenerated = generatedContent.Split(' ').Length;
|
||||
|
||||
return new LocalInferenceResult
|
||||
{
|
||||
Content = generatedContent,
|
||||
TokensGenerated = tokensGenerated,
|
||||
InferenceTimeMs = stopwatch.ElapsedMilliseconds,
|
||||
TimeToFirstTokenMs = firstTokenTime,
|
||||
ModelId = $"onnx:{Path.GetFileName(_config.ModelPath)}",
|
||||
Deterministic = true,
|
||||
Seed = _config.Seed
|
||||
};
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<string> GenerateStreamAsync(
|
||||
string prompt,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_modelLoaded || _config is null)
|
||||
{
|
||||
throw new InvalidOperationException("Model not loaded");
|
||||
}
|
||||
|
||||
var response = "[ONNX] Analysis based on provided evidence.".Split(' ');
|
||||
foreach (var word in response)
|
||||
{
|
||||
await Task.Delay(40, cancellationToken);
|
||||
yield return word + " ";
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> VerifyDigestAsync(string expectedDigest, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_config is null || !File.Exists(_config.ModelPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
using var sha256 = SHA256.Create();
|
||||
await using var stream = File.OpenRead(_config.ModelPath);
|
||||
var hash = await sha256.ComputeHashAsync(stream, cancellationToken);
|
||||
_computedDigest = Convert.ToHexStringLower(hash);
|
||||
|
||||
return string.Equals(_computedDigest, expectedDigest, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private long EstimateMemoryUsage()
|
||||
{
|
||||
if (_config is null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return new FileInfo(_config.ModelPath).Length * 2; // ONNX typically needs 2x model size
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_modelLoaded = false;
|
||||
_config = null;
|
||||
_computedDigest = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
namespace StellaOps.AdvisoryAI.PolicyStudio;
|
||||
|
||||
/// <summary>
|
||||
/// A generated lattice rule.
|
||||
/// </summary>
|
||||
public sealed record LatticeRule
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique rule ID.
|
||||
/// </summary>
|
||||
public required string RuleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule name for display.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule description.
|
||||
/// </summary>
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// K4 lattice expression.
|
||||
/// </summary>
|
||||
public required string LatticeExpression { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule conditions in structured format.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<PolicyCondition> Conditions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Resulting disposition.
|
||||
/// </summary>
|
||||
public required string Disposition { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule priority.
|
||||
/// </summary>
|
||||
public required int Priority { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Scope of the rule.
|
||||
/// </summary>
|
||||
public required string Scope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether rule is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of generating rules from intent.
|
||||
/// </summary>
|
||||
public sealed record RuleGenerationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Generated rules.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<LatticeRule> Rules { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether generation was successful.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validation warnings.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> Warnings { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validation errors (if any).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Errors { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source intent ID.
|
||||
/// </summary>
|
||||
public required string IntentId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Generated timestamp.
|
||||
/// </summary>
|
||||
public required string GeneratedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule validation result.
|
||||
/// </summary>
|
||||
public sealed record RuleValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether rules are valid.
|
||||
/// </summary>
|
||||
public required bool Valid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detected conflicts.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<RuleConflict> Conflicts { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unreachable conditions.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> UnreachableConditions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Potential infinite loops.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> PotentialLoops { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Coverage analysis.
|
||||
/// </summary>
|
||||
public required double Coverage { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A conflict between rules.
|
||||
/// </summary>
|
||||
public sealed record RuleConflict
|
||||
{
|
||||
/// <summary>
|
||||
/// First conflicting rule ID.
|
||||
/// </summary>
|
||||
public required string RuleId1 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Second conflicting rule ID.
|
||||
/// </summary>
|
||||
public required string RuleId2 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Description of the conflict.
|
||||
/// </summary>
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Suggested resolution.
|
||||
/// </summary>
|
||||
public required string SuggestedResolution { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Severity of conflict (warning, error).
|
||||
/// </summary>
|
||||
public required string Severity { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for generating lattice rules from policy intents.
|
||||
/// Sprint: SPRINT_20251226_017_AI_policy_copilot
|
||||
/// Task: POLICY-05
|
||||
/// </summary>
|
||||
public interface IPolicyRuleGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Generate lattice rules from a policy intent.
|
||||
/// </summary>
|
||||
/// <param name="intent">Parsed policy intent.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Generated rules with validation status.</returns>
|
||||
Task<RuleGenerationResult> GenerateAsync(
|
||||
PolicyIntent intent,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validate a set of rules for conflicts and issues.
|
||||
/// </summary>
|
||||
/// <param name="rules">Rules to validate.</param>
|
||||
/// <param name="existingRuleIds">Existing rule IDs to check against.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Validation result.</returns>
|
||||
Task<RuleValidationResult> ValidateAsync(
|
||||
IReadOnlyList<LatticeRule> rules,
|
||||
IReadOnlyList<string>? existingRuleIds = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
namespace StellaOps.AdvisoryAI.PolicyStudio;
|
||||
|
||||
/// <summary>
|
||||
/// Type of synthesized test case.
|
||||
/// </summary>
|
||||
public enum TestCaseType
|
||||
{
|
||||
/// <summary>
|
||||
/// Input that should match the rule (positive case).
|
||||
/// </summary>
|
||||
Positive,
|
||||
|
||||
/// <summary>
|
||||
/// Input that should NOT match the rule (negative case).
|
||||
/// </summary>
|
||||
Negative,
|
||||
|
||||
/// <summary>
|
||||
/// Input at boundary conditions.
|
||||
/// </summary>
|
||||
Boundary,
|
||||
|
||||
/// <summary>
|
||||
/// Input that triggers multiple rules (conflict case).
|
||||
/// </summary>
|
||||
Conflict
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A synthesized test case for policy validation.
|
||||
/// </summary>
|
||||
public sealed record PolicyTestCase
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique test case ID.
|
||||
/// </summary>
|
||||
public required string TestCaseId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Test case name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of test case.
|
||||
/// </summary>
|
||||
public required TestCaseType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Input values for the test.
|
||||
/// </summary>
|
||||
public required IReadOnlyDictionary<string, object> Input { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected disposition/output.
|
||||
/// </summary>
|
||||
public required string ExpectedDisposition { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule IDs being tested.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> TargetRuleIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Description of what the test validates.
|
||||
/// </summary>
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is a generated or manual test.
|
||||
/// </summary>
|
||||
public bool Generated { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of running policy test cases.
|
||||
/// </summary>
|
||||
public sealed record TestRunResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Total tests run.
|
||||
/// </summary>
|
||||
public required int Total { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tests passed.
|
||||
/// </summary>
|
||||
public required int Passed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tests failed.
|
||||
/// </summary>
|
||||
public required int Failed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Individual test results.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<TestCaseResult> Results { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overall success.
|
||||
/// </summary>
|
||||
public bool Success => Failed == 0;
|
||||
|
||||
/// <summary>
|
||||
/// Run timestamp.
|
||||
/// </summary>
|
||||
public required string RunAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a single test case.
|
||||
/// </summary>
|
||||
public sealed record TestCaseResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Test case ID.
|
||||
/// </summary>
|
||||
public required string TestCaseId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether test passed.
|
||||
/// </summary>
|
||||
public required bool Passed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected disposition.
|
||||
/// </summary>
|
||||
public required string Expected { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Actual disposition.
|
||||
/// </summary>
|
||||
public required string Actual { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for synthesizing policy test cases.
|
||||
/// Sprint: SPRINT_20251226_017_AI_policy_copilot
|
||||
/// Task: POLICY-08
|
||||
/// </summary>
|
||||
public interface ITestCaseSynthesizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Generate test cases for a set of rules.
|
||||
/// </summary>
|
||||
/// <param name="rules">Rules to generate tests for.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Generated test cases.</returns>
|
||||
Task<IReadOnlyList<PolicyTestCase>> SynthesizeAsync(
|
||||
IReadOnlyList<LatticeRule> rules,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Run test cases against rules.
|
||||
/// </summary>
|
||||
/// <param name="testCases">Test cases to run.</param>
|
||||
/// <param name="rules">Rules to test.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Test run results.</returns>
|
||||
Task<TestRunResult> RunTestsAsync(
|
||||
IReadOnlyList<PolicyTestCase> testCases,
|
||||
IReadOnlyList<LatticeRule> rules,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.PolicyStudio;
|
||||
|
||||
/// <summary>
|
||||
/// Generator for K4 lattice-compatible rules.
|
||||
/// Sprint: SPRINT_20251226_017_AI_policy_copilot
|
||||
/// Task: POLICY-06
|
||||
/// </summary>
|
||||
public sealed class LatticeRuleGenerator : IPolicyRuleGenerator
|
||||
{
|
||||
public Task<RuleGenerationResult> GenerateAsync(
|
||||
PolicyIntent intent,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var rules = new List<LatticeRule>();
|
||||
var warnings = new List<string>();
|
||||
|
||||
// Generate rule ID
|
||||
var ruleId = $"rule:{ComputeHash(intent.IntentId)[..12]}";
|
||||
|
||||
// Build lattice expression from conditions
|
||||
var latticeExpr = BuildLatticeExpression(intent.Conditions);
|
||||
|
||||
// Determine disposition from actions
|
||||
var disposition = DetermineDisposition(intent.Actions);
|
||||
|
||||
// Create the rule
|
||||
var rule = new LatticeRule
|
||||
{
|
||||
RuleId = ruleId,
|
||||
Name = GenerateRuleName(intent),
|
||||
Description = intent.OriginalInput,
|
||||
LatticeExpression = latticeExpr,
|
||||
Conditions = intent.Conditions,
|
||||
Disposition = disposition,
|
||||
Priority = intent.Priority,
|
||||
Scope = intent.Scope
|
||||
};
|
||||
|
||||
rules.Add(rule);
|
||||
|
||||
// Add warnings for complex conditions
|
||||
if (intent.Conditions.Count > 5)
|
||||
{
|
||||
warnings.Add("Rule has many conditions - consider splitting into multiple rules");
|
||||
}
|
||||
|
||||
if (intent.Confidence < 0.9)
|
||||
{
|
||||
warnings.Add($"Intent confidence is {intent.Confidence:P0} - review generated rule carefully");
|
||||
}
|
||||
|
||||
return Task.FromResult(new RuleGenerationResult
|
||||
{
|
||||
Rules = rules,
|
||||
Success = true,
|
||||
Warnings = warnings,
|
||||
IntentId = intent.IntentId,
|
||||
GeneratedAt = DateTime.UtcNow.ToString("O")
|
||||
});
|
||||
}
|
||||
|
||||
public Task<RuleValidationResult> ValidateAsync(
|
||||
IReadOnlyList<LatticeRule> rules,
|
||||
IReadOnlyList<string>? existingRuleIds = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var conflicts = new List<RuleConflict>();
|
||||
var unreachable = new List<string>();
|
||||
var loops = new List<string>();
|
||||
|
||||
// Check for conflicts between rules
|
||||
for (int i = 0; i < rules.Count; i++)
|
||||
{
|
||||
for (int j = i + 1; j < rules.Count; j++)
|
||||
{
|
||||
var conflict = DetectConflict(rules[i], rules[j]);
|
||||
if (conflict != null)
|
||||
{
|
||||
conflicts.Add(conflict);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for unreachable conditions
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
if (HasUnreachableConditions(rule))
|
||||
{
|
||||
unreachable.Add($"Rule {rule.RuleId} has unreachable conditions");
|
||||
}
|
||||
}
|
||||
|
||||
// Check for potential loops (circular dependencies)
|
||||
// In a real implementation, this would analyze rule dependencies
|
||||
|
||||
var coverage = CalculateCoverage(rules);
|
||||
|
||||
return Task.FromResult(new RuleValidationResult
|
||||
{
|
||||
Valid = conflicts.Count == 0 && unreachable.Count == 0 && loops.Count == 0,
|
||||
Conflicts = conflicts,
|
||||
UnreachableConditions = unreachable,
|
||||
PotentialLoops = loops,
|
||||
Coverage = coverage
|
||||
});
|
||||
}
|
||||
|
||||
private static string BuildLatticeExpression(IReadOnlyList<PolicyCondition> conditions)
|
||||
{
|
||||
if (conditions.Count == 0)
|
||||
{
|
||||
return "TRUE";
|
||||
}
|
||||
|
||||
var parts = new List<string>();
|
||||
foreach (var condition in conditions)
|
||||
{
|
||||
var atom = MapToAtom(condition);
|
||||
parts.Add(atom);
|
||||
}
|
||||
|
||||
// Join with lattice meet operator
|
||||
return string.Join(" ∧ ", parts);
|
||||
}
|
||||
|
||||
private static string MapToAtom(PolicyCondition condition)
|
||||
{
|
||||
// Map condition to K4 lattice atom
|
||||
return condition.Field switch
|
||||
{
|
||||
"severity" => $"severity({condition.Value})",
|
||||
"reachable" => condition.Value is true ? "Reachable" : "¬Reachable",
|
||||
"has_vex" => condition.Value is true ? "HasVex" : "¬HasVex",
|
||||
"vex_status" => $"VexStatus({condition.Value})",
|
||||
"cvss_score" => $"CVSS {condition.Operator} {condition.Value}",
|
||||
"epss_score" => $"EPSS {condition.Operator} {condition.Value}",
|
||||
"scope" => $"Scope({condition.Value})",
|
||||
_ => $"{condition.Field} {condition.Operator} {condition.Value}"
|
||||
};
|
||||
}
|
||||
|
||||
private static string DetermineDisposition(IReadOnlyList<PolicyAction> actions)
|
||||
{
|
||||
foreach (var action in actions)
|
||||
{
|
||||
if (action.ActionType == "set_verdict" &&
|
||||
action.Parameters.TryGetValue("verdict", out var verdict))
|
||||
{
|
||||
return verdict?.ToString() ?? "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
return actions.Count > 0 ? actions[0].ActionType : "pass";
|
||||
}
|
||||
|
||||
private static string GenerateRuleName(PolicyIntent intent)
|
||||
{
|
||||
var prefix = intent.IntentType switch
|
||||
{
|
||||
PolicyIntentType.OverrideRule => "Override",
|
||||
PolicyIntentType.EscalationRule => "Escalate",
|
||||
PolicyIntentType.ExceptionCondition => "Exception",
|
||||
PolicyIntentType.MergePrecedence => "Precedence",
|
||||
PolicyIntentType.ThresholdRule => "Threshold",
|
||||
PolicyIntentType.ScopeRestriction => "Scope",
|
||||
_ => "Rule"
|
||||
};
|
||||
|
||||
var suffix = intent.OriginalInput.Length > 30
|
||||
? intent.OriginalInput[..27] + "..."
|
||||
: intent.OriginalInput;
|
||||
|
||||
return $"{prefix}: {suffix}";
|
||||
}
|
||||
|
||||
private static RuleConflict? DetectConflict(LatticeRule rule1, LatticeRule rule2)
|
||||
{
|
||||
// Check for overlapping conditions with different dispositions
|
||||
if (rule1.Disposition != rule2.Disposition)
|
||||
{
|
||||
var overlap = FindConditionOverlap(rule1.Conditions, rule2.Conditions);
|
||||
if (overlap > 0.5)
|
||||
{
|
||||
return new RuleConflict
|
||||
{
|
||||
RuleId1 = rule1.RuleId,
|
||||
RuleId2 = rule2.RuleId,
|
||||
Description = $"Rules have {overlap:P0} condition overlap but different dispositions",
|
||||
SuggestedResolution = rule1.Priority > rule2.Priority
|
||||
? $"Rule {rule1.RuleId} will take precedence"
|
||||
: $"Rule {rule2.RuleId} will take precedence",
|
||||
Severity = overlap > 0.8 ? "error" : "warning"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static double FindConditionOverlap(
|
||||
IReadOnlyList<PolicyCondition> conditions1,
|
||||
IReadOnlyList<PolicyCondition> conditions2)
|
||||
{
|
||||
if (conditions1.Count == 0 || conditions2.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var fields1 = conditions1.Select(c => c.Field).ToHashSet();
|
||||
var fields2 = conditions2.Select(c => c.Field).ToHashSet();
|
||||
|
||||
var intersection = fields1.Intersect(fields2).Count();
|
||||
var union = fields1.Union(fields2).Count();
|
||||
|
||||
return union > 0 ? (double)intersection / union : 0;
|
||||
}
|
||||
|
||||
private static bool HasUnreachableConditions(LatticeRule rule)
|
||||
{
|
||||
// Check for contradictory conditions
|
||||
var conditions = rule.Conditions.ToList();
|
||||
for (int i = 0; i < conditions.Count; i++)
|
||||
{
|
||||
for (int j = i + 1; j < conditions.Count; j++)
|
||||
{
|
||||
if (conditions[i].Field == conditions[j].Field &&
|
||||
conditions[i].Operator == "equals" &&
|
||||
conditions[j].Operator == "equals" &&
|
||||
!Equals(conditions[i].Value, conditions[j].Value))
|
||||
{
|
||||
return true; // Same field with different required values
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static double CalculateCoverage(IReadOnlyList<LatticeRule> rules)
|
||||
{
|
||||
// Estimate coverage based on rule conditions
|
||||
var uniqueFields = rules
|
||||
.SelectMany(r => r.Conditions)
|
||||
.Select(c => c.Field)
|
||||
.Distinct()
|
||||
.Count();
|
||||
|
||||
// Simple heuristic: more fields covered = higher coverage
|
||||
return Math.Min(1.0, uniqueFields * 0.1);
|
||||
}
|
||||
|
||||
private static string ComputeHash(string content)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return Convert.ToHexStringLower(bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.PolicyStudio;
|
||||
|
||||
/// <summary>
|
||||
/// Property-based test case synthesizer for policy validation.
|
||||
/// Sprint: SPRINT_20251226_017_AI_policy_copilot
|
||||
/// Task: POLICY-09
|
||||
/// </summary>
|
||||
public sealed class PropertyBasedTestSynthesizer : ITestCaseSynthesizer
|
||||
{
|
||||
public Task<IReadOnlyList<PolicyTestCase>> SynthesizeAsync(
|
||||
IReadOnlyList<LatticeRule> rules,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var testCases = new List<PolicyTestCase>();
|
||||
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
// POLICY-10: Generate positive tests
|
||||
testCases.AddRange(GeneratePositiveTests(rule));
|
||||
|
||||
// POLICY-11: Generate negative tests
|
||||
testCases.AddRange(GenerateNegativeTests(rule));
|
||||
|
||||
// POLICY-12: Generate boundary tests
|
||||
testCases.AddRange(GenerateBoundaryTests(rule));
|
||||
}
|
||||
|
||||
// Generate conflict tests for overlapping rules
|
||||
testCases.AddRange(GenerateConflictTests(rules));
|
||||
|
||||
return Task.FromResult<IReadOnlyList<PolicyTestCase>>(testCases);
|
||||
}
|
||||
|
||||
public Task<TestRunResult> RunTestsAsync(
|
||||
IReadOnlyList<PolicyTestCase> testCases,
|
||||
IReadOnlyList<LatticeRule> rules,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = new List<TestCaseResult>();
|
||||
|
||||
foreach (var testCase in testCases)
|
||||
{
|
||||
var result = EvaluateTestCase(testCase, rules);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
return Task.FromResult(new TestRunResult
|
||||
{
|
||||
Total = results.Count,
|
||||
Passed = results.Count(r => r.Passed),
|
||||
Failed = results.Count(r => !r.Passed),
|
||||
Results = results,
|
||||
RunAt = DateTime.UtcNow.ToString("O")
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate positive test cases (inputs that should match).
|
||||
/// POLICY-10
|
||||
/// </summary>
|
||||
private static IEnumerable<PolicyTestCase> GeneratePositiveTests(LatticeRule rule)
|
||||
{
|
||||
var testId = $"test-pos-{ComputeHash(rule.RuleId)[..8]}";
|
||||
|
||||
// Create input that satisfies all conditions
|
||||
var input = new Dictionary<string, object>();
|
||||
foreach (var condition in rule.Conditions)
|
||||
{
|
||||
input[condition.Field] = condition.Value;
|
||||
}
|
||||
|
||||
yield return new PolicyTestCase
|
||||
{
|
||||
TestCaseId = testId,
|
||||
Name = $"Positive: {rule.Name}",
|
||||
Type = TestCaseType.Positive,
|
||||
Input = input,
|
||||
ExpectedDisposition = rule.Disposition,
|
||||
TargetRuleIds = new[] { rule.RuleId },
|
||||
Description = $"Input satisfying all conditions should produce {rule.Disposition}"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate negative test cases (inputs that should NOT match).
|
||||
/// POLICY-11
|
||||
/// </summary>
|
||||
private static IEnumerable<PolicyTestCase> GenerateNegativeTests(LatticeRule rule)
|
||||
{
|
||||
var baseId = ComputeHash(rule.RuleId)[..8];
|
||||
|
||||
// For each condition, create a test that violates just that condition
|
||||
int i = 0;
|
||||
foreach (var condition in rule.Conditions)
|
||||
{
|
||||
var input = new Dictionary<string, object>();
|
||||
|
||||
// Satisfy all other conditions
|
||||
foreach (var c in rule.Conditions)
|
||||
{
|
||||
input[c.Field] = c.Value;
|
||||
}
|
||||
|
||||
// Violate this specific condition
|
||||
input[condition.Field] = GetOppositeValue(condition);
|
||||
|
||||
yield return new PolicyTestCase
|
||||
{
|
||||
TestCaseId = $"test-neg-{baseId}-{i++}",
|
||||
Name = $"Negative: {rule.Name} (violates {condition.Field})",
|
||||
Type = TestCaseType.Negative,
|
||||
Input = input,
|
||||
ExpectedDisposition = "pass", // Default when rule doesn't match
|
||||
TargetRuleIds = new[] { rule.RuleId },
|
||||
Description = $"Violating {condition.Field} condition should not trigger rule"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate boundary test cases.
|
||||
/// </summary>
|
||||
private static IEnumerable<PolicyTestCase> GenerateBoundaryTests(LatticeRule rule)
|
||||
{
|
||||
var baseId = ComputeHash(rule.RuleId)[..8];
|
||||
int i = 0;
|
||||
|
||||
foreach (var condition in rule.Conditions)
|
||||
{
|
||||
// Generate boundary values for numeric conditions
|
||||
if (condition.Operator is "greater_than" or "less_than" or ">" or "<")
|
||||
{
|
||||
var value = condition.Value;
|
||||
if (value is double dv)
|
||||
{
|
||||
// Test at boundary
|
||||
var input = new Dictionary<string, object>();
|
||||
foreach (var c in rule.Conditions)
|
||||
{
|
||||
input[c.Field] = c.Value;
|
||||
}
|
||||
|
||||
// Just at boundary
|
||||
input[condition.Field] = dv;
|
||||
|
||||
yield return new PolicyTestCase
|
||||
{
|
||||
TestCaseId = $"test-bnd-{baseId}-{i++}",
|
||||
Name = $"Boundary: {rule.Name} ({condition.Field}={dv})",
|
||||
Type = TestCaseType.Boundary,
|
||||
Input = input,
|
||||
ExpectedDisposition = EvaluateBoundary(condition, dv) ? rule.Disposition : "pass",
|
||||
TargetRuleIds = new[] { rule.RuleId },
|
||||
Description = $"Testing boundary value for {condition.Field}"
|
||||
};
|
||||
|
||||
// Just past boundary
|
||||
var epsilon = 0.001;
|
||||
var pastValue = condition.Operator is "greater_than" or ">" ? dv + epsilon : dv - epsilon;
|
||||
input[condition.Field] = pastValue;
|
||||
|
||||
yield return new PolicyTestCase
|
||||
{
|
||||
TestCaseId = $"test-bnd-{baseId}-{i++}",
|
||||
Name = $"Boundary: {rule.Name} ({condition.Field}={pastValue:F3})",
|
||||
Type = TestCaseType.Boundary,
|
||||
Input = input,
|
||||
ExpectedDisposition = rule.Disposition,
|
||||
TargetRuleIds = new[] { rule.RuleId },
|
||||
Description = $"Testing past boundary value for {condition.Field}"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate conflict test cases for overlapping rules.
|
||||
/// POLICY-12
|
||||
/// </summary>
|
||||
private static IEnumerable<PolicyTestCase> GenerateConflictTests(IReadOnlyList<LatticeRule> rules)
|
||||
{
|
||||
for (int i = 0; i < rules.Count; i++)
|
||||
{
|
||||
for (int j = i + 1; j < rules.Count; j++)
|
||||
{
|
||||
var rule1 = rules[i];
|
||||
var rule2 = rules[j];
|
||||
|
||||
// Check if rules could overlap
|
||||
var commonFields = rule1.Conditions.Select(c => c.Field)
|
||||
.Intersect(rule2.Conditions.Select(c => c.Field))
|
||||
.ToList();
|
||||
|
||||
if (commonFields.Count > 0)
|
||||
{
|
||||
// Create input that could trigger both rules
|
||||
var input = new Dictionary<string, object>();
|
||||
|
||||
foreach (var condition in rule1.Conditions)
|
||||
{
|
||||
input[condition.Field] = condition.Value;
|
||||
}
|
||||
foreach (var condition in rule2.Conditions)
|
||||
{
|
||||
if (!input.ContainsKey(condition.Field))
|
||||
{
|
||||
input[condition.Field] = condition.Value;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine expected based on priority
|
||||
var expectedDisposition = rule1.Priority >= rule2.Priority
|
||||
? rule1.Disposition
|
||||
: rule2.Disposition;
|
||||
|
||||
yield return new PolicyTestCase
|
||||
{
|
||||
TestCaseId = $"test-conflict-{ComputeHash(rule1.RuleId + rule2.RuleId)[..8]}",
|
||||
Name = $"Conflict: {rule1.Name} vs {rule2.Name}",
|
||||
Type = TestCaseType.Conflict,
|
||||
Input = input,
|
||||
ExpectedDisposition = expectedDisposition,
|
||||
TargetRuleIds = new[] { rule1.RuleId, rule2.RuleId },
|
||||
Description = $"Testing priority resolution between {rule1.RuleId} and {rule2.RuleId}"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static object GetOppositeValue(PolicyCondition condition)
|
||||
{
|
||||
return condition.Value switch
|
||||
{
|
||||
bool b => !b,
|
||||
string s when s == "critical" => "low",
|
||||
string s when s == "high" => "low",
|
||||
string s when s == "low" => "critical",
|
||||
double d => d * -1,
|
||||
int i => i * -1,
|
||||
_ => "opposite_value"
|
||||
};
|
||||
}
|
||||
|
||||
private static bool EvaluateBoundary(PolicyCondition condition, double value)
|
||||
{
|
||||
// Boundary value typically doesn't satisfy strict comparison
|
||||
return condition.Operator is ">=" or "<=" or "greater_than_or_equal" or "less_than_or_equal";
|
||||
}
|
||||
|
||||
private static TestCaseResult EvaluateTestCase(PolicyTestCase testCase, IReadOnlyList<LatticeRule> rules)
|
||||
{
|
||||
// Find matching rules
|
||||
var matchingRules = rules
|
||||
.Where(r => testCase.TargetRuleIds.Contains(r.RuleId))
|
||||
.Where(r => EvaluateConditions(r.Conditions, testCase.Input))
|
||||
.OrderByDescending(r => r.Priority)
|
||||
.ToList();
|
||||
|
||||
var actual = matchingRules.Count > 0
|
||||
? matchingRules[0].Disposition
|
||||
: "pass";
|
||||
|
||||
return new TestCaseResult
|
||||
{
|
||||
TestCaseId = testCase.TestCaseId,
|
||||
Passed = actual == testCase.ExpectedDisposition,
|
||||
Expected = testCase.ExpectedDisposition,
|
||||
Actual = actual,
|
||||
ErrorMessage = actual != testCase.ExpectedDisposition
|
||||
? $"Expected {testCase.ExpectedDisposition} but got {actual}"
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
private static bool EvaluateConditions(
|
||||
IReadOnlyList<PolicyCondition> conditions,
|
||||
IReadOnlyDictionary<string, object> input)
|
||||
{
|
||||
foreach (var condition in conditions)
|
||||
{
|
||||
if (!input.TryGetValue(condition.Field, out var value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!EvaluateCondition(condition, value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool EvaluateCondition(PolicyCondition condition, object actualValue)
|
||||
{
|
||||
return condition.Operator switch
|
||||
{
|
||||
"equals" or "=" or "==" => Equals(condition.Value, actualValue),
|
||||
"not_equals" or "!=" => !Equals(condition.Value, actualValue),
|
||||
"greater_than" or ">" when actualValue is double d => d > Convert.ToDouble(condition.Value),
|
||||
"less_than" or "<" when actualValue is double d => d < Convert.ToDouble(condition.Value),
|
||||
"contains" when actualValue is string s => s.Contains(condition.Value?.ToString() ?? "", StringComparison.OrdinalIgnoreCase),
|
||||
_ => Equals(condition.Value, actualValue)
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeHash(string content)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return Convert.ToHexStringLower(bytes);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user