From 438478c7febf7199428cca88f0d821fb0ba8b0bf Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Wed, 8 Jul 2026 21:31:40 +0100 Subject: [PATCH] =?UTF-8?q?Phase=2020AJ=20=E2=80=93=20Story=20Intelligence?= =?UTF-8?q?=20Pipeline=20Completion=20and=20Resume=20Support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PlotLine/Controllers/OnboardingController.cs | 18 ++ .../StoryIntelligencePipelineRepository.cs | 74 +++++++ .../StoryIntelligencePersistenceModels.cs | 65 ++++++ PlotLine/Program.cs | 2 + PlotLine/Services/CoreServices.cs | 6 +- .../OnboardingStoryIntelligenceService.cs | 69 ++++++ ...StoryIntelligenceCharacterImportService.cs | 2 + .../StoryIntelligenceImportCommitService.cs | 6 + .../StoryIntelligencePipelineStateService.cs | 94 ++++++++ PlotLine/Services/StoryIntelligenceService.cs | 62 +++++- ...ase20AJ_StoryIntelligencePipelineState.sql | 208 ++++++++++++++++++ PlotLine/ViewModels/CoreViewModels.cs | 1 + PlotLine/ViewModels/OnboardingViewModels.cs | 11 + PlotLine/Views/Books/Details.cshtml | 35 +++ PlotLine/Views/Projects/Index.cshtml | 26 +++ PlotLine/wwwroot/css/onboarding.css | 18 ++ 16 files changed, 694 insertions(+), 3 deletions(-) create mode 100644 PlotLine/Data/StoryIntelligencePipelineRepository.cs create mode 100644 PlotLine/Services/StoryIntelligencePipelineStateService.cs create mode 100644 PlotLine/Sql/127_Phase20AJ_StoryIntelligencePipelineState.sql diff --git a/PlotLine/Controllers/OnboardingController.cs b/PlotLine/Controllers/OnboardingController.cs index 53ddefd..e4df73b 100644 --- a/PlotLine/Controllers/OnboardingController.cs +++ b/PlotLine/Controllers/OnboardingController.cs @@ -62,6 +62,24 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard return View(model); } + [HttpGet("story-intelligence/book/{bookId:int}/continue")] + public async Task ContinueStoryIntelligence(int bookId) + { + var target = await storyIntelligence.ResumeBookAsync(bookId); + if (target is null) + { + TempData["ArchiveError"] = "Story Intelligence could not find saved analysis for that book."; + return RedirectToAction("Details", "Books", new { id = bookId }); + } + + return target.Route switch + { + StoryIntelligenceResumeRoutes.Complete => RedirectToAction(nameof(StoryIntelligenceComplete), new { batchId = target.BatchID }), + StoryIntelligenceResumeRoutes.Characters => RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId = target.BatchID }), + _ => RedirectToAction(nameof(StoryIntelligenceReview), new { batchId = target.BatchID }) + }; + } + [HttpPost("story-intelligence/start")] [ValidateAntiForgeryToken] public async Task StartStoryIntelligence() diff --git a/PlotLine/Data/StoryIntelligencePipelineRepository.cs b/PlotLine/Data/StoryIntelligencePipelineRepository.cs new file mode 100644 index 0000000..9d14205 --- /dev/null +++ b/PlotLine/Data/StoryIntelligencePipelineRepository.cs @@ -0,0 +1,74 @@ +using System.Data; +using Dapper; +using PlotLine.Models; + +namespace PlotLine.Data; + +public interface IStoryIntelligencePipelineRepository +{ + Task GetByBookAsync(int bookId); + Task GetByBookForUserAsync(int bookId, int userId); + Task> ListForUserAsync(int userId); + Task UpsertAsync(StoryIntelligenceBookPipelineSaveRequest request); + Task> ListCommittedRunsByBookAsync(int bookId, int userId); +} + +public sealed class StoryIntelligencePipelineRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligencePipelineRepository +{ + public async Task GetByBookAsync(int bookId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.StoryIntelligenceBookPipeline_GetByBook", + new { BookID = bookId }, + commandType: CommandType.StoredProcedure); + } + + public async Task GetByBookForUserAsync(int bookId, int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.StoryIntelligenceBookPipeline_GetByBookForUser", + new { BookID = bookId, UserID = userId }, + commandType: CommandType.StoredProcedure); + } + + public async Task> ListForUserAsync(int userId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "dbo.StoryIntelligenceBookPipeline_ListForUser", + new { UserID = userId }, + commandType: CommandType.StoredProcedure); + return rows.ToList(); + } + + public async Task UpsertAsync(StoryIntelligenceBookPipelineSaveRequest request) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.StoryIntelligenceBookPipeline_Upsert", + new + { + request.ProjectID, + request.BookID, + request.CurrentStage, + request.LastCompletedStage, + request.CurrentReviewStage, + request.Status, + request.CompletedUtc, + request.LastRunID + }, + commandType: CommandType.StoredProcedure); + } + + public async Task> ListCommittedRunsByBookAsync(int bookId, int userId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "dbo.StoryIntelligenceBookPipeline_ListCommittedRunsByBook", + new { BookID = bookId, UserID = userId }, + commandType: CommandType.StoredProcedure); + return rows.ToList(); + } +} diff --git a/PlotLine/Models/StoryIntelligencePersistenceModels.cs b/PlotLine/Models/StoryIntelligencePersistenceModels.cs index a263512..1cbf31d 100644 --- a/PlotLine/Models/StoryIntelligencePersistenceModels.cs +++ b/PlotLine/Models/StoryIntelligencePersistenceModels.cs @@ -21,6 +21,71 @@ public static class StoryIntelligenceFailureStages public const string DomainImport = "DomainImport"; } +public static class StoryIntelligencePipelineStages +{ + public const string Chapters = "Chapters"; + public const string SceneReview = "SceneReview"; + public const string SceneImport = "SceneImport"; + public const string CharacterReview = "CharacterReview"; + public const string CharacterImport = "CharacterImport"; + public const string Complete = "Complete"; +} + +public static class StoryIntelligencePipelineStatuses +{ + public const string InProgress = "InProgress"; + public const string NeedsReview = "NeedsReview"; + public const string Complete = "Complete"; +} + +public sealed class StoryIntelligenceBookPipelineState +{ + public int StoryIntelligenceBookPipelineID { get; init; } + 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 string BookDisplayTitle => BookTitleFormatter.DisplayTitle(BookTitle, BookSubtitle); + public string CurrentStage { get; init; } = StoryIntelligencePipelineStages.SceneReview; + public string? LastCompletedStage { get; init; } + public string? CurrentReviewStage { get; init; } + public string Status { get; init; } = StoryIntelligencePipelineStatuses.NeedsReview; + public DateTime? CompletedUtc { get; init; } + public int? LastRunID { get; init; } + public DateTime CreatedUtc { get; init; } + public DateTime UpdatedUtc { get; init; } + + public bool IsComplete => string.Equals(Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase); +} + +public sealed class StoryIntelligenceBookPipelineSaveRequest +{ + public int ProjectID { get; init; } + public int BookID { get; init; } + public string CurrentStage { get; init; } = StoryIntelligencePipelineStages.SceneReview; + public string? LastCompletedStage { get; init; } + public string? CurrentReviewStage { get; init; } + public string Status { get; init; } = StoryIntelligencePipelineStatuses.NeedsReview; + public DateTime? CompletedUtc { get; init; } + public int? LastRunID { get; init; } +} + +public sealed class StoryIntelligenceCommittedRunSummary +{ + public int StoryIntelligenceRunID { get; init; } + public int UserID { get; init; } + 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? SourceLabel { get; init; } +} + public sealed class StoryIntelligenceRunSaveRequest { public int UserID { get; init; } diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 88a3016..f5eef2b 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -143,6 +143,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(); @@ -207,6 +208,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/CoreServices.cs b/PlotLine/Services/CoreServices.cs index 6ab5268..ff10dbb 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -1139,6 +1139,7 @@ public sealed class BookService( IBookCoverService bookCovers, ISubscriptionService subscriptions, IProjectActivityService activity, + IStoryIntelligencePipelineStateService storyIntelligencePipeline, ICurrentUserService currentUser) : IBookService { public async Task GetDetailAsync(int bookId) @@ -1162,7 +1163,10 @@ public sealed class BookService( ManuscriptDocument = currentUser.UserId is int userId ? await manuscriptDocuments.GetByBookAsync(bookId, userId) : null, - Chapters = await chapters.ListByBookAsync(bookId) + Chapters = await chapters.ListByBookAsync(bookId), + StoryIntelligencePipeline = currentUser.UserId is int pipelineUserId + ? await storyIntelligencePipeline.GetForBookAsync(bookId, pipelineUserId) + : null }; } diff --git a/PlotLine/Services/OnboardingStoryIntelligenceService.cs b/PlotLine/Services/OnboardingStoryIntelligenceService.cs index 8e934c8..007e258 100644 --- a/PlotLine/Services/OnboardingStoryIntelligenceService.cs +++ b/PlotLine/Services/OnboardingStoryIntelligenceService.cs @@ -17,6 +17,7 @@ public interface IOnboardingStoryIntelligenceService Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportCharactersAsync(Guid batchId, StoryIntelligenceCharacterImportForm form); Task GetCharacterImportResultAsync(Guid batchId); Task GetCompletionAsync(Guid batchId); + Task ResumeBookAsync(int bookId); } public sealed class OnboardingStoryIntelligenceService( @@ -26,6 +27,7 @@ public sealed class OnboardingStoryIntelligenceService( IStoryIntelligenceResultRepository runs, IStoryIntelligenceImportCommitService commits, IStoryIntelligenceCharacterImportService characterImport, + IStoryIntelligencePipelineStateService pipelineState, IStoryIntelligenceClient client, IOnboardingStoryIntelligenceBatchStore batchStore, IStoryIntelligenceProgressNotifier notifier, @@ -397,6 +399,64 @@ public sealed class OnboardingStoryIntelligenceService( }; } + public async Task ResumeBookAsync(int bookId) + { + var userId = RequireUserId(); + var state = await pipelineState.GetForBookAsync(bookId, userId); + if (state is null) + { + return null; + } + + var committedRuns = await pipelineState.ListCommittedRunsByBookAsync(bookId, userId); + if (committedRuns.Count == 0) + { + return null; + } + + var batch = new OnboardingStoryIntelligenceBatch + { + UserID = userId, + OnboardingID = 0, + PreviewID = Guid.Empty, + ProjectID = state.ProjectID, + BookID = state.BookID, + ProjectName = state.ProjectName, + BookTitle = state.BookDisplayTitle, + Items = committedRuns + .OrderBy(run => run.ChapterNumber) + .ThenBy(run => run.StoryIntelligenceRunID) + .Select(run => new OnboardingStoryIntelligenceBatchItem + { + TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID}", + ChapterNumber = Convert.ToInt32(run.ChapterNumber), + ChapterTitle = run.ChapterTitle, + ChapterID = run.ChapterID, + RunID = run.StoryIntelligenceRunID + }) + .ToList() + }; + + if (state.IsComplete) + { + batch.CharacterStageComplete = true; + } + + await batchStore.SaveAsync(batch); + + var route = state.CurrentStage switch + { + StoryIntelligencePipelineStages.Complete => StoryIntelligenceResumeRoutes.Complete, + StoryIntelligencePipelineStages.CharacterReview => StoryIntelligenceResumeRoutes.Characters, + StoryIntelligencePipelineStages.CharacterImport => StoryIntelligenceResumeRoutes.Characters, + StoryIntelligencePipelineStages.SceneReview => StoryIntelligenceResumeRoutes.SceneReview, + StoryIntelligencePipelineStages.SceneImport => StoryIntelligenceResumeRoutes.SceneReview, + _ => StoryIntelligenceResumeRoutes.SceneReview + }; + + return new OnboardingStoryIntelligenceResumeTarget(batch.BatchID, route); + } + private async Task<(UserOnboardingState State, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, OnboardingManuscriptBuildResult? Build, OnboardingWizardViewModel Wizard)?> GetContextAsync(int userId) { var state = await onboardingRepository.GetAsync(userId); @@ -655,3 +715,12 @@ public sealed class StoryIntelligenceCharacterImportBatchResult public int PovLinksResolved { get; init; } public int ScenePeoplePanelsUpdated { get; init; } } + +public static class StoryIntelligenceResumeRoutes +{ + public const string SceneReview = "SceneReview"; + public const string Characters = "Characters"; + public const string Complete = "Complete"; +} + +public sealed record OnboardingStoryIntelligenceResumeTarget(Guid BatchID, string Route); diff --git a/PlotLine/Services/StoryIntelligenceCharacterImportService.cs b/PlotLine/Services/StoryIntelligenceCharacterImportService.cs index 049175b..bdb89dc 100644 --- a/PlotLine/Services/StoryIntelligenceCharacterImportService.cs +++ b/PlotLine/Services/StoryIntelligenceCharacterImportService.cs @@ -15,6 +15,7 @@ public sealed class StoryIntelligenceCharacterImportService( IStoryIntelligenceResultRepository runs, ICharacterRepository characters, ISceneRepository scenes, + IStoryIntelligencePipelineStateService pipelineState, ILogger logger) : IStoryIntelligenceCharacterImportService { private static readonly JsonSerializerOptions JsonOptions = new() @@ -168,6 +169,7 @@ public sealed class StoryIntelligenceCharacterImportService( PovLinksResolved = povLinks, ScenePeoplePanelsUpdated = linkedAppearances }; + await pipelineState.RecordCharacterImportAsync(batch.ProjectID, batch.BookID); logger.LogInformation( "Imported Story Intelligence characters for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Ignored={Ignored} LinkedAppearances={LinkedAppearances} PovLinks={PovLinks}", diff --git a/PlotLine/Services/StoryIntelligenceImportCommitService.cs b/PlotLine/Services/StoryIntelligenceImportCommitService.cs index fe9b3c7..4d21ae8 100644 --- a/PlotLine/Services/StoryIntelligenceImportCommitService.cs +++ b/PlotLine/Services/StoryIntelligenceImportCommitService.cs @@ -20,6 +20,7 @@ public sealed class StoryIntelligenceImportCommitService( ISceneMetricTypeRepository metricTypes, IWriterWorkspaceRepository writerWorkspace, ICharacterRepository characters, + IStoryIntelligencePipelineStateService pipelineState, ILogger logger) : IStoryIntelligenceImportCommitService { private static readonly JsonSerializerOptions JsonOptions = new() @@ -97,6 +98,11 @@ public sealed class StoryIntelligenceImportCommitService( result.ScenesCreated, result.MetricsCreated); + if (result.Success && prepared.Run.ProjectID.HasValue && prepared.Run.BookID.HasValue) + { + await pipelineState.RecordSceneImportAsync(prepared.Run.ProjectID.Value, prepared.Run.BookID.Value, prepared.Run.StoryIntelligenceRunID); + } + return result; } diff --git a/PlotLine/Services/StoryIntelligencePipelineStateService.cs b/PlotLine/Services/StoryIntelligencePipelineStateService.cs new file mode 100644 index 0000000..9ec1961 --- /dev/null +++ b/PlotLine/Services/StoryIntelligencePipelineStateService.cs @@ -0,0 +1,94 @@ +using PlotLine.Data; +using PlotLine.Models; + +namespace PlotLine.Services; + +public interface IStoryIntelligencePipelineStateService +{ + Task GetForBookAsync(int bookId, int userId); + Task> ListForUserAsync(int userId); + Task EnsureForBookAsync(int bookId, int userId); + Task RecordSceneImportAsync(int projectId, int bookId, int runId); + Task RecordCharacterImportAsync(int projectId, int bookId); + Task> ListCommittedRunsByBookAsync(int bookId, int userId); +} + +public sealed class StoryIntelligencePipelineStateService( + IStoryIntelligencePipelineRepository pipelines, + ILogger logger) : IStoryIntelligencePipelineStateService +{ + public async Task GetForBookAsync(int bookId, int userId) + => await pipelines.GetByBookForUserAsync(bookId, userId) + ?? await EnsureForBookAsync(bookId, userId); + + public Task> ListForUserAsync(int userId) + => pipelines.ListForUserAsync(userId); + + public async Task EnsureForBookAsync(int bookId, int userId) + { + var existing = await pipelines.GetByBookForUserAsync(bookId, userId); + if (existing is not null) + { + return existing; + } + + var committedRuns = await pipelines.ListCommittedRunsByBookAsync(bookId, userId); + if (committedRuns.Count == 0) + { + return null; + } + + var latestRun = committedRuns.OrderByDescending(run => run.StoryIntelligenceRunID).First(); + var created = await pipelines.UpsertAsync(new StoryIntelligenceBookPipelineSaveRequest + { + ProjectID = latestRun.ProjectID, + BookID = latestRun.BookID, + CurrentStage = StoryIntelligencePipelineStages.CharacterReview, + LastCompletedStage = StoryIntelligencePipelineStages.SceneImport, + CurrentReviewStage = StoryIntelligencePipelineStages.CharacterReview, + Status = StoryIntelligencePipelineStatuses.NeedsReview, + CompletedUtc = null, + LastRunID = latestRun.StoryIntelligenceRunID + }); + + logger.LogInformation( + "Created Story Intelligence pipeline state for existing imported book {BookID}. ProjectID={ProjectID} CurrentStage={CurrentStage}", + bookId, + created.ProjectID, + created.CurrentStage); + return created; + } + + public async Task RecordSceneImportAsync(int projectId, int bookId, int runId) + { + await pipelines.UpsertAsync(new StoryIntelligenceBookPipelineSaveRequest + { + ProjectID = projectId, + BookID = bookId, + CurrentStage = StoryIntelligencePipelineStages.CharacterReview, + LastCompletedStage = StoryIntelligencePipelineStages.SceneImport, + CurrentReviewStage = StoryIntelligencePipelineStages.CharacterReview, + Status = StoryIntelligencePipelineStatuses.NeedsReview, + CompletedUtc = null, + LastRunID = runId + }); + } + + public async Task RecordCharacterImportAsync(int projectId, int bookId) + { + await pipelines.UpsertAsync(new StoryIntelligenceBookPipelineSaveRequest + { + ProjectID = projectId, + BookID = bookId, + CurrentStage = StoryIntelligencePipelineStages.Complete, + LastCompletedStage = StoryIntelligencePipelineStages.CharacterImport, + CurrentReviewStage = null, + Status = StoryIntelligencePipelineStatuses.Complete, + CompletedUtc = DateTime.UtcNow, + LastRunID = null + }); + } + + public Task> ListCommittedRunsByBookAsync(int bookId, int userId) + => pipelines.ListCommittedRunsByBookAsync(bookId, userId); +} diff --git a/PlotLine/Services/StoryIntelligenceService.cs b/PlotLine/Services/StoryIntelligenceService.cs index 1fcf5f4..659f027 100644 --- a/PlotLine/Services/StoryIntelligenceService.cs +++ b/PlotLine/Services/StoryIntelligenceService.cs @@ -69,6 +69,7 @@ public sealed class StubStoryIntelligenceProvider : IStoryIntelligenceProvider public sealed class StoryIntelligenceService( IStoryIntelligenceRepository repository, IStoryIntelligenceResultRepository persistedRuns, + IStoryIntelligencePipelineStateService pipelineState, IOnboardingStoryIntelligenceBatchStore onboardingBatches, IOnboardingRepository onboarding, IProjectRepository projects, @@ -148,6 +149,9 @@ public sealed class StoryIntelligenceService( return new StoryIntelligenceDashboardViewModel(); } + var pipelineBooks = (await pipelineState.ListForUserAsync(currentUser.UserId.Value)) + .Select(ToPipelineBookStatus) + .ToList(); var activeBatch = await onboardingBatches.GetLatestActiveForUserAsync(currentUser.UserId.Value); if (activeBatch is not null) { @@ -178,11 +182,27 @@ public sealed class StoryIntelligenceService( ActiveImportProgressLabel = $"Chapter {currentChapter:N0} of {activeBatch.Items.Count:N0}", Title = "Story Intelligence is reading", Description = "PlotDirector is analysing your manuscript in the background.", - ButtonText = "View progress" + ButtonText = "View progress", + PipelineBooks = pipelineBooks }; } } + var nextPipeline = pipelineBooks.FirstOrDefault(item => !item.IsComplete) ?? pipelineBooks.FirstOrDefault(); + if (nextPipeline is not null) + { + return new StoryIntelligenceDashboardViewModel + { + ShouldShow = true, + Title = nextPipeline.IsComplete ? "Story Intelligence Complete" : "Continue Story Intelligence", + Description = nextPipeline.IsComplete + ? $"{nextPipeline.BookTitle} has completed the currently available Story Intelligence stages." + : $"{nextPipeline.BookTitle} is waiting at the next Story Intelligence review stage.", + ButtonText = nextPipeline.IsComplete ? "View summary" : "Continue Story Intelligence", + PipelineBooks = pipelineBooks + }; + } + var state = await onboarding.GetAsync(currentUser.UserId.Value); return state?.ProjectID.HasValue == true && state.BookID.HasValue ? new StoryIntelligenceDashboardViewModel @@ -190,7 +210,8 @@ public sealed class StoryIntelligenceService( ShouldShow = true, Title = "Import your manuscript", Description = "Scan your manuscript with the Word Companion, review the chapters, then let PlotDirector prepare scene suggestions.", - ButtonText = "Continue manuscript import" + ButtonText = "Continue manuscript import", + PipelineBooks = pipelineBooks } : new StoryIntelligenceDashboardViewModel(); } @@ -229,6 +250,43 @@ public sealed class StoryIntelligenceService( private static string BuildBookTitle(Book? book) => book is null ? "Selected book" : $"Book {book.BookNumber}: {book.BookDisplayTitle}"; + private static StoryIntelligencePipelineBookStatusViewModel ToPipelineBookStatus(StoryIntelligenceBookPipelineState state) + => new() + { + BookID = state.BookID, + BookTitle = state.BookDisplayTitle, + StatusLabel = PipelineStatusLabel(state), + Description = PipelineDescription(state), + IsComplete = state.IsComplete + }; + + private static string PipelineStatusLabel(StoryIntelligenceBookPipelineState state) + { + if (state.IsComplete) + { + return "Story Intelligence Complete"; + } + + return state.CurrentReviewStage switch + { + StoryIntelligencePipelineStages.CharacterReview => "Waiting for Character Review", + StoryIntelligencePipelineStages.SceneReview => "Needs Scene Review", + _ => string.Equals(state.Status, StoryIntelligencePipelineStatuses.InProgress, StringComparison.OrdinalIgnoreCase) + ? "Story Intelligence In Progress" + : "Story Intelligence Needs Review" + }; + } + + private static string PipelineDescription(StoryIntelligenceBookPipelineState state) + => state.IsComplete + ? "All currently available Story Intelligence stages are complete." + : state.CurrentReviewStage switch + { + StoryIntelligencePipelineStages.CharacterReview => "Review detected characters and decide what to create or link.", + StoryIntelligencePipelineStages.SceneReview => "Review prepared scenes before creating them.", + _ => "Continue from the next Story Intelligence stage." + }; + private static string FormatElapsed(TimeSpan elapsed) => elapsed.TotalMinutes < 1 ? $"{Math.Max(0, (int)elapsed.TotalSeconds)} sec" diff --git a/PlotLine/Sql/127_Phase20AJ_StoryIntelligencePipelineState.sql b/PlotLine/Sql/127_Phase20AJ_StoryIntelligencePipelineState.sql new file mode 100644 index 0000000..6a8362f --- /dev/null +++ b/PlotLine/Sql/127_Phase20AJ_StoryIntelligencePipelineState.sql @@ -0,0 +1,208 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +IF OBJECT_ID(N'dbo.StoryIntelligenceBookPipelines', N'U') IS NULL +BEGIN + CREATE TABLE dbo.StoryIntelligenceBookPipelines + ( + StoryIntelligenceBookPipelineID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryIntelligenceBookPipelines PRIMARY KEY, + ProjectID int NOT NULL, + BookID int NOT NULL, + CurrentStage nvarchar(80) NOT NULL, + LastCompletedStage nvarchar(80) NULL, + CurrentReviewStage nvarchar(80) NULL, + Status nvarchar(40) NOT NULL, + CompletedUtc datetime2 NULL, + LastRunID int NULL, + CreatedUtc datetime2 NOT NULL CONSTRAINT DF_StoryIntelligenceBookPipelines_CreatedUtc DEFAULT SYSUTCDATETIME(), + UpdatedUtc datetime2 NOT NULL CONSTRAINT DF_StoryIntelligenceBookPipelines_UpdatedUtc DEFAULT SYSUTCDATETIME(), + CONSTRAINT FK_StoryIntelligenceBookPipelines_Projects FOREIGN KEY (ProjectID) REFERENCES dbo.Projects(ProjectID), + CONSTRAINT FK_StoryIntelligenceBookPipelines_Books FOREIGN KEY (BookID) REFERENCES dbo.Books(BookID), + CONSTRAINT FK_StoryIntelligenceBookPipelines_LastRun FOREIGN KEY (LastRunID) REFERENCES dbo.StoryIntelligenceRuns(StoryIntelligenceRunID), + CONSTRAINT CK_StoryIntelligenceBookPipelines_Status CHECK (Status IN (N'InProgress', N'NeedsReview', N'Complete')), + CONSTRAINT CK_StoryIntelligenceBookPipelines_CurrentStage CHECK (CurrentStage IN (N'Chapters', N'SceneReview', N'SceneImport', N'CharacterReview', N'CharacterImport', N'Complete')) + ); +END; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_StoryIntelligenceBookPipelines_Book' AND object_id = OBJECT_ID(N'dbo.StoryIntelligenceBookPipelines')) + CREATE UNIQUE INDEX UX_StoryIntelligenceBookPipelines_Book ON dbo.StoryIntelligenceBookPipelines(BookID); +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_StoryIntelligenceBookPipelines_Project_Status' AND object_id = OBJECT_ID(N'dbo.StoryIntelligenceBookPipelines')) + CREATE INDEX IX_StoryIntelligenceBookPipelines_Project_Status ON dbo.StoryIntelligenceBookPipelines(ProjectID, Status, UpdatedUtc DESC); +GO + +;WITH LatestCommittedBookRun AS +( + SELECT CAST(r.ProjectID AS int) AS ProjectID, + CAST(r.BookID AS int) AS BookID, + r.StoryIntelligenceRunID, + ROW_NUMBER() OVER (PARTITION BY r.BookID ORDER BY r.StoryIntelligenceRunID DESC) AS RowNumber + FROM dbo.StoryIntelligenceRuns r + INNER JOIN dbo.StoryIntelligenceImportCommits commitRows ON commitRows.StoryIntelligenceRunID = r.StoryIntelligenceRunID + AND commitRows.Status = N'Completed' + INNER JOIN dbo.Books b ON b.BookID = r.BookID + INNER JOIN dbo.Projects p ON p.ProjectID = r.ProjectID + WHERE r.ProjectID IS NOT NULL + AND r.BookID IS NOT NULL + AND b.IsArchived = 0 + AND p.IsArchived = 0 +) +INSERT dbo.StoryIntelligenceBookPipelines + (ProjectID, BookID, CurrentStage, LastCompletedStage, CurrentReviewStage, Status, CompletedUtc, LastRunID) +SELECT source.ProjectID, + source.BookID, + N'CharacterReview', + N'SceneImport', + N'CharacterReview', + N'NeedsReview', + NULL, + source.StoryIntelligenceRunID +FROM LatestCommittedBookRun source +WHERE source.RowNumber = 1 + AND NOT EXISTS + ( + SELECT 1 + FROM dbo.StoryIntelligenceBookPipelines existing + WHERE existing.BookID = source.BookID + ); +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceBookPipeline_GetByBook + @BookID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT p.StoryIntelligenceBookPipelineID, p.ProjectID, project.ProjectName, + p.BookID, book.BookTitle, book.Subtitle AS BookSubtitle, + p.CurrentStage, p.LastCompletedStage, p.CurrentReviewStage, p.Status, + p.CompletedUtc, p.LastRunID, p.CreatedUtc, p.UpdatedUtc + FROM dbo.StoryIntelligenceBookPipelines p + INNER JOIN dbo.Projects project ON project.ProjectID = p.ProjectID + INNER JOIN dbo.Books book ON book.BookID = p.BookID + WHERE p.BookID = @BookID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceBookPipeline_Upsert + @ProjectID int, + @BookID int, + @CurrentStage nvarchar(80), + @LastCompletedStage nvarchar(80) = NULL, + @CurrentReviewStage nvarchar(80) = NULL, + @Status nvarchar(40), + @CompletedUtc datetime2 = NULL, + @LastRunID int = NULL +AS +BEGIN + SET NOCOUNT ON; + + IF EXISTS (SELECT 1 FROM dbo.StoryIntelligenceBookPipelines WHERE BookID = @BookID) + BEGIN + UPDATE dbo.StoryIntelligenceBookPipelines + SET ProjectID = @ProjectID, + CurrentStage = @CurrentStage, + LastCompletedStage = @LastCompletedStage, + CurrentReviewStage = @CurrentReviewStage, + Status = @Status, + CompletedUtc = @CompletedUtc, + LastRunID = COALESCE(@LastRunID, LastRunID), + UpdatedUtc = SYSUTCDATETIME() + WHERE BookID = @BookID; + END + ELSE + BEGIN + INSERT dbo.StoryIntelligenceBookPipelines + (ProjectID, BookID, CurrentStage, LastCompletedStage, CurrentReviewStage, Status, CompletedUtc, LastRunID) + VALUES + (@ProjectID, @BookID, @CurrentStage, @LastCompletedStage, @CurrentReviewStage, @Status, @CompletedUtc, @LastRunID); + END; + + EXEC dbo.StoryIntelligenceBookPipeline_GetByBook @BookID = @BookID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceBookPipeline_GetByBookForUser + @BookID int, + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT p.StoryIntelligenceBookPipelineID, p.ProjectID, project.ProjectName, + p.BookID, book.BookTitle, book.Subtitle AS BookSubtitle, + p.CurrentStage, p.LastCompletedStage, p.CurrentReviewStage, p.Status, + p.CompletedUtc, p.LastRunID, p.CreatedUtc, p.UpdatedUtc + FROM dbo.StoryIntelligenceBookPipelines p + INNER JOIN dbo.Projects project ON project.ProjectID = p.ProjectID + INNER JOIN dbo.Books book ON book.BookID = p.BookID + INNER JOIN dbo.ProjectUserAccess access ON access.ProjectID = p.ProjectID + AND access.UserID = @UserID + AND access.IsActive = 1 + WHERE p.BookID = @BookID + AND book.IsArchived = 0 + AND project.IsArchived = 0; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceBookPipeline_ListForUser + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT p.StoryIntelligenceBookPipelineID, p.ProjectID, project.ProjectName, + p.BookID, book.BookTitle, book.Subtitle AS BookSubtitle, + p.CurrentStage, p.LastCompletedStage, p.CurrentReviewStage, p.Status, + p.CompletedUtc, p.LastRunID, p.CreatedUtc, p.UpdatedUtc + FROM dbo.StoryIntelligenceBookPipelines p + INNER JOIN dbo.Projects project ON project.ProjectID = p.ProjectID + INNER JOIN dbo.Books book ON book.BookID = p.BookID + INNER JOIN dbo.ProjectUserAccess access ON access.ProjectID = p.ProjectID + AND access.UserID = @UserID + AND access.IsActive = 1 + WHERE book.IsArchived = 0 + AND project.IsArchived = 0 + ORDER BY + CASE p.Status WHEN N'NeedsReview' THEN 0 WHEN N'InProgress' THEN 1 ELSE 2 END, + p.UpdatedUtc DESC; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceBookPipeline_ListCommittedRunsByBook + @BookID int, + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT r.StoryIntelligenceRunID, r.UserID, + CAST(r.ProjectID AS int) AS ProjectID, project.ProjectName, + CAST(r.BookID AS int) AS BookID, book.BookTitle, book.Subtitle AS BookSubtitle, + CAST(r.ChapterID AS int) AS ChapterID, + CAST(COALESCE(r.ChapterNumber, chapter.ChapterNumber) AS decimal(10,2)) AS ChapterNumber, + chapter.ChapterTitle, + r.SourceLabel + FROM dbo.StoryIntelligenceRuns r + INNER JOIN dbo.StoryIntelligenceImportCommits commitRows ON commitRows.StoryIntelligenceRunID = r.StoryIntelligenceRunID + AND commitRows.Status = N'Completed' + INNER JOIN dbo.Books book ON book.BookID = r.BookID + INNER JOIN dbo.Projects project ON project.ProjectID = r.ProjectID + INNER JOIN dbo.Chapters chapter ON chapter.ChapterID = r.ChapterID + INNER JOIN dbo.ProjectUserAccess access ON access.ProjectID = project.ProjectID + AND access.UserID = @UserID + AND access.IsActive = 1 + WHERE r.BookID = @BookID + AND r.ProjectID IS NOT NULL + AND r.ChapterID IS NOT NULL + AND r.BookID IS NOT NULL + AND book.IsArchived = 0 + AND project.IsArchived = 0 + AND chapter.IsArchived = 0 + ORDER BY chapter.SortOrder, chapter.ChapterNumber, r.StoryIntelligenceRunID; +END; +GO diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index 191ccf2..bc82c92 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -244,6 +244,7 @@ public sealed class BookDetailViewModel public Book Book { get; set; } = new(); public ManuscriptDocumentModel? ManuscriptDocument { get; set; } public IReadOnlyList Chapters { get; set; } = []; + public StoryIntelligenceBookPipelineState? StoryIntelligencePipeline { get; set; } } public sealed class ChapterEditViewModel diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs index f8c1b03..0150c90 100644 --- a/PlotLine/ViewModels/OnboardingViewModels.cs +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -294,7 +294,18 @@ public sealed class StoryIntelligenceDashboardViewModel public Guid? ActiveBatchID { get; init; } public string? ActiveImportBookTitle { get; init; } public string? ActiveImportProgressLabel { get; init; } + public IReadOnlyList PipelineBooks { get; init; } = []; public bool HasActiveImport => ActiveBatchID.HasValue; + public bool HasPipelineBooks => PipelineBooks.Count > 0; +} + +public sealed class StoryIntelligencePipelineBookStatusViewModel +{ + public int BookID { get; init; } + public string BookTitle { get; init; } = string.Empty; + public string StatusLabel { get; init; } = "Story Intelligence Needs Review"; + public string Description { get; init; } = string.Empty; + public bool IsComplete { get; init; } } public sealed class CompanionPresenceViewModel diff --git a/PlotLine/Views/Books/Details.cshtml b/PlotLine/Views/Books/Details.cshtml index 239c20b..725f074 100644 --- a/PlotLine/Views/Books/Details.cshtml +++ b/PlotLine/Views/Books/Details.cshtml @@ -58,6 +58,24 @@ +@if (Model.StoryIntelligencePipeline is not null) +{ + var pipeline = Model.StoryIntelligencePipeline; +
+
+
+

Story Intelligence

+

@(pipeline.IsComplete ? "Story Intelligence Complete" : "Continue Story Intelligence")

+

@StoryIntelligenceDescription(pipeline)

+
+ @(pipeline.IsComplete ? "View Story Intelligence Summary" : "Continue Story Intelligence") +
+
+} +

Book metadata

@@ -171,3 +189,20 @@
}
+ +@functions { + private static string StoryIntelligenceDescription(StoryIntelligenceBookPipelineState pipeline) + { + if (pipeline.IsComplete) + { + return "All currently available Story Intelligence stages are complete."; + } + + return pipeline.CurrentReviewStage switch + { + StoryIntelligencePipelineStages.CharacterReview => "Scene creation is complete. Review detected characters to continue building the story database.", + StoryIntelligencePipelineStages.SceneReview => "Review prepared scenes before creating them in PlotDirector.", + _ => "PlotDirector will resume from the next Story Intelligence stage." + }; + } +} diff --git a/PlotLine/Views/Projects/Index.cshtml b/PlotLine/Views/Projects/Index.cshtml index d27d10e..3c7b174 100644 --- a/PlotLine/Views/Projects/Index.cshtml +++ b/PlotLine/Views/Projects/Index.cshtml @@ -52,6 +52,32 @@ asp-route-batchId="@Model.StoryIntelligence.ActiveBatchID">View progress } +else if (Model.StoryIntelligence.HasPipelineBooks) +{ + var primaryPipelineBook = Model.StoryIntelligence.PipelineBooks.FirstOrDefault(item => !item.IsComplete) + ?? Model.StoryIntelligence.PipelineBooks.First(); +
+
+

Story Intelligence

+

@Model.StoryIntelligence.Title

+

@Model.StoryIntelligence.Description

+
+ @foreach (var item in Model.StoryIntelligence.PipelineBooks.Take(3)) + { +
+ @item.BookTitle + @item.StatusLabel +
+ } +
+
+ @Model.StoryIntelligence.ButtonText +
+} @if (TempData["ArchiveMessage"] is string archiveMessage) { diff --git a/PlotLine/wwwroot/css/onboarding.css b/PlotLine/wwwroot/css/onboarding.css index 2755379..bc064e9 100644 --- a/PlotLine/wwwroot/css/onboarding.css +++ b/PlotLine/wwwroot/css/onboarding.css @@ -1261,6 +1261,24 @@ summary.story-review-chapter-heading { color: var(--bs-secondary-color); } +.story-intelligence-status-list { + display: grid; + gap: .35rem; + margin-top: .7rem; +} + +.story-intelligence-status-list__item { + display: flex; + flex-wrap: wrap; + gap: .35rem .75rem; + align-items: baseline; +} + +.story-intelligence-status-list__item span { + color: var(--bs-secondary-color); + font-size: .9rem; +} + .story-intelligence-dashboard-progress { display: grid; grid-template-columns: auto auto;