//
// 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,
@"\[(?