using System.Diagnostics; using System.Net; using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; 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, string? modelOverride = null, int? maxOutputTokensOverride = null); StoryIntelligenceClientConfigurationStatus GetConfigurationStatus(); } public interface IStoryIntelligenceDiagnosticsService { Task GetDiagnosticsAsync(CancellationToken cancellationToken); } public interface IStoryIntelligenceDryRunService { StoryIntelligenceDryRunViewModel GetDefault(); Task ExecuteAsync(StoryIntelligenceDryRunForm form, CancellationToken cancellationToken); } public interface IChapterStructureDryRunService { ChapterStructureDryRunViewModel GetDefault(); Task ExecuteAsync(ChapterStructureDryRunForm form, 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.EffectiveModel, ChapterStructureModel = settings.EffectiveChapterStructureModel, SceneIntelligenceModel = settings.EffectiveSceneIntelligenceModel, MaxOutputTokens = settings.MaxOutputTokens, TimeoutSeconds = settings.TimeoutSeconds, ClientConstructed = true, ConnectionStatus = "Not checked on diagnostics page. Only the admin dry-run page makes a live OpenAI request." }; public async Task ExecutePromptAsync( string completedPrompt, string promptVersion, CancellationToken cancellationToken, string? modelOverride = null, int? maxOutputTokensOverride = null) { if (string.IsNullOrWhiteSpace(settings.ApiKey)) { throw new InvalidOperationException("OpenAI API key is not configured. Add OpenAI:ApiKey to User Secrets or environment variables."); } var model = string.IsNullOrWhiteSpace(modelOverride) ? settings.EffectiveModel : modelOverride.Trim(); if (string.IsNullOrWhiteSpace(model)) { throw new InvalidOperationException("OpenAI model is not configured. Set OpenAI:Model or StoryIntelligence stage models in configuration."); } if (string.IsNullOrWhiteSpace(completedPrompt)) { throw new InvalidOperationException("Completed prompt is empty."); } var maxOutputTokens = maxOutputTokensOverride.GetValueOrDefault(settings.MaxOutputTokens); if (maxOutputTokens <= 0) { maxOutputTokens = settings.MaxOutputTokens; } var requestBody = JsonSerializer.Serialize(new { model = model, input = completedPrompt, max_output_tokens = maxOutputTokens, store = false, reasoning = new { effort = "minimal" }, text = new { verbosity = "low" } }, JsonOptions); var stopwatch = Stopwatch.StartNew(); try { var (responseText, retryCount) = await SendWithRetryAsync(requestBody, cancellationToken); stopwatch.Stop(); logger.LogInformation( "Story Intelligence prompt executed. PromptVersion={PromptVersion} Model={Model} DurationMs={DurationMs} RetryCount={RetryCount} InputTokens={InputTokens} OutputTokens={OutputTokens} EstimatedCost={EstimatedCost} Success={Success}", promptVersion, model, stopwatch.ElapsedMilliseconds, retryCount, null, null, null, true); return new StoryIntelligenceClientResult { RawResponseText = responseText, Model = model, Duration = stopwatch.Elapsed, RetryCount = retryCount, InputTokens = GetTokenUsage(responseText).InputTokens, OutputTokens = GetTokenUsage(responseText).OutputTokens }; } catch (Exception ex) when (ex is not OperationCanceledException) { stopwatch.Stop(); logger.LogError( ex, "Story Intelligence prompt failed. PromptVersion={PromptVersion} Model={Model} DurationMs={DurationMs} RetryCount={RetryCount} InputTokens={InputTokens} OutputTokens={OutputTokens} EstimatedCost={EstimatedCost} Success={Success}", promptVersion, model, stopwatch.ElapsedMilliseconds, null, null, null, null, false); throw; } } private async Task<(string ResponseText, int RetryCount)> SendWithRetryAsync(string requestBody, CancellationToken cancellationToken) { const int maxAttempts = 3; var retryCount = 0; 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, retryCount); } if (!ShouldRetry(response.StatusCode) || attempt == maxAttempts) { throw new InvalidOperationException($"OpenAI request failed with {(int)response.StatusCode} {response.StatusCode}: {TrimForLog(responseText)}"); } retryCount++; } catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested) { if (attempt == maxAttempts) { throw new InvalidOperationException($"OpenAI request timed out after {settings.TimeoutSeconds} seconds."); } retryCount++; await DelayForRetryAsync(attempt, cancellationToken); continue; } catch (HttpRequestException) when (attempt < maxAttempts) { retryCount++; 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]; private static (int? InputTokens, int? OutputTokens) GetTokenUsage(string responseText) { try { var response = JsonSerializer.Deserialize(responseText, JsonOptions); return (response?.Usage?.InputTokens, response?.Usage?.OutputTokens); } catch (JsonException) { return (null, null); } } } 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, ChapterStructureModel = clientStatus.ChapterStructureModel, SceneIntelligenceModel = clientStatus.SceneIntelligenceModel, ApiKeyConfigured = clientStatus.ApiKeyConfigured, ClientConstructed = clientStatus.ClientConstructed, ConnectionStatus = clientStatus.ConnectionStatus, PromptFileName = ScenePromptFile, PromptVersion = versions.GetPromptVersion(ScenePromptFile), 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 StoryIntelligenceDryRunService( IStoryPromptRepository prompts, IStoryPromptVersionService versions, IStoryPromptBuilder builder, IStoryIntelligenceClient client, IStorySceneValidator validator, ILogger logger) : IStoryIntelligenceDryRunService { private const string ScenePromptFile = "Scene-Prompt-V1.md"; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, WriteIndented = true }; public StoryIntelligenceDryRunViewModel GetDefault() => new(); public async Task ExecuteAsync(StoryIntelligenceDryRunForm form, CancellationToken cancellationToken) { var model = new StoryIntelligenceDryRunViewModel { Form = form }; var promptVersion = versions.GetPromptVersion(ScenePromptFile); var clientStatus = client.GetConfigurationStatus(); var contextJson = BuildSceneContextJson(form); try { if (string.IsNullOrWhiteSpace(form.SceneText)) { throw new InvalidOperationException("Enter short fictional scene text before running the dry run."); } var template = await prompts.LoadPromptAsync(ScenePromptFile, cancellationToken); var completedPrompt = builder.BuildPrompt(template, contextJson, form.SceneText); var result = await client.ExecutePromptAsync( completedPrompt, promptVersion, cancellationToken, clientStatus.SceneIntelligenceModel); var sceneJson = ExtractSceneJson(result.RawResponseText); var parsedScene = DeserializeScene(sceneJson); var validation = validator.Validate(parsedScene); model.Result = new StoryIntelligenceDryRunResultViewModel { Success = true, PromptVersion = promptVersion, Model = result.Model, SceneContextJson = contextJson, Duration = FormatDuration(result.Duration), RetryCount = result.RetryCount, InputTokens = result.InputTokens, OutputTokens = result.OutputTokens, RawResponseText = result.RawResponseText, SceneJsonText = sceneJson, ParsedScene = parsedScene, Validation = validation }; logger.LogInformation( "Admin Story Intelligence dry run completed. PromptVersion={PromptVersion} Model={Model} DurationMs={DurationMs} RetryCount={RetryCount} InputTokens={InputTokens} OutputTokens={OutputTokens} ValidationPassed={ValidationPassed} ValidationWarnings={ValidationWarnings} ValidationErrors={ValidationErrors} Success={Success}", promptVersion, result.Model, result.Duration.TotalMilliseconds, result.RetryCount, result.InputTokens, result.OutputTokens, validation.IsValid, validation.Warnings.Count, validation.Errors.Count, true); } catch (JsonException ex) { model.Result = new StoryIntelligenceDryRunResultViewModel { Success = false, PromptVersion = promptVersion, Model = clientStatus.SceneIntelligenceModel, SceneContextJson = contextJson, ErrorMessage = $"Scene Intelligence JSON could not be deserialised: {ex.Message}", Validation = validator.Validate(null) }; logger.LogWarning( ex, "Admin Story Intelligence dry run deserialisation failed. PromptVersion={PromptVersion} Model={Model} Success={Success}", promptVersion, clientStatus.SceneIntelligenceModel, false); } catch (Exception ex) when (ex is not OperationCanceledException) { model.Result = new StoryIntelligenceDryRunResultViewModel { Success = false, PromptVersion = promptVersion, Model = clientStatus.SceneIntelligenceModel, SceneContextJson = contextJson, ErrorMessage = ex.Message }; logger.LogWarning( ex, "Admin Story Intelligence dry run failed. PromptVersion={PromptVersion} Model={Model} Success={Success}", promptVersion, clientStatus.SceneIntelligenceModel, false); } return model; } private static string BuildSceneContextJson(StoryIntelligenceDryRunForm form) => JsonSerializer.Serialize(new { projectId = form.ProjectID, bookId = form.BookID, chapterId = form.ChapterID, sceneId = form.SceneID, chapterNumber = form.ChapterNumber, sceneNumber = form.SceneNumber, sourceLabel = string.IsNullOrWhiteSpace(form.SourceLabel) ? "Admin test scene" : form.SourceLabel.Trim() }, JsonOptions); private static string FormatDuration(TimeSpan duration) => duration.TotalSeconds < 1 ? $"{duration.TotalMilliseconds:0} ms" : $"{duration.TotalSeconds:0.00} sec"; private static string ExtractSceneJson(string rawResponseText) { var response = JsonSerializer.Deserialize(rawResponseText, JsonOptions) ?? throw new JsonException("OpenAI response envelope was empty."); var outputText = response.Output? .SelectMany(item => item.Content ?? []) .FirstOrDefault(content => string.Equals(content.Type, "output_text", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(content.Text)) ?.Text; if (string.IsNullOrWhiteSpace(outputText)) { throw new JsonException("OpenAI response did not contain output_text content."); } return outputText.Trim(); } private static SceneIntelligenceScene? DeserializeScene(string sceneJson) => JsonSerializer.Deserialize(sceneJson, JsonOptions); } public sealed class ChapterStructureDryRunService( IStoryPromptRepository prompts, IStoryPromptVersionService versions, IStoryIntelligenceClient client, IChapterStructureValidator validator, ILogger logger) : IChapterStructureDryRunService { private const string ChapterPromptFile = "Chapter-Structure-Prompt-V1.md"; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, WriteIndented = true }; public ChapterStructureDryRunViewModel GetDefault() => new(); public async Task ExecuteAsync(ChapterStructureDryRunForm form, CancellationToken cancellationToken) { var model = new ChapterStructureDryRunViewModel { Form = form }; var promptVersion = versions.GetPromptVersion(ChapterPromptFile); var clientStatus = client.GetConfigurationStatus(); var contextJson = BuildChapterContextJson(form); var paragraphs = StoryIntelligenceParagraphs.Split(form.ChapterText); var paragraphCount = paragraphs.Count; try { if (string.IsNullOrWhiteSpace(form.ChapterText)) { throw new InvalidOperationException("Enter fictional chapter text before running the chapter structure test."); } var template = await prompts.LoadPromptAsync(ChapterPromptFile, cancellationToken); var numberedChapterText = StoryIntelligenceParagraphs.Number(paragraphs); var completedPrompt = BuildChapterPrompt(template, contextJson, numberedChapterText); var result = await client.ExecutePromptAsync( completedPrompt, promptVersion, cancellationToken, clientStatus.ChapterStructureModel); var chapterJson = ExtractOutputText(result.RawResponseText); var parsedChapter = DeserializeChapter(chapterJson); var chapterNormalisation = ChapterStructureBoundaryNormaliser.Normalise(parsedChapter, paragraphCount); var chapterForValidation = chapterNormalisation.ChapterStructure; var validation = validator.Validate(chapterForValidation, paragraphCount); ChapterStructureBoundaryNormaliser.AddIssuesTo(validation, chapterNormalisation); model.Result = new ChapterStructureDryRunResultViewModel { Success = true, PromptVersion = promptVersion, Model = result.Model, ChapterContextJson = contextJson, ParagraphCount = paragraphCount, Duration = FormatDuration(result.Duration), RetryCount = result.RetryCount, InputTokens = result.InputTokens, OutputTokens = result.OutputTokens, RawResponseText = result.RawResponseText, ChapterJsonText = chapterJson, ParsedChapter = chapterForValidation, Validation = validation }; logger.LogInformation( "Admin Chapter Structure dry run completed. PromptVersion={PromptVersion} Model={Model} DurationMs={DurationMs} RetryCount={RetryCount} InputTokens={InputTokens} OutputTokens={OutputTokens} ValidationPassed={ValidationPassed} ValidationWarnings={ValidationWarnings} ValidationErrors={ValidationErrors} Success={Success}", promptVersion, result.Model, result.Duration.TotalMilliseconds, result.RetryCount, result.InputTokens, result.OutputTokens, validation.IsValid, validation.Warnings.Count, validation.Errors.Count, true); } catch (JsonException ex) { model.Result = new ChapterStructureDryRunResultViewModel { Success = false, PromptVersion = promptVersion, Model = clientStatus.ChapterStructureModel, ChapterContextJson = contextJson, ParagraphCount = paragraphCount, ErrorMessage = $"Chapter Structure JSON could not be deserialised: {ex.Message}", Validation = validator.Validate(null, paragraphCount) }; logger.LogWarning( ex, "Admin Chapter Structure dry run deserialisation failed. PromptVersion={PromptVersion} Model={Model} Success={Success}", promptVersion, clientStatus.ChapterStructureModel, false); } catch (Exception ex) when (ex is not OperationCanceledException) { model.Result = new ChapterStructureDryRunResultViewModel { Success = false, PromptVersion = promptVersion, Model = clientStatus.ChapterStructureModel, ChapterContextJson = contextJson, ParagraphCount = paragraphCount, ErrorMessage = ex.Message }; logger.LogWarning( ex, "Admin Chapter Structure dry run failed. PromptVersion={PromptVersion} Model={Model} Success={Success}", promptVersion, clientStatus.ChapterStructureModel, false); } return model; } private static string BuildChapterContextJson(ChapterStructureDryRunForm form) => JsonSerializer.Serialize(new { projectId = form.ProjectID, bookId = form.BookID, chapterId = form.ChapterID, chapterNumber = form.ChapterNumber, sourceLabel = string.IsNullOrWhiteSpace(form.SourceLabel) ? "Admin test chapter" : form.SourceLabel.Trim() }, JsonOptions); private static string BuildChapterPrompt(string promptTemplate, string chapterContextJson, string chapterText) { if (string.IsNullOrWhiteSpace(promptTemplate)) { throw new InvalidOperationException("Prompt template is empty."); } return promptTemplate .Replace("{{CHAPTER_CONTEXT_JSON}}", chapterContextJson ?? string.Empty, StringComparison.Ordinal) .Replace("{{CHAPTER_TEXT}}", chapterText ?? string.Empty, StringComparison.Ordinal); } private static string ExtractOutputText(string rawResponseText) { var response = JsonSerializer.Deserialize(rawResponseText, JsonOptions) ?? throw new JsonException("OpenAI response envelope was empty."); var outputText = response.Output? .SelectMany(item => item.Content ?? []) .FirstOrDefault(content => string.Equals(content.Type, "output_text", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(content.Text)) ?.Text; if (string.IsNullOrWhiteSpace(outputText)) { throw new JsonException("OpenAI response did not contain output_text content."); } return outputText.Trim(); } private static ChapterStructureModel? DeserializeChapter(string chapterJson) => JsonSerializer.Deserialize(chapterJson, JsonOptions); private static string FormatDuration(TimeSpan duration) => duration.TotalSeconds < 1 ? $"{duration.TotalMilliseconds:0} ms" : $"{duration.TotalSeconds:0.00} sec"; } internal sealed class OpenAIResponseEnvelope { public List? Output { get; init; } public OpenAIResponseUsage? Usage { get; init; } } internal sealed class OpenAIResponseOutputItem { public List? Content { get; init; } } internal sealed class OpenAIResponseContentItem { public string? Type { get; init; } public string? Text { get; init; } } internal sealed class OpenAIResponseUsage { [JsonPropertyName("input_tokens")] public int? InputTokens { get; init; } [JsonPropertyName("output_tokens")] public int? OutputTokens { get; init; } } public sealed class StoryIntelligenceClientConfigurationStatus { public bool ApiKeyConfigured { get; init; } public string Model { get; init; } = string.Empty; public string ChapterStructureModel { get; init; } = string.Empty; public string SceneIntelligenceModel { get; init; } = string.Empty; public int MaxOutputTokens { get; init; } public int TimeoutSeconds { get; init; } public bool ClientConstructed { get; init; } public string ConnectionStatus { get; init; } = string.Empty; }