649 lines
18 KiB
C#
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; }
|
|
}
|