sprints work
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
// <copyright file="IOpsMemoryChatProvider.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
|
||||
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,358 @@
|
||||
// <copyright file="OpsMemoryChatProvider.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
using StellaOps.OpsMemory.Playbook;
|
||||
using StellaOps.OpsMemory.Similarity;
|
||||
using StellaOps.OpsMemory.Storage;
|
||||
|
||||
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
|
||||
var summaries = similarRecords
|
||||
.Select(r => CreateSummary(r.Record, r.SimilarityScore))
|
||||
.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,281 @@
|
||||
// <copyright file="OpsMemoryContextEnricher.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
|
||||
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,160 @@
|
||||
// <copyright file="OpsMemoryDecisionHook.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -296,5 +296,8 @@ public enum OutcomeStatus
|
||||
NegativeOutcome,
|
||||
|
||||
/// <summary>Outcome is still pending.</summary>
|
||||
Pending
|
||||
Pending,
|
||||
|
||||
/// <summary>Decision failed to execute.</summary>
|
||||
Failure
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// <copyright file="IPlaybookSuggestionService.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </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);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ namespace StellaOps.OpsMemory.Playbook;
|
||||
/// Service for generating playbook suggestions based on past decisions.
|
||||
/// Sprint: SPRINT_20260107_006_004 Task OM-005
|
||||
/// </summary>
|
||||
public sealed class PlaybookSuggestionService
|
||||
public sealed class PlaybookSuggestionService : IPlaybookSuggestionService
|
||||
{
|
||||
private readonly IOpsMemoryStore _store;
|
||||
private readonly SimilarityVectorGenerator _vectorGenerator;
|
||||
@@ -95,6 +95,25 @@ public sealed class PlaybookSuggestionService
|
||||
};
|
||||
}
|
||||
|
||||
/// <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,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// <copyright file="ISimilarityVectorGenerator.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -13,4 +13,7 @@
|
||||
<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,87 @@
|
||||
// <copyright file="IKnownIssueStore.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.OpsMemory.Integration;
|
||||
|
||||
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);
|
||||
}
|
||||
139
src/OpsMemory/StellaOps.OpsMemory/Storage/ITacticStore.cs
Normal file
139
src/OpsMemory/StellaOps.OpsMemory/Storage/ITacticStore.cs
Normal file
@@ -0,0 +1,139 @@
|
||||
// <copyright file="ITacticStore.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.OpsMemory.Integration;
|
||||
|
||||
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,378 @@
|
||||
// <copyright file="OpsMemoryChatProviderIntegrationTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </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.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
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class OpsMemoryChatProviderIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private const string ConnectionString = "Host=localhost;Port=5433;Database=stellaops_test;Username=stellaops_ci;Password=ci_test_password";
|
||||
|
||||
private NpgsqlDataSource? _dataSource;
|
||||
private PostgresOpsMemoryStore? _store;
|
||||
private OpsMemoryChatProvider? _chatProvider;
|
||||
private SimilarityVectorGenerator? _vectorGenerator;
|
||||
private string _testTenantId = string.Empty;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
_dataSource = NpgsqlDataSource.Create(ConnectionString);
|
||||
_store = new PostgresOpsMemoryStore(
|
||||
_dataSource,
|
||||
NullLogger<PostgresOpsMemoryStore>.Instance);
|
||||
|
||||
_vectorGenerator = new SimilarityVectorGenerator();
|
||||
|
||||
// Create chat provider with mock stores for known issues and tactics
|
||||
_chatProvider = new OpsMemoryChatProvider(
|
||||
_store,
|
||||
new NullKnownIssueStore(),
|
||||
new NullTacticStore(),
|
||||
_vectorGenerator,
|
||||
NullLogger<OpsMemoryChatProvider>.Instance);
|
||||
|
||||
_testTenantId = $"test-{Guid.NewGuid()}";
|
||||
|
||||
// Clean up any existing test data
|
||||
await using var cmd = _dataSource.CreateCommand("DELETE FROM opsmemory.decisions WHERE tenant_id LIKE 'test-%'");
|
||||
await cmd.ExecuteNonQueryAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
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 IKnownIssueStore for testing.
|
||||
/// </summary>
|
||||
private sealed class NullKnownIssueStore : IKnownIssueStore
|
||||
{
|
||||
public Task<KnownIssue> CreateAsync(KnownIssue issue, CancellationToken ct) =>
|
||||
Task.FromResult(issue);
|
||||
|
||||
public Task<KnownIssue?> UpdateAsync(KnownIssue issue, CancellationToken ct) =>
|
||||
Task.FromResult<KnownIssue?>(issue);
|
||||
|
||||
public Task<KnownIssue?> GetByIdAsync(string issueId, string tenantId, CancellationToken ct) =>
|
||||
Task.FromResult<KnownIssue?>(null);
|
||||
|
||||
public Task<ImmutableArray<KnownIssue>> FindByContextAsync(
|
||||
string tenantId, string? cveId, string? component, CancellationToken ct) =>
|
||||
Task.FromResult(ImmutableArray<KnownIssue>.Empty);
|
||||
|
||||
public Task<ImmutableArray<KnownIssue>> ListAsync(
|
||||
string tenantId, int limit, int offset, CancellationToken ct) =>
|
||||
Task.FromResult(ImmutableArray<KnownIssue>.Empty);
|
||||
|
||||
public Task<bool> DeleteAsync(string issueId, string tenantId, CancellationToken ct) =>
|
||||
Task.FromResult(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null implementation of ITacticStore for testing.
|
||||
/// </summary>
|
||||
private sealed class NullTacticStore : ITacticStore
|
||||
{
|
||||
public Task<Tactic> CreateAsync(Tactic tactic, string tenantId, CancellationToken ct) =>
|
||||
Task.FromResult(tactic);
|
||||
|
||||
public Task<Tactic?> UpdateAsync(Tactic tactic, string tenantId, CancellationToken ct) =>
|
||||
Task.FromResult<Tactic?>(tactic);
|
||||
|
||||
public Task<Tactic?> GetByIdAsync(string tacticId, string tenantId, CancellationToken ct) =>
|
||||
Task.FromResult<Tactic?>(null);
|
||||
|
||||
public Task<ImmutableArray<Tactic>> FindByTriggerAsync(
|
||||
string tenantId, TacticTrigger trigger, CancellationToken ct) =>
|
||||
Task.FromResult(ImmutableArray<Tactic>.Empty);
|
||||
|
||||
public Task<ImmutableArray<Tactic>> ListAsync(
|
||||
string tenantId, int limit, int offset, CancellationToken ct) =>
|
||||
Task.FromResult(ImmutableArray<Tactic>.Empty);
|
||||
|
||||
public Task<Tactic?> RecordUsageAsync(
|
||||
string tacticId, string tenantId, bool wasSuccessful, CancellationToken ct) =>
|
||||
Task.FromResult<Tactic?>(null);
|
||||
|
||||
public Task<bool> DeleteAsync(string tacticId, string tenantId, CancellationToken ct) =>
|
||||
Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
// <copyright file="OpsMemoryChatProviderTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </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
|
||||
};
|
||||
|
||||
var similarMatches = new List<SimilarityMatch>
|
||||
{
|
||||
new SimilarityMatch
|
||||
{
|
||||
Record = CreateTestRecord("om-001", "CVE-1", OutcomeStatus.Failure),
|
||||
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.AcceptRisk,
|
||||
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.AcceptRisk, 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,
|
||||
HasMore = false
|
||||
};
|
||||
|
||||
_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.AcceptRisk,
|
||||
Rationale = "Test rationale",
|
||||
DecidedBy = "test-user",
|
||||
DecidedAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
Outcome = outcomeStatus.HasValue
|
||||
? new OutcomeRecord
|
||||
{
|
||||
Status = outcomeStatus.Value,
|
||||
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 AGPL-3.0-or-later.
|
||||
// </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.AcceptRisk,
|
||||
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("AcceptRisk", 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.AcceptRisk,
|
||||
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.AcceptRisk,
|
||||
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.AcceptRisk,
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user