From 7d93f833b4017eece3b80fc81d6b423f8d98ed51 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sat, 4 Jul 2026 17:06:59 +0100 Subject: [PATCH] =?UTF-8?q?Phase=2020I=20=E2=80=93=20Story=20Intelligence?= =?UTF-8?q?=20Infrastructure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PlotLine/Controllers/AdminController.cs | 9 +- PlotLine/Models/StoryIntelligenceModels.cs | 36 ++ PlotLine/Program.cs | 7 + .../StoryIntelligenceOpenAIInfrastructure.cs | 309 ++++++++++++++++++ .../ViewModels/FeatureRequestViewModels.cs | 18 + PlotLine/Views/Admin/Index.cshtml | 9 +- .../Admin/StoryIntelligenceDiagnostics.cshtml | 98 ++++++ PlotLine/appsettings.json | 6 + 8 files changed, 488 insertions(+), 4 deletions(-) create mode 100644 PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs create mode 100644 PlotLine/Views/Admin/StoryIntelligenceDiagnostics.cshtml diff --git a/PlotLine/Controllers/AdminController.cs b/PlotLine/Controllers/AdminController.cs index a763caa..1b9ee90 100644 --- a/PlotLine/Controllers/AdminController.cs +++ b/PlotLine/Controllers/AdminController.cs @@ -11,7 +11,8 @@ namespace PlotLine.Controllers; public sealed class AdminController( ICurrentUserService currentUser, IFeatureRequestService featureRequests, - IEmailService emails) : Controller + IEmailService emails, + IStoryIntelligenceDiagnosticsService storyIntelligenceDiagnostics) : Controller { [HttpGet("")] [HttpGet("index")] @@ -29,6 +30,12 @@ public sealed class AdminController( return View(); } + [HttpGet("story-intelligence-diagnostics")] + public async Task StoryIntelligenceDiagnostics(CancellationToken cancellationToken) + { + return View(await storyIntelligenceDiagnostics.GetDiagnosticsAsync(cancellationToken)); + } + [HttpGet("feature-requests")] public async Task FeatureRequests(string? status, string? appArea, string? importance, string? search, string? sort) { diff --git a/PlotLine/Models/StoryIntelligenceModels.cs b/PlotLine/Models/StoryIntelligenceModels.cs index 7fb3a7b..c59ccf6 100644 --- a/PlotLine/Models/StoryIntelligenceModels.cs +++ b/PlotLine/Models/StoryIntelligenceModels.cs @@ -64,3 +64,39 @@ public sealed class StoryIntelligenceJobProgress public bool IsActive { get; init; } public bool IsCompleted { get; init; } } + +public sealed class StoryIntelligenceOptions +{ + public string ApiKey { get; init; } = string.Empty; + public string Model { get; init; } = string.Empty; + public decimal Temperature { get; init; } = 0.2m; + public int MaxOutputTokens { get; init; } = 4000; + public int TimeoutSeconds { get; init; } = 120; +} + +public sealed class StoryIntelligenceRun +{ + public Guid RunId { get; init; } = Guid.NewGuid(); + public int ProjectId { get; init; } + public int BookId { get; init; } + public DateTime StartedUtc { get; init; } = DateTime.UtcNow; + public DateTime? CompletedUtc { get; init; } + public string Status { get; init; } = "Pending"; + public string PromptVersion { get; init; } = string.Empty; + public string Model { get; init; } = string.Empty; + public int? InputTokens { get; init; } + public int? OutputTokens { get; init; } + public decimal? EstimatedCost { get; init; } + public bool Cancelled { get; init; } + public string? ErrorMessage { get; init; } +} + +public sealed class StoryIntelligenceClientResult +{ + public string RawResponseText { get; init; } = string.Empty; + public string Model { get; init; } = string.Empty; + public TimeSpan Duration { get; init; } + public int? InputTokens { get; init; } + public int? OutputTokens { get; init; } + public decimal? EstimatedCost { get; init; } +} diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 9fc0407..363503c 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -29,6 +29,7 @@ public class Program options.MaximumReceiveMessageSize = 1024 * 1024; }); builder.Services.AddHttpContextAccessor(); + builder.Services.AddMemoryCache(); builder.Services.AddDistributedMemoryCache(); builder.Services.AddSession(options => { @@ -40,6 +41,7 @@ public class Program builder.Services.Configure(builder.Configuration.GetSection("EmailSettings")); builder.Services.Configure(builder.Configuration.GetSection("Stripe")); builder.Services.Configure(builder.Configuration.GetSection("Storage")); + builder.Services.Configure(builder.Configuration.GetSection("OpenAI")); builder.Services.AddDataProtection() .PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(builder.Environment.ContentRootPath, "App_Data", "DataProtectionKeys"))); builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) @@ -181,6 +183,11 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddHttpClient(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs b/PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs new file mode 100644 index 0000000..03c9119 --- /dev/null +++ b/PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs @@ -0,0 +1,309 @@ +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; +} diff --git a/PlotLine/ViewModels/FeatureRequestViewModels.cs b/PlotLine/ViewModels/FeatureRequestViewModels.cs index 1877ee5..98007d2 100644 --- a/PlotLine/ViewModels/FeatureRequestViewModels.cs +++ b/PlotLine/ViewModels/FeatureRequestViewModels.cs @@ -78,6 +78,24 @@ public sealed class AdminDashboardViewModel public FeatureRequestAdminSummary FeatureRequestSummary { get; set; } = new(); } +public sealed class StoryIntelligenceDiagnosticsViewModel +{ + public string Model { get; set; } = string.Empty; + public decimal Temperature { get; set; } + public int MaxOutputTokens { get; set; } + public int TimeoutSeconds { get; set; } + public bool ApiKeyConfigured { get; set; } + public bool ClientConstructed { get; set; } + public string ConnectionStatus { get; set; } = string.Empty; + public bool PromptRepositoryHealthy { get; set; } + public string PromptFileName { get; set; } = string.Empty; + public bool ScenePromptLoaded { get; set; } + public int ScenePromptLength { get; set; } + public string PromptVersion { get; set; } = string.Empty; + public bool RuntimeSubstitutionWorks { get; set; } + public string? ErrorMessage { get; set; } +} + public sealed class AdminFeatureRequestFilter { public string? Status { get; set; } diff --git a/PlotLine/Views/Admin/Index.cshtml b/PlotLine/Views/Admin/Index.cshtml index ff6af14..886ec94 100644 --- a/PlotLine/Views/Admin/Index.cshtml +++ b/PlotLine/Views/Admin/Index.cshtml @@ -76,9 +76,12 @@
-

Placeholder

-

Users

-

User management tools will be added later.

+

Story Intelligence

+

AI Diagnostics

+

Check prompt loading and OpenAI configuration without making API calls.

+
+
+ Open
diff --git a/PlotLine/Views/Admin/StoryIntelligenceDiagnostics.cshtml b/PlotLine/Views/Admin/StoryIntelligenceDiagnostics.cshtml new file mode 100644 index 0000000..0eea006 --- /dev/null +++ b/PlotLine/Views/Admin/StoryIntelligenceDiagnostics.cshtml @@ -0,0 +1,98 @@ +@model StoryIntelligenceDiagnosticsViewModel +@{ + ViewData["Title"] = "Story Intelligence Diagnostics"; +} + +
+
+

Admin utility

+

Story Intelligence Diagnostics

+

Configuration and prompt loading checks. This page does not call OpenAI.

+
+
+ + + +@if (!string.IsNullOrWhiteSpace(Model.ErrorMessage)) +{ +
@Model.ErrorMessage
+} + +
+

OpenAI configuration

+
+
+
+

Model

+

@Model.Model

+
+
+
+
+

API key

+

@StatusText(Model.ApiKeyConfigured)

+
+
+
+
+

Client

+

@StatusText(Model.ClientConstructed)

+
+
+
+
+
Temperature
+
@Model.Temperature
+
Max output tokens
+
@Model.MaxOutputTokens.ToString("N0")
+
Timeout
+
@Model.TimeoutSeconds seconds
+
Connection status
+
@Model.ConnectionStatus
+
+
+ +
+

Prompt repository

+
+
+
+

Repository

+

@StatusText(Model.PromptRepositoryHealthy)

+
+
+
+
+

Scene prompt

+

@StatusText(Model.ScenePromptLoaded)

+
+
+
+
+

Prompt version

+

@Model.PromptVersion

+
+
+
+
+

Substitution

+

@StatusText(Model.RuntimeSubstitutionWorks)

+
+
+
+
+
Prompt file
+
@Model.PromptFileName
+
Prompt length
+
@Model.ScenePromptLength.ToString("N0") characters
+
+
+ +@functions { + private static string StatusText(bool value) => value ? "Configured" : "Missing"; +} diff --git a/PlotLine/appsettings.json b/PlotLine/appsettings.json index 07932c9..65fadc9 100644 --- a/PlotLine/appsettings.json +++ b/PlotLine/appsettings.json @@ -19,6 +19,12 @@ "Storage": { "UploadsRoot": "" }, + "OpenAI": { + "Model": "gpt-5", + "Temperature": 0.2, + "MaxOutputTokens": 4000, + "TimeoutSeconds": 120 + }, "Site": { "PublicBaseUrl": "https://plotdirector.com" },