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