more audit work
This commit is contained in:
289
src/AdvisoryAI/StellaOps.AdvisoryAI/Chat/ActionProposalParser.cs
Normal file
289
src/AdvisoryAI/StellaOps.AdvisoryAI/Chat/ActionProposalParser.cs
Normal file
@@ -0,0 +1,289 @@
|
||||
// <copyright file="ActionProposalParser.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Parses model output for proposed actions.
|
||||
/// Sprint: SPRINT_20260107_006_003 Task CH-004
|
||||
/// </summary>
|
||||
public sealed partial class ActionProposalParser
|
||||
{
|
||||
private static readonly ImmutableDictionary<string, ActionDefinition> ActionDefinitions =
|
||||
new Dictionary<string, ActionDefinition>
|
||||
{
|
||||
["approve"] = new ActionDefinition
|
||||
{
|
||||
Type = "approve",
|
||||
Description = "Accept risk with expiry",
|
||||
RequiredRole = "approver",
|
||||
RequiredParams = ImmutableArray.Create("cve_id"),
|
||||
OptionalParams = ImmutableArray.Create("expiry", "rationale", "component")
|
||||
},
|
||||
["quarantine"] = new ActionDefinition
|
||||
{
|
||||
Type = "quarantine",
|
||||
Description = "Block deployment",
|
||||
RequiredRole = "operator",
|
||||
RequiredParams = ImmutableArray.Create("image_digest"),
|
||||
OptionalParams = ImmutableArray.Create("reason", "duration")
|
||||
},
|
||||
["defer"] = new ActionDefinition
|
||||
{
|
||||
Type = "defer",
|
||||
Description = "Mark as under investigation",
|
||||
RequiredRole = "triage",
|
||||
RequiredParams = ImmutableArray.Create("cve_id"),
|
||||
OptionalParams = ImmutableArray.Create("until", "assignee", "notes")
|
||||
},
|
||||
["generate_manifest"] = new ActionDefinition
|
||||
{
|
||||
Type = "generate_manifest",
|
||||
Description = "Create integration manifest",
|
||||
RequiredRole = "admin",
|
||||
RequiredParams = ImmutableArray.Create("integration_type"),
|
||||
OptionalParams = ImmutableArray.Create("name", "scopes")
|
||||
},
|
||||
["create_vex"] = new ActionDefinition
|
||||
{
|
||||
Type = "create_vex",
|
||||
Description = "Draft VEX statement",
|
||||
RequiredRole = "issuer",
|
||||
RequiredParams = ImmutableArray.Create("product", "vulnerability"),
|
||||
OptionalParams = ImmutableArray.Create("status", "justification", "statement")
|
||||
}
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
/// <summary>
|
||||
/// Parses model output for action proposals.
|
||||
/// </summary>
|
||||
/// <param name="modelOutput">The raw model output.</param>
|
||||
/// <param name="userPermissions">The user's permissions/roles.</param>
|
||||
/// <returns>Parsed action proposals.</returns>
|
||||
public ActionParseResult Parse(string modelOutput, ImmutableArray<string> userPermissions)
|
||||
{
|
||||
var proposals = new List<ParsedActionProposal>();
|
||||
var warnings = new List<string>();
|
||||
|
||||
// Match action button format: [Label]{action:type,param1=value1}
|
||||
var matches = ActionButtonRegex().Matches(modelOutput);
|
||||
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
var label = match.Groups["label"].Value;
|
||||
var actionSpec = match.Groups["spec"].Value;
|
||||
|
||||
var parseResult = ParseActionSpec(actionSpec, label, userPermissions);
|
||||
|
||||
if (parseResult.Proposal is not null)
|
||||
{
|
||||
proposals.Add(parseResult.Proposal);
|
||||
}
|
||||
|
||||
if (parseResult.Warning is not null)
|
||||
{
|
||||
warnings.Add(parseResult.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for inline action markers
|
||||
var inlineMatches = InlineActionRegex().Matches(modelOutput);
|
||||
foreach (Match match in inlineMatches)
|
||||
{
|
||||
var actionType = match.Groups["type"].Value.ToLowerInvariant();
|
||||
var paramsStr = match.Groups["params"].Value;
|
||||
|
||||
var parseResult = ParseActionSpec($"action:{actionType},{paramsStr}", actionType, userPermissions);
|
||||
|
||||
if (parseResult.Proposal is not null &&
|
||||
!proposals.Any(p => p.ActionType == parseResult.Proposal.ActionType))
|
||||
{
|
||||
proposals.Add(parseResult.Proposal);
|
||||
}
|
||||
|
||||
if (parseResult.Warning is not null)
|
||||
{
|
||||
warnings.Add(parseResult.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
return new ActionParseResult
|
||||
{
|
||||
Proposals = proposals.ToImmutableArray(),
|
||||
Warnings = warnings.ToImmutableArray(),
|
||||
HasBlockedActions = proposals.Any(p => !p.IsAllowed)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strips action markers from model output for display.
|
||||
/// </summary>
|
||||
public string StripActionMarkers(string modelOutput)
|
||||
{
|
||||
var result = ActionButtonRegex().Replace(modelOutput, m => m.Groups["label"].Value);
|
||||
result = InlineActionRegex().Replace(result, string.Empty);
|
||||
return result.Trim();
|
||||
}
|
||||
|
||||
private (ParsedActionProposal? Proposal, string? Warning) ParseActionSpec(
|
||||
string actionSpec,
|
||||
string label,
|
||||
ImmutableArray<string> userPermissions)
|
||||
{
|
||||
// Parse "action:type,param1=value1,param2=value2"
|
||||
if (!actionSpec.StartsWith("action:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return (null, $"Invalid action format: {actionSpec}");
|
||||
}
|
||||
|
||||
var parts = actionSpec[7..].Split(',');
|
||||
if (parts.Length == 0)
|
||||
{
|
||||
return (null, "Action type not specified");
|
||||
}
|
||||
|
||||
var actionType = parts[0].Trim().ToLowerInvariant();
|
||||
|
||||
// Parse parameters
|
||||
var parameters = new Dictionary<string, string>();
|
||||
for (int i = 1; i < parts.Length; i++)
|
||||
{
|
||||
var paramParts = parts[i].Split('=', 2);
|
||||
if (paramParts.Length == 2)
|
||||
{
|
||||
parameters[paramParts[0].Trim()] = paramParts[1].Trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Validate action type
|
||||
if (!ActionDefinitions.TryGetValue(actionType, out var definition))
|
||||
{
|
||||
return (null, $"Unknown action type: {actionType}");
|
||||
}
|
||||
|
||||
// Check permissions
|
||||
var isAllowed = userPermissions.Contains(definition.RequiredRole, StringComparer.OrdinalIgnoreCase);
|
||||
string? blockedReason = null;
|
||||
|
||||
if (!isAllowed)
|
||||
{
|
||||
blockedReason = $"Requires '{definition.RequiredRole}' role";
|
||||
}
|
||||
|
||||
// Validate required parameters
|
||||
var missingParams = definition.RequiredParams
|
||||
.Where(p => !parameters.ContainsKey(p))
|
||||
.ToList();
|
||||
|
||||
if (missingParams.Count > 0)
|
||||
{
|
||||
return (null, $"Missing required parameters: {string.Join(", ", missingParams)}");
|
||||
}
|
||||
|
||||
var proposal = new ParsedActionProposal
|
||||
{
|
||||
ActionType = actionType,
|
||||
Label = label,
|
||||
Parameters = parameters.ToImmutableDictionary(),
|
||||
IsAllowed = isAllowed,
|
||||
BlockedReason = blockedReason,
|
||||
RequiredRole = definition.RequiredRole,
|
||||
Description = definition.Description
|
||||
};
|
||||
|
||||
return (proposal, null);
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\[(?<label>[^\]]+)\]\{(?<spec>action:[^}]+)\}", RegexOptions.Compiled)]
|
||||
private static partial Regex ActionButtonRegex();
|
||||
|
||||
[GeneratedRegex(@"<!--\s*ACTION:\s*(?<type>\w+)\s*(?<params>[^>]*)\s*-->", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||
private static partial Regex InlineActionRegex();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Definition of an action type.
|
||||
/// </summary>
|
||||
internal sealed record ActionDefinition
|
||||
{
|
||||
public required string Type { get; init; }
|
||||
public required string Description { get; init; }
|
||||
public required string RequiredRole { get; init; }
|
||||
public ImmutableArray<string> RequiredParams { get; init; } = ImmutableArray<string>.Empty;
|
||||
public ImmutableArray<string> OptionalParams { get; init; } = ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of parsing action proposals.
|
||||
/// </summary>
|
||||
public sealed record ActionParseResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the parsed action proposals.
|
||||
/// </summary>
|
||||
public ImmutableArray<ParsedActionProposal> Proposals { get; init; } =
|
||||
ImmutableArray<ParsedActionProposal>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets any warnings from parsing.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Warnings { get; init; } =
|
||||
ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether any actions were blocked due to permissions.
|
||||
/// </summary>
|
||||
public bool HasBlockedActions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the allowed proposals only.
|
||||
/// </summary>
|
||||
public ImmutableArray<ParsedActionProposal> AllowedProposals =>
|
||||
Proposals.Where(p => p.IsAllowed).ToImmutableArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A parsed action proposal.
|
||||
/// </summary>
|
||||
public sealed record ParsedActionProposal
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the action type.
|
||||
/// </summary>
|
||||
public required string ActionType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the display label.
|
||||
/// </summary>
|
||||
public required string Label { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the action parameters.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Parameters { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this action is allowed for the user.
|
||||
/// </summary>
|
||||
public bool IsAllowed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the reason the action is blocked (if not allowed).
|
||||
/// </summary>
|
||||
public string? BlockedReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the required role for this action.
|
||||
/// </summary>
|
||||
public required string RequiredRole { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the action description.
|
||||
/// </summary>
|
||||
public required string Description { get; init; }
|
||||
}
|
||||
270
src/AdvisoryAI/StellaOps.AdvisoryAI/Chat/ChatPromptAssembler.cs
Normal file
270
src/AdvisoryAI/StellaOps.AdvisoryAI/Chat/ChatPromptAssembler.cs
Normal file
@@ -0,0 +1,270 @@
|
||||
// <copyright file="ChatPromptAssembler.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Assembles multi-turn prompts for AdvisoryAI chat.
|
||||
/// Sprint: SPRINT_20260107_006_003 Task CH-003
|
||||
/// </summary>
|
||||
public sealed class ChatPromptAssembler
|
||||
{
|
||||
private readonly ChatPromptOptions _options;
|
||||
private readonly ConversationContextBuilder _contextBuilder;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChatPromptAssembler"/> class.
|
||||
/// </summary>
|
||||
public ChatPromptAssembler(
|
||||
IOptions<ChatPromptOptions> options,
|
||||
ConversationContextBuilder contextBuilder)
|
||||
{
|
||||
_options = options.Value;
|
||||
_contextBuilder = contextBuilder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assembles a complete prompt for the LLM.
|
||||
/// </summary>
|
||||
/// <param name="conversation">The conversation to build prompt from.</param>
|
||||
/// <param name="userMessage">The new user message.</param>
|
||||
/// <returns>The assembled prompt.</returns>
|
||||
public AssembledPrompt Assemble(Conversation conversation, string userMessage)
|
||||
{
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
// Add system prompt
|
||||
var systemPrompt = BuildSystemPrompt(conversation.Context);
|
||||
messages.Add(new ChatMessage(ChatMessageRole.System, systemPrompt));
|
||||
|
||||
// Build context and add to system message or as separate context
|
||||
var context = _contextBuilder.Build(conversation, _options.MaxContextTokens);
|
||||
|
||||
// Add conversation history
|
||||
foreach (var turn in context.History)
|
||||
{
|
||||
var role = turn.Role switch
|
||||
{
|
||||
TurnRole.User => ChatMessageRole.User,
|
||||
TurnRole.Assistant => ChatMessageRole.Assistant,
|
||||
TurnRole.System => ChatMessageRole.System,
|
||||
_ => ChatMessageRole.User
|
||||
};
|
||||
|
||||
var content = turn.Content;
|
||||
|
||||
// Include evidence links as footnotes for assistant messages
|
||||
if (turn.Role == TurnRole.Assistant && !turn.EvidenceLinks.IsEmpty)
|
||||
{
|
||||
content = AppendEvidenceFootnotes(content, turn.EvidenceLinks);
|
||||
}
|
||||
|
||||
messages.Add(new ChatMessage(role, content));
|
||||
}
|
||||
|
||||
// Add the new user message
|
||||
messages.Add(new ChatMessage(ChatMessageRole.User, userMessage));
|
||||
|
||||
// Calculate token estimate
|
||||
var totalTokens = messages.Sum(m => EstimateTokens(m.Content));
|
||||
|
||||
return new AssembledPrompt
|
||||
{
|
||||
Messages = messages.ToImmutableArray(),
|
||||
Context = context,
|
||||
EstimatedTokens = totalTokens,
|
||||
SystemPromptVersion = _options.SystemPromptVersion
|
||||
};
|
||||
}
|
||||
|
||||
private string BuildSystemPrompt(ConversationContext conversationContext)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Core identity
|
||||
sb.AppendLine(_options.BaseSystemPrompt);
|
||||
sb.AppendLine();
|
||||
|
||||
// Grounding rules
|
||||
sb.AppendLine("## GROUNDING RULES");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("1. ALWAYS cite internal object links for claims about vulnerabilities, components, or security status.");
|
||||
sb.AppendLine("2. Use the link format: [type:path] for deep links to evidence.");
|
||||
sb.AppendLine("3. NEVER make claims about security status without evidence backing.");
|
||||
sb.AppendLine("4. For actions, present action buttons; do not execute actions directly.");
|
||||
sb.AppendLine("5. If uncertain, clearly state limitations and ask for clarification.");
|
||||
sb.AppendLine();
|
||||
|
||||
// Object link formats
|
||||
sb.AppendLine("## OBJECT LINK FORMATS");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("When referencing internal objects, use these formats:");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("| Type | Format | Example |");
|
||||
sb.AppendLine("|------|--------|---------|");
|
||||
sb.AppendLine("| SBOM | `[sbom:{id}]` | `[sbom:abc123]` |");
|
||||
sb.AppendLine("| Reachability | `[reach:{service}:{function}]` | `[reach:api-gateway:grpc.Server]` |");
|
||||
sb.AppendLine("| Runtime | `[runtime:{service}:traces]` | `[runtime:api-gateway:traces]` |");
|
||||
sb.AppendLine("| VEX | `[vex:{issuer}:{digest}]` | `[vex:stellaops:sha256:abc]` |");
|
||||
sb.AppendLine("| Attestation | `[attest:dsse:{digest}]` | `[attest:dsse:sha256:xyz]` |");
|
||||
sb.AppendLine("| Authority Key | `[auth:keys/{keyId}]` | `[auth:keys/gitlab-oidc]` |");
|
||||
sb.AppendLine("| Documentation | `[docs:{path}]` | `[docs:scopes/ci-webhook]` |");
|
||||
sb.AppendLine();
|
||||
|
||||
// Action proposal format
|
||||
sb.AppendLine("## ACTION PROPOSALS");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("When suggesting actions, use this button format:");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("```");
|
||||
sb.AppendLine("[Action Label]{{action:type,param1=value1,param2=value2}}");
|
||||
sb.AppendLine("```");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Available actions:");
|
||||
sb.AppendLine("- `approve` - Accept risk (requires approver role)");
|
||||
sb.AppendLine("- `quarantine` - Block deployment (requires operator role)");
|
||||
sb.AppendLine("- `defer` - Mark under investigation (requires triage role)");
|
||||
sb.AppendLine("- `generate_manifest` - Create integration manifest (requires admin role)");
|
||||
sb.AppendLine("- `create_vex` - Draft VEX statement (requires issuer role)");
|
||||
sb.AppendLine();
|
||||
|
||||
// Context-specific rules
|
||||
if (conversationContext.CurrentCveId is not null)
|
||||
{
|
||||
sb.AppendLine("## CURRENT FOCUS");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"The user is currently investigating **{conversationContext.CurrentCveId}**.");
|
||||
sb.AppendLine("Prioritize information relevant to this vulnerability.");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
if (conversationContext.Policy is not null)
|
||||
{
|
||||
sb.AppendLine("## USER PERMISSIONS");
|
||||
sb.AppendLine();
|
||||
if (conversationContext.Policy.AutomationAllowed)
|
||||
{
|
||||
sb.AppendLine("- Automation is ALLOWED for this user");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine("- Automation is DISABLED - only suggest actions, don't offer execution");
|
||||
}
|
||||
if (!conversationContext.Policy.Permissions.IsEmpty)
|
||||
{
|
||||
sb.AppendLine($"- Roles: {string.Join(", ", conversationContext.Policy.Permissions)}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string AppendEvidenceFootnotes(string content, ImmutableArray<EvidenceLink> links)
|
||||
{
|
||||
if (links.IsEmpty)
|
||||
{
|
||||
return content;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder(content);
|
||||
sb.AppendLine();
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("---");
|
||||
sb.AppendLine("**Evidence:**");
|
||||
foreach (var link in links.Take(5))
|
||||
{
|
||||
var label = link.Label ?? link.Uri;
|
||||
sb.AppendLine($"- [{label}]({link.Uri})");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static int EstimateTokens(string text)
|
||||
{
|
||||
// Rough estimate: ~4 characters per token for English
|
||||
return (text.Length + 3) / 4;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An assembled prompt ready for LLM invocation.
|
||||
/// </summary>
|
||||
public sealed record AssembledPrompt
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the messages to send to the LLM.
|
||||
/// </summary>
|
||||
public ImmutableArray<ChatMessage> Messages { get; init; } =
|
||||
ImmutableArray<ChatMessage>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the built context.
|
||||
/// </summary>
|
||||
public required BuiltContext Context { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the estimated token count.
|
||||
/// </summary>
|
||||
public int EstimatedTokens { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the system prompt version used.
|
||||
/// </summary>
|
||||
public string? SystemPromptVersion { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A chat message for the LLM.
|
||||
/// </summary>
|
||||
public sealed record ChatMessage(ChatMessageRole Role, string Content);
|
||||
|
||||
/// <summary>
|
||||
/// Chat message roles.
|
||||
/// </summary>
|
||||
public enum ChatMessageRole
|
||||
{
|
||||
/// <summary>System message.</summary>
|
||||
System,
|
||||
|
||||
/// <summary>User message.</summary>
|
||||
User,
|
||||
|
||||
/// <summary>Assistant message.</summary>
|
||||
Assistant
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for chat prompts.
|
||||
/// </summary>
|
||||
public sealed class ChatPromptOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the base system prompt.
|
||||
/// </summary>
|
||||
public string BaseSystemPrompt { get; set; } =
|
||||
"You are AdvisoryAI, an AI assistant for StellaOps, a sovereign container security platform. " +
|
||||
"You help users understand vulnerabilities, navigate security evidence, and make informed decisions. " +
|
||||
"Your responses are grounded in internal evidence and you always cite your sources.";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum tokens for context.
|
||||
/// </summary>
|
||||
public int MaxContextTokens { get; set; } = 4000;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum tokens for history.
|
||||
/// </summary>
|
||||
public int MaxHistoryTokens { get; set; } = 2000;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the system prompt version for tracking.
|
||||
/// </summary>
|
||||
public string SystemPromptVersion { get; set; } = "v1.0.0";
|
||||
}
|
||||
488
src/AdvisoryAI/StellaOps.AdvisoryAI/Chat/ChatResponseStreamer.cs
Normal file
488
src/AdvisoryAI/StellaOps.AdvisoryAI/Chat/ChatResponseStreamer.cs
Normal file
@@ -0,0 +1,488 @@
|
||||
// <copyright file="ChatResponseStreamer.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Streams chat responses as Server-Sent Events.
|
||||
/// Sprint: SPRINT_20260107_006_003 Task CH-006
|
||||
/// </summary>
|
||||
public sealed class ChatResponseStreamer
|
||||
{
|
||||
private readonly ILogger<ChatResponseStreamer> _logger;
|
||||
private readonly StreamingOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChatResponseStreamer"/> class.
|
||||
/// </summary>
|
||||
public ChatResponseStreamer(
|
||||
ILogger<ChatResponseStreamer> logger,
|
||||
StreamingOptions? options = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_options = options ?? new StreamingOptions();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Streams response tokens from an LLM as Server-Sent Events.
|
||||
/// </summary>
|
||||
/// <param name="tokenSource">The source of tokens from the LLM.</param>
|
||||
/// <param name="conversationId">The conversation ID.</param>
|
||||
/// <param name="turnId">The turn ID being generated.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Async enumerable of SSE events.</returns>
|
||||
public async IAsyncEnumerable<StreamEvent> StreamResponseAsync(
|
||||
IAsyncEnumerable<TokenChunk> tokenSource,
|
||||
string conversationId,
|
||||
string turnId,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var contentBuilder = new StringBuilder();
|
||||
var citations = new List<CitationEvent>();
|
||||
var actions = new List<ActionEvent>();
|
||||
var tokenCount = 0;
|
||||
var startTime = DateTimeOffset.UtcNow;
|
||||
|
||||
// Send start event
|
||||
yield return new StreamEvent(StreamEventType.Start, new StartEventData
|
||||
{
|
||||
ConversationId = conversationId,
|
||||
TurnId = turnId,
|
||||
Timestamp = startTime.ToString("O", CultureInfo.InvariantCulture)
|
||||
});
|
||||
|
||||
await foreach (var chunk in tokenSource.WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
tokenCount++;
|
||||
contentBuilder.Append(chunk.Content);
|
||||
|
||||
// Yield token event
|
||||
yield return new StreamEvent(StreamEventType.Token, new TokenEventData
|
||||
{
|
||||
Content = chunk.Content,
|
||||
Index = tokenCount
|
||||
});
|
||||
|
||||
// Check for citations in the accumulated content
|
||||
var newCitations = ExtractNewCitations(contentBuilder.ToString(), citations.Count);
|
||||
foreach (var citation in newCitations)
|
||||
{
|
||||
citations.Add(citation);
|
||||
yield return new StreamEvent(StreamEventType.Citation, citation);
|
||||
}
|
||||
|
||||
// Check for action proposals
|
||||
var newActions = ExtractNewActions(contentBuilder.ToString(), actions.Count);
|
||||
foreach (var action in newActions)
|
||||
{
|
||||
actions.Add(action);
|
||||
yield return new StreamEvent(StreamEventType.Action, action);
|
||||
}
|
||||
|
||||
// Periodically send progress events
|
||||
if (tokenCount % _options.ProgressInterval == 0)
|
||||
{
|
||||
yield return new StreamEvent(StreamEventType.Progress, new ProgressEventData
|
||||
{
|
||||
TokensGenerated = tokenCount,
|
||||
ElapsedMs = (int)(DateTimeOffset.UtcNow - startTime).TotalMilliseconds
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Send completion event
|
||||
var endTime = DateTimeOffset.UtcNow;
|
||||
var groundingScore = CalculateGroundingScore(citations.Count, contentBuilder.Length);
|
||||
|
||||
yield return new StreamEvent(StreamEventType.Done, new DoneEventData
|
||||
{
|
||||
TurnId = turnId,
|
||||
TotalTokens = tokenCount,
|
||||
CitationCount = citations.Count,
|
||||
ActionCount = actions.Count,
|
||||
GroundingScore = groundingScore,
|
||||
DurationMs = (int)(endTime - startTime).TotalMilliseconds,
|
||||
Timestamp = endTime.ToString("O", CultureInfo.InvariantCulture)
|
||||
});
|
||||
|
||||
_logger.LogInformation(
|
||||
"Stream completed: conversation={ConversationId}, turn={TurnId}, tokens={Tokens}, grounding={Grounding:F2}",
|
||||
conversationId, turnId, tokenCount, groundingScore);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a stream event as an SSE string.
|
||||
/// </summary>
|
||||
public static string FormatAsSSE(StreamEvent evt)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.Append("event: ");
|
||||
sb.AppendLine(evt.Type.ToString().ToLowerInvariant());
|
||||
|
||||
var json = JsonSerializer.Serialize(evt.Data, JsonOptions);
|
||||
sb.Append("data: ");
|
||||
sb.AppendLine(json);
|
||||
|
||||
sb.AppendLine(); // Empty line to end the event
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles connection drops by checkpointing.
|
||||
/// </summary>
|
||||
public StreamCheckpoint CreateCheckpoint(
|
||||
string conversationId,
|
||||
string turnId,
|
||||
int tokenIndex,
|
||||
string partialContent)
|
||||
{
|
||||
return new StreamCheckpoint
|
||||
{
|
||||
ConversationId = conversationId,
|
||||
TurnId = turnId,
|
||||
TokenIndex = tokenIndex,
|
||||
PartialContent = partialContent,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resumes streaming from a checkpoint.
|
||||
/// </summary>
|
||||
public async IAsyncEnumerable<StreamEvent> ResumeFromCheckpointAsync(
|
||||
StreamCheckpoint checkpoint,
|
||||
IAsyncEnumerable<TokenChunk> tokenSource,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Send resume event
|
||||
yield return new StreamEvent(StreamEventType.Resume, new ResumeEventData
|
||||
{
|
||||
ConversationId = checkpoint.ConversationId,
|
||||
TurnId = checkpoint.TurnId,
|
||||
ResumedFromToken = checkpoint.TokenIndex
|
||||
});
|
||||
|
||||
// Skip tokens we already have
|
||||
var skipCount = checkpoint.TokenIndex;
|
||||
var skipped = 0;
|
||||
|
||||
await foreach (var chunk in tokenSource.WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (skipped < skipCount)
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return new StreamEvent(StreamEventType.Token, new TokenEventData
|
||||
{
|
||||
Content = chunk.Content,
|
||||
Index = skipped + 1
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private List<CitationEvent> ExtractNewCitations(string content, int existingCount)
|
||||
{
|
||||
var citations = new List<CitationEvent>();
|
||||
|
||||
// Pattern: [type:path]
|
||||
var matches = System.Text.RegularExpressions.Regex.Matches(
|
||||
content,
|
||||
@"\[(?<type>sbom|reach|runtime|vex|attest|auth|docs):(?<path>[^\]]+)\]");
|
||||
|
||||
for (int i = existingCount; i < matches.Count; i++)
|
||||
{
|
||||
var match = matches[i];
|
||||
citations.Add(new CitationEvent
|
||||
{
|
||||
Type = match.Groups["type"].Value,
|
||||
Path = match.Groups["path"].Value,
|
||||
Index = i + 1,
|
||||
Verified = false // Will be verified by GroundingValidator
|
||||
});
|
||||
}
|
||||
|
||||
return citations;
|
||||
}
|
||||
|
||||
private List<ActionEvent> ExtractNewActions(string content, int existingCount)
|
||||
{
|
||||
var actions = new List<ActionEvent>();
|
||||
|
||||
// Pattern: [Label]{action:type,params}
|
||||
var matches = System.Text.RegularExpressions.Regex.Matches(
|
||||
content,
|
||||
@"\[(?<label>[^\]]+)\]\{action:(?<type>\w+)(?:,(?<params>[^}]*))?\}");
|
||||
|
||||
for (int i = existingCount; i < matches.Count; i++)
|
||||
{
|
||||
var match = matches[i];
|
||||
actions.Add(new ActionEvent
|
||||
{
|
||||
Type = match.Groups["type"].Value,
|
||||
Label = match.Groups["label"].Value,
|
||||
Params = match.Groups["params"].Value,
|
||||
Index = i + 1,
|
||||
Enabled = true // Will be validated by ActionProposalParser
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
private static double CalculateGroundingScore(int citationCount, int contentLength)
|
||||
{
|
||||
if (contentLength == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Rough heuristic: expect ~1 citation per 200 characters
|
||||
var expectedCitations = contentLength / 200.0;
|
||||
if (expectedCitations < 1)
|
||||
{
|
||||
expectedCitations = 1;
|
||||
}
|
||||
|
||||
var ratio = citationCount / expectedCitations;
|
||||
return Math.Min(1.0, ratio);
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A token chunk from the LLM.
|
||||
/// </summary>
|
||||
public sealed record TokenChunk
|
||||
{
|
||||
/// <summary>Gets the token content.</summary>
|
||||
public required string Content { get; init; }
|
||||
|
||||
/// <summary>Gets optional metadata.</summary>
|
||||
public ImmutableDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of stream events.
|
||||
/// </summary>
|
||||
public enum StreamEventType
|
||||
{
|
||||
/// <summary>Stream starting.</summary>
|
||||
Start,
|
||||
|
||||
/// <summary>Token generated.</summary>
|
||||
Token,
|
||||
|
||||
/// <summary>Citation extracted.</summary>
|
||||
Citation,
|
||||
|
||||
/// <summary>Action proposal detected.</summary>
|
||||
Action,
|
||||
|
||||
/// <summary>Progress update.</summary>
|
||||
Progress,
|
||||
|
||||
/// <summary>Stream completed.</summary>
|
||||
Done,
|
||||
|
||||
/// <summary>Error occurred.</summary>
|
||||
Error,
|
||||
|
||||
/// <summary>Stream resumed.</summary>
|
||||
Resume
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A stream event with type and data.
|
||||
/// </summary>
|
||||
public sealed record StreamEvent(StreamEventType Type, object Data);
|
||||
|
||||
/// <summary>
|
||||
/// Start event data.
|
||||
/// </summary>
|
||||
public sealed record StartEventData
|
||||
{
|
||||
/// <summary>Gets the conversation ID.</summary>
|
||||
public required string ConversationId { get; init; }
|
||||
|
||||
/// <summary>Gets the turn ID.</summary>
|
||||
public required string TurnId { get; init; }
|
||||
|
||||
/// <summary>Gets the timestamp.</summary>
|
||||
public required string Timestamp { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Token event data.
|
||||
/// </summary>
|
||||
public sealed record TokenEventData
|
||||
{
|
||||
/// <summary>Gets the token content.</summary>
|
||||
public required string Content { get; init; }
|
||||
|
||||
/// <summary>Gets the token index.</summary>
|
||||
public required int Index { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Citation event data.
|
||||
/// </summary>
|
||||
public sealed record CitationEvent
|
||||
{
|
||||
/// <summary>Gets the citation type.</summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>Gets the citation path.</summary>
|
||||
public required string Path { get; init; }
|
||||
|
||||
/// <summary>Gets the citation index.</summary>
|
||||
public required int Index { get; init; }
|
||||
|
||||
/// <summary>Gets whether the citation is verified.</summary>
|
||||
public bool Verified { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Action event data.
|
||||
/// </summary>
|
||||
public sealed record ActionEvent
|
||||
{
|
||||
/// <summary>Gets the action type.</summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>Gets the action label.</summary>
|
||||
public required string Label { get; init; }
|
||||
|
||||
/// <summary>Gets the action parameters.</summary>
|
||||
public required string Params { get; init; }
|
||||
|
||||
/// <summary>Gets the action index.</summary>
|
||||
public required int Index { get; init; }
|
||||
|
||||
/// <summary>Gets whether the action is enabled.</summary>
|
||||
public bool Enabled { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Progress event data.
|
||||
/// </summary>
|
||||
public sealed record ProgressEventData
|
||||
{
|
||||
/// <summary>Gets tokens generated so far.</summary>
|
||||
public required int TokensGenerated { get; init; }
|
||||
|
||||
/// <summary>Gets elapsed milliseconds.</summary>
|
||||
public required int ElapsedMs { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Done event data.
|
||||
/// </summary>
|
||||
public sealed record DoneEventData
|
||||
{
|
||||
/// <summary>Gets the turn ID.</summary>
|
||||
public required string TurnId { get; init; }
|
||||
|
||||
/// <summary>Gets total tokens.</summary>
|
||||
public required int TotalTokens { get; init; }
|
||||
|
||||
/// <summary>Gets citation count.</summary>
|
||||
public required int CitationCount { get; init; }
|
||||
|
||||
/// <summary>Gets action count.</summary>
|
||||
public required int ActionCount { get; init; }
|
||||
|
||||
/// <summary>Gets the grounding score.</summary>
|
||||
public required double GroundingScore { get; init; }
|
||||
|
||||
/// <summary>Gets duration in milliseconds.</summary>
|
||||
public required int DurationMs { get; init; }
|
||||
|
||||
/// <summary>Gets the timestamp.</summary>
|
||||
public required string Timestamp { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error event data.
|
||||
/// </summary>
|
||||
public sealed record ErrorEventData
|
||||
{
|
||||
/// <summary>Gets the error code.</summary>
|
||||
public required string Code { get; init; }
|
||||
|
||||
/// <summary>Gets the error message.</summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>Gets tokens generated before error.</summary>
|
||||
public int TokensGenerated { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resume event data.
|
||||
/// </summary>
|
||||
public sealed record ResumeEventData
|
||||
{
|
||||
/// <summary>Gets the conversation ID.</summary>
|
||||
public required string ConversationId { get; init; }
|
||||
|
||||
/// <summary>Gets the turn ID.</summary>
|
||||
public required string TurnId { get; init; }
|
||||
|
||||
/// <summary>Gets the token index resumed from.</summary>
|
||||
public required int ResumedFromToken { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checkpoint for resuming streams.
|
||||
/// </summary>
|
||||
public sealed record StreamCheckpoint
|
||||
{
|
||||
/// <summary>Gets the conversation ID.</summary>
|
||||
public required string ConversationId { get; init; }
|
||||
|
||||
/// <summary>Gets the turn ID.</summary>
|
||||
public required string TurnId { get; init; }
|
||||
|
||||
/// <summary>Gets the token index.</summary>
|
||||
public required int TokenIndex { get; init; }
|
||||
|
||||
/// <summary>Gets partial content accumulated.</summary>
|
||||
public required string PartialContent { get; init; }
|
||||
|
||||
/// <summary>Gets when checkpoint was created.</summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for streaming.
|
||||
/// </summary>
|
||||
public sealed class StreamingOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the interval for progress events (in tokens).
|
||||
/// Default: 50 tokens.
|
||||
/// </summary>
|
||||
public int ProgressInterval { get; set; } = 50;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timeout for idle streams.
|
||||
/// Default: 30 seconds.
|
||||
/// </summary>
|
||||
public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
// <copyright file="ConversationContextBuilder.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Builds context from conversation history for LLM prompts.
|
||||
/// Sprint: SPRINT_20260107_006_003 Task CH-002
|
||||
/// </summary>
|
||||
public sealed class ConversationContextBuilder
|
||||
{
|
||||
private readonly ConversationContextOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ConversationContextBuilder"/> class.
|
||||
/// </summary>
|
||||
public ConversationContextBuilder(ConversationContextOptions? options = null)
|
||||
{
|
||||
_options = options ?? new ConversationContextOptions();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds context from a conversation for use in LLM prompts.
|
||||
/// </summary>
|
||||
/// <param name="conversation">The conversation to build context from.</param>
|
||||
/// <param name="tokenBudget">The maximum token budget for context.</param>
|
||||
/// <returns>The built context.</returns>
|
||||
public BuiltContext Build(Conversation conversation, int? tokenBudget = null)
|
||||
{
|
||||
var budget = tokenBudget ?? _options.DefaultTokenBudget;
|
||||
var builder = new BuiltContextBuilder();
|
||||
|
||||
// Add conversation context (CVE, component, scan, etc.)
|
||||
AddConversationContext(builder, conversation.Context);
|
||||
|
||||
// Add policy context
|
||||
if (conversation.Context.Policy is not null)
|
||||
{
|
||||
AddPolicyContext(builder, conversation.Context.Policy);
|
||||
}
|
||||
|
||||
// Add evidence links
|
||||
AddEvidenceContext(builder, conversation.Context.EvidenceLinks);
|
||||
|
||||
// Add conversation history (truncated to fit budget)
|
||||
var historyTokens = budget - builder.EstimatedTokens;
|
||||
AddConversationHistory(builder, conversation.Turns, historyTokens);
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges evidence links from a new turn into the conversation context.
|
||||
/// </summary>
|
||||
public ConversationContext MergeEvidence(
|
||||
ConversationContext existing,
|
||||
IEnumerable<EvidenceLink> newLinks)
|
||||
{
|
||||
var allLinks = existing.EvidenceLinks
|
||||
.Concat(newLinks)
|
||||
.DistinctBy(l => l.Uri)
|
||||
.Take(_options.MaxEvidenceLinks)
|
||||
.ToImmutableArray();
|
||||
|
||||
return existing with { EvidenceLinks = allLinks };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the conversation context with a new focus (CVE, component, etc.).
|
||||
/// </summary>
|
||||
public ConversationContext UpdateFocus(
|
||||
ConversationContext existing,
|
||||
string? cveId = null,
|
||||
string? component = null,
|
||||
string? imageDigest = null,
|
||||
string? scanId = null,
|
||||
string? sbomId = null)
|
||||
{
|
||||
return existing with
|
||||
{
|
||||
CurrentCveId = cveId ?? existing.CurrentCveId,
|
||||
CurrentComponent = component ?? existing.CurrentComponent,
|
||||
CurrentImageDigest = imageDigest ?? existing.CurrentImageDigest,
|
||||
ScanId = scanId ?? existing.ScanId,
|
||||
SbomId = sbomId ?? existing.SbomId
|
||||
};
|
||||
}
|
||||
|
||||
private void AddConversationContext(BuiltContextBuilder builder, ConversationContext context)
|
||||
{
|
||||
if (context.CurrentCveId is not null)
|
||||
{
|
||||
builder.AddContextItem("Current CVE", context.CurrentCveId);
|
||||
}
|
||||
|
||||
if (context.CurrentComponent is not null)
|
||||
{
|
||||
builder.AddContextItem("Current Component", context.CurrentComponent);
|
||||
}
|
||||
|
||||
if (context.CurrentImageDigest is not null)
|
||||
{
|
||||
builder.AddContextItem("Image Digest", context.CurrentImageDigest);
|
||||
}
|
||||
|
||||
if (context.ScanId is not null)
|
||||
{
|
||||
builder.AddContextItem("Scan ID", context.ScanId);
|
||||
}
|
||||
|
||||
if (context.SbomId is not null)
|
||||
{
|
||||
builder.AddContextItem("SBOM ID", context.SbomId);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddPolicyContext(BuiltContextBuilder builder, PolicyContext policy)
|
||||
{
|
||||
if (policy.PolicyIds.Length > 0)
|
||||
{
|
||||
builder.AddContextItem("Policies", string.Join(", ", policy.PolicyIds));
|
||||
}
|
||||
|
||||
if (policy.Permissions.Length > 0)
|
||||
{
|
||||
builder.AddContextItem("User Permissions", string.Join(", ", policy.Permissions));
|
||||
}
|
||||
|
||||
builder.AddContextItem("Automation Allowed", policy.AutomationAllowed ? "Yes" : "No");
|
||||
}
|
||||
|
||||
private static void AddEvidenceContext(BuiltContextBuilder builder, ImmutableArray<EvidenceLink> links)
|
||||
{
|
||||
if (links.IsEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var evidenceByType = links.GroupBy(l => l.Type);
|
||||
foreach (var group in evidenceByType)
|
||||
{
|
||||
var uris = group.Select(l => l.Uri).ToList();
|
||||
builder.AddEvidenceReference(group.Key, uris);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddConversationHistory(
|
||||
BuiltContextBuilder builder,
|
||||
ImmutableArray<ConversationTurn> turns,
|
||||
int tokenBudget)
|
||||
{
|
||||
if (turns.IsEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Process turns from newest to oldest, but we'll reverse for output
|
||||
var selectedTurns = new List<ConversationTurn>();
|
||||
var currentTokens = 0;
|
||||
|
||||
// Always include the most recent turns within budget
|
||||
for (int i = turns.Length - 1; i >= 0 && currentTokens < tokenBudget; i--)
|
||||
{
|
||||
var turn = turns[i];
|
||||
var turnTokens = EstimateTokens(turn.Content);
|
||||
|
||||
if (currentTokens + turnTokens <= tokenBudget)
|
||||
{
|
||||
selectedTurns.Insert(0, turn);
|
||||
currentTokens += turnTokens;
|
||||
}
|
||||
else if (selectedTurns.Count == 0)
|
||||
{
|
||||
// Always include at least the last turn, truncated if needed
|
||||
var truncatedContent = TruncateToTokens(turn.Content, tokenBudget);
|
||||
selectedTurns.Add(turn with { Content = truncatedContent });
|
||||
break;
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Add summary indicator if we truncated
|
||||
var wasTruncated = selectedTurns.Count < turns.Length;
|
||||
builder.AddHistory(selectedTurns, wasTruncated, turns.Length - selectedTurns.Count);
|
||||
}
|
||||
|
||||
private static int EstimateTokens(string text)
|
||||
{
|
||||
// Rough estimate: ~4 characters per token for English
|
||||
return (text.Length + 3) / 4;
|
||||
}
|
||||
|
||||
private static string TruncateToTokens(string text, int maxTokens)
|
||||
{
|
||||
var maxChars = maxTokens * 4;
|
||||
if (text.Length <= maxChars)
|
||||
{
|
||||
return text;
|
||||
}
|
||||
|
||||
return text[..(maxChars - 3)] + "...";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builder for constructing context output.
|
||||
/// </summary>
|
||||
internal sealed class BuiltContextBuilder
|
||||
{
|
||||
private readonly List<(string Key, string Value)> _contextItems = new();
|
||||
private readonly Dictionary<EvidenceLinkType, List<string>> _evidence = new();
|
||||
private readonly List<ConversationTurn> _history = new();
|
||||
private bool _historyTruncated;
|
||||
private int _omittedTurnCount;
|
||||
|
||||
public int EstimatedTokens { get; private set; }
|
||||
|
||||
public void AddContextItem(string key, string value)
|
||||
{
|
||||
_contextItems.Add((key, value));
|
||||
EstimatedTokens += (key.Length + value.Length + 4) / 4;
|
||||
}
|
||||
|
||||
public void AddEvidenceReference(EvidenceLinkType type, List<string> uris)
|
||||
{
|
||||
_evidence[type] = uris;
|
||||
EstimatedTokens += uris.Sum(u => u.Length) / 4;
|
||||
}
|
||||
|
||||
public void AddHistory(List<ConversationTurn> turns, bool truncated, int omittedCount)
|
||||
{
|
||||
_history.AddRange(turns);
|
||||
_historyTruncated = truncated;
|
||||
_omittedTurnCount = omittedCount;
|
||||
EstimatedTokens += turns.Sum(t => t.Content.Length) / 4;
|
||||
}
|
||||
|
||||
public BuiltContext Build()
|
||||
{
|
||||
return new BuiltContext
|
||||
{
|
||||
ContextItems = _contextItems.ToImmutableArray(),
|
||||
EvidenceReferences = _evidence.ToImmutableDictionary(
|
||||
kv => kv.Key,
|
||||
kv => (IReadOnlyList<string>)kv.Value),
|
||||
History = _history.ToImmutableArray(),
|
||||
HistoryTruncated = _historyTruncated,
|
||||
OmittedTurnCount = _omittedTurnCount,
|
||||
EstimatedTokenCount = EstimatedTokens
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The built context for LLM prompts.
|
||||
/// </summary>
|
||||
public sealed record BuiltContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the context items (key-value pairs).
|
||||
/// </summary>
|
||||
public ImmutableArray<(string Key, string Value)> ContextItems { get; init; } =
|
||||
ImmutableArray<(string, string)>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets evidence references grouped by type.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<EvidenceLinkType, IReadOnlyList<string>> EvidenceReferences { get; init; } =
|
||||
ImmutableDictionary<EvidenceLinkType, IReadOnlyList<string>>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the conversation history.
|
||||
/// </summary>
|
||||
public ImmutableArray<ConversationTurn> History { get; init; } =
|
||||
ImmutableArray<ConversationTurn>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the history was truncated.
|
||||
/// </summary>
|
||||
public bool HistoryTruncated { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of omitted turns.
|
||||
/// </summary>
|
||||
public int OmittedTurnCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the estimated token count.
|
||||
/// </summary>
|
||||
public int EstimatedTokenCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Formats the context as a string for prompt injection.
|
||||
/// </summary>
|
||||
public string FormatForPrompt()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Context section
|
||||
if (ContextItems.Length > 0)
|
||||
{
|
||||
sb.AppendLine("## Current Context");
|
||||
foreach (var (key, value) in ContextItems)
|
||||
{
|
||||
sb.AppendLine($"- **{key}**: {value}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// Evidence section
|
||||
if (EvidenceReferences.Count > 0)
|
||||
{
|
||||
sb.AppendLine("## Available Evidence");
|
||||
foreach (var (type, uris) in EvidenceReferences)
|
||||
{
|
||||
sb.AppendLine($"### {type}");
|
||||
foreach (var uri in uris.Take(5))
|
||||
{
|
||||
sb.AppendLine($"- [{uri}]");
|
||||
}
|
||||
if (uris.Count > 5)
|
||||
{
|
||||
sb.AppendLine($"- ... and {uris.Count - 5} more");
|
||||
}
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// History section
|
||||
if (History.Length > 0)
|
||||
{
|
||||
sb.AppendLine("## Conversation History");
|
||||
if (HistoryTruncated)
|
||||
{
|
||||
sb.AppendLine($"*({OmittedTurnCount} earlier messages omitted)*");
|
||||
}
|
||||
foreach (var turn in History)
|
||||
{
|
||||
var role = turn.Role switch
|
||||
{
|
||||
TurnRole.User => "User",
|
||||
TurnRole.Assistant => "Assistant",
|
||||
TurnRole.System => "System",
|
||||
_ => "Unknown"
|
||||
};
|
||||
sb.AppendLine($"**{role}**: {turn.Content}");
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for conversation context building.
|
||||
/// </summary>
|
||||
public sealed class ConversationContextOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the default token budget.
|
||||
/// Default: 4000 tokens.
|
||||
/// </summary>
|
||||
public int DefaultTokenBudget { get; set; } = 4000;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum evidence links to include.
|
||||
/// Default: 20.
|
||||
/// </summary>
|
||||
public int MaxEvidenceLinks { get; set; } = 20;
|
||||
}
|
||||
648
src/AdvisoryAI/StellaOps.AdvisoryAI/Chat/ConversationService.cs
Normal file
648
src/AdvisoryAI/StellaOps.AdvisoryAI/Chat/ConversationService.cs
Normal file
@@ -0,0 +1,648 @@
|
||||
// <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; }
|
||||
}
|
||||
601
src/AdvisoryAI/StellaOps.AdvisoryAI/Chat/GroundingValidator.cs
Normal file
601
src/AdvisoryAI/StellaOps.AdvisoryAI/Chat/GroundingValidator.cs
Normal file
@@ -0,0 +1,601 @@
|
||||
// <copyright file="GroundingValidator.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.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Validates that AI responses are properly grounded with citations.
|
||||
/// Sprint: SPRINT_20260107_006_003 Task CH-007
|
||||
/// </summary>
|
||||
public sealed partial class GroundingValidator
|
||||
{
|
||||
private readonly IObjectLinkResolver _linkResolver;
|
||||
private readonly ILogger<GroundingValidator> _logger;
|
||||
private readonly GroundingOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="GroundingValidator"/> class.
|
||||
/// </summary>
|
||||
public GroundingValidator(
|
||||
IObjectLinkResolver linkResolver,
|
||||
ILogger<GroundingValidator> logger,
|
||||
GroundingOptions? options = null)
|
||||
{
|
||||
_linkResolver = linkResolver;
|
||||
_logger = logger;
|
||||
_options = options ?? new GroundingOptions();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a response for proper grounding.
|
||||
/// </summary>
|
||||
/// <param name="response">The AI response to validate.</param>
|
||||
/// <param name="context">The conversation context.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Validation result with grounding score.</returns>
|
||||
public async Task<GroundingValidationResult> ValidateAsync(
|
||||
string response,
|
||||
ConversationContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var extractedLinks = ExtractObjectLinks(response);
|
||||
var claims = ExtractClaims(response);
|
||||
var issues = new List<GroundingIssue>();
|
||||
|
||||
// Validate each link resolves to a real object
|
||||
var validatedLinks = new List<ValidatedLink>();
|
||||
foreach (var link in extractedLinks)
|
||||
{
|
||||
var resolution = await _linkResolver.ResolveAsync(
|
||||
link.Type, link.Path, context.TenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var validated = new ValidatedLink
|
||||
{
|
||||
Type = link.Type,
|
||||
Path = link.Path,
|
||||
Position = link.Position,
|
||||
IsValid = resolution.Exists,
|
||||
ResolvedUri = resolution.Uri,
|
||||
ObjectType = resolution.ObjectType
|
||||
};
|
||||
|
||||
validatedLinks.Add(validated);
|
||||
|
||||
if (!resolution.Exists)
|
||||
{
|
||||
issues.Add(new GroundingIssue
|
||||
{
|
||||
Type = GroundingIssueType.InvalidLink,
|
||||
Message = $"Object link does not resolve: [{link.Type}:{link.Path}]",
|
||||
Position = link.Position,
|
||||
Severity = IssueSeverity.Error
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for ungrounded claims
|
||||
var groundedClaims = 0;
|
||||
var ungroundedClaims = new List<UngroundedClaim>();
|
||||
|
||||
foreach (var claim in claims)
|
||||
{
|
||||
var hasNearbyLink = validatedLinks.Any(l =>
|
||||
l.IsValid &&
|
||||
Math.Abs(l.Position - claim.Position) < _options.MaxLinkDistance);
|
||||
|
||||
if (hasNearbyLink)
|
||||
{
|
||||
groundedClaims++;
|
||||
}
|
||||
else
|
||||
{
|
||||
ungroundedClaims.Add(claim);
|
||||
issues.Add(new GroundingIssue
|
||||
{
|
||||
Type = GroundingIssueType.UngroundedClaim,
|
||||
Message = $"Claim without nearby citation: \"{TruncateClaim(claim.Text)}\"",
|
||||
Position = claim.Position,
|
||||
Severity = IssueSeverity.Warning
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate grounding score
|
||||
var score = CalculateGroundingScore(
|
||||
validatedLinks.Count(l => l.IsValid),
|
||||
validatedLinks.Count,
|
||||
groundedClaims,
|
||||
claims.Count,
|
||||
response.Length);
|
||||
|
||||
// Check if response should be rejected
|
||||
var isAcceptable = score >= _options.MinGroundingScore;
|
||||
|
||||
if (!isAcceptable)
|
||||
{
|
||||
issues.Insert(0, new GroundingIssue
|
||||
{
|
||||
Type = GroundingIssueType.BelowThreshold,
|
||||
Message = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"Grounding score {0:F2} is below threshold {1:F2}",
|
||||
score,
|
||||
_options.MinGroundingScore),
|
||||
Position = 0,
|
||||
Severity = IssueSeverity.Critical
|
||||
});
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Grounding validation: score={Score:F2}, links={ValidLinks}/{TotalLinks}, claims={GroundedClaims}/{TotalClaims}, acceptable={IsAcceptable}",
|
||||
score, validatedLinks.Count(l => l.IsValid), validatedLinks.Count, groundedClaims, claims.Count, isAcceptable);
|
||||
|
||||
return new GroundingValidationResult
|
||||
{
|
||||
GroundingScore = score,
|
||||
IsAcceptable = isAcceptable,
|
||||
ValidatedLinks = validatedLinks.ToImmutableArray(),
|
||||
TotalClaims = claims.Count,
|
||||
GroundedClaims = groundedClaims,
|
||||
UngroundedClaims = ungroundedClaims.ToImmutableArray(),
|
||||
Issues = issues.ToImmutableArray()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rejects a response that fails grounding validation.
|
||||
/// </summary>
|
||||
public RejectionResult RejectResponse(GroundingValidationResult validation)
|
||||
{
|
||||
var reason = new System.Text.StringBuilder();
|
||||
reason.AppendLine("Response rejected due to insufficient grounding:");
|
||||
reason.AppendLine();
|
||||
|
||||
foreach (var issue in validation.Issues.Where(i => i.Severity >= IssueSeverity.Error))
|
||||
{
|
||||
reason.AppendLine($"- {issue.Message}");
|
||||
}
|
||||
|
||||
reason.AppendLine();
|
||||
reason.AppendLine(string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"Grounding score: {0:P0} (minimum required: {1:P0})",
|
||||
validation.GroundingScore,
|
||||
_options.MinGroundingScore));
|
||||
|
||||
return new RejectionResult
|
||||
{
|
||||
Reason = reason.ToString(),
|
||||
GroundingScore = validation.GroundingScore,
|
||||
RequiredScore = _options.MinGroundingScore,
|
||||
Issues = validation.Issues
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Suggests improvements for a poorly grounded response.
|
||||
/// </summary>
|
||||
public ImmutableArray<GroundingSuggestion> SuggestImprovements(GroundingValidationResult validation)
|
||||
{
|
||||
var suggestions = new List<GroundingSuggestion>();
|
||||
|
||||
if (validation.UngroundedClaims.Length > 0)
|
||||
{
|
||||
suggestions.Add(new GroundingSuggestion
|
||||
{
|
||||
Type = SuggestionType.AddCitations,
|
||||
Message = $"Add citations for {validation.UngroundedClaims.Length} ungrounded claim(s)",
|
||||
Examples = validation.UngroundedClaims
|
||||
.Take(3)
|
||||
.Select(c => $"Claim: \"{TruncateClaim(c.Text)}\" - needs evidence link")
|
||||
.ToImmutableArray()
|
||||
});
|
||||
}
|
||||
|
||||
var invalidLinks = validation.ValidatedLinks.Where(l => !l.IsValid).ToList();
|
||||
if (invalidLinks.Count > 0)
|
||||
{
|
||||
suggestions.Add(new GroundingSuggestion
|
||||
{
|
||||
Type = SuggestionType.FixLinks,
|
||||
Message = $"Fix {invalidLinks.Count} invalid object link(s)",
|
||||
Examples = invalidLinks
|
||||
.Take(3)
|
||||
.Select(l => $"Invalid: [{l.Type}:{l.Path}]")
|
||||
.ToImmutableArray()
|
||||
});
|
||||
}
|
||||
|
||||
if (validation.ValidatedLinks.Length == 0 && validation.TotalClaims > 0)
|
||||
{
|
||||
suggestions.Add(new GroundingSuggestion
|
||||
{
|
||||
Type = SuggestionType.AddEvidence,
|
||||
Message = "Response contains claims but no evidence links",
|
||||
Examples = ImmutableArray.Create(
|
||||
"Use [sbom:id] for SBOM references",
|
||||
"Use [vex:issuer:digest] for VEX statements",
|
||||
"Use [reach:service:function] for reachability data")
|
||||
});
|
||||
}
|
||||
|
||||
return suggestions.ToImmutableArray();
|
||||
}
|
||||
|
||||
private List<ExtractedLink> ExtractObjectLinks(string response)
|
||||
{
|
||||
var links = new List<ExtractedLink>();
|
||||
var matches = ObjectLinkRegex().Matches(response);
|
||||
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
links.Add(new ExtractedLink
|
||||
{
|
||||
Type = match.Groups["type"].Value,
|
||||
Path = match.Groups["path"].Value,
|
||||
Position = match.Index
|
||||
});
|
||||
}
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
private List<UngroundedClaim> ExtractClaims(string response)
|
||||
{
|
||||
var claims = new List<UngroundedClaim>();
|
||||
|
||||
// Look for claim patterns: "is affected", "is vulnerable", "is not affected", etc.
|
||||
var claimPatterns = ClaimPatternRegex().Matches(response);
|
||||
|
||||
foreach (Match match in claimPatterns)
|
||||
{
|
||||
claims.Add(new UngroundedClaim
|
||||
{
|
||||
Text = match.Value,
|
||||
Position = match.Index,
|
||||
ClaimType = DetermineClaimType(match.Value)
|
||||
});
|
||||
}
|
||||
|
||||
// Also look for severity/score statements
|
||||
var severityMatches = SeverityClaimRegex().Matches(response);
|
||||
foreach (Match match in severityMatches)
|
||||
{
|
||||
claims.Add(new UngroundedClaim
|
||||
{
|
||||
Text = match.Value,
|
||||
Position = match.Index,
|
||||
ClaimType = ClaimType.SeverityAssessment
|
||||
});
|
||||
}
|
||||
|
||||
return claims;
|
||||
}
|
||||
|
||||
private static ClaimType DetermineClaimType(string text)
|
||||
{
|
||||
var lower = text.ToLowerInvariant();
|
||||
if (lower.Contains("not affected") || lower.Contains("not vulnerable"))
|
||||
{
|
||||
return ClaimType.NotAffected;
|
||||
}
|
||||
if (lower.Contains("affected") || lower.Contains("vulnerable"))
|
||||
{
|
||||
return ClaimType.Affected;
|
||||
}
|
||||
if (lower.Contains("fixed") || lower.Contains("patched"))
|
||||
{
|
||||
return ClaimType.Fixed;
|
||||
}
|
||||
if (lower.Contains("under investigation"))
|
||||
{
|
||||
return ClaimType.UnderInvestigation;
|
||||
}
|
||||
return ClaimType.General;
|
||||
}
|
||||
|
||||
private double CalculateGroundingScore(
|
||||
int validLinks,
|
||||
int totalLinks,
|
||||
int groundedClaims,
|
||||
int totalClaims,
|
||||
int responseLength)
|
||||
{
|
||||
// Weight factors
|
||||
const double linkValidityWeight = 0.4;
|
||||
const double claimGroundingWeight = 0.4;
|
||||
const double densityWeight = 0.2;
|
||||
|
||||
// Link validity score
|
||||
var linkScore = totalLinks > 0 ? (double)validLinks / totalLinks : 0;
|
||||
|
||||
// Claim grounding score
|
||||
var claimScore = totalClaims > 0 ? (double)groundedClaims / totalClaims : 1.0;
|
||||
|
||||
// Density score (links per 500 chars)
|
||||
var expectedLinks = responseLength / 500.0;
|
||||
if (expectedLinks < 1)
|
||||
{
|
||||
expectedLinks = 1;
|
||||
}
|
||||
var densityScore = Math.Min(1.0, validLinks / expectedLinks);
|
||||
|
||||
return (linkScore * linkValidityWeight) +
|
||||
(claimScore * claimGroundingWeight) +
|
||||
(densityScore * densityWeight);
|
||||
}
|
||||
|
||||
private static string TruncateClaim(string claim)
|
||||
{
|
||||
const int maxLength = 50;
|
||||
if (claim.Length <= maxLength)
|
||||
{
|
||||
return claim;
|
||||
}
|
||||
return claim[..(maxLength - 3)] + "...";
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\[(?<type>sbom|reach|runtime|vex|attest|auth|docs):(?<path>[^\]]+)\]", RegexOptions.Compiled)]
|
||||
private static partial Regex ObjectLinkRegex();
|
||||
|
||||
[GeneratedRegex(@"(?:is|are|was|were|has been|have been)\s+(?:not\s+)?(?:affected|vulnerable|exploitable|fixed|patched|mitigated|under investigation)", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||
private static partial Regex ClaimPatternRegex();
|
||||
|
||||
[GeneratedRegex(@"(?:severity|CVSS|EPSS|score|rating)\s*(?:is|of|:)?\s*(?:\d+\.?\d*|critical|high|medium|low)", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||
private static partial Regex SeverityClaimRegex();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for resolving object links.
|
||||
/// </summary>
|
||||
public interface IObjectLinkResolver
|
||||
{
|
||||
/// <summary>Resolves an object link to verify it exists.</summary>
|
||||
Task<LinkResolution> ResolveAsync(string type, string path, string? tenantId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of link resolution.
|
||||
/// </summary>
|
||||
public sealed record LinkResolution
|
||||
{
|
||||
/// <summary>Gets whether the object exists.</summary>
|
||||
public bool Exists { get; init; }
|
||||
|
||||
/// <summary>Gets the resolved URI.</summary>
|
||||
public string? Uri { get; init; }
|
||||
|
||||
/// <summary>Gets the object type.</summary>
|
||||
public string? ObjectType { get; init; }
|
||||
|
||||
/// <summary>Gets resolution metadata.</summary>
|
||||
public ImmutableDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of grounding validation.
|
||||
/// </summary>
|
||||
public sealed record GroundingValidationResult
|
||||
{
|
||||
/// <summary>Gets the grounding score (0.0-1.0).</summary>
|
||||
public double GroundingScore { get; init; }
|
||||
|
||||
/// <summary>Gets whether the response is acceptable.</summary>
|
||||
public bool IsAcceptable { get; init; }
|
||||
|
||||
/// <summary>Gets validated links.</summary>
|
||||
public ImmutableArray<ValidatedLink> ValidatedLinks { get; init; } =
|
||||
ImmutableArray<ValidatedLink>.Empty;
|
||||
|
||||
/// <summary>Gets total claims found.</summary>
|
||||
public int TotalClaims { get; init; }
|
||||
|
||||
/// <summary>Gets grounded claims count.</summary>
|
||||
public int GroundedClaims { get; init; }
|
||||
|
||||
/// <summary>Gets ungrounded claims.</summary>
|
||||
public ImmutableArray<UngroundedClaim> UngroundedClaims { get; init; } =
|
||||
ImmutableArray<UngroundedClaim>.Empty;
|
||||
|
||||
/// <summary>Gets validation issues.</summary>
|
||||
public ImmutableArray<GroundingIssue> Issues { get; init; } =
|
||||
ImmutableArray<GroundingIssue>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A validated object link.
|
||||
/// </summary>
|
||||
public sealed record ValidatedLink
|
||||
{
|
||||
/// <summary>Gets the link type.</summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>Gets the link path.</summary>
|
||||
public required string Path { get; init; }
|
||||
|
||||
/// <summary>Gets the position in response.</summary>
|
||||
public int Position { get; init; }
|
||||
|
||||
/// <summary>Gets whether the link is valid.</summary>
|
||||
public bool IsValid { get; init; }
|
||||
|
||||
/// <summary>Gets the resolved URI.</summary>
|
||||
public string? ResolvedUri { get; init; }
|
||||
|
||||
/// <summary>Gets the object type.</summary>
|
||||
public string? ObjectType { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An extracted link before validation.
|
||||
/// </summary>
|
||||
internal sealed record ExtractedLink
|
||||
{
|
||||
public required string Type { get; init; }
|
||||
public required string Path { get; init; }
|
||||
public int Position { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An ungrounded claim.
|
||||
/// </summary>
|
||||
public sealed record UngroundedClaim
|
||||
{
|
||||
/// <summary>Gets the claim text.</summary>
|
||||
public required string Text { get; init; }
|
||||
|
||||
/// <summary>Gets the position in response.</summary>
|
||||
public int Position { get; init; }
|
||||
|
||||
/// <summary>Gets the claim type.</summary>
|
||||
public ClaimType ClaimType { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of claims.
|
||||
/// </summary>
|
||||
public enum ClaimType
|
||||
{
|
||||
/// <summary>General claim.</summary>
|
||||
General,
|
||||
|
||||
/// <summary>Claims something is affected.</summary>
|
||||
Affected,
|
||||
|
||||
/// <summary>Claims something is not affected.</summary>
|
||||
NotAffected,
|
||||
|
||||
/// <summary>Claims something is fixed.</summary>
|
||||
Fixed,
|
||||
|
||||
/// <summary>Claims something is under investigation.</summary>
|
||||
UnderInvestigation,
|
||||
|
||||
/// <summary>Severity or score assessment.</summary>
|
||||
SeverityAssessment
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A grounding issue.
|
||||
/// </summary>
|
||||
public sealed record GroundingIssue
|
||||
{
|
||||
/// <summary>Gets the issue type.</summary>
|
||||
public required GroundingIssueType Type { get; init; }
|
||||
|
||||
/// <summary>Gets the issue message.</summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>Gets the position in response.</summary>
|
||||
public int Position { get; init; }
|
||||
|
||||
/// <summary>Gets the severity.</summary>
|
||||
public IssueSeverity Severity { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of grounding issues.
|
||||
/// </summary>
|
||||
public enum GroundingIssueType
|
||||
{
|
||||
/// <summary>Link does not resolve.</summary>
|
||||
InvalidLink,
|
||||
|
||||
/// <summary>Claim without citation.</summary>
|
||||
UngroundedClaim,
|
||||
|
||||
/// <summary>Score below threshold.</summary>
|
||||
BelowThreshold
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issue severity.
|
||||
/// </summary>
|
||||
public enum IssueSeverity
|
||||
{
|
||||
/// <summary>Informational.</summary>
|
||||
Info,
|
||||
|
||||
/// <summary>Warning.</summary>
|
||||
Warning,
|
||||
|
||||
/// <summary>Error.</summary>
|
||||
Error,
|
||||
|
||||
/// <summary>Critical.</summary>
|
||||
Critical
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of rejecting a response.
|
||||
/// </summary>
|
||||
public sealed record RejectionResult
|
||||
{
|
||||
/// <summary>Gets the rejection reason.</summary>
|
||||
public required string Reason { get; init; }
|
||||
|
||||
/// <summary>Gets the grounding score.</summary>
|
||||
public double GroundingScore { get; init; }
|
||||
|
||||
/// <summary>Gets the required score.</summary>
|
||||
public double RequiredScore { get; init; }
|
||||
|
||||
/// <summary>Gets the issues.</summary>
|
||||
public ImmutableArray<GroundingIssue> Issues { get; init; } =
|
||||
ImmutableArray<GroundingIssue>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A suggestion for improving grounding.
|
||||
/// </summary>
|
||||
public sealed record GroundingSuggestion
|
||||
{
|
||||
/// <summary>Gets the suggestion type.</summary>
|
||||
public required SuggestionType Type { get; init; }
|
||||
|
||||
/// <summary>Gets the suggestion message.</summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>Gets example improvements.</summary>
|
||||
public ImmutableArray<string> Examples { get; init; } =
|
||||
ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of grounding suggestions.
|
||||
/// </summary>
|
||||
public enum SuggestionType
|
||||
{
|
||||
/// <summary>Add citations.</summary>
|
||||
AddCitations,
|
||||
|
||||
/// <summary>Fix invalid links.</summary>
|
||||
FixLinks,
|
||||
|
||||
/// <summary>Add evidence.</summary>
|
||||
AddEvidence
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for grounding validation.
|
||||
/// </summary>
|
||||
public sealed class GroundingOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum grounding score.
|
||||
/// Default: 0.5.
|
||||
/// </summary>
|
||||
public double MinGroundingScore { get; set; } = 0.5;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum distance between claim and link.
|
||||
/// Default: 200 characters.
|
||||
/// </summary>
|
||||
public int MaxLinkDistance { get; set; } = 200;
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />
|
||||
|
||||
373
src/AdvisoryAI/StellaOps.AdvisoryAI/Storage/ConversationStore.cs
Normal file
373
src/AdvisoryAI/StellaOps.AdvisoryAI/Storage/ConversationStore.cs
Normal file
@@ -0,0 +1,373 @@
|
||||
// <copyright file="ConversationStore.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.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.AdvisoryAI.Chat;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL-backed conversation storage.
|
||||
/// Sprint: SPRINT_20260107_006_003 Task CH-008
|
||||
/// </summary>
|
||||
public sealed class ConversationStore : IConversationStore, IAsyncDisposable
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly ILogger<ConversationStore> _logger;
|
||||
private readonly ConversationStoreOptions _options;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ConversationStore"/> class.
|
||||
/// </summary>
|
||||
public ConversationStore(
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<ConversationStore> logger,
|
||||
ConversationStoreOptions? options = null)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_logger = logger;
|
||||
_options = options ?? new ConversationStoreOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Conversation> CreateAsync(
|
||||
Conversation conversation,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO advisoryai.conversations (
|
||||
conversation_id, tenant_id, user_id, created_at, updated_at,
|
||||
context, metadata
|
||||
) VALUES (
|
||||
@conversationId, @tenantId, @userId, @createdAt, @updatedAt,
|
||||
@context::jsonb, @metadata::jsonb
|
||||
)
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("conversationId", conversation.ConversationId);
|
||||
cmd.Parameters.AddWithValue("tenantId", conversation.TenantId);
|
||||
cmd.Parameters.AddWithValue("userId", conversation.UserId);
|
||||
cmd.Parameters.AddWithValue("createdAt", conversation.CreatedAt);
|
||||
cmd.Parameters.AddWithValue("updatedAt", conversation.UpdatedAt);
|
||||
cmd.Parameters.AddWithValue("context", JsonSerializer.Serialize(conversation.Context, JsonOptions));
|
||||
cmd.Parameters.AddWithValue("metadata", JsonSerializer.Serialize(conversation.Metadata, JsonOptions));
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created conversation {ConversationId} for user {UserId}",
|
||||
conversation.ConversationId, conversation.UserId);
|
||||
|
||||
return conversation;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Conversation?> GetByIdAsync(
|
||||
string conversationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM advisoryai.conversations
|
||||
WHERE conversation_id = @conversationId
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("conversationId", conversationId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var conversation = await MapConversationAsync(reader, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Load turns
|
||||
var turns = await GetTurnsAsync(conversationId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return conversation with { Turns = turns };
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Conversation>> GetByUserAsync(
|
||||
string tenantId,
|
||||
string userId,
|
||||
int limit = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = string.Create(CultureInfo.InvariantCulture, $"""
|
||||
SELECT * FROM advisoryai.conversations
|
||||
WHERE tenant_id = @tenantId AND user_id = @userId
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT {limit}
|
||||
""");
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("tenantId", tenantId);
|
||||
cmd.Parameters.AddWithValue("userId", userId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var conversations = new List<Conversation>();
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
conversations.Add(await MapConversationAsync(reader, cancellationToken).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
return conversations;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Conversation> AddTurnAsync(
|
||||
string conversationId,
|
||||
ConversationTurn turn,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string insertSql = """
|
||||
INSERT INTO advisoryai.turns (
|
||||
turn_id, conversation_id, role, content, timestamp,
|
||||
evidence_links, proposed_actions, metadata
|
||||
) VALUES (
|
||||
@turnId, @conversationId, @role, @content, @timestamp,
|
||||
@evidenceLinks::jsonb, @proposedActions::jsonb, @metadata::jsonb
|
||||
)
|
||||
""";
|
||||
|
||||
const string updateSql = """
|
||||
UPDATE advisoryai.conversations
|
||||
SET updated_at = @updatedAt
|
||||
WHERE conversation_id = @conversationId
|
||||
""";
|
||||
|
||||
await using var transaction = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Insert turn
|
||||
await using (var insertCmd = _dataSource.CreateCommand(insertSql))
|
||||
{
|
||||
insertCmd.Parameters.AddWithValue("turnId", turn.TurnId);
|
||||
insertCmd.Parameters.AddWithValue("conversationId", conversationId);
|
||||
insertCmd.Parameters.AddWithValue("role", turn.Role.ToString());
|
||||
insertCmd.Parameters.AddWithValue("content", turn.Content);
|
||||
insertCmd.Parameters.AddWithValue("timestamp", turn.Timestamp);
|
||||
insertCmd.Parameters.AddWithValue("evidenceLinks", JsonSerializer.Serialize(turn.EvidenceLinks, JsonOptions));
|
||||
insertCmd.Parameters.AddWithValue("proposedActions", JsonSerializer.Serialize(turn.ProposedActions, JsonOptions));
|
||||
insertCmd.Parameters.AddWithValue("metadata", JsonSerializer.Serialize(turn.Metadata, JsonOptions));
|
||||
|
||||
await insertCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Update conversation timestamp
|
||||
await using (var updateCmd = _dataSource.CreateCommand(updateSql))
|
||||
{
|
||||
updateCmd.Parameters.AddWithValue("conversationId", conversationId);
|
||||
updateCmd.Parameters.AddWithValue("updatedAt", turn.Timestamp);
|
||||
|
||||
await updateCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Added turn {TurnId} to conversation {ConversationId}",
|
||||
turn.TurnId, conversationId);
|
||||
|
||||
return (await GetByIdAsync(conversationId, cancellationToken).ConfigureAwait(false))!;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DeleteAsync(
|
||||
string conversationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
DELETE FROM advisoryai.conversations
|
||||
WHERE conversation_id = @conversationId
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("conversationId", conversationId);
|
||||
|
||||
var rowsAffected = await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (rowsAffected > 0)
|
||||
{
|
||||
_logger.LogInformation("Deleted conversation {ConversationId}", conversationId);
|
||||
}
|
||||
|
||||
return rowsAffected > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task CleanupExpiredAsync(
|
||||
TimeSpan maxAge,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
DELETE FROM advisoryai.conversations
|
||||
WHERE updated_at < @cutoff
|
||||
""";
|
||||
|
||||
var cutoff = DateTimeOffset.UtcNow - maxAge;
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("cutoff", cutoff);
|
||||
|
||||
var rowsDeleted = await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (rowsDeleted > 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Cleaned up {Count} expired conversations older than {MaxAge}",
|
||||
rowsDeleted, maxAge);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
// NpgsqlDataSource is typically managed by DI, so we don't dispose it here
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task<ImmutableArray<ConversationTurn>> GetTurnsAsync(
|
||||
string conversationId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM advisoryai.turns
|
||||
WHERE conversation_id = @conversationId
|
||||
ORDER BY timestamp ASC
|
||||
""";
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
cmd.Parameters.AddWithValue("conversationId", conversationId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var turns = new List<ConversationTurn>();
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
turns.Add(MapTurn(reader));
|
||||
}
|
||||
|
||||
return turns.ToImmutableArray();
|
||||
}
|
||||
|
||||
private async Task<Conversation> MapConversationAsync(
|
||||
NpgsqlDataReader reader,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_ = cancellationToken; // Suppress unused parameter warning
|
||||
|
||||
var contextJson = reader.IsDBNull(reader.GetOrdinal("context"))
|
||||
? null : reader.GetString(reader.GetOrdinal("context"));
|
||||
var metadataJson = reader.IsDBNull(reader.GetOrdinal("metadata"))
|
||||
? null : reader.GetString(reader.GetOrdinal("metadata"));
|
||||
|
||||
var context = contextJson != null
|
||||
? JsonSerializer.Deserialize<ConversationContext>(contextJson, JsonOptions) ?? new ConversationContext()
|
||||
: new ConversationContext();
|
||||
|
||||
var metadata = metadataJson != null
|
||||
? JsonSerializer.Deserialize<ImmutableDictionary<string, string>>(metadataJson, JsonOptions)
|
||||
?? ImmutableDictionary<string, string>.Empty
|
||||
: ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
return new Conversation
|
||||
{
|
||||
ConversationId = reader.GetString(reader.GetOrdinal("conversation_id")),
|
||||
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
|
||||
UserId = reader.GetString(reader.GetOrdinal("user_id")),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
||||
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("updated_at")),
|
||||
Context = context,
|
||||
Metadata = metadata,
|
||||
Turns = ImmutableArray<ConversationTurn>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static ConversationTurn MapTurn(NpgsqlDataReader reader)
|
||||
{
|
||||
var evidenceLinksJson = reader.IsDBNull(reader.GetOrdinal("evidence_links"))
|
||||
? null : reader.GetString(reader.GetOrdinal("evidence_links"));
|
||||
var proposedActionsJson = reader.IsDBNull(reader.GetOrdinal("proposed_actions"))
|
||||
? null : reader.GetString(reader.GetOrdinal("proposed_actions"));
|
||||
var metadataJson = reader.IsDBNull(reader.GetOrdinal("metadata"))
|
||||
? null : reader.GetString(reader.GetOrdinal("metadata"));
|
||||
|
||||
var evidenceLinks = evidenceLinksJson != null
|
||||
? JsonSerializer.Deserialize<ImmutableArray<EvidenceLink>>(evidenceLinksJson, JsonOptions)
|
||||
: ImmutableArray<EvidenceLink>.Empty;
|
||||
|
||||
var proposedActions = proposedActionsJson != null
|
||||
? JsonSerializer.Deserialize<ImmutableArray<ProposedAction>>(proposedActionsJson, JsonOptions)
|
||||
: ImmutableArray<ProposedAction>.Empty;
|
||||
|
||||
var metadata = metadataJson != null
|
||||
? JsonSerializer.Deserialize<ImmutableDictionary<string, string>>(metadataJson, JsonOptions)
|
||||
?? ImmutableDictionary<string, string>.Empty
|
||||
: ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
var roleStr = reader.GetString(reader.GetOrdinal("role"));
|
||||
var role = Enum.TryParse<TurnRole>(roleStr, ignoreCase: true, out var parsedRole)
|
||||
? parsedRole
|
||||
: TurnRole.User;
|
||||
|
||||
return new ConversationTurn
|
||||
{
|
||||
TurnId = reader.GetString(reader.GetOrdinal("turn_id")),
|
||||
Role = role,
|
||||
Content = reader.GetString(reader.GetOrdinal("content")),
|
||||
Timestamp = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("timestamp")),
|
||||
EvidenceLinks = evidenceLinks,
|
||||
ProposedActions = proposedActions,
|
||||
Metadata = metadata
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for conversation storage.
|
||||
/// </summary>
|
||||
public interface IConversationStore
|
||||
{
|
||||
/// <summary>Creates a new conversation.</summary>
|
||||
Task<Conversation> CreateAsync(Conversation conversation, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Gets a conversation by ID.</summary>
|
||||
Task<Conversation?> GetByIdAsync(string conversationId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Gets conversations for a user.</summary>
|
||||
Task<IReadOnlyList<Conversation>> GetByUserAsync(string tenantId, string userId, int limit = 20, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Adds a turn to a conversation.</summary>
|
||||
Task<Conversation> AddTurnAsync(string conversationId, ConversationTurn turn, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Deletes a conversation.</summary>
|
||||
Task<bool> DeleteAsync(string conversationId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Cleans up expired conversations.</summary>
|
||||
Task CleanupExpiredAsync(TimeSpan maxAge, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for conversation store.
|
||||
/// </summary>
|
||||
public sealed class ConversationStoreOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the default conversation TTL.
|
||||
/// Default: 24 hours.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultTtl { get; set; } = TimeSpan.FromHours(24);
|
||||
}
|
||||
Reference in New Issue
Block a user