diff --git a/PlotLine/Controllers/AdminController.cs b/PlotLine/Controllers/AdminController.cs index 1b9ee90..b76991f 100644 --- a/PlotLine/Controllers/AdminController.cs +++ b/PlotLine/Controllers/AdminController.cs @@ -12,7 +12,8 @@ public sealed class AdminController( ICurrentUserService currentUser, IFeatureRequestService featureRequests, IEmailService emails, - IStoryIntelligenceDiagnosticsService storyIntelligenceDiagnostics) : Controller + IStoryIntelligenceDiagnosticsService storyIntelligenceDiagnostics, + IStoryIntelligenceDryRunService storyIntelligenceDryRun) : Controller { [HttpGet("")] [HttpGet("index")] @@ -36,6 +37,19 @@ public sealed class AdminController( return View(await storyIntelligenceDiagnostics.GetDiagnosticsAsync(cancellationToken)); } + [HttpGet("story-intelligence-test")] + public IActionResult StoryIntelligenceTest() + { + return View(storyIntelligenceDryRun.GetDefault()); + } + + [HttpPost("story-intelligence-test")] + [ValidateAntiForgeryToken] + public async Task StoryIntelligenceTest([Bind(Prefix = "Form")] StoryIntelligenceDryRunForm form, CancellationToken cancellationToken) + { + return View(await storyIntelligenceDryRun.ExecuteAsync(form, 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 c59ccf6..0a466c1 100644 --- a/PlotLine/Models/StoryIntelligenceModels.cs +++ b/PlotLine/Models/StoryIntelligenceModels.cs @@ -96,6 +96,7 @@ 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 RetryCount { 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 363503c..842e000 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -188,6 +188,7 @@ public class Program builder.Services.AddSingleton(); builder.Services.AddHttpClient(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs b/PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs index 03c9119..2d1f575 100644 --- a/PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs +++ b/PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs @@ -36,6 +36,12 @@ public interface IStoryIntelligenceDiagnosticsService Task GetDiagnosticsAsync(CancellationToken cancellationToken); } +public interface IStoryIntelligenceDryRunService +{ + StoryIntelligenceDryRunViewModel GetDefault(); + Task ExecuteAsync(StoryIntelligenceDryRunForm form, CancellationToken cancellationToken); +} + public sealed class StoryPromptRepository( IWebHostEnvironment environment, IMemoryCache cache, @@ -154,13 +160,14 @@ public sealed class StoryIntelligenceClient( var stopwatch = Stopwatch.StartNew(); try { - var responseText = await SendWithRetryAsync(requestBody, cancellationToken); + var (responseText, retryCount) = 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}", + "Story Intelligence prompt executed. PromptVersion={PromptVersion} Model={Model} DurationMs={DurationMs} RetryCount={RetryCount} InputTokens={InputTokens} OutputTokens={OutputTokens} EstimatedCost={EstimatedCost} Success={Success}", promptVersion, settings.Model, stopwatch.ElapsedMilliseconds, + retryCount, null, null, null, @@ -170,7 +177,8 @@ public sealed class StoryIntelligenceClient( { RawResponseText = responseText, Model = settings.Model, - Duration = stopwatch.Elapsed + Duration = stopwatch.Elapsed, + RetryCount = retryCount }; } catch (Exception ex) when (ex is not OperationCanceledException) @@ -178,21 +186,23 @@ public sealed class StoryIntelligenceClient( stopwatch.Stop(); logger.LogError( ex, - "Story Intelligence prompt failed. PromptVersion={PromptVersion} Model={Model} DurationMs={DurationMs} InputTokens={InputTokens} OutputTokens={OutputTokens} EstimatedCost={EstimatedCost} Success={Success}", + "Story Intelligence prompt failed. PromptVersion={PromptVersion} Model={Model} DurationMs={DurationMs} RetryCount={RetryCount} InputTokens={InputTokens} OutputTokens={OutputTokens} EstimatedCost={EstimatedCost} Success={Success}", promptVersion, settings.Model, stopwatch.ElapsedMilliseconds, null, null, null, + null, false); throw; } } - private async Task SendWithRetryAsync(string requestBody, CancellationToken cancellationToken) + 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") @@ -210,21 +220,25 @@ public sealed class StoryIntelligenceClient( if (response.IsSuccessStatusCode) { - return responseText; + 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 && attempt < maxAttempts) { + retryCount++; await DelayForRetryAsync(attempt, cancellationToken); continue; } catch (HttpRequestException) when (attempt < maxAttempts) { + retryCount++; await DelayForRetryAsync(attempt, cancellationToken); continue; } @@ -297,6 +311,99 @@ public sealed class StoryIntelligenceDiagnosticsService( } } +public sealed class StoryIntelligenceDryRunService( + IStoryPromptRepository prompts, + IStoryPromptVersionService versions, + IStoryPromptBuilder builder, + IStoryIntelligenceClient client, + ILogger logger) : IStoryIntelligenceDryRunService +{ + private const string ScenePromptFile = "Scene-Prompt-V1.md"; + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + 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); + model.Result = new StoryIntelligenceDryRunResultViewModel + { + Success = true, + PromptVersion = promptVersion, + Model = result.Model, + SceneContextJson = contextJson, + Duration = FormatDuration(result.Duration), + RetryCount = result.RetryCount, + RawResponseText = result.RawResponseText + }; + + logger.LogInformation( + "Admin Story Intelligence dry run completed. PromptVersion={PromptVersion} Model={Model} DurationMs={DurationMs} RetryCount={RetryCount} Success={Success}", + promptVersion, + result.Model, + result.Duration.TotalMilliseconds, + result.RetryCount, + true); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + model.Result = new StoryIntelligenceDryRunResultViewModel + { + Success = false, + PromptVersion = promptVersion, + Model = clientStatus.Model, + SceneContextJson = contextJson, + ErrorMessage = ex.Message + }; + + logger.LogWarning( + ex, + "Admin Story Intelligence dry run failed. PromptVersion={PromptVersion} Model={Model} Success={Success}", + promptVersion, + clientStatus.Model, + 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"; +} + public sealed class StoryIntelligenceClientConfigurationStatus { public bool ApiKeyConfigured { get; init; } diff --git a/PlotLine/ViewModels/FeatureRequestViewModels.cs b/PlotLine/ViewModels/FeatureRequestViewModels.cs index 98007d2..967a32e 100644 --- a/PlotLine/ViewModels/FeatureRequestViewModels.cs +++ b/PlotLine/ViewModels/FeatureRequestViewModels.cs @@ -96,6 +96,48 @@ public sealed class StoryIntelligenceDiagnosticsViewModel public string? ErrorMessage { get; set; } } +public sealed class StoryIntelligenceDryRunViewModel +{ + public StoryIntelligenceDryRunForm Form { get; set; } = StoryIntelligenceDryRunForm.Default(); + public StoryIntelligenceDryRunResultViewModel? Result { get; set; } +} + +public sealed class StoryIntelligenceDryRunForm +{ + public int? ProjectID { get; set; } + public int? BookID { get; set; } + public int? ChapterID { get; set; } + public int? SceneID { get; set; } + public decimal? ChapterNumber { get; set; } + public decimal? SceneNumber { get; set; } + public string SourceLabel { get; set; } = "Admin test scene"; + public string SceneText { get; set; } = string.Empty; + + public static StoryIntelligenceDryRunForm Default() => new() + { + ProjectID = null, + BookID = null, + ChapterID = null, + SceneID = null, + ChapterNumber = 1, + SceneNumber = 1, + SourceLabel = "Admin test scene", + SceneText = "Mara found the brass key under the loose stair. Elias watched from the doorway and asked whether she had told the captain. Mara hid the key in her glove and said the captain would learn soon enough." + }; +} + +public sealed class StoryIntelligenceDryRunResultViewModel +{ + public bool Success { get; init; } + public string PromptVersion { get; init; } = string.Empty; + public string Model { get; init; } = string.Empty; + public string SceneContextJson { get; init; } = string.Empty; + public string Duration { get; init; } = string.Empty; + public int RetryCount { get; init; } + public string RawResponseText { get; init; } = string.Empty; + public string? ErrorMessage { get; init; } +} + public sealed class AdminFeatureRequestFilter { public string? Status { get; set; } diff --git a/PlotLine/Views/Admin/Index.cshtml b/PlotLine/Views/Admin/Index.cshtml index 886ec94..4512cfd 100644 --- a/PlotLine/Views/Admin/Index.cshtml +++ b/PlotLine/Views/Admin/Index.cshtml @@ -82,6 +82,7 @@
diff --git a/PlotLine/Views/Admin/StoryIntelligenceDiagnostics.cshtml b/PlotLine/Views/Admin/StoryIntelligenceDiagnostics.cshtml index 0eea006..9bc7f28 100644 --- a/PlotLine/Views/Admin/StoryIntelligenceDiagnostics.cshtml +++ b/PlotLine/Views/Admin/StoryIntelligenceDiagnostics.cshtml @@ -93,6 +93,12 @@ +
+

Manual dry run

+

This admin-only tool loads the prompt, substitutes test scene data and performs a live OpenAI call without saving data.

+ Open dry run +
+ @functions { private static string StatusText(bool value) => value ? "Configured" : "Missing"; } diff --git a/PlotLine/Views/Admin/StoryIntelligenceTest.cshtml b/PlotLine/Views/Admin/StoryIntelligenceTest.cshtml new file mode 100644 index 0000000..4608268 --- /dev/null +++ b/PlotLine/Views/Admin/StoryIntelligenceTest.cshtml @@ -0,0 +1,112 @@ +@model StoryIntelligenceDryRunViewModel +@{ + ViewData["Title"] = "Story Intelligence Dry Run"; + var result = Model.Result; +} + +
+
+

Admin utility

+

Story Intelligence Dry Run

+

This is a development dry run. It does not save data or modify projects.

+
+
+ + + +
+

Manual scene test

+

Use short fictional sample text only. This page loads Scene-Prompt-V1.md, replaces runtime placeholders, and displays the raw response.

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + Back to diagnostics +
+
+
+ +@if (result is not null) +{ +
+

Dry-run result

+ @if (!result.Success) + { +
@result.ErrorMessage
+ } +
+
+
+

Prompt version

+

@result.PromptVersion

+
+
+
+
+

Model

+

@result.Model

+
+
+
+
+

Duration

+

@(string.IsNullOrWhiteSpace(result.Duration) ? "Not run" : result.Duration)

+
+
+
+
+

Retries

+

@result.RetryCount.ToString("N0")

+
+
+
+
+ + +
+
+ + +
+
+}