Phase 20J OpenAi dry run.
This commit is contained in:
parent
7d93f833b4
commit
406dae8bd7
@ -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<IActionResult> StoryIntelligenceTest([Bind(Prefix = "Form")] StoryIntelligenceDryRunForm form, CancellationToken cancellationToken)
|
||||
{
|
||||
return View(await storyIntelligenceDryRun.ExecuteAsync(form, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("feature-requests")]
|
||||
public async Task<IActionResult> FeatureRequests(string? status, string? appArea, string? importance, string? search, string? sort)
|
||||
{
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -188,6 +188,7 @@ public class Program
|
||||
builder.Services.AddSingleton<IStoryPromptVersionService, StoryPromptVersionService>();
|
||||
builder.Services.AddHttpClient<IStoryIntelligenceClient, StoryIntelligenceClient>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceDiagnosticsService, StoryIntelligenceDiagnosticsService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceDryRunService, StoryIntelligenceDryRunService>();
|
||||
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
|
||||
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
||||
builder.Services.AddScoped<IStripeBillingService, StripeBillingService>();
|
||||
|
||||
@ -36,6 +36,12 @@ public interface IStoryIntelligenceDiagnosticsService
|
||||
Task<StoryIntelligenceDiagnosticsViewModel> GetDiagnosticsAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IStoryIntelligenceDryRunService
|
||||
{
|
||||
StoryIntelligenceDryRunViewModel GetDefault();
|
||||
Task<StoryIntelligenceDryRunViewModel> 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<string> 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<StoryIntelligenceDryRunService> 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<StoryIntelligenceDryRunViewModel> 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; }
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -82,6 +82,7 @@
|
||||
</div>
|
||||
<div class="project-card__actions">
|
||||
<a class="btn btn-primary btn-sm" asp-action="StoryIntelligenceDiagnostics">Open</a>
|
||||
<a class="btn btn-outline-primary btn-sm" asp-action="StoryIntelligenceTest">Dry run</a>
|
||||
</div>
|
||||
</article>
|
||||
<article class="project-card">
|
||||
|
||||
@ -93,6 +93,12 @@
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="edit-panel">
|
||||
<h2>Manual dry run</h2>
|
||||
<p>This admin-only tool loads the prompt, substitutes test scene data and performs a live OpenAI call without saving data.</p>
|
||||
<a class="btn btn-primary" asp-action="StoryIntelligenceTest">Open dry run</a>
|
||||
</section>
|
||||
|
||||
@functions {
|
||||
private static string StatusText(bool value) => value ? "Configured" : "Missing";
|
||||
}
|
||||
|
||||
112
PlotLine/Views/Admin/StoryIntelligenceTest.cshtml
Normal file
112
PlotLine/Views/Admin/StoryIntelligenceTest.cshtml
Normal file
@ -0,0 +1,112 @@
|
||||
@model StoryIntelligenceDryRunViewModel
|
||||
@{
|
||||
ViewData["Title"] = "Story Intelligence Dry Run";
|
||||
var result = Model.Result;
|
||||
}
|
||||
|
||||
<div class="page-heading compact">
|
||||
<div>
|
||||
<p class="eyebrow">Admin utility</p>
|
||||
<h1>Story Intelligence Dry Run</h1>
|
||||
<p class="lead-text">This is a development dry run. It does not save data or modify projects.</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"> / </span>
|
||||
<a asp-action="StoryIntelligenceDiagnostics">Story Intelligence Diagnostics</a>
|
||||
<span class="muted"> / Dry Run</span>
|
||||
</nav>
|
||||
|
||||
<section class="edit-panel">
|
||||
<h2>Manual scene test</h2>
|
||||
<p>Use short fictional sample text only. This page loads <code>Scene-Prompt-V1.md</code>, replaces runtime placeholders, and displays the raw response.</p>
|
||||
<form asp-action="StoryIntelligenceTest" method="post">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" asp-for="Form.ProjectID">ProjectID</label>
|
||||
<input class="form-control" asp-for="Form.ProjectID" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" asp-for="Form.BookID">BookID</label>
|
||||
<input class="form-control" asp-for="Form.BookID" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" asp-for="Form.ChapterID">ChapterID</label>
|
||||
<input class="form-control" asp-for="Form.ChapterID" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" asp-for="Form.SceneID">SceneID</label>
|
||||
<input class="form-control" asp-for="Form.SceneID" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" asp-for="Form.ChapterNumber">ChapterNumber</label>
|
||||
<input class="form-control" asp-for="Form.ChapterNumber" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" asp-for="Form.SceneNumber">SceneNumber</label>
|
||||
<input class="form-control" asp-for="Form.SceneNumber" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" asp-for="Form.SourceLabel">SourceLabel</label>
|
||||
<input class="form-control" asp-for="Form.SourceLabel" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" asp-for="Form.SceneText">Scene text</label>
|
||||
<textarea class="form-control" asp-for="Form.SceneText" rows="8"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-row mt-3">
|
||||
<button class="btn btn-primary" type="submit">Run dry test</button>
|
||||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceDiagnostics">Back to diagnostics</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@if (result is not null)
|
||||
{
|
||||
<section class="edit-panel">
|
||||
<h2>Dry-run result</h2>
|
||||
@if (!result.Success)
|
||||
{
|
||||
<div class="alert alert-warning">@result.ErrorMessage</div>
|
||||
}
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<div class="border rounded p-3 h-100">
|
||||
<p class="eyebrow">Prompt version</p>
|
||||
<h3 class="mb-0">@result.PromptVersion</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="border rounded p-3 h-100">
|
||||
<p class="eyebrow">Model</p>
|
||||
<h3 class="mb-0">@result.Model</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="border rounded p-3 h-100">
|
||||
<p class="eyebrow">Duration</p>
|
||||
<h3 class="mb-0">@(string.IsNullOrWhiteSpace(result.Duration) ? "Not run" : result.Duration)</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="border rounded p-3 h-100">
|
||||
<p class="eyebrow">Retries</p>
|
||||
<h3 class="mb-0">@result.RetryCount.ToString("N0")</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="form-label" for="sceneContextJson">Scene context JSON</label>
|
||||
<textarea class="form-control font-monospace" id="sceneContextJson" rows="8" readonly>@result.SceneContextJson</textarea>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="form-label" for="rawResponseText">Raw response text</label>
|
||||
<textarea class="form-control font-monospace" id="rawResponseText" rows="18" readonly>@result.RawResponseText</textarea>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user