Phase 20AJ – Story Intelligence Pipeline Completion and Resume Support

This commit is contained in:
Nick Beckley 2026-07-08 21:31:40 +01:00
parent a3a6c52287
commit 438478c7fe
16 changed files with 694 additions and 3 deletions

View File

@ -62,6 +62,24 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return View(model); return View(model);
} }
[HttpGet("story-intelligence/book/{bookId:int}/continue")]
public async Task<IActionResult> 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")] [HttpPost("story-intelligence/start")]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> StartStoryIntelligence() public async Task<IActionResult> StartStoryIntelligence()

View File

@ -0,0 +1,74 @@
using System.Data;
using Dapper;
using PlotLine.Models;
namespace PlotLine.Data;
public interface IStoryIntelligencePipelineRepository
{
Task<StoryIntelligenceBookPipelineState?> GetByBookAsync(int bookId);
Task<StoryIntelligenceBookPipelineState?> GetByBookForUserAsync(int bookId, int userId);
Task<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(int userId);
Task<StoryIntelligenceBookPipelineState> UpsertAsync(StoryIntelligenceBookPipelineSaveRequest request);
Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId);
}
public sealed class StoryIntelligencePipelineRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligencePipelineRepository
{
public async Task<StoryIntelligenceBookPipelineState?> GetByBookAsync(int bookId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceBookPipelineState>(
"dbo.StoryIntelligenceBookPipeline_GetByBook",
new { BookID = bookId },
commandType: CommandType.StoredProcedure);
}
public async Task<StoryIntelligenceBookPipelineState?> GetByBookForUserAsync(int bookId, int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceBookPipelineState>(
"dbo.StoryIntelligenceBookPipeline_GetByBookForUser",
new { BookID = bookId, UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(int userId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<StoryIntelligenceBookPipelineState>(
"dbo.StoryIntelligenceBookPipeline_ListForUser",
new { UserID = userId },
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<StoryIntelligenceBookPipelineState> UpsertAsync(StoryIntelligenceBookPipelineSaveRequest request)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<StoryIntelligenceBookPipelineState>(
"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<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<StoryIntelligenceCommittedRunSummary>(
"dbo.StoryIntelligenceBookPipeline_ListCommittedRunsByBook",
new { BookID = bookId, UserID = userId },
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
}

View File

@ -21,6 +21,71 @@ public static class StoryIntelligenceFailureStages
public const string DomainImport = "DomainImport"; 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 sealed class StoryIntelligenceRunSaveRequest
{ {
public int UserID { get; init; } public int UserID { get; init; }

View File

@ -143,6 +143,7 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceRepository, StoryIntelligenceRepository>(); builder.Services.AddScoped<IStoryIntelligenceRepository, StoryIntelligenceRepository>();
builder.Services.AddScoped<IStoryIntelligenceResultRepository, StoryIntelligenceResultRepository>(); builder.Services.AddScoped<IStoryIntelligenceResultRepository, StoryIntelligenceResultRepository>();
builder.Services.AddScoped<IStoryIntelligenceSourceRepository, StoryIntelligenceSourceRepository>(); builder.Services.AddScoped<IStoryIntelligenceSourceRepository, StoryIntelligenceSourceRepository>();
builder.Services.AddScoped<IStoryIntelligencePipelineRepository, StoryIntelligencePipelineRepository>();
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>(); builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>(); builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>();
builder.Services.AddScoped<IProjectActivityRepository, ProjectActivityRepository>(); builder.Services.AddScoped<IProjectActivityRepository, ProjectActivityRepository>();
@ -207,6 +208,7 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceFullChapterTestService, StoryIntelligenceFullChapterTestService>(); builder.Services.AddScoped<IStoryIntelligenceFullChapterTestService, StoryIntelligenceFullChapterTestService>();
builder.Services.AddScoped<IStoryIntelligenceImportCommitService, StoryIntelligenceImportCommitService>(); builder.Services.AddScoped<IStoryIntelligenceImportCommitService, StoryIntelligenceImportCommitService>();
builder.Services.AddScoped<IStoryIntelligenceCharacterImportService, StoryIntelligenceCharacterImportService>(); builder.Services.AddScoped<IStoryIntelligenceCharacterImportService, StoryIntelligenceCharacterImportService>();
builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>();
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>(); builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>(); builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();
builder.Services.AddScoped<IPersistedStoryIntelligenceRunner, PersistedStoryIntelligenceRunner>(); builder.Services.AddScoped<IPersistedStoryIntelligenceRunner, PersistedStoryIntelligenceRunner>();

View File

@ -1139,6 +1139,7 @@ public sealed class BookService(
IBookCoverService bookCovers, IBookCoverService bookCovers,
ISubscriptionService subscriptions, ISubscriptionService subscriptions,
IProjectActivityService activity, IProjectActivityService activity,
IStoryIntelligencePipelineStateService storyIntelligencePipeline,
ICurrentUserService currentUser) : IBookService ICurrentUserService currentUser) : IBookService
{ {
public async Task<BookDetailViewModel?> GetDetailAsync(int bookId) public async Task<BookDetailViewModel?> GetDetailAsync(int bookId)
@ -1162,7 +1163,10 @@ public sealed class BookService(
ManuscriptDocument = currentUser.UserId is int userId ManuscriptDocument = currentUser.UserId is int userId
? await manuscriptDocuments.GetByBookAsync(bookId, userId) ? await manuscriptDocuments.GetByBookAsync(bookId, userId)
: null, : null,
Chapters = await chapters.ListByBookAsync(bookId) Chapters = await chapters.ListByBookAsync(bookId),
StoryIntelligencePipeline = currentUser.UserId is int pipelineUserId
? await storyIntelligencePipeline.GetForBookAsync(bookId, pipelineUserId)
: null
}; };
} }

View File

@ -17,6 +17,7 @@ public interface IOnboardingStoryIntelligenceService
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportCharactersAsync(Guid batchId, StoryIntelligenceCharacterImportForm form); Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportCharactersAsync(Guid batchId, StoryIntelligenceCharacterImportForm form);
Task<StoryIntelligenceCharacterImportResultViewModel?> GetCharacterImportResultAsync(Guid batchId); Task<StoryIntelligenceCharacterImportResultViewModel?> GetCharacterImportResultAsync(Guid batchId);
Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId); Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId);
Task<OnboardingStoryIntelligenceResumeTarget?> ResumeBookAsync(int bookId);
} }
public sealed class OnboardingStoryIntelligenceService( public sealed class OnboardingStoryIntelligenceService(
@ -26,6 +27,7 @@ public sealed class OnboardingStoryIntelligenceService(
IStoryIntelligenceResultRepository runs, IStoryIntelligenceResultRepository runs,
IStoryIntelligenceImportCommitService commits, IStoryIntelligenceImportCommitService commits,
IStoryIntelligenceCharacterImportService characterImport, IStoryIntelligenceCharacterImportService characterImport,
IStoryIntelligencePipelineStateService pipelineState,
IStoryIntelligenceClient client, IStoryIntelligenceClient client,
IOnboardingStoryIntelligenceBatchStore batchStore, IOnboardingStoryIntelligenceBatchStore batchStore,
IStoryIntelligenceProgressNotifier notifier, IStoryIntelligenceProgressNotifier notifier,
@ -397,6 +399,64 @@ public sealed class OnboardingStoryIntelligenceService(
}; };
} }
public async Task<OnboardingStoryIntelligenceResumeTarget?> 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) private async Task<(UserOnboardingState State, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, OnboardingManuscriptBuildResult? Build, OnboardingWizardViewModel Wizard)?> GetContextAsync(int userId)
{ {
var state = await onboardingRepository.GetAsync(userId); var state = await onboardingRepository.GetAsync(userId);
@ -655,3 +715,12 @@ public sealed class StoryIntelligenceCharacterImportBatchResult
public int PovLinksResolved { get; init; } public int PovLinksResolved { get; init; }
public int ScenePeoplePanelsUpdated { 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);

View File

@ -15,6 +15,7 @@ public sealed class StoryIntelligenceCharacterImportService(
IStoryIntelligenceResultRepository runs, IStoryIntelligenceResultRepository runs,
ICharacterRepository characters, ICharacterRepository characters,
ISceneRepository scenes, ISceneRepository scenes,
IStoryIntelligencePipelineStateService pipelineState,
ILogger<StoryIntelligenceCharacterImportService> logger) : IStoryIntelligenceCharacterImportService ILogger<StoryIntelligenceCharacterImportService> logger) : IStoryIntelligenceCharacterImportService
{ {
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
@ -168,6 +169,7 @@ public sealed class StoryIntelligenceCharacterImportService(
PovLinksResolved = povLinks, PovLinksResolved = povLinks,
ScenePeoplePanelsUpdated = linkedAppearances ScenePeoplePanelsUpdated = linkedAppearances
}; };
await pipelineState.RecordCharacterImportAsync(batch.ProjectID, batch.BookID);
logger.LogInformation( logger.LogInformation(
"Imported Story Intelligence characters for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Ignored={Ignored} LinkedAppearances={LinkedAppearances} PovLinks={PovLinks}", "Imported Story Intelligence characters for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Ignored={Ignored} LinkedAppearances={LinkedAppearances} PovLinks={PovLinks}",

View File

@ -20,6 +20,7 @@ public sealed class StoryIntelligenceImportCommitService(
ISceneMetricTypeRepository metricTypes, ISceneMetricTypeRepository metricTypes,
IWriterWorkspaceRepository writerWorkspace, IWriterWorkspaceRepository writerWorkspace,
ICharacterRepository characters, ICharacterRepository characters,
IStoryIntelligencePipelineStateService pipelineState,
ILogger<StoryIntelligenceImportCommitService> logger) : IStoryIntelligenceImportCommitService ILogger<StoryIntelligenceImportCommitService> logger) : IStoryIntelligenceImportCommitService
{ {
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
@ -97,6 +98,11 @@ public sealed class StoryIntelligenceImportCommitService(
result.ScenesCreated, result.ScenesCreated,
result.MetricsCreated); 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; return result;
} }

View File

@ -0,0 +1,94 @@
using PlotLine.Data;
using PlotLine.Models;
namespace PlotLine.Services;
public interface IStoryIntelligencePipelineStateService
{
Task<StoryIntelligenceBookPipelineState?> GetForBookAsync(int bookId, int userId);
Task<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(int userId);
Task<StoryIntelligenceBookPipelineState?> EnsureForBookAsync(int bookId, int userId);
Task RecordSceneImportAsync(int projectId, int bookId, int runId);
Task RecordCharacterImportAsync(int projectId, int bookId);
Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId);
}
public sealed class StoryIntelligencePipelineStateService(
IStoryIntelligencePipelineRepository pipelines,
ILogger<StoryIntelligencePipelineStateService> logger) : IStoryIntelligencePipelineStateService
{
public async Task<StoryIntelligenceBookPipelineState?> GetForBookAsync(int bookId, int userId)
=> await pipelines.GetByBookForUserAsync(bookId, userId)
?? await EnsureForBookAsync(bookId, userId);
public Task<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(int userId)
=> pipelines.ListForUserAsync(userId);
public async Task<StoryIntelligenceBookPipelineState?> 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<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId)
=> pipelines.ListCommittedRunsByBookAsync(bookId, userId);
}

View File

@ -69,6 +69,7 @@ public sealed class StubStoryIntelligenceProvider : IStoryIntelligenceProvider
public sealed class StoryIntelligenceService( public sealed class StoryIntelligenceService(
IStoryIntelligenceRepository repository, IStoryIntelligenceRepository repository,
IStoryIntelligenceResultRepository persistedRuns, IStoryIntelligenceResultRepository persistedRuns,
IStoryIntelligencePipelineStateService pipelineState,
IOnboardingStoryIntelligenceBatchStore onboardingBatches, IOnboardingStoryIntelligenceBatchStore onboardingBatches,
IOnboardingRepository onboarding, IOnboardingRepository onboarding,
IProjectRepository projects, IProjectRepository projects,
@ -148,6 +149,9 @@ public sealed class StoryIntelligenceService(
return new StoryIntelligenceDashboardViewModel(); return new StoryIntelligenceDashboardViewModel();
} }
var pipelineBooks = (await pipelineState.ListForUserAsync(currentUser.UserId.Value))
.Select(ToPipelineBookStatus)
.ToList();
var activeBatch = await onboardingBatches.GetLatestActiveForUserAsync(currentUser.UserId.Value); var activeBatch = await onboardingBatches.GetLatestActiveForUserAsync(currentUser.UserId.Value);
if (activeBatch is not null) if (activeBatch is not null)
{ {
@ -178,11 +182,27 @@ public sealed class StoryIntelligenceService(
ActiveImportProgressLabel = $"Chapter {currentChapter:N0} of {activeBatch.Items.Count:N0}", ActiveImportProgressLabel = $"Chapter {currentChapter:N0} of {activeBatch.Items.Count:N0}",
Title = "Story Intelligence is reading", Title = "Story Intelligence is reading",
Description = "PlotDirector is analysing your manuscript in the background.", 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); var state = await onboarding.GetAsync(currentUser.UserId.Value);
return state?.ProjectID.HasValue == true && state.BookID.HasValue return state?.ProjectID.HasValue == true && state.BookID.HasValue
? new StoryIntelligenceDashboardViewModel ? new StoryIntelligenceDashboardViewModel
@ -190,7 +210,8 @@ public sealed class StoryIntelligenceService(
ShouldShow = true, ShouldShow = true,
Title = "Import your manuscript", Title = "Import your manuscript",
Description = "Scan your manuscript with the Word Companion, review the chapters, then let PlotDirector prepare scene suggestions.", 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(); : new StoryIntelligenceDashboardViewModel();
} }
@ -229,6 +250,43 @@ public sealed class StoryIntelligenceService(
private static string BuildBookTitle(Book? book) private static string BuildBookTitle(Book? book)
=> book is null ? "Selected book" : $"Book {book.BookNumber}: {book.BookDisplayTitle}"; => 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) private static string FormatElapsed(TimeSpan elapsed)
=> elapsed.TotalMinutes < 1 => elapsed.TotalMinutes < 1
? $"{Math.Max(0, (int)elapsed.TotalSeconds)} sec" ? $"{Math.Max(0, (int)elapsed.TotalSeconds)} sec"

View File

@ -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

View File

@ -244,6 +244,7 @@ public sealed class BookDetailViewModel
public Book Book { get; set; } = new(); public Book Book { get; set; } = new();
public ManuscriptDocumentModel? ManuscriptDocument { get; set; } public ManuscriptDocumentModel? ManuscriptDocument { get; set; }
public IReadOnlyList<Chapter> Chapters { get; set; } = []; public IReadOnlyList<Chapter> Chapters { get; set; } = [];
public StoryIntelligenceBookPipelineState? StoryIntelligencePipeline { get; set; }
} }
public sealed class ChapterEditViewModel public sealed class ChapterEditViewModel

View File

@ -294,7 +294,18 @@ public sealed class StoryIntelligenceDashboardViewModel
public Guid? ActiveBatchID { get; init; } public Guid? ActiveBatchID { get; init; }
public string? ActiveImportBookTitle { get; init; } public string? ActiveImportBookTitle { get; init; }
public string? ActiveImportProgressLabel { get; init; } public string? ActiveImportProgressLabel { get; init; }
public IReadOnlyList<StoryIntelligencePipelineBookStatusViewModel> PipelineBooks { get; init; } = [];
public bool HasActiveImport => ActiveBatchID.HasValue; 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 public sealed class CompanionPresenceViewModel

View File

@ -58,6 +58,24 @@
</div> </div>
</div> </div>
@if (Model.StoryIntelligencePipeline is not null)
{
var pipeline = Model.StoryIntelligencePipeline;
<section class="list-section">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3">
<div>
<p class="eyebrow">Story Intelligence</p>
<h2>@(pipeline.IsComplete ? "Story Intelligence Complete" : "Continue Story Intelligence")</h2>
<p class="muted mb-0">@StoryIntelligenceDescription(pipeline)</p>
</div>
<a class="btn btn-primary"
asp-controller="Onboarding"
asp-action="ContinueStoryIntelligence"
asp-route-bookId="@Model.Book.BookID">@(pipeline.IsComplete ? "View Story Intelligence Summary" : "Continue Story Intelligence")</a>
</div>
</section>
}
<section class="list-section"> <section class="list-section">
<h2>Book metadata</h2> <h2>Book metadata</h2>
<div class="row g-3"> <div class="row g-3">
@ -171,3 +189,20 @@
</div> </div>
} }
</section> </section>
@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."
};
}
}

View File

@ -52,6 +52,32 @@
asp-route-batchId="@Model.StoryIntelligence.ActiveBatchID">View progress</a> asp-route-batchId="@Model.StoryIntelligence.ActiveBatchID">View progress</a>
</section> </section>
} }
else if (Model.StoryIntelligence.HasPipelineBooks)
{
var primaryPipelineBook = Model.StoryIntelligence.PipelineBooks.FirstOrDefault(item => !item.IsComplete)
?? Model.StoryIntelligence.PipelineBooks.First();
<section class="story-intelligence-dashboard-card story-intelligence-dashboard-card--compact"
aria-label="Story Intelligence status">
<div>
<p class="eyebrow">Story Intelligence</p>
<h2>@Model.StoryIntelligence.Title</h2>
<p>@Model.StoryIntelligence.Description</p>
<div class="story-intelligence-status-list">
@foreach (var item in Model.StoryIntelligence.PipelineBooks.Take(3))
{
<div class="story-intelligence-status-list__item">
<strong>@item.BookTitle</strong>
<span>@item.StatusLabel</span>
</div>
}
</div>
</div>
<a class="btn btn-primary"
asp-controller="Onboarding"
asp-action="ContinueStoryIntelligence"
asp-route-bookId="@primaryPipelineBook.BookID">@Model.StoryIntelligence.ButtonText</a>
</section>
}
@if (TempData["ArchiveMessage"] is string archiveMessage) @if (TempData["ArchiveMessage"] is string archiveMessage)
{ {

View File

@ -1261,6 +1261,24 @@ summary.story-review-chapter-heading {
color: var(--bs-secondary-color); 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 { .story-intelligence-dashboard-progress {
display: grid; display: grid;
grid-template-columns: auto auto; grid-template-columns: auto auto;