// // Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later. // using System.Collections.Concurrent; using System.Collections.Immutable; using System.Security.Cryptography; using System.Text; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace StellaOps.AdvisoryAI.Chat; /// /// Service for managing AdvisoryAI conversation sessions. /// Sprint: SPRINT_20260107_006_003 Task CH-001 /// public sealed class ConversationService : IConversationService { private readonly ConcurrentDictionary _conversations = new(); private readonly ConversationOptions _options; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; private readonly IGuidGenerator _guidGenerator; /// /// Initializes a new instance of the class. /// public ConversationService( IOptions options, TimeProvider timeProvider, IGuidGenerator guidGenerator, ILogger logger) { _options = options.Value; _timeProvider = timeProvider; _guidGenerator = guidGenerator; _logger = logger; } /// public Task CreateAsync( ConversationRequest request, CancellationToken cancellationToken = default) { var conversationId = GenerateConversationId(request); var now = _timeProvider.GetUtcNow(); var conversation = new Conversation { ConversationId = conversationId, TenantId = request.TenantId, UserId = request.UserId, CreatedAt = now, UpdatedAt = now, Context = request.InitialContext ?? new ConversationContext(), Turns = ImmutableArray.Empty, Metadata = request.Metadata ?? ImmutableDictionary.Empty }; _conversations[conversationId] = conversation; _logger.LogDebug( "Created conversation {ConversationId} for user {UserId}", conversationId, request.UserId); return Task.FromResult(conversation); } /// public Task GetAsync( string conversationId, CancellationToken cancellationToken = default) { _conversations.TryGetValue(conversationId, out var conversation); return Task.FromResult(conversation); } /// public Task AddTurnAsync( string conversationId, TurnRequest request, CancellationToken cancellationToken = default) { if (!_conversations.TryGetValue(conversationId, out var conversation)) { throw new ConversationNotFoundException(conversationId); } var now = _timeProvider.GetUtcNow(); var turnId = $"{conversationId}-{conversation.Turns.Length + 1}"; var turn = new ConversationTurn { TurnId = turnId, Role = request.Role, Content = request.Content, Timestamp = now, EvidenceLinks = request.EvidenceLinks ?? ImmutableArray.Empty, ProposedActions = request.ProposedActions ?? ImmutableArray.Empty, Metadata = request.Metadata ?? ImmutableDictionary.Empty }; // Enforce max turns limit var turns = conversation.Turns; if (turns.Length >= _options.MaxTurnsPerConversation) { // Remove oldest turn to make room turns = turns.RemoveAt(0); _logger.LogDebug( "Conversation {ConversationId} exceeded max turns, removed oldest", conversationId); } var updatedConversation = conversation with { Turns = turns.Add(turn), UpdatedAt = now }; _conversations[conversationId] = updatedConversation; return Task.FromResult(turn); } /// public Task DeleteAsync( string conversationId, CancellationToken cancellationToken = default) { var removed = _conversations.TryRemove(conversationId, out _); if (removed) { _logger.LogDebug("Deleted conversation {ConversationId}", conversationId); } return Task.FromResult(removed); } /// public Task> ListAsync( string tenantId, string? userId = null, int? limit = null, CancellationToken cancellationToken = default) { var query = _conversations.Values .Where(c => c.TenantId == tenantId); if (userId is not null) { query = query.Where(c => c.UserId == userId); } var result = query .OrderByDescending(c => c.UpdatedAt) .Take(limit ?? 50) .ToList(); return Task.FromResult>(result); } /// public Task UpdateContextAsync( string conversationId, ConversationContext context, CancellationToken cancellationToken = default) { if (!_conversations.TryGetValue(conversationId, out var conversation)) { return Task.FromResult(null); } var updatedConversation = conversation with { Context = context, UpdatedAt = _timeProvider.GetUtcNow() }; _conversations[conversationId] = updatedConversation; return Task.FromResult(updatedConversation); } /// /// Removes stale conversations older than the retention period. /// public int PruneStaleConversations() { var cutoff = _timeProvider.GetUtcNow() - _options.ConversationRetention; var staleIds = _conversations .Where(kv => kv.Value.UpdatedAt < cutoff) .Select(kv => kv.Key) .ToList(); foreach (var id in staleIds) { _conversations.TryRemove(id, out _); } if (staleIds.Count > 0) { _logger.LogInformation( "Pruned {Count} stale conversations older than {Cutoff}", staleIds.Count, cutoff); } return staleIds.Count; } private string GenerateConversationId(ConversationRequest request) { // Generate deterministic UUID based on tenant, user, and timestamp var input = $"{request.TenantId}:{request.UserId}:{_timeProvider.GetUtcNow():O}:{_guidGenerator.NewGuid()}"; var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input)); // Format as UUID var guidBytes = new byte[16]; Array.Copy(hash, guidBytes, 16); // Set version 5 (SHA-1/name-based) bits guidBytes[6] = (byte)((guidBytes[6] & 0x0F) | 0x50); guidBytes[8] = (byte)((guidBytes[8] & 0x3F) | 0x80); return new Guid(guidBytes).ToString("N"); } } /// /// Interface for conversation session management. /// public interface IConversationService { /// /// Creates a new conversation session. /// Task CreateAsync(ConversationRequest request, CancellationToken cancellationToken = default); /// /// Gets a conversation by ID. /// Task GetAsync(string conversationId, CancellationToken cancellationToken = default); /// /// Adds a turn (message) to a conversation. /// Task AddTurnAsync(string conversationId, TurnRequest request, CancellationToken cancellationToken = default); /// /// Deletes a conversation. /// Task DeleteAsync(string conversationId, CancellationToken cancellationToken = default); /// /// Lists conversations for a tenant/user. /// Task> ListAsync(string tenantId, string? userId = null, int? limit = null, CancellationToken cancellationToken = default); /// /// Updates the context for a conversation. /// Task UpdateContextAsync(string conversationId, ConversationContext context, CancellationToken cancellationToken = default); } /// /// Interface for GUID generation (for testability). /// public interface IGuidGenerator { /// /// Generates a new GUID. /// Guid NewGuid(); } /// /// Default GUID generator. /// public sealed class DefaultGuidGenerator : IGuidGenerator { /// public Guid NewGuid() => Guid.NewGuid(); } /// /// A conversation session. /// public sealed record Conversation { /// /// Gets the conversation identifier. /// public required string ConversationId { get; init; } /// /// Gets the tenant identifier. /// public required string TenantId { get; init; } /// /// Gets the user identifier. /// public required string UserId { get; init; } /// /// Gets when the conversation was created. /// public required DateTimeOffset CreatedAt { get; init; } /// /// Gets when the conversation was last updated. /// public required DateTimeOffset UpdatedAt { get; init; } /// /// Gets the conversation context. /// public required ConversationContext Context { get; init; } /// /// Gets the conversation turns (messages). /// public ImmutableArray Turns { get; init; } = ImmutableArray.Empty; /// /// Gets additional metadata. /// public ImmutableDictionary Metadata { get; init; } = ImmutableDictionary.Empty; /// /// Gets the turn count. /// public int TurnCount => Turns.Length; } /// /// Context information for a conversation. /// public sealed record ConversationContext { /// /// Gets the tenant identifier for resolution. /// public string? TenantId { get; init; } /// /// Gets the current CVE being discussed. /// public string? CurrentCveId { get; init; } /// /// Gets the current component PURL. /// public string? CurrentComponent { get; init; } /// /// Gets the current image digest. /// public string? CurrentImageDigest { get; init; } /// /// Gets the scan ID in context. /// public string? ScanId { get; init; } /// /// Gets the SBOM ID in context. /// public string? SbomId { get; init; } /// /// Gets accumulated evidence links. /// public ImmutableArray EvidenceLinks { get; init; } = ImmutableArray.Empty; /// /// Gets the policy context. /// public PolicyContext? Policy { get; init; } } /// /// Policy context for a conversation. /// public sealed record PolicyContext { /// /// Gets the policy IDs in scope. /// public ImmutableArray PolicyIds { get; init; } = ImmutableArray.Empty; /// /// Gets the user's permissions. /// public ImmutableArray Permissions { get; init; } = ImmutableArray.Empty; /// /// Gets whether automation is allowed. /// public bool AutomationAllowed { get; init; } } /// /// A single turn in a conversation. /// public sealed record ConversationTurn { /// /// Gets the turn identifier. /// public required string TurnId { get; init; } /// /// Gets the role (user/assistant/system). /// public required TurnRole Role { get; init; } /// /// Gets the message content. /// public required string Content { get; init; } /// /// Gets the timestamp. /// public required DateTimeOffset Timestamp { get; init; } /// /// Gets evidence links referenced in this turn. /// public ImmutableArray EvidenceLinks { get; init; } = ImmutableArray.Empty; /// /// Gets proposed actions in this turn. /// public ImmutableArray ProposedActions { get; init; } = ImmutableArray.Empty; /// /// Gets additional metadata. /// public ImmutableDictionary Metadata { get; init; } = ImmutableDictionary.Empty; } /// /// Turn role (who is speaking). /// public enum TurnRole { /// User message. User, /// Assistant (AdvisoryAI) response. Assistant, /// System message. System } /// /// A link to evidence (SBOM, DSSE, call-graph, etc.). /// public sealed record EvidenceLink { /// /// Gets the link type. /// public required EvidenceLinkType Type { get; init; } /// /// Gets the URI (e.g., "sbom:abc123", "dsse:xyz789"). /// public required string Uri { get; init; } /// /// Gets the display label. /// public string? Label { get; init; } /// /// Gets the confidence score (if applicable). /// public double? Confidence { get; init; } } /// /// Types of evidence links. /// public enum EvidenceLinkType { /// SBOM reference. Sbom, /// DSSE envelope. Dsse, /// Call graph node. CallGraph, /// Reachability analysis. Reachability, /// Runtime trace. RuntimeTrace, /// VEX statement. Vex, /// Documentation link. Documentation, /// Authority key. AuthorityKey, /// Other evidence. Other } /// /// A proposed action from AdvisoryAI. /// public sealed record ProposedAction { /// /// Gets the action type. /// public required string ActionType { get; init; } /// /// Gets the action label for display. /// public required string Label { get; init; } /// /// Gets the action payload (JSON). /// public string? Payload { get; init; } /// /// Gets whether this action requires confirmation. /// public bool RequiresConfirmation { get; init; } = true; /// /// Gets the policy gate for this action. /// public string? PolicyGate { get; init; } } /// /// Request to create a conversation. /// public sealed record ConversationRequest { /// /// Gets the tenant ID. /// public required string TenantId { get; init; } /// /// Gets the user ID. /// public required string UserId { get; init; } /// /// Gets the initial context. /// public ConversationContext? InitialContext { get; init; } /// /// Gets additional metadata. /// public ImmutableDictionary? Metadata { get; init; } } /// /// Request to add a turn to a conversation. /// public sealed record TurnRequest { /// /// Gets the role. /// public required TurnRole Role { get; init; } /// /// Gets the content. /// public required string Content { get; init; } /// /// Gets evidence links in this turn. /// public ImmutableArray? EvidenceLinks { get; init; } /// /// Gets proposed actions in this turn. /// public ImmutableArray? ProposedActions { get; init; } /// /// Gets additional metadata. /// public ImmutableDictionary? Metadata { get; init; } } /// /// Configuration options for conversations. /// public sealed class ConversationOptions { /// /// Gets or sets the maximum turns per conversation. /// Default: 50. /// public int MaxTurnsPerConversation { get; set; } = 50; /// /// Gets or sets the conversation retention period. /// Default: 7 days. /// public TimeSpan ConversationRetention { get; set; } = TimeSpan.FromDays(7); } /// /// Exception thrown when a conversation is not found. /// public sealed class ConversationNotFoundException : Exception { /// /// Initializes a new instance of the class. /// public ConversationNotFoundException(string conversationId) : base($"Conversation '{conversationId}' not found") { ConversationId = conversationId; } /// /// Gets the conversation ID that was not found. /// public string ConversationId { get; } }