sprints work

This commit is contained in:
master
2026-01-10 20:32:13 +02:00
parent 0d5eda86fc
commit 17d0631b8e
189 changed files with 40667 additions and 497 deletions

View File

@@ -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; } = [];
}

View File

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

View File

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

View File

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

View File

@@ -296,5 +296,8 @@ public enum OutcomeStatus
NegativeOutcome,
/// <summary>Outcome is still pending.</summary>
Pending
Pending,
/// <summary>Decision failed to execute.</summary>
Failure
}

View File

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

View File

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

View File

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

View File

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

View File

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

View 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; } = [];
}

View File

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

View File

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

View File

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