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 });
|
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")]
|
[HttpGet("story-intelligence-runs")]
|
||||||
public async Task<IActionResult> StoryIntelligenceRuns()
|
public async Task<IActionResult> StoryIntelligenceRuns()
|
||||||
{
|
{
|
||||||
@ -106,6 +120,15 @@ public sealed class AdminController(
|
|||||||
return model is null ? NotFound() : View(model);
|
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")]
|
[HttpGet("feature-requests")]
|
||||||
public async Task<IActionResult> FeatureRequests(string? status, string? appArea, string? importance, string? search, string? sort)
|
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
|
public interface IStoryIntelligenceResultRepository
|
||||||
{
|
{
|
||||||
Task<int> SaveAsync(StoryIntelligenceRunSaveRequest request);
|
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<IReadOnlyList<StoryIntelligenceSavedRunListItem>> ListRunsAsync();
|
||||||
Task<StoryIntelligenceSavedRun?> GetRunAsync(int runId);
|
Task<StoryIntelligenceSavedRun?> GetRunAsync(int runId);
|
||||||
Task<StoryIntelligenceSavedChapterResult?> GetChapterResultAsync(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()
|
public async Task<IReadOnlyList<StoryIntelligenceSavedRunListItem>> ListRunsAsync()
|
||||||
{
|
{
|
||||||
using var connection = connectionFactory.CreateConnection();
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
|||||||
@ -73,6 +73,13 @@ public sealed class StoryIntelligenceOptions
|
|||||||
public int TimeoutSeconds { get; init; } = 120;
|
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 sealed class StoryIntelligenceRun
|
||||||
{
|
{
|
||||||
public Guid RunId { get; init; } = Guid.NewGuid();
|
public Guid RunId { get; init; } = Guid.NewGuid();
|
||||||
|
|||||||
@ -55,6 +55,21 @@ public sealed class StoryIntelligenceRunSaveRequest
|
|||||||
public IReadOnlyList<StoryIntelligenceSceneResultSaveRequest> SceneResults { get; init; } = [];
|
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 sealed class StoryIntelligenceChapterResultSaveRequest
|
||||||
{
|
{
|
||||||
public int? ProjectID { get; init; }
|
public int? ProjectID { get; init; }
|
||||||
@ -138,6 +153,13 @@ public sealed class StoryIntelligenceSavedRun
|
|||||||
public int? SourceDetectedTablesCount { get; init; }
|
public int? SourceDetectedTablesCount { get; init; }
|
||||||
public int? SourceDetectedFootnotesCount { get; init; }
|
public int? SourceDetectedFootnotesCount { get; init; }
|
||||||
public int? SourceDetectedCommentsCount { 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 PromptVersion { get; init; } = string.Empty;
|
||||||
public string? PromptVersionsSummary { get; init; }
|
public string? PromptVersionsSummary { get; init; }
|
||||||
public string Model { get; init; } = string.Empty;
|
public string Model { get; init; } = string.Empty;
|
||||||
@ -156,6 +178,45 @@ public sealed class StoryIntelligenceSavedRun
|
|||||||
public DateTime UpdatedUtc { get; init; }
|
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 sealed class StoryIntelligenceSavedChapterResult
|
||||||
{
|
{
|
||||||
public int ChapterResultID { get; init; }
|
public int ChapterResultID { get; init; }
|
||||||
|
|||||||
@ -42,6 +42,7 @@ public class Program
|
|||||||
builder.Services.Configure<StripeSettings>(builder.Configuration.GetSection("Stripe"));
|
builder.Services.Configure<StripeSettings>(builder.Configuration.GetSection("Stripe"));
|
||||||
builder.Services.Configure<StorageSettings>(builder.Configuration.GetSection("Storage"));
|
builder.Services.Configure<StorageSettings>(builder.Configuration.GetSection("Storage"));
|
||||||
builder.Services.Configure<StoryIntelligenceOptions>(builder.Configuration.GetSection("OpenAI"));
|
builder.Services.Configure<StoryIntelligenceOptions>(builder.Configuration.GetSection("OpenAI"));
|
||||||
|
builder.Services.Configure<StoryIntelligencePricingOptions>(builder.Configuration.GetSection("StoryIntelligence:Pricing"));
|
||||||
builder.Services.AddDataProtection()
|
builder.Services.AddDataProtection()
|
||||||
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(builder.Environment.ContentRootPath, "App_Data", "DataProtectionKeys")));
|
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(builder.Environment.ContentRootPath, "App_Data", "DataProtectionKeys")));
|
||||||
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
@ -195,6 +196,7 @@ public class Program
|
|||||||
builder.Services.AddScoped<IChapterStructureDryRunService, ChapterStructureDryRunService>();
|
builder.Services.AddScoped<IChapterStructureDryRunService, ChapterStructureDryRunService>();
|
||||||
builder.Services.AddScoped<IChapterStoryIntelligenceDryRunService, ChapterStoryIntelligenceDryRunService>();
|
builder.Services.AddScoped<IChapterStoryIntelligenceDryRunService, ChapterStoryIntelligenceDryRunService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceResultPersistenceService, StoryIntelligenceResultPersistenceService>();
|
builder.Services.AddScoped<IStoryIntelligenceResultPersistenceService, StoryIntelligenceResultPersistenceService>();
|
||||||
|
builder.Services.AddScoped<IPersistedStoryIntelligenceRunner, PersistedStoryIntelligenceRunner>();
|
||||||
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
|
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
|
||||||
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
||||||
builder.Services.AddScoped<IStripeBillingService, StripeBillingService>();
|
builder.Services.AddScoped<IStripeBillingService, StripeBillingService>();
|
||||||
@ -210,6 +212,7 @@ public class Program
|
|||||||
builder.Services.AddScoped<IFeatureRequestService, FeatureRequestService>();
|
builder.Services.AddScoped<IFeatureRequestService, FeatureRequestService>();
|
||||||
builder.Services.AddHostedService<EmailQueueWorker>();
|
builder.Services.AddHostedService<EmailQueueWorker>();
|
||||||
builder.Services.AddHostedService<StoryIntelligenceWorker>();
|
builder.Services.AddHostedService<StoryIntelligenceWorker>();
|
||||||
|
builder.Services.AddHostedService<PersistedStoryIntelligenceWorker>();
|
||||||
|
|
||||||
var app = builder.Build();
|
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
|
public interface IStoryIntelligenceResultPersistenceService
|
||||||
{
|
{
|
||||||
Task<int> SaveDisplayedRunAsync(int userId, string payload);
|
Task<int> SaveDisplayedRunAsync(int userId, string payload);
|
||||||
|
Task<int> QueueAdminTextRunAsync(int userId, ChapterStoryIntelligenceDryRunForm form);
|
||||||
|
Task CancelRunAsync(int runId);
|
||||||
Task<StoryIntelligenceSavedRunsViewModel> ListRunsAsync();
|
Task<StoryIntelligenceSavedRunsViewModel> ListRunsAsync();
|
||||||
Task<StoryIntelligenceSavedRunDetailViewModel?> GetRunDetailAsync(int runId);
|
Task<StoryIntelligenceSavedRunDetailViewModel?> GetRunDetailAsync(int runId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class StoryIntelligenceResultPersistenceService(
|
public sealed class StoryIntelligenceResultPersistenceService(
|
||||||
IStoryIntelligenceResultRepository repository,
|
IStoryIntelligenceResultRepository repository,
|
||||||
|
IStoryIntelligenceClient client,
|
||||||
ILogger<StoryIntelligenceResultPersistenceService> logger) : IStoryIntelligenceResultPersistenceService
|
ILogger<StoryIntelligenceResultPersistenceService> logger) : IStoryIntelligenceResultPersistenceService
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
@ -57,6 +60,34 @@ public sealed class StoryIntelligenceResultPersistenceService(
|
|||||||
return runId;
|
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()
|
public async Task<StoryIntelligenceSavedRunsViewModel> ListRunsAsync()
|
||||||
=> new() { Runs = await repository.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>
|
||||||
<div class="button-row mt-3">
|
<div class="button-row mt-3">
|
||||||
<button class="btn btn-primary" type="submit">Run combined test</button>
|
<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>
|
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceDiagnostics">Back to diagnostics</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -2,6 +2,13 @@
|
|||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Story Intelligence Run";
|
ViewData["Title"] = "Story Intelligence Run";
|
||||||
var run = Model.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">
|
<div class="page-heading compact">
|
||||||
@ -64,6 +71,16 @@ else
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dl class="mt-3 mb-0">
|
<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>
|
<dt>Source type</dt>
|
||||||
<dd>@run.SourceType</dd>
|
<dd>@run.SourceType</dd>
|
||||||
<dt>Source file</dt>
|
<dt>Source file</dt>
|
||||||
@ -97,6 +114,10 @@ else
|
|||||||
<dd>@run.StartedUtc.ToString("yyyy-MM-dd HH:mm:ss") UTC</dd>
|
<dd>@run.StartedUtc.ToString("yyyy-MM-dd HH:mm:ss") UTC</dd>
|
||||||
<dt>Completed</dt>
|
<dt>Completed</dt>
|
||||||
<dd>@(run.CompletedUtc?.ToString("yyyy-MM-dd HH:mm:ss") ?? "None") UTC</dd>
|
<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>
|
<dt>Failure stage</dt>
|
||||||
<dd>@Display(run.FailureStage)</dd>
|
<dd>@Display(run.FailureStage)</dd>
|
||||||
<dt>Input tokens</dt>
|
<dt>Input tokens</dt>
|
||||||
@ -110,6 +131,13 @@ else
|
|||||||
<dt>Error detail</dt>
|
<dt>Error detail</dt>
|
||||||
<dd><pre class="mb-0">@Display(run.ErrorDetail)</pre></dd>
|
<dd><pre class="mb-0">@Display(run.ErrorDetail)</pre></dd>
|
||||||
</dl>
|
</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>
|
||||||
|
|
||||||
<section class="edit-panel">
|
<section class="edit-panel">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user