consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
@@ -3,5 +3,5 @@
|
||||
|
||||
"advisoryai.validation.q_required": "q ist erforderlich.",
|
||||
"advisoryai.validation.q_max_512": "q darf maximal 512 Zeichen lang sein.",
|
||||
"advisoryai.validation.tenant_required": "Tenant-Kontext ist erforderlich."
|
||||
"advisoryai.validation.tenant_required": "Mandantenkontext ist erforderlich."
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -319,7 +319,7 @@
|
||||
"sha256": "1be0ec1ce56cd616259095ccbfce106121c75e4d40621fbe2b1f022ff76072fe"
|
||||
},
|
||||
{
|
||||
"path": "docs/modules/cli/guides/commands/orchestrator.md",
|
||||
"path": "docs/modules/cli/guides/commands/jobengine.md",
|
||||
"sha256": "5e74b92d1615f8300765ed156ed709c70645ad95f67b22f43bc47cc10589de30"
|
||||
},
|
||||
{
|
||||
@@ -1583,7 +1583,7 @@
|
||||
"sha256": "fa50d45dc2b02d2f89a12d801d38c83e3d89070caa318575123da54db9ea48c5"
|
||||
},
|
||||
{
|
||||
"path": "docs/operations/orchestrator-runbook.md",
|
||||
"path": "docs/operations/jobengine-runbook.md",
|
||||
"sha256": "64af4dd5bda8eebb2e9323e2bf7ef8308b0dd2e2ba33a11bae20222f4945c247"
|
||||
},
|
||||
{
|
||||
@@ -1715,23 +1715,23 @@
|
||||
"sha256": "a1a31a4c8baf091f67e3a5b043118ee93a05c5314fb3eb1c3b6fd14e53c19d96"
|
||||
},
|
||||
{
|
||||
"path": "docs/operations/runbooks/orchestrator-evidence-missing.md",
|
||||
"path": "docs/operations/runbooks/jobengine-evidence-missing.md",
|
||||
"sha256": "a180683a2de5a3fe60ae6477c1de7c5b36e37ad189c06373109c4afebe58b6da"
|
||||
},
|
||||
{
|
||||
"path": "docs/operations/runbooks/orchestrator-gate-timeout.md",
|
||||
"path": "docs/operations/runbooks/jobengine-gate-timeout.md",
|
||||
"sha256": "3695ac55be0a165a4ca2052fa328f0dc312e20194cafeda8b3a9bda7ac599e76"
|
||||
},
|
||||
{
|
||||
"path": "docs/operations/runbooks/orchestrator-promotion-stuck.md",
|
||||
"path": "docs/operations/runbooks/jobengine-promotion-stuck.md",
|
||||
"sha256": "bd5f464a941808d9bdb9a80d488c2885b3a9282fe4f7b05402e2b1766c22d276"
|
||||
},
|
||||
{
|
||||
"path": "docs/operations/runbooks/orchestrator-quota-exceeded.md",
|
||||
"path": "docs/operations/runbooks/jobengine-quota-exceeded.md",
|
||||
"sha256": "1cfcaef596e6d75e54545c1e41bc310f5858f67f0a0800a0087ba92a7471d567"
|
||||
},
|
||||
{
|
||||
"path": "docs/operations/runbooks/orchestrator-rollback-failed.md",
|
||||
"path": "docs/operations/runbooks/jobengine-rollback-failed.md",
|
||||
"sha256": "35abf5027af2e64060137fa9dbb23f500002c57ecea8ec762fa6fce1d4711073"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -48,8 +48,8 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />
|
||||
<ProjectReference Include="..\..\Concelier\__Libraries\StellaOps.Concelier.RawModels\StellaOps.Concelier.RawModels.csproj" />
|
||||
<ProjectReference Include="..\..\Excititor\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
|
||||
<ProjectReference Include="..\..\OpsMemory\StellaOps.OpsMemory\StellaOps.OpsMemory.csproj" />
|
||||
<ProjectReference Include="..\..\Concelier\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
|
||||
<ProjectReference Include="..\__Libraries\StellaOps.OpsMemory\StellaOps.OpsMemory.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.AdvisoryAI.Attestation\StellaOps.AdvisoryAI.Attestation.csproj" />
|
||||
<!-- Evidence Packs (Sprint: SPRINT_20260109_011_005 Task: EVPK-006) -->
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Evidence.Pack\StellaOps.Evidence.Pack.csproj" />
|
||||
|
||||
@@ -48,7 +48,7 @@ internal sealed class PlatformCatalogIngestionAdapter : ISearchIngestionAdapter
|
||||
EntityType: "pack",
|
||||
Title: "Pack: Offline Kit",
|
||||
Summary: "Offline kit export bundle",
|
||||
Source: "orchestrator",
|
||||
Source: "jobengine",
|
||||
Route: "/packs/offline-kit"),
|
||||
new PlatformCatalogEntry(
|
||||
EntityId: "tenant-acme",
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,570 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
using StellaOps.OpsMemory.Playbook;
|
||||
using StellaOps.OpsMemory.Storage;
|
||||
using StellaOps.OpsMemory.WebService.Security;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.OpsMemory.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// OpsMemory API endpoints for decision recording and playbook suggestions.
|
||||
/// Sprint: SPRINT_20260107_006_004 Task OM-006
|
||||
/// </summary>
|
||||
public static class OpsMemoryEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps OpsMemory endpoints.
|
||||
/// </summary>
|
||||
public static void MapOpsMemoryEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/opsmemory")
|
||||
.WithTags("OpsMemory")
|
||||
.RequireAuthorization(OpsMemoryPolicies.Read)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapPost("/decisions", RecordDecisionAsync)
|
||||
.WithName("RecordDecision")
|
||||
.WithDescription("Records a security decision (accept, suppress, mitigate, escalate) for a CVE and component combination. The decision is stored with situational context for future playbook learning and similarity matching. Returns 201 Created with the new memory ID.")
|
||||
.RequireAuthorization(OpsMemoryPolicies.Write);
|
||||
|
||||
group.MapGet("/decisions/{memoryId}", GetDecisionAsync)
|
||||
.WithName("GetDecision")
|
||||
.WithDescription("Returns the full decision record for a specific memory ID including situational context, decision details, mitigation information, and outcome if recorded. Returns 404 if not found.");
|
||||
|
||||
group.MapPost("/decisions/{memoryId}/outcome", RecordOutcomeAsync)
|
||||
.WithName("RecordOutcome")
|
||||
.WithDescription("Records the observed outcome of a previously stored security decision, capturing resolution time, actual impact, lessons learned, and whether the decision would be repeated. Returns 200 with the updated memory ID.")
|
||||
.RequireAuthorization(OpsMemoryPolicies.Write);
|
||||
|
||||
group.MapGet("/suggestions", GetSuggestionsAsync)
|
||||
.WithName("GetPlaybookSuggestions")
|
||||
.WithDescription("Returns ranked playbook suggestions for a given situational context by matching against historical decisions using similarity scoring. Each suggestion includes confidence, success rate, and evidence from past decisions.");
|
||||
|
||||
group.MapGet("/decisions", QueryDecisionsAsync)
|
||||
.WithName("QueryDecisions")
|
||||
.WithDescription("Queries stored security decisions with optional filters by CVE ID, component prefix, action type, and outcome status. Supports cursor-based pagination.");
|
||||
|
||||
group.MapGet("/stats", GetStatsAsync)
|
||||
.WithName("GetOpsMemoryStats")
|
||||
.WithDescription("Returns aggregated decision statistics for the tenant including total decision count, decisions with recorded outcomes, and overall success rate.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record a new security decision.
|
||||
/// </summary>
|
||||
private static async Task<Results<Created<RecordDecisionResponse>, BadRequest<ProblemDetails>>> RecordDecisionAsync(
|
||||
RecordDecisionRequest request,
|
||||
IOpsMemoryStore store,
|
||||
TimeProvider timeProvider,
|
||||
IGuidProvider guidProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.TenantId))
|
||||
{
|
||||
return TypedResults.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "TenantId is required"
|
||||
});
|
||||
}
|
||||
|
||||
if (!Enum.TryParse<DecisionAction>(request.Action, ignoreCase: true, out var action))
|
||||
{
|
||||
return TypedResults.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = $"Invalid action: {request.Action}. Valid values: {string.Join(", ", Enum.GetNames<DecisionAction>())}"
|
||||
});
|
||||
}
|
||||
|
||||
var record = new OpsMemoryRecord
|
||||
{
|
||||
MemoryId = guidProvider.NewGuid().ToString("N"),
|
||||
TenantId = request.TenantId,
|
||||
RecordedAt = timeProvider.GetUtcNow(),
|
||||
Situation = new SituationContext
|
||||
{
|
||||
CveId = request.CveId,
|
||||
Component = request.ComponentPurl,
|
||||
Severity = request.Severity,
|
||||
Reachability = ParseReachability(request.Reachability),
|
||||
EpssScore = request.EpssScore,
|
||||
CvssScore = request.CvssScore,
|
||||
ContextTags = request.ContextTags?.ToImmutableArray() ?? ImmutableArray<string>.Empty
|
||||
},
|
||||
Decision = new DecisionRecord
|
||||
{
|
||||
Action = action,
|
||||
Rationale = request.Rationale ?? string.Empty,
|
||||
DecidedBy = request.DecidedBy ?? "unknown",
|
||||
DecidedAt = timeProvider.GetUtcNow(),
|
||||
PolicyReference = request.PolicyReference,
|
||||
Mitigation = !string.IsNullOrEmpty(request.MitigationType)
|
||||
? new MitigationDetails
|
||||
{
|
||||
Type = request.MitigationType,
|
||||
Description = request.MitigationDetails ?? string.Empty
|
||||
}
|
||||
: null
|
||||
}
|
||||
};
|
||||
|
||||
var saved = await store.RecordDecisionAsync(record, cancellationToken);
|
||||
|
||||
return TypedResults.Created(
|
||||
$"/api/v1/opsmemory/decisions/{saved.MemoryId}",
|
||||
new RecordDecisionResponse
|
||||
{
|
||||
MemoryId = saved.MemoryId,
|
||||
RecordedAt = saved.RecordedAt
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a specific decision.
|
||||
/// </summary>
|
||||
private static async Task<Results<Ok<DecisionDetailsResponse>, NotFound>> GetDecisionAsync(
|
||||
string memoryId,
|
||||
string tenantId,
|
||||
IOpsMemoryStore store,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var record = await store.GetByIdAsync(memoryId, tenantId, cancellationToken);
|
||||
|
||||
if (record == null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.Ok(MapToDetailsResponse(record));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record the outcome of a decision.
|
||||
/// </summary>
|
||||
private static async Task<Results<Ok<OutcomeRecordedResponse>, NotFound, BadRequest<ProblemDetails>>> RecordOutcomeAsync(
|
||||
string memoryId,
|
||||
string tenantId,
|
||||
RecordOutcomeRequest request,
|
||||
IOpsMemoryStore store,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Enum.TryParse<OutcomeStatus>(request.Status, ignoreCase: true, out var status))
|
||||
{
|
||||
return TypedResults.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = $"Invalid status: {request.Status}. Valid values: {string.Join(", ", Enum.GetNames<OutcomeStatus>())}"
|
||||
});
|
||||
}
|
||||
|
||||
var outcome = new OutcomeRecord
|
||||
{
|
||||
Status = status,
|
||||
ResolutionTime = request.ResolutionTimeMinutes.HasValue
|
||||
? TimeSpan.FromMinutes(request.ResolutionTimeMinutes.Value)
|
||||
: null,
|
||||
ActualImpact = request.ActualImpact,
|
||||
LessonsLearned = request.LessonsLearned,
|
||||
RecordedBy = request.RecordedBy ?? "unknown",
|
||||
RecordedAt = timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
var updated = await store.RecordOutcomeAsync(memoryId, tenantId, outcome, cancellationToken);
|
||||
|
||||
if (updated == null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.Ok(new OutcomeRecordedResponse
|
||||
{
|
||||
MemoryId = memoryId,
|
||||
Status = status.ToString(),
|
||||
RecordedAt = outcome.RecordedAt
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get playbook suggestions for a situation.
|
||||
/// </summary>
|
||||
private static async Task<Ok<PlaybookSuggestionsResponse>> GetSuggestionsAsync(
|
||||
string tenantId,
|
||||
PlaybookSuggestionService suggestionService,
|
||||
string? cveId,
|
||||
string? componentPurl,
|
||||
string? severity,
|
||||
string? reachability,
|
||||
double? epssScore,
|
||||
double? cvssScore,
|
||||
int? limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var request = new PlaybookSuggestionRequest
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Situation = new SituationContext
|
||||
{
|
||||
CveId = cveId,
|
||||
Component = componentPurl,
|
||||
Severity = severity,
|
||||
Reachability = ParseReachability(reachability),
|
||||
EpssScore = epssScore,
|
||||
CvssScore = cvssScore
|
||||
},
|
||||
MaxSuggestions = limit ?? 3
|
||||
};
|
||||
|
||||
var result = await suggestionService.GetSuggestionsAsync(request, cancellationToken);
|
||||
|
||||
return TypedResults.Ok(new PlaybookSuggestionsResponse
|
||||
{
|
||||
Suggestions = result.Suggestions.Select(s => new PlaybookSuggestionDto
|
||||
{
|
||||
SuggestedAction = s.Action.ToString(),
|
||||
Confidence = s.Confidence,
|
||||
Rationale = s.Rationale,
|
||||
SuccessRate = s.SuccessRate,
|
||||
SimilarDecisionCount = s.SimilarDecisionCount,
|
||||
AverageResolutionTimeMinutes = s.AverageResolutionTime?.TotalMinutes,
|
||||
Evidence = s.Evidence.Select(e => new PlaybookEvidenceDto
|
||||
{
|
||||
MemoryId = e.MemoryId,
|
||||
Similarity = e.Similarity,
|
||||
Action = e.Action.ToString(),
|
||||
Outcome = e.Outcome.ToString(),
|
||||
DecidedAt = e.DecidedAt,
|
||||
CveId = e.Cve,
|
||||
ComponentPurl = e.Component
|
||||
}).ToList(),
|
||||
MatchingFactors = s.MatchingFactors.ToList()
|
||||
}).ToList(),
|
||||
AnalyzedRecords = result.AnalyzedRecords,
|
||||
TopSimilarity = result.TopSimilarity
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query past decisions.
|
||||
/// </summary>
|
||||
private static async Task<Ok<QueryDecisionsResponse>> QueryDecisionsAsync(
|
||||
string tenantId,
|
||||
IOpsMemoryStore store,
|
||||
string? cveId,
|
||||
string? componentPrefix,
|
||||
string? action,
|
||||
string? outcomeStatus,
|
||||
int? pageSize,
|
||||
string? cursor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DecisionAction? actionFilter = null;
|
||||
if (!string.IsNullOrEmpty(action) && Enum.TryParse<DecisionAction>(action, ignoreCase: true, out var a))
|
||||
{
|
||||
actionFilter = a;
|
||||
}
|
||||
|
||||
OutcomeStatus? statusFilter = null;
|
||||
if (!string.IsNullOrEmpty(outcomeStatus) && Enum.TryParse<OutcomeStatus>(outcomeStatus, ignoreCase: true, out var s))
|
||||
{
|
||||
statusFilter = s;
|
||||
}
|
||||
|
||||
var query = new OpsMemoryQuery
|
||||
{
|
||||
TenantId = tenantId,
|
||||
CveId = cveId,
|
||||
ComponentPrefix = componentPrefix,
|
||||
Action = actionFilter,
|
||||
OutcomeStatus = statusFilter,
|
||||
PageSize = pageSize ?? 20,
|
||||
Cursor = cursor
|
||||
};
|
||||
|
||||
var result = await store.QueryAsync(query, cancellationToken);
|
||||
|
||||
return TypedResults.Ok(new QueryDecisionsResponse
|
||||
{
|
||||
Decisions = result.Items.Select(MapToSummary).ToList(),
|
||||
TotalCount = result.TotalCount,
|
||||
NextCursor = result.NextCursor,
|
||||
HasMore = result.HasMore
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get statistics for a tenant.
|
||||
/// </summary>
|
||||
private static async Task<Ok<OpsMemoryStatsResponse>> GetStatsAsync(
|
||||
string tenantId,
|
||||
IOpsMemoryStore store,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var stats = await store.GetStatsAsync(tenantId, null, cancellationToken);
|
||||
|
||||
return TypedResults.Ok(new OpsMemoryStatsResponse
|
||||
{
|
||||
TenantId = tenantId,
|
||||
TotalDecisions = stats.TotalDecisions,
|
||||
DecisionsWithOutcomes = stats.DecisionsWithOutcomes,
|
||||
SuccessRate = stats.SuccessRate
|
||||
});
|
||||
}
|
||||
|
||||
private static ReachabilityStatus ParseReachability(string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return ReachabilityStatus.Unknown;
|
||||
}
|
||||
|
||||
return Enum.TryParse<ReachabilityStatus>(value, ignoreCase: true, out var result)
|
||||
? result
|
||||
: ReachabilityStatus.Unknown;
|
||||
}
|
||||
|
||||
private static DecisionDetailsResponse MapToDetailsResponse(OpsMemoryRecord record)
|
||||
{
|
||||
return new DecisionDetailsResponse
|
||||
{
|
||||
MemoryId = record.MemoryId,
|
||||
TenantId = record.TenantId,
|
||||
RecordedAt = record.RecordedAt,
|
||||
Situation = new SituationDto
|
||||
{
|
||||
CveId = record.Situation.CveId,
|
||||
Component = record.Situation.Component,
|
||||
ComponentName = record.Situation.ComponentName,
|
||||
Severity = record.Situation.Severity,
|
||||
Reachability = record.Situation.Reachability.ToString(),
|
||||
EpssScore = record.Situation.EpssScore,
|
||||
CvssScore = record.Situation.CvssScore,
|
||||
IsKev = record.Situation.IsKev,
|
||||
ContextTags = record.Situation.ContextTags.ToList()
|
||||
},
|
||||
Decision = new DecisionDto
|
||||
{
|
||||
Action = record.Decision.Action.ToString(),
|
||||
Rationale = record.Decision.Rationale,
|
||||
DecidedBy = record.Decision.DecidedBy,
|
||||
DecidedAt = record.Decision.DecidedAt,
|
||||
PolicyReference = record.Decision.PolicyReference,
|
||||
VexStatementId = record.Decision.VexStatementId,
|
||||
Mitigation = record.Decision.Mitigation != null
|
||||
? new MitigationDto
|
||||
{
|
||||
Type = record.Decision.Mitigation.Type,
|
||||
Description = record.Decision.Mitigation.Description,
|
||||
Effectiveness = record.Decision.Mitigation.Effectiveness,
|
||||
ExpiresAt = record.Decision.Mitigation.ExpiresAt
|
||||
}
|
||||
: null
|
||||
},
|
||||
Outcome = record.Outcome != null
|
||||
? new OutcomeDto
|
||||
{
|
||||
Status = record.Outcome.Status.ToString(),
|
||||
ResolutionTimeMinutes = record.Outcome.ResolutionTime?.TotalMinutes,
|
||||
ActualImpact = record.Outcome.ActualImpact,
|
||||
LessonsLearned = record.Outcome.LessonsLearned,
|
||||
RecordedBy = record.Outcome.RecordedBy,
|
||||
RecordedAt = record.Outcome.RecordedAt,
|
||||
WouldRepeat = record.Outcome.WouldRepeat,
|
||||
AlternativeActions = record.Outcome.AlternativeActions
|
||||
}
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
private static DecisionSummaryDto MapToSummary(OpsMemoryRecord record)
|
||||
{
|
||||
return new DecisionSummaryDto
|
||||
{
|
||||
MemoryId = record.MemoryId,
|
||||
RecordedAt = record.RecordedAt,
|
||||
CveId = record.Situation.CveId,
|
||||
Component = record.Situation.Component,
|
||||
Action = record.Decision.Action.ToString(),
|
||||
OutcomeStatus = record.Outcome?.Status.ToString(),
|
||||
DecidedBy = record.Decision.DecidedBy
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#region Request/Response DTOs
|
||||
|
||||
/// <summary>Request to record a decision.</summary>
|
||||
public sealed record RecordDecisionRequest
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public string? CveId { get; init; }
|
||||
public string? ComponentPurl { get; init; }
|
||||
public string? Severity { get; init; }
|
||||
public string? Reachability { get; init; }
|
||||
public double? EpssScore { get; init; }
|
||||
public double? CvssScore { get; init; }
|
||||
public List<string>? ContextTags { get; init; }
|
||||
public required string Action { get; init; }
|
||||
public string? Rationale { get; init; }
|
||||
public string? DecidedBy { get; init; }
|
||||
public string? PolicyReference { get; init; }
|
||||
public string? MitigationType { get; init; }
|
||||
public string? MitigationDetails { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Response after recording a decision.</summary>
|
||||
public sealed record RecordDecisionResponse
|
||||
{
|
||||
public required string MemoryId { get; init; }
|
||||
public DateTimeOffset RecordedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Request to record an outcome.</summary>
|
||||
public sealed record RecordOutcomeRequest
|
||||
{
|
||||
public required string Status { get; init; }
|
||||
public int? ResolutionTimeMinutes { get; init; }
|
||||
public string? ActualImpact { get; init; }
|
||||
public string? LessonsLearned { get; init; }
|
||||
public string? RecordedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Response after recording an outcome.</summary>
|
||||
public sealed record OutcomeRecordedResponse
|
||||
{
|
||||
public required string MemoryId { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public DateTimeOffset RecordedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Detailed decision information.</summary>
|
||||
public sealed record DecisionDetailsResponse
|
||||
{
|
||||
public required string MemoryId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public DateTimeOffset RecordedAt { get; init; }
|
||||
public required SituationDto Situation { get; init; }
|
||||
public required DecisionDto Decision { get; init; }
|
||||
public OutcomeDto? Outcome { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Situation context DTO.</summary>
|
||||
public sealed record SituationDto
|
||||
{
|
||||
public string? CveId { get; init; }
|
||||
public string? Component { get; init; }
|
||||
public string? ComponentName { get; init; }
|
||||
public string? Severity { get; init; }
|
||||
public string? Reachability { get; init; }
|
||||
public double? EpssScore { get; init; }
|
||||
public double? CvssScore { get; init; }
|
||||
public bool IsKev { get; init; }
|
||||
public List<string>? ContextTags { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Decision DTO.</summary>
|
||||
public sealed record DecisionDto
|
||||
{
|
||||
public required string Action { get; init; }
|
||||
public required string Rationale { get; init; }
|
||||
public required string DecidedBy { get; init; }
|
||||
public DateTimeOffset DecidedAt { get; init; }
|
||||
public string? PolicyReference { get; init; }
|
||||
public string? VexStatementId { get; init; }
|
||||
public MitigationDto? Mitigation { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Mitigation DTO.</summary>
|
||||
public sealed record MitigationDto
|
||||
{
|
||||
public required string Type { get; init; }
|
||||
public required string Description { get; init; }
|
||||
public double? Effectiveness { get; init; }
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Outcome DTO.</summary>
|
||||
public sealed record OutcomeDto
|
||||
{
|
||||
public required string Status { get; init; }
|
||||
public double? ResolutionTimeMinutes { get; init; }
|
||||
public string? ActualImpact { get; init; }
|
||||
public string? LessonsLearned { get; init; }
|
||||
public required string RecordedBy { get; init; }
|
||||
public DateTimeOffset RecordedAt { get; init; }
|
||||
public bool? WouldRepeat { get; init; }
|
||||
public string? AlternativeActions { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Playbook suggestions response.</summary>
|
||||
public sealed record PlaybookSuggestionsResponse
|
||||
{
|
||||
public required List<PlaybookSuggestionDto> Suggestions { get; init; }
|
||||
public int AnalyzedRecords { get; init; }
|
||||
public double? TopSimilarity { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>A single playbook suggestion.</summary>
|
||||
public sealed record PlaybookSuggestionDto
|
||||
{
|
||||
public required string SuggestedAction { get; init; }
|
||||
public double Confidence { get; init; }
|
||||
public required string Rationale { get; init; }
|
||||
public double SuccessRate { get; init; }
|
||||
public int SimilarDecisionCount { get; init; }
|
||||
public double? AverageResolutionTimeMinutes { get; init; }
|
||||
public required List<PlaybookEvidenceDto> Evidence { get; init; }
|
||||
public List<string>? MatchingFactors { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Evidence for a suggestion.</summary>
|
||||
public sealed record PlaybookEvidenceDto
|
||||
{
|
||||
public required string MemoryId { get; init; }
|
||||
public double Similarity { get; init; }
|
||||
public required string Action { get; init; }
|
||||
public required string Outcome { get; init; }
|
||||
public DateTimeOffset DecidedAt { get; init; }
|
||||
public string? CveId { get; init; }
|
||||
public string? ComponentPurl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Query decisions response.</summary>
|
||||
public sealed record QueryDecisionsResponse
|
||||
{
|
||||
public required List<DecisionSummaryDto> Decisions { get; init; }
|
||||
public int? TotalCount { get; init; }
|
||||
public string? NextCursor { get; init; }
|
||||
public bool HasMore { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Decision summary for lists.</summary>
|
||||
public sealed record DecisionSummaryDto
|
||||
{
|
||||
public required string MemoryId { get; init; }
|
||||
public DateTimeOffset RecordedAt { get; init; }
|
||||
public string? CveId { get; init; }
|
||||
public string? Component { get; init; }
|
||||
public required string Action { get; init; }
|
||||
public string? OutcomeStatus { get; init; }
|
||||
public string? DecidedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Statistics response.</summary>
|
||||
public sealed record OpsMemoryStatsResponse
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public int TotalDecisions { get; init; }
|
||||
public int DecisionsWithOutcomes { get; init; }
|
||||
public double SuccessRate { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
110
src/AdvisoryAI/StellaOps.OpsMemory.WebService/Program.cs
Normal file
110
src/AdvisoryAI/StellaOps.OpsMemory.WebService/Program.cs
Normal file
@@ -0,0 +1,110 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using Npgsql;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.OpsMemory.Playbook;
|
||||
using StellaOps.OpsMemory.Similarity;
|
||||
using StellaOps.OpsMemory.Storage;
|
||||
using StellaOps.Localization;
|
||||
using StellaOps.Router.AspNet;
|
||||
using StellaOps.OpsMemory.WebService.Endpoints;
|
||||
using StellaOps.OpsMemory.WebService.Security;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add PostgreSQL data source
|
||||
var connectionString = ResolveOpsMemoryConnectionString(builder);
|
||||
builder.Services.AddSingleton<NpgsqlDataSource>(_ => NpgsqlDataSource.Create(connectionString));
|
||||
|
||||
// Add determinism abstractions (TimeProvider + IGuidProvider for endpoint parameter binding)
|
||||
builder.Services.AddDeterminismDefaults();
|
||||
|
||||
// Add OpsMemory services
|
||||
builder.Services.AddSingleton<IOpsMemoryStore, PostgresOpsMemoryStore>();
|
||||
builder.Services.AddSingleton<SimilarityVectorGenerator>();
|
||||
builder.Services.AddSingleton<PlaybookSuggestionService>();
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(options =>
|
||||
{
|
||||
options.SwaggerDoc("v1", new()
|
||||
{
|
||||
Title = "StellaOps OpsMemory API",
|
||||
Version = "v1",
|
||||
Description = "Decision ledger and playbook suggestions API for security operations learning"
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddHealthChecks();
|
||||
|
||||
// Authentication and authorization
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(builder.Configuration);
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddStellaOpsScopePolicy(OpsMemoryPolicies.Read, StellaOpsScopes.OpsMemoryRead);
|
||||
options.AddStellaOpsScopePolicy(OpsMemoryPolicies.Write, StellaOpsScopes.OpsMemoryWrite);
|
||||
});
|
||||
|
||||
builder.Services.AddStellaOpsTenantServices();
|
||||
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
|
||||
builder.Services.AddStellaOpsLocalization(builder.Configuration);
|
||||
builder.Services.AddTranslationBundle(System.Reflection.Assembly.GetExecutingAssembly());
|
||||
|
||||
// Stella Router integration
|
||||
var routerEnabled = builder.Services.AddRouterMicroservice(
|
||||
builder.Configuration,
|
||||
serviceName: "opsmemory",
|
||||
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
|
||||
routerOptionsSection: "Router");
|
||||
builder.TryAddStellaOpsLocalBinding("opsmemory");
|
||||
var app = builder.Build();
|
||||
app.LogStellaOpsLocalHostname("opsmemory");
|
||||
|
||||
// Configure the HTTP request pipeline
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
app.UseStellaOpsCors();
|
||||
app.UseStellaOpsLocalization();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseStellaOpsTenantMiddleware();
|
||||
app.TryUseStellaRouter(routerEnabled);
|
||||
|
||||
// Map endpoints
|
||||
app.MapOpsMemoryEndpoints();
|
||||
app.MapHealthChecks("/health");
|
||||
|
||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||
await app.LoadTranslationsAsync();
|
||||
app.Run();
|
||||
|
||||
static string ResolveOpsMemoryConnectionString(WebApplicationBuilder builder)
|
||||
{
|
||||
// Explicit service connection has priority; shared default is the compose-compatible fallback.
|
||||
var configuredConnectionString =
|
||||
builder.Configuration.GetConnectionString("OpsMemory")
|
||||
?? builder.Configuration["ConnectionStrings:OpsMemory"]
|
||||
?? builder.Configuration.GetConnectionString("Default")
|
||||
?? builder.Configuration["ConnectionStrings:Default"];
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(configuredConnectionString))
|
||||
{
|
||||
return configuredConnectionString.Trim();
|
||||
}
|
||||
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
return "Host=localhost;Port=5432;Database=stellaops;Username=stellaops;Password=stellaops";
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"OpsMemory database connection string is required in non-development environments. Configure ConnectionStrings:OpsMemory or ConnectionStrings:Default.");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"profiles": {
|
||||
"StellaOps.OpsMemory.WebService": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"STELLAOPS_WEBSERVICES_CORS": "true",
|
||||
"STELLAOPS_WEBSERVICES_CORS_ORIGIN": "https://stella-ops.local,https://stella-ops.local:10000,https://localhost:10000"
|
||||
},
|
||||
"applicationUrl": "https://localhost:10270;http://localhost:10271"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
namespace StellaOps.OpsMemory.WebService.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Named authorization policy constants for the OpsMemory service.
|
||||
/// Policies are registered via AddStellaOpsScopePolicy in Program.cs.
|
||||
/// </summary>
|
||||
internal static class OpsMemoryPolicies
|
||||
{
|
||||
/// <summary>Policy for reading decisions and suggestions. Requires ops-memory:read scope.</summary>
|
||||
public const string Read = "OpsMemory.Read";
|
||||
|
||||
/// <summary>Policy for recording decisions and outcomes. Requires ops-memory:write scope.</summary>
|
||||
public const string Write = "OpsMemory.Write";
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.OpsMemory.WebService</RootNamespace>
|
||||
<Description>StellaOps OpsMemory Service - Decision ledger and playbook suggestions API</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\__Libraries\StellaOps.OpsMemory\StellaOps.OpsMemory.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Localization\StellaOps.Localization.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Translations\*.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="StellaOpsReleaseVersion">
|
||||
<Version>1.0.0-alpha1</Version>
|
||||
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
9
src/AdvisoryAI/StellaOps.OpsMemory.WebService/TASKS.md
Normal file
9
src/AdvisoryAI/StellaOps.OpsMemory.WebService/TASKS.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# StellaOps.OpsMemory.WebService Task Board
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| S312-OPSMEMORY-CONNECTION | DONE | Sprint `docs/implplan/SPRINT_20260305_312_DOCS_storage_policy_postgres_rustfs_alignment.md` TASK-312-007: aligned connection resolution with compose defaults (`ConnectionStrings:Default` fallback) and added fail-fast behavior for non-development when DB config is missing. |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/OpsMemory/StellaOps.OpsMemory.WebService/StellaOps.OpsMemory.WebService.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"_meta": { "locale": "en-US", "namespace": "opsmemory", "version": "1.0" }
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
// <copyright file="IOpsMemoryChatProvider.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
|
||||
using StellaOps.OpsMemory.Models;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.OpsMemory.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Provider for integrating OpsMemory with chat-based AI advisors.
|
||||
/// Enables surfacing past decisions in chat context and recording new decisions from chat actions.
|
||||
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-001
|
||||
/// </summary>
|
||||
public interface IOpsMemoryChatProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Enriches chat context with relevant past decisions and playbook suggestions.
|
||||
/// </summary>
|
||||
/// <param name="request">The chat context request with situational information.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>OpsMemory context with similar decisions and applicable tactics.</returns>
|
||||
Task<OpsMemoryContext> EnrichContextAsync(
|
||||
ChatContextRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Records a decision from an executed chat action.
|
||||
/// </summary>
|
||||
/// <param name="action">The action execution result from chat.</param>
|
||||
/// <param name="context">The conversation context.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The recorded OpsMemory record.</returns>
|
||||
Task<OpsMemoryRecord> RecordFromActionAsync(
|
||||
ActionExecutionResult action,
|
||||
ConversationContext context,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets recent decisions for a tenant.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="limit">Maximum number of decisions to return.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Recent decision summaries.</returns>
|
||||
Task<IReadOnlyList<PastDecisionSummary>> GetRecentDecisionsAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for chat context enrichment from OpsMemory.
|
||||
/// </summary>
|
||||
public sealed record ChatContextRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the tenant identifier for isolation.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the CVE identifier being discussed (if any).
|
||||
/// </summary>
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the component PURL being discussed.
|
||||
/// </summary>
|
||||
public string? Component { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the severity level (Critical, High, Medium, Low).
|
||||
/// </summary>
|
||||
public string? Severity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the reachability status.
|
||||
/// </summary>
|
||||
public ReachabilityStatus? Reachability { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the CVSS score (0-10).
|
||||
/// </summary>
|
||||
public double? CvssScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the EPSS score (0-1).
|
||||
/// </summary>
|
||||
public double? EpssScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets additional context tags (environment, team, etc.).
|
||||
/// </summary>
|
||||
public ImmutableArray<string> ContextTags { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum number of similar decisions to return.
|
||||
/// </summary>
|
||||
public int MaxSuggestions { get; init; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the minimum similarity score for matches (0-1).
|
||||
/// </summary>
|
||||
public double MinSimilarity { get; init; } = 0.6;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context from OpsMemory to enrich chat responses.
|
||||
/// </summary>
|
||||
public sealed record OpsMemoryContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets similar past decisions with their outcomes.
|
||||
/// </summary>
|
||||
public ImmutableArray<PastDecisionSummary> SimilarDecisions { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets relevant known issues from the corpus.
|
||||
/// </summary>
|
||||
public ImmutableArray<KnownIssue> RelevantKnownIssues { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets applicable tactics based on the situation.
|
||||
/// </summary>
|
||||
public ImmutableArray<Tactic> ApplicableTactics { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the generated prompt segment for the AI.
|
||||
/// </summary>
|
||||
public string? PromptSegment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of similar situations found.
|
||||
/// </summary>
|
||||
public int TotalSimilarCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether there are applicable playbook entries.
|
||||
/// </summary>
|
||||
public bool HasPlaybookEntries => SimilarDecisions.Length > 0 || ApplicableTactics.Length > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of a past decision for chat context.
|
||||
/// </summary>
|
||||
public sealed record PastDecisionSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the memory record ID.
|
||||
/// </summary>
|
||||
public required string MemoryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the CVE ID (if any).
|
||||
/// </summary>
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the component affected.
|
||||
/// </summary>
|
||||
public string? Component { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the severity at the time of decision.
|
||||
/// </summary>
|
||||
public string? Severity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the action that was taken.
|
||||
/// </summary>
|
||||
public required DecisionAction Action { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the rationale for the decision.
|
||||
/// </summary>
|
||||
public string? Rationale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the outcome status (if recorded).
|
||||
/// </summary>
|
||||
public OutcomeStatus? OutcomeStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the similarity score to the current situation (0-1).
|
||||
/// </summary>
|
||||
public double SimilarityScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the decision was made.
|
||||
/// </summary>
|
||||
public DateTimeOffset DecidedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets any lessons learned from the outcome.
|
||||
/// </summary>
|
||||
public string? LessonsLearned { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A known issue from the corpus.
|
||||
/// </summary>
|
||||
public sealed record KnownIssue
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the issue identifier.
|
||||
/// </summary>
|
||||
public required string IssueId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the issue title.
|
||||
/// </summary>
|
||||
public required string Title { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the issue description.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the recommended action.
|
||||
/// </summary>
|
||||
public string? RecommendedAction { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets relevance score (0-1).
|
||||
/// </summary>
|
||||
public double Relevance { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A playbook tactic applicable to the situation.
|
||||
/// </summary>
|
||||
public sealed record Tactic
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the tactic identifier.
|
||||
/// </summary>
|
||||
public required string TacticId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tactic name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tactic description.
|
||||
/// </summary>
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets applicability conditions.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Conditions { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the recommended action.
|
||||
/// </summary>
|
||||
public DecisionAction RecommendedAction { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets confidence score (0-1).
|
||||
/// </summary>
|
||||
public double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets success rate from past applications.
|
||||
/// </summary>
|
||||
public double? SuccessRate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of executing an action from chat.
|
||||
/// </summary>
|
||||
public sealed record ActionExecutionResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the action that was executed.
|
||||
/// </summary>
|
||||
public required DecisionAction Action { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the CVE ID affected.
|
||||
/// </summary>
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the component affected.
|
||||
/// </summary>
|
||||
public string? Component { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the action was successful.
|
||||
/// </summary>
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets any error message if failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the rationale provided by the user or AI.
|
||||
/// </summary>
|
||||
public string? Rationale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp of execution.
|
||||
/// </summary>
|
||||
public DateTimeOffset ExecutedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the user who triggered the action.
|
||||
/// </summary>
|
||||
public required string ActorId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets additional metadata about the action.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context of the conversation where the action was taken.
|
||||
/// </summary>
|
||||
public sealed record ConversationContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the conversation identifier.
|
||||
/// </summary>
|
||||
public required string ConversationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tenant identifier.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the user identifier.
|
||||
/// </summary>
|
||||
public required string UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the conversation summary/topic.
|
||||
/// </summary>
|
||||
public string? Topic { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the turn number where action was taken.
|
||||
/// </summary>
|
||||
public int TurnNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the situation context extracted from the conversation.
|
||||
/// </summary>
|
||||
public SituationContext? Situation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets any evidence links from the conversation.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> EvidenceLinks { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
// <copyright file="OpsMemoryChatProvider.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
using StellaOps.OpsMemory.Playbook;
|
||||
using StellaOps.OpsMemory.Similarity;
|
||||
using StellaOps.OpsMemory.Storage;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.OpsMemory.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of OpsMemory chat provider for AI integration.
|
||||
/// Provides context enrichment from past decisions and records new decisions from chat actions.
|
||||
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-002
|
||||
/// </summary>
|
||||
public sealed class OpsMemoryChatProvider : IOpsMemoryChatProvider
|
||||
{
|
||||
private readonly IOpsMemoryStore _store;
|
||||
private readonly ISimilarityVectorGenerator _vectorGenerator;
|
||||
private readonly IPlaybookSuggestionService _playbookService;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<OpsMemoryChatProvider> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new OpsMemoryChatProvider.
|
||||
/// </summary>
|
||||
public OpsMemoryChatProvider(
|
||||
IOpsMemoryStore store,
|
||||
ISimilarityVectorGenerator vectorGenerator,
|
||||
IPlaybookSuggestionService playbookService,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<OpsMemoryChatProvider> logger)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_vectorGenerator = vectorGenerator ?? throw new ArgumentNullException(nameof(vectorGenerator));
|
||||
_playbookService = playbookService ?? throw new ArgumentNullException(nameof(playbookService));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OpsMemoryContext> EnrichContextAsync(
|
||||
ChatContextRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Enriching chat context for tenant {TenantId}, CVE {CveId}",
|
||||
request.TenantId, request.CveId ?? "(none)");
|
||||
|
||||
// Build situation from request
|
||||
var situation = BuildSituation(request);
|
||||
|
||||
// Generate similarity vector for the current situation
|
||||
var queryVector = _vectorGenerator.Generate(situation);
|
||||
|
||||
// Find similar past decisions
|
||||
var similarQuery = new SimilarityQuery
|
||||
{
|
||||
TenantId = request.TenantId,
|
||||
SimilarityVector = queryVector,
|
||||
Situation = situation,
|
||||
MinSimilarity = request.MinSimilarity,
|
||||
Limit = request.MaxSuggestions * 2 // Fetch more to filter by outcome
|
||||
};
|
||||
|
||||
var similarRecords = await _store.FindSimilarAsync(similarQuery, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Convert to summaries with similarity scores, filtering out failed outcomes
|
||||
var summaries = similarRecords
|
||||
.Select(r => CreateSummary(r.Record, r.SimilarityScore))
|
||||
.Where(s => s.OutcomeStatus is null or OutcomeStatus.Success or OutcomeStatus.PartialSuccess)
|
||||
.OrderByDescending(s => s.SimilarityScore)
|
||||
.ThenByDescending(s => s.OutcomeStatus == OutcomeStatus.Success ? 1 : 0)
|
||||
.Take(request.MaxSuggestions)
|
||||
.ToImmutableArray();
|
||||
|
||||
// Get applicable tactics from playbook
|
||||
var tactics = await GetApplicableTacticsAsync(situation, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Get known issues if CVE is provided
|
||||
var knownIssues = request.CveId is not null
|
||||
? await GetKnownIssuesAsync(request.CveId, cancellationToken).ConfigureAwait(false)
|
||||
: ImmutableArray<KnownIssue>.Empty;
|
||||
|
||||
// Build prompt segment for AI
|
||||
var promptSegment = BuildPromptSegment(summaries, tactics, knownIssues);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Found {DecisionCount} similar decisions, {TacticCount} applicable tactics for {CveId}",
|
||||
summaries.Length, tactics.Length, request.CveId ?? "(no CVE)");
|
||||
|
||||
return new OpsMemoryContext
|
||||
{
|
||||
SimilarDecisions = summaries,
|
||||
ApplicableTactics = tactics,
|
||||
RelevantKnownIssues = knownIssues,
|
||||
PromptSegment = promptSegment,
|
||||
TotalSimilarCount = similarRecords.Count
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OpsMemoryRecord> RecordFromActionAsync(
|
||||
ActionExecutionResult action,
|
||||
ConversationContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Recording decision from chat action: {Action} for CVE {CveId}",
|
||||
action.Action, action.CveId ?? "(none)");
|
||||
|
||||
// Build situation from conversation context
|
||||
var situation = context.Situation ?? BuildSituationFromAction(action);
|
||||
|
||||
// Generate similarity vector
|
||||
var vector = _vectorGenerator.Generate(situation);
|
||||
|
||||
// Create memory record
|
||||
var memoryId = GenerateMemoryId();
|
||||
var record = new OpsMemoryRecord
|
||||
{
|
||||
MemoryId = memoryId,
|
||||
TenantId = context.TenantId,
|
||||
RecordedAt = _timeProvider.GetUtcNow(),
|
||||
Situation = situation,
|
||||
Decision = new DecisionRecord
|
||||
{
|
||||
Action = action.Action,
|
||||
Rationale = action.Rationale ?? $"Decision made via chat conversation {context.ConversationId}",
|
||||
DecidedBy = action.ActorId,
|
||||
DecidedAt = action.ExecutedAt,
|
||||
PolicyReference = null, // Not from policy gate
|
||||
VexStatementId = null,
|
||||
Mitigation = null
|
||||
},
|
||||
Outcome = null, // Outcome tracked separately
|
||||
SimilarityVector = vector
|
||||
};
|
||||
|
||||
// Store the record
|
||||
await _store.RecordDecisionAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Recorded decision {MemoryId} from chat conversation {ConversationId}",
|
||||
memoryId, context.ConversationId);
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PastDecisionSummary>> GetRecentDecisionsAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var query = new OpsMemoryQuery
|
||||
{
|
||||
TenantId = tenantId,
|
||||
PageSize = limit,
|
||||
SortBy = OpsMemorySortField.RecordedAt,
|
||||
Descending = true
|
||||
};
|
||||
|
||||
var result = await _store.QueryAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return result.Items
|
||||
.Select(r => CreateSummary(r, 0))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static SituationContext BuildSituation(ChatContextRequest request)
|
||||
{
|
||||
return new SituationContext
|
||||
{
|
||||
CveId = request.CveId,
|
||||
Component = request.Component,
|
||||
Severity = request.Severity,
|
||||
Reachability = request.Reachability ?? ReachabilityStatus.Unknown,
|
||||
CvssScore = request.CvssScore,
|
||||
EpssScore = request.EpssScore,
|
||||
ContextTags = request.ContextTags
|
||||
};
|
||||
}
|
||||
|
||||
private static SituationContext BuildSituationFromAction(ActionExecutionResult action)
|
||||
{
|
||||
return new SituationContext
|
||||
{
|
||||
CveId = action.CveId,
|
||||
Component = action.Component,
|
||||
Reachability = ReachabilityStatus.Unknown,
|
||||
ContextTags = action.Metadata.Keys
|
||||
.Where(k => k.StartsWith("tag:", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(k => k[4..])
|
||||
.ToImmutableArray()
|
||||
};
|
||||
}
|
||||
|
||||
private static PastDecisionSummary CreateSummary(OpsMemoryRecord record, double similarityScore)
|
||||
{
|
||||
return new PastDecisionSummary
|
||||
{
|
||||
MemoryId = record.MemoryId,
|
||||
CveId = record.Situation.CveId,
|
||||
Component = record.Situation.Component ?? record.Situation.ComponentName,
|
||||
Severity = record.Situation.Severity,
|
||||
Action = record.Decision.Action,
|
||||
Rationale = record.Decision.Rationale,
|
||||
OutcomeStatus = record.Outcome?.Status,
|
||||
SimilarityScore = similarityScore,
|
||||
DecidedAt = record.Decision.DecidedAt,
|
||||
LessonsLearned = record.Outcome?.LessonsLearned
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<ImmutableArray<Tactic>> GetApplicableTacticsAsync(
|
||||
SituationContext situation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var suggestions = await _playbookService.GetSuggestionsAsync(
|
||||
situation,
|
||||
maxSuggestions: 3,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return suggestions
|
||||
.Select(s => new Tactic
|
||||
{
|
||||
TacticId = $"tactic-{s.Action}",
|
||||
Name = s.Action.ToString(),
|
||||
Description = s.Rationale,
|
||||
Conditions = s.MatchingFactors,
|
||||
RecommendedAction = s.Action,
|
||||
Confidence = s.Confidence,
|
||||
SuccessRate = s.SuccessRate
|
||||
})
|
||||
.ToImmutableArray();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get playbook suggestions");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private Task<ImmutableArray<KnownIssue>> GetKnownIssuesAsync(
|
||||
string cveId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// This would integrate with a known issues database
|
||||
// For now, return empty - the actual implementation would query a separate store
|
||||
_ = cancellationToken;
|
||||
_logger.LogDebug("Getting known issues for {CveId}", cveId);
|
||||
return Task.FromResult(ImmutableArray<KnownIssue>.Empty);
|
||||
}
|
||||
|
||||
private static string BuildPromptSegment(
|
||||
ImmutableArray<PastDecisionSummary> decisions,
|
||||
ImmutableArray<Tactic> tactics,
|
||||
ImmutableArray<KnownIssue> issues)
|
||||
{
|
||||
if (decisions.Length == 0 && tactics.Length == 0 && issues.Length == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("## Previous Similar Situations (from OpsMemory)");
|
||||
sb.AppendLine();
|
||||
|
||||
if (decisions.Length > 0)
|
||||
{
|
||||
sb.AppendLine("### Past Decisions");
|
||||
foreach (var decision in decisions)
|
||||
{
|
||||
var outcomeEmoji = decision.OutcomeStatus switch
|
||||
{
|
||||
OutcomeStatus.Success => "[SUCCESS]",
|
||||
OutcomeStatus.Failure => "[FAILED]",
|
||||
OutcomeStatus.PartialSuccess => "[PARTIAL]",
|
||||
_ => "[PENDING]"
|
||||
};
|
||||
|
||||
sb.AppendLine(CultureInfo.InvariantCulture,
|
||||
$"- {decision.CveId ?? "Unknown CVE"} ({decision.Severity ?? "?"} severity): " +
|
||||
$"**{decision.Action}** {outcomeEmoji}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(decision.Rationale))
|
||||
{
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $" Rationale: {decision.Rationale}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(decision.LessonsLearned))
|
||||
{
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $" Lessons: {decision.LessonsLearned}");
|
||||
}
|
||||
|
||||
sb.AppendLine(CultureInfo.InvariantCulture,
|
||||
$" Reference: [ops-mem:{decision.MemoryId}]");
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (tactics.Length > 0)
|
||||
{
|
||||
sb.AppendLine("### Applicable Playbook Tactics");
|
||||
foreach (var tactic in tactics)
|
||||
{
|
||||
var successRate = tactic.SuccessRate.HasValue
|
||||
? $" ({tactic.SuccessRate.Value:P0} success rate)"
|
||||
: "";
|
||||
|
||||
sb.AppendLine(CultureInfo.InvariantCulture,
|
||||
$"- **{tactic.Name}**: {tactic.Description}{successRate}");
|
||||
|
||||
if (tactic.Conditions.Length > 0)
|
||||
{
|
||||
sb.AppendLine(CultureInfo.InvariantCulture,
|
||||
$" When: {string.Join(", ", tactic.Conditions)}");
|
||||
}
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (issues.Length > 0)
|
||||
{
|
||||
sb.AppendLine("### Known Issues");
|
||||
foreach (var issue in issues)
|
||||
{
|
||||
sb.AppendLine(CultureInfo.InvariantCulture,
|
||||
$"- **{issue.Title}**: {issue.Description}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(issue.RecommendedAction))
|
||||
{
|
||||
sb.AppendLine(CultureInfo.InvariantCulture,
|
||||
$" Recommended: {issue.RecommendedAction}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private string GenerateMemoryId()
|
||||
{
|
||||
var timestamp = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
var random = Random.Shared.Next(1000, 9999);
|
||||
return $"om-chat-{timestamp}-{random}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
// <copyright file="OpsMemoryContextEnricher.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.OpsMemory.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Enriches AI prompt context with OpsMemory data.
|
||||
/// Generates structured prompt segments for past decisions and playbook tactics.
|
||||
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-003
|
||||
/// </summary>
|
||||
public sealed class OpsMemoryContextEnricher
|
||||
{
|
||||
private readonly IOpsMemoryChatProvider _chatProvider;
|
||||
private readonly ILogger<OpsMemoryContextEnricher> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new OpsMemoryContextEnricher.
|
||||
/// </summary>
|
||||
public OpsMemoryContextEnricher(
|
||||
IOpsMemoryChatProvider chatProvider,
|
||||
ILogger<OpsMemoryContextEnricher> logger)
|
||||
{
|
||||
_chatProvider = chatProvider ?? throw new ArgumentNullException(nameof(chatProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enriches a chat prompt with OpsMemory context.
|
||||
/// </summary>
|
||||
/// <param name="request">The context request.</param>
|
||||
/// <param name="existingPrompt">Optional existing prompt to augment.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Enriched prompt with OpsMemory context.</returns>
|
||||
public async Task<EnrichedPromptResult> EnrichPromptAsync(
|
||||
ChatContextRequest request,
|
||||
string? existingPrompt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Enriching prompt with OpsMemory for CVE {CveId}",
|
||||
request.CveId ?? "(none)");
|
||||
|
||||
var context = await _chatProvider.EnrichContextAsync(request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var systemPromptAddition = BuildSystemPromptAddition(context);
|
||||
var contextBlock = BuildContextBlock(context);
|
||||
|
||||
var enrichedPrompt = string.IsNullOrWhiteSpace(existingPrompt)
|
||||
? contextBlock
|
||||
: $"{existingPrompt}\n\n{contextBlock}";
|
||||
|
||||
return new EnrichedPromptResult
|
||||
{
|
||||
EnrichedPrompt = enrichedPrompt,
|
||||
SystemPromptAddition = systemPromptAddition,
|
||||
Context = context,
|
||||
DecisionsReferenced = context.SimilarDecisions.Select(d => d.MemoryId).ToImmutableArray(),
|
||||
TacticsApplied = context.ApplicableTactics.Select(t => t.TacticId).ToImmutableArray()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a system prompt addition for OpsMemory-aware responses.
|
||||
/// </summary>
|
||||
public static string BuildSystemPromptAddition(OpsMemoryContext context)
|
||||
{
|
||||
if (!context.HasPlaybookEntries)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("## OpsMemory Instructions");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("You have access to the organization's institutional decision memory (OpsMemory).");
|
||||
sb.AppendLine("When providing recommendations:");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("1. Reference past decisions using `[ops-mem:ID]` format");
|
||||
sb.AppendLine("2. Explain how past outcomes inform current recommendations");
|
||||
sb.AppendLine("3. Note any lessons learned from similar situations");
|
||||
sb.AppendLine("4. If a past approach failed, suggest alternatives");
|
||||
sb.AppendLine("5. Include confidence levels based on historical success rates");
|
||||
sb.AppendLine();
|
||||
|
||||
if (context.SimilarDecisions.Length > 0)
|
||||
{
|
||||
sb.AppendLine($"Available past decisions: {context.SimilarDecisions.Length} similar situations found.");
|
||||
}
|
||||
|
||||
if (context.ApplicableTactics.Length > 0)
|
||||
{
|
||||
sb.AppendLine($"Applicable playbook tactics: {context.ApplicableTactics.Length} tactics available.");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the context block to include in the prompt.
|
||||
/// </summary>
|
||||
public static string BuildContextBlock(OpsMemoryContext context)
|
||||
{
|
||||
if (!context.HasPlaybookEntries)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("---");
|
||||
sb.AppendLine("## Institutional Memory (OpsMemory)");
|
||||
sb.AppendLine();
|
||||
|
||||
// Past decisions section
|
||||
if (context.SimilarDecisions.Length > 0)
|
||||
{
|
||||
sb.AppendLine("### Similar Past Decisions");
|
||||
sb.AppendLine();
|
||||
|
||||
foreach (var decision in context.SimilarDecisions)
|
||||
{
|
||||
FormatDecisionSummary(sb, decision);
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// Applicable tactics section
|
||||
if (context.ApplicableTactics.Length > 0)
|
||||
{
|
||||
sb.AppendLine("### Playbook Tactics");
|
||||
sb.AppendLine();
|
||||
|
||||
foreach (var tactic in context.ApplicableTactics)
|
||||
{
|
||||
FormatTactic(sb, tactic);
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// Known issues section
|
||||
if (context.RelevantKnownIssues.Length > 0)
|
||||
{
|
||||
sb.AppendLine("### Known Issues");
|
||||
sb.AppendLine();
|
||||
|
||||
foreach (var issue in context.RelevantKnownIssues)
|
||||
{
|
||||
FormatKnownIssue(sb, issue);
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
sb.AppendLine("---");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static void FormatDecisionSummary(StringBuilder sb, PastDecisionSummary decision)
|
||||
{
|
||||
var outcomeIndicator = decision.OutcomeStatus switch
|
||||
{
|
||||
OutcomeStatus.Success => "[SUCCESS]",
|
||||
OutcomeStatus.Failure => "[FAILED]",
|
||||
OutcomeStatus.PartialSuccess => "[PARTIAL]",
|
||||
_ => "[PENDING]"
|
||||
};
|
||||
|
||||
var similarity = decision.SimilarityScore.ToString("P0", CultureInfo.InvariantCulture);
|
||||
|
||||
sb.AppendLine(CultureInfo.InvariantCulture,
|
||||
$"#### {decision.CveId ?? "Unknown"} - {decision.Action} {outcomeIndicator}");
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"- **Similarity:** {similarity}");
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"- **Component:** {decision.Component ?? "Unknown"}");
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"- **Severity:** {decision.Severity ?? "?"}");
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"- **Date:** {decision.DecidedAt:yyyy-MM-dd}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(decision.Rationale))
|
||||
{
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"- **Rationale:** {decision.Rationale}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(decision.LessonsLearned))
|
||||
{
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"- **Lessons:** {decision.LessonsLearned}");
|
||||
}
|
||||
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"- **Reference:** `[ops-mem:{decision.MemoryId}]`");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
private static void FormatTactic(StringBuilder sb, Tactic tactic)
|
||||
{
|
||||
var confidence = tactic.Confidence.ToString("P0", CultureInfo.InvariantCulture);
|
||||
var successRate = tactic.SuccessRate?.ToString("P0", CultureInfo.InvariantCulture) ?? "N/A";
|
||||
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"#### {tactic.Name}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"{tactic.Description}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"- **Recommended Action:** {tactic.RecommendedAction}");
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"- **Confidence:** {confidence}");
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"- **Historical Success Rate:** {successRate}");
|
||||
|
||||
if (tactic.Conditions.Length > 0)
|
||||
{
|
||||
sb.AppendLine(CultureInfo.InvariantCulture,
|
||||
$"- **Conditions:** {string.Join(", ", tactic.Conditions)}");
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
private static void FormatKnownIssue(StringBuilder sb, KnownIssue issue)
|
||||
{
|
||||
var relevance = issue.Relevance.ToString("P0", CultureInfo.InvariantCulture);
|
||||
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"#### {issue.Title} ({relevance} relevant)");
|
||||
sb.AppendLine();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(issue.Description))
|
||||
{
|
||||
sb.AppendLine(issue.Description);
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(issue.RecommendedAction))
|
||||
{
|
||||
sb.AppendLine(CultureInfo.InvariantCulture, $"**Recommended:** {issue.RecommendedAction}");
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of prompt enrichment with OpsMemory context.
|
||||
/// </summary>
|
||||
public sealed record EnrichedPromptResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the enriched prompt with OpsMemory context.
|
||||
/// </summary>
|
||||
public required string EnrichedPrompt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets additional content for the system prompt.
|
||||
/// </summary>
|
||||
public string? SystemPromptAddition { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the full OpsMemory context used for enrichment.
|
||||
/// </summary>
|
||||
public required OpsMemoryContext Context { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the memory IDs of decisions referenced.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> DecisionsReferenced { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tactic IDs applied.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> TacticsApplied { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether any OpsMemory context was added.
|
||||
/// </summary>
|
||||
public bool HasEnrichment => Context.HasPlaybookEntries;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// <copyright file="OpsMemoryDecisionHook.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Services;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
using StellaOps.OpsMemory.Similarity;
|
||||
using StellaOps.OpsMemory.Storage;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.OpsMemory.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Decision hook that records Findings decisions to OpsMemory for playbook learning.
|
||||
/// Sprint: SPRINT_20260107_006_004 Task: OM-007
|
||||
/// </summary>
|
||||
public sealed class OpsMemoryDecisionHook : IDecisionHook
|
||||
{
|
||||
private readonly IOpsMemoryStore _store;
|
||||
private readonly ISimilarityVectorGenerator _vectorGenerator;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<OpsMemoryDecisionHook> _logger;
|
||||
|
||||
public OpsMemoryDecisionHook(
|
||||
IOpsMemoryStore store,
|
||||
ISimilarityVectorGenerator vectorGenerator,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<OpsMemoryDecisionHook> logger)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_vectorGenerator = vectorGenerator ?? throw new ArgumentNullException(nameof(vectorGenerator));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task OnDecisionRecordedAsync(
|
||||
DecisionEvent decision,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Recording decision {DecisionId} to OpsMemory for tenant {TenantId}",
|
||||
decision.Id, tenantId);
|
||||
|
||||
try
|
||||
{
|
||||
// Extract situation context from the decision
|
||||
var situation = ExtractSituation(decision);
|
||||
|
||||
// Generate similarity vector for future matching
|
||||
var vector = _vectorGenerator.Generate(situation);
|
||||
|
||||
// Map decision to OpsMemory record
|
||||
var record = new OpsMemoryRecord
|
||||
{
|
||||
MemoryId = $"om-{decision.Id}",
|
||||
TenantId = tenantId,
|
||||
RecordedAt = _timeProvider.GetUtcNow(),
|
||||
Situation = situation,
|
||||
Decision = new DecisionRecord
|
||||
{
|
||||
Action = MapDecisionAction(decision.DecisionStatus),
|
||||
Rationale = BuildRationale(decision),
|
||||
DecidedBy = decision.ActorId,
|
||||
DecidedAt = decision.Timestamp,
|
||||
PolicyReference = decision.PolicyContext,
|
||||
VexStatementId = null, // Would be extracted from evidence if available
|
||||
Mitigation = null
|
||||
},
|
||||
Outcome = null, // Outcome recorded later via OutcomeTrackingService
|
||||
SimilarityVector = vector
|
||||
};
|
||||
|
||||
await _store.RecordDecisionAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Decision {DecisionId} recorded to OpsMemory as {MemoryId}",
|
||||
decision.Id, record.MemoryId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log but don't throw - this is fire-and-forget
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to record decision {DecisionId} to OpsMemory: {Message}",
|
||||
decision.Id, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts situation context from a decision event.
|
||||
/// </summary>
|
||||
private static SituationContext ExtractSituation(DecisionEvent decision)
|
||||
{
|
||||
// Parse alert ID format: tenant|artifact|vuln or similar
|
||||
var parts = decision.AlertId.Split('|');
|
||||
var vulnId = parts.Length > 2 ? parts[2] : null;
|
||||
|
||||
// Extract CVE from vulnerability ID if present
|
||||
string? cveId = null;
|
||||
if (vulnId?.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
cveId = vulnId;
|
||||
}
|
||||
|
||||
return new SituationContext
|
||||
{
|
||||
CveId = cveId,
|
||||
Component = null, // Would be extracted from evidence bundle
|
||||
ComponentName = null,
|
||||
ComponentVersion = null,
|
||||
Severity = null, // Would be extracted from finding data
|
||||
CvssScore = null,
|
||||
Reachability = ReachabilityStatus.Unknown,
|
||||
EpssScore = null,
|
||||
IsKev = false,
|
||||
ContextTags = ImmutableArray<string>.Empty,
|
||||
AdditionalContext = ImmutableDictionary<string, string>.Empty
|
||||
.Add("artifact_id", decision.ArtifactId)
|
||||
.Add("alert_id", decision.AlertId)
|
||||
.Add("reason_code", decision.ReasonCode)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps decision status to OpsMemory decision action.
|
||||
/// </summary>
|
||||
private static DecisionAction MapDecisionAction(string decisionStatus)
|
||||
{
|
||||
return decisionStatus.ToLowerInvariant() switch
|
||||
{
|
||||
"affected" => DecisionAction.Remediate,
|
||||
"not_affected" => DecisionAction.Accept,
|
||||
"under_investigation" => DecisionAction.Defer,
|
||||
_ => DecisionAction.Defer
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a rationale string from decision data.
|
||||
/// </summary>
|
||||
private static string BuildRationale(DecisionEvent decision)
|
||||
{
|
||||
var parts = new List<string>
|
||||
{
|
||||
$"Status: {decision.DecisionStatus}",
|
||||
$"Reason: {decision.ReasonCode}"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(decision.ReasonText))
|
||||
{
|
||||
parts.Add($"Details: {decision.ReasonText}");
|
||||
}
|
||||
|
||||
return string.Join("; ", parts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
// <copyright file="OpsMemoryRecord.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.OpsMemory.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A structured record of a security decision and its outcome for playbook learning.
|
||||
/// Sprint: SPRINT_20260107_006_004 Task OM-001
|
||||
/// </summary>
|
||||
public sealed record OpsMemoryRecord
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the unique memory record identifier.
|
||||
/// </summary>
|
||||
public required string MemoryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tenant identifier for multi-tenancy isolation.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp when this record was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset RecordedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the situation context at the time of decision.
|
||||
/// </summary>
|
||||
public required SituationContext Situation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the decision that was made.
|
||||
/// </summary>
|
||||
public required DecisionRecord Decision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the outcome of the decision (if recorded).
|
||||
/// </summary>
|
||||
public OutcomeRecord? Outcome { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the similarity vector for finding related decisions.
|
||||
/// </summary>
|
||||
public ImmutableArray<float> SimilarityVector { get; init; } = ImmutableArray<float>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this record has an outcome recorded.
|
||||
/// </summary>
|
||||
public bool HasOutcome => Outcome is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this decision was successful.
|
||||
/// </summary>
|
||||
public bool WasSuccessful => Outcome?.Status == OutcomeStatus.Success;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The security context at the time a decision was made.
|
||||
/// </summary>
|
||||
public sealed record SituationContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the CVE identifier (if applicable).
|
||||
/// </summary>
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the component PURL.
|
||||
/// </summary>
|
||||
public string? Component { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the component name (for display).
|
||||
/// </summary>
|
||||
public string? ComponentName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the component version.
|
||||
/// </summary>
|
||||
public string? ComponentVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the severity level.
|
||||
/// </summary>
|
||||
public string? Severity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the CVSS score (0-10).
|
||||
/// </summary>
|
||||
public double? CvssScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the reachability status.
|
||||
/// </summary>
|
||||
public ReachabilityStatus Reachability { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the EPSS score (0-1).
|
||||
/// </summary>
|
||||
public double? EpssScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the KEV (Known Exploited Vulnerability) status.
|
||||
/// </summary>
|
||||
public bool IsKev { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the context tags (environment, service, team, etc.).
|
||||
/// </summary>
|
||||
public ImmutableArray<string> ContextTags { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets additional context fields.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> AdditionalContext { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reachability status for a vulnerability.
|
||||
/// </summary>
|
||||
public enum ReachabilityStatus
|
||||
{
|
||||
/// <summary>Not analyzed.</summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>Confirmed reachable from entry point.</summary>
|
||||
Reachable,
|
||||
|
||||
/// <summary>Analyzed and determined not reachable.</summary>
|
||||
NotReachable,
|
||||
|
||||
/// <summary>Potentially reachable (inconclusive analysis).</summary>
|
||||
Potential
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A security decision that was made.
|
||||
/// </summary>
|
||||
public sealed record DecisionRecord
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the action taken.
|
||||
/// </summary>
|
||||
public required DecisionAction Action { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the human-readable rationale.
|
||||
/// </summary>
|
||||
public required string Rationale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the identity of the decision maker.
|
||||
/// </summary>
|
||||
public required string DecidedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp of the decision.
|
||||
/// </summary>
|
||||
public required DateTimeOffset DecidedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the policy reference that guided this decision.
|
||||
/// </summary>
|
||||
public string? PolicyReference { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the VEX statement ID (if one was created).
|
||||
/// </summary>
|
||||
public string? VexStatementId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets any mitigation details.
|
||||
/// </summary>
|
||||
public MitigationDetails? Mitigation { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Actions that can be taken for a security finding.
|
||||
/// </summary>
|
||||
public enum DecisionAction
|
||||
{
|
||||
/// <summary>Accept the risk (no action).</summary>
|
||||
Accept,
|
||||
|
||||
/// <summary>Remediate (upgrade/patch).</summary>
|
||||
Remediate,
|
||||
|
||||
/// <summary>Quarantine/isolate the component.</summary>
|
||||
Quarantine,
|
||||
|
||||
/// <summary>Apply mitigation (WAF, config, etc.).</summary>
|
||||
Mitigate,
|
||||
|
||||
/// <summary>Defer for later review.</summary>
|
||||
Defer,
|
||||
|
||||
/// <summary>Escalate to security team.</summary>
|
||||
Escalate,
|
||||
|
||||
/// <summary>False positive - not applicable.</summary>
|
||||
FalsePositive
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Details about a mitigation applied.
|
||||
/// </summary>
|
||||
public sealed record MitigationDetails
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the type of mitigation.
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the description of the mitigation.
|
||||
/// </summary>
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the mitigation effectiveness (0-1).
|
||||
/// </summary>
|
||||
public double? Effectiveness { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the expiration date for the mitigation.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The outcome of a security decision.
|
||||
/// </summary>
|
||||
public sealed record OutcomeRecord
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the outcome status.
|
||||
/// </summary>
|
||||
public required OutcomeStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the time to resolution.
|
||||
/// </summary>
|
||||
public TimeSpan? ResolutionTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the actual impact experienced.
|
||||
/// </summary>
|
||||
public string? ActualImpact { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets lessons learned from this decision.
|
||||
/// </summary>
|
||||
public string? LessonsLearned { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets who recorded the outcome.
|
||||
/// </summary>
|
||||
public required string RecordedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the outcome was recorded.
|
||||
/// </summary>
|
||||
public required DateTimeOffset RecordedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the original decision would be made again.
|
||||
/// </summary>
|
||||
public bool? WouldRepeat { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets alternative actions that might have been better.
|
||||
/// </summary>
|
||||
public string? AlternativeActions { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outcome status for a decision.
|
||||
/// </summary>
|
||||
public enum OutcomeStatus
|
||||
{
|
||||
/// <summary>Decision led to successful resolution.</summary>
|
||||
Success,
|
||||
|
||||
/// <summary>Decision led to partial resolution.</summary>
|
||||
PartialSuccess,
|
||||
|
||||
/// <summary>Decision was ineffective.</summary>
|
||||
Ineffective,
|
||||
|
||||
/// <summary>Decision led to negative consequences.</summary>
|
||||
NegativeOutcome,
|
||||
|
||||
/// <summary>Outcome is still pending.</summary>
|
||||
Pending,
|
||||
|
||||
/// <summary>Decision failed to execute.</summary>
|
||||
Failure
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// <copyright file="IPlaybookSuggestionService.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using StellaOps.OpsMemory.Models;
|
||||
|
||||
namespace StellaOps.OpsMemory.Playbook;
|
||||
|
||||
/// <summary>
|
||||
/// Service for generating playbook suggestions based on past decisions.
|
||||
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-002 (extracted interface)
|
||||
/// </summary>
|
||||
public interface IPlaybookSuggestionService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets playbook suggestions for a given situation.
|
||||
/// </summary>
|
||||
/// <param name="request">The suggestion request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Playbook suggestions ordered by confidence.</returns>
|
||||
Task<PlaybookSuggestionResult> GetSuggestionsAsync(
|
||||
PlaybookSuggestionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets playbook suggestions for a situation context.
|
||||
/// </summary>
|
||||
/// <param name="situation">The situation to analyze.</param>
|
||||
/// <param name="maxSuggestions">Maximum suggestions to return.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Playbook suggestions.</returns>
|
||||
Task<IReadOnlyList<PlaybookSuggestion>> GetSuggestionsAsync(
|
||||
SituationContext situation,
|
||||
int maxSuggestions,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
// <copyright file="PlaybookSuggestionService.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
using StellaOps.OpsMemory.Similarity;
|
||||
using StellaOps.OpsMemory.Storage;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.OpsMemory.Playbook;
|
||||
|
||||
/// <summary>
|
||||
/// Service for generating playbook suggestions based on past decisions.
|
||||
/// Sprint: SPRINT_20260107_006_004 Task OM-005
|
||||
/// </summary>
|
||||
public sealed class PlaybookSuggestionService : IPlaybookSuggestionService
|
||||
{
|
||||
private readonly IOpsMemoryStore _store;
|
||||
private readonly SimilarityVectorGenerator _vectorGenerator;
|
||||
private readonly ILogger<PlaybookSuggestionService> _logger;
|
||||
|
||||
private const int DefaultTopK = 10;
|
||||
private const int MaxSuggestions = 3;
|
||||
private const double MinConfidence = 0.5;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PlaybookSuggestionService"/> class.
|
||||
/// </summary>
|
||||
public PlaybookSuggestionService(
|
||||
IOpsMemoryStore store,
|
||||
SimilarityVectorGenerator vectorGenerator,
|
||||
ILogger<PlaybookSuggestionService> logger)
|
||||
{
|
||||
_store = store;
|
||||
_vectorGenerator = vectorGenerator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets playbook suggestions for a given situation.
|
||||
/// </summary>
|
||||
/// <param name="request">The suggestion request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Playbook suggestions ordered by confidence.</returns>
|
||||
public async Task<PlaybookSuggestionResult> GetSuggestionsAsync(
|
||||
PlaybookSuggestionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Generating playbook suggestions for CVE {CveId}, component {Component}",
|
||||
request.Situation.CveId,
|
||||
request.Situation.Component);
|
||||
|
||||
// Generate similarity vector for current situation
|
||||
var situationVector = _vectorGenerator.Generate(request.Situation);
|
||||
|
||||
// Query similar situations with successful outcomes
|
||||
var query = new SimilarityQuery
|
||||
{
|
||||
TenantId = request.TenantId,
|
||||
SimilarityVector = situationVector,
|
||||
Limit = request.TopK ?? DefaultTopK,
|
||||
MinSimilarity = request.MinSimilarity ?? 0.6,
|
||||
OnlyWithOutcome = true,
|
||||
OnlySuccessful = true,
|
||||
Since = request.Since
|
||||
};
|
||||
|
||||
var similarRecords = await _store.FindSimilarAsync(query, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (similarRecords.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No similar successful decisions found");
|
||||
return new PlaybookSuggestionResult
|
||||
{
|
||||
Suggestions = ImmutableArray<PlaybookSuggestion>.Empty,
|
||||
AnalyzedRecords = 0
|
||||
};
|
||||
}
|
||||
|
||||
// Group by action and rank by success rate and similarity
|
||||
var suggestions = GroupAndRankSuggestions(
|
||||
request.Situation,
|
||||
similarRecords,
|
||||
request.MaxSuggestions ?? MaxSuggestions);
|
||||
|
||||
return new PlaybookSuggestionResult
|
||||
{
|
||||
Suggestions = suggestions,
|
||||
AnalyzedRecords = similarRecords.Count,
|
||||
TopSimilarity = similarRecords.Max(r => r.SimilarityScore)
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PlaybookSuggestion>> GetSuggestionsAsync(
|
||||
SituationContext situation,
|
||||
int maxSuggestions,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Create a default request with tenant placeholder
|
||||
// In real use, the tenant would be extracted from context
|
||||
var request = new PlaybookSuggestionRequest
|
||||
{
|
||||
TenantId = "default",
|
||||
Situation = situation,
|
||||
MaxSuggestions = maxSuggestions
|
||||
};
|
||||
|
||||
var result = await GetSuggestionsAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return result.Suggestions;
|
||||
}
|
||||
|
||||
private ImmutableArray<PlaybookSuggestion> GroupAndRankSuggestions(
|
||||
SituationContext currentSituation,
|
||||
IReadOnlyList<SimilarityMatch> similarRecords,
|
||||
int maxSuggestions)
|
||||
{
|
||||
// Group by action
|
||||
var byAction = similarRecords
|
||||
.GroupBy(r => r.Record.Decision.Action)
|
||||
.Select(g => new
|
||||
{
|
||||
Action = g.Key,
|
||||
Records = g.ToList(),
|
||||
AvgSimilarity = g.Average(r => r.SimilarityScore),
|
||||
SuccessCount = g.Count(r => r.Record.Outcome?.Status == OutcomeStatus.Success),
|
||||
TotalCount = g.Count()
|
||||
})
|
||||
.OrderByDescending(g => g.SuccessCount)
|
||||
.ThenByDescending(g => g.AvgSimilarity)
|
||||
.Take(maxSuggestions)
|
||||
.ToList();
|
||||
|
||||
var suggestions = new List<PlaybookSuggestion>();
|
||||
|
||||
foreach (var group in byAction)
|
||||
{
|
||||
var confidence = CalculateConfidence(
|
||||
group.SuccessCount,
|
||||
group.TotalCount,
|
||||
group.AvgSimilarity);
|
||||
|
||||
if (confidence < MinConfidence)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the best example for this action
|
||||
var bestExample = group.Records
|
||||
.OrderByDescending(r => r.SimilarityScore)
|
||||
.First();
|
||||
|
||||
// Get matching factors
|
||||
var factors = _vectorGenerator.GetMatchingFactors(
|
||||
currentSituation,
|
||||
bestExample.Record.Situation);
|
||||
|
||||
// Build rationale from past decisions
|
||||
var rationales = group.Records
|
||||
.Select(r => r.Record.Decision.Rationale)
|
||||
.Distinct()
|
||||
.Take(3)
|
||||
.ToList();
|
||||
|
||||
suggestions.Add(new PlaybookSuggestion
|
||||
{
|
||||
Action = group.Action,
|
||||
Confidence = confidence,
|
||||
Rationale = BuildRationale(group.Action, rationales, factors),
|
||||
Evidence = BuildEvidence(group.Records),
|
||||
MatchingFactors = factors,
|
||||
SimilarDecisionCount = group.TotalCount,
|
||||
SuccessRate = (double)group.SuccessCount / group.TotalCount,
|
||||
BestMatchMemoryId = bestExample.Record.MemoryId,
|
||||
AverageResolutionTime = CalculateAverageResolutionTime(group.Records)
|
||||
});
|
||||
}
|
||||
|
||||
return suggestions
|
||||
.OrderByDescending(s => s.Confidence)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static double CalculateConfidence(int successCount, int totalCount, double avgSimilarity)
|
||||
{
|
||||
// Confidence formula:
|
||||
// - Base: success rate * similarity
|
||||
// - Boost for more data points (log scale)
|
||||
var successRate = (double)successCount / totalCount;
|
||||
var dataBoost = Math.Min(1.0, Math.Log10(totalCount + 1) / 2);
|
||||
|
||||
return (successRate * 0.5 + avgSimilarity * 0.3 + dataBoost * 0.2);
|
||||
}
|
||||
|
||||
private static string BuildRationale(
|
||||
DecisionAction action,
|
||||
List<string> pastRationales,
|
||||
ImmutableArray<string> matchingFactors)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
|
||||
// Start with the recommendation
|
||||
parts.Add($"Recommended action: **{action}**");
|
||||
|
||||
// Add matching factors
|
||||
if (matchingFactors.Length > 0)
|
||||
{
|
||||
parts.Add(string.Format(CultureInfo.InvariantCulture,
|
||||
"This situation matches previous cases with: {0}.",
|
||||
string.Join(", ", matchingFactors)));
|
||||
}
|
||||
|
||||
// Add sample past rationales
|
||||
if (pastRationales.Count > 0)
|
||||
{
|
||||
parts.Add(string.Format(CultureInfo.InvariantCulture,
|
||||
"Past decisions used similar reasoning: \"{0}\"",
|
||||
pastRationales[0]));
|
||||
}
|
||||
|
||||
return string.Join(" ", parts);
|
||||
}
|
||||
|
||||
private static ImmutableArray<PlaybookEvidence> BuildEvidence(List<SimilarityMatch> records)
|
||||
{
|
||||
return records
|
||||
.OrderByDescending(r => r.SimilarityScore)
|
||||
.Take(5)
|
||||
.Select(r => new PlaybookEvidence
|
||||
{
|
||||
MemoryId = r.Record.MemoryId,
|
||||
Similarity = r.SimilarityScore,
|
||||
Action = r.Record.Decision.Action,
|
||||
Outcome = r.Record.Outcome?.Status ?? OutcomeStatus.Pending,
|
||||
DecidedAt = r.Record.Decision.DecidedAt,
|
||||
Cve = r.Record.Situation.CveId,
|
||||
Component = r.Record.Situation.ComponentName
|
||||
})
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static TimeSpan? CalculateAverageResolutionTime(List<SimilarityMatch> records)
|
||||
{
|
||||
var resolutionTimes = records
|
||||
.Where(r => r.Record.Outcome?.ResolutionTime.HasValue == true)
|
||||
.Select(r => r.Record.Outcome!.ResolutionTime!.Value)
|
||||
.ToList();
|
||||
|
||||
if (resolutionTimes.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var avgTicks = resolutionTimes.Average(t => t.Ticks);
|
||||
return TimeSpan.FromTicks((long)avgTicks);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for playbook suggestions.
|
||||
/// </summary>
|
||||
public sealed record PlaybookSuggestionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the tenant ID.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current situation to get suggestions for.
|
||||
/// </summary>
|
||||
public required SituationContext Situation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum number of similar records to analyze.
|
||||
/// </summary>
|
||||
public int? TopK { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the minimum similarity threshold.
|
||||
/// </summary>
|
||||
public double? MinSimilarity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum number of suggestions to return.
|
||||
/// </summary>
|
||||
public int? MaxSuggestions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the earliest date for historical records.
|
||||
/// </summary>
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of playbook suggestion generation.
|
||||
/// </summary>
|
||||
public sealed record PlaybookSuggestionResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the suggestions ordered by confidence.
|
||||
/// </summary>
|
||||
public ImmutableArray<PlaybookSuggestion> Suggestions { get; init; } =
|
||||
ImmutableArray<PlaybookSuggestion>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of historical records analyzed.
|
||||
/// </summary>
|
||||
public int AnalyzedRecords { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the highest similarity score found.
|
||||
/// </summary>
|
||||
public double? TopSimilarity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether any suggestions were found.
|
||||
/// </summary>
|
||||
public bool HasSuggestions => Suggestions.Length > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A playbook suggestion based on historical data.
|
||||
/// </summary>
|
||||
public sealed record PlaybookSuggestion
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the recommended action.
|
||||
/// </summary>
|
||||
public required DecisionAction Action { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the confidence level (0-1).
|
||||
/// </summary>
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the human-readable rationale.
|
||||
/// </summary>
|
||||
public required string Rationale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the evidence records supporting this suggestion.
|
||||
/// </summary>
|
||||
public ImmutableArray<PlaybookEvidence> Evidence { get; init; } =
|
||||
ImmutableArray<PlaybookEvidence>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the factors that matched the current situation.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> MatchingFactors { get; init; } =
|
||||
ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of similar past decisions.
|
||||
/// </summary>
|
||||
public int SimilarDecisionCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the success rate for this action in similar situations.
|
||||
/// </summary>
|
||||
public double SuccessRate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the memory ID of the best matching past decision.
|
||||
/// </summary>
|
||||
public string? BestMatchMemoryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the average resolution time for this action.
|
||||
/// </summary>
|
||||
public TimeSpan? AverageResolutionTime { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence record for a playbook suggestion.
|
||||
/// </summary>
|
||||
public sealed record PlaybookEvidence
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the memory record ID.
|
||||
/// </summary>
|
||||
public required string MemoryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the similarity score to current situation.
|
||||
/// </summary>
|
||||
public required double Similarity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the action that was taken.
|
||||
/// </summary>
|
||||
public required DecisionAction Action { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the outcome of the decision.
|
||||
/// </summary>
|
||||
public required OutcomeStatus Outcome { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the decision was made.
|
||||
/// </summary>
|
||||
public required DateTimeOffset DecidedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the CVE (if any).
|
||||
/// </summary>
|
||||
public string? Cve { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the component name (if any).
|
||||
/// </summary>
|
||||
public string? Component { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// <copyright file="ISimilarityVectorGenerator.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
|
||||
using StellaOps.OpsMemory.Models;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.OpsMemory.Similarity;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for generating similarity vectors from situation contexts.
|
||||
/// Sprint: SPRINT_20260107_006_004 Task OM-004
|
||||
/// </summary>
|
||||
public interface ISimilarityVectorGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a similarity vector from a situation context.
|
||||
/// </summary>
|
||||
/// <param name="situation">The situation to vectorize.</param>
|
||||
/// <returns>A normalized similarity vector.</returns>
|
||||
ImmutableArray<float> Generate(SituationContext situation);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the factors that contributed to similarity between two situations.
|
||||
/// </summary>
|
||||
/// <param name="a">First situation.</param>
|
||||
/// <param name="b">Second situation.</param>
|
||||
/// <returns>List of matching factors.</returns>
|
||||
ImmutableArray<string> GetMatchingFactors(SituationContext a, SituationContext b);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
// <copyright file="SimilarityVectorGenerator.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
|
||||
using StellaOps.OpsMemory.Models;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.OpsMemory.Similarity;
|
||||
|
||||
/// <summary>
|
||||
/// Generates similarity vectors for finding related security decisions.
|
||||
/// Sprint: SPRINT_20260107_006_004 Task OM-004
|
||||
/// </summary>
|
||||
public sealed class SimilarityVectorGenerator : ISimilarityVectorGenerator
|
||||
{
|
||||
// Vector dimensions:
|
||||
// [0-9] : CVE category one-hot (10 categories)
|
||||
// [10-14] : Severity one-hot (none, low, medium, high, critical)
|
||||
// [15-18] : Reachability one-hot (unknown, reachable, not-reachable, potential)
|
||||
// [19-23] : EPSS band one-hot (0-0.2, 0.2-0.4, 0.4-0.6, 0.6-0.8, 0.8-1.0)
|
||||
// [24-28] : CVSS band one-hot (0-2, 2-4, 4-6, 6-8, 8-10)
|
||||
// [29] : KEV flag
|
||||
// [30-39] : Component type one-hot (10 types)
|
||||
// [40-49] : Context tag presence (10 common tags)
|
||||
private const int VectorDimension = 50;
|
||||
|
||||
private static readonly ImmutableArray<string> CveCategories = ImmutableArray.Create(
|
||||
"memory", "injection", "auth", "crypto", "dos",
|
||||
"info-disclosure", "privilege-escalation", "xss", "path-traversal", "other");
|
||||
|
||||
private static readonly ImmutableArray<string> ComponentTypes = ImmutableArray.Create(
|
||||
"npm", "maven", "pypi", "nuget", "go", "cargo", "deb", "rpm", "apk", "other");
|
||||
|
||||
private static readonly ImmutableArray<string> CommonContextTags = ImmutableArray.Create(
|
||||
"production", "development", "staging", "external-facing", "internal",
|
||||
"payment", "auth", "data", "api", "frontend");
|
||||
|
||||
/// <summary>
|
||||
/// Generates a similarity vector from a situation context.
|
||||
/// </summary>
|
||||
/// <param name="situation">The situation to vectorize.</param>
|
||||
/// <returns>A normalized similarity vector.</returns>
|
||||
public ImmutableArray<float> Generate(SituationContext situation)
|
||||
{
|
||||
var vector = new float[VectorDimension];
|
||||
|
||||
// CVE category (dimensions 0-9)
|
||||
var category = ClassifyCve(situation.CveId);
|
||||
var categoryIndex = CveCategories.IndexOf(category);
|
||||
if (categoryIndex >= 0 && categoryIndex < 10)
|
||||
{
|
||||
vector[categoryIndex] = 1.0f;
|
||||
}
|
||||
|
||||
// Severity (dimensions 10-14)
|
||||
var severityIndex = GetSeverityIndex(situation.Severity);
|
||||
if (severityIndex >= 0)
|
||||
{
|
||||
vector[10 + severityIndex] = 1.0f;
|
||||
}
|
||||
|
||||
// Reachability (dimensions 15-18)
|
||||
vector[15 + (int)situation.Reachability] = 1.0f;
|
||||
|
||||
// EPSS band (dimensions 19-23)
|
||||
if (situation.EpssScore.HasValue)
|
||||
{
|
||||
var epssBand = GetBandIndex(situation.EpssScore.Value, 0, 1, 5);
|
||||
vector[19 + epssBand] = 1.0f;
|
||||
}
|
||||
|
||||
// CVSS band (dimensions 24-28)
|
||||
if (situation.CvssScore.HasValue)
|
||||
{
|
||||
var cvssBand = GetBandIndex(situation.CvssScore.Value, 0, 10, 5);
|
||||
vector[24 + cvssBand] = 1.0f;
|
||||
}
|
||||
|
||||
// KEV flag (dimension 29)
|
||||
vector[29] = situation.IsKev ? 1.0f : 0.0f;
|
||||
|
||||
// Component type (dimensions 30-39)
|
||||
var componentType = ClassifyComponent(situation.Component);
|
||||
var typeIndex = ComponentTypes.IndexOf(componentType);
|
||||
if (typeIndex >= 0 && typeIndex < 10)
|
||||
{
|
||||
vector[30 + typeIndex] = 1.0f;
|
||||
}
|
||||
|
||||
// Context tags (dimensions 40-49)
|
||||
foreach (var tag in situation.ContextTags)
|
||||
{
|
||||
var tagLower = tag.ToLowerInvariant();
|
||||
var tagIndex = CommonContextTags.IndexOf(tagLower);
|
||||
if (tagIndex >= 0 && tagIndex < 10)
|
||||
{
|
||||
vector[40 + tagIndex] = 1.0f;
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize to unit vector
|
||||
return Normalize(vector);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes cosine similarity between two vectors.
|
||||
/// </summary>
|
||||
public static double CosineSimilarity(ImmutableArray<float> a, ImmutableArray<float> b)
|
||||
{
|
||||
if (a.Length != b.Length)
|
||||
{
|
||||
throw new ArgumentException("Vectors must have the same dimension");
|
||||
}
|
||||
|
||||
double dotProduct = 0;
|
||||
double normA = 0;
|
||||
double normB = 0;
|
||||
|
||||
for (int i = 0; i < a.Length; i++)
|
||||
{
|
||||
dotProduct += a[i] * b[i];
|
||||
normA += a[i] * a[i];
|
||||
normB += b[i] * b[i];
|
||||
}
|
||||
|
||||
if (normA == 0 || normB == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return dotProduct / (Math.Sqrt(normA) * Math.Sqrt(normB));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the factors that contributed to similarity.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> GetMatchingFactors(
|
||||
SituationContext a,
|
||||
SituationContext b)
|
||||
{
|
||||
var factors = new List<string>();
|
||||
|
||||
// Check CVE category match
|
||||
var catA = ClassifyCve(a.CveId);
|
||||
var catB = ClassifyCve(b.CveId);
|
||||
if (catA == catB && catA != "other")
|
||||
{
|
||||
factors.Add($"Same vulnerability category: {catA}");
|
||||
}
|
||||
|
||||
// Check severity match
|
||||
if (a.Severity?.ToLowerInvariant() == b.Severity?.ToLowerInvariant() &&
|
||||
!string.IsNullOrEmpty(a.Severity))
|
||||
{
|
||||
factors.Add($"Same severity: {a.Severity}");
|
||||
}
|
||||
|
||||
// Check reachability match
|
||||
if (a.Reachability == b.Reachability && a.Reachability != ReachabilityStatus.Unknown)
|
||||
{
|
||||
factors.Add($"Same reachability: {a.Reachability}");
|
||||
}
|
||||
|
||||
// Check similar EPSS
|
||||
if (a.EpssScore.HasValue && b.EpssScore.HasValue)
|
||||
{
|
||||
if (Math.Abs(a.EpssScore.Value - b.EpssScore.Value) < 0.2)
|
||||
{
|
||||
factors.Add(string.Format(CultureInfo.InvariantCulture,
|
||||
"Similar EPSS: {0:P0} vs {1:P0}",
|
||||
a.EpssScore.Value, b.EpssScore.Value));
|
||||
}
|
||||
}
|
||||
|
||||
// Check KEV match
|
||||
if (a.IsKev && b.IsKev)
|
||||
{
|
||||
factors.Add("Both are KEV");
|
||||
}
|
||||
|
||||
// Check component type match
|
||||
var typeA = ClassifyComponent(a.Component);
|
||||
var typeB = ClassifyComponent(b.Component);
|
||||
if (typeA == typeB && typeA != "other")
|
||||
{
|
||||
factors.Add($"Same component type: {typeA}");
|
||||
}
|
||||
|
||||
// Check overlapping context tags
|
||||
var commonTags = a.ContextTags.Intersect(b.ContextTags, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
if (commonTags.Count > 0)
|
||||
{
|
||||
factors.Add($"Shared context: {string.Join(", ", commonTags)}");
|
||||
}
|
||||
|
||||
return factors.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static string ClassifyCve(string? cveId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(cveId))
|
||||
{
|
||||
return "other";
|
||||
}
|
||||
|
||||
// Simple heuristic classification based on CVE patterns
|
||||
// In production, this would query a CVE database or use CWE mapping
|
||||
var cveLower = cveId.ToLowerInvariant();
|
||||
|
||||
// These are placeholder classifications - real implementation would use CWE data
|
||||
return "other";
|
||||
}
|
||||
|
||||
private static string ClassifyComponent(string? purl)
|
||||
{
|
||||
if (string.IsNullOrEmpty(purl))
|
||||
{
|
||||
return "other";
|
||||
}
|
||||
|
||||
// Parse PURL type
|
||||
if (purl.StartsWith("pkg:npm/", StringComparison.OrdinalIgnoreCase))
|
||||
return "npm";
|
||||
if (purl.StartsWith("pkg:maven/", StringComparison.OrdinalIgnoreCase))
|
||||
return "maven";
|
||||
if (purl.StartsWith("pkg:pypi/", StringComparison.OrdinalIgnoreCase))
|
||||
return "pypi";
|
||||
if (purl.StartsWith("pkg:nuget/", StringComparison.OrdinalIgnoreCase))
|
||||
return "nuget";
|
||||
if (purl.StartsWith("pkg:golang/", StringComparison.OrdinalIgnoreCase))
|
||||
return "go";
|
||||
if (purl.StartsWith("pkg:cargo/", StringComparison.OrdinalIgnoreCase))
|
||||
return "cargo";
|
||||
if (purl.StartsWith("pkg:deb/", StringComparison.OrdinalIgnoreCase))
|
||||
return "deb";
|
||||
if (purl.StartsWith("pkg:rpm/", StringComparison.OrdinalIgnoreCase))
|
||||
return "rpm";
|
||||
if (purl.StartsWith("pkg:apk/", StringComparison.OrdinalIgnoreCase))
|
||||
return "apk";
|
||||
|
||||
return "other";
|
||||
}
|
||||
|
||||
private static int GetSeverityIndex(string? severity)
|
||||
{
|
||||
if (string.IsNullOrEmpty(severity))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return severity.ToLowerInvariant() switch
|
||||
{
|
||||
"none" => 0,
|
||||
"low" => 1,
|
||||
"medium" => 2,
|
||||
"high" => 3,
|
||||
"critical" => 4,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
|
||||
private static int GetBandIndex(double value, double min, double max, int bands)
|
||||
{
|
||||
var normalized = (value - min) / (max - min);
|
||||
var band = (int)(normalized * bands);
|
||||
return Math.Clamp(band, 0, bands - 1);
|
||||
}
|
||||
|
||||
private static ImmutableArray<float> Normalize(float[] vector)
|
||||
{
|
||||
double norm = 0;
|
||||
foreach (var v in vector)
|
||||
{
|
||||
norm += v * v;
|
||||
}
|
||||
|
||||
if (norm == 0)
|
||||
{
|
||||
return vector.ToImmutableArray();
|
||||
}
|
||||
|
||||
var normSqrt = (float)Math.Sqrt(norm);
|
||||
for (int i = 0; i < vector.Length; i++)
|
||||
{
|
||||
vector[i] /= normSqrt;
|
||||
}
|
||||
|
||||
return vector.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Description>OpsMemory - Decision ledger for security playbook learning</Description>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\Findings\StellaOps.Findings.Ledger\StellaOps.Findings.Ledger.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,88 @@
|
||||
// <copyright file="IKnownIssueStore.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
|
||||
using StellaOps.OpsMemory.Integration;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.OpsMemory.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Storage interface for known issues.
|
||||
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-007
|
||||
/// </summary>
|
||||
public interface IKnownIssueStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new known issue.
|
||||
/// </summary>
|
||||
/// <param name="issue">The issue to create.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The created issue with assigned ID.</returns>
|
||||
Task<KnownIssue> CreateAsync(
|
||||
KnownIssue issue,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing known issue.
|
||||
/// </summary>
|
||||
/// <param name="issue">The issue to update.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The updated issue.</returns>
|
||||
Task<KnownIssue?> UpdateAsync(
|
||||
KnownIssue issue,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a known issue by ID.
|
||||
/// </summary>
|
||||
/// <param name="issueId">The issue ID.</param>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The issue or null if not found.</returns>
|
||||
Task<KnownIssue?> GetByIdAsync(
|
||||
string issueId,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Finds known issues by context (CVE, component, or tags).
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="cveId">Optional CVE ID to match.</param>
|
||||
/// <param name="component">Optional component PURL to match.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Matching known issues with relevance scores.</returns>
|
||||
Task<ImmutableArray<KnownIssue>> FindByContextAsync(
|
||||
string tenantId,
|
||||
string? cveId,
|
||||
string? component,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all known issues for a tenant.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="limit">Maximum number of issues to return.</param>
|
||||
/// <param name="offset">Number of issues to skip.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Paginated list of known issues.</returns>
|
||||
Task<ImmutableArray<KnownIssue>> ListAsync(
|
||||
string tenantId,
|
||||
int limit = 50,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a known issue.
|
||||
/// </summary>
|
||||
/// <param name="issueId">The issue ID.</param>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if deleted, false if not found.</returns>
|
||||
Task<bool> DeleteAsync(
|
||||
string issueId,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
// <copyright file="IOpsMemoryStore.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
|
||||
using StellaOps.OpsMemory.Models;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.OpsMemory.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for OpsMemory storage operations.
|
||||
/// Sprint: SPRINT_20260107_006_004 Task OM-002
|
||||
/// </summary>
|
||||
public interface IOpsMemoryStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Records a new security decision.
|
||||
/// </summary>
|
||||
/// <param name="record">The decision record to store.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The created record with assigned ID.</returns>
|
||||
Task<OpsMemoryRecord> RecordDecisionAsync(
|
||||
OpsMemoryRecord record,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Records the outcome of a previously made decision.
|
||||
/// </summary>
|
||||
/// <param name="memoryId">The memory record ID.</param>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="outcome">The outcome details.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The updated record.</returns>
|
||||
Task<OpsMemoryRecord?> RecordOutcomeAsync(
|
||||
string memoryId,
|
||||
string tenantId,
|
||||
OutcomeRecord outcome,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Finds decisions similar to the given situation.
|
||||
/// </summary>
|
||||
/// <param name="query">The similarity query.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Similar records ordered by similarity score.</returns>
|
||||
Task<IReadOnlyList<SimilarityMatch>> FindSimilarAsync(
|
||||
SimilarityQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific memory record by ID.
|
||||
/// </summary>
|
||||
/// <param name="memoryId">The memory record ID.</param>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The record or null if not found.</returns>
|
||||
Task<OpsMemoryRecord?> GetByIdAsync(
|
||||
string memoryId,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Queries memory records with filters.
|
||||
/// </summary>
|
||||
/// <param name="query">The query parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Matching records with pagination info.</returns>
|
||||
Task<PagedResult<OpsMemoryRecord>> QueryAsync(
|
||||
OpsMemoryQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets statistics for playbook analysis.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="since">Optional start date for statistics.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Aggregated statistics.</returns>
|
||||
Task<OpsMemoryStats> GetStatsAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset? since = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query for finding similar situations.
|
||||
/// </summary>
|
||||
public sealed record SimilarityQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the tenant ID for isolation.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the similarity vector to match against.
|
||||
/// </summary>
|
||||
public ImmutableArray<float> SimilarityVector { get; init; } = ImmutableArray<float>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the situation to match (used if vector not provided).
|
||||
/// </summary>
|
||||
public SituationContext? Situation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum number of results (default: 10).
|
||||
/// </summary>
|
||||
public int Limit { get; init; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the minimum similarity threshold (0-1, default: 0.7).
|
||||
/// </summary>
|
||||
public double MinSimilarity { get; init; } = 0.7;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether to include only records with outcomes.
|
||||
/// </summary>
|
||||
public bool OnlyWithOutcome { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether to include only successful outcomes.
|
||||
/// </summary>
|
||||
public bool OnlySuccessful { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the earliest record date to consider.
|
||||
/// </summary>
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A similarity match result.
|
||||
/// </summary>
|
||||
public sealed record SimilarityMatch
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the matching memory record.
|
||||
/// </summary>
|
||||
public required OpsMemoryRecord Record { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the similarity score (0-1).
|
||||
/// </summary>
|
||||
public required double SimilarityScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the matching factors that contributed to similarity.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> MatchingFactors { get; init; } = ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query for listing memory records.
|
||||
/// </summary>
|
||||
public sealed record OpsMemoryQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the tenant ID.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the CVE filter (exact match).
|
||||
/// </summary>
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the component PURL filter (prefix match).
|
||||
/// </summary>
|
||||
public string? ComponentPrefix { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the action filter.
|
||||
/// </summary>
|
||||
public DecisionAction? Action { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the outcome status filter.
|
||||
/// </summary>
|
||||
public OutcomeStatus? OutcomeStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the start date filter.
|
||||
/// </summary>
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the end date filter.
|
||||
/// </summary>
|
||||
public DateTimeOffset? Until { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the context tag filter (any match).
|
||||
/// </summary>
|
||||
public ImmutableArray<string> ContextTags { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the page size (default: 20).
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cursor for pagination.
|
||||
/// </summary>
|
||||
public string? Cursor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the sort field.
|
||||
/// </summary>
|
||||
public OpsMemorySortField SortBy { get; init; } = OpsMemorySortField.RecordedAt;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether to sort descending.
|
||||
/// </summary>
|
||||
public bool Descending { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sort fields for memory queries.
|
||||
/// </summary>
|
||||
public enum OpsMemorySortField
|
||||
{
|
||||
/// <summary>Sort by record creation time.</summary>
|
||||
RecordedAt,
|
||||
|
||||
/// <summary>Sort by decision time.</summary>
|
||||
DecidedAt,
|
||||
|
||||
/// <summary>Sort by CVSS score.</summary>
|
||||
CvssScore,
|
||||
|
||||
/// <summary>Sort by EPSS score.</summary>
|
||||
EpssScore
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Paginated result.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The item type.</typeparam>
|
||||
public sealed record PagedResult<T>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the items in this page.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<T> Items { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total count (if available).
|
||||
/// </summary>
|
||||
public int? TotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cursor for the next page.
|
||||
/// </summary>
|
||||
public string? NextCursor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether there are more results.
|
||||
/// </summary>
|
||||
public bool HasMore => NextCursor is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated statistics for OpsMemory.
|
||||
/// </summary>
|
||||
public sealed record OpsMemoryStats
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the total number of decisions.
|
||||
/// </summary>
|
||||
public int TotalDecisions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of decisions with outcomes recorded.
|
||||
/// </summary>
|
||||
public int DecisionsWithOutcomes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the success rate (0-1).
|
||||
/// </summary>
|
||||
public double SuccessRate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the breakdown by action.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<DecisionAction, int> ByAction { get; init; } =
|
||||
ImmutableDictionary<DecisionAction, int>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the breakdown by outcome status.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<OutcomeStatus, int> ByOutcome { get; init; } =
|
||||
ImmutableDictionary<OutcomeStatus, int>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the average resolution time.
|
||||
/// </summary>
|
||||
public TimeSpan? AverageResolutionTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the most common context tags.
|
||||
/// </summary>
|
||||
public ImmutableArray<(string Tag, int Count)> TopContextTags { get; init; } =
|
||||
ImmutableArray<(string, int)>.Empty;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// <copyright file="ITacticStore.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
|
||||
using StellaOps.OpsMemory.Integration;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.OpsMemory.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Storage interface for playbook tactics.
|
||||
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-007
|
||||
/// </summary>
|
||||
public interface ITacticStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new tactic.
|
||||
/// </summary>
|
||||
/// <param name="tactic">The tactic to create.</param>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The created tactic with assigned ID.</returns>
|
||||
Task<Tactic> CreateAsync(
|
||||
Tactic tactic,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing tactic.
|
||||
/// </summary>
|
||||
/// <param name="tactic">The tactic to update.</param>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The updated tactic.</returns>
|
||||
Task<Tactic?> UpdateAsync(
|
||||
Tactic tactic,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a tactic by ID.
|
||||
/// </summary>
|
||||
/// <param name="tacticId">The tactic ID.</param>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The tactic or null if not found.</returns>
|
||||
Task<Tactic?> GetByIdAsync(
|
||||
string tacticId,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Finds tactics matching the given trigger conditions.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="trigger">The trigger conditions to match.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Matching tactics ordered by confidence.</returns>
|
||||
Task<ImmutableArray<Tactic>> FindByTriggerAsync(
|
||||
string tenantId,
|
||||
TacticTrigger trigger,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all tactics for a tenant.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="limit">Maximum number of tactics to return.</param>
|
||||
/// <param name="offset">Number of tactics to skip.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Paginated list of tactics.</returns>
|
||||
Task<ImmutableArray<Tactic>> ListAsync(
|
||||
string tenantId,
|
||||
int limit = 50,
|
||||
int offset = 0,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Records usage of a tactic (updates usage count and success rate).
|
||||
/// </summary>
|
||||
/// <param name="tacticId">The tactic ID.</param>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="wasSuccessful">Whether the tactic application was successful.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The updated tactic.</returns>
|
||||
Task<Tactic?> RecordUsageAsync(
|
||||
string tacticId,
|
||||
string tenantId,
|
||||
bool wasSuccessful,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a tactic.
|
||||
/// </summary>
|
||||
/// <param name="tacticId">The tactic ID.</param>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if deleted, false if not found.</returns>
|
||||
Task<bool> DeleteAsync(
|
||||
string tacticId,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trigger conditions for matching tactics.
|
||||
/// </summary>
|
||||
public sealed record TacticTrigger
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the severities to match.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Severities { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the CVE categories to match.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> CveCategories { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether to require reachability.
|
||||
/// </summary>
|
||||
public bool? RequiresReachable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the minimum EPSS score.
|
||||
/// </summary>
|
||||
public double? MinEpssScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the minimum CVSS score.
|
||||
/// </summary>
|
||||
public double? MinCvssScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets context tags to match.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> ContextTags { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
// <copyright file="PostgresOpsMemoryStore.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.OpsMemory.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of IOpsMemoryStore.
|
||||
/// Sprint: SPRINT_20260107_006_004 Task OM-003
|
||||
/// </summary>
|
||||
public sealed class PostgresOpsMemoryStore : IOpsMemoryStore, IAsyncDisposable
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly ILogger<PostgresOpsMemoryStore> _logger;
|
||||
private readonly OpsMemoryStoreOptions _options;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PostgresOpsMemoryStore"/> class.
|
||||
/// </summary>
|
||||
public PostgresOpsMemoryStore(
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<PostgresOpsMemoryStore> logger,
|
||||
OpsMemoryStoreOptions? options = null)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
_options = options ?? new OpsMemoryStoreOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OpsMemoryRecord> RecordDecisionAsync(
|
||||
OpsMemoryRecord record,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO opsmemory.decisions (
|
||||
memory_id, tenant_id, recorded_at,
|
||||
cve_id, component, component_name, component_version,
|
||||
severity, cvss_score, reachability, epss_score, is_kev,
|
||||
context_tags, additional_context,
|
||||
action, rationale, decided_by, decided_at,
|
||||
policy_reference, vex_statement_id, mitigation,
|
||||
similarity_vector
|
||||
) VALUES (
|
||||
@memoryId, @tenantId, @recordedAt,
|
||||
@cveId, @component, @componentName, @componentVersion,
|
||||
@severity, @cvssScore, @reachability, @epssScore, @isKev,
|
||||
@contextTags, @additionalContext,
|
||||
@action, @rationale, @decidedBy, @decidedAt,
|
||||
@policyReference, @vexStatementId, @mitigation,
|
||||
@similarityVector
|
||||
)
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
AddRecordParameters(cmd, record);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Recorded decision {MemoryId} for tenant {TenantId}: {Action}",
|
||||
record.MemoryId, record.TenantId, record.Decision.Action);
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OpsMemoryRecord?> RecordOutcomeAsync(
|
||||
string memoryId,
|
||||
string tenantId,
|
||||
OutcomeRecord outcome,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE opsmemory.decisions
|
||||
SET outcome_status = @status,
|
||||
outcome_resolution_time = @resolutionTime,
|
||||
outcome_actual_impact = @actualImpact,
|
||||
outcome_lessons_learned = @lessonsLearned,
|
||||
outcome_recorded_by = @recordedBy,
|
||||
outcome_recorded_at = @recordedAt,
|
||||
outcome_would_repeat = @wouldRepeat,
|
||||
outcome_alternative_actions = @alternativeActions
|
||||
WHERE memory_id = @memoryId AND tenant_id = @tenantId
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("memoryId", memoryId);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("status", outcome.Status.ToString());
|
||||
cmd.Parameters.AddWithValue("resolutionTime",
|
||||
(object?)outcome.ResolutionTime?.TotalSeconds ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("actualImpact", (object?)outcome.ActualImpact ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("lessonsLearned", (object?)outcome.LessonsLearned ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("recordedBy", outcome.RecordedBy);
|
||||
cmd.Parameters.AddWithValue("recordedAt", outcome.RecordedAt);
|
||||
cmd.Parameters.AddWithValue("wouldRepeat", (object?)outcome.WouldRepeat ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("alternativeActions", (object?)outcome.AlternativeActions ?? DBNull.Value);
|
||||
|
||||
var affected = await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (affected == 0)
|
||||
{
|
||||
_logger.LogWarning("Decision {MemoryId} not found for tenant {TenantId}", memoryId, tenantId);
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Recorded outcome for decision {MemoryId}: {Status}",
|
||||
memoryId, outcome.Status);
|
||||
|
||||
return await GetByIdAsync(memoryId, tenantId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OpsMemoryRecord?> GetByIdAsync(
|
||||
string memoryId,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM opsmemory.decisions
|
||||
WHERE memory_id = @memoryId AND tenant_id = @tenantId
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("memoryId", memoryId);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return MapRecord(reader);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PagedResult<OpsMemoryRecord>> QueryAsync(
|
||||
OpsMemoryQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var whereClauses = new List<string> { "tenant_id = @tenantId" };
|
||||
var parameters = new List<NpgsqlParameter> { new("tenantId", query.TenantId) };
|
||||
|
||||
if (!string.IsNullOrEmpty(query.CveId))
|
||||
{
|
||||
whereClauses.Add("cve_id = @cveId");
|
||||
parameters.Add(new NpgsqlParameter("cveId", query.CveId));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(query.ComponentPrefix))
|
||||
{
|
||||
whereClauses.Add("component LIKE @componentPrefix");
|
||||
parameters.Add(new NpgsqlParameter("componentPrefix", query.ComponentPrefix + "%"));
|
||||
}
|
||||
|
||||
if (query.Action.HasValue)
|
||||
{
|
||||
whereClauses.Add("action = @action");
|
||||
parameters.Add(new NpgsqlParameter("action", query.Action.Value.ToString()));
|
||||
}
|
||||
|
||||
if (query.OutcomeStatus.HasValue)
|
||||
{
|
||||
whereClauses.Add("outcome_status = @outcomeStatus");
|
||||
parameters.Add(new NpgsqlParameter("outcomeStatus", query.OutcomeStatus.Value.ToString()));
|
||||
}
|
||||
|
||||
if (query.Since.HasValue)
|
||||
{
|
||||
whereClauses.Add("recorded_at >= @since");
|
||||
parameters.Add(new NpgsqlParameter("since", query.Since.Value));
|
||||
}
|
||||
|
||||
if (query.Until.HasValue)
|
||||
{
|
||||
whereClauses.Add("recorded_at <= @until");
|
||||
parameters.Add(new NpgsqlParameter("until", query.Until.Value));
|
||||
}
|
||||
|
||||
if (!query.ContextTags.IsDefaultOrEmpty)
|
||||
{
|
||||
whereClauses.Add("context_tags && @contextTags");
|
||||
parameters.Add(new NpgsqlParameter("contextTags", query.ContextTags.ToArray()));
|
||||
}
|
||||
|
||||
var whereClause = string.Join(" AND ", whereClauses);
|
||||
var sortColumn = query.SortBy switch
|
||||
{
|
||||
OpsMemorySortField.DecidedAt => "decided_at",
|
||||
OpsMemorySortField.CvssScore => "cvss_score",
|
||||
OpsMemorySortField.EpssScore => "epss_score",
|
||||
_ => "recorded_at"
|
||||
};
|
||||
var sortDir = query.Descending ? "DESC" : "ASC";
|
||||
|
||||
// Count query
|
||||
var countSql = $"SELECT COUNT(*) FROM opsmemory.decisions WHERE {whereClause}";
|
||||
|
||||
await using var countCmd = _dataSource.CreateCommand(countSql);
|
||||
foreach (var p in parameters)
|
||||
{
|
||||
countCmd.Parameters.Add(CloneParameter(p));
|
||||
}
|
||||
var totalCount = Convert.ToInt32(
|
||||
await countCmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false),
|
||||
CultureInfo.InvariantCulture);
|
||||
|
||||
// Select query with pagination
|
||||
var selectSql = $"""
|
||||
SELECT * FROM opsmemory.decisions
|
||||
WHERE {whereClause}
|
||||
ORDER BY {sortColumn} {sortDir}
|
||||
LIMIT {query.PageSize + 1}
|
||||
""";
|
||||
|
||||
if (!string.IsNullOrEmpty(query.Cursor))
|
||||
{
|
||||
selectSql = $"""
|
||||
SELECT * FROM opsmemory.decisions
|
||||
WHERE {whereClause} AND recorded_at < @cursor
|
||||
ORDER BY {sortColumn} {sortDir}
|
||||
LIMIT {query.PageSize + 1}
|
||||
""";
|
||||
parameters.Add(new NpgsqlParameter("cursor", DateTimeOffset.Parse(query.Cursor, CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
await using var selectCmd = _dataSource.CreateCommand(selectSql);
|
||||
foreach (var p in parameters)
|
||||
{
|
||||
selectCmd.Parameters.Add(CloneParameter(p));
|
||||
}
|
||||
|
||||
var records = new List<OpsMemoryRecord>();
|
||||
await using var reader = await selectCmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
records.Add(MapRecord(reader));
|
||||
}
|
||||
|
||||
string? nextCursor = null;
|
||||
if (records.Count > query.PageSize)
|
||||
{
|
||||
records.RemoveAt(records.Count - 1);
|
||||
nextCursor = records[^1].RecordedAt.ToString("O", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
return new PagedResult<OpsMemoryRecord>
|
||||
{
|
||||
Items = records,
|
||||
TotalCount = totalCount,
|
||||
NextCursor = nextCursor
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<SimilarityMatch>> FindSimilarAsync(
|
||||
SimilarityQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Without pgvector, we fetch candidates and compute similarity in-memory
|
||||
var whereClauses = new List<string> { "tenant_id = @tenantId" };
|
||||
var parameters = new List<NpgsqlParameter> { new("tenantId", query.TenantId) };
|
||||
|
||||
if (query.OnlyWithOutcome)
|
||||
{
|
||||
whereClauses.Add("outcome_status IS NOT NULL");
|
||||
}
|
||||
|
||||
if (query.OnlySuccessful)
|
||||
{
|
||||
whereClauses.Add("outcome_status = 'Success'");
|
||||
}
|
||||
|
||||
if (query.Since.HasValue)
|
||||
{
|
||||
whereClauses.Add("recorded_at >= @since");
|
||||
parameters.Add(new NpgsqlParameter("since", query.Since.Value));
|
||||
}
|
||||
|
||||
var whereClause = string.Join(" AND ", whereClauses);
|
||||
|
||||
var sql = $"""
|
||||
SELECT * FROM opsmemory.decisions
|
||||
WHERE {whereClause}
|
||||
AND similarity_vector IS NOT NULL
|
||||
AND array_length(similarity_vector, 1) > 0
|
||||
ORDER BY recorded_at DESC
|
||||
LIMIT 100
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
foreach (var p in parameters)
|
||||
{
|
||||
cmd.Parameters.Add(p);
|
||||
}
|
||||
|
||||
var candidates = new List<(OpsMemoryRecord Record, float[] Vector)>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var record = MapRecord(reader);
|
||||
var vectorOrdinal = reader.GetOrdinal("similarity_vector");
|
||||
if (!reader.IsDBNull(vectorOrdinal))
|
||||
{
|
||||
var vector = (float[])reader.GetValue(vectorOrdinal);
|
||||
if (vector.Length > 0)
|
||||
{
|
||||
candidates.Add((record, vector));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute cosine similarity
|
||||
var queryVector = query.SimilarityVector.ToArray();
|
||||
var matches = candidates
|
||||
.Select(c => new
|
||||
{
|
||||
c.Record,
|
||||
Similarity = CosineSimilarity(queryVector, c.Vector)
|
||||
})
|
||||
.Where(m => m.Similarity >= query.MinSimilarity)
|
||||
.OrderByDescending(m => m.Similarity)
|
||||
.Take(query.Limit)
|
||||
.Select(m => new SimilarityMatch
|
||||
{
|
||||
Record = m.Record,
|
||||
SimilarityScore = m.Similarity,
|
||||
MatchingFactors = DetermineMatchingFactors(m.Record, query.Situation)
|
||||
})
|
||||
.ToList();
|
||||
|
||||
_logger.LogDebug(
|
||||
"Found {Count} similar records for tenant {TenantId} (checked {Candidates} candidates)",
|
||||
matches.Count, query.TenantId, candidates.Count);
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OpsMemoryStats> GetStatsAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset? since = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var whereClause = "tenant_id = @tenantId";
|
||||
var parameters = new List<NpgsqlParameter> { new("tenantId", tenantId) };
|
||||
|
||||
if (since.HasValue)
|
||||
{
|
||||
whereClause += " AND recorded_at >= @since";
|
||||
parameters.Add(new NpgsqlParameter("since", since.Value));
|
||||
}
|
||||
|
||||
var sql = $"""
|
||||
SELECT
|
||||
COUNT(*) as total_decisions,
|
||||
COUNT(outcome_status) as decisions_with_outcomes,
|
||||
COUNT(*) FILTER (WHERE outcome_status = 'Success') as successful_outcomes,
|
||||
AVG(outcome_resolution_time) FILTER (WHERE outcome_resolution_time IS NOT NULL) as avg_resolution_time
|
||||
FROM opsmemory.decisions
|
||||
WHERE {whereClause}
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
foreach (var p in parameters)
|
||||
{
|
||||
cmd.Parameters.Add(p);
|
||||
}
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var totalDecisions = reader.GetInt32(0);
|
||||
var decisionsWithOutcomes = reader.GetInt32(1);
|
||||
var successfulOutcomes = reader.GetInt32(2);
|
||||
var avgResolutionSeconds = reader.IsDBNull(3) ? (double?)null : reader.GetDouble(3);
|
||||
|
||||
// Get breakdown by action
|
||||
var actionSql = $"""
|
||||
SELECT action, COUNT(*) as count
|
||||
FROM opsmemory.decisions
|
||||
WHERE {whereClause}
|
||||
GROUP BY action
|
||||
""";
|
||||
|
||||
await using var actionCmd = _dataSource.CreateCommand(actionSql);
|
||||
foreach (var p in parameters)
|
||||
{
|
||||
actionCmd.Parameters.Add(CloneParameter(p));
|
||||
}
|
||||
|
||||
var byAction = ImmutableDictionary.CreateBuilder<DecisionAction, int>();
|
||||
await using var actionReader = await actionCmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await actionReader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (Enum.TryParse<DecisionAction>(actionReader.GetString(0), out var action))
|
||||
{
|
||||
byAction[action] = actionReader.GetInt32(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Get breakdown by outcome
|
||||
var outcomeSql = $"""
|
||||
SELECT outcome_status, COUNT(*) as count
|
||||
FROM opsmemory.decisions
|
||||
WHERE {whereClause} AND outcome_status IS NOT NULL
|
||||
GROUP BY outcome_status
|
||||
""";
|
||||
|
||||
await using var outcomeCmd = _dataSource.CreateCommand(outcomeSql);
|
||||
foreach (var p in parameters)
|
||||
{
|
||||
outcomeCmd.Parameters.Add(CloneParameter(p));
|
||||
}
|
||||
|
||||
var byOutcome = ImmutableDictionary.CreateBuilder<OutcomeStatus, int>();
|
||||
await using var outcomeReader = await outcomeCmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await outcomeReader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (Enum.TryParse<OutcomeStatus>(outcomeReader.GetString(0), out var status))
|
||||
{
|
||||
byOutcome[status] = outcomeReader.GetInt32(1);
|
||||
}
|
||||
}
|
||||
|
||||
return new OpsMemoryStats
|
||||
{
|
||||
TotalDecisions = totalDecisions,
|
||||
DecisionsWithOutcomes = decisionsWithOutcomes,
|
||||
SuccessRate = decisionsWithOutcomes > 0
|
||||
? (double)successfulOutcomes / decisionsWithOutcomes
|
||||
: 0,
|
||||
ByAction = byAction.ToImmutable(),
|
||||
ByOutcome = byOutcome.ToImmutable(),
|
||||
AverageResolutionTime = avgResolutionSeconds.HasValue
|
||||
? TimeSpan.FromSeconds(avgResolutionSeconds.Value)
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
// DataSource is managed externally
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static void AddRecordParameters(NpgsqlCommand cmd, OpsMemoryRecord record)
|
||||
{
|
||||
cmd.Parameters.AddWithValue("memoryId", record.MemoryId);
|
||||
cmd.Parameters.AddWithValue("tenantId", record.TenantId);
|
||||
cmd.Parameters.AddWithValue("recordedAt", record.RecordedAt);
|
||||
|
||||
// Situation
|
||||
cmd.Parameters.AddWithValue("cveId", (object?)record.Situation.CveId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("component", (object?)record.Situation.Component ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("componentName", (object?)record.Situation.ComponentName ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("componentVersion", (object?)record.Situation.ComponentVersion ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("severity", (object?)record.Situation.Severity ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("cvssScore", (object?)record.Situation.CvssScore ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("reachability", record.Situation.Reachability.ToString());
|
||||
cmd.Parameters.AddWithValue("epssScore", (object?)record.Situation.EpssScore ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("isKev", record.Situation.IsKev);
|
||||
cmd.Parameters.AddWithValue("contextTags", record.Situation.ContextTags.IsDefaultOrEmpty
|
||||
? Array.Empty<string>()
|
||||
: record.Situation.ContextTags.ToArray());
|
||||
|
||||
var additionalContext = record.Situation.AdditionalContext.IsEmpty
|
||||
? null
|
||||
: JsonSerializer.Serialize(record.Situation.AdditionalContext, JsonOptions);
|
||||
cmd.Parameters.Add(new NpgsqlParameter("additionalContext", NpgsqlDbType.Jsonb)
|
||||
{
|
||||
Value = (object?)additionalContext ?? DBNull.Value
|
||||
});
|
||||
|
||||
// Decision
|
||||
cmd.Parameters.AddWithValue("action", record.Decision.Action.ToString());
|
||||
cmd.Parameters.AddWithValue("rationale", record.Decision.Rationale);
|
||||
cmd.Parameters.AddWithValue("decidedBy", record.Decision.DecidedBy);
|
||||
cmd.Parameters.AddWithValue("decidedAt", record.Decision.DecidedAt);
|
||||
cmd.Parameters.AddWithValue("policyReference", (object?)record.Decision.PolicyReference ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("vexStatementId", (object?)record.Decision.VexStatementId ?? DBNull.Value);
|
||||
|
||||
var mitigation = record.Decision.Mitigation is null
|
||||
? null
|
||||
: JsonSerializer.Serialize(record.Decision.Mitigation, JsonOptions);
|
||||
cmd.Parameters.Add(new NpgsqlParameter("mitigation", NpgsqlDbType.Jsonb)
|
||||
{
|
||||
Value = (object?)mitigation ?? DBNull.Value
|
||||
});
|
||||
|
||||
// Similarity vector
|
||||
cmd.Parameters.AddWithValue("similarityVector", record.SimilarityVector.IsDefaultOrEmpty
|
||||
? Array.Empty<float>()
|
||||
: record.SimilarityVector.ToArray());
|
||||
}
|
||||
|
||||
private static OpsMemoryRecord MapRecord(NpgsqlDataReader reader)
|
||||
{
|
||||
OutcomeRecord? outcome = null;
|
||||
var outcomeStatusOrdinal = reader.GetOrdinal("outcome_status");
|
||||
if (!reader.IsDBNull(outcomeStatusOrdinal))
|
||||
{
|
||||
var resolutionTimeOrdinal = reader.GetOrdinal("outcome_resolution_time");
|
||||
TimeSpan? resolutionTime = reader.IsDBNull(resolutionTimeOrdinal)
|
||||
? null
|
||||
: TimeSpan.FromSeconds(reader.GetDouble(resolutionTimeOrdinal));
|
||||
|
||||
outcome = new OutcomeRecord
|
||||
{
|
||||
Status = Enum.Parse<OutcomeStatus>(reader.GetString(outcomeStatusOrdinal)),
|
||||
ResolutionTime = resolutionTime,
|
||||
ActualImpact = GetNullableString(reader, "outcome_actual_impact"),
|
||||
LessonsLearned = GetNullableString(reader, "outcome_lessons_learned"),
|
||||
RecordedBy = reader.GetString(reader.GetOrdinal("outcome_recorded_by")),
|
||||
RecordedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("outcome_recorded_at")),
|
||||
WouldRepeat = GetNullableBool(reader, "outcome_would_repeat"),
|
||||
AlternativeActions = GetNullableString(reader, "outcome_alternative_actions")
|
||||
};
|
||||
}
|
||||
|
||||
var contextTagsOrdinal = reader.GetOrdinal("context_tags");
|
||||
var contextTags = reader.IsDBNull(contextTagsOrdinal)
|
||||
? ImmutableArray<string>.Empty
|
||||
: ((string[])reader.GetValue(contextTagsOrdinal)).ToImmutableArray();
|
||||
|
||||
var additionalContextOrdinal = reader.GetOrdinal("additional_context");
|
||||
var additionalContext = reader.IsDBNull(additionalContextOrdinal)
|
||||
? ImmutableDictionary<string, string>.Empty
|
||||
: JsonSerializer.Deserialize<ImmutableDictionary<string, string>>(
|
||||
reader.GetString(additionalContextOrdinal), JsonOptions)
|
||||
?? ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
var mitigationOrdinal = reader.GetOrdinal("mitigation");
|
||||
MitigationDetails? mitigation = reader.IsDBNull(mitigationOrdinal)
|
||||
? null
|
||||
: JsonSerializer.Deserialize<MitigationDetails>(reader.GetString(mitigationOrdinal), JsonOptions);
|
||||
|
||||
var vectorOrdinal = reader.GetOrdinal("similarity_vector");
|
||||
var similarityVector = reader.IsDBNull(vectorOrdinal)
|
||||
? ImmutableArray<float>.Empty
|
||||
: ((float[])reader.GetValue(vectorOrdinal)).ToImmutableArray();
|
||||
|
||||
return new OpsMemoryRecord
|
||||
{
|
||||
MemoryId = reader.GetString(reader.GetOrdinal("memory_id")),
|
||||
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
|
||||
RecordedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("recorded_at")),
|
||||
Situation = new SituationContext
|
||||
{
|
||||
CveId = GetNullableString(reader, "cve_id"),
|
||||
Component = GetNullableString(reader, "component"),
|
||||
ComponentName = GetNullableString(reader, "component_name"),
|
||||
ComponentVersion = GetNullableString(reader, "component_version"),
|
||||
Severity = GetNullableString(reader, "severity"),
|
||||
CvssScore = GetNullableDouble(reader, "cvss_score"),
|
||||
Reachability = Enum.Parse<ReachabilityStatus>(
|
||||
reader.GetString(reader.GetOrdinal("reachability"))),
|
||||
EpssScore = GetNullableDouble(reader, "epss_score"),
|
||||
IsKev = reader.GetBoolean(reader.GetOrdinal("is_kev")),
|
||||
ContextTags = contextTags,
|
||||
AdditionalContext = additionalContext
|
||||
},
|
||||
Decision = new DecisionRecord
|
||||
{
|
||||
Action = Enum.Parse<DecisionAction>(reader.GetString(reader.GetOrdinal("action"))),
|
||||
Rationale = reader.GetString(reader.GetOrdinal("rationale")),
|
||||
DecidedBy = reader.GetString(reader.GetOrdinal("decided_by")),
|
||||
DecidedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("decided_at")),
|
||||
PolicyReference = GetNullableString(reader, "policy_reference"),
|
||||
VexStatementId = GetNullableString(reader, "vex_statement_id"),
|
||||
Mitigation = mitigation
|
||||
},
|
||||
Outcome = outcome,
|
||||
SimilarityVector = similarityVector
|
||||
};
|
||||
}
|
||||
|
||||
private static string? GetNullableString(NpgsqlDataReader reader, string column)
|
||||
{
|
||||
var ordinal = reader.GetOrdinal(column);
|
||||
return reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal);
|
||||
}
|
||||
|
||||
private static double? GetNullableDouble(NpgsqlDataReader reader, string column)
|
||||
{
|
||||
var ordinal = reader.GetOrdinal(column);
|
||||
return reader.IsDBNull(ordinal) ? null : reader.GetDouble(ordinal);
|
||||
}
|
||||
|
||||
private static bool? GetNullableBool(NpgsqlDataReader reader, string column)
|
||||
{
|
||||
var ordinal = reader.GetOrdinal(column);
|
||||
return reader.IsDBNull(ordinal) ? null : reader.GetBoolean(ordinal);
|
||||
}
|
||||
|
||||
private static NpgsqlParameter CloneParameter(NpgsqlParameter p) =>
|
||||
new(p.ParameterName, p.Value);
|
||||
|
||||
private static double CosineSimilarity(float[] a, float[] b)
|
||||
{
|
||||
if (a.Length != b.Length || a.Length == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
double dotProduct = 0;
|
||||
double normA = 0;
|
||||
double normB = 0;
|
||||
|
||||
for (int i = 0; i < a.Length; i++)
|
||||
{
|
||||
dotProduct += a[i] * b[i];
|
||||
normA += a[i] * a[i];
|
||||
normB += b[i] * b[i];
|
||||
}
|
||||
|
||||
if (normA == 0 || normB == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return dotProduct / (Math.Sqrt(normA) * Math.Sqrt(normB));
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> DetermineMatchingFactors(OpsMemoryRecord record, SituationContext? query)
|
||||
{
|
||||
if (query is null)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
var factors = new List<string>();
|
||||
|
||||
if (query.CveId == record.Situation.CveId)
|
||||
{
|
||||
factors.Add("same_cve");
|
||||
}
|
||||
|
||||
if (query.Severity == record.Situation.Severity)
|
||||
{
|
||||
factors.Add("same_severity");
|
||||
}
|
||||
|
||||
if (query.Reachability == record.Situation.Reachability)
|
||||
{
|
||||
factors.Add("same_reachability");
|
||||
}
|
||||
|
||||
if (query.IsKev == record.Situation.IsKev)
|
||||
{
|
||||
factors.Add("same_kev_status");
|
||||
}
|
||||
|
||||
if (!query.ContextTags.IsDefaultOrEmpty && !record.Situation.ContextTags.IsDefaultOrEmpty)
|
||||
{
|
||||
var overlap = query.ContextTags.Intersect(record.Situation.ContextTags).ToList();
|
||||
if (overlap.Count > 0)
|
||||
{
|
||||
factors.Add($"shared_tags:{string.Join(",", overlap)}");
|
||||
}
|
||||
}
|
||||
|
||||
return factors.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for OpsMemory store.
|
||||
/// </summary>
|
||||
public sealed class OpsMemoryStoreOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the default page size.
|
||||
/// </summary>
|
||||
public int DefaultPageSize { get; set; } = 20;
|
||||
}
|
||||
8
src/AdvisoryAI/__Libraries/StellaOps.OpsMemory/TASKS.md
Normal file
8
src/AdvisoryAI/__Libraries/StellaOps.OpsMemory/TASKS.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# StellaOps.OpsMemory Task Board
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/OpsMemory/StellaOps.OpsMemory/StellaOps.OpsMemory.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
@@ -0,0 +1,421 @@
|
||||
// <copyright file="OutcomeTrackingService.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
using StellaOps.OpsMemory.Storage;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.OpsMemory.Tracking;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks outcomes of decisions and links them back to OpsMemory.
|
||||
/// Sprint: SPRINT_20260107_006_004 Task OM-008
|
||||
/// </summary>
|
||||
public sealed class OutcomeTrackingService : IOutcomeTrackingService
|
||||
{
|
||||
private readonly IOpsMemoryStore _store;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<OutcomeTrackingService> _logger;
|
||||
private readonly OutcomeTrackingOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="OutcomeTrackingService"/> class.
|
||||
/// </summary>
|
||||
public OutcomeTrackingService(
|
||||
IOpsMemoryStore store,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<OutcomeTrackingService> logger,
|
||||
OutcomeTrackingOptions? options = null)
|
||||
{
|
||||
_store = store;
|
||||
_timeProvider = timeProvider;
|
||||
_logger = logger;
|
||||
_options = options ?? new OutcomeTrackingOptions();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects if a resolution event corresponds to a tracked decision.
|
||||
/// </summary>
|
||||
public async Task<OutcomePrompt?> DetectResolutionAsync(
|
||||
ResolutionEvent resolutionEvent,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Query for decisions matching the CVE and component
|
||||
var query = new OpsMemoryQuery
|
||||
{
|
||||
TenantId = resolutionEvent.TenantId,
|
||||
CveId = resolutionEvent.CveId,
|
||||
ComponentPrefix = resolutionEvent.ComponentPurl,
|
||||
PageSize = 10
|
||||
};
|
||||
|
||||
var result = await _store.QueryAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Find decisions without outcomes
|
||||
var pendingDecisions = result.Items
|
||||
.Where(r => !r.HasOutcome)
|
||||
.OrderByDescending(r => r.RecordedAt)
|
||||
.ToList();
|
||||
|
||||
if (pendingDecisions.Count == 0)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"No pending decisions found for CVE {CveId} on {Component}",
|
||||
resolutionEvent.CveId,
|
||||
resolutionEvent.ComponentPurl);
|
||||
return null;
|
||||
}
|
||||
|
||||
var decision = pendingDecisions[0];
|
||||
var elapsed = resolutionEvent.ResolvedAt - decision.Decision.DecidedAt;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Resolution detected for decision {MemoryId}: {ResolutionType} after {Elapsed}",
|
||||
decision.MemoryId,
|
||||
resolutionEvent.ResolutionType,
|
||||
elapsed);
|
||||
|
||||
return new OutcomePrompt
|
||||
{
|
||||
MemoryId = decision.MemoryId,
|
||||
TenantId = decision.TenantId,
|
||||
CveId = resolutionEvent.CveId,
|
||||
Component = resolutionEvent.ComponentPurl,
|
||||
DecisionAction = decision.Decision.Action.ToString(),
|
||||
DecidedAt = decision.Decision.DecidedAt,
|
||||
SuggestedOutcome = MapResolutionToOutcome(resolutionEvent.ResolutionType, decision.Decision.Action),
|
||||
ResolutionDetails = resolutionEvent.Details,
|
||||
ElapsedSinceDecision = elapsed
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records an outcome for a decision based on user classification.
|
||||
/// </summary>
|
||||
public async Task<RecordedOutcome> RecordOutcomeAsync(
|
||||
string memoryId,
|
||||
string tenantId,
|
||||
OutcomeStatus status,
|
||||
string? impact = null,
|
||||
string? lessons = null,
|
||||
string recordedBy = "system",
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var outcome = new OutcomeRecord
|
||||
{
|
||||
Status = status,
|
||||
ActualImpact = impact,
|
||||
LessonsLearned = lessons,
|
||||
RecordedBy = recordedBy,
|
||||
RecordedAt = now
|
||||
};
|
||||
|
||||
var updated = await _store.RecordOutcomeAsync(
|
||||
memoryId,
|
||||
tenantId,
|
||||
outcome,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Outcome recorded for decision {MemoryId}: {Status}",
|
||||
memoryId,
|
||||
status);
|
||||
|
||||
return new RecordedOutcome
|
||||
{
|
||||
MemoryId = memoryId,
|
||||
TenantId = tenantId,
|
||||
Status = status,
|
||||
RecordedAt = now,
|
||||
Success = updated is not null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets pending outcome prompts for a tenant.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<OutcomePrompt>> GetPendingPromptsAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cutoff = _timeProvider.GetUtcNow().AddDays(-_options.OutcomeWindowDays);
|
||||
var minAge = _timeProvider.GetUtcNow().AddHours(-_options.MinHoursBeforePrompt);
|
||||
|
||||
var query = new OpsMemoryQuery
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Since = cutoff,
|
||||
Until = minAge,
|
||||
OutcomeStatus = null, // Looking for records without outcomes
|
||||
PageSize = _options.MaxPendingPrompts
|
||||
};
|
||||
|
||||
var result = await _store.QueryAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Filter to only those without outcomes
|
||||
var pendingDecisions = result.Items
|
||||
.Where(r => !r.HasOutcome)
|
||||
.ToList();
|
||||
|
||||
var prompts = pendingDecisions.Select(d => new OutcomePrompt
|
||||
{
|
||||
MemoryId = d.MemoryId,
|
||||
TenantId = d.TenantId,
|
||||
CveId = d.Situation.CveId,
|
||||
Component = d.Situation.Component,
|
||||
DecisionAction = d.Decision.Action.ToString(),
|
||||
DecidedAt = d.Decision.DecidedAt,
|
||||
ElapsedSinceDecision = _timeProvider.GetUtcNow() - d.Decision.DecidedAt
|
||||
}).ToList();
|
||||
|
||||
_logger.LogDebug(
|
||||
"Found {Count} pending prompts for tenant {TenantId}",
|
||||
prompts.Count,
|
||||
tenantId);
|
||||
|
||||
return prompts;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates success metrics for a tenant over a time period.
|
||||
/// </summary>
|
||||
public async Task<OutcomeMetrics> CalculateMetricsAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset to,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = new OpsMemoryQuery
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Since = from,
|
||||
Until = to,
|
||||
PageSize = 1000 // Get all for metrics calculation
|
||||
};
|
||||
|
||||
var result = await _store.QueryAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
var records = result.Items;
|
||||
|
||||
var totalDecisions = records.Count;
|
||||
var withOutcomes = records.Where(r => r.HasOutcome).ToList();
|
||||
var successful = withOutcomes.Count(r => r.WasSuccessful);
|
||||
|
||||
var resolutionTimes = withOutcomes
|
||||
.Where(r => r.Outcome?.ResolutionTime.HasValue == true)
|
||||
.Select(r => r.Outcome!.ResolutionTime!.Value)
|
||||
.ToList();
|
||||
|
||||
var avgResolutionTime = resolutionTimes.Count > 0
|
||||
? TimeSpan.FromTicks((long)resolutionTimes.Average(t => t.Ticks))
|
||||
: TimeSpan.Zero;
|
||||
|
||||
var byAction = records
|
||||
.GroupBy(r => r.Decision.Action)
|
||||
.ToImmutableDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var byOutcome = withOutcomes
|
||||
.Where(r => r.Outcome is not null)
|
||||
.GroupBy(r => r.Outcome!.Status)
|
||||
.ToImmutableDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
return new OutcomeMetrics
|
||||
{
|
||||
TenantId = tenantId,
|
||||
PeriodStart = from,
|
||||
PeriodEnd = to,
|
||||
TotalDecisions = totalDecisions,
|
||||
DecisionsWithOutcome = withOutcomes.Count,
|
||||
SuccessfulOutcomes = successful,
|
||||
SuccessRate = withOutcomes.Count > 0 ? (double)successful / withOutcomes.Count : 0,
|
||||
AverageResolutionTime = avgResolutionTime,
|
||||
ByAction = byAction,
|
||||
ByOutcome = byOutcome
|
||||
};
|
||||
}
|
||||
|
||||
private static OutcomeStatus MapResolutionToOutcome(string resolutionType, DecisionAction action)
|
||||
{
|
||||
return resolutionType.ToUpperInvariant() switch
|
||||
{
|
||||
"UPGRADED" => OutcomeStatus.Success,
|
||||
"PATCHED" => OutcomeStatus.Success,
|
||||
"REMOVED" => OutcomeStatus.Success,
|
||||
"MITIGATED" => OutcomeStatus.PartialSuccess,
|
||||
"FALSE_POSITIVE" => OutcomeStatus.Success,
|
||||
"WONT_FIX" => action == DecisionAction.Accept ? OutcomeStatus.Success : OutcomeStatus.PartialSuccess,
|
||||
"EXPLOITED" => OutcomeStatus.NegativeOutcome,
|
||||
"INCIDENT" => OutcomeStatus.NegativeOutcome,
|
||||
_ => OutcomeStatus.Pending
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for outcome tracking.
|
||||
/// </summary>
|
||||
public interface IOutcomeTrackingService
|
||||
{
|
||||
/// <summary>Detects resolution and creates outcome prompt.</summary>
|
||||
Task<OutcomePrompt?> DetectResolutionAsync(ResolutionEvent resolutionEvent, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Records an outcome for a decision.</summary>
|
||||
Task<RecordedOutcome> RecordOutcomeAsync(
|
||||
string memoryId,
|
||||
string tenantId,
|
||||
OutcomeStatus status,
|
||||
string? impact = null,
|
||||
string? lessons = null,
|
||||
string recordedBy = "system",
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Gets pending outcome prompts.</summary>
|
||||
Task<IReadOnlyList<OutcomePrompt>> GetPendingPromptsAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Calculates success metrics.</summary>
|
||||
Task<OutcomeMetrics> CalculateMetricsAsync(string tenantId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A resolution event indicating a finding was resolved.
|
||||
/// </summary>
|
||||
public sealed record ResolutionEvent
|
||||
{
|
||||
/// <summary>Gets the tenant ID.</summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>Gets the CVE ID.</summary>
|
||||
public required string CveId { get; init; }
|
||||
|
||||
/// <summary>Gets the component PURL.</summary>
|
||||
public required string ComponentPurl { get; init; }
|
||||
|
||||
/// <summary>Gets when the resolution occurred.</summary>
|
||||
public required DateTimeOffset ResolvedAt { get; init; }
|
||||
|
||||
/// <summary>Gets the resolution type.</summary>
|
||||
public required string ResolutionType { get; init; }
|
||||
|
||||
/// <summary>Gets additional details.</summary>
|
||||
public string? Details { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prompt for recording an outcome.
|
||||
/// </summary>
|
||||
public sealed record OutcomePrompt
|
||||
{
|
||||
/// <summary>Gets the memory ID.</summary>
|
||||
public required string MemoryId { get; init; }
|
||||
|
||||
/// <summary>Gets the tenant ID.</summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>Gets the CVE ID.</summary>
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>Gets the component.</summary>
|
||||
public string? Component { get; init; }
|
||||
|
||||
/// <summary>Gets the decision action taken.</summary>
|
||||
public required string DecisionAction { get; init; }
|
||||
|
||||
/// <summary>Gets when the decision was made.</summary>
|
||||
public required DateTimeOffset DecidedAt { get; init; }
|
||||
|
||||
/// <summary>Gets the suggested outcome status.</summary>
|
||||
public OutcomeStatus? SuggestedOutcome { get; init; }
|
||||
|
||||
/// <summary>Gets resolution details.</summary>
|
||||
public string? ResolutionDetails { get; init; }
|
||||
|
||||
/// <summary>Gets elapsed time since decision.</summary>
|
||||
public TimeSpan? ElapsedSinceDecision { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recorded outcome result.
|
||||
/// </summary>
|
||||
public sealed record RecordedOutcome
|
||||
{
|
||||
/// <summary>Gets the memory ID.</summary>
|
||||
public required string MemoryId { get; init; }
|
||||
|
||||
/// <summary>Gets the tenant ID.</summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>Gets the outcome status.</summary>
|
||||
public required OutcomeStatus Status { get; init; }
|
||||
|
||||
/// <summary>Gets when recorded.</summary>
|
||||
public required DateTimeOffset RecordedAt { get; init; }
|
||||
|
||||
/// <summary>Gets whether recording succeeded.</summary>
|
||||
public bool Success { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Success metrics for outcomes.
|
||||
/// </summary>
|
||||
public sealed record OutcomeMetrics
|
||||
{
|
||||
/// <summary>Gets the tenant ID.</summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>Gets the period start.</summary>
|
||||
public required DateTimeOffset PeriodStart { get; init; }
|
||||
|
||||
/// <summary>Gets the period end.</summary>
|
||||
public required DateTimeOffset PeriodEnd { get; init; }
|
||||
|
||||
/// <summary>Gets total decisions.</summary>
|
||||
public int TotalDecisions { get; init; }
|
||||
|
||||
/// <summary>Gets decisions with outcomes.</summary>
|
||||
public int DecisionsWithOutcome { get; init; }
|
||||
|
||||
/// <summary>Gets successful outcomes.</summary>
|
||||
public int SuccessfulOutcomes { get; init; }
|
||||
|
||||
/// <summary>Gets success rate (0-1).</summary>
|
||||
public double SuccessRate { get; init; }
|
||||
|
||||
/// <summary>Gets average resolution time.</summary>
|
||||
public TimeSpan AverageResolutionTime { get; init; }
|
||||
|
||||
/// <summary>Gets counts by action.</summary>
|
||||
public ImmutableDictionary<DecisionAction, int> ByAction { get; init; } =
|
||||
ImmutableDictionary<DecisionAction, int>.Empty;
|
||||
|
||||
/// <summary>Gets counts by outcome status.</summary>
|
||||
public ImmutableDictionary<OutcomeStatus, int> ByOutcome { get; init; } =
|
||||
ImmutableDictionary<OutcomeStatus, int>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for outcome tracking.
|
||||
/// </summary>
|
||||
public sealed class OutcomeTrackingOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the window in days to look back for outcomes.
|
||||
/// Default: 30 days.
|
||||
/// </summary>
|
||||
public int OutcomeWindowDays { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets minimum hours before prompting for outcome.
|
||||
/// Default: 24 hours.
|
||||
/// </summary>
|
||||
public int MinHoursBeforePrompt { get; set; } = 24;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets maximum pending prompts to return.
|
||||
/// Default: 50.
|
||||
/// </summary>
|
||||
public int MaxPendingPrompts { get; set; } = 50;
|
||||
}
|
||||
@@ -24,7 +24,7 @@
|
||||
<ProjectReference Include="..\..\StellaOps.AdvisoryAI.Worker\StellaOps.AdvisoryAI.Worker.csproj" />
|
||||
<ProjectReference Include="..\..\..\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\Concelier\__Libraries\StellaOps.Concelier.RawModels\StellaOps.Concelier.RawModels.csproj" />
|
||||
<ProjectReference Include="..\..\..\Excititor\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\Concelier\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\Plugin\StellaOps.Plugin.Testing\StellaOps.Plugin.Testing.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
// <copyright file="OpsMemoryChatProviderIntegrationTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Npgsql;
|
||||
using StellaOps.OpsMemory.Integration;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
using StellaOps.OpsMemory.Playbook;
|
||||
using StellaOps.OpsMemory.Similarity;
|
||||
using StellaOps.OpsMemory.Storage;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.OpsMemory.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for OpsMemoryChatProvider.
|
||||
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-009
|
||||
/// Uses Testcontainers to provision an ephemeral PostgreSQL instance.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
[Collection(OpsMemoryPostgresCollection.Name)]
|
||||
public sealed class OpsMemoryChatProviderIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private readonly OpsMemoryPostgresFixture _fixture;
|
||||
|
||||
private NpgsqlDataSource? _dataSource;
|
||||
private PostgresOpsMemoryStore? _store;
|
||||
private OpsMemoryChatProvider? _chatProvider;
|
||||
private SimilarityVectorGenerator? _vectorGenerator;
|
||||
private string _testTenantId = string.Empty;
|
||||
|
||||
public OpsMemoryChatProviderIntegrationTests(OpsMemoryPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
|
||||
_dataSource = NpgsqlDataSource.Create(_fixture.ConnectionString);
|
||||
_store = new PostgresOpsMemoryStore(
|
||||
_dataSource,
|
||||
NullLogger<PostgresOpsMemoryStore>.Instance);
|
||||
|
||||
_vectorGenerator = new SimilarityVectorGenerator();
|
||||
|
||||
// Create chat provider with required dependencies
|
||||
_chatProvider = new OpsMemoryChatProvider(
|
||||
_store,
|
||||
_vectorGenerator,
|
||||
new NullPlaybookSuggestionService(),
|
||||
TimeProvider.System,
|
||||
NullLogger<OpsMemoryChatProvider>.Instance);
|
||||
|
||||
_testTenantId = $"test-{Guid.NewGuid()}";
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_store != null)
|
||||
{
|
||||
await _store.DisposeAsync();
|
||||
}
|
||||
|
||||
if (_dataSource != null)
|
||||
{
|
||||
await _dataSource.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichContext_WithNoHistory_ReturnsEmptyContext()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Arrange
|
||||
var request = new ChatContextRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
CveId = "CVE-2024-9999",
|
||||
Severity = "HIGH",
|
||||
Reachability = ReachabilityStatus.Reachable
|
||||
};
|
||||
|
||||
// Act
|
||||
var context = await _chatProvider!.EnrichContextAsync(request, ct);
|
||||
|
||||
// Assert
|
||||
context.SimilarDecisions.Should().BeEmpty();
|
||||
context.HasPlaybookEntries.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichContext_WithSimilarDecisions_ReturnsSortedBySimilarity()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// Arrange - Create decisions with different similarity levels
|
||||
var record1 = await CreateAndStoreDecision(
|
||||
_testTenantId, "CVE-2024-1234", "pkg:npm/test@1.0.0", "HIGH",
|
||||
DecisionAction.Remediate, OutcomeStatus.Success, now);
|
||||
|
||||
var record2 = await CreateAndStoreDecision(
|
||||
_testTenantId, "CVE-2024-5678", "pkg:npm/test@1.0.0", "CRITICAL",
|
||||
DecisionAction.Quarantine, OutcomeStatus.Success, now);
|
||||
|
||||
var record3 = await CreateAndStoreDecision(
|
||||
_testTenantId, "CVE-2024-9012", "pkg:maven/other@2.0.0", "LOW",
|
||||
DecisionAction.Accept, OutcomeStatus.Success, now);
|
||||
|
||||
var request = new ChatContextRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
CveId = "CVE-2024-NEW1",
|
||||
Component = "pkg:npm/test@1.0.0",
|
||||
Severity = "HIGH",
|
||||
Reachability = ReachabilityStatus.Reachable,
|
||||
MaxSuggestions = 3,
|
||||
MinSimilarity = 0.3
|
||||
};
|
||||
|
||||
// Act
|
||||
var context = await _chatProvider!.EnrichContextAsync(request, ct);
|
||||
|
||||
// Assert
|
||||
context.SimilarDecisions.Should().NotBeEmpty();
|
||||
context.HasPlaybookEntries.Should().BeTrue();
|
||||
|
||||
// Similar npm/HIGH decisions should rank higher than maven/LOW
|
||||
if (context.SimilarDecisions.Length >= 2)
|
||||
{
|
||||
context.SimilarDecisions[0].SimilarityScore.Should()
|
||||
.BeGreaterThanOrEqualTo(context.SimilarDecisions[1].SimilarityScore);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichContext_FiltersOutFailedDecisions()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// Arrange - Create one successful and one failed decision
|
||||
await CreateAndStoreDecision(
|
||||
_testTenantId, "CVE-SUCCESS-001", "pkg:npm/test@1.0.0", "HIGH",
|
||||
DecisionAction.Defer, OutcomeStatus.Success, now);
|
||||
|
||||
await CreateAndStoreDecision(
|
||||
_testTenantId, "CVE-FAILURE-001", "pkg:npm/test@1.0.0", "HIGH",
|
||||
DecisionAction.Accept, OutcomeStatus.Failure, now);
|
||||
|
||||
var request = new ChatContextRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
Component = "pkg:npm/test@1.0.0",
|
||||
Severity = "HIGH",
|
||||
MaxSuggestions = 10,
|
||||
MinSimilarity = 0.0
|
||||
};
|
||||
|
||||
// Act
|
||||
var context = await _chatProvider!.EnrichContextAsync(request, ct);
|
||||
|
||||
// Assert - Only successful decisions should be returned
|
||||
context.SimilarDecisions.Should().AllSatisfy(d =>
|
||||
d.OutcomeStatus.Should().BeOneOf(OutcomeStatus.Success, OutcomeStatus.PartialSuccess));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordFromAction_CreatesOpsMemoryRecord()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// Arrange
|
||||
var action = new ActionExecutionResult
|
||||
{
|
||||
Action = DecisionAction.Remediate,
|
||||
CveId = "CVE-2024-ACTION-001",
|
||||
Component = "pkg:npm/vulnerable@1.0.0",
|
||||
Success = true,
|
||||
Rationale = "Upgrading to patched version",
|
||||
ExecutedAt = now,
|
||||
ActorId = "user:alice@example.com"
|
||||
};
|
||||
|
||||
var context = new ConversationContext
|
||||
{
|
||||
ConversationId = "conv-123",
|
||||
TenantId = _testTenantId,
|
||||
UserId = "alice@example.com",
|
||||
Topic = "CVE Remediation",
|
||||
Situation = new SituationContext
|
||||
{
|
||||
CveId = "CVE-2024-ACTION-001",
|
||||
Component = "pkg:npm/vulnerable@1.0.0",
|
||||
Severity = "HIGH",
|
||||
Reachability = ReachabilityStatus.Reachable
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var record = await _chatProvider!.RecordFromActionAsync(action, context, ct);
|
||||
|
||||
// Assert
|
||||
record.Should().NotBeNull();
|
||||
record.TenantId.Should().Be(_testTenantId);
|
||||
record.Situation.CveId.Should().Be("CVE-2024-ACTION-001");
|
||||
record.Decision.Action.Should().Be(DecisionAction.Remediate);
|
||||
record.Decision.Rationale.Should().Be("Upgrading to patched version");
|
||||
|
||||
// Verify persisted
|
||||
var retrieved = await _store!.GetByIdAsync(record.MemoryId, _testTenantId, ct);
|
||||
retrieved.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentDecisions_ReturnsOrderedByDate()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// Arrange - Create decisions at different times
|
||||
await CreateAndStoreDecision(_testTenantId, "CVE-OLDEST", "pkg:test@1", "LOW",
|
||||
DecisionAction.Accept, null, now.AddDays(-10));
|
||||
|
||||
await CreateAndStoreDecision(_testTenantId, "CVE-MIDDLE", "pkg:test@1", "MEDIUM",
|
||||
DecisionAction.Defer, null, now.AddDays(-5));
|
||||
|
||||
await CreateAndStoreDecision(_testTenantId, "CVE-NEWEST", "pkg:test@1", "HIGH",
|
||||
DecisionAction.Remediate, null, now);
|
||||
|
||||
// Act
|
||||
var recent = await _chatProvider!.GetRecentDecisionsAsync(_testTenantId, 3, ct);
|
||||
|
||||
// Assert
|
||||
recent.Should().HaveCount(3);
|
||||
recent[0].CveId.Should().Be("CVE-NEWEST");
|
||||
recent[1].CveId.Should().Be("CVE-MIDDLE");
|
||||
recent[2].CveId.Should().Be("CVE-OLDEST");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichContext_IsTenantIsolated()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// Arrange - Create decisions in different tenants
|
||||
var otherTenantId = $"test-other-{Guid.NewGuid()}";
|
||||
|
||||
await CreateAndStoreDecision(_testTenantId, "CVE-TENANT1", "pkg:test@1", "HIGH",
|
||||
DecisionAction.Remediate, OutcomeStatus.Success, now);
|
||||
|
||||
await CreateAndStoreDecision(otherTenantId, "CVE-TENANT2", "pkg:test@1", "HIGH",
|
||||
DecisionAction.Accept, OutcomeStatus.Success, now);
|
||||
|
||||
var request = new ChatContextRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
Severity = "HIGH",
|
||||
MaxSuggestions = 10,
|
||||
MinSimilarity = 0.0
|
||||
};
|
||||
|
||||
// Act
|
||||
var context = await _chatProvider!.EnrichContextAsync(request, ct);
|
||||
|
||||
// Assert - Only decisions from _testTenantId should be returned
|
||||
context.SimilarDecisions.Should().AllSatisfy(d =>
|
||||
d.CveId.Should().Be("CVE-TENANT1"));
|
||||
}
|
||||
|
||||
private async Task<OpsMemoryRecord> CreateAndStoreDecision(
|
||||
string tenantId,
|
||||
string cveId,
|
||||
string component,
|
||||
string severity,
|
||||
DecisionAction action,
|
||||
OutcomeStatus? outcome,
|
||||
DateTimeOffset at)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
var situation = new SituationContext
|
||||
{
|
||||
CveId = cveId,
|
||||
Component = component,
|
||||
Severity = severity,
|
||||
Reachability = ReachabilityStatus.Reachable
|
||||
};
|
||||
|
||||
var record = new OpsMemoryRecord
|
||||
{
|
||||
MemoryId = Guid.NewGuid().ToString(),
|
||||
TenantId = tenantId,
|
||||
RecordedAt = at,
|
||||
Situation = situation,
|
||||
Decision = new DecisionRecord
|
||||
{
|
||||
Action = action,
|
||||
Rationale = $"Test decision for {cveId}",
|
||||
DecidedBy = "test",
|
||||
DecidedAt = at
|
||||
},
|
||||
SimilarityVector = _vectorGenerator!.Generate(situation)
|
||||
};
|
||||
|
||||
await _store!.RecordDecisionAsync(record, ct);
|
||||
|
||||
if (outcome.HasValue)
|
||||
{
|
||||
await _store.RecordOutcomeAsync(record.MemoryId, tenantId, new OutcomeRecord
|
||||
{
|
||||
Status = outcome.Value,
|
||||
RecordedBy = "test",
|
||||
RecordedAt = at.AddDays(1)
|
||||
}, ct);
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null implementation of IPlaybookSuggestionService for testing.
|
||||
/// </summary>
|
||||
private sealed class NullPlaybookSuggestionService : IPlaybookSuggestionService
|
||||
{
|
||||
public Task<PlaybookSuggestionResult> GetSuggestionsAsync(
|
||||
PlaybookSuggestionRequest request,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new PlaybookSuggestionResult
|
||||
{
|
||||
Suggestions = ImmutableArray<PlaybookSuggestion>.Empty
|
||||
});
|
||||
|
||||
public Task<IReadOnlyList<PlaybookSuggestion>> GetSuggestionsAsync(
|
||||
SituationContext situation,
|
||||
int maxSuggestions,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IReadOnlyList<PlaybookSuggestion>>(
|
||||
ImmutableArray<PlaybookSuggestion>.Empty);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// <copyright file="OpsMemoryPostgresFixture.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using Npgsql;
|
||||
using Testcontainers.PostgreSql;
|
||||
using Xunit;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace StellaOps.OpsMemory.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL integration test fixture for OpsMemory tests.
|
||||
/// Starts a Testcontainers PostgreSQL instance and applies the opsmemory migration.
|
||||
/// </summary>
|
||||
public sealed class OpsMemoryPostgresFixture : IAsyncLifetime
|
||||
{
|
||||
private const string PostgresImage = "postgres:16-alpine";
|
||||
|
||||
private PostgreSqlContainer? _container;
|
||||
|
||||
public string ConnectionString => _container?.GetConnectionString()
|
||||
?? throw new InvalidOperationException("Container not initialized");
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_container = new PostgreSqlBuilder()
|
||||
.WithImage(PostgresImage)
|
||||
.Build();
|
||||
|
||||
await _container.StartAsync();
|
||||
}
|
||||
catch (ArgumentException ex) when (
|
||||
string.Equals(ex.ParamName, "DockerEndpointAuthConfig", StringComparison.Ordinal) ||
|
||||
ex.Message.Contains("Docker is either not running", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (_container is not null)
|
||||
{
|
||||
try { await _container.DisposeAsync(); } catch { /* ignore */ }
|
||||
}
|
||||
_container = null;
|
||||
throw SkipException.ForSkip(
|
||||
$"OpsMemory integration tests require Docker/Testcontainers. Skipping: {ex.Message}");
|
||||
}
|
||||
|
||||
// Apply the opsmemory migration
|
||||
await using var conn = new NpgsqlConnection(ConnectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
var migrationSql = await LoadMigrationSqlAsync();
|
||||
await using var migrationCmd = new NpgsqlCommand(migrationSql, conn);
|
||||
migrationCmd.CommandTimeout = 60;
|
||||
await migrationCmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_container is not null)
|
||||
{
|
||||
await _container.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Truncates all opsmemory tables for test isolation.
|
||||
/// </summary>
|
||||
public async Task TruncateAllTablesAsync()
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(ConnectionString);
|
||||
await conn.OpenAsync();
|
||||
|
||||
await using var cmd = new NpgsqlCommand(
|
||||
"TRUNCATE TABLE opsmemory.decisions CASCADE;", conn);
|
||||
await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
private static async Task<string> LoadMigrationSqlAsync()
|
||||
{
|
||||
var directory = AppContext.BaseDirectory;
|
||||
while (directory is not null)
|
||||
{
|
||||
var migrationPath = Path.Combine(directory, "devops", "database",
|
||||
"migrations", "V20260108__opsmemory_advisoryai_schema.sql");
|
||||
|
||||
if (File.Exists(migrationPath))
|
||||
{
|
||||
return await File.ReadAllTextAsync(migrationPath);
|
||||
}
|
||||
|
||||
directory = Directory.GetParent(directory)?.FullName;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"Cannot find opsmemory migration SQL. Ensure the test runs from within the repository.");
|
||||
}
|
||||
}
|
||||
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class OpsMemoryPostgresCollection : ICollectionFixture<OpsMemoryPostgresFixture>
|
||||
{
|
||||
public const string Name = "OpsMemoryPostgres";
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
// <copyright file="PostgresOpsMemoryStoreTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Npgsql;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
using StellaOps.OpsMemory.Storage;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.OpsMemory.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for PostgresOpsMemoryStore.
|
||||
/// Uses Testcontainers to provision an ephemeral PostgreSQL instance.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
[Collection(OpsMemoryPostgresCollection.Name)]
|
||||
public sealed class PostgresOpsMemoryStoreTests : IAsyncLifetime
|
||||
{
|
||||
private readonly OpsMemoryPostgresFixture _fixture;
|
||||
|
||||
private NpgsqlDataSource? _dataSource;
|
||||
private PostgresOpsMemoryStore? _store;
|
||||
|
||||
public PostgresOpsMemoryStoreTests(OpsMemoryPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _fixture.TruncateAllTablesAsync();
|
||||
|
||||
_dataSource = NpgsqlDataSource.Create(_fixture.ConnectionString);
|
||||
_store = new PostgresOpsMemoryStore(
|
||||
_dataSource,
|
||||
NullLogger<PostgresOpsMemoryStore>.Instance);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_store != null)
|
||||
{
|
||||
await _store.DisposeAsync();
|
||||
}
|
||||
|
||||
if (_dataSource != null)
|
||||
{
|
||||
await _dataSource.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordDecision_ShouldPersistAndRetrieve()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Arrange
|
||||
var tenantId = $"test-{Guid.NewGuid()}";
|
||||
var memoryId = Guid.NewGuid().ToString();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var contextTags = ImmutableArray.Create("production", "critical-path");
|
||||
var similarityVector = ImmutableArray.CreateRange(Enumerable.Range(0, 50).Select(i => (float)i / 100));
|
||||
|
||||
var record = new OpsMemoryRecord
|
||||
{
|
||||
MemoryId = memoryId,
|
||||
TenantId = tenantId,
|
||||
RecordedAt = now,
|
||||
Situation = new SituationContext
|
||||
{
|
||||
CveId = "CVE-2024-1234",
|
||||
Component = "pkg:npm/lodash@4.17.20",
|
||||
ComponentName = "lodash",
|
||||
ComponentVersion = "4.17.20",
|
||||
Severity = "HIGH",
|
||||
CvssScore = 7.5,
|
||||
Reachability = ReachabilityStatus.Reachable,
|
||||
EpssScore = 0.15,
|
||||
IsKev = false,
|
||||
ContextTags = contextTags
|
||||
},
|
||||
Decision = new DecisionRecord
|
||||
{
|
||||
Action = DecisionAction.Defer,
|
||||
Rationale = "Waiting for upstream patch",
|
||||
DecidedBy = "security-team",
|
||||
DecidedAt = now
|
||||
},
|
||||
SimilarityVector = similarityVector
|
||||
};
|
||||
|
||||
// Act
|
||||
await _store!.RecordDecisionAsync(record, ct);
|
||||
var retrieved = await _store.GetByIdAsync(memoryId, tenantId, ct);
|
||||
|
||||
// Assert
|
||||
retrieved.Should().NotBeNull();
|
||||
retrieved!.MemoryId.Should().Be(memoryId);
|
||||
retrieved.TenantId.Should().Be(tenantId);
|
||||
retrieved.Situation.CveId.Should().Be("CVE-2024-1234");
|
||||
retrieved.Situation.Component.Should().Be("pkg:npm/lodash@4.17.20");
|
||||
retrieved.Situation.Severity.Should().Be("HIGH");
|
||||
retrieved.Situation.CvssScore.Should().Be(7.5);
|
||||
retrieved.Situation.Reachability.Should().Be(ReachabilityStatus.Reachable);
|
||||
retrieved.Decision.Action.Should().Be(DecisionAction.Defer);
|
||||
retrieved.Decision.Rationale.Should().Be("Waiting for upstream patch");
|
||||
retrieved.Situation.ContextTags.Should().Contain("production");
|
||||
retrieved.SimilarityVector.Should().HaveCount(50);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordOutcome_ShouldUpdateDecision()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Arrange
|
||||
var tenantId = $"test-{Guid.NewGuid()}";
|
||||
var memoryId = Guid.NewGuid().ToString();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var record = new OpsMemoryRecord
|
||||
{
|
||||
MemoryId = memoryId,
|
||||
TenantId = tenantId,
|
||||
RecordedAt = now,
|
||||
Situation = new SituationContext
|
||||
{
|
||||
CveId = "CVE-2024-5678",
|
||||
Reachability = ReachabilityStatus.Unknown
|
||||
},
|
||||
Decision = new DecisionRecord
|
||||
{
|
||||
Action = DecisionAction.Remediate,
|
||||
Rationale = "Critical finding requires fix",
|
||||
DecidedBy = "auto-policy",
|
||||
DecidedAt = now
|
||||
}
|
||||
};
|
||||
|
||||
await _store!.RecordDecisionAsync(record, ct);
|
||||
|
||||
var outcome = new OutcomeRecord
|
||||
{
|
||||
Status = OutcomeStatus.Success,
|
||||
ResolutionTime = TimeSpan.FromDays(3),
|
||||
ActualImpact = "None - patched before exploitation",
|
||||
LessonsLearned = "Auto-remediation worked well",
|
||||
RecordedBy = "security-team",
|
||||
RecordedAt = now.AddDays(3),
|
||||
WouldRepeat = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var updated = await _store.RecordOutcomeAsync(memoryId, tenantId, outcome, ct);
|
||||
|
||||
// Assert
|
||||
updated.Should().NotBeNull();
|
||||
updated!.HasOutcome.Should().BeTrue();
|
||||
updated.Outcome.Should().NotBeNull();
|
||||
updated.Outcome!.Status.Should().Be(OutcomeStatus.Success);
|
||||
updated.Outcome.ResolutionTime.Should().Be(TimeSpan.FromDays(3));
|
||||
updated.Outcome.ActualImpact.Should().Be("None - patched before exploitation");
|
||||
updated.WasSuccessful.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Query_ShouldFilterByTenant()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Arrange
|
||||
var tenant1 = $"test-{Guid.NewGuid()}";
|
||||
var tenant2 = $"test-{Guid.NewGuid()}";
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var record1 = CreateRecord(tenant1, "CVE-T1-001", DecisionAction.Accept, now);
|
||||
var record2 = CreateRecord(tenant2, "CVE-T2-001", DecisionAction.Defer, now);
|
||||
|
||||
await _store!.RecordDecisionAsync(record1, ct);
|
||||
await _store.RecordDecisionAsync(record2, ct);
|
||||
|
||||
// Act
|
||||
var result = await _store.QueryAsync(new OpsMemoryQuery { TenantId = tenant1 }, ct);
|
||||
|
||||
// Assert
|
||||
result.Items.Should().HaveCount(1);
|
||||
result.Items[0].Situation.CveId.Should().Be("CVE-T1-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Query_ShouldFilterByCve()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Arrange
|
||||
var tenantId = $"test-{Guid.NewGuid()}";
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var record1 = CreateRecord(tenantId, "CVE-2024-1111", DecisionAction.Accept, now);
|
||||
var record2 = CreateRecord(tenantId, "CVE-2024-2222", DecisionAction.Defer, now);
|
||||
|
||||
await _store!.RecordDecisionAsync(record1, ct);
|
||||
await _store.RecordDecisionAsync(record2, ct);
|
||||
|
||||
// Act
|
||||
var result = await _store.QueryAsync(new OpsMemoryQuery
|
||||
{
|
||||
TenantId = tenantId,
|
||||
CveId = "CVE-2024-1111"
|
||||
}, ct);
|
||||
|
||||
// Assert
|
||||
result.Items.Should().HaveCount(1);
|
||||
result.Items[0].Decision.Action.Should().Be(DecisionAction.Accept);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStats_ShouldReturnCorrectCounts()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
|
||||
// Arrange
|
||||
var tenantId = $"test-{Guid.NewGuid()}";
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var record1 = CreateRecord(tenantId, "CVE-STAT-001", DecisionAction.Remediate, now);
|
||||
var record2 = CreateRecord(tenantId, "CVE-STAT-002", DecisionAction.Defer, now);
|
||||
|
||||
await _store!.RecordDecisionAsync(record1, ct);
|
||||
await _store.RecordDecisionAsync(record2, ct);
|
||||
|
||||
// Record outcome for one
|
||||
await _store.RecordOutcomeAsync(record1.MemoryId, tenantId, new OutcomeRecord
|
||||
{
|
||||
Status = OutcomeStatus.Success,
|
||||
RecordedBy = "test",
|
||||
RecordedAt = now
|
||||
}, ct);
|
||||
|
||||
// Act
|
||||
var stats = await _store.GetStatsAsync(tenantId, null, ct);
|
||||
|
||||
// Assert
|
||||
stats.TotalDecisions.Should().Be(2);
|
||||
stats.DecisionsWithOutcomes.Should().Be(1);
|
||||
stats.SuccessRate.Should().Be(1.0);
|
||||
stats.ByAction.Should().ContainKey(DecisionAction.Remediate);
|
||||
stats.ByAction.Should().ContainKey(DecisionAction.Defer);
|
||||
}
|
||||
|
||||
private static OpsMemoryRecord CreateRecord(string tenantId, string cveId, DecisionAction action, DateTimeOffset at)
|
||||
{
|
||||
return new OpsMemoryRecord
|
||||
{
|
||||
MemoryId = Guid.NewGuid().ToString(),
|
||||
TenantId = tenantId,
|
||||
RecordedAt = at,
|
||||
Situation = new SituationContext
|
||||
{
|
||||
CveId = cveId,
|
||||
Reachability = ReachabilityStatus.Unknown
|
||||
},
|
||||
Decision = new DecisionRecord
|
||||
{
|
||||
Action = action,
|
||||
Rationale = $"Test decision for {cveId}",
|
||||
DecidedBy = "test",
|
||||
DecidedAt = at
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.OpsMemory\StellaOps.OpsMemory.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,9 @@
|
||||
# StellaOps.OpsMemory.Tests Task Board
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| S312-OPSMEMORY-VERIFY | DONE | Sprint `docs/implplan/SPRINT_20260305_312_DOCS_storage_policy_postgres_rustfs_alignment.md` verification for TASK-312-007: `dotnet test src/AdvisoryAI/__Tests/StellaOps.OpsMemory.Tests/StellaOps.OpsMemory.Tests.csproj -v minimal` passed (50 tests). |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/OpsMemory/__Tests/StellaOps.OpsMemory.Tests/StellaOps.OpsMemory.Tests.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
@@ -0,0 +1,316 @@
|
||||
// <copyright file="OpsMemoryChatProviderTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.OpsMemory.Integration;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
using StellaOps.OpsMemory.Playbook;
|
||||
using StellaOps.OpsMemory.Similarity;
|
||||
using StellaOps.OpsMemory.Storage;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.OpsMemory.Tests.Unit;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for OpsMemoryChatProvider.
|
||||
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-005
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpsMemoryChatProviderTests
|
||||
{
|
||||
private readonly Mock<IOpsMemoryStore> _storeMock;
|
||||
private readonly Mock<ISimilarityVectorGenerator> _vectorGeneratorMock;
|
||||
private readonly Mock<IPlaybookSuggestionService> _playbookMock;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly OpsMemoryChatProvider _sut;
|
||||
|
||||
public OpsMemoryChatProviderTests()
|
||||
{
|
||||
_storeMock = new Mock<IOpsMemoryStore>();
|
||||
_vectorGeneratorMock = new Mock<ISimilarityVectorGenerator>();
|
||||
_playbookMock = new Mock<IPlaybookSuggestionService>();
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
_sut = new OpsMemoryChatProvider(
|
||||
_storeMock.Object,
|
||||
_vectorGeneratorMock.Object,
|
||||
_playbookMock.Object,
|
||||
_timeProvider,
|
||||
NullLogger<OpsMemoryChatProvider>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichContextAsync_WithSimilarDecisions_ReturnsSummaries()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ChatContextRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
CveId = "CVE-2021-44228",
|
||||
Severity = "Critical",
|
||||
MaxSuggestions = 3
|
||||
};
|
||||
|
||||
var pastRecord = CreateTestRecord("om-001", "CVE-2021-44227", OutcomeStatus.Success);
|
||||
var similarMatches = new List<SimilarityMatch>
|
||||
{
|
||||
new SimilarityMatch { Record = pastRecord, SimilarityScore = 0.85 }
|
||||
};
|
||||
|
||||
_vectorGeneratorMock.Setup(v => v.Generate(It.IsAny<SituationContext>()))
|
||||
.Returns(ImmutableArray.Create(0.1f, 0.2f, 0.3f));
|
||||
|
||||
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(similarMatches);
|
||||
|
||||
_playbookMock.Setup(p => p.GetSuggestionsAsync(It.IsAny<SituationContext>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<PlaybookSuggestion>());
|
||||
|
||||
// Act
|
||||
var result = await _sut.EnrichContextAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result.SimilarDecisions);
|
||||
Assert.Equal("om-001", result.SimilarDecisions[0].MemoryId);
|
||||
Assert.Equal(0.85, result.SimilarDecisions[0].SimilarityScore);
|
||||
Assert.Equal(OutcomeStatus.Success, result.SimilarDecisions[0].OutcomeStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichContextAsync_WithNoMatches_ReturnsEmptyContext()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ChatContextRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
CveId = "CVE-2099-99999",
|
||||
MaxSuggestions = 3
|
||||
};
|
||||
|
||||
_vectorGeneratorMock.Setup(v => v.Generate(It.IsAny<SituationContext>()))
|
||||
.Returns(ImmutableArray.Create(0.1f, 0.2f, 0.3f));
|
||||
|
||||
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<SimilarityMatch>());
|
||||
|
||||
_playbookMock.Setup(p => p.GetSuggestionsAsync(It.IsAny<SituationContext>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<PlaybookSuggestion>());
|
||||
|
||||
// Act
|
||||
var result = await _sut.EnrichContextAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result.SimilarDecisions);
|
||||
Assert.Empty(result.ApplicableTactics);
|
||||
Assert.False(result.HasPlaybookEntries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichContextAsync_OrdersBySimilarityThenOutcome()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ChatContextRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
MaxSuggestions = 3
|
||||
};
|
||||
|
||||
// Note: Failed outcomes are filtered out, so only successful decisions are returned
|
||||
var similarMatches = new List<SimilarityMatch>
|
||||
{
|
||||
new SimilarityMatch
|
||||
{
|
||||
Record = CreateTestRecord("om-001", "CVE-1", OutcomeStatus.PartialSuccess),
|
||||
SimilarityScore = 0.9
|
||||
},
|
||||
new SimilarityMatch
|
||||
{
|
||||
Record = CreateTestRecord("om-002", "CVE-2", OutcomeStatus.Success),
|
||||
SimilarityScore = 0.9
|
||||
},
|
||||
new SimilarityMatch
|
||||
{
|
||||
Record = CreateTestRecord("om-003", "CVE-3", OutcomeStatus.Success),
|
||||
SimilarityScore = 0.8
|
||||
}
|
||||
};
|
||||
|
||||
_vectorGeneratorMock.Setup(v => v.Generate(It.IsAny<SituationContext>()))
|
||||
.Returns(ImmutableArray.Create(0.1f, 0.2f, 0.3f));
|
||||
|
||||
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(similarMatches);
|
||||
|
||||
_playbookMock.Setup(p => p.GetSuggestionsAsync(It.IsAny<SituationContext>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<PlaybookSuggestion>());
|
||||
|
||||
// Act
|
||||
var result = await _sut.EnrichContextAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, result.SimilarDecisions.Length);
|
||||
// Highest similarity with success outcome should be first
|
||||
Assert.Equal("om-002", result.SimilarDecisions[0].MemoryId);
|
||||
Assert.Equal("om-001", result.SimilarDecisions[1].MemoryId);
|
||||
Assert.Equal("om-003", result.SimilarDecisions[2].MemoryId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordFromActionAsync_CreatesValidRecord()
|
||||
{
|
||||
// Arrange
|
||||
var action = new ActionExecutionResult
|
||||
{
|
||||
Action = DecisionAction.Accept,
|
||||
CveId = "CVE-2021-44228",
|
||||
Component = "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.0",
|
||||
Success = true,
|
||||
Rationale = "Risk accepted due to air-gapped environment",
|
||||
ExecutedAt = _timeProvider.GetUtcNow(),
|
||||
ActorId = "user-123"
|
||||
};
|
||||
|
||||
var conversationContext = new ConversationContext
|
||||
{
|
||||
ConversationId = "conv-001",
|
||||
TenantId = "tenant-1",
|
||||
UserId = "user-123",
|
||||
TurnNumber = 5
|
||||
};
|
||||
|
||||
_vectorGeneratorMock.Setup(v => v.Generate(It.IsAny<SituationContext>()))
|
||||
.Returns(ImmutableArray.Create(0.1f, 0.2f, 0.3f));
|
||||
|
||||
OpsMemoryRecord? capturedRecord = null;
|
||||
_storeMock.Setup(s => s.RecordDecisionAsync(It.IsAny<OpsMemoryRecord>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<OpsMemoryRecord, CancellationToken>((r, _) => capturedRecord = r)
|
||||
.ReturnsAsync((OpsMemoryRecord r, CancellationToken _) => r);
|
||||
|
||||
// Act
|
||||
var result = await _sut.RecordFromActionAsync(action, conversationContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.StartsWith("om-chat-", result.MemoryId);
|
||||
Assert.Equal("tenant-1", result.TenantId);
|
||||
Assert.Equal(DecisionAction.Accept, result.Decision.Action);
|
||||
Assert.Equal("user-123", result.Decision.DecidedBy);
|
||||
Assert.Contains("Risk accepted", result.Decision.Rationale);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentDecisionsAsync_ReturnsFormattedSummaries()
|
||||
{
|
||||
// Arrange
|
||||
var records = new PagedResult<OpsMemoryRecord>
|
||||
{
|
||||
Items = ImmutableArray.Create(
|
||||
CreateTestRecord("om-001", "CVE-2021-44228", OutcomeStatus.Success),
|
||||
CreateTestRecord("om-002", "CVE-2021-44229", OutcomeStatus.Failure)
|
||||
),
|
||||
TotalCount = 2,
|
||||
NextCursor = null
|
||||
};
|
||||
|
||||
_storeMock.Setup(s => s.QueryAsync(It.IsAny<OpsMemoryQuery>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(records);
|
||||
|
||||
// Act
|
||||
var result = await _sut.GetRecentDecisionsAsync("tenant-1", 10, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal("om-001", result[0].MemoryId);
|
||||
Assert.Equal("om-002", result[1].MemoryId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichContextAsync_GeneratesPromptSegment()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ChatContextRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
CveId = "CVE-2021-44228",
|
||||
MaxSuggestions = 3
|
||||
};
|
||||
|
||||
var pastRecord = CreateTestRecord("om-001", "CVE-2021-44227", OutcomeStatus.Success);
|
||||
var similarMatches = new List<SimilarityMatch>
|
||||
{
|
||||
new SimilarityMatch { Record = pastRecord, SimilarityScore = 0.85 }
|
||||
};
|
||||
|
||||
_vectorGeneratorMock.Setup(v => v.Generate(It.IsAny<SituationContext>()))
|
||||
.Returns(ImmutableArray.Create(0.1f, 0.2f, 0.3f));
|
||||
|
||||
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(similarMatches);
|
||||
|
||||
_playbookMock.Setup(p => p.GetSuggestionsAsync(It.IsAny<SituationContext>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<PlaybookSuggestion>());
|
||||
|
||||
// Act
|
||||
var result = await _sut.EnrichContextAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.PromptSegment);
|
||||
Assert.Contains("Previous Similar Situations", result.PromptSegment);
|
||||
Assert.Contains("CVE-2021-44227", result.PromptSegment);
|
||||
Assert.Contains("[SUCCESS]", result.PromptSegment);
|
||||
}
|
||||
|
||||
private static OpsMemoryRecord CreateTestRecord(string memoryId, string cveId, OutcomeStatus? outcomeStatus)
|
||||
{
|
||||
return new OpsMemoryRecord
|
||||
{
|
||||
MemoryId = memoryId,
|
||||
TenantId = "tenant-1",
|
||||
RecordedAt = DateTimeOffset.UtcNow,
|
||||
Situation = new SituationContext
|
||||
{
|
||||
CveId = cveId,
|
||||
Severity = "High",
|
||||
Reachability = ReachabilityStatus.Unknown
|
||||
},
|
||||
Decision = new DecisionRecord
|
||||
{
|
||||
Action = DecisionAction.Accept,
|
||||
Rationale = "Test rationale",
|
||||
DecidedBy = "test-user",
|
||||
DecidedAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
Outcome = outcomeStatus.HasValue
|
||||
? new OutcomeRecord
|
||||
{
|
||||
Status = outcomeStatus.Value,
|
||||
RecordedBy = "test-user",
|
||||
RecordedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake time provider for testing.
|
||||
/// </summary>
|
||||
public sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset now)
|
||||
{
|
||||
_now = now;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public void Advance(TimeSpan duration)
|
||||
{
|
||||
_now = _now.Add(duration);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
// <copyright file="OpsMemoryContextEnricherTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.OpsMemory.Integration;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.OpsMemory.Tests.Unit;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for OpsMemoryContextEnricher.
|
||||
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-005
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpsMemoryContextEnricherTests
|
||||
{
|
||||
private readonly Mock<IOpsMemoryChatProvider> _chatProviderMock;
|
||||
private readonly OpsMemoryContextEnricher _sut;
|
||||
|
||||
public OpsMemoryContextEnricherTests()
|
||||
{
|
||||
_chatProviderMock = new Mock<IOpsMemoryChatProvider>();
|
||||
_sut = new OpsMemoryContextEnricher(
|
||||
_chatProviderMock.Object,
|
||||
NullLogger<OpsMemoryContextEnricher>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichPromptAsync_WithDecisions_IncludesContextBlock()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ChatContextRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
CveId = "CVE-2021-44228"
|
||||
};
|
||||
|
||||
var context = new OpsMemoryContext
|
||||
{
|
||||
SimilarDecisions = ImmutableArray.Create(
|
||||
new PastDecisionSummary
|
||||
{
|
||||
MemoryId = "om-001",
|
||||
CveId = "CVE-2021-44227",
|
||||
Action = DecisionAction.Accept,
|
||||
OutcomeStatus = OutcomeStatus.Success,
|
||||
SimilarityScore = 0.85,
|
||||
DecidedAt = DateTimeOffset.UtcNow,
|
||||
Rationale = "Air-gapped environment"
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
_chatProviderMock.Setup(p => p.EnrichContextAsync(It.IsAny<ChatContextRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(context);
|
||||
|
||||
// Act
|
||||
var result = await _sut.EnrichPromptAsync(request, existingPrompt: null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasEnrichment);
|
||||
Assert.Contains("Institutional Memory", result.EnrichedPrompt);
|
||||
Assert.Contains("CVE-2021-44227", result.EnrichedPrompt);
|
||||
Assert.Contains("Accept", result.EnrichedPrompt);
|
||||
Assert.Contains("[SUCCESS]", result.EnrichedPrompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichPromptAsync_WithExistingPrompt_AppendsContextBlock()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ChatContextRequest { TenantId = "tenant-1" };
|
||||
var existingPrompt = "User asks about vulnerability remediation.";
|
||||
|
||||
var context = new OpsMemoryContext
|
||||
{
|
||||
SimilarDecisions = ImmutableArray.Create(
|
||||
new PastDecisionSummary
|
||||
{
|
||||
MemoryId = "om-001",
|
||||
CveId = "CVE-2021-44228",
|
||||
Action = DecisionAction.Remediate,
|
||||
OutcomeStatus = OutcomeStatus.Success,
|
||||
SimilarityScore = 0.9,
|
||||
DecidedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
_chatProviderMock.Setup(p => p.EnrichContextAsync(It.IsAny<ChatContextRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(context);
|
||||
|
||||
// Act
|
||||
var result = await _sut.EnrichPromptAsync(request, existingPrompt, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.StartsWith("User asks about vulnerability remediation.", result.EnrichedPrompt);
|
||||
Assert.Contains("Institutional Memory", result.EnrichedPrompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichPromptAsync_WithNoEntries_ReturnsEmptyEnrichment()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ChatContextRequest { TenantId = "tenant-1" };
|
||||
|
||||
var emptyContext = new OpsMemoryContext
|
||||
{
|
||||
SimilarDecisions = ImmutableArray<PastDecisionSummary>.Empty,
|
||||
ApplicableTactics = ImmutableArray<Tactic>.Empty,
|
||||
RelevantKnownIssues = ImmutableArray<KnownIssue>.Empty
|
||||
};
|
||||
|
||||
_chatProviderMock.Setup(p => p.EnrichContextAsync(It.IsAny<ChatContextRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(emptyContext);
|
||||
|
||||
// Act
|
||||
var result = await _sut.EnrichPromptAsync(request, existingPrompt: null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.HasEnrichment);
|
||||
Assert.Empty(result.EnrichedPrompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichPromptAsync_WithTactics_IncludesPlaybookSection()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ChatContextRequest { TenantId = "tenant-1" };
|
||||
|
||||
var context = new OpsMemoryContext
|
||||
{
|
||||
ApplicableTactics = ImmutableArray.Create(
|
||||
new Tactic
|
||||
{
|
||||
TacticId = "tac-001",
|
||||
Name = "Immediate Patch",
|
||||
Description = "Apply vendor patch immediately for critical vulnerabilities",
|
||||
RecommendedAction = DecisionAction.Remediate,
|
||||
Confidence = 0.9,
|
||||
SuccessRate = 0.95
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
_chatProviderMock.Setup(p => p.EnrichContextAsync(It.IsAny<ChatContextRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(context);
|
||||
|
||||
// Act
|
||||
var result = await _sut.EnrichPromptAsync(request, existingPrompt: null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasEnrichment);
|
||||
Assert.Contains("Playbook Tactics", result.EnrichedPrompt);
|
||||
Assert.Contains("Immediate Patch", result.EnrichedPrompt);
|
||||
Assert.Contains("95", result.EnrichedPrompt); // Success rate formatted as percentage
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSystemPromptAddition_WithPlaybookEntries_IncludesInstructions()
|
||||
{
|
||||
// Arrange
|
||||
var context = new OpsMemoryContext
|
||||
{
|
||||
SimilarDecisions = ImmutableArray.Create(
|
||||
new PastDecisionSummary
|
||||
{
|
||||
MemoryId = "om-001",
|
||||
CveId = "CVE-2021-44228",
|
||||
Action = DecisionAction.Accept,
|
||||
SimilarityScore = 0.85,
|
||||
DecidedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
),
|
||||
ApplicableTactics = ImmutableArray.Create(
|
||||
new Tactic
|
||||
{
|
||||
TacticId = "tac-001",
|
||||
Name = "Test Tactic",
|
||||
Description = "Test description",
|
||||
Confidence = 0.8
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = OpsMemoryContextEnricher.BuildSystemPromptAddition(context);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("OpsMemory Instructions", result);
|
||||
Assert.Contains("[ops-mem:ID]", result);
|
||||
Assert.Contains("1 similar situations", result);
|
||||
Assert.Contains("1 tactics available", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildContextBlock_WithLessonsLearned_IncludesLessons()
|
||||
{
|
||||
// Arrange
|
||||
var context = new OpsMemoryContext
|
||||
{
|
||||
SimilarDecisions = ImmutableArray.Create(
|
||||
new PastDecisionSummary
|
||||
{
|
||||
MemoryId = "om-001",
|
||||
CveId = "CVE-2021-44228",
|
||||
Action = DecisionAction.Accept,
|
||||
OutcomeStatus = OutcomeStatus.Failure,
|
||||
SimilarityScore = 0.85,
|
||||
DecidedAt = DateTimeOffset.UtcNow,
|
||||
LessonsLearned = "Should have patched despite low priority"
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = OpsMemoryContextEnricher.BuildContextBlock(context);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("[FAILED]", result);
|
||||
Assert.Contains("Should have patched", result);
|
||||
Assert.Contains("Lessons:", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnrichPromptAsync_TracksReferencedMemoryIds()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ChatContextRequest { TenantId = "tenant-1" };
|
||||
|
||||
var context = new OpsMemoryContext
|
||||
{
|
||||
SimilarDecisions = ImmutableArray.Create(
|
||||
new PastDecisionSummary
|
||||
{
|
||||
MemoryId = "om-001",
|
||||
CveId = "CVE-1",
|
||||
Action = DecisionAction.Accept,
|
||||
SimilarityScore = 0.9,
|
||||
DecidedAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
new PastDecisionSummary
|
||||
{
|
||||
MemoryId = "om-002",
|
||||
CveId = "CVE-2",
|
||||
Action = DecisionAction.Remediate,
|
||||
SimilarityScore = 0.8,
|
||||
DecidedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
_chatProviderMock.Setup(p => p.EnrichContextAsync(It.IsAny<ChatContextRequest>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(context);
|
||||
|
||||
// Act
|
||||
var result = await _sut.EnrichPromptAsync(request, existingPrompt: null, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.DecisionsReferenced.Length);
|
||||
Assert.Contains("om-001", result.DecisionsReferenced);
|
||||
Assert.Contains("om-002", result.DecisionsReferenced);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
using StellaOps.OpsMemory.Playbook;
|
||||
using StellaOps.OpsMemory.Similarity;
|
||||
using StellaOps.OpsMemory.Storage;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.OpsMemory.Tests.Unit;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for PlaybookSuggestionService.
|
||||
/// Sprint: SPRINT_20260107_006_004 Task OM-010
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public class PlaybookSuggestionServiceTests
|
||||
{
|
||||
private readonly Mock<IOpsMemoryStore> _storeMock;
|
||||
private readonly SimilarityVectorGenerator _vectorGenerator;
|
||||
private readonly PlaybookSuggestionService _service;
|
||||
|
||||
public PlaybookSuggestionServiceTests()
|
||||
{
|
||||
_storeMock = new Mock<IOpsMemoryStore>();
|
||||
_vectorGenerator = new SimilarityVectorGenerator();
|
||||
_service = new PlaybookSuggestionService(
|
||||
_storeMock.Object,
|
||||
_vectorGenerator,
|
||||
NullLogger<PlaybookSuggestionService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSuggestionsAsync_WithNoSimilarRecords_ReturnsEmptySuggestions()
|
||||
{
|
||||
// Arrange
|
||||
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<SimilarityMatch>());
|
||||
|
||||
var request = new PlaybookSuggestionRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
Situation = new SituationContext
|
||||
{
|
||||
CveId = "CVE-2023-12345",
|
||||
Severity = "high"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GetSuggestionsAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Suggestions.Should().BeEmpty();
|
||||
result.AnalyzedRecords.Should().Be(0);
|
||||
result.HasSuggestions.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSuggestionsAsync_WithSimilarRecords_ReturnsSuggestions()
|
||||
{
|
||||
// Arrange
|
||||
var pastRecord = CreatePastRecord(DecisionAction.Remediate, OutcomeStatus.Success);
|
||||
var matches = new List<SimilarityMatch>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Record = pastRecord,
|
||||
SimilarityScore = 0.85
|
||||
}
|
||||
};
|
||||
|
||||
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(matches);
|
||||
|
||||
var request = new PlaybookSuggestionRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
Situation = new SituationContext
|
||||
{
|
||||
CveId = "CVE-2023-12345",
|
||||
Severity = "high",
|
||||
Reachability = ReachabilityStatus.Reachable
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GetSuggestionsAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Suggestions.Should().NotBeEmpty();
|
||||
result.AnalyzedRecords.Should().Be(1);
|
||||
result.HasSuggestions.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSuggestionsAsync_GroupsByAction_AndRanksBySuccessRate()
|
||||
{
|
||||
// Arrange - multiple records with same action
|
||||
var remediateSuccess1 = CreatePastRecord(DecisionAction.Remediate, OutcomeStatus.Success);
|
||||
var remediateSuccess2 = CreatePastRecord(DecisionAction.Remediate, OutcomeStatus.Success);
|
||||
var acceptPartial = CreatePastRecord(DecisionAction.Accept, OutcomeStatus.PartialSuccess);
|
||||
|
||||
var matches = new List<SimilarityMatch>
|
||||
{
|
||||
new() { Record = remediateSuccess1, SimilarityScore = 0.9 },
|
||||
new() { Record = remediateSuccess2, SimilarityScore = 0.85 },
|
||||
new() { Record = acceptPartial, SimilarityScore = 0.8 }
|
||||
};
|
||||
|
||||
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(matches);
|
||||
|
||||
var request = new PlaybookSuggestionRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
Situation = new SituationContext { Severity = "high" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GetSuggestionsAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Suggestions.Should().NotBeEmpty();
|
||||
// Remediate should rank higher due to 100% success rate
|
||||
var firstSuggestion = result.Suggestions.First();
|
||||
firstSuggestion.Action.Should().Be(DecisionAction.Remediate);
|
||||
firstSuggestion.SuccessRate.Should().Be(1.0); // 100%
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSuggestionsAsync_RespectsMaxSuggestionsLimit()
|
||||
{
|
||||
// Arrange - more actions than max suggestions
|
||||
var remediate = CreatePastRecord(DecisionAction.Remediate, OutcomeStatus.Success);
|
||||
var accept = CreatePastRecord(DecisionAction.Accept, OutcomeStatus.Success);
|
||||
var mitigate = CreatePastRecord(DecisionAction.Mitigate, OutcomeStatus.Success);
|
||||
var defer = CreatePastRecord(DecisionAction.Defer, OutcomeStatus.Success);
|
||||
|
||||
var matches = new List<SimilarityMatch>
|
||||
{
|
||||
new() { Record = remediate, SimilarityScore = 0.9 },
|
||||
new() { Record = accept, SimilarityScore = 0.85 },
|
||||
new() { Record = mitigate, SimilarityScore = 0.8 },
|
||||
new() { Record = defer, SimilarityScore = 0.75 }
|
||||
};
|
||||
|
||||
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(matches);
|
||||
|
||||
var request = new PlaybookSuggestionRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
Situation = new SituationContext { Severity = "high" },
|
||||
MaxSuggestions = 2
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GetSuggestionsAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Suggestions.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSuggestionsAsync_IncludesEvidenceRecords()
|
||||
{
|
||||
// Arrange
|
||||
var pastRecord = CreatePastRecord(DecisionAction.Remediate, OutcomeStatus.Success);
|
||||
var matches = new List<SimilarityMatch>
|
||||
{
|
||||
new() { Record = pastRecord, SimilarityScore = 0.9 }
|
||||
};
|
||||
|
||||
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(matches);
|
||||
|
||||
var request = new PlaybookSuggestionRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
Situation = new SituationContext { Severity = "high" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GetSuggestionsAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Suggestions.Should().NotBeEmpty();
|
||||
var suggestion = result.Suggestions.First();
|
||||
suggestion.Evidence.Should().NotBeEmpty();
|
||||
suggestion.Evidence.First().MemoryId.Should().Be(pastRecord.MemoryId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSuggestionsAsync_CalculatesConfidence()
|
||||
{
|
||||
// Arrange
|
||||
var pastRecord = CreatePastRecord(DecisionAction.Remediate, OutcomeStatus.Success);
|
||||
var matches = new List<SimilarityMatch>
|
||||
{
|
||||
new() { Record = pastRecord, SimilarityScore = 0.9 }
|
||||
};
|
||||
|
||||
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(matches);
|
||||
|
||||
var request = new PlaybookSuggestionRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
Situation = new SituationContext { Severity = "high" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GetSuggestionsAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Suggestions.Should().NotBeEmpty();
|
||||
var suggestion = result.Suggestions.First();
|
||||
suggestion.Confidence.Should().BeGreaterThan(0);
|
||||
suggestion.Confidence.Should().BeLessThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSuggestionsAsync_GeneratesRationale()
|
||||
{
|
||||
// Arrange
|
||||
var pastRecord = CreatePastRecord(DecisionAction.Remediate, OutcomeStatus.Success);
|
||||
var matches = new List<SimilarityMatch>
|
||||
{
|
||||
new() { Record = pastRecord, SimilarityScore = 0.9 }
|
||||
};
|
||||
|
||||
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(matches);
|
||||
|
||||
var request = new PlaybookSuggestionRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
Situation = new SituationContext { Severity = "high" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.GetSuggestionsAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Suggestions.Should().NotBeEmpty();
|
||||
var suggestion = result.Suggestions.First();
|
||||
suggestion.Rationale.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
private static OpsMemoryRecord CreatePastRecord(DecisionAction action, OutcomeStatus outcome)
|
||||
{
|
||||
var memoryId = Guid.NewGuid().ToString("N");
|
||||
return new OpsMemoryRecord
|
||||
{
|
||||
MemoryId = memoryId,
|
||||
TenantId = "tenant-1",
|
||||
RecordedAt = DateTimeOffset.UtcNow.AddDays(-7),
|
||||
Situation = new SituationContext
|
||||
{
|
||||
CveId = "CVE-2023-44487",
|
||||
Severity = "high",
|
||||
Reachability = ReachabilityStatus.Reachable
|
||||
},
|
||||
Decision = new DecisionRecord
|
||||
{
|
||||
Action = action,
|
||||
Rationale = "Test decision rationale",
|
||||
DecidedBy = "test-user",
|
||||
DecidedAt = DateTimeOffset.UtcNow.AddDays(-7)
|
||||
},
|
||||
Outcome = new OutcomeRecord
|
||||
{
|
||||
Status = outcome,
|
||||
ResolutionTime = TimeSpan.FromHours(4),
|
||||
RecordedBy = "test-user",
|
||||
RecordedAt = DateTimeOffset.UtcNow.AddDays(-5)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
using StellaOps.OpsMemory.Similarity;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.OpsMemory.Tests.Unit;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for SimilarityVectorGenerator.
|
||||
/// Sprint: SPRINT_20260107_006_004 Task OM-010
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public class SimilarityVectorGeneratorTests
|
||||
{
|
||||
private readonly SimilarityVectorGenerator _generator = new();
|
||||
|
||||
[Fact]
|
||||
public void Generate_WithEmptySituation_SetsReachabilityUnknownDimension()
|
||||
{
|
||||
// Arrange
|
||||
var situation = new SituationContext();
|
||||
|
||||
// Act
|
||||
var vector = _generator.Generate(situation);
|
||||
|
||||
// Assert
|
||||
vector.Should().HaveCount(50);
|
||||
// Reachability defaults to Unknown (dimension 15)
|
||||
vector[15].Should().BeGreaterThan(0, "reachability Unknown should be set");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_WithSeverity_SetsCorrectDimension()
|
||||
{
|
||||
// Arrange
|
||||
var lowSeverity = new SituationContext { Severity = "low" };
|
||||
var criticalSeverity = new SituationContext { Severity = "critical" };
|
||||
|
||||
// Act
|
||||
var lowVector = _generator.Generate(lowSeverity);
|
||||
var criticalVector = _generator.Generate(criticalSeverity);
|
||||
|
||||
// Assert
|
||||
// Severity dimensions are 10-14 (none, low, medium, high, critical)
|
||||
lowVector[11].Should().BeGreaterThan(0, "low severity should set dimension 11");
|
||||
criticalVector[14].Should().BeGreaterThan(0, "critical severity should set dimension 14");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_WithReachability_SetsCorrectDimension()
|
||||
{
|
||||
// Arrange
|
||||
var reachable = new SituationContext { Reachability = ReachabilityStatus.Reachable };
|
||||
var notReachable = new SituationContext { Reachability = ReachabilityStatus.NotReachable };
|
||||
|
||||
// Act
|
||||
var reachableVector = _generator.Generate(reachable);
|
||||
var notReachableVector = _generator.Generate(notReachable);
|
||||
|
||||
// Assert
|
||||
// Reachability dimensions are 15-18 (unknown=0, reachable=1, notReachable=2, potential=3)
|
||||
reachableVector[16].Should().BeGreaterThan(0, "reachable should set dimension 16");
|
||||
notReachableVector[17].Should().BeGreaterThan(0, "not reachable should set dimension 17");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_WithEpssScore_SetsBandDimension()
|
||||
{
|
||||
// Arrange
|
||||
var lowEpss = new SituationContext { EpssScore = 0.1 }; // Band 0-0.2 -> dim 19
|
||||
var highEpss = new SituationContext { EpssScore = 0.9 }; // Band 0.8-1.0 -> dim 23
|
||||
|
||||
// Act
|
||||
var lowVector = _generator.Generate(lowEpss);
|
||||
var highVector = _generator.Generate(highEpss);
|
||||
|
||||
// Assert
|
||||
lowVector[19].Should().BeGreaterThan(0, "low EPSS should set dimension 19");
|
||||
highVector[23].Should().BeGreaterThan(0, "high EPSS should set dimension 23");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_WithCvssScore_SetsBandDimension()
|
||||
{
|
||||
// Arrange
|
||||
var lowCvss = new SituationContext { CvssScore = 1.5 }; // Band 0-2 -> dim 24
|
||||
var highCvss = new SituationContext { CvssScore = 9.5 }; // Band 8-10 -> dim 28
|
||||
|
||||
// Act
|
||||
var lowVector = _generator.Generate(lowCvss);
|
||||
var highVector = _generator.Generate(highCvss);
|
||||
|
||||
// Assert
|
||||
lowVector[24].Should().BeGreaterThan(0, "low CVSS should set dimension 24");
|
||||
highVector[28].Should().BeGreaterThan(0, "high CVSS should set dimension 28");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_WithKev_SetsKevDimension()
|
||||
{
|
||||
// Arrange
|
||||
var kev = new SituationContext { IsKev = true };
|
||||
var notKev = new SituationContext { IsKev = false };
|
||||
|
||||
// Act
|
||||
var kevVector = _generator.Generate(kev);
|
||||
var notKevVector = _generator.Generate(notKev);
|
||||
|
||||
// Assert
|
||||
kevVector[29].Should().BeGreaterThan(0, "KEV should set dimension 29");
|
||||
notKevVector[29].Should().Be(0, "non-KEV should not set dimension 29");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_WithNpmComponent_SetsComponentTypeDimension()
|
||||
{
|
||||
// Arrange
|
||||
var npm = new SituationContext { Component = "pkg:npm/lodash@4.17.21" };
|
||||
|
||||
// Act
|
||||
var vector = _generator.Generate(npm);
|
||||
|
||||
// Assert
|
||||
// Component type dimensions are 30-39, npm is index 0
|
||||
vector[30].Should().BeGreaterThan(0, "npm component should set dimension 30");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_WithContextTags_SetsTagDimensions()
|
||||
{
|
||||
// Arrange
|
||||
var situation = new SituationContext
|
||||
{
|
||||
ContextTags = ImmutableArray.Create("production", "external-facing", "payment")
|
||||
};
|
||||
|
||||
// Act
|
||||
var vector = _generator.Generate(situation);
|
||||
|
||||
// Assert
|
||||
// Context tag dimensions are 40-49
|
||||
// production=0, external-facing=3, payment=5
|
||||
vector[40].Should().BeGreaterThan(0, "production tag should set dimension 40");
|
||||
vector[43].Should().BeGreaterThan(0, "external-facing tag should set dimension 43");
|
||||
vector[45].Should().BeGreaterThan(0, "payment tag should set dimension 45");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_VectorIsNormalized()
|
||||
{
|
||||
// Arrange
|
||||
var situation = new SituationContext
|
||||
{
|
||||
Severity = "high",
|
||||
Reachability = ReachabilityStatus.Reachable,
|
||||
EpssScore = 0.5,
|
||||
CvssScore = 7.5,
|
||||
IsKev = true,
|
||||
ContextTags = ImmutableArray.Create("production", "payment")
|
||||
};
|
||||
|
||||
// Act
|
||||
var vector = _generator.Generate(situation);
|
||||
|
||||
// Assert - L2 norm should be approximately 1 (unit vector)
|
||||
var norm = Math.Sqrt(vector.Sum(v => v * v));
|
||||
norm.Should().BeApproximately(1.0, 0.001, "vector should be normalized to unit length");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CosineSimilarity_IdenticalVectors_ReturnsOne()
|
||||
{
|
||||
// Arrange
|
||||
var situation = new SituationContext
|
||||
{
|
||||
Severity = "high",
|
||||
Reachability = ReachabilityStatus.Reachable
|
||||
};
|
||||
var vector = _generator.Generate(situation);
|
||||
|
||||
// Act
|
||||
var similarity = SimilarityVectorGenerator.CosineSimilarity(vector, vector);
|
||||
|
||||
// Assert
|
||||
similarity.Should().BeApproximately(1.0, 0.001, "identical vectors should have similarity 1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CosineSimilarity_DifferentSituations_ReturnsLowerThanIdentical()
|
||||
{
|
||||
// Arrange - create two different vectors
|
||||
var situation1 = new SituationContext
|
||||
{
|
||||
Severity = "low",
|
||||
Reachability = ReachabilityStatus.Reachable,
|
||||
EpssScore = 0.1
|
||||
};
|
||||
var situation2 = new SituationContext
|
||||
{
|
||||
Severity = "critical",
|
||||
Reachability = ReachabilityStatus.NotReachable,
|
||||
EpssScore = 0.9
|
||||
};
|
||||
|
||||
var vector1 = _generator.Generate(situation1);
|
||||
var vector2 = _generator.Generate(situation2);
|
||||
|
||||
// Act
|
||||
var sameSimilarity = SimilarityVectorGenerator.CosineSimilarity(vector1, vector1);
|
||||
var diffSimilarity = SimilarityVectorGenerator.CosineSimilarity(vector1, vector2);
|
||||
|
||||
// Assert - different situations should have lower similarity than identical
|
||||
diffSimilarity.Should().BeLessThan(sameSimilarity, "different situations should have lower similarity than identical");
|
||||
diffSimilarity.Should().BeGreaterThanOrEqualTo(0, "similarity should be non-negative");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CosineSimilarity_SimilarSituations_ReturnsHighValue()
|
||||
{
|
||||
// Arrange
|
||||
var situation1 = new SituationContext
|
||||
{
|
||||
Severity = "high",
|
||||
Reachability = ReachabilityStatus.Reachable,
|
||||
EpssScore = 0.75,
|
||||
IsKev = true
|
||||
};
|
||||
var situation2 = new SituationContext
|
||||
{
|
||||
Severity = "high",
|
||||
Reachability = ReachabilityStatus.Reachable,
|
||||
EpssScore = 0.8, // Same band as 0.75
|
||||
IsKev = true
|
||||
};
|
||||
|
||||
var vector1 = _generator.Generate(situation1);
|
||||
var vector2 = _generator.Generate(situation2);
|
||||
|
||||
// Act
|
||||
var similarity = SimilarityVectorGenerator.CosineSimilarity(vector1, vector2);
|
||||
|
||||
// Assert - 0.8+ is still quite similar for real-world situations
|
||||
similarity.Should().BeGreaterThan(0.8, "similar situations should have high similarity");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CosineSimilarity_DifferentLengthVectors_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
var vector1 = ImmutableArray.Create(1.0f, 0.0f, 0.0f);
|
||||
var vector2 = ImmutableArray.Create(1.0f, 0.0f);
|
||||
|
||||
// Act & Assert
|
||||
Action act = () => SimilarityVectorGenerator.CosineSimilarity(vector1, vector2);
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithMessage("*same dimension*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingFactors_SameSeverity_ReportsMatch()
|
||||
{
|
||||
// Arrange
|
||||
var a = new SituationContext { Severity = "high" };
|
||||
var b = new SituationContext { Severity = "high" };
|
||||
|
||||
// Act
|
||||
var factors = _generator.GetMatchingFactors(a, b);
|
||||
|
||||
// Assert
|
||||
factors.Should().Contain(f => f.Contains("severity", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingFactors_SameReachability_ReportsMatch()
|
||||
{
|
||||
// Arrange
|
||||
var a = new SituationContext { Reachability = ReachabilityStatus.Reachable };
|
||||
var b = new SituationContext { Reachability = ReachabilityStatus.Reachable };
|
||||
|
||||
// Act
|
||||
var factors = _generator.GetMatchingFactors(a, b);
|
||||
|
||||
// Assert
|
||||
factors.Should().Contain(f => f.Contains("reachability", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingFactors_BothKev_ReportsMatch()
|
||||
{
|
||||
// Arrange
|
||||
var a = new SituationContext { IsKev = true };
|
||||
var b = new SituationContext { IsKev = true };
|
||||
|
||||
// Act
|
||||
var factors = _generator.GetMatchingFactors(a, b);
|
||||
|
||||
// Assert
|
||||
factors.Should().Contain(f => f.Contains("KEV", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingFactors_OverlappingTags_ReportsMatch()
|
||||
{
|
||||
// Arrange
|
||||
var a = new SituationContext { ContextTags = ImmutableArray.Create("production", "api") };
|
||||
var b = new SituationContext { ContextTags = ImmutableArray.Create("production", "frontend") };
|
||||
|
||||
// Act
|
||||
var factors = _generator.GetMatchingFactors(a, b);
|
||||
|
||||
// Assert
|
||||
factors.Should().Contain(f => f.Contains("production", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingFactors_SimilarEpss_ReportsMatch()
|
||||
{
|
||||
// Arrange
|
||||
var a = new SituationContext { EpssScore = 0.75 };
|
||||
var b = new SituationContext { EpssScore = 0.80 }; // Within 0.2 threshold
|
||||
|
||||
// Act
|
||||
var factors = _generator.GetMatchingFactors(a, b);
|
||||
|
||||
// Assert
|
||||
factors.Should().Contain(f => f.Contains("EPSS", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMatchingFactors_DifferentEpss_DoesNotReportMatch()
|
||||
{
|
||||
// Arrange
|
||||
var a = new SituationContext { EpssScore = 0.2 };
|
||||
var b = new SituationContext { EpssScore = 0.8 }; // More than 0.2 difference
|
||||
|
||||
// Act
|
||||
var factors = _generator.GetMatchingFactors(a, b);
|
||||
|
||||
// Assert
|
||||
factors.Should().NotContain(f => f.Contains("EPSS", StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user