Phase 20P Implement a real persisted Story Intelligence job runner.
This commit is contained in:
parent
650febab70
commit
9f9688bcb5
@ -93,6 +93,20 @@ public sealed class AdminController(
|
||||
return RedirectToAction(nameof(StoryIntelligenceRunDetails), new { id = runId });
|
||||
}
|
||||
|
||||
[HttpPost("chapter-story-intelligence-test/queue")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> QueueChapterStoryIntelligenceRun([Bind(Prefix = "Form")] ChapterStoryIntelligenceDryRunForm form)
|
||||
{
|
||||
if (currentUser.UserId is not int userId)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
var runId = await storyIntelligenceResults.QueueAdminTextRunAsync(userId, form);
|
||||
TempData["AdminMessage"] = $"Story Intelligence run {runId:N0} queued. The background worker will process it progressively.";
|
||||
return RedirectToAction(nameof(StoryIntelligenceRunDetails), new { id = runId });
|
||||
}
|
||||
|
||||
[HttpGet("story-intelligence-runs")]
|
||||
public async Task<IActionResult> StoryIntelligenceRuns()
|
||||
{
|
||||
@ -106,6 +120,15 @@ public sealed class AdminController(
|
||||
return model is null ? NotFound() : View(model);
|
||||
}
|
||||
|
||||
[HttpPost("story-intelligence-runs/{id:int}/cancel")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> CancelStoryIntelligenceRun(int id)
|
||||
{
|
||||
await storyIntelligenceResults.CancelRunAsync(id);
|
||||
TempData["AdminMessage"] = $"Cancellation requested for Story Intelligence run {id:N0}.";
|
||||
return RedirectToAction(nameof(StoryIntelligenceRunDetails), new { id });
|
||||
}
|
||||
|
||||
[HttpGet("feature-requests")]
|
||||
public async Task<IActionResult> FeatureRequests(string? status, string? appArea, string? importance, string? search, string? sort)
|
||||
{
|
||||
|
||||
@ -7,6 +7,28 @@ namespace PlotLine.Data;
|
||||
public interface IStoryIntelligenceResultRepository
|
||||
{
|
||||
Task<int> SaveAsync(StoryIntelligenceRunSaveRequest request);
|
||||
Task<int> QueueAdminTextAsync(StoryIntelligenceRunQueueRequest request);
|
||||
Task<StoryIntelligenceQueuedRun?> ClaimNextPendingAsync();
|
||||
Task UpdateProgressAsync(
|
||||
int runId,
|
||||
string? status = null,
|
||||
string? currentStage = null,
|
||||
string? currentMessage = null,
|
||||
int? totalDetectedScenes = null,
|
||||
int? completedScenes = null,
|
||||
int? failedScenes = null,
|
||||
int? totalInputTokens = null,
|
||||
int? totalOutputTokens = null,
|
||||
int? totalTokens = null,
|
||||
long? totalDurationMs = null,
|
||||
decimal? estimatedCostGBP = null,
|
||||
decimal? estimatedCostUSD = null);
|
||||
Task CompleteRunAsync(int runId, string status, int? totalInputTokens, int? totalOutputTokens, int? totalTokens, long? totalDurationMs, decimal? estimatedCostGBP, decimal? estimatedCostUSD);
|
||||
Task FailRunAsync(int runId, string failureStage, string errorMessage, string? errorDetail, long? totalDurationMs);
|
||||
Task RequestCancelAsync(int runId);
|
||||
Task CancelRunAsync(int runId, long? totalDurationMs);
|
||||
Task<int> SaveChapterResultAsync(int runId, StoryIntelligenceChapterResultSaveRequest chapter);
|
||||
Task<int> SaveSceneResultAsync(int runId, int? chapterResultId, StoryIntelligenceSceneResultSaveRequest scene);
|
||||
Task<IReadOnlyList<StoryIntelligenceSavedRunListItem>> ListRunsAsync();
|
||||
Task<StoryIntelligenceSavedRun?> GetRunAsync(int runId);
|
||||
Task<StoryIntelligenceSavedChapterResult?> GetChapterResultAsync(int runId);
|
||||
@ -131,6 +153,186 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> QueueAdminTextAsync(StoryIntelligenceRunQueueRequest request)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleAsync<int>(
|
||||
"dbo.StoryIntelligenceRun_QueueAdminText",
|
||||
new
|
||||
{
|
||||
request.UserID,
|
||||
request.ProjectID,
|
||||
request.BookID,
|
||||
request.SourceType,
|
||||
request.SourceLabel,
|
||||
SourceText = request.ChapterText,
|
||||
request.SourceWordCount,
|
||||
request.SourceCharacterCount,
|
||||
request.SourceChapterCount,
|
||||
request.Model,
|
||||
request.PromptVersionsSummary
|
||||
},
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceQueuedRun?> ClaimNextPendingAsync()
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceQueuedRun>(
|
||||
"dbo.StoryIntelligenceRun_ClaimNextPending",
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task UpdateProgressAsync(
|
||||
int runId,
|
||||
string? status = null,
|
||||
string? currentStage = null,
|
||||
string? currentMessage = null,
|
||||
int? totalDetectedScenes = null,
|
||||
int? completedScenes = null,
|
||||
int? failedScenes = null,
|
||||
int? totalInputTokens = null,
|
||||
int? totalOutputTokens = null,
|
||||
int? totalTokens = null,
|
||||
long? totalDurationMs = null,
|
||||
decimal? estimatedCostGBP = null,
|
||||
decimal? estimatedCostUSD = null)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
await connection.ExecuteAsync(
|
||||
"dbo.StoryIntelligenceRun_UpdateProgress",
|
||||
new
|
||||
{
|
||||
StoryIntelligenceRunID = runId,
|
||||
Status = status,
|
||||
CurrentStage = currentStage,
|
||||
CurrentMessage = currentMessage,
|
||||
TotalDetectedScenes = totalDetectedScenes,
|
||||
CompletedScenes = completedScenes,
|
||||
FailedScenes = failedScenes,
|
||||
TotalInputTokens = totalInputTokens,
|
||||
TotalOutputTokens = totalOutputTokens,
|
||||
TotalTokens = totalTokens,
|
||||
TotalDurationMs = totalDurationMs,
|
||||
EstimatedCostGBP = estimatedCostGBP,
|
||||
EstimatedCostUSD = estimatedCostUSD
|
||||
},
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task CompleteRunAsync(int runId, string status, int? totalInputTokens, int? totalOutputTokens, int? totalTokens, long? totalDurationMs, decimal? estimatedCostGBP, decimal? estimatedCostUSD)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
await connection.ExecuteAsync(
|
||||
"dbo.StoryIntelligenceRun_Complete",
|
||||
new
|
||||
{
|
||||
StoryIntelligenceRunID = runId,
|
||||
Status = status,
|
||||
TotalInputTokens = totalInputTokens,
|
||||
TotalOutputTokens = totalOutputTokens,
|
||||
TotalTokens = totalTokens,
|
||||
TotalDurationMs = totalDurationMs,
|
||||
EstimatedCostGBP = estimatedCostGBP,
|
||||
EstimatedCostUSD = estimatedCostUSD
|
||||
},
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task FailRunAsync(int runId, string failureStage, string errorMessage, string? errorDetail, long? totalDurationMs)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
await connection.ExecuteAsync(
|
||||
"dbo.StoryIntelligenceRun_Fail",
|
||||
new
|
||||
{
|
||||
StoryIntelligenceRunID = runId,
|
||||
FailureStage = failureStage,
|
||||
ErrorMessage = errorMessage,
|
||||
ErrorDetail = errorDetail,
|
||||
TotalDurationMs = totalDurationMs
|
||||
},
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task RequestCancelAsync(int runId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
await connection.ExecuteAsync(
|
||||
"dbo.StoryIntelligenceRun_RequestCancel",
|
||||
new { StoryIntelligenceRunID = runId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task CancelRunAsync(int runId, long? totalDurationMs)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
await connection.ExecuteAsync(
|
||||
"dbo.StoryIntelligenceRun_Cancel",
|
||||
new { StoryIntelligenceRunID = runId, TotalDurationMs = totalDurationMs },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<int> SaveChapterResultAsync(int runId, StoryIntelligenceChapterResultSaveRequest chapter)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleAsync<int>(
|
||||
"dbo.StoryIntelligenceChapterResult_Save",
|
||||
new
|
||||
{
|
||||
StoryIntelligenceRunID = runId,
|
||||
chapter.ProjectID,
|
||||
chapter.BookID,
|
||||
chapter.ChapterID,
|
||||
chapter.ChapterNumber,
|
||||
chapter.SourceLabel,
|
||||
chapter.PromptVersion,
|
||||
chapter.Model,
|
||||
chapter.RawResponseJson,
|
||||
chapter.OutputTextJson,
|
||||
chapter.ParsedJson,
|
||||
chapter.ValidationErrorsCount,
|
||||
chapter.ValidationWarningsCount,
|
||||
chapter.InputTokens,
|
||||
chapter.OutputTokens,
|
||||
chapter.TotalTokens,
|
||||
chapter.DurationMs
|
||||
},
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<int> SaveSceneResultAsync(int runId, int? chapterResultId, StoryIntelligenceSceneResultSaveRequest scene)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleAsync<int>(
|
||||
"dbo.StoryIntelligenceSceneResult_Save",
|
||||
new
|
||||
{
|
||||
StoryIntelligenceRunID = runId,
|
||||
ChapterResultID = chapterResultId,
|
||||
scene.ProjectID,
|
||||
scene.BookID,
|
||||
scene.ChapterID,
|
||||
scene.SceneID,
|
||||
scene.TemporarySceneNumber,
|
||||
scene.StartParagraph,
|
||||
scene.EndParagraph,
|
||||
scene.SourceLabel,
|
||||
scene.PromptVersion,
|
||||
scene.Model,
|
||||
scene.RawResponseJson,
|
||||
scene.OutputTextJson,
|
||||
scene.ParsedJson,
|
||||
scene.ValidationErrorsCount,
|
||||
scene.ValidationWarningsCount,
|
||||
scene.InputTokens,
|
||||
scene.OutputTokens,
|
||||
scene.TotalTokens,
|
||||
scene.DurationMs
|
||||
},
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<StoryIntelligenceSavedRunListItem>> ListRunsAsync()
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
|
||||
@ -73,6 +73,13 @@ public sealed class StoryIntelligenceOptions
|
||||
public int TimeoutSeconds { get; init; } = 120;
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligencePricingOptions
|
||||
{
|
||||
public decimal? InputPerMillionUsd { get; init; }
|
||||
public decimal? OutputPerMillionUsd { get; init; }
|
||||
public decimal? UsdToGbpRate { get; init; }
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceRun
|
||||
{
|
||||
public Guid RunId { get; init; } = Guid.NewGuid();
|
||||
|
||||
@ -55,6 +55,21 @@ public sealed class StoryIntelligenceRunSaveRequest
|
||||
public IReadOnlyList<StoryIntelligenceSceneResultSaveRequest> SceneResults { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceRunQueueRequest
|
||||
{
|
||||
public int UserID { get; init; }
|
||||
public int? ProjectID { get; init; }
|
||||
public int? BookID { get; init; }
|
||||
public string SourceType { get; init; } = "AdminText";
|
||||
public string SourceLabel { get; init; } = string.Empty;
|
||||
public string ChapterText { get; init; } = string.Empty;
|
||||
public int? SourceWordCount { get; init; }
|
||||
public int? SourceCharacterCount { get; init; }
|
||||
public int? SourceChapterCount { get; init; }
|
||||
public string Model { get; init; } = string.Empty;
|
||||
public string PromptVersionsSummary { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceChapterResultSaveRequest
|
||||
{
|
||||
public int? ProjectID { get; init; }
|
||||
@ -138,6 +153,13 @@ public sealed class StoryIntelligenceSavedRun
|
||||
public int? SourceDetectedTablesCount { get; init; }
|
||||
public int? SourceDetectedFootnotesCount { get; init; }
|
||||
public int? SourceDetectedCommentsCount { get; init; }
|
||||
public string? CurrentStage { get; init; }
|
||||
public string? CurrentMessage { get; init; }
|
||||
public int? TotalDetectedScenes { get; init; }
|
||||
public int? CompletedScenes { get; init; }
|
||||
public int? FailedScenes { get; init; }
|
||||
public DateTime? CancellationRequestedUtc { get; init; }
|
||||
public DateTime? CancelledUtc { get; init; }
|
||||
public string PromptVersion { get; init; } = string.Empty;
|
||||
public string? PromptVersionsSummary { get; init; }
|
||||
public string Model { get; init; } = string.Empty;
|
||||
@ -156,6 +178,45 @@ public sealed class StoryIntelligenceSavedRun
|
||||
public DateTime UpdatedUtc { get; init; }
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceQueuedRun
|
||||
{
|
||||
public int StoryIntelligenceRunID { get; init; }
|
||||
public int UserID { get; init; }
|
||||
public int? ProjectID { get; init; }
|
||||
public int? BookID { get; init; }
|
||||
public string Status { get; init; } = string.Empty;
|
||||
public string SourceType { get; init; } = string.Empty;
|
||||
public string? SourceLabel { get; init; }
|
||||
public string? SourceText { get; init; }
|
||||
public int? SourceWordCount { get; init; }
|
||||
public int? SourceCharacterCount { get; init; }
|
||||
public int? SourceChapterCount { get; init; }
|
||||
public string PromptVersion { get; init; } = string.Empty;
|
||||
public string? PromptVersionsSummary { get; init; }
|
||||
public string Model { get; init; } = string.Empty;
|
||||
public DateTime StartedUtc { get; init; }
|
||||
public DateTime? CompletedUtc { get; init; }
|
||||
public string? FailureStage { get; init; }
|
||||
public int? TotalInputTokens { get; init; }
|
||||
public int? TotalOutputTokens { get; init; }
|
||||
public int? TotalTokens { get; init; }
|
||||
public long? TotalDurationMs { get; init; }
|
||||
public decimal? EstimatedCostGBP { get; init; }
|
||||
public decimal? EstimatedCostUSD { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
public string? ErrorDetail { get; init; }
|
||||
public string? CurrentStage { get; init; }
|
||||
public string? CurrentMessage { get; init; }
|
||||
public int? TotalDetectedScenes { get; init; }
|
||||
public int? CompletedScenes { get; init; }
|
||||
public int? FailedScenes { get; init; }
|
||||
public DateTime? CancellationRequestedUtc { get; init; }
|
||||
public DateTime? CancelledUtc { get; init; }
|
||||
public DateTime CreatedUtc { get; init; }
|
||||
public DateTime UpdatedUtc { get; init; }
|
||||
public bool CancellationRequested => CancellationRequestedUtc.HasValue;
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceSavedChapterResult
|
||||
{
|
||||
public int ChapterResultID { get; init; }
|
||||
|
||||
@ -42,6 +42,7 @@ public class Program
|
||||
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.Configure<StoryIntelligencePricingOptions>(builder.Configuration.GetSection("StoryIntelligence:Pricing"));
|
||||
builder.Services.AddDataProtection()
|
||||
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(builder.Environment.ContentRootPath, "App_Data", "DataProtectionKeys")));
|
||||
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
@ -195,6 +196,7 @@ public class Program
|
||||
builder.Services.AddScoped<IChapterStructureDryRunService, ChapterStructureDryRunService>();
|
||||
builder.Services.AddScoped<IChapterStoryIntelligenceDryRunService, ChapterStoryIntelligenceDryRunService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceResultPersistenceService, StoryIntelligenceResultPersistenceService>();
|
||||
builder.Services.AddScoped<IPersistedStoryIntelligenceRunner, PersistedStoryIntelligenceRunner>();
|
||||
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
|
||||
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
||||
builder.Services.AddScoped<IStripeBillingService, StripeBillingService>();
|
||||
@ -210,6 +212,7 @@ public class Program
|
||||
builder.Services.AddScoped<IFeatureRequestService, FeatureRequestService>();
|
||||
builder.Services.AddHostedService<EmailQueueWorker>();
|
||||
builder.Services.AddHostedService<StoryIntelligenceWorker>();
|
||||
builder.Services.AddHostedService<PersistedStoryIntelligenceWorker>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
|
||||
439
PlotLine/Services/PersistedStoryIntelligenceRunner.cs
Normal file
439
PlotLine/Services/PersistedStoryIntelligenceRunner.cs
Normal file
@ -0,0 +1,439 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PlotLine.Data;
|
||||
using PlotLine.Models;
|
||||
|
||||
namespace PlotLine.Services;
|
||||
|
||||
public interface IPersistedStoryIntelligenceRunner
|
||||
{
|
||||
Task ProcessNextAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class PersistedStoryIntelligenceRunner(
|
||||
IStoryIntelligenceResultRepository repository,
|
||||
IStoryPromptRepository prompts,
|
||||
IStoryPromptVersionService versions,
|
||||
IStoryPromptBuilder scenePromptBuilder,
|
||||
IStoryIntelligenceClient client,
|
||||
IChapterStructureValidator chapterValidator,
|
||||
IStorySceneValidator sceneValidator,
|
||||
IOptions<StoryIntelligencePricingOptions> pricingOptions,
|
||||
ILogger<PersistedStoryIntelligenceRunner> logger) : IPersistedStoryIntelligenceRunner
|
||||
{
|
||||
private const string ChapterPromptFile = "Chapter-Structure-Prompt-V1.md";
|
||||
private const string ScenePromptFile = "Scene-Prompt-V1.md";
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private readonly StoryIntelligencePricingOptions pricing = pricingOptions.Value;
|
||||
|
||||
public async Task ProcessNextAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var run = await repository.ClaimNextPendingAsync();
|
||||
if (run is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await ProcessAsync(run, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task ProcessAsync(StoryIntelligenceQueuedRun run, CancellationToken cancellationToken)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var totals = new TokenTotals();
|
||||
var chapterPromptVersion = versions.GetPromptVersion(ChapterPromptFile);
|
||||
var scenePromptVersion = versions.GetPromptVersion(ScenePromptFile);
|
||||
int? chapterResultId = null;
|
||||
var failedScenes = 0;
|
||||
var completedScenes = 0;
|
||||
var hasWarnings = false;
|
||||
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(run.SourceText))
|
||||
{
|
||||
throw new StoryIntelligenceRunFailureException(
|
||||
StoryIntelligenceFailureStages.DocumentRead,
|
||||
"Story Intelligence run has no source text.");
|
||||
}
|
||||
|
||||
await ThrowIfCancellationRequestedAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds, cancellationToken);
|
||||
var paragraphs = SplitParagraphs(run.SourceText);
|
||||
if (paragraphs.Count == 0)
|
||||
{
|
||||
throw new StoryIntelligenceRunFailureException(
|
||||
StoryIntelligenceFailureStages.DocumentRead,
|
||||
"Story Intelligence run source text does not contain readable paragraphs.");
|
||||
}
|
||||
|
||||
var chapterContextJson = BuildChapterContextJson(run);
|
||||
var chapterTemplate = await prompts.LoadPromptAsync(ChapterPromptFile, cancellationToken);
|
||||
var chapterPrompt = BuildChapterPrompt(chapterTemplate, chapterContextJson, run.SourceText);
|
||||
|
||||
await repository.UpdateProgressAsync(
|
||||
run.StoryIntelligenceRunID,
|
||||
currentStage: StoryIntelligenceFailureStages.ChapterStructure,
|
||||
currentMessage: "Running Chapter Structure analysis.",
|
||||
totalDurationMs: stopwatch.ElapsedMilliseconds);
|
||||
|
||||
var chapterClientResult = await client.ExecutePromptAsync(chapterPrompt, chapterPromptVersion, cancellationToken);
|
||||
totals.Add(chapterClientResult);
|
||||
var chapterJson = ExtractOutputText(chapterClientResult.RawResponseText);
|
||||
var parsedChapter = JsonSerializer.Deserialize<ChapterStructureModel>(chapterJson, JsonOptions);
|
||||
var chapterValidation = chapterValidator.Validate(parsedChapter, paragraphs.Count);
|
||||
hasWarnings = chapterValidation.Warnings.Count > 0;
|
||||
chapterResultId = await repository.SaveChapterResultAsync(
|
||||
run.StoryIntelligenceRunID,
|
||||
new StoryIntelligenceChapterResultSaveRequest
|
||||
{
|
||||
ProjectID = run.ProjectID,
|
||||
BookID = run.BookID,
|
||||
ChapterID = null,
|
||||
ChapterNumber = 1,
|
||||
SourceLabel = SourceLabel(run),
|
||||
PromptVersion = chapterPromptVersion,
|
||||
Model = chapterClientResult.Model,
|
||||
RawResponseJson = chapterClientResult.RawResponseText,
|
||||
OutputTextJson = chapterJson,
|
||||
ParsedJson = SerializeParsed(parsedChapter),
|
||||
ValidationErrorsCount = chapterValidation.Errors.Count,
|
||||
ValidationWarningsCount = chapterValidation.Warnings.Count,
|
||||
InputTokens = chapterClientResult.InputTokens,
|
||||
OutputTokens = chapterClientResult.OutputTokens,
|
||||
TotalTokens = SumTokens(chapterClientResult.InputTokens, chapterClientResult.OutputTokens),
|
||||
DurationMs = Convert.ToInt64(chapterClientResult.Duration.TotalMilliseconds)
|
||||
});
|
||||
|
||||
if (!chapterValidation.IsValid || parsedChapter?.SceneBoundaries is null)
|
||||
{
|
||||
throw new StoryIntelligenceRunFailureException(
|
||||
StoryIntelligenceFailureStages.ChapterStructure,
|
||||
"Chapter Structure validation failed.",
|
||||
BuildValidationDetail(chapterValidation));
|
||||
}
|
||||
|
||||
var sceneBlocks = parsedChapter.SceneBoundaries
|
||||
.Select(boundary => BuildSceneBlock(run, paragraphs, boundary))
|
||||
.ToList();
|
||||
failedScenes += sceneBlocks.Count(block => !block.SplitValid);
|
||||
|
||||
await repository.UpdateProgressAsync(
|
||||
run.StoryIntelligenceRunID,
|
||||
currentStage: StoryIntelligenceFailureStages.SceneSplit,
|
||||
currentMessage: $"Detected {sceneBlocks.Count:N0} suggested scene(s).",
|
||||
totalDetectedScenes: sceneBlocks.Count,
|
||||
failedScenes: failedScenes,
|
||||
totalInputTokens: totals.InputTokens,
|
||||
totalOutputTokens: totals.OutputTokens,
|
||||
totalTokens: totals.TotalTokens,
|
||||
totalDurationMs: stopwatch.ElapsedMilliseconds,
|
||||
estimatedCostUSD: EstimateUsd(totals),
|
||||
estimatedCostGBP: EstimateGbp(totals));
|
||||
|
||||
var sceneTemplate = await prompts.LoadPromptAsync(ScenePromptFile, cancellationToken);
|
||||
foreach (var block in sceneBlocks)
|
||||
{
|
||||
await ThrowIfCancellationRequestedAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds, cancellationToken);
|
||||
if (!block.SplitValid)
|
||||
{
|
||||
await PersistFailedSceneAsync(run, chapterResultId, block, scenePromptVersion, "Scene boundary range is invalid for the submitted chapter paragraphs.");
|
||||
continue;
|
||||
}
|
||||
|
||||
await repository.UpdateProgressAsync(
|
||||
run.StoryIntelligenceRunID,
|
||||
currentStage: StoryIntelligenceFailureStages.SceneIntelligence,
|
||||
currentMessage: $"Running Scene Intelligence for suggested scene {block.TemporarySceneNumber:N0}.",
|
||||
completedScenes: completedScenes,
|
||||
failedScenes: failedScenes,
|
||||
totalDurationMs: stopwatch.ElapsedMilliseconds);
|
||||
|
||||
try
|
||||
{
|
||||
var completedScenePrompt = scenePromptBuilder.BuildPrompt(sceneTemplate, block.SceneContextJson, block.SceneText);
|
||||
var sceneClientResult = await client.ExecutePromptAsync(completedScenePrompt, scenePromptVersion, cancellationToken);
|
||||
totals.Add(sceneClientResult);
|
||||
var sceneJson = ExtractOutputText(sceneClientResult.RawResponseText);
|
||||
var parsedScene = JsonSerializer.Deserialize<SceneIntelligenceScene>(sceneJson, JsonOptions);
|
||||
var validation = sceneValidator.Validate(parsedScene);
|
||||
hasWarnings = hasWarnings || validation.Warnings.Count > 0;
|
||||
|
||||
await repository.SaveSceneResultAsync(
|
||||
run.StoryIntelligenceRunID,
|
||||
chapterResultId,
|
||||
new StoryIntelligenceSceneResultSaveRequest
|
||||
{
|
||||
ProjectID = run.ProjectID,
|
||||
BookID = run.BookID,
|
||||
ChapterID = null,
|
||||
SceneID = null,
|
||||
TemporarySceneNumber = block.TemporarySceneNumber,
|
||||
StartParagraph = block.StartParagraph,
|
||||
EndParagraph = block.EndParagraph,
|
||||
SourceLabel = block.SourceLabel,
|
||||
PromptVersion = scenePromptVersion,
|
||||
Model = sceneClientResult.Model,
|
||||
RawResponseJson = sceneClientResult.RawResponseText,
|
||||
OutputTextJson = sceneJson,
|
||||
ParsedJson = SerializeParsed(parsedScene),
|
||||
ValidationErrorsCount = validation.Errors.Count,
|
||||
ValidationWarningsCount = validation.Warnings.Count,
|
||||
InputTokens = sceneClientResult.InputTokens,
|
||||
OutputTokens = sceneClientResult.OutputTokens,
|
||||
TotalTokens = SumTokens(sceneClientResult.InputTokens, sceneClientResult.OutputTokens),
|
||||
DurationMs = Convert.ToInt64(sceneClientResult.Duration.TotalMilliseconds)
|
||||
});
|
||||
|
||||
completedScenes++;
|
||||
if (!validation.IsValid || validation.Warnings.Count > 0)
|
||||
{
|
||||
failedScenes += validation.Errors.Count > 0 ? 1 : 0;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException && !IsFatal(ex))
|
||||
{
|
||||
failedScenes++;
|
||||
await PersistFailedSceneAsync(run, chapterResultId, block, scenePromptVersion, ex.Message);
|
||||
logger.LogWarning(ex, "Story Intelligence scene failed for run {StoryIntelligenceRunID}, scene {TemporarySceneNumber}.", run.StoryIntelligenceRunID, block.TemporarySceneNumber);
|
||||
}
|
||||
|
||||
await repository.UpdateProgressAsync(
|
||||
run.StoryIntelligenceRunID,
|
||||
completedScenes: completedScenes,
|
||||
failedScenes: failedScenes,
|
||||
totalInputTokens: totals.InputTokens,
|
||||
totalOutputTokens: totals.OutputTokens,
|
||||
totalTokens: totals.TotalTokens,
|
||||
totalDurationMs: stopwatch.ElapsedMilliseconds,
|
||||
estimatedCostUSD: EstimateUsd(totals),
|
||||
estimatedCostGBP: EstimateGbp(totals));
|
||||
}
|
||||
|
||||
var finalStatus = failedScenes > 0 || hasWarnings
|
||||
? StoryIntelligenceRunStatuses.CompletedWithWarnings
|
||||
: StoryIntelligenceRunStatuses.Completed;
|
||||
|
||||
await repository.CompleteRunAsync(
|
||||
run.StoryIntelligenceRunID,
|
||||
finalStatus,
|
||||
totals.InputTokens,
|
||||
totals.OutputTokens,
|
||||
totals.TotalTokens,
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
EstimateGbp(totals),
|
||||
EstimateUsd(totals));
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await repository.CancelRunAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
catch (StoryIntelligenceRunFailureException ex)
|
||||
{
|
||||
await repository.FailRunAsync(run.StoryIntelligenceRunID, ex.FailureStage, ex.Message, ex.Detail, stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
await repository.FailRunAsync(
|
||||
run.StoryIntelligenceRunID,
|
||||
ClassifyFailureStage(ex),
|
||||
ex.Message,
|
||||
ex.ToString(),
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ThrowIfCancellationRequestedAsync(int runId, long elapsedMs, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var latest = await repository.GetRunAsync(runId);
|
||||
if (latest?.CancellationRequestedUtc.HasValue == true || string.Equals(latest?.Status, StoryIntelligenceRunStatuses.Cancelled, StringComparison.Ordinal))
|
||||
{
|
||||
await repository.CancelRunAsync(runId, elapsedMs);
|
||||
throw new OperationCanceledException();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PersistFailedSceneAsync(
|
||||
StoryIntelligenceQueuedRun run,
|
||||
int? chapterResultId,
|
||||
StorySceneTextBlock block,
|
||||
string scenePromptVersion,
|
||||
string message)
|
||||
{
|
||||
await repository.SaveSceneResultAsync(
|
||||
run.StoryIntelligenceRunID,
|
||||
chapterResultId,
|
||||
new StoryIntelligenceSceneResultSaveRequest
|
||||
{
|
||||
ProjectID = run.ProjectID,
|
||||
BookID = run.BookID,
|
||||
ChapterID = null,
|
||||
SceneID = null,
|
||||
TemporarySceneNumber = block.TemporarySceneNumber,
|
||||
StartParagraph = block.StartParagraph,
|
||||
EndParagraph = block.EndParagraph,
|
||||
SourceLabel = block.SourceLabel,
|
||||
PromptVersion = scenePromptVersion,
|
||||
Model = run.Model,
|
||||
RawResponseJson = string.Empty,
|
||||
OutputTextJson = string.Empty,
|
||||
ParsedJson = JsonSerializer.Serialize(new { error = message }, JsonOptions),
|
||||
ValidationErrorsCount = 1,
|
||||
ValidationWarningsCount = 0
|
||||
});
|
||||
}
|
||||
|
||||
private decimal? EstimateUsd(TokenTotals totals)
|
||||
{
|
||||
if (!pricing.InputPerMillionUsd.HasValue || !pricing.OutputPerMillionUsd.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var inputCost = (totals.InputTokens ?? 0) / 1_000_000m * pricing.InputPerMillionUsd.Value;
|
||||
var outputCost = (totals.OutputTokens ?? 0) / 1_000_000m * pricing.OutputPerMillionUsd.Value;
|
||||
return inputCost + outputCost;
|
||||
}
|
||||
|
||||
private decimal? EstimateGbp(TokenTotals totals)
|
||||
{
|
||||
var usd = EstimateUsd(totals);
|
||||
return usd.HasValue && pricing.UsdToGbpRate.HasValue ? usd.Value * pricing.UsdToGbpRate.Value : null;
|
||||
}
|
||||
|
||||
private static StorySceneTextBlock BuildSceneBlock(StoryIntelligenceQueuedRun run, IReadOnlyList<string> paragraphs, ChapterSceneBoundary boundary)
|
||||
{
|
||||
var sceneNumber = boundary.SceneNumber ?? 0;
|
||||
var start = boundary.StartParagraph ?? 0;
|
||||
var end = boundary.EndParagraph ?? 0;
|
||||
var sourceLabel = $"{SourceLabel(run)}, suggested scene {sceneNumber}";
|
||||
|
||||
if (sceneNumber <= 0 || start <= 0 || end <= 0 || start > end || end > paragraphs.Count)
|
||||
{
|
||||
return new StorySceneTextBlock(sceneNumber, start, end, sourceLabel, string.Empty, string.Empty, false);
|
||||
}
|
||||
|
||||
var sceneText = string.Join(Environment.NewLine + Environment.NewLine, paragraphs.Skip(start - 1).Take(end - start + 1));
|
||||
return new StorySceneTextBlock(
|
||||
sceneNumber,
|
||||
start,
|
||||
end,
|
||||
sourceLabel,
|
||||
sceneText,
|
||||
BuildSceneContextJson(run, boundary),
|
||||
true);
|
||||
}
|
||||
|
||||
private static string BuildChapterContextJson(StoryIntelligenceQueuedRun run)
|
||||
=> JsonSerializer.Serialize(new
|
||||
{
|
||||
projectId = run.ProjectID,
|
||||
bookId = run.BookID,
|
||||
chapterId = (int?)null,
|
||||
chapterNumber = 1,
|
||||
sourceLabel = SourceLabel(run)
|
||||
}, JsonOptions);
|
||||
|
||||
private static string BuildSceneContextJson(StoryIntelligenceQueuedRun run, ChapterSceneBoundary boundary)
|
||||
=> JsonSerializer.Serialize(new
|
||||
{
|
||||
projectId = run.ProjectID,
|
||||
bookId = run.BookID,
|
||||
chapterId = (int?)null,
|
||||
sceneId = (int?)null,
|
||||
chapterNumber = 1,
|
||||
sceneNumber = boundary.SceneNumber,
|
||||
sourceLabel = $"{SourceLabel(run)}, suggested scene {boundary.SceneNumber}"
|
||||
}, JsonOptions);
|
||||
|
||||
private static string BuildChapterPrompt(string promptTemplate, string chapterContextJson, string chapterText)
|
||||
=> 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<OpenAIResponseEnvelope>(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;
|
||||
|
||||
return string.IsNullOrWhiteSpace(outputText)
|
||||
? throw new JsonException("OpenAI response did not contain output_text content.")
|
||||
: outputText.Trim();
|
||||
}
|
||||
|
||||
private static List<string> SplitParagraphs(string chapterText)
|
||||
=> chapterText.Split(["\r\n\r\n", "\n\n", "\r\r"], StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(paragraph => paragraph.Trim())
|
||||
.Where(paragraph => !string.IsNullOrWhiteSpace(paragraph))
|
||||
.ToList();
|
||||
|
||||
private static string SerializeParsed<T>(T? value)
|
||||
=> value is null ? string.Empty : JsonSerializer.Serialize(value, JsonOptions);
|
||||
|
||||
private static int? SumTokens(int? inputTokens, int? outputTokens)
|
||||
=> inputTokens.HasValue || outputTokens.HasValue ? (inputTokens ?? 0) + (outputTokens ?? 0) : null;
|
||||
|
||||
private static string SourceLabel(StoryIntelligenceQueuedRun run)
|
||||
=> string.IsNullOrWhiteSpace(run.SourceLabel) ? "Admin persisted Story Intelligence chapter" : run.SourceLabel.Trim();
|
||||
|
||||
private static string BuildValidationDetail(PlotLine.Models.StoryIntelligence.ValidationResult validation)
|
||||
=> string.Join(
|
||||
Environment.NewLine,
|
||||
validation.Errors.Concat(validation.Warnings).Select(issue => $"{issue.Severity} {issue.Path}: {issue.Message}"));
|
||||
|
||||
private static bool IsFatal(Exception ex)
|
||||
=> ex is InvalidOperationException invalid
|
||||
&& (invalid.Message.Contains("API key", StringComparison.OrdinalIgnoreCase)
|
||||
|| invalid.Message.Contains("model is not configured", StringComparison.OrdinalIgnoreCase)
|
||||
|| invalid.Message.Contains("prompt", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static string ClassifyFailureStage(Exception ex)
|
||||
=> ex is FileNotFoundException
|
||||
? StoryIntelligenceFailureStages.ChapterStructure
|
||||
: ex is JsonException
|
||||
? StoryIntelligenceFailureStages.Validation
|
||||
: StoryIntelligenceFailureStages.Persistence;
|
||||
|
||||
private sealed class StoryIntelligenceRunFailureException(string failureStage, string message, string? detail = null) : Exception(message)
|
||||
{
|
||||
public string FailureStage { get; } = failureStage;
|
||||
public string? Detail { get; } = detail;
|
||||
}
|
||||
|
||||
private sealed record StorySceneTextBlock(
|
||||
int TemporarySceneNumber,
|
||||
int StartParagraph,
|
||||
int EndParagraph,
|
||||
string SourceLabel,
|
||||
string SceneText,
|
||||
string SceneContextJson,
|
||||
bool SplitValid);
|
||||
|
||||
private sealed class TokenTotals
|
||||
{
|
||||
public int? InputTokens { get; private set; }
|
||||
public int? OutputTokens { get; private set; }
|
||||
public int? TotalTokens => InputTokens.HasValue || OutputTokens.HasValue ? (InputTokens ?? 0) + (OutputTokens ?? 0) : null;
|
||||
|
||||
public void Add(StoryIntelligenceClientResult result)
|
||||
{
|
||||
InputTokens = SumNullable(InputTokens, result.InputTokens);
|
||||
OutputTokens = SumNullable(OutputTokens, result.OutputTokens);
|
||||
}
|
||||
|
||||
private static int? SumNullable(int? current, int? next)
|
||||
=> current.HasValue || next.HasValue ? (current ?? 0) + (next ?? 0) : null;
|
||||
}
|
||||
}
|
||||
30
PlotLine/Services/PersistedStoryIntelligenceWorker.cs
Normal file
30
PlotLine/Services/PersistedStoryIntelligenceWorker.cs
Normal file
@ -0,0 +1,30 @@
|
||||
namespace PlotLine.Services;
|
||||
|
||||
public sealed class PersistedStoryIntelligenceWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<PersistedStoryIntelligenceWorker> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(3));
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var runner = scope.ServiceProvider.GetRequiredService<IPersistedStoryIntelligenceRunner>();
|
||||
await runner.ProcessNextAsync(stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Persisted Story Intelligence worker failed while processing the next queued run.");
|
||||
}
|
||||
|
||||
await timer.WaitForNextTickAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -8,12 +8,15 @@ namespace PlotLine.Services;
|
||||
public interface IStoryIntelligenceResultPersistenceService
|
||||
{
|
||||
Task<int> SaveDisplayedRunAsync(int userId, string payload);
|
||||
Task<int> QueueAdminTextRunAsync(int userId, ChapterStoryIntelligenceDryRunForm form);
|
||||
Task CancelRunAsync(int runId);
|
||||
Task<StoryIntelligenceSavedRunsViewModel> ListRunsAsync();
|
||||
Task<StoryIntelligenceSavedRunDetailViewModel?> GetRunDetailAsync(int runId);
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceResultPersistenceService(
|
||||
IStoryIntelligenceResultRepository repository,
|
||||
IStoryIntelligenceClient client,
|
||||
ILogger<StoryIntelligenceResultPersistenceService> logger) : IStoryIntelligenceResultPersistenceService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
@ -57,6 +60,34 @@ public sealed class StoryIntelligenceResultPersistenceService(
|
||||
return runId;
|
||||
}
|
||||
|
||||
public async Task<int> QueueAdminTextRunAsync(int userId, ChapterStoryIntelligenceDryRunForm form)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(form.ChapterText))
|
||||
{
|
||||
throw new InvalidOperationException("Enter chapter text before queueing a persisted Story Intelligence run.");
|
||||
}
|
||||
|
||||
var promptVersionsSummary = "Chapter=Chapter-Structure-Prompt-V1; Scene=Scene-Prompt-V1";
|
||||
var clientStatus = client.GetConfigurationStatus();
|
||||
return await repository.QueueAdminTextAsync(new StoryIntelligenceRunQueueRequest
|
||||
{
|
||||
UserID = userId,
|
||||
ProjectID = form.ProjectID,
|
||||
BookID = form.BookID,
|
||||
SourceType = "AdminText",
|
||||
SourceLabel = CleanSourceLabel(form.SourceLabel),
|
||||
ChapterText = form.ChapterText,
|
||||
SourceWordCount = CountWords(form.ChapterText),
|
||||
SourceCharacterCount = form.ChapterText.Length,
|
||||
SourceChapterCount = 1,
|
||||
Model = clientStatus.Model,
|
||||
PromptVersionsSummary = promptVersionsSummary
|
||||
});
|
||||
}
|
||||
|
||||
public Task CancelRunAsync(int runId)
|
||||
=> repository.RequestCancelAsync(runId);
|
||||
|
||||
public async Task<StoryIntelligenceSavedRunsViewModel> ListRunsAsync()
|
||||
=> new() { Runs = await repository.ListRunsAsync() };
|
||||
|
||||
|
||||
311
PlotLine/Sql/117_Phase20K_PersistedStoryIntelligenceRunner.sql
Normal file
311
PlotLine/Sql/117_Phase20K_PersistedStoryIntelligenceRunner.sql
Normal file
@ -0,0 +1,311 @@
|
||||
SET ANSI_NULLS ON;
|
||||
GO
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
GO
|
||||
|
||||
IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'SourceLabel') IS NULL
|
||||
ALTER TABLE dbo.StoryIntelligenceRuns ADD SourceLabel nvarchar(300) NULL;
|
||||
GO
|
||||
IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'SourceText') IS NULL
|
||||
ALTER TABLE dbo.StoryIntelligenceRuns ADD SourceText nvarchar(max) NULL;
|
||||
GO
|
||||
IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'CurrentStage') IS NULL
|
||||
ALTER TABLE dbo.StoryIntelligenceRuns ADD CurrentStage nvarchar(80) NULL;
|
||||
GO
|
||||
IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'CurrentMessage') IS NULL
|
||||
ALTER TABLE dbo.StoryIntelligenceRuns ADD CurrentMessage nvarchar(400) NULL;
|
||||
GO
|
||||
IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'TotalDetectedScenes') IS NULL
|
||||
ALTER TABLE dbo.StoryIntelligenceRuns ADD TotalDetectedScenes int NULL;
|
||||
GO
|
||||
IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'CompletedScenes') IS NULL
|
||||
ALTER TABLE dbo.StoryIntelligenceRuns ADD CompletedScenes int NULL;
|
||||
GO
|
||||
IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'FailedScenes') IS NULL
|
||||
ALTER TABLE dbo.StoryIntelligenceRuns ADD FailedScenes int NULL;
|
||||
GO
|
||||
IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'CancellationRequestedUtc') IS NULL
|
||||
ALTER TABLE dbo.StoryIntelligenceRuns ADD CancellationRequestedUtc datetime2 NULL;
|
||||
GO
|
||||
IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'CancelledUtc') IS NULL
|
||||
ALTER TABLE dbo.StoryIntelligenceRuns ADD CancelledUtc datetime2 NULL;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_QueueAdminText
|
||||
@UserID int,
|
||||
@ProjectID int = NULL,
|
||||
@BookID int = NULL,
|
||||
@SourceType nvarchar(50),
|
||||
@SourceLabel nvarchar(300),
|
||||
@SourceText nvarchar(max),
|
||||
@SourceWordCount int = NULL,
|
||||
@SourceCharacterCount int = NULL,
|
||||
@SourceChapterCount int = NULL,
|
||||
@Model nvarchar(100),
|
||||
@PromptVersionsSummary nvarchar(500)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
INSERT dbo.StoryIntelligenceRuns
|
||||
(
|
||||
UserID, ProjectID, BookID, Status, SourceType, SourceLabel, SourceText,
|
||||
SourceWordCount, SourceCharacterCount, SourceChapterCount, Model,
|
||||
PromptVersion, PromptVersionsSummary, StartedUtc, CurrentStage, CurrentMessage,
|
||||
CompletedScenes, FailedScenes
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@UserID, @ProjectID, @BookID, N'Pending', @SourceType, @SourceLabel, @SourceText,
|
||||
@SourceWordCount, @SourceCharacterCount, @SourceChapterCount, @Model,
|
||||
@PromptVersionsSummary, @PromptVersionsSummary, SYSUTCDATETIME(), N'Pending',
|
||||
N'Queued for Story Intelligence processing.', 0, 0
|
||||
);
|
||||
|
||||
SELECT CAST(SCOPE_IDENTITY() AS int) AS StoryIntelligenceRunID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_ClaimNextPending
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
|
||||
DECLARE @StoryIntelligenceRunID int;
|
||||
|
||||
SELECT TOP (1) @StoryIntelligenceRunID = StoryIntelligenceRunID
|
||||
FROM dbo.StoryIntelligenceRuns WITH (UPDLOCK, READPAST)
|
||||
WHERE Status = N'Pending'
|
||||
ORDER BY CreatedUtc;
|
||||
|
||||
IF @StoryIntelligenceRunID IS NULL
|
||||
BEGIN
|
||||
SELECT TOP (0)
|
||||
StoryIntelligenceRunID, UserID, ProjectID, BookID, Status, SourceType, SourceLabel,
|
||||
SourceText, SourceWordCount, SourceCharacterCount, SourceChapterCount, PromptVersion,
|
||||
PromptVersionsSummary, Model, StartedUtc, CompletedUtc, FailureStage, TotalInputTokens,
|
||||
TotalOutputTokens, TotalTokens, TotalDurationMs, EstimatedCostGBP, EstimatedCostUSD,
|
||||
ErrorMessage, ErrorDetail, CurrentStage, CurrentMessage, TotalDetectedScenes,
|
||||
CompletedScenes, FailedScenes, CancellationRequestedUtc, CancelledUtc, CreatedUtc, UpdatedUtc
|
||||
FROM dbo.StoryIntelligenceRuns;
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
UPDATE dbo.StoryIntelligenceRuns
|
||||
SET Status = N'Running',
|
||||
StartedUtc = SYSUTCDATETIME(),
|
||||
CurrentStage = N'ChapterStructure',
|
||||
CurrentMessage = N'Running Chapter Structure analysis.',
|
||||
UpdatedUtc = SYSUTCDATETIME()
|
||||
WHERE StoryIntelligenceRunID = @StoryIntelligenceRunID
|
||||
AND Status = N'Pending';
|
||||
|
||||
SELECT
|
||||
StoryIntelligenceRunID, UserID, ProjectID, BookID, Status, SourceType, SourceLabel,
|
||||
SourceText, SourceWordCount, SourceCharacterCount, SourceChapterCount, PromptVersion,
|
||||
PromptVersionsSummary, Model, StartedUtc, CompletedUtc, FailureStage, TotalInputTokens,
|
||||
TotalOutputTokens, TotalTokens, TotalDurationMs, EstimatedCostGBP, EstimatedCostUSD,
|
||||
ErrorMessage, ErrorDetail, CurrentStage, CurrentMessage, TotalDetectedScenes,
|
||||
CompletedScenes, FailedScenes, CancellationRequestedUtc, CancelledUtc, CreatedUtc, UpdatedUtc
|
||||
FROM dbo.StoryIntelligenceRuns
|
||||
WHERE StoryIntelligenceRunID = @StoryIntelligenceRunID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_UpdateProgress
|
||||
@StoryIntelligenceRunID int,
|
||||
@Status nvarchar(50) = NULL,
|
||||
@CurrentStage nvarchar(80) = NULL,
|
||||
@CurrentMessage nvarchar(400) = NULL,
|
||||
@TotalDetectedScenes int = NULL,
|
||||
@CompletedScenes int = NULL,
|
||||
@FailedScenes int = NULL,
|
||||
@TotalInputTokens int = NULL,
|
||||
@TotalOutputTokens int = NULL,
|
||||
@TotalTokens int = NULL,
|
||||
@TotalDurationMs bigint = NULL,
|
||||
@EstimatedCostGBP decimal(19,6) = NULL,
|
||||
@EstimatedCostUSD decimal(19,6) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
UPDATE dbo.StoryIntelligenceRuns
|
||||
SET Status = COALESCE(@Status, Status),
|
||||
CurrentStage = COALESCE(@CurrentStage, CurrentStage),
|
||||
CurrentMessage = COALESCE(@CurrentMessage, CurrentMessage),
|
||||
TotalDetectedScenes = COALESCE(@TotalDetectedScenes, TotalDetectedScenes),
|
||||
CompletedScenes = COALESCE(@CompletedScenes, CompletedScenes),
|
||||
FailedScenes = COALESCE(@FailedScenes, FailedScenes),
|
||||
TotalInputTokens = COALESCE(@TotalInputTokens, TotalInputTokens),
|
||||
TotalOutputTokens = COALESCE(@TotalOutputTokens, TotalOutputTokens),
|
||||
TotalTokens = COALESCE(@TotalTokens, TotalTokens),
|
||||
TotalDurationMs = COALESCE(@TotalDurationMs, TotalDurationMs),
|
||||
EstimatedCostGBP = COALESCE(@EstimatedCostGBP, EstimatedCostGBP),
|
||||
EstimatedCostUSD = COALESCE(@EstimatedCostUSD, EstimatedCostUSD),
|
||||
UpdatedUtc = SYSUTCDATETIME()
|
||||
WHERE StoryIntelligenceRunID = @StoryIntelligenceRunID
|
||||
AND Status NOT IN (N'Completed', N'CompletedWithWarnings', N'Failed', N'Cancelled');
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_Complete
|
||||
@StoryIntelligenceRunID int,
|
||||
@Status nvarchar(50),
|
||||
@TotalInputTokens int = NULL,
|
||||
@TotalOutputTokens int = NULL,
|
||||
@TotalTokens int = NULL,
|
||||
@TotalDurationMs bigint = NULL,
|
||||
@EstimatedCostGBP decimal(19,6) = NULL,
|
||||
@EstimatedCostUSD decimal(19,6) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
UPDATE dbo.StoryIntelligenceRuns
|
||||
SET Status = @Status,
|
||||
CompletedUtc = SYSUTCDATETIME(),
|
||||
CurrentStage = N'Completed',
|
||||
CurrentMessage = CASE WHEN @Status = N'CompletedWithWarnings'
|
||||
THEN N'Story Intelligence completed with warnings.'
|
||||
ELSE N'Story Intelligence completed.'
|
||||
END,
|
||||
TotalInputTokens = COALESCE(@TotalInputTokens, TotalInputTokens),
|
||||
TotalOutputTokens = COALESCE(@TotalOutputTokens, TotalOutputTokens),
|
||||
TotalTokens = COALESCE(@TotalTokens, TotalTokens),
|
||||
TotalDurationMs = COALESCE(@TotalDurationMs, TotalDurationMs),
|
||||
EstimatedCostGBP = COALESCE(@EstimatedCostGBP, EstimatedCostGBP),
|
||||
EstimatedCostUSD = COALESCE(@EstimatedCostUSD, EstimatedCostUSD),
|
||||
UpdatedUtc = SYSUTCDATETIME()
|
||||
WHERE StoryIntelligenceRunID = @StoryIntelligenceRunID
|
||||
AND Status NOT IN (N'Completed', N'CompletedWithWarnings', N'Failed', N'Cancelled');
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_Fail
|
||||
@StoryIntelligenceRunID int,
|
||||
@FailureStage nvarchar(80),
|
||||
@ErrorMessage nvarchar(max),
|
||||
@ErrorDetail nvarchar(max) = NULL,
|
||||
@TotalDurationMs bigint = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
UPDATE dbo.StoryIntelligenceRuns
|
||||
SET Status = N'Failed',
|
||||
CompletedUtc = SYSUTCDATETIME(),
|
||||
FailureStage = @FailureStage,
|
||||
ErrorMessage = @ErrorMessage,
|
||||
ErrorDetail = @ErrorDetail,
|
||||
CurrentStage = @FailureStage,
|
||||
CurrentMessage = N'Story Intelligence failed.',
|
||||
TotalDurationMs = COALESCE(@TotalDurationMs, TotalDurationMs),
|
||||
UpdatedUtc = SYSUTCDATETIME()
|
||||
WHERE StoryIntelligenceRunID = @StoryIntelligenceRunID
|
||||
AND Status NOT IN (N'Completed', N'CompletedWithWarnings', N'Failed', N'Cancelled');
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_RequestCancel
|
||||
@StoryIntelligenceRunID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
UPDATE dbo.StoryIntelligenceRuns
|
||||
SET CancellationRequestedUtc = COALESCE(CancellationRequestedUtc, SYSUTCDATETIME()),
|
||||
CurrentMessage = CASE WHEN Status = N'Pending'
|
||||
THEN N'Cancellation requested before processing.'
|
||||
ELSE N'Cancellation requested. Processing will stop at the next safe checkpoint.'
|
||||
END,
|
||||
UpdatedUtc = SYSUTCDATETIME()
|
||||
WHERE StoryIntelligenceRunID = @StoryIntelligenceRunID
|
||||
AND Status IN (N'Pending', N'Running');
|
||||
|
||||
UPDATE dbo.StoryIntelligenceRuns
|
||||
SET Status = N'Cancelled',
|
||||
CompletedUtc = SYSUTCDATETIME(),
|
||||
CancelledUtc = SYSUTCDATETIME(),
|
||||
CurrentStage = N'Cancelled',
|
||||
CurrentMessage = N'Story Intelligence run was cancelled before processing.',
|
||||
UpdatedUtc = SYSUTCDATETIME()
|
||||
WHERE StoryIntelligenceRunID = @StoryIntelligenceRunID
|
||||
AND Status = N'Pending';
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_Cancel
|
||||
@StoryIntelligenceRunID int,
|
||||
@TotalDurationMs bigint = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
UPDATE dbo.StoryIntelligenceRuns
|
||||
SET Status = N'Cancelled',
|
||||
CompletedUtc = SYSUTCDATETIME(),
|
||||
CancelledUtc = SYSUTCDATETIME(),
|
||||
CurrentStage = N'Cancelled',
|
||||
CurrentMessage = N'Story Intelligence run was cancelled.',
|
||||
TotalDurationMs = COALESCE(@TotalDurationMs, TotalDurationMs),
|
||||
UpdatedUtc = SYSUTCDATETIME()
|
||||
WHERE StoryIntelligenceRunID = @StoryIntelligenceRunID
|
||||
AND Status NOT IN (N'Completed', N'CompletedWithWarnings', N'Failed', N'Cancelled');
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_GetAdmin
|
||||
@StoryIntelligenceRunID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT
|
||||
r.StoryIntelligenceRunID,
|
||||
r.UserID,
|
||||
r.ProjectID,
|
||||
p.ProjectName AS ProjectTitle,
|
||||
r.BookID,
|
||||
b.BookTitle,
|
||||
r.Status,
|
||||
r.SourceType,
|
||||
r.SourceFileName,
|
||||
r.SourceFileSizeBytes,
|
||||
r.SourceWordCount,
|
||||
r.SourceCharacterCount,
|
||||
r.SourceChapterCount,
|
||||
r.SourceDetectedImagesCount,
|
||||
r.SourceDetectedTablesCount,
|
||||
r.SourceDetectedFootnotesCount,
|
||||
r.SourceDetectedCommentsCount,
|
||||
r.CurrentStage,
|
||||
r.CurrentMessage,
|
||||
r.TotalDetectedScenes,
|
||||
r.CompletedScenes,
|
||||
r.FailedScenes,
|
||||
r.CancellationRequestedUtc,
|
||||
r.CancelledUtc,
|
||||
r.PromptVersion,
|
||||
r.PromptVersionsSummary,
|
||||
r.Model,
|
||||
r.StartedUtc,
|
||||
r.CompletedUtc,
|
||||
r.FailureStage,
|
||||
r.TotalInputTokens,
|
||||
r.TotalOutputTokens,
|
||||
r.TotalTokens,
|
||||
r.TotalDurationMs,
|
||||
r.EstimatedCostGBP,
|
||||
r.EstimatedCostUSD,
|
||||
r.ErrorMessage,
|
||||
r.ErrorDetail,
|
||||
r.CreatedUtc,
|
||||
r.UpdatedUtc
|
||||
FROM dbo.StoryIntelligenceRuns r
|
||||
LEFT JOIN dbo.Projects p ON p.ProjectID = r.ProjectID
|
||||
LEFT JOIN dbo.Books b ON b.BookID = r.BookID
|
||||
WHERE r.StoryIntelligenceRunID = @StoryIntelligenceRunID;
|
||||
END;
|
||||
GO
|
||||
@ -63,6 +63,7 @@
|
||||
</div>
|
||||
<div class="button-row mt-3">
|
||||
<button class="btn btn-primary" type="submit">Run combined test</button>
|
||||
<button class="btn btn-outline-primary" type="submit" formaction="@Url.Action("QueueChapterStoryIntelligenceRun")">Queue persisted run</button>
|
||||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceDiagnostics">Back to diagnostics</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -2,6 +2,13 @@
|
||||
@{
|
||||
ViewData["Title"] = "Story Intelligence Run";
|
||||
var run = Model.Run;
|
||||
var canCancel = run is not null && (run.Status == StoryIntelligenceRunStatuses.Pending || run.Status == StoryIntelligenceRunStatuses.Running);
|
||||
var shouldRefresh = run is not null && (canCancel || run.Status == "Running");
|
||||
}
|
||||
|
||||
@if (shouldRefresh)
|
||||
{
|
||||
<meta http-equiv="refresh" content="10" />
|
||||
}
|
||||
|
||||
<div class="page-heading compact">
|
||||
@ -64,6 +71,16 @@ else
|
||||
</div>
|
||||
|
||||
<dl class="mt-3 mb-0">
|
||||
<dt>Current stage</dt>
|
||||
<dd>@Display(run.CurrentStage)</dd>
|
||||
<dt>Current message</dt>
|
||||
<dd>@Display(run.CurrentMessage)</dd>
|
||||
<dt>Scene progress</dt>
|
||||
<dd>
|
||||
Detected: @Display(run.TotalDetectedScenes),
|
||||
completed: @Display(run.CompletedScenes),
|
||||
failed: @Display(run.FailedScenes)
|
||||
</dd>
|
||||
<dt>Source type</dt>
|
||||
<dd>@run.SourceType</dd>
|
||||
<dt>Source file</dt>
|
||||
@ -97,6 +114,10 @@ else
|
||||
<dd>@run.StartedUtc.ToString("yyyy-MM-dd HH:mm:ss") UTC</dd>
|
||||
<dt>Completed</dt>
|
||||
<dd>@(run.CompletedUtc?.ToString("yyyy-MM-dd HH:mm:ss") ?? "None") UTC</dd>
|
||||
<dt>Cancellation requested</dt>
|
||||
<dd>@(run.CancellationRequestedUtc?.ToString("yyyy-MM-dd HH:mm:ss") ?? "None") UTC</dd>
|
||||
<dt>Cancelled</dt>
|
||||
<dd>@(run.CancelledUtc?.ToString("yyyy-MM-dd HH:mm:ss") ?? "None") UTC</dd>
|
||||
<dt>Failure stage</dt>
|
||||
<dd>@Display(run.FailureStage)</dd>
|
||||
<dt>Input tokens</dt>
|
||||
@ -110,6 +131,13 @@ else
|
||||
<dt>Error detail</dt>
|
||||
<dd><pre class="mb-0">@Display(run.ErrorDetail)</pre></dd>
|
||||
</dl>
|
||||
|
||||
@if (canCancel)
|
||||
{
|
||||
<form asp-action="CancelStoryIntelligenceRun" asp-route-id="@run.StoryIntelligenceRunID" method="post" class="mt-3">
|
||||
<button class="btn btn-outline-danger" type="submit">Cancel run</button>
|
||||
</form>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="edit-panel">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user