// // Copyright (c) StellaOps. Licensed under the BUSL-1.1. // using Microsoft.Extensions.Logging; using System.Collections.Immutable; using System.Globalization; using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; namespace StellaOps.AdvisoryAI.Chat; /// /// Streams chat responses as Server-Sent Events. /// Sprint: SPRINT_20260107_006_003 Task CH-006 /// public sealed class ChatResponseStreamer { private readonly ILogger _logger; private readonly StreamingOptions _options; /// /// Initializes a new instance of the class. /// public ChatResponseStreamer( ILogger logger, StreamingOptions? options = null) { _logger = logger; _options = options ?? new StreamingOptions(); } /// /// Streams response tokens from an LLM as Server-Sent Events. /// /// The source of tokens from the LLM. /// The conversation ID. /// The turn ID being generated. /// Cancellation token. /// Async enumerable of SSE events. public async IAsyncEnumerable StreamResponseAsync( IAsyncEnumerable tokenSource, string conversationId, string turnId, [EnumeratorCancellation] CancellationToken cancellationToken = default) { var contentBuilder = new StringBuilder(); var citations = new List(); var actions = new List(); 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); } /// /// Formats a stream event as an SSE string. /// 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(); } /// /// Handles connection drops by checkpointing. /// 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 }; } /// /// Resumes streaming from a checkpoint. /// public async IAsyncEnumerable ResumeFromCheckpointAsync( StreamCheckpoint checkpoint, IAsyncEnumerable 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 ExtractNewCitations(string content, int existingCount) { var citations = new List(); // Pattern: [type:path] var matches = System.Text.RegularExpressions.Regex.Matches( content, @"\[(?sbom|reach|runtime|vex|attest|auth|docs):(?[^\]]+)\]"); 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 ExtractNewActions(string content, int existingCount) { var actions = new List(); // Pattern: [Label]{action:type,params} var matches = System.Text.RegularExpressions.Regex.Matches( content, @"\[(?