From b8541dbbc0732e1986e41bd08b0c475d7dc18a60 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sat, 4 Jul 2026 12:59:22 +0100 Subject: [PATCH] Phase 20H Implement the Story Intelligence framework. --- PlotLine/Controllers/OnboardingController.cs | 60 +++- PlotLine/Data/StoryIntelligenceRepository.cs | 106 ++++++ PlotLine/Hubs/StoryIntelligenceHub.cs | 36 +++ PlotLine/Models/StoryIntelligenceModels.cs | 66 ++++ PlotLine/Program.cs | 5 + PlotLine/Services/CoreServices.cs | 4 +- PlotLine/Services/StoryIntelligenceService.cs | 294 +++++++++++++++++ ...14_Phase20H_StoryIntelligenceFramework.sql | 304 ++++++++++++++++++ PlotLine/ViewModels/CoreViewModels.cs | 1 + PlotLine/ViewModels/OnboardingViewModels.cs | 35 ++ .../Views/Onboarding/BuildComplete.cshtml | 2 +- .../Views/Onboarding/StoryIntelligence.cshtml | 70 ++++ .../StoryIntelligenceComplete.cshtml | 47 +++ .../StoryIntelligenceProgress.cshtml | 60 ++++ PlotLine/Views/Projects/Index.cshtml | 37 +++ PlotLine/wwwroot/css/onboarding.css | 102 +++++- .../wwwroot/js/story-intelligence-progress.js | 78 +++++ 17 files changed, 1303 insertions(+), 4 deletions(-) create mode 100644 PlotLine/Data/StoryIntelligenceRepository.cs create mode 100644 PlotLine/Hubs/StoryIntelligenceHub.cs create mode 100644 PlotLine/Models/StoryIntelligenceModels.cs create mode 100644 PlotLine/Services/StoryIntelligenceService.cs create mode 100644 PlotLine/Sql/114_Phase20H_StoryIntelligenceFramework.sql create mode 100644 PlotLine/Views/Onboarding/StoryIntelligence.cshtml create mode 100644 PlotLine/Views/Onboarding/StoryIntelligenceComplete.cshtml create mode 100644 PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml create mode 100644 PlotLine/wwwroot/js/story-intelligence-progress.js diff --git a/PlotLine/Controllers/OnboardingController.cs b/PlotLine/Controllers/OnboardingController.cs index 92b1f4d..acd2971 100644 --- a/PlotLine/Controllers/OnboardingController.cs +++ b/PlotLine/Controllers/OnboardingController.cs @@ -8,7 +8,7 @@ namespace PlotLine.Controllers; [Authorize] [Route("onboarding")] -public sealed class OnboardingController(IOnboardingService onboarding) : Controller +public sealed class OnboardingController(IOnboardingService onboarding, IStoryIntelligenceService storyIntelligence) : Controller { [HttpGet("")] public async Task Index() @@ -31,6 +31,64 @@ public sealed class OnboardingController(IOnboardingService onboarding) : Contro return model is null ? NotFound() : View(model); } + [HttpGet("story-intelligence")] + public async Task StoryIntelligence() + { + var model = await storyIntelligence.GetOverviewAsync(); + return View(model); + } + + [HttpPost("story-intelligence/start")] + [ValidateAntiForgeryToken] + public async Task StartStoryIntelligence() + { + var job = await storyIntelligence.StartAsync(); + if (job is null) + { + TempData["ArchiveError"] = "Choose a project and book before starting Story Intelligence."; + return RedirectToAction(nameof(Index)); + } + + return RedirectToAction(nameof(StoryIntelligenceProgress), new { jobId = job.JobID }); + } + + [HttpPost("story-intelligence/skip")] + [ValidateAntiForgeryToken] + public async Task SkipStoryIntelligence() + { + await onboarding.MarkCompletedAsync(); + TempData["ArchiveMessage"] = "Story Intelligence skipped for now. You can return to it from your dashboard."; + return RedirectToAction("Index", "Projects"); + } + + [HttpGet("story-intelligence/progress")] + public async Task StoryIntelligenceProgress(Guid jobId) + { + var model = await storyIntelligence.GetProgressAsync(jobId); + return model is null ? NotFound() : View(model); + } + + [HttpPost("story-intelligence/cancel")] + [ValidateAntiForgeryToken] + public async Task CancelStoryIntelligence(Guid jobId) + { + var job = await storyIntelligence.CancelAsync(jobId); + if (job is null) + { + return NotFound(); + } + + TempData["ArchiveMessage"] = "Story Intelligence setup was cancelled."; + return RedirectToAction("Index", "Projects"); + } + + [HttpGet("story-intelligence/complete")] + public async Task StoryIntelligenceComplete(Guid jobId) + { + var model = await storyIntelligence.GetCompletionAsync(jobId); + return model is null ? NotFound() : View(model); + } + [HttpPost("scan-review")] [ValidateAntiForgeryToken] public async Task SaveScanReview(ManuscriptScanReviewForm form, string intent = "save") diff --git a/PlotLine/Data/StoryIntelligenceRepository.cs b/PlotLine/Data/StoryIntelligenceRepository.cs new file mode 100644 index 0000000..1b783b5 --- /dev/null +++ b/PlotLine/Data/StoryIntelligenceRepository.cs @@ -0,0 +1,106 @@ +using System.Data; +using Dapper; +using PlotLine.Models; + +namespace PlotLine.Data; + +public interface IStoryIntelligenceRepository +{ + Task CreateWithConsentAsync(int userId, int onboardingStateId, int projectId, int bookId, string consentText); + Task GetAsync(int userId, Guid jobId); + Task GetLatestForUserAsync(int userId); + Task ClaimNextPendingAsync(); + Task UpdateProgressAsync(Guid jobId, string status, int progressPercent, string currentStage, string currentMessage, string? estimatedRemaining); + Task CompleteAsync(Guid jobId); + Task CancelAsync(int userId, Guid jobId); + Task FailAsync(Guid jobId, string errorMessage); +} + +public sealed class StoryIntelligenceRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligenceRepository +{ + public async Task CreateWithConsentAsync(int userId, int onboardingStateId, int projectId, int bookId, string consentText) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.StoryIntelligenceJob_CreateWithConsent", + new + { + UserID = userId, + UserOnboardingStateID = onboardingStateId, + ProjectID = projectId, + BookID = bookId, + ConsentText = consentText + }, + commandType: CommandType.StoredProcedure); + } + + public async Task GetAsync(int userId, Guid jobId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.StoryIntelligenceJob_Get", + new { UserID = userId, JobID = jobId }, + commandType: CommandType.StoredProcedure); + } + + public async Task GetLatestForUserAsync(int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.StoryIntelligenceJob_GetLatestForUser", + new { UserID = userId }, + commandType: CommandType.StoredProcedure); + } + + public async Task ClaimNextPendingAsync() + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.StoryIntelligenceJob_ClaimNextPending", + commandType: CommandType.StoredProcedure); + } + + public async Task UpdateProgressAsync(Guid jobId, string status, int progressPercent, string currentStage, string currentMessage, string? estimatedRemaining) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.StoryIntelligenceJob_UpdateProgress", + new + { + JobID = jobId, + Status = status, + ProgressPercent = progressPercent, + CurrentStage = currentStage, + CurrentMessage = currentMessage, + EstimatedRemaining = estimatedRemaining + }, + commandType: CommandType.StoredProcedure); + } + + public async Task CompleteAsync(Guid jobId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.StoryIntelligenceJob_Complete", + new { JobID = jobId }, + commandType: CommandType.StoredProcedure); + } + + public async Task CancelAsync(int userId, Guid jobId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.StoryIntelligenceJob_Cancel", + new { UserID = userId, JobID = jobId }, + commandType: CommandType.StoredProcedure); + } + + public async Task FailAsync(Guid jobId, string errorMessage) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.StoryIntelligenceJob_Fail", + new { JobID = jobId, ErrorMessage = errorMessage }, + commandType: CommandType.StoredProcedure); + } +} diff --git a/PlotLine/Hubs/StoryIntelligenceHub.cs b/PlotLine/Hubs/StoryIntelligenceHub.cs new file mode 100644 index 0000000..cbabaeb --- /dev/null +++ b/PlotLine/Hubs/StoryIntelligenceHub.cs @@ -0,0 +1,36 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; +using PlotLine.Models; +using PlotLine.Services; + +namespace PlotLine.Hubs; + +[Authorize] +public sealed class StoryIntelligenceHub(IStoryIntelligenceService storyIntelligence) : Hub +{ + public override async Task OnConnectedAsync() + { + if (TryGetUserId(out var userId)) + { + await Groups.AddToGroupAsync(Context.ConnectionId, UserGroup(userId)); + } + + await base.OnConnectedAsync(); + } + + public async Task WatchStoryIntelligenceJob(Guid jobId) + { + var userId = RequireUserId(); + await Groups.AddToGroupAsync(Context.ConnectionId, UserGroup(userId)); + return await storyIntelligence.GetProgressForUserAsync(userId, jobId); + } + + public static string UserGroup(int userId) => $"story-intelligence:{userId}"; + + private int RequireUserId() + => TryGetUserId(out var userId) ? userId : throw new HubException("Sign in to use Story Intelligence."); + + private bool TryGetUserId(out int userId) + => int.TryParse(Context.User?.FindFirstValue(ClaimTypes.NameIdentifier), out userId); +} diff --git a/PlotLine/Models/StoryIntelligenceModels.cs b/PlotLine/Models/StoryIntelligenceModels.cs new file mode 100644 index 0000000..7fb3a7b --- /dev/null +++ b/PlotLine/Models/StoryIntelligenceModels.cs @@ -0,0 +1,66 @@ +namespace PlotLine.Models; + +public static class StoryIntelligenceJobStatuses +{ + public const string Pending = "Pending"; + public const string Preparing = "Preparing"; + public const string ReadingScenes = "ReadingScenes"; + public const string AnalysingCharacters = "AnalysingCharacters"; + public const string AnalysingRelationships = "AnalysingRelationships"; + public const string AnalysingLocations = "AnalysingLocations"; + public const string AnalysingAssets = "AnalysingAssets"; + public const string AnalysingTimeline = "AnalysingTimeline"; + public const string Finalising = "Finalising"; + public const string Completed = "Completed"; + public const string Cancelled = "Cancelled"; + public const string Failed = "Failed"; +} + +public sealed class StoryIntelligenceJob +{ + public Guid JobID { get; init; } + public int ConsentID { get; init; } + public int UserID { get; init; } + public int ProjectID { get; init; } + public int BookID { get; init; } + public string Status { get; init; } = StoryIntelligenceJobStatuses.Pending; + public DateTime CreatedUtc { get; init; } + public DateTime? StartedUtc { get; init; } + public DateTime? CompletedUtc { get; init; } + public DateTime? CancelledUtc { get; init; } + public int ProgressPercent { get; init; } + public string CurrentStage { get; init; } = StoryIntelligenceJobStatuses.Pending; + public string CurrentMessage { get; init; } = "Story Intelligence is queued."; + public string? EstimatedRemaining { get; init; } + public string? ErrorMessage { get; init; } + public DateTime UpdatedUtc { get; init; } + public bool IsActive => Status is StoryIntelligenceJobStatuses.Pending + or StoryIntelligenceJobStatuses.Preparing + or StoryIntelligenceJobStatuses.ReadingScenes + or StoryIntelligenceJobStatuses.AnalysingCharacters + or StoryIntelligenceJobStatuses.AnalysingRelationships + or StoryIntelligenceJobStatuses.AnalysingLocations + or StoryIntelligenceJobStatuses.AnalysingAssets + or StoryIntelligenceJobStatuses.AnalysingTimeline + or StoryIntelligenceJobStatuses.Finalising; + public bool IsCompleted => string.Equals(Status, StoryIntelligenceJobStatuses.Completed, StringComparison.Ordinal); + public bool IsCancelled => string.Equals(Status, StoryIntelligenceJobStatuses.Cancelled, StringComparison.Ordinal); +} + +public sealed class StoryIntelligenceJobProgress +{ + public Guid JobID { get; init; } + public int UserID { get; init; } + public int ProjectID { get; init; } + public int BookID { get; init; } + public string Status { get; init; } = StoryIntelligenceJobStatuses.Pending; + public string CurrentStage { get; init; } = StoryIntelligenceJobStatuses.Pending; + public string CurrentMessage { get; init; } = "Story Intelligence is queued."; + public int ProgressPercent { get; init; } + public string? EstimatedRemaining { get; init; } + public string ElapsedTime { get; init; } = "0 min"; + public string? ErrorMessage { get; init; } + public DateTime UpdatedUtc { get; init; } + public bool IsActive { get; init; } + public bool IsCompleted { get; init; } +} diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 1e1da91..9fc0407 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -133,6 +133,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(); @@ -178,6 +179,8 @@ public class Program builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -192,6 +195,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddHostedService(); + builder.Services.AddHostedService(); var app = builder.Build(); @@ -221,6 +225,7 @@ public class Program pattern: "{controller=Home}/{action=Index}/{id?}") .WithStaticAssets(); app.MapHub("/hubs/word-companion-follow"); + app.MapHub("/hubs/story-intelligence"); app.Run(); } diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index 90fa11c..3313445 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -428,6 +428,7 @@ public sealed class ProjectService( IProjectActivityService activity, ISubscriptionService subscriptions, IOnboardingService onboarding, + IStoryIntelligenceService storyIntelligence, ICurrentUserService currentUser, IHardDeleteFileCleanupService fileCleanup, ILogger logger) : IProjectService @@ -451,7 +452,8 @@ public sealed class ProjectService( ActiveProjects = activeProjects.Select(project => ToIndexCard(project, booksByProject)).ToList(), ArchivedProjects = archivedProjects.Select(project => ToIndexCard(project, booksByProject)).ToList(), Trial = TrialVisibilityViewModel.FromSubscription(await subscriptions.GetCurrentSubscriptionAsync(currentUser.UserId.Value), DateTime.UtcNow), - OnboardingNudge = await onboarding.GetDashboardNudgeAsync() + OnboardingNudge = await onboarding.GetDashboardNudgeAsync(), + StoryIntelligence = await storyIntelligence.GetDashboardAsync() }; } diff --git a/PlotLine/Services/StoryIntelligenceService.cs b/PlotLine/Services/StoryIntelligenceService.cs new file mode 100644 index 0000000..ab93258 --- /dev/null +++ b/PlotLine/Services/StoryIntelligenceService.cs @@ -0,0 +1,294 @@ +using Microsoft.AspNetCore.SignalR; +using PlotLine.Data; +using PlotLine.Hubs; +using PlotLine.Models; +using PlotLine.ViewModels; + +namespace PlotLine.Services; + +public interface IStoryIntelligenceService +{ + Task GetOverviewAsync(); + Task GetProgressAsync(Guid jobId); + Task GetCompletionAsync(Guid jobId); + Task StartAsync(); + Task CancelAsync(Guid jobId); + Task GetDashboardAsync(); + Task GetProgressForUserAsync(int userId, Guid jobId); +} + +public interface IStoryIntelligenceProvider +{ + Task ProcessAsync(StoryIntelligenceJob job, Func progress, CancellationToken cancellationToken); +} + +public sealed class StoryIntelligenceProviderProgress +{ + public string Status { get; init; } = StoryIntelligenceJobStatuses.Preparing; + public string Stage { get; init; } = StoryIntelligenceJobStatuses.Preparing; + public string Message { get; init; } = "Preparing manuscript..."; + public int Percent { get; init; } + public string? EstimatedRemaining { get; init; } +} + +public sealed class StubStoryIntelligenceProvider : IStoryIntelligenceProvider +{ + public async Task ProcessAsync(StoryIntelligenceJob job, Func progress, CancellationToken cancellationToken) + { + var stages = new[] + { + new StoryIntelligenceProviderProgress + { + Percent = 20, + Message = "Building analysis pipeline...", + EstimatedRemaining = "Calculating" + }, + new StoryIntelligenceProviderProgress + { + Percent = 55, + Message = "Preparing scene batches...", + EstimatedRemaining = "Calculating" + }, + new StoryIntelligenceProviderProgress + { + Percent = 85, + Message = "Initialising Story Intelligence...", + EstimatedRemaining = "Calculating" + } + }; + + foreach (var stage in stages) + { + cancellationToken.ThrowIfCancellationRequested(); + await progress(stage); + await Task.Yield(); + } + } +} + +public sealed class StoryIntelligenceService( + IStoryIntelligenceRepository repository, + IOnboardingRepository onboarding, + IProjectRepository projects, + IBookRepository books, + ICurrentUserService currentUser) : IStoryIntelligenceService +{ + private const string ConsentText = "I consent to PlotDirector preparing the Story Intelligence analysis framework for this manuscript. I understand future AI analysis is optional and will use the OpenAI API."; + + public async Task GetOverviewAsync() + { + var state = await onboarding.GetOrCreateAsync(RequireUserId()); + var latest = await repository.GetLatestForUserAsync(state.UserID); + return new StoryIntelligenceOverviewViewModel + { + ProjectID = state.ProjectID, + BookID = state.BookID, + SelectedProjectName = state.ProjectID.HasValue ? (await projects.GetForUserAsync(state.ProjectID.Value, state.UserID))?.ProjectName : null, + SelectedBookTitle = state.BookID.HasValue ? BuildBookTitle(await books.GetAsync(state.BookID.Value)) : null, + ExistingJob = latest is null ? null : ToProgress(latest) + }; + } + + public async Task GetProgressAsync(Guid jobId) + { + var job = await repository.GetAsync(RequireUserId(), jobId); + return job is null + ? null + : new StoryIntelligenceProgressViewModel + { + Job = ToProgress(job), + ProjectName = (await projects.GetForUserAsync(job.ProjectID, job.UserID))?.ProjectName, + BookTitle = BuildBookTitle(await books.GetAsync(job.BookID)) + }; + } + + public async Task GetCompletionAsync(Guid jobId) + { + var job = await repository.GetAsync(RequireUserId(), jobId); + if (job is null || !job.IsCompleted) + { + return null; + } + + return new StoryIntelligenceCompletionViewModel + { + Job = ToProgress(job), + ProjectID = job.ProjectID, + BookID = job.BookID, + ProjectName = (await projects.GetForUserAsync(job.ProjectID, job.UserID))?.ProjectName, + BookTitle = BuildBookTitle(await books.GetAsync(job.BookID)) + }; + } + + public async Task StartAsync() + { + var state = await onboarding.GetOrCreateAsync(RequireUserId()); + if (!state.ProjectID.HasValue || !state.BookID.HasValue) + { + return null; + } + + return await repository.CreateWithConsentAsync( + state.UserID, + state.UserOnboardingStateID, + state.ProjectID.Value, + state.BookID.Value, + ConsentText); + } + + public async Task CancelAsync(Guid jobId) + => await repository.CancelAsync(RequireUserId(), jobId); + + public async Task GetDashboardAsync() + { + if (!currentUser.UserId.HasValue) + { + return new StoryIntelligenceDashboardViewModel(); + } + + var state = await onboarding.GetAsync(currentUser.UserId.Value); + var latest = await repository.GetLatestForUserAsync(currentUser.UserId.Value); + if (latest is not null) + { + return new StoryIntelligenceDashboardViewModel + { + ShouldShow = true, + Job = ToProgress(latest), + Title = latest.IsCompleted ? "Story Intelligence ready" : latest.IsActive ? "Story Intelligence preparing" : "Story Intelligence available", + Description = latest.IsCompleted + ? "The analysis framework is prepared for your manuscript." + : latest.IsActive + ? latest.CurrentMessage + : "You can prepare Story Intelligence for your built manuscript.", + ButtonText = latest.IsCompleted ? "View completion" : latest.IsActive ? "View progress" : "Set up Story Intelligence" + }; + } + + return state?.ProjectID.HasValue == true && state.BookID.HasValue + ? new StoryIntelligenceDashboardViewModel + { + ShouldShow = true, + Title = "Story Intelligence available", + Description = "Prepare the optional analysis framework for your manuscript.", + ButtonText = "Set up Story Intelligence" + } + : new StoryIntelligenceDashboardViewModel(); + } + + public async Task GetProgressForUserAsync(int userId, Guid jobId) + { + var job = await repository.GetAsync(userId, jobId); + return job is null ? null : ToProgress(job); + } + + public static StoryIntelligenceJobProgress ToProgress(StoryIntelligenceJob job) + { + var elapsedFrom = job.StartedUtc ?? job.CreatedUtc; + return new StoryIntelligenceJobProgress + { + JobID = job.JobID, + UserID = job.UserID, + ProjectID = job.ProjectID, + BookID = job.BookID, + Status = job.Status, + CurrentStage = job.CurrentStage, + CurrentMessage = job.CurrentMessage, + ProgressPercent = job.ProgressPercent, + EstimatedRemaining = job.EstimatedRemaining, + ErrorMessage = job.ErrorMessage, + UpdatedUtc = job.UpdatedUtc, + IsActive = job.IsActive, + IsCompleted = job.IsCompleted, + ElapsedTime = FormatElapsed(DateTime.UtcNow - elapsedFrom) + }; + } + + private int RequireUserId() + => currentUser.UserId ?? throw new InvalidOperationException("Sign in to use Story Intelligence."); + + private static string BuildBookTitle(Book? book) + => book is null ? "Selected book" : $"Book {book.BookNumber}: {book.BookDisplayTitle}"; + + private static string FormatElapsed(TimeSpan elapsed) + => elapsed.TotalMinutes < 1 + ? $"{Math.Max(0, (int)elapsed.TotalSeconds)} sec" + : $"{(int)elapsed.TotalMinutes} min {elapsed.Seconds:D2} sec"; +} + +public sealed class StoryIntelligenceWorker( + IServiceScopeFactory scopeFactory, + IHubContext hub, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(2)); + while (!stoppingToken.IsCancellationRequested) + { + await ProcessNextAsync(stoppingToken); + await timer.WaitForNextTickAsync(stoppingToken); + } + } + + private async Task ProcessNextAsync(CancellationToken stoppingToken) + { + using var scope = scopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var provider = scope.ServiceProvider.GetRequiredService(); + var job = await repository.ClaimNextPendingAsync(); + if (job is null) + { + return; + } + + await NotifyAsync(job); + try + { + await provider.ProcessAsync(job, async update => + { + var updated = await repository.UpdateProgressAsync( + job.JobID, + update.Status, + update.Percent, + update.Stage, + update.Message, + update.EstimatedRemaining); + if (updated is not null) + { + await NotifyAsync(updated); + if (updated.IsCancelled) + { + throw new OperationCanceledException(); + } + } + }, stoppingToken); + + var completed = await repository.CompleteAsync(job.JobID); + if (completed is not null) + { + await NotifyAsync(completed); + } + } + catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested) + { + var cancelled = await repository.CancelAsync(job.UserID, job.JobID); + if (cancelled is not null) + { + await NotifyAsync(cancelled); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Story Intelligence job {JobID} failed.", job.JobID); + var failed = await repository.FailAsync(job.JobID, ex.Message); + if (failed is not null) + { + await NotifyAsync(failed); + } + } + } + + private Task NotifyAsync(StoryIntelligenceJob job) + => hub.Clients.Group(StoryIntelligenceHub.UserGroup(job.UserID)) + .SendAsync("StoryIntelligenceProgressChanged", StoryIntelligenceService.ToProgress(job)); +} diff --git a/PlotLine/Sql/114_Phase20H_StoryIntelligenceFramework.sql b/PlotLine/Sql/114_Phase20H_StoryIntelligenceFramework.sql new file mode 100644 index 0000000..8bec24d --- /dev/null +++ b/PlotLine/Sql/114_Phase20H_StoryIntelligenceFramework.sql @@ -0,0 +1,304 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +IF OBJECT_ID(N'dbo.StoryIntelligenceConsents', N'U') IS NULL +BEGIN + CREATE TABLE dbo.StoryIntelligenceConsents + ( + StoryIntelligenceConsentID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryIntelligenceConsents PRIMARY KEY, + UserID int NOT NULL, + UserOnboardingStateID int NOT NULL, + ProjectID int NOT NULL, + BookID int NOT NULL, + ConsentedUtc datetime2 NOT NULL CONSTRAINT DF_StoryIntelligenceConsents_ConsentedUtc DEFAULT SYSUTCDATETIME(), + ConsentVersion nvarchar(40) NOT NULL CONSTRAINT DF_StoryIntelligenceConsents_ConsentVersion DEFAULT N'Phase20H', + ConsentText nvarchar(1000) NOT NULL, + CONSTRAINT FK_StoryIntelligenceConsents_AppUser FOREIGN KEY (UserID) REFERENCES dbo.AppUser(UserID), + CONSTRAINT FK_StoryIntelligenceConsents_UserOnboardingState FOREIGN KEY (UserOnboardingStateID) REFERENCES dbo.UserOnboardingState(UserOnboardingStateID), + CONSTRAINT FK_StoryIntelligenceConsents_Projects FOREIGN KEY (ProjectID) REFERENCES dbo.Projects(ProjectID), + CONSTRAINT FK_StoryIntelligenceConsents_Books FOREIGN KEY (BookID) REFERENCES dbo.Books(BookID) + ); +END; +GO + +IF OBJECT_ID(N'dbo.StoryIntelligenceJobs', N'U') IS NULL +BEGIN + CREATE TABLE dbo.StoryIntelligenceJobs + ( + StoryIntelligenceJobID uniqueidentifier NOT NULL CONSTRAINT PK_StoryIntelligenceJobs PRIMARY KEY, + StoryIntelligenceConsentID int NOT NULL, + UserID int NOT NULL, + ProjectID int NOT NULL, + BookID int NOT NULL, + Status nvarchar(40) NOT NULL CONSTRAINT DF_StoryIntelligenceJobs_Status DEFAULT N'Pending', + CreatedUtc datetime2 NOT NULL CONSTRAINT DF_StoryIntelligenceJobs_CreatedUtc DEFAULT SYSUTCDATETIME(), + StartedUtc datetime2 NULL, + CompletedUtc datetime2 NULL, + CancelledUtc datetime2 NULL, + ProgressPercent int NOT NULL CONSTRAINT DF_StoryIntelligenceJobs_ProgressPercent DEFAULT 0, + CurrentStage nvarchar(80) NOT NULL CONSTRAINT DF_StoryIntelligenceJobs_CurrentStage DEFAULT N'Pending', + CurrentMessage nvarchar(400) NOT NULL CONSTRAINT DF_StoryIntelligenceJobs_CurrentMessage DEFAULT N'Story Intelligence is queued.', + EstimatedRemaining nvarchar(80) NULL, + ErrorMessage nvarchar(1000) NULL, + UpdatedUtc datetime2 NOT NULL CONSTRAINT DF_StoryIntelligenceJobs_UpdatedUtc DEFAULT SYSUTCDATETIME(), + CONSTRAINT FK_StoryIntelligenceJobs_Consent FOREIGN KEY (StoryIntelligenceConsentID) REFERENCES dbo.StoryIntelligenceConsents(StoryIntelligenceConsentID), + CONSTRAINT FK_StoryIntelligenceJobs_AppUser FOREIGN KEY (UserID) REFERENCES dbo.AppUser(UserID), + CONSTRAINT FK_StoryIntelligenceJobs_Projects FOREIGN KEY (ProjectID) REFERENCES dbo.Projects(ProjectID), + CONSTRAINT FK_StoryIntelligenceJobs_Books FOREIGN KEY (BookID) REFERENCES dbo.Books(BookID), + CONSTRAINT CK_StoryIntelligenceJobs_Status CHECK (Status IN + ( + N'Pending', N'Preparing', N'ReadingScenes', N'AnalysingCharacters', N'AnalysingRelationships', + N'AnalysingLocations', N'AnalysingAssets', N'AnalysingTimeline', N'Finalising', + N'Completed', N'Cancelled', N'Failed' + )), + CONSTRAINT CK_StoryIntelligenceJobs_ProgressPercent CHECK (ProgressPercent >= 0 AND ProgressPercent <= 100) + ); +END; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_StoryIntelligenceJobs_User_Status' AND object_id = OBJECT_ID(N'dbo.StoryIntelligenceJobs')) + CREATE INDEX IX_StoryIntelligenceJobs_User_Status ON dbo.StoryIntelligenceJobs(UserID, Status, CreatedUtc DESC); +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_StoryIntelligenceJobs_Pending' AND object_id = OBJECT_ID(N'dbo.StoryIntelligenceJobs')) + CREATE INDEX IX_StoryIntelligenceJobs_Pending ON dbo.StoryIntelligenceJobs(Status, CreatedUtc) INCLUDE (StoryIntelligenceJobID); +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceJob_Get + @UserID int, + @JobID uniqueidentifier +AS +BEGIN + SET NOCOUNT ON; + + SELECT StoryIntelligenceJobID AS JobID, StoryIntelligenceConsentID AS ConsentID, UserID, ProjectID, BookID, + Status, CreatedUtc, StartedUtc, CompletedUtc, CancelledUtc, ProgressPercent, CurrentStage, + CurrentMessage, EstimatedRemaining, ErrorMessage, UpdatedUtc + FROM dbo.StoryIntelligenceJobs + WHERE StoryIntelligenceJobID = @JobID + AND UserID = @UserID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceJob_CreateWithConsent + @UserID int, + @UserOnboardingStateID int, + @ProjectID int, + @BookID int, + @ConsentText nvarchar(1000) +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + + IF NOT EXISTS + ( + SELECT 1 + FROM dbo.UserOnboardingState uos + INNER JOIN dbo.Books b ON b.BookID = @BookID AND b.ProjectID = @ProjectID AND b.IsArchived = 0 + INNER JOIN dbo.Projects p ON p.ProjectID = @ProjectID AND p.IsArchived = 0 + INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID AND pua.UserID = @UserID AND pua.IsActive = 1 + WHERE uos.UserOnboardingStateID = @UserOnboardingStateID + AND uos.UserID = @UserID + ) + RETURN; + + DECLARE @ExistingActiveJobID uniqueidentifier; + SELECT TOP (1) @ExistingActiveJobID = StoryIntelligenceJobID + FROM dbo.StoryIntelligenceJobs + WHERE UserID = @UserID + AND ProjectID = @ProjectID + AND BookID = @BookID + AND Status IN (N'Pending', N'Preparing', N'ReadingScenes', N'AnalysingCharacters', N'AnalysingRelationships', + N'AnalysingLocations', N'AnalysingAssets', N'AnalysingTimeline', N'Finalising') + ORDER BY CreatedUtc DESC; + + IF @ExistingActiveJobID IS NOT NULL + BEGIN + EXEC dbo.StoryIntelligenceJob_Get @UserID = @UserID, @JobID = @ExistingActiveJobID; + RETURN; + END; + + DECLARE @ConsentID int; + DECLARE @JobID uniqueidentifier = NEWID(); + + BEGIN TRANSACTION; + + INSERT dbo.StoryIntelligenceConsents (UserID, UserOnboardingStateID, ProjectID, BookID, ConsentText) + VALUES (@UserID, @UserOnboardingStateID, @ProjectID, @BookID, @ConsentText); + SET @ConsentID = CAST(SCOPE_IDENTITY() AS int); + + INSERT dbo.StoryIntelligenceJobs + ( + StoryIntelligenceJobID, StoryIntelligenceConsentID, UserID, ProjectID, BookID, + Status, ProgressPercent, CurrentStage, CurrentMessage, EstimatedRemaining + ) + VALUES + ( + @JobID, @ConsentID, @UserID, @ProjectID, @BookID, + N'Pending', 0, N'Pending', N'Story Intelligence is queued.', N'Calculating' + ); + + COMMIT TRANSACTION; + + EXEC dbo.StoryIntelligenceJob_Get @UserID = @UserID, @JobID = @JobID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceJob_GetLatestForUser + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT TOP (1) StoryIntelligenceJobID AS JobID, StoryIntelligenceConsentID AS ConsentID, UserID, ProjectID, BookID, + Status, CreatedUtc, StartedUtc, CompletedUtc, CancelledUtc, ProgressPercent, CurrentStage, + CurrentMessage, EstimatedRemaining, ErrorMessage, UpdatedUtc + FROM dbo.StoryIntelligenceJobs + WHERE UserID = @UserID + ORDER BY CreatedUtc DESC; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceJob_ClaimNextPending +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + + DECLARE @JobID uniqueidentifier; + SELECT TOP (1) @JobID = StoryIntelligenceJobID + FROM dbo.StoryIntelligenceJobs WITH (UPDLOCK, READPAST) + WHERE Status = N'Pending' + ORDER BY CreatedUtc; + + IF @JobID IS NULL + BEGIN + SELECT TOP (0) StoryIntelligenceJobID AS JobID, StoryIntelligenceConsentID AS ConsentID, UserID, ProjectID, BookID, + Status, CreatedUtc, StartedUtc, CompletedUtc, CancelledUtc, ProgressPercent, CurrentStage, + CurrentMessage, EstimatedRemaining, ErrorMessage, UpdatedUtc + FROM dbo.StoryIntelligenceJobs; + RETURN; + END; + + UPDATE dbo.StoryIntelligenceJobs + SET Status = N'Preparing', + StartedUtc = COALESCE(StartedUtc, SYSUTCDATETIME()), + ProgressPercent = 5, + CurrentStage = N'Preparing', + CurrentMessage = N'Preparing manuscript...', + EstimatedRemaining = N'Calculating', + UpdatedUtc = SYSUTCDATETIME() + WHERE StoryIntelligenceJobID = @JobID + AND Status = N'Pending'; + + SELECT j.StoryIntelligenceJobID AS JobID, j.StoryIntelligenceConsentID AS ConsentID, j.UserID, j.ProjectID, j.BookID, + j.Status, j.CreatedUtc, j.StartedUtc, j.CompletedUtc, j.CancelledUtc, j.ProgressPercent, j.CurrentStage, + j.CurrentMessage, j.EstimatedRemaining, j.ErrorMessage, j.UpdatedUtc + FROM dbo.StoryIntelligenceJobs j + WHERE j.StoryIntelligenceJobID = @JobID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceJob_UpdateProgress + @JobID uniqueidentifier, + @Status nvarchar(40), + @ProgressPercent int, + @CurrentStage nvarchar(80), + @CurrentMessage nvarchar(400), + @EstimatedRemaining nvarchar(80) = NULL +AS +BEGIN + SET NOCOUNT ON; + + UPDATE dbo.StoryIntelligenceJobs + SET Status = @Status, + ProgressPercent = @ProgressPercent, + CurrentStage = @CurrentStage, + CurrentMessage = @CurrentMessage, + EstimatedRemaining = @EstimatedRemaining, + UpdatedUtc = SYSUTCDATETIME() + WHERE StoryIntelligenceJobID = @JobID + AND Status NOT IN (N'Completed', N'Cancelled', N'Failed'); + + SELECT StoryIntelligenceJobID AS JobID, StoryIntelligenceConsentID AS ConsentID, UserID, ProjectID, BookID, + Status, CreatedUtc, StartedUtc, CompletedUtc, CancelledUtc, ProgressPercent, CurrentStage, + CurrentMessage, EstimatedRemaining, ErrorMessage, UpdatedUtc + FROM dbo.StoryIntelligenceJobs + WHERE StoryIntelligenceJobID = @JobID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceJob_Complete + @JobID uniqueidentifier +AS +BEGIN + SET NOCOUNT ON; + + UPDATE dbo.StoryIntelligenceJobs + SET Status = N'Completed', + ProgressPercent = 100, + CurrentStage = N'Completed', + CurrentMessage = N'Story Intelligence is ready.', + EstimatedRemaining = N'Complete', + CompletedUtc = COALESCE(CompletedUtc, SYSUTCDATETIME()), + UpdatedUtc = SYSUTCDATETIME() + WHERE StoryIntelligenceJobID = @JobID + AND Status NOT IN (N'Completed', N'Cancelled', N'Failed'); + + SELECT StoryIntelligenceJobID AS JobID, StoryIntelligenceConsentID AS ConsentID, UserID, ProjectID, BookID, + Status, CreatedUtc, StartedUtc, CompletedUtc, CancelledUtc, ProgressPercent, CurrentStage, + CurrentMessage, EstimatedRemaining, ErrorMessage, UpdatedUtc + FROM dbo.StoryIntelligenceJobs + WHERE StoryIntelligenceJobID = @JobID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceJob_Cancel + @UserID int, + @JobID uniqueidentifier +AS +BEGIN + SET NOCOUNT ON; + + UPDATE dbo.StoryIntelligenceJobs + SET Status = N'Cancelled', + CurrentStage = N'Cancelled', + CurrentMessage = N'Story Intelligence setup was cancelled.', + EstimatedRemaining = NULL, + CancelledUtc = COALESCE(CancelledUtc, SYSUTCDATETIME()), + UpdatedUtc = SYSUTCDATETIME() + WHERE StoryIntelligenceJobID = @JobID + AND UserID = @UserID + AND Status NOT IN (N'Completed', N'Cancelled', N'Failed'); + + EXEC dbo.StoryIntelligenceJob_Get @UserID = @UserID, @JobID = @JobID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceJob_Fail + @JobID uniqueidentifier, + @ErrorMessage nvarchar(1000) +AS +BEGIN + SET NOCOUNT ON; + + UPDATE dbo.StoryIntelligenceJobs + SET Status = N'Failed', + CurrentStage = N'Failed', + CurrentMessage = N'Story Intelligence setup could not finish.', + ErrorMessage = @ErrorMessage, + EstimatedRemaining = NULL, + UpdatedUtc = SYSUTCDATETIME() + WHERE StoryIntelligenceJobID = @JobID + AND Status NOT IN (N'Completed', N'Cancelled'); + + SELECT StoryIntelligenceJobID AS JobID, StoryIntelligenceConsentID AS ConsentID, UserID, ProjectID, BookID, + Status, CreatedUtc, StartedUtc, CompletedUtc, CancelledUtc, ProgressPercent, CurrentStage, + CurrentMessage, EstimatedRemaining, ErrorMessage, UpdatedUtc + FROM dbo.StoryIntelligenceJobs + WHERE StoryIntelligenceJobID = @JobID; +END; +GO diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index bf15c6e..4a66b63 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -27,6 +27,7 @@ public sealed class ProjectIndexViewModel public IReadOnlyList ArchivedProjects { get; init; } = []; public TrialVisibilityViewModel? Trial { get; init; } public OnboardingNudgeViewModel OnboardingNudge { get; init; } = new(); + public StoryIntelligenceDashboardViewModel StoryIntelligence { get; init; } = new(); } public sealed class ProjectIndexCardViewModel diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs index f6a17bb..141fad0 100644 --- a/PlotLine/ViewModels/OnboardingViewModels.cs +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -104,6 +104,41 @@ public sealed class OnboardingNudgeViewModel public bool CompanionConnected { get; init; } } +public sealed class StoryIntelligenceOverviewViewModel +{ + public int? ProjectID { get; init; } + public int? BookID { get; init; } + public string? SelectedProjectName { get; init; } + public string? SelectedBookTitle { get; init; } + public StoryIntelligenceJobProgress? ExistingJob { get; init; } + public bool CanStart => ProjectID.HasValue && BookID.HasValue && ExistingJob?.IsActive != true; +} + +public sealed class StoryIntelligenceProgressViewModel +{ + public StoryIntelligenceJobProgress Job { get; init; } = new(); + public string? ProjectName { get; init; } + public string? BookTitle { get; init; } +} + +public sealed class StoryIntelligenceCompletionViewModel +{ + public StoryIntelligenceJobProgress Job { get; init; } = new(); + public int ProjectID { get; init; } + public int BookID { get; init; } + public string? ProjectName { get; init; } + public string? BookTitle { get; init; } +} + +public sealed class StoryIntelligenceDashboardViewModel +{ + public bool ShouldShow { get; init; } + public string Title { get; init; } = "Story Intelligence available"; + public string Description { get; init; } = "Prepare the optional analysis framework for your manuscript."; + public string ButtonText { get; init; } = "Set up Story Intelligence"; + public StoryIntelligenceJobProgress? Job { get; init; } +} + public sealed class CompanionPresenceViewModel { public bool IsConnected { get; init; } diff --git a/PlotLine/Views/Onboarding/BuildComplete.cshtml b/PlotLine/Views/Onboarding/BuildComplete.cshtml index 73b5e21..78f48ad 100644 --- a/PlotLine/Views/Onboarding/BuildComplete.cshtml +++ b/PlotLine/Views/Onboarding/BuildComplete.cshtml @@ -54,7 +54,7 @@ diff --git a/PlotLine/Views/Onboarding/StoryIntelligence.cshtml b/PlotLine/Views/Onboarding/StoryIntelligence.cshtml new file mode 100644 index 0000000..6bf5116 --- /dev/null +++ b/PlotLine/Views/Onboarding/StoryIntelligence.cshtml @@ -0,0 +1,70 @@ +@model StoryIntelligenceOverviewViewModel +@{ + ViewData["Title"] = "Story Intelligence"; +} + +
+
+
+

Story Intelligence

+

PlotDirector can now analyse your manuscript to automatically build much of your project.

+

This step is optional. In this phase, PlotDirector only prepares the analysis framework; no AI analysis will run yet.

+
+ + @if (Model.ExistingJob?.IsActive == true) + { +
+

Story Intelligence is already preparing

+

@Model.ExistingJob.CurrentMessage

+ View progress +
+ } + else if (Model.ExistingJob?.IsCompleted == true) + { +
+

Story Intelligence is ready

+

The framework has already been prepared for this manuscript.

+ View completion +
+ } + else + { +
+
+ Optional + You stay in control +

You can skip this now and continue using the project structure you already built.

+
+
+ Uses AI later + Explicit consent required +

Future manuscript analysis will only run after you choose to start it.

+
+
+ Privacy + OpenAI API content is not used to train AI models +

Manuscript content is processed securely when AI analysis is enabled in a later phase.

+
+
+ Estimate + About 5-15 minutes +

Credit use will be shown here before charging is enabled. Placeholder: included setup credits.

+
+
+ +
+

Consent

+

By choosing Analyse my manuscript, you consent to PlotDirector preparing the Story Intelligence job for @Model.SelectedBookTitle in @Model.SelectedProjectName. This phase does not call OpenAI or analyse manuscript content.

+
+ +
+
+ +
+
+ +
+
+ } +
+
diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceComplete.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceComplete.cshtml new file mode 100644 index 0000000..9744ac2 --- /dev/null +++ b/PlotLine/Views/Onboarding/StoryIntelligenceComplete.cshtml @@ -0,0 +1,47 @@ +@model StoryIntelligenceCompletionViewModel +@{ + ViewData["Title"] = "Story Intelligence ready"; +} + +
+
+
+

@Model.BookTitle

+
+ +

Story Intelligence is ready.

+
+

The analysis engine is prepared. In the next phase PlotDirector will begin analysing your manuscript.

+
+ +
+

Temporary completion page

+

This page confirms the framework job, consent, background processing and progress plumbing are working. Later phases will replace it with real analysis results.

+
+ +
+
+ Status + @Model.Job.Status +
+
+ Progress + @Model.Job.ProgressPercent% +
+
+ Elapsed + @Model.Job.ElapsedTime +
+
+ Stage + @Model.Job.CurrentStage +
+
+ + +
+
diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml new file mode 100644 index 0000000..42bd12c --- /dev/null +++ b/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml @@ -0,0 +1,60 @@ +@model StoryIntelligenceProgressViewModel +@{ + ViewData["Title"] = "Preparing Story Intelligence"; +} + +
+
+
+

@Model.BookTitle

+

Preparing Story Intelligence

+

PlotDirector is preparing the analysis engine. This phase does not run AI analysis or generate story suggestions.

+
+ +
+
+ @Model.Job.CurrentMessage + @Model.Job.ProgressPercent% +
+ +
+
+ Stage + @Model.Job.CurrentStage +
+
+ Status + @Model.Job.Status +
+
+ Elapsed + @Model.Job.ElapsedTime +
+
+ Remaining + @(Model.Job.EstimatedRemaining ?? "Calculating") +
+
+
+ +
+
+ + +
+ Continue +
+
+
+ +@section Scripts { + +} diff --git a/PlotLine/Views/Projects/Index.cshtml b/PlotLine/Views/Projects/Index.cshtml index c079088..e139118 100644 --- a/PlotLine/Views/Projects/Index.cshtml +++ b/PlotLine/Views/Projects/Index.cshtml @@ -47,6 +47,42 @@ } +@if (Model.StoryIntelligence.ShouldShow) +{ +
+
+

Story Intelligence

+

@Model.StoryIntelligence.Title

+

@Model.StoryIntelligence.Description

+ @if (Model.StoryIntelligence.Job is not null) + { +
+ @Model.StoryIntelligence.Job.Status + @Model.StoryIntelligence.Job.ProgressPercent% + +
+ } +
+ @if (Model.StoryIntelligence.Job?.IsActive == true) + { + @Model.StoryIntelligence.ButtonText + } + else if (Model.StoryIntelligence.Job?.IsCompleted == true) + { + @Model.StoryIntelligence.ButtonText + } + else + { + @Model.StoryIntelligence.ButtonText + } +
+} + @if (TempData["ArchiveMessage"] is string archiveMessage) {
@archiveMessage
@@ -309,6 +345,7 @@ else } @section Scripts { +