more audit work

This commit is contained in:
master
2026-01-08 10:21:51 +02:00
parent 43c02081ef
commit 51cf4bc16c
546 changed files with 36721 additions and 4003 deletions

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

View 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";
}

View 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);
}

View File

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

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

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

View File

@@ -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" />

View 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);
}