From 7ccf334c93e0b354a1c92972b8e4d7d5fce794cb Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sat, 4 Jul 2026 22:07:00 +0100 Subject: [PATCH] =?UTF-8?q?Phase=2020Q=20=E2=80=94=20Real=20Chapter=20Sour?= =?UTF-8?q?ce=20From=20Existing=20Book?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PlotLine/Controllers/AdminController.cs | 28 +- .../Data/StoryIntelligenceResultRepository.cs | 2 + .../Data/StoryIntelligenceSourceRepository.cs | 54 ++++ .../StoryIntelligencePersistenceModels.cs | 50 ++++ PlotLine/Program.cs | 2 + .../PersistedStoryIntelligenceRunner.cs | 19 +- ...IntelligenceExistingChapterQueueService.cs | 145 +++++++++ ...oryIntelligenceResultPersistenceService.cs | 2 + ...StoryIntelligenceExistingChapterSource.sql | 281 ++++++++++++++++++ PlotLine/Views/Admin/Index.cshtml | 1 + .../StoryIntelligenceExistingChapter.cshtml | 109 +++++++ .../Admin/StoryIntelligenceRunDetails.cshtml | 9 + .../Views/Admin/StoryIntelligenceRuns.cshtml | 1 + 13 files changed, 694 insertions(+), 9 deletions(-) create mode 100644 PlotLine/Data/StoryIntelligenceSourceRepository.cs create mode 100644 PlotLine/Services/StoryIntelligenceExistingChapterQueueService.cs create mode 100644 PlotLine/Sql/118_Phase20L_StoryIntelligenceExistingChapterSource.sql create mode 100644 PlotLine/Views/Admin/StoryIntelligenceExistingChapter.cshtml diff --git a/PlotLine/Controllers/AdminController.cs b/PlotLine/Controllers/AdminController.cs index 849ef7b..95573a0 100644 --- a/PlotLine/Controllers/AdminController.cs +++ b/PlotLine/Controllers/AdminController.cs @@ -16,7 +16,8 @@ public sealed class AdminController( IStoryIntelligenceDryRunService storyIntelligenceDryRun, IChapterStructureDryRunService chapterStructureDryRun, IChapterStoryIntelligenceDryRunService chapterStoryIntelligenceDryRun, - IStoryIntelligenceResultPersistenceService storyIntelligenceResults) : Controller + IStoryIntelligenceResultPersistenceService storyIntelligenceResults, + IStoryIntelligenceExistingChapterQueueService existingChapterQueue) : Controller { [HttpGet("")] [HttpGet("index")] @@ -107,6 +108,31 @@ public sealed class AdminController( return RedirectToAction(nameof(StoryIntelligenceRunDetails), new { id = runId }); } + [HttpGet("story-intelligence-existing-chapter")] + public async Task StoryIntelligenceExistingChapter([FromQuery] StoryIntelligenceExistingChapterQueueForm form) + { + return View(await existingChapterQueue.BuildViewModelAsync(form)); + } + + [HttpPost("story-intelligence-existing-chapter")] + [ValidateAntiForgeryToken] + public async Task QueueStoryIntelligenceExistingChapter(StoryIntelligenceExistingChapterQueueForm form) + { + if (currentUser.UserId is not int userId) + { + return Forbid(); + } + + var model = await existingChapterQueue.QueueAsync(userId, form); + if (model.QueuedRunID is int runId) + { + TempData["AdminMessage"] = $"Story Intelligence run {runId:N0} queued from existing chapter. The background worker will process it progressively."; + return RedirectToAction(nameof(StoryIntelligenceRunDetails), new { id = runId }); + } + + return View(nameof(StoryIntelligenceExistingChapter), model); + } + [HttpGet("story-intelligence-runs")] public async Task StoryIntelligenceRuns() { diff --git a/PlotLine/Data/StoryIntelligenceResultRepository.cs b/PlotLine/Data/StoryIntelligenceResultRepository.cs index be351e2..5dbb392 100644 --- a/PlotLine/Data/StoryIntelligenceResultRepository.cs +++ b/PlotLine/Data/StoryIntelligenceResultRepository.cs @@ -163,6 +163,8 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn request.UserID, request.ProjectID, request.BookID, + request.ChapterID, + request.ChapterNumber, request.SourceType, request.SourceLabel, SourceText = request.ChapterText, diff --git a/PlotLine/Data/StoryIntelligenceSourceRepository.cs b/PlotLine/Data/StoryIntelligenceSourceRepository.cs new file mode 100644 index 0000000..3a86fe3 --- /dev/null +++ b/PlotLine/Data/StoryIntelligenceSourceRepository.cs @@ -0,0 +1,54 @@ +using System.Data; +using Dapper; +using PlotLine.Models; + +namespace PlotLine.Data; + +public interface IStoryIntelligenceSourceRepository +{ + Task> ListProjectsAsync(); + Task> ListBooksAsync(int projectId); + Task> ListChaptersAsync(int bookId); + Task GetChapterSourceAsync(int chapterId); +} + +public sealed class StoryIntelligenceSourceRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligenceSourceRepository +{ + public async Task> ListProjectsAsync() + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "dbo.StoryIntelligenceSource_ProjectListAdmin", + commandType: CommandType.StoredProcedure); + return rows.ToList(); + } + + public async Task> ListBooksAsync(int projectId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "dbo.StoryIntelligenceSource_BookListAdmin", + new { ProjectID = projectId }, + commandType: CommandType.StoredProcedure); + return rows.ToList(); + } + + public async Task> ListChaptersAsync(int bookId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "dbo.StoryIntelligenceSource_ChapterListAdmin", + new { BookID = bookId }, + commandType: CommandType.StoredProcedure); + return rows.ToList(); + } + + public async Task GetChapterSourceAsync(int chapterId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.StoryIntelligenceSource_ChapterText_GetAdmin", + new { ChapterID = chapterId }, + commandType: CommandType.StoredProcedure); + } +} diff --git a/PlotLine/Models/StoryIntelligencePersistenceModels.cs b/PlotLine/Models/StoryIntelligencePersistenceModels.cs index 8d900f7..b66ce5d 100644 --- a/PlotLine/Models/StoryIntelligencePersistenceModels.cs +++ b/PlotLine/Models/StoryIntelligencePersistenceModels.cs @@ -60,6 +60,8 @@ public sealed class StoryIntelligenceRunQueueRequest public int UserID { get; init; } public int? ProjectID { get; init; } public int? BookID { get; init; } + public int? ChapterID { get; init; } + public decimal? ChapterNumber { get; init; } public string SourceType { get; init; } = "AdminText"; public string SourceLabel { get; init; } = string.Empty; public string ChapterText { get; init; } = string.Empty; @@ -122,6 +124,8 @@ public sealed class StoryIntelligenceSavedRunListItem public string? ProjectTitle { get; init; } public int? BookID { get; init; } public string? BookTitle { get; init; } + public int? ChapterID { get; init; } + public decimal? ChapterNumber { get; init; } public string Status { get; init; } = string.Empty; public string SourceType { get; init; } = string.Empty; public string? FailureStage { get; init; } @@ -142,6 +146,8 @@ public sealed class StoryIntelligenceSavedRun public string? ProjectTitle { get; init; } public int? BookID { get; init; } public string? BookTitle { get; init; } + public int? ChapterID { get; init; } + public decimal? ChapterNumber { get; init; } public string Status { get; init; } = string.Empty; public string SourceType { get; init; } = string.Empty; public string? SourceFileName { get; init; } @@ -184,6 +190,8 @@ public sealed class StoryIntelligenceQueuedRun public int UserID { get; init; } public int? ProjectID { get; init; } public int? BookID { get; init; } + public int? ChapterID { get; init; } + public decimal? ChapterNumber { get; init; } public string Status { get; init; } = string.Empty; public string SourceType { get; init; } = string.Empty; public string? SourceLabel { get; init; } @@ -217,6 +225,48 @@ public sealed class StoryIntelligenceQueuedRun public bool CancellationRequested => CancellationRequestedUtc.HasValue; } +public sealed class StoryIntelligenceSourceOption +{ + public int Value { get; init; } + public string Text { get; init; } = string.Empty; +} + +public sealed class StoryIntelligenceExistingChapterSource +{ + public int ProjectID { get; init; } + public string ProjectName { get; init; } = string.Empty; + public int BookID { get; init; } + public string BookTitle { get; init; } = string.Empty; + public string? BookSubtitle { get; init; } + public int ChapterID { get; init; } + public decimal ChapterNumber { get; init; } + public string ChapterTitle { get; init; } = string.Empty; + public string? SourceText { get; init; } + public int? SourceWordCount { get; init; } + public int? SourceCharacterCount { get; init; } + + public bool HasSourceText => !string.IsNullOrWhiteSpace(SourceText); +} + +public sealed class StoryIntelligenceExistingChapterQueueForm +{ + public int? ProjectID { get; init; } + public int? BookID { get; init; } + public int? ChapterID { get; init; } +} + +public sealed class StoryIntelligenceExistingChapterQueueViewModel +{ + public StoryIntelligenceExistingChapterQueueForm Form { get; init; } = new(); + public IReadOnlyList Projects { get; init; } = []; + public IReadOnlyList Books { get; init; } = []; + public IReadOnlyList Chapters { get; init; } = []; + public StoryIntelligenceExistingChapterSource? SelectedChapter { get; init; } + public string? WarningMessage { get; init; } + public string? ErrorMessage { get; init; } + public int? QueuedRunID { get; init; } +} + public sealed class StoryIntelligenceSavedChapterResult { public int ChapterResultID { get; init; } diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 9988ff8..083d51e 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -138,6 +138,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -196,6 +197,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs index 9621def..15cfccc 100644 --- a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs +++ b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs @@ -95,8 +95,8 @@ public sealed class PersistedStoryIntelligenceRunner( { ProjectID = run.ProjectID, BookID = run.BookID, - ChapterID = null, - ChapterNumber = 1, + ChapterID = run.ChapterID, + ChapterNumber = ChapterNumber(run), SourceLabel = SourceLabel(run), PromptVersion = chapterPromptVersion, Model = chapterClientResult.Model, @@ -172,7 +172,7 @@ public sealed class PersistedStoryIntelligenceRunner( { ProjectID = run.ProjectID, BookID = run.BookID, - ChapterID = null, + ChapterID = run.ChapterID, SceneID = null, TemporarySceneNumber = block.TemporarySceneNumber, StartParagraph = block.StartParagraph, @@ -274,7 +274,7 @@ public sealed class PersistedStoryIntelligenceRunner( { ProjectID = run.ProjectID, BookID = run.BookID, - ChapterID = null, + ChapterID = run.ChapterID, SceneID = null, TemporarySceneNumber = block.TemporarySceneNumber, StartParagraph = block.StartParagraph, @@ -336,8 +336,8 @@ public sealed class PersistedStoryIntelligenceRunner( { projectId = run.ProjectID, bookId = run.BookID, - chapterId = (int?)null, - chapterNumber = 1, + chapterId = run.ChapterID, + chapterNumber = ChapterNumber(run), sourceLabel = SourceLabel(run) }, JsonOptions); @@ -346,9 +346,9 @@ public sealed class PersistedStoryIntelligenceRunner( { projectId = run.ProjectID, bookId = run.BookID, - chapterId = (int?)null, + chapterId = run.ChapterID, sceneId = (int?)null, - chapterNumber = 1, + chapterNumber = ChapterNumber(run), sceneNumber = boundary.SceneNumber, sourceLabel = $"{SourceLabel(run)}, suggested scene {boundary.SceneNumber}" }, JsonOptions); @@ -388,6 +388,9 @@ public sealed class PersistedStoryIntelligenceRunner( private static string SourceLabel(StoryIntelligenceQueuedRun run) => string.IsNullOrWhiteSpace(run.SourceLabel) ? "Admin persisted Story Intelligence chapter" : run.SourceLabel.Trim(); + private static decimal ChapterNumber(StoryIntelligenceQueuedRun run) + => run.ChapterNumber ?? 1; + private static string BuildValidationDetail(PlotLine.Models.StoryIntelligence.ValidationResult validation) => string.Join( Environment.NewLine, diff --git a/PlotLine/Services/StoryIntelligenceExistingChapterQueueService.cs b/PlotLine/Services/StoryIntelligenceExistingChapterQueueService.cs new file mode 100644 index 0000000..079ea80 --- /dev/null +++ b/PlotLine/Services/StoryIntelligenceExistingChapterQueueService.cs @@ -0,0 +1,145 @@ +using PlotLine.Data; +using PlotLine.Models; + +namespace PlotLine.Services; + +public interface IStoryIntelligenceExistingChapterQueueService +{ + Task BuildViewModelAsync(StoryIntelligenceExistingChapterQueueForm form); + Task QueueAsync(int userId, StoryIntelligenceExistingChapterQueueForm form); +} + +public sealed class StoryIntelligenceExistingChapterQueueService( + IStoryIntelligenceSourceRepository sources, + IStoryIntelligenceResultRepository runs, + IStoryIntelligenceClient client, + ILogger logger) : IStoryIntelligenceExistingChapterQueueService +{ + private const string NoStoredChapterTextMessage = "No stored chapter text is currently available for this chapter. A future Word Companion/document extraction phase is required."; + + public async Task BuildViewModelAsync(StoryIntelligenceExistingChapterQueueForm form) + { + var selected = await GetSelectedChapterAsync(form); + return await BuildViewModelAsync(form, selected, warningMessage: BuildWarning(selected), errorMessage: null, queuedRunId: null); + } + + public async Task QueueAsync(int userId, StoryIntelligenceExistingChapterQueueForm form) + { + if (form.ChapterID is not int chapterId) + { + return await BuildViewModelAsync( + form, + selectedChapter: null, + warningMessage: null, + errorMessage: "Choose a chapter before queueing a Story Intelligence run.", + queuedRunId: null); + } + + var selected = await sources.GetChapterSourceAsync(chapterId); + if (selected is null) + { + return await BuildViewModelAsync( + form, + selectedChapter: null, + warningMessage: null, + errorMessage: "The selected chapter could not be found.", + queuedRunId: null); + } + + if (!selected.HasSourceText) + { + return await BuildViewModelAsync( + NormalizeForm(selected), + selected, + NoStoredChapterTextMessage, + errorMessage: null, + queuedRunId: null); + } + + var sourceText = selected.SourceText!.Trim(); + var clientStatus = client.GetConfigurationStatus(); + var runId = await runs.QueueAdminTextAsync(new StoryIntelligenceRunQueueRequest + { + UserID = userId, + ProjectID = selected.ProjectID, + BookID = selected.BookID, + ChapterID = selected.ChapterID, + ChapterNumber = selected.ChapterNumber, + SourceType = "ExistingChapter", + SourceLabel = BuildSourceLabel(selected), + ChapterText = sourceText, + SourceWordCount = selected.SourceWordCount ?? CountWords(sourceText), + SourceCharacterCount = selected.SourceCharacterCount ?? sourceText.Length, + SourceChapterCount = 1, + Model = clientStatus.Model, + PromptVersionsSummary = "Chapter=Chapter-Structure-Prompt-V1; Scene=Scene-Prompt-V1" + }); + + logger.LogInformation( + "Queued Story Intelligence run {StoryIntelligenceRunID} from existing chapter {ChapterID}. ProjectID={ProjectID} BookID={BookID} SourceWordCount={SourceWordCount} SourceCharacterCount={SourceCharacterCount}", + runId, + selected.ChapterID, + selected.ProjectID, + selected.BookID, + selected.SourceWordCount, + selected.SourceCharacterCount); + + return await BuildViewModelAsync(NormalizeForm(selected), selected, warningMessage: null, errorMessage: null, queuedRunId: runId); + } + + private async Task BuildViewModelAsync( + StoryIntelligenceExistingChapterQueueForm form, + StoryIntelligenceExistingChapterSource? selectedChapter, + string? warningMessage, + string? errorMessage, + int? queuedRunId) + { + var normalizedForm = selectedChapter is not null ? NormalizeForm(selectedChapter) : form; + var projects = await sources.ListProjectsAsync(); + var books = normalizedForm.ProjectID is int projectId + ? await sources.ListBooksAsync(projectId) + : []; + var chapters = normalizedForm.BookID is int bookId + ? await sources.ListChaptersAsync(bookId) + : []; + + return new StoryIntelligenceExistingChapterQueueViewModel + { + Form = normalizedForm, + Projects = projects, + Books = books, + Chapters = chapters, + SelectedChapter = selectedChapter, + WarningMessage = warningMessage, + ErrorMessage = errorMessage, + QueuedRunID = queuedRunId + }; + } + + private async Task GetSelectedChapterAsync(StoryIntelligenceExistingChapterQueueForm form) + => form.ChapterID is int chapterId ? await sources.GetChapterSourceAsync(chapterId) : null; + + private static string? BuildWarning(StoryIntelligenceExistingChapterSource? selected) + => selected is not null && !selected.HasSourceText ? NoStoredChapterTextMessage : null; + + private static StoryIntelligenceExistingChapterQueueForm NormalizeForm(StoryIntelligenceExistingChapterSource selected) + => new() + { + ProjectID = selected.ProjectID, + BookID = selected.BookID, + ChapterID = selected.ChapterID + }; + + private static string BuildSourceLabel(StoryIntelligenceExistingChapterSource selected) + { + var book = string.IsNullOrWhiteSpace(selected.BookSubtitle) + ? selected.BookTitle + : $"{selected.BookTitle}: {selected.BookSubtitle}"; + return $"{selected.ProjectName} / {book} / Chapter {selected.ChapterNumber}: {selected.ChapterTitle}"; + } + + private static int? CountWords(string? value) + => string.IsNullOrWhiteSpace(value) + ? null + : value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length; +} diff --git a/PlotLine/Services/StoryIntelligenceResultPersistenceService.cs b/PlotLine/Services/StoryIntelligenceResultPersistenceService.cs index 8a8400c..c8e2ad2 100644 --- a/PlotLine/Services/StoryIntelligenceResultPersistenceService.cs +++ b/PlotLine/Services/StoryIntelligenceResultPersistenceService.cs @@ -74,6 +74,8 @@ public sealed class StoryIntelligenceResultPersistenceService( UserID = userId, ProjectID = form.ProjectID, BookID = form.BookID, + ChapterID = form.ChapterID, + ChapterNumber = form.ChapterNumber, SourceType = "AdminText", SourceLabel = CleanSourceLabel(form.SourceLabel), ChapterText = form.ChapterText, diff --git a/PlotLine/Sql/118_Phase20L_StoryIntelligenceExistingChapterSource.sql b/PlotLine/Sql/118_Phase20L_StoryIntelligenceExistingChapterSource.sql new file mode 100644 index 0000000..2c3e5ea --- /dev/null +++ b/PlotLine/Sql/118_Phase20L_StoryIntelligenceExistingChapterSource.sql @@ -0,0 +1,281 @@ +IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'ChapterID') IS NULL +BEGIN + ALTER TABLE dbo.StoryIntelligenceRuns ADD ChapterID int NULL; +END; +GO + +IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'ChapterNumber') IS NULL +BEGIN + ALTER TABLE dbo.StoryIntelligenceRuns ADD ChapterNumber decimal(9, 2) NULL; +END; +GO + +IF NOT EXISTS +( + SELECT 1 + FROM sys.foreign_keys + WHERE name = N'FK_StoryIntelligenceRuns_Chapters' + AND parent_object_id = OBJECT_ID(N'dbo.StoryIntelligenceRuns') +) +BEGIN + ALTER TABLE dbo.StoryIntelligenceRuns + ADD CONSTRAINT FK_StoryIntelligenceRuns_Chapters + FOREIGN KEY (ChapterID) REFERENCES dbo.Chapters(ChapterID); +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceSource_ProjectListAdmin +AS +BEGIN + SET NOCOUNT ON; + + SELECT + ProjectID AS Value, + ProjectName AS Text + FROM dbo.Projects + WHERE IsArchived = 0 + ORDER BY ProjectName; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceSource_BookListAdmin + @ProjectID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT + BookID AS Value, + CASE + WHEN NULLIF(LTRIM(RTRIM(Subtitle)), N'') IS NULL THEN BookTitle + ELSE CONCAT(BookTitle, N': ', Subtitle) + END AS Text + FROM dbo.Books + WHERE ProjectID = @ProjectID + AND IsArchived = 0 + ORDER BY SortOrder, BookNumber, BookTitle; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceSource_ChapterListAdmin + @BookID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT + ChapterID AS Value, + CONCAT(N'Chapter ', CONVERT(nvarchar(20), ChapterNumber), N': ', ChapterTitle) AS Text + FROM dbo.Chapters + WHERE BookID = @BookID + AND IsArchived = 0 + ORDER BY SortOrder, ChapterNumber, ChapterTitle; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceSource_ChapterText_GetAdmin + @ChapterID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT + p.ProjectID, + p.ProjectName, + b.BookID, + b.BookTitle, + b.Subtitle AS BookSubtitle, + c.ChapterID, + c.ChapterNumber, + c.ChapterTitle, + CAST(NULL AS nvarchar(max)) AS SourceText, + CAST(NULL AS int) AS SourceWordCount, + CAST(NULL AS int) AS SourceCharacterCount + FROM dbo.Chapters c + INNER JOIN dbo.Books b ON b.BookID = c.BookID + INNER JOIN dbo.Projects p ON p.ProjectID = b.ProjectID + WHERE c.ChapterID = @ChapterID + AND c.IsArchived = 0 + AND b.IsArchived = 0 + AND p.IsArchived = 0; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_QueueAdminText + @UserID int, + @ProjectID int = NULL, + @BookID int = NULL, + @ChapterID int = NULL, + @ChapterNumber decimal(9, 2) = 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, ChapterID, ChapterNumber, Status, SourceType, SourceLabel, SourceText, + SourceWordCount, SourceCharacterCount, SourceChapterCount, Model, + PromptVersion, PromptVersionsSummary, StartedUtc, CurrentStage, CurrentMessage, + CompletedScenes, FailedScenes + ) + VALUES + ( + @UserID, @ProjectID, @BookID, @ChapterID, @ChapterNumber, 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, ChapterID, ChapterNumber, 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, ChapterID, ChapterNumber, 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_ListAdmin +AS +BEGIN + SET NOCOUNT ON; + + SELECT + r.StoryIntelligenceRunID, + r.CreatedUtc, + r.UserID, + r.ProjectID, + p.ProjectName AS ProjectTitle, + r.BookID, + b.BookTitle, + r.ChapterID, + r.ChapterNumber, + r.Status, + r.SourceType, + r.FailureStage, + COUNT(sr.SceneResultID) AS SceneCount, + r.TotalTokens, + r.TotalDurationMs, + r.EstimatedCostGBP, + r.EstimatedCostUSD, + COALESCE(cr.ValidationErrorsCount, 0) + COALESCE(SUM(sr.ValidationErrorsCount), 0) AS ValidationErrorsCount, + COALESCE(cr.ValidationWarningsCount, 0) + COALESCE(SUM(sr.ValidationWarningsCount), 0) AS ValidationWarningsCount + FROM dbo.StoryIntelligenceRuns r + LEFT JOIN dbo.Projects p ON p.ProjectID = r.ProjectID + LEFT JOIN dbo.Books b ON b.BookID = r.BookID + LEFT JOIN dbo.StoryIntelligenceChapterResults cr ON cr.StoryIntelligenceRunID = r.StoryIntelligenceRunID + LEFT JOIN dbo.StoryIntelligenceSceneResults sr ON sr.StoryIntelligenceRunID = r.StoryIntelligenceRunID + GROUP BY + r.StoryIntelligenceRunID, r.CreatedUtc, r.UserID, r.ProjectID, p.ProjectName, + r.BookID, b.BookTitle, r.ChapterID, r.ChapterNumber, r.Status, r.SourceType, + r.FailureStage, r.TotalTokens, r.TotalDurationMs, r.EstimatedCostGBP, r.EstimatedCostUSD, + cr.ValidationErrorsCount, cr.ValidationWarningsCount + ORDER BY r.CreatedUtc DESC; +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.ChapterID, + r.ChapterNumber, + 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 diff --git a/PlotLine/Views/Admin/Index.cshtml b/PlotLine/Views/Admin/Index.cshtml index 195c0ac..dddd770 100644 --- a/PlotLine/Views/Admin/Index.cshtml +++ b/PlotLine/Views/Admin/Index.cshtml @@ -85,6 +85,7 @@ Dry run Chapter test Pipeline test + Queue chapter Saved runs diff --git a/PlotLine/Views/Admin/StoryIntelligenceExistingChapter.cshtml b/PlotLine/Views/Admin/StoryIntelligenceExistingChapter.cshtml new file mode 100644 index 0000000..0a3d051 --- /dev/null +++ b/PlotLine/Views/Admin/StoryIntelligenceExistingChapter.cshtml @@ -0,0 +1,109 @@ +@model StoryIntelligenceExistingChapterQueueViewModel +@{ + ViewData["Title"] = "Queue Story Intelligence From Chapter"; +} + +
+
+

Admin utility

+

Queue from existing chapter

+

Run the persisted Story Intelligence pipeline from read-only PlotDirector chapter data.

+
+
+ + + +@if (!string.IsNullOrWhiteSpace(Model.ErrorMessage)) +{ +
@Model.ErrorMessage
+} + +@if (!string.IsNullOrWhiteSpace(Model.WarningMessage)) +{ +
@Model.WarningMessage
+} + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + Saved runs +
+
+
+ +@if (Model.SelectedChapter is not null) +{ +
+

Selected source

+
+
Project
+
@Model.SelectedChapter.ProjectName
+
Book
+
@DisplayBook(Model.SelectedChapter)
+
Chapter
+
Chapter @Model.SelectedChapter.ChapterNumber: @Model.SelectedChapter.ChapterTitle
+
Word count
+
@DisplayCount(Model.SelectedChapter.SourceWordCount)
+
Character count
+
@DisplayCount(Model.SelectedChapter.SourceCharacterCount)
+
Text source
+
@(Model.SelectedChapter.HasSourceText ? "Stored chapter manuscript text is available." : "No stored chapter manuscript text is available.")
+
+ +
+ @Html.AntiForgeryToken() + + + + +
+
+} + +@functions { + private static string DisplayCount(int? value) + => value.HasValue ? value.Value.ToString("N0") : "Not available"; + + private static string DisplayBook(StoryIntelligenceExistingChapterSource source) + => string.IsNullOrWhiteSpace(source.BookSubtitle) + ? source.BookTitle + : $"{source.BookTitle}: {source.BookSubtitle}"; +} diff --git a/PlotLine/Views/Admin/StoryIntelligenceRunDetails.cshtml b/PlotLine/Views/Admin/StoryIntelligenceRunDetails.cshtml index 34d77d8..7299bdc 100644 --- a/PlotLine/Views/Admin/StoryIntelligenceRunDetails.cshtml +++ b/PlotLine/Views/Admin/StoryIntelligenceRunDetails.cshtml @@ -104,6 +104,8 @@ else
@DisplayName(run.ProjectTitle, run.ProjectID, "Project")
Book
@DisplayName(run.BookTitle, run.BookID, "Book")
+
Chapter
+
@DisplayChapter(run.ChapterID, run.ChapterNumber)
Prompt version
@run.PromptVersion
Prompt versions summary
@@ -239,4 +241,11 @@ else : id.HasValue ? $"{label} {id.Value:N0}" : "None"; + + private static string DisplayChapter(int? chapterId, decimal? chapterNumber) + => chapterId.HasValue + ? chapterNumber.HasValue + ? $"Chapter {chapterNumber.Value:0.##} (ID {chapterId.Value:N0})" + : $"Chapter ID {chapterId.Value:N0}" + : "None"; } diff --git a/PlotLine/Views/Admin/StoryIntelligenceRuns.cshtml b/PlotLine/Views/Admin/StoryIntelligenceRuns.cshtml index 6c12875..17b5d6b 100644 --- a/PlotLine/Views/Admin/StoryIntelligenceRuns.cshtml +++ b/PlotLine/Views/Admin/StoryIntelligenceRuns.cshtml @@ -26,6 +26,7 @@