Files
git.stella-ops.org/src/AdvisoryAI/StellaOps.AdvisoryAI/Chat/ConversationService.cs
2026-01-08 20:46:43 +02:00

649 lines
18 KiB
C#

// <copyright file="ConversationService.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
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;
/// <summary>
/// Service for managing AdvisoryAI conversation sessions.
/// Sprint: SPRINT_20260107_006_003 Task CH-001
/// </summary>
public sealed class ConversationService : IConversationService
{
private readonly ConcurrentDictionary<string, Conversation> _conversations = new();
private readonly ConversationOptions _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<ConversationService> _logger;
private readonly IGuidGenerator _guidGenerator;
/// <summary>
/// Initializes a new instance of the <see cref="ConversationService"/> class.
/// </summary>
public ConversationService(
IOptions<ConversationOptions> options,
TimeProvider timeProvider,
IGuidGenerator guidGenerator,
ILogger<ConversationService> logger)
{
_options = options.Value;
_timeProvider = timeProvider;
_guidGenerator = guidGenerator;
_logger = logger;
}
/// <inheritdoc/>
public Task<Conversation> 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<ConversationTurn>.Empty,
Metadata = request.Metadata ?? ImmutableDictionary<string, string>.Empty
};
_conversations[conversationId] = conversation;
_logger.LogDebug(
"Created conversation {ConversationId} for user {UserId}",
conversationId, request.UserId);
return Task.FromResult(conversation);
}
/// <inheritdoc/>
public Task<Conversation?> GetAsync(
string conversationId,
CancellationToken cancellationToken = default)
{
_conversations.TryGetValue(conversationId, out var conversation);
return Task.FromResult(conversation);
}
/// <inheritdoc/>
public Task<ConversationTurn> 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<EvidenceLink>.Empty,
ProposedActions = request.ProposedActions ?? ImmutableArray<ProposedAction>.Empty,
Metadata = request.Metadata ?? ImmutableDictionary<string, string>.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);
}
/// <inheritdoc/>
public Task<bool> DeleteAsync(
string conversationId,
CancellationToken cancellationToken = default)
{
var removed = _conversations.TryRemove(conversationId, out _);
if (removed)
{
_logger.LogDebug("Deleted conversation {ConversationId}", conversationId);
}
return Task.FromResult(removed);
}
/// <inheritdoc/>
public Task<IReadOnlyList<Conversation>> 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<IReadOnlyList<Conversation>>(result);
}
/// <inheritdoc/>
public Task<Conversation?> UpdateContextAsync(
string conversationId,
ConversationContext context,
CancellationToken cancellationToken = default)
{
if (!_conversations.TryGetValue(conversationId, out var conversation))
{
return Task.FromResult<Conversation?>(null);
}
var updatedConversation = conversation with
{
Context = context,
UpdatedAt = _timeProvider.GetUtcNow()
};
_conversations[conversationId] = updatedConversation;
return Task.FromResult<Conversation?>(updatedConversation);
}
/// <summary>
/// Removes stale conversations older than the retention period.
/// </summary>
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");
}
}
/// <summary>
/// Interface for conversation session management.
/// </summary>
public interface IConversationService
{
/// <summary>
/// Creates a new conversation session.
/// </summary>
Task<Conversation> CreateAsync(ConversationRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a conversation by ID.
/// </summary>
Task<Conversation?> GetAsync(string conversationId, CancellationToken cancellationToken = default);
/// <summary>
/// Adds a turn (message) to a conversation.
/// </summary>
Task<ConversationTurn> AddTurnAsync(string conversationId, TurnRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a conversation.
/// </summary>
Task<bool> DeleteAsync(string conversationId, CancellationToken cancellationToken = default);
/// <summary>
/// Lists conversations for a tenant/user.
/// </summary>
Task<IReadOnlyList<Conversation>> ListAsync(string tenantId, string? userId = null, int? limit = null, CancellationToken cancellationToken = default);
/// <summary>
/// Updates the context for a conversation.
/// </summary>
Task<Conversation?> UpdateContextAsync(string conversationId, ConversationContext context, CancellationToken cancellationToken = default);
}
/// <summary>
/// Interface for GUID generation (for testability).
/// </summary>
public interface IGuidGenerator
{
/// <summary>
/// Generates a new GUID.
/// </summary>
Guid NewGuid();
}
/// <summary>
/// Default GUID generator.
/// </summary>
public sealed class DefaultGuidGenerator : IGuidGenerator
{
/// <inheritdoc/>
public Guid NewGuid() => Guid.NewGuid();
}
/// <summary>
/// A conversation session.
/// </summary>
public sealed record Conversation
{
/// <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 when the conversation was created.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Gets when the conversation was last updated.
/// </summary>
public required DateTimeOffset UpdatedAt { get; init; }
/// <summary>
/// Gets the conversation context.
/// </summary>
public required ConversationContext Context { get; init; }
/// <summary>
/// Gets the conversation turns (messages).
/// </summary>
public ImmutableArray<ConversationTurn> Turns { get; init; } = ImmutableArray<ConversationTurn>.Empty;
/// <summary>
/// Gets additional metadata.
/// </summary>
public ImmutableDictionary<string, string> Metadata { get; init; } =
ImmutableDictionary<string, string>.Empty;
/// <summary>
/// Gets the turn count.
/// </summary>
public int TurnCount => Turns.Length;
}
/// <summary>
/// Context information for a conversation.
/// </summary>
public sealed record ConversationContext
{
/// <summary>
/// Gets the tenant identifier for resolution.
/// </summary>
public string? TenantId { get; init; }
/// <summary>
/// Gets the current CVE being discussed.
/// </summary>
public string? CurrentCveId { get; init; }
/// <summary>
/// Gets the current component PURL.
/// </summary>
public string? CurrentComponent { get; init; }
/// <summary>
/// Gets the current image digest.
/// </summary>
public string? CurrentImageDigest { get; init; }
/// <summary>
/// Gets the scan ID in context.
/// </summary>
public string? ScanId { get; init; }
/// <summary>
/// Gets the SBOM ID in context.
/// </summary>
public string? SbomId { get; init; }
/// <summary>
/// Gets accumulated evidence links.
/// </summary>
public ImmutableArray<EvidenceLink> EvidenceLinks { get; init; } =
ImmutableArray<EvidenceLink>.Empty;
/// <summary>
/// Gets the policy context.
/// </summary>
public PolicyContext? Policy { get; init; }
}
/// <summary>
/// Policy context for a conversation.
/// </summary>
public sealed record PolicyContext
{
/// <summary>
/// Gets the policy IDs in scope.
/// </summary>
public ImmutableArray<string> PolicyIds { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Gets the user's permissions.
/// </summary>
public ImmutableArray<string> Permissions { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Gets whether automation is allowed.
/// </summary>
public bool AutomationAllowed { get; init; }
}
/// <summary>
/// A single turn in a conversation.
/// </summary>
public sealed record ConversationTurn
{
/// <summary>
/// Gets the turn identifier.
/// </summary>
public required string TurnId { get; init; }
/// <summary>
/// Gets the role (user/assistant/system).
/// </summary>
public required TurnRole Role { get; init; }
/// <summary>
/// Gets the message content.
/// </summary>
public required string Content { get; init; }
/// <summary>
/// Gets the timestamp.
/// </summary>
public required DateTimeOffset Timestamp { get; init; }
/// <summary>
/// Gets evidence links referenced in this turn.
/// </summary>
public ImmutableArray<EvidenceLink> EvidenceLinks { get; init; } =
ImmutableArray<EvidenceLink>.Empty;
/// <summary>
/// Gets proposed actions in this turn.
/// </summary>
public ImmutableArray<ProposedAction> ProposedActions { get; init; } =
ImmutableArray<ProposedAction>.Empty;
/// <summary>
/// Gets additional metadata.
/// </summary>
public ImmutableDictionary<string, string> Metadata { get; init; } =
ImmutableDictionary<string, string>.Empty;
}
/// <summary>
/// Turn role (who is speaking).
/// </summary>
public enum TurnRole
{
/// <summary>User message.</summary>
User,
/// <summary>Assistant (AdvisoryAI) response.</summary>
Assistant,
/// <summary>System message.</summary>
System
}
/// <summary>
/// A link to evidence (SBOM, DSSE, call-graph, etc.).
/// </summary>
public sealed record EvidenceLink
{
/// <summary>
/// Gets the link type.
/// </summary>
public required EvidenceLinkType Type { get; init; }
/// <summary>
/// Gets the URI (e.g., "sbom:abc123", "dsse:xyz789").
/// </summary>
public required string Uri { get; init; }
/// <summary>
/// Gets the display label.
/// </summary>
public string? Label { get; init; }
/// <summary>
/// Gets the confidence score (if applicable).
/// </summary>
public double? Confidence { get; init; }
}
/// <summary>
/// Types of evidence links.
/// </summary>
public enum EvidenceLinkType
{
/// <summary>SBOM reference.</summary>
Sbom,
/// <summary>DSSE envelope.</summary>
Dsse,
/// <summary>Call graph node.</summary>
CallGraph,
/// <summary>Reachability analysis.</summary>
Reachability,
/// <summary>Runtime trace.</summary>
RuntimeTrace,
/// <summary>VEX statement.</summary>
Vex,
/// <summary>Documentation link.</summary>
Documentation,
/// <summary>Authority key.</summary>
AuthorityKey,
/// <summary>Other evidence.</summary>
Other
}
/// <summary>
/// A proposed action from AdvisoryAI.
/// </summary>
public sealed record ProposedAction
{
/// <summary>
/// Gets the action type.
/// </summary>
public required string ActionType { get; init; }
/// <summary>
/// Gets the action label for display.
/// </summary>
public required string Label { get; init; }
/// <summary>
/// Gets the action payload (JSON).
/// </summary>
public string? Payload { get; init; }
/// <summary>
/// Gets whether this action requires confirmation.
/// </summary>
public bool RequiresConfirmation { get; init; } = true;
/// <summary>
/// Gets the policy gate for this action.
/// </summary>
public string? PolicyGate { get; init; }
}
/// <summary>
/// Request to create a conversation.
/// </summary>
public sealed record ConversationRequest
{
/// <summary>
/// Gets the tenant ID.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Gets the user ID.
/// </summary>
public required string UserId { get; init; }
/// <summary>
/// Gets the initial context.
/// </summary>
public ConversationContext? InitialContext { get; init; }
/// <summary>
/// Gets additional metadata.
/// </summary>
public ImmutableDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to add a turn to a conversation.
/// </summary>
public sealed record TurnRequest
{
/// <summary>
/// Gets the role.
/// </summary>
public required TurnRole Role { get; init; }
/// <summary>
/// Gets the content.
/// </summary>
public required string Content { get; init; }
/// <summary>
/// Gets evidence links in this turn.
/// </summary>
public ImmutableArray<EvidenceLink>? EvidenceLinks { get; init; }
/// <summary>
/// Gets proposed actions in this turn.
/// </summary>
public ImmutableArray<ProposedAction>? ProposedActions { get; init; }
/// <summary>
/// Gets additional metadata.
/// </summary>
public ImmutableDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Configuration options for conversations.
/// </summary>
public sealed class ConversationOptions
{
/// <summary>
/// Gets or sets the maximum turns per conversation.
/// Default: 50.
/// </summary>
public int MaxTurnsPerConversation { get; set; } = 50;
/// <summary>
/// Gets or sets the conversation retention period.
/// Default: 7 days.
/// </summary>
public TimeSpan ConversationRetention { get; set; } = TimeSpan.FromDays(7);
}
/// <summary>
/// Exception thrown when a conversation is not found.
/// </summary>
public sealed class ConversationNotFoundException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="ConversationNotFoundException"/> class.
/// </summary>
public ConversationNotFoundException(string conversationId)
: base($"Conversation '{conversationId}' not found")
{
ConversationId = conversationId;
}
/// <summary>
/// Gets the conversation ID that was not found.
/// </summary>
public string ConversationId { get; }
}