Phase 20I – Story Intelligence Infrastructure

This commit is contained in:
Nick Beckley 2026-07-04 17:06:59 +01:00
parent a2b3fb2d03
commit 7d93f833b4
8 changed files with 488 additions and 4 deletions

View File

@ -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<IActionResult> StoryIntelligenceDiagnostics(CancellationToken cancellationToken)
{
return View(await storyIntelligenceDiagnostics.GetDiagnosticsAsync(cancellationToken));
}
[HttpGet("feature-requests")]
public async Task<IActionResult> FeatureRequests(string? status, string? appArea, string? importance, string? search, string? sort)
{

View File

@ -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; }
}

View File

@ -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<EmailSettings>(builder.Configuration.GetSection("EmailSettings"));
builder.Services.Configure<StripeSettings>(builder.Configuration.GetSection("Stripe"));
builder.Services.Configure<StorageSettings>(builder.Configuration.GetSection("Storage"));
builder.Services.Configure<StoryIntelligenceOptions>(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<IOnboardingService, OnboardingService>();
builder.Services.AddScoped<IStoryIntelligenceService, StoryIntelligenceService>();
builder.Services.AddScoped<IStoryIntelligenceProvider, StubStoryIntelligenceProvider>();
builder.Services.AddSingleton<IStoryPromptRepository, StoryPromptRepository>();
builder.Services.AddSingleton<IStoryPromptBuilder, StoryPromptBuilder>();
builder.Services.AddSingleton<IStoryPromptVersionService, StoryPromptVersionService>();
builder.Services.AddHttpClient<IStoryIntelligenceClient, StoryIntelligenceClient>();
builder.Services.AddScoped<IStoryIntelligenceDiagnosticsService, StoryIntelligenceDiagnosticsService>();
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
builder.Services.AddScoped<IStripeBillingService, StripeBillingService>();

View File

@ -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<string> 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<StoryIntelligenceClientResult> ExecutePromptAsync(string completedPrompt, string promptVersion, CancellationToken cancellationToken);
StoryIntelligenceClientConfigurationStatus GetConfigurationStatus();
}
public interface IStoryIntelligenceDiagnosticsService
{
Task<StoryIntelligenceDiagnosticsViewModel> GetDiagnosticsAsync(CancellationToken cancellationToken);
}
public sealed class StoryPromptRepository(
IWebHostEnvironment environment,
IMemoryCache cache,
ILogger<StoryPromptRepository> logger) : IStoryPromptRepository
{
private const string PromptFolder = "Docs/AI";
public async Task<string> 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<StoryIntelligenceOptions> options,
ILogger<StoryIntelligenceClient> 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<StoryIntelligenceClientResult> 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<string> 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<StoryIntelligenceDiagnosticsViewModel> 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;
}

View File

@ -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; }

View File

@ -76,9 +76,12 @@
</article>
<article class="project-card">
<div class="project-card__body">
<p class="eyebrow">Placeholder</p>
<h2>Users</h2>
<p class="project-card__description">User management tools will be added later.</p>
<p class="eyebrow">Story Intelligence</p>
<h2>AI Diagnostics</h2>
<p class="project-card__description">Check prompt loading and OpenAI configuration without making API calls.</p>
</div>
<div class="project-card__actions">
<a class="btn btn-primary btn-sm" asp-action="StoryIntelligenceDiagnostics">Open</a>
</div>
</article>
<article class="project-card">

View File

@ -0,0 +1,98 @@
@model StoryIntelligenceDiagnosticsViewModel
@{
ViewData["Title"] = "Story Intelligence Diagnostics";
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Admin utility</p>
<h1>Story Intelligence Diagnostics</h1>
<p class="lead-text">Configuration and prompt loading checks. This page does not call OpenAI.</p>
</div>
</div>
<nav class="mb-3" aria-label="Breadcrumb">
<a asp-controller="Projects" asp-action="Index">Projects</a>
<span class="muted"> / </span>
<a asp-action="Index">Admin</a>
<span class="muted"> / Story Intelligence Diagnostics</span>
</nav>
@if (!string.IsNullOrWhiteSpace(Model.ErrorMessage))
{
<div class="alert alert-warning">@Model.ErrorMessage</div>
}
<section class="edit-panel">
<h2>OpenAI configuration</h2>
<div class="row g-3">
<div class="col-md-4">
<div class="border rounded p-3 h-100">
<p class="eyebrow">Model</p>
<h3 class="mb-0">@Model.Model</h3>
</div>
</div>
<div class="col-md-4">
<div class="border rounded p-3 h-100">
<p class="eyebrow">API key</p>
<h3 class="mb-0">@StatusText(Model.ApiKeyConfigured)</h3>
</div>
</div>
<div class="col-md-4">
<div class="border rounded p-3 h-100">
<p class="eyebrow">Client</p>
<h3 class="mb-0">@StatusText(Model.ClientConstructed)</h3>
</div>
</div>
</div>
<dl class="mt-3 mb-0">
<dt>Temperature</dt>
<dd>@Model.Temperature</dd>
<dt>Max output tokens</dt>
<dd>@Model.MaxOutputTokens.ToString("N0")</dd>
<dt>Timeout</dt>
<dd>@Model.TimeoutSeconds seconds</dd>
<dt>Connection status</dt>
<dd>@Model.ConnectionStatus</dd>
</dl>
</section>
<section class="edit-panel">
<h2>Prompt repository</h2>
<div class="row g-3">
<div class="col-md-3">
<div class="border rounded p-3 h-100">
<p class="eyebrow">Repository</p>
<h3 class="mb-0">@StatusText(Model.PromptRepositoryHealthy)</h3>
</div>
</div>
<div class="col-md-3">
<div class="border rounded p-3 h-100">
<p class="eyebrow">Scene prompt</p>
<h3 class="mb-0">@StatusText(Model.ScenePromptLoaded)</h3>
</div>
</div>
<div class="col-md-3">
<div class="border rounded p-3 h-100">
<p class="eyebrow">Prompt version</p>
<h3 class="mb-0">@Model.PromptVersion</h3>
</div>
</div>
<div class="col-md-3">
<div class="border rounded p-3 h-100">
<p class="eyebrow">Substitution</p>
<h3 class="mb-0">@StatusText(Model.RuntimeSubstitutionWorks)</h3>
</div>
</div>
</div>
<dl class="mt-3 mb-0">
<dt>Prompt file</dt>
<dd>@Model.PromptFileName</dd>
<dt>Prompt length</dt>
<dd>@Model.ScenePromptLength.ToString("N0") characters</dd>
</dl>
</section>
@functions {
private static string StatusText(bool value) => value ? "Configured" : "Missing";
}

View File

@ -19,6 +19,12 @@
"Storage": {
"UploadsRoot": ""
},
"OpenAI": {
"Model": "gpt-5",
"Temperature": 0.2,
"MaxOutputTokens": 4000,
"TimeoutSeconds": 120
},
"Site": {
"PublicBaseUrl": "https://plotdirector.com"
},