using System.Diagnostics; using System.Net; using System.Net.Http.Headers; using System.Text; using System.Text.Json; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using PlotLine.Models; using PlotLine.ViewModels; namespace PlotLine.Services; public interface IStoryPromptRepository { Task LoadPromptAsync(string fileName, CancellationToken cancellationToken); } public interface IStoryPromptBuilder { string BuildPrompt(string promptTemplate, string sceneContextJson, string sceneText); } public interface IStoryPromptVersionService { string GetPromptVersion(string fileName); } public interface IStoryIntelligenceClient { Task ExecutePromptAsync(string completedPrompt, string promptVersion, CancellationToken cancellationToken); StoryIntelligenceClientConfigurationStatus GetConfigurationStatus(); } public interface IStoryIntelligenceDiagnosticsService { Task GetDiagnosticsAsync(CancellationToken cancellationToken); } public sealed class StoryPromptRepository( IWebHostEnvironment environment, IMemoryCache cache, ILogger logger) : IStoryPromptRepository { private const string PromptFolder = "Docs/AI"; public async Task LoadPromptAsync(string fileName, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(fileName) || fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || fileName.Contains(Path.DirectorySeparatorChar) || fileName.Contains(Path.AltDirectorySeparatorChar)) { throw new InvalidOperationException("Prompt filename is invalid."); } var cacheKey = $"story-prompt:{fileName}"; if (cache.TryGetValue(cacheKey, out string? cachedPrompt) && !string.IsNullOrWhiteSpace(cachedPrompt)) { return cachedPrompt; } var promptPath = Path.Combine(environment.ContentRootPath, PromptFolder, fileName); if (!File.Exists(promptPath)) { throw new FileNotFoundException($"Story Intelligence prompt file '{fileName}' was not found in /Docs/AI/.", promptPath); } var prompt = await File.ReadAllTextAsync(promptPath, cancellationToken); if (string.IsNullOrWhiteSpace(prompt)) { throw new InvalidOperationException($"Story Intelligence prompt file '{fileName}' is empty."); } cache.Set(cacheKey, prompt, TimeSpan.FromMinutes(15)); logger.LogInformation("Loaded Story Intelligence prompt {PromptFile} from {PromptPath}.", fileName, promptPath); return prompt; } } public sealed class StoryPromptBuilder : IStoryPromptBuilder { public string BuildPrompt(string promptTemplate, string sceneContextJson, string sceneText) { if (string.IsNullOrWhiteSpace(promptTemplate)) { throw new InvalidOperationException("Prompt template is empty."); } return promptTemplate .Replace("{{SCENE_CONTEXT_JSON}}", sceneContextJson ?? string.Empty, StringComparison.Ordinal) .Replace("{{SCENE_TEXT}}", sceneText ?? string.Empty, StringComparison.Ordinal); } } public sealed class StoryPromptVersionService : IStoryPromptVersionService { public string GetPromptVersion(string fileName) { if (string.IsNullOrWhiteSpace(fileName)) { throw new InvalidOperationException("Prompt filename is required."); } var safeName = Path.GetFileName(fileName); return Path.GetFileNameWithoutExtension(safeName); } } public sealed class StoryIntelligenceClient( HttpClient httpClient, IOptions options, ILogger logger) : IStoryIntelligenceClient { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private readonly StoryIntelligenceOptions settings = options.Value; public StoryIntelligenceClientConfigurationStatus GetConfigurationStatus() => new() { ApiKeyConfigured = !string.IsNullOrWhiteSpace(settings.ApiKey), Model = settings.Model, Temperature = settings.Temperature, MaxOutputTokens = settings.MaxOutputTokens, TimeoutSeconds = settings.TimeoutSeconds, ClientConstructed = true, ConnectionStatus = "Not checked. Diagnostics do not call OpenAI." }; public async Task ExecutePromptAsync(string completedPrompt, string promptVersion, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(settings.ApiKey)) { throw new InvalidOperationException("OpenAI API key is not configured. Add OpenAI:ApiKey to User Secrets or environment variables."); } if (string.IsNullOrWhiteSpace(settings.Model)) { throw new InvalidOperationException("OpenAI model is not configured. Set OpenAI:Model in configuration."); } if (string.IsNullOrWhiteSpace(completedPrompt)) { throw new InvalidOperationException("Completed prompt is empty."); } var requestBody = JsonSerializer.Serialize(new { model = settings.Model, input = completedPrompt, temperature = settings.Temperature, max_output_tokens = settings.MaxOutputTokens }, JsonOptions); var stopwatch = Stopwatch.StartNew(); try { var responseText = await SendWithRetryAsync(requestBody, cancellationToken); stopwatch.Stop(); logger.LogInformation( "Story Intelligence prompt executed. PromptVersion={PromptVersion} Model={Model} DurationMs={DurationMs} InputTokens={InputTokens} OutputTokens={OutputTokens} EstimatedCost={EstimatedCost} Success={Success}", promptVersion, settings.Model, stopwatch.ElapsedMilliseconds, null, null, null, true); return new StoryIntelligenceClientResult { RawResponseText = responseText, Model = settings.Model, Duration = stopwatch.Elapsed }; } catch (Exception ex) when (ex is not OperationCanceledException) { stopwatch.Stop(); logger.LogError( ex, "Story Intelligence prompt failed. PromptVersion={PromptVersion} Model={Model} DurationMs={DurationMs} InputTokens={InputTokens} OutputTokens={OutputTokens} EstimatedCost={EstimatedCost} Success={Success}", promptVersion, settings.Model, stopwatch.ElapsedMilliseconds, null, null, null, false); throw; } } private async Task SendWithRetryAsync(string requestBody, CancellationToken cancellationToken) { const int maxAttempts = 3; for (var attempt = 1; attempt <= maxAttempts; attempt++) { using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.openai.com/v1/responses") { Content = new StringContent(requestBody, Encoding.UTF8, "application/json") }; request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", settings.ApiKey); try { using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeout.CancelAfter(TimeSpan.FromSeconds(Math.Max(1, settings.TimeoutSeconds))); using var response = await httpClient.SendAsync(request, timeout.Token); var responseText = await response.Content.ReadAsStringAsync(cancellationToken); if (response.IsSuccessStatusCode) { return responseText; } if (!ShouldRetry(response.StatusCode) || attempt == maxAttempts) { throw new InvalidOperationException($"OpenAI request failed with {(int)response.StatusCode} {response.StatusCode}: {TrimForLog(responseText)}"); } } catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested && attempt < maxAttempts) { await DelayForRetryAsync(attempt, cancellationToken); continue; } catch (HttpRequestException) when (attempt < maxAttempts) { await DelayForRetryAsync(attempt, cancellationToken); continue; } await DelayForRetryAsync(attempt, cancellationToken); } throw new InvalidOperationException("OpenAI request failed after retry attempts."); } private static bool ShouldRetry(HttpStatusCode statusCode) => statusCode is HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests or HttpStatusCode.InternalServerError or HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout; private static Task DelayForRetryAsync(int attempt, CancellationToken cancellationToken) => Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt - 1)), cancellationToken); private static string TrimForLog(string value) => string.IsNullOrWhiteSpace(value) || value.Length <= 500 ? value : value[..500]; } public sealed class StoryIntelligenceDiagnosticsService( IStoryPromptRepository prompts, IStoryPromptVersionService versions, IStoryPromptBuilder builder, IStoryIntelligenceClient client) : IStoryIntelligenceDiagnosticsService { private const string ScenePromptFile = "Scene-Prompt-V1.md"; public async Task GetDiagnosticsAsync(CancellationToken cancellationToken) { var clientStatus = client.GetConfigurationStatus(); var model = new StoryIntelligenceDiagnosticsViewModel { Model = clientStatus.Model, ApiKeyConfigured = clientStatus.ApiKeyConfigured, ClientConstructed = clientStatus.ClientConstructed, ConnectionStatus = clientStatus.ConnectionStatus, PromptFileName = ScenePromptFile, PromptVersion = versions.GetPromptVersion(ScenePromptFile), Temperature = clientStatus.Temperature, MaxOutputTokens = clientStatus.MaxOutputTokens, TimeoutSeconds = clientStatus.TimeoutSeconds }; try { var prompt = await prompts.LoadPromptAsync(ScenePromptFile, cancellationToken); model.PromptRepositoryHealthy = true; model.ScenePromptLoaded = true; model.ScenePromptLength = prompt.Length; var completed = builder.BuildPrompt(prompt, "{\"sceneId\":0}", "Diagnostic scene text."); model.RuntimeSubstitutionWorks = completed.Contains("\"sceneId\":0", StringComparison.Ordinal) && completed.Contains("Diagnostic scene text.", StringComparison.Ordinal) && !completed.Contains("{{SCENE_CONTEXT_JSON}}", StringComparison.Ordinal) && !completed.Contains("{{SCENE_TEXT}}", StringComparison.Ordinal); } catch (Exception ex) { model.PromptRepositoryHealthy = false; model.ScenePromptLoaded = false; model.ErrorMessage = ex.Message; } return model; } } public sealed class StoryIntelligenceClientConfigurationStatus { public bool ApiKeyConfigured { get; init; } public string Model { get; init; } = string.Empty; public decimal Temperature { get; init; } public int MaxOutputTokens { get; init; } public int TimeoutSeconds { get; init; } public bool ClientConstructed { get; init; } public string ConnectionStatus { get; init; } = string.Empty; }