From a3a6c522875c662cee4d4e3b7eb8aa86c25b9cc5 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Wed, 8 Jul 2026 21:14:48 +0100 Subject: [PATCH] =?UTF-8?q?Phase=2020AI=20=E2=80=93=20Story=20Intelligence?= =?UTF-8?q?=20Pipeline=20&=20Character=20Review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PlotLine/Controllers/OnboardingController.cs | 52 +++++- .../OnboardingStoryIntelligenceService.cs | 58 ++++++- ...StoryIntelligenceCharacterImportService.cs | 65 ++++++- PlotLine/ViewModels/OnboardingViewModels.cs | 39 ++++- .../Views/Onboarding/StoryIntelligence.cshtml | 8 +- .../StoryIntelligenceCharacterComplete.cshtml | 53 ++++++ .../StoryIntelligenceCharacters.cshtml | 159 ++++++++++++++++++ .../StoryIntelligenceComplete.cshtml | 19 ++- .../StoryIntelligenceProgress.cshtml | 6 +- .../Onboarding/StoryIntelligenceReview.cshtml | 62 +------ .../_StoryIntelligencePipelineHeader.cshtml | 50 ++++++ .../_StoryIntelligencePipelineSummary.cshtml | 36 ++++ PlotLine/wwwroot/css/onboarding.css | 118 +++++++++++++ 13 files changed, 640 insertions(+), 85 deletions(-) create mode 100644 PlotLine/Views/Onboarding/StoryIntelligenceCharacterComplete.cshtml create mode 100644 PlotLine/Views/Onboarding/StoryIntelligenceCharacters.cshtml create mode 100644 PlotLine/Views/Onboarding/_StoryIntelligencePipelineHeader.cshtml create mode 100644 PlotLine/Views/Onboarding/_StoryIntelligencePipelineSummary.cshtml diff --git a/PlotLine/Controllers/OnboardingController.cs b/PlotLine/Controllers/OnboardingController.cs index 4468266..53ddefd 100644 --- a/PlotLine/Controllers/OnboardingController.cs +++ b/PlotLine/Controllers/OnboardingController.cs @@ -109,8 +109,32 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard return NotFound(); } - return model.HasActiveRuns - ? RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId }) + if (model.HasActiveRuns) + { + return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId }); + } + + return model.AllCommitted + ? RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId }) + : View(model); + } + + [HttpGet("story-intelligence/characters")] + public async Task StoryIntelligenceCharacters(Guid batchId) + { + var model = await storyIntelligence.GetProgressAsync(batchId); + if (model is null) + { + return NotFound(); + } + + if (model.HasActiveRuns) + { + return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId }); + } + + return !model.AllCommitted + ? RedirectToAction(nameof(StoryIntelligenceReview), new { batchId }) : View(model); } @@ -160,7 +184,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; return result.Success && progress.AllCommitted - ? RedirectToAction(nameof(StoryIntelligenceComplete), new { batchId }) + ? RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId }) : RedirectToAction(nameof(StoryIntelligenceReview), new { batchId }); } @@ -175,12 +199,32 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard } TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; - return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId = form.BatchID }); + return result.Success + ? RedirectToAction(nameof(StoryIntelligenceCharacterComplete), new { batchId = form.BatchID }) + : RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId = form.BatchID }); + } + + [HttpGet("story-intelligence/characters/complete")] + public async Task StoryIntelligenceCharacterComplete(Guid batchId) + { + var model = await storyIntelligence.GetCharacterImportResultAsync(batchId); + return model is null ? NotFound() : View(model); } [HttpGet("story-intelligence/complete")] public async Task StoryIntelligenceComplete(Guid batchId) { + var progress = await storyIntelligence.GetProgressAsync(batchId); + if (progress is null) + { + return NotFound(); + } + + if (!progress.PipelineDashboard.CharacterStageComplete) + { + return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId }); + } + var model = await storyIntelligence.GetCompletionAsync(batchId); return model is null ? NotFound() : View(model); } diff --git a/PlotLine/Services/OnboardingStoryIntelligenceService.cs b/PlotLine/Services/OnboardingStoryIntelligenceService.cs index f63a998..8e934c8 100644 --- a/PlotLine/Services/OnboardingStoryIntelligenceService.cs +++ b/PlotLine/Services/OnboardingStoryIntelligenceService.cs @@ -15,6 +15,7 @@ public interface IOnboardingStoryIntelligenceService Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAsync(Guid batchId, int runId); Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAllAsync(Guid batchId); Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportCharactersAsync(Guid batchId, StoryIntelligenceCharacterImportForm form); + Task GetCharacterImportResultAsync(Guid batchId); Task GetCompletionAsync(Guid batchId); } @@ -255,6 +256,7 @@ public sealed class OnboardingStoryIntelligenceService( }); } + var characterReview = await characterImport.BuildReviewAsync(batch); return new StoryIntelligenceProgressViewModel { BatchID = batch.BatchID, @@ -264,7 +266,15 @@ public sealed class OnboardingStoryIntelligenceService( ProjectName = batch.ProjectName, BookTitle = batch.BookTitle, Chapters = chapters, - CharacterReview = await characterImport.BuildReviewAsync(batch) + CharacterReview = characterReview, + PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel + { + ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete), + ScenesImported = chapters.Sum(chapter => chapter.ScenesCreated), + CharactersIdentified = characterReview.Candidates.Count + batch.CharacterDecisions.Count, + CharactersCreatedOrLinked = batch.CharacterDecisions.Count(decision => decision.CreatedOrLinked), + CharacterStageComplete = batch.CharacterStageComplete || characterReview.IsComplete + } }; } @@ -343,6 +353,27 @@ public sealed class OnboardingStoryIntelligenceService( return (await GetProgressAsync(batchId), result); } + public async Task GetCharacterImportResultAsync(Guid batchId) + { + var batch = await batchStore.GetAsync(RequireUserId(), batchId); + if (batch is null) + { + return null; + } + + return new StoryIntelligenceCharacterImportResultViewModel + { + BatchID = batch.BatchID, + ProjectID = batch.ProjectID, + BookID = batch.BookID, + CharactersCreated = batch.LastCharacterImportResult?.CharactersCreated ?? 0, + CharactersLinked = batch.LastCharacterImportResult?.CharactersLinked ?? 0, + CharactersIgnored = batch.LastCharacterImportResult?.CharactersIgnored ?? 0, + PovLinksResolved = batch.LastCharacterImportResult?.PovLinksResolved ?? 0, + ScenePeoplePanelsUpdated = batch.LastCharacterImportResult?.ScenePeoplePanelsUpdated ?? 0 + }; + } + public async Task GetCompletionAsync(Guid batchId) { var progress = await GetProgressAsync(batchId); @@ -360,7 +391,9 @@ public sealed class OnboardingStoryIntelligenceService( ProjectName = progress.ProjectName, BookTitle = progress.BookTitle, ChaptersCommitted = progress.CommittedChapterCount, - ScenesCreated = progress.Chapters.Sum(chapter => chapter.ScenesCreated) + ScenesCreated = progress.Chapters.Sum(chapter => chapter.ScenesCreated), + CharactersCreatedOrLinked = progress.PipelineDashboard.CharactersCreatedOrLinked, + CharacterStageComplete = progress.PipelineDashboard.CharacterStageComplete }; } @@ -591,6 +624,9 @@ public sealed class OnboardingStoryIntelligenceBatch public string? BookTitle { get; init; } public DateTime CreatedUtc { get; init; } = DateTime.UtcNow; public IReadOnlyList Items { get; init; } = []; + public List CharacterDecisions { get; } = []; + public StoryIntelligenceCharacterImportBatchResult? LastCharacterImportResult { get; set; } + public bool CharacterStageComplete { get; set; } } public sealed class OnboardingStoryIntelligenceBatchItem @@ -601,3 +637,21 @@ public sealed class OnboardingStoryIntelligenceBatchItem public int ChapterID { get; init; } public int RunID { get; init; } } + +public sealed class OnboardingStoryIntelligenceCharacterDecision +{ + public string Key { get; init; } = string.Empty; + public string Action { get; init; } = string.Empty; + public string? CharacterName { get; init; } + public int? CharacterID { get; init; } + public bool CreatedOrLinked { get; init; } +} + +public sealed class StoryIntelligenceCharacterImportBatchResult +{ + public int CharactersCreated { get; init; } + public int CharactersLinked { get; init; } + public int CharactersIgnored { get; init; } + public int PovLinksResolved { get; init; } + public int ScenePeoplePanelsUpdated { get; init; } +} diff --git a/PlotLine/Services/StoryIntelligenceCharacterImportService.cs b/PlotLine/Services/StoryIntelligenceCharacterImportService.cs index 1d6e547..049175b 100644 --- a/PlotLine/Services/StoryIntelligenceCharacterImportService.cs +++ b/PlotLine/Services/StoryIntelligenceCharacterImportService.cs @@ -33,12 +33,15 @@ public sealed class StoryIntelligenceCharacterImportService( public async Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch) { var data = await BuildCandidateDataAsync(batch); + var decidedKeys = batch.CharacterDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); + var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList(); return new StoryIntelligenceCharacterReviewViewModel { HasCommittedScenes = data.HasCommittedScenes, - CanImport = data.Candidates.Count > 0, + CanImport = visibleCandidates.Count > 0, AlreadyLinkedCount = data.AlreadyLinkedCount, - Candidates = data.Candidates.Select(candidate => new StoryIntelligenceCharacterReviewCandidateViewModel + IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0, + Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceCharacterReviewCandidateViewModel { Key = candidate.Key, CharacterName = candidate.DisplayName, @@ -62,11 +65,11 @@ public sealed class StoryIntelligenceCharacterImportService( } var choices = form.Characters - .Where(choice => choice.Include && !string.IsNullOrWhiteSpace(choice.Key)) + .Where(choice => !string.IsNullOrWhiteSpace(choice.Key)) .ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase); if (choices.Count == 0) { - return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose at least one character to create or link." }; + return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose at least one character to create, link or ignore." }; } var existingIndex = await BuildCharacterIndexAsync(batch.ProjectID); @@ -74,6 +77,8 @@ public sealed class StoryIntelligenceCharacterImportService( var roleTypes = lookupData.RoleTypes; var presenceTypes = lookupData.PresenceTypes; var created = 0; + var linkedExisting = 0; + var ignored = 0; var linkedAppearances = 0; var povLinks = 0; var resolvedCharacters = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -85,11 +90,22 @@ public sealed class StoryIntelligenceCharacterImportService( continue; } + if (string.Equals(choice.Action, StoryIntelligenceCharacterImportActions.Ignore, StringComparison.OrdinalIgnoreCase)) + { + ignored++; + AddDecision(batch, candidate.Key, StoryIntelligenceCharacterImportActions.Ignore, candidate.DisplayName, null, false); + continue; + } + var requestedName = Clean(choice.ImportName); var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName; - var characterId = candidate.ExistingCharacterID - ?? existingIndex.Find(importName)?.CharacterID - ?? existingIndex.Find(candidate.DisplayName)?.CharacterID; + var forceCreateSeparate = string.Equals(choice.Action, StoryIntelligenceCharacterImportActions.CreateSeparate, StringComparison.OrdinalIgnoreCase); + var matchedExistingId = forceCreateSeparate + ? null + : candidate.ExistingCharacterID + ?? existingIndex.Find(importName)?.CharacterID + ?? existingIndex.Find(candidate.DisplayName)?.CharacterID; + var characterId = matchedExistingId; if (!characterId.HasValue) { @@ -102,6 +118,10 @@ public sealed class StoryIntelligenceCharacterImportService( }); created++; } + else + { + linkedExisting++; + } AddResolvedName(resolvedCharacters, candidate.DisplayName, characterId.Value); AddResolvedName(resolvedCharacters, importName, characterId.Value); @@ -123,6 +143,8 @@ public sealed class StoryIntelligenceCharacterImportService( }); linkedAppearances++; } + + AddDecision(batch, candidate.Key, choice.Action, importName, characterId.Value, true); } foreach (var scene in data.SceneAnalyses) @@ -137,10 +159,22 @@ public sealed class StoryIntelligenceCharacterImportService( povLinks++; } + batch.CharacterStageComplete = true; + batch.LastCharacterImportResult = new StoryIntelligenceCharacterImportBatchResult + { + CharactersCreated = created, + CharactersLinked = linkedExisting, + CharactersIgnored = ignored, + PovLinksResolved = povLinks, + ScenePeoplePanelsUpdated = linkedAppearances + }; + logger.LogInformation( - "Imported Story Intelligence characters for batch {BatchID}. Created={Created} LinkedAppearances={LinkedAppearances} PovLinks={PovLinks}", + "Imported Story Intelligence characters for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Ignored={Ignored} LinkedAppearances={LinkedAppearances} PovLinks={PovLinks}", batch.BatchID, created, + linkedExisting, + ignored, linkedAppearances, povLinks); @@ -148,10 +182,23 @@ public sealed class StoryIntelligenceCharacterImportService( { Success = true, ScenesCreated = linkedAppearances, - Message = $"Characters imported. {created:N0} character(s) created, {linkedAppearances:N0} scene appearance(s) linked, {povLinks:N0} POV link(s) updated." + Message = $"Characters imported. {created:N0} created, {linkedExisting:N0} linked, {ignored:N0} ignored, {linkedAppearances:N0} scene appearance(s) updated, {povLinks:N0} POV link(s) resolved." }; } + private static void AddDecision(OnboardingStoryIntelligenceBatch batch, string key, string action, string? characterName, int? characterId, bool createdOrLinked) + { + batch.CharacterDecisions.RemoveAll(decision => string.Equals(decision.Key, key, StringComparison.OrdinalIgnoreCase)); + batch.CharacterDecisions.Add(new OnboardingStoryIntelligenceCharacterDecision + { + Key = key, + Action = action, + CharacterName = characterName, + CharacterID = characterId, + CreatedOrLinked = createdOrLinked + }); + } + private async Task BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch) { var existingIndex = await BuildCharacterIndexAsync(batch.ProjectID); diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs index 0f8725c..f8c1b03 100644 --- a/PlotLine/ViewModels/OnboardingViewModels.cs +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -39,6 +39,11 @@ public sealed class OnboardingJourneyHeaderViewModel public string AriaLabel { get; init; } = "Manuscript import progress"; } +public sealed class StoryIntelligencePipelineHeaderViewModel +{ + public string CurrentStage { get; init; } = "Analyse Manuscript"; +} + public sealed class WritingJourneyOnboardingForm { [Required(ErrorMessage = "Choose the closest writing stage before continuing.")] @@ -138,6 +143,7 @@ public sealed class StoryIntelligenceProgressViewModel public string? BookTitle { get; init; } public IReadOnlyList Chapters { get; init; } = []; public StoryIntelligenceCharacterReviewViewModel CharacterReview { get; init; } = new(); + public StoryIntelligencePipelineDashboardViewModel PipelineDashboard { get; init; } = new(); public int ChapterCount => Chapters.Count; public int CompletedChapterCount => Chapters.Count(chapter => chapter.IsRunComplete); public int CommittedChapterCount => Chapters.Count(chapter => chapter.HasCompletedCommit); @@ -154,11 +160,21 @@ public sealed class StoryIntelligenceProgressViewModel public bool HasCharactersToReview => CharacterReview.Candidates.Count > 0; } +public sealed class StoryIntelligencePipelineDashboardViewModel +{ + public int ChaptersAnalysed { get; init; } + public int ScenesImported { get; init; } + public int CharactersIdentified { get; init; } + public int CharactersCreatedOrLinked { get; init; } + public bool CharacterStageComplete { get; init; } +} + public sealed class StoryIntelligenceCharacterReviewViewModel { public bool CanImport { get; init; } public bool HasCommittedScenes { get; init; } public int AlreadyLinkedCount { get; init; } + public bool IsComplete { get; init; } public IReadOnlyList Candidates { get; init; } = []; } @@ -186,10 +202,17 @@ public sealed class StoryIntelligenceCharacterImportForm public sealed class StoryIntelligenceCharacterImportChoiceForm { public string Key { get; set; } = string.Empty; - public bool Include { get; set; } + public string Action { get; set; } = StoryIntelligenceCharacterImportActions.Approve; public string? ImportName { get; set; } } +public static class StoryIntelligenceCharacterImportActions +{ + public const string Approve = "Approve"; + public const string Ignore = "Ignore"; + public const string CreateSeparate = "CreateSeparate"; +} + public sealed class StoryIntelligenceCompletionViewModel { public StoryIntelligenceJobProgress Job { get; init; } = new(); @@ -201,6 +224,20 @@ public sealed class StoryIntelligenceCompletionViewModel public string? BookTitle { get; init; } public int ChaptersCommitted { get; init; } public int ScenesCreated { get; init; } + public int CharactersCreatedOrLinked { get; init; } + public bool CharacterStageComplete { get; init; } +} + +public sealed class StoryIntelligenceCharacterImportResultViewModel +{ + public Guid BatchID { get; init; } + public int ProjectID { get; init; } + public int BookID { get; init; } + public int CharactersCreated { get; init; } + public int CharactersLinked { get; init; } + public int CharactersIgnored { get; init; } + public int PovLinksResolved { get; init; } + public int ScenePeoplePanelsUpdated { get; init; } } public sealed class StoryIntelligenceOnboardingChapterViewModel diff --git a/PlotLine/Views/Onboarding/StoryIntelligence.cshtml b/PlotLine/Views/Onboarding/StoryIntelligence.cshtml index 7e6addd..125b7a4 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligence.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligence.cshtml @@ -5,9 +5,9 @@
- +
-

Step 8 of 9

+

Analyse manuscript

Analyse Manuscript

PlotDirector will analyse approved chapters in the background and prepare scene suggestions for review.

@@ -27,7 +27,7 @@

Analysis has already started

You can continue reviewing progress for the approved chapters.

- Review analysis + Continue pipeline
@@ -61,7 +61,7 @@

Nothing will be created yet. PlotDirector will prepare scene suggestions for review.

-

For this step, only scenes can be created in PlotDirector. Characters, locations, assets and relationships stay as later suggestions.

+

Scenes are reviewed first. Character review follows after scenes are created, with locations, assets, relationships and knowledge shown as upcoming Story Intelligence stages.

@if (!string.IsNullOrWhiteSpace(Model.Message)) diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceCharacterComplete.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceCharacterComplete.cshtml new file mode 100644 index 0000000..cbe4b4f --- /dev/null +++ b/PlotLine/Views/Onboarding/StoryIntelligenceCharacterComplete.cshtml @@ -0,0 +1,53 @@ +@model StoryIntelligenceCharacterImportResultViewModel +@{ + ViewData["Title"] = "Characters created"; +} + +
+
+ +
+

Build Story Database

+
+ +

Characters created

+
+

PlotDirector updated your story database from the approved character decisions. The next Story Intelligence stages are ready to slot in as they are implemented.

+
+ +
+
+ Characters created + @Model.CharactersCreated.ToString("N0") +
+
+ Characters linked + @Model.CharactersLinked.ToString("N0") +
+
+ Characters ignored + @Model.CharactersIgnored.ToString("N0") +
+
+ POV links resolved + @Model.PovLinksResolved.ToString("N0") +
+
+ Scene People panels updated + @Model.ScenePeoplePanelsUpdated.ToString("N0") +
+
+ +
+
Review LocationsComing Soon
+
Review AssetsComing Soon
+
Review RelationshipsComing Soon
+
Review KnowledgeComing Soon
+
+ + +
+
diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceCharacters.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceCharacters.cshtml new file mode 100644 index 0000000..1f64a62 --- /dev/null +++ b/PlotLine/Views/Onboarding/StoryIntelligenceCharacters.cshtml @@ -0,0 +1,159 @@ +@model StoryIntelligenceProgressViewModel +@{ + ViewData["Title"] = "Review characters"; +} + +
+
+ +
+

Review Story Intelligence

+

Review Characters

+

Approve the characters PlotDirector should create or link from your imported scenes. You stay in control of every character record.

+
+ + @if (TempData["OnboardingStoryIntelligenceError"] is string error) + { +
@error
+ } + @if (TempData["OnboardingStoryIntelligenceMessage"] is string message) + { +
@message
+ } + + + +
+ @if (Model.CharacterReview.Candidates.Count == 0) + { +
+ No character decisions are waiting. +

Characters are already up to date, or there were no character suggestions ready to import.

+
+
+ @FutureStage("Review Locations") + @FutureStage("Review Assets") + @FutureStage("Review Relationships") + @FutureStage("Review Knowledge") +
+ + } + else + { +
+ + + + + +
+ +
+ +
+ @for (var i = 0; i < Model.CharacterReview.Candidates.Count; i++) + { + var candidate = Model.CharacterReview.Candidates[i]; +
+ + + + @candidate.CharacterName + @candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s") + + @candidate.Confidence + + + + +
+
+
First appearance
@Display(candidate.ExampleFirstAppearance)
+
Possible aliases
@(candidate.PossibleAliases.Count == 0 ? "None detected" : string.Join(", ", candidate.PossibleAliases))
+
Existing match
@(candidate.ExistingCharacterName ?? "None")
+
+ + @if (candidate.IsExistingMatch) + { +
+ Possible existing character +

@candidate.CharacterName may already be @candidate.ExistingCharacterName. Choose whether to link them or create a separate character.

+
+ } + +
+ Decision + + @if (candidate.IsExistingMatch) + { + + } + +
+ + + +
+
+ } +
+ +
+ Back to scenes + +
+
+ } +
+
+
+ +@section Scripts { + +} + +@functions { + private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "Not detected" : value; + + private static Microsoft.AspNetCore.Html.IHtmlContent FutureStage(string label) + => new Microsoft.AspNetCore.Html.HtmlString($"
{System.Net.WebUtility.HtmlEncode(label)}Coming Soon
"); +} diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceComplete.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceComplete.cshtml index f5e5dc8..5c2b4af 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceComplete.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceComplete.cshtml @@ -5,14 +5,14 @@
- +
-

Step 9 of 9

+

Story Intelligence pipeline

-

Manuscript import complete

+

Story Intelligence complete

-

PlotDirector created scenes from the approved manuscript chapters. Characters, locations, assets and relationships were left untouched.

+

PlotDirector completed every implemented Story Intelligence stage for this manuscript. Future stages will continue from this same pipeline as they become available.

@@ -25,8 +25,8 @@ @Model.ScenesCreated
- Notes needing review - None imported + Characters created / linked + @Model.CharactersCreatedOrLinked.ToString("N0")
Book @@ -34,6 +34,13 @@
+
+
Review LocationsComing Soon
+
Review AssetsComing Soon
+
Review RelationshipsComing Soon
+
Review KnowledgeComing Soon
+
+
View scenes View book diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml index f3a8e16..0e6798c 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml @@ -13,9 +13,9 @@ data-story-intelligence-run-ids="@runIds" data-story-max-progress-percent="@progressPercent" data-story-intelligence-has-active-runs="@Model.HasActiveRuns.ToString().ToLowerInvariant()"> - +
-

Step 8 of 9

+

Analyse manuscript

Reading your manuscript

PlotDirector is analysing your chapters and preparing scenes for review. You can safely leave this page and return later.

@@ -142,7 +142,7 @@ Review analysis + asp-route-batchId="@Model.BatchID">Review scenes } }
diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceReview.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceReview.cshtml index e60fddc..11ddb96 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceReview.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceReview.cshtml @@ -2,7 +2,7 @@ @using System.Security.Claims @inject IConfiguration Configuration @{ - ViewData["Title"] = "Review analysis"; + ViewData["Title"] = "Review scenes"; var adminEmails = Configuration.GetSection("Admin:AllowedEmails").Get() ?? []; var userEmail = User.FindFirstValue(ClaimTypes.Email); var isAdmin = !string.IsNullOrWhiteSpace(userEmail) @@ -13,11 +13,11 @@
- +
-

Step 9 of 9

-

Review Story Intelligence Results

-

PlotDirector has finished reading your manuscript. Review the detected scenes first, then approve any characters you want to add or link.

+

Review Story Intelligence

+

Review Scenes

+

PlotDirector has finished reading your manuscript. Review the detected scenes before creating them in your story database.

@if (TempData["OnboardingStoryIntelligenceError"] is string error) @@ -178,56 +178,6 @@ Scene creation uses PlotDirector import safeguards. Duplicate imports are blocked, and failures roll back safely.
- @if (Model.CharacterReview.HasCommittedScenes) - { -
-

Review Characters

- @if (Model.CharacterReview.Candidates.Count == 0) - { -

No new character suggestions are waiting. Existing scene character links are already up to date where PlotDirector could match them.

- } - else - { -

Approve the characters PlotDirector should create or link from the imported scenes. You can rename new characters before import.

-
- -
- @for (var i = 0; i < Model.CharacterReview.Candidates.Count; i++) - { - var candidate = Model.CharacterReview.Candidates[i]; -
- -
- - @candidate.ActionLabel -
-
-
Appears in
@candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s")
-
Confidence
@candidate.Confidence
-
Match
@(candidate.ExistingCharacterName ?? "New character")
-
- @if (candidate.PossibleAliases.Count > 0) - { -

Aliases: @string.Join(", ", candidate.PossibleAliases)

- } - @if (!string.IsNullOrWhiteSpace(candidate.ExampleFirstAppearance)) - { -

@candidate.ExampleFirstAppearance

- } - - -
- } -
- -
- } -
- } -
@if (Model.BatchID.HasValue) { @@ -241,7 +191,7 @@ } else if (Model.AllCommitted) { - Continue + Review characters } }
diff --git a/PlotLine/Views/Onboarding/_StoryIntelligencePipelineHeader.cshtml b/PlotLine/Views/Onboarding/_StoryIntelligencePipelineHeader.cshtml new file mode 100644 index 0000000..b90ac0e --- /dev/null +++ b/PlotLine/Views/Onboarding/_StoryIntelligencePipelineHeader.cshtml @@ -0,0 +1,50 @@ +@model StoryIntelligencePipelineHeaderViewModel +@{ + var phases = new[] + { + new { Name = "Phase 1", Label = "Prepare manuscript", Stages = new[] { "Connect Word Companion", "Scan Manuscript", "Review Chapters" } }, + new { Name = "Phase 2", Label = "Analyse manuscript", Stages = new[] { "Analyse Manuscript" } }, + new { Name = "Phase 3", Label = "Review Story Intelligence", Stages = new[] { "Review Scenes", "Review Characters", "Review Locations", "Review Assets", "Review Relationships", "Review Knowledge" } }, + new { Name = "Phase 4", Label = "Build Story Database", Stages = new[] { "Create Scenes", "Create Characters", "Complete" } } + }; + var flatStages = phases.SelectMany(phase => phase.Stages).ToList(); + var currentIndex = Math.Max(0, flatStages.FindIndex(stage => string.Equals(stage, Model.CurrentStage, StringComparison.OrdinalIgnoreCase))); + var percent = flatStages.Count <= 1 ? 100 : Math.Clamp((currentIndex * 100) / (flatStages.Count - 1), 0, 100); +} + +
+ @Model.CurrentStage +
+ +
+
+
    + @foreach (var phase in phases) + { + var firstStageIndex = flatStages.FindIndex(stage => phase.Stages.Contains(stage)); + var lastStageIndex = firstStageIndex + phase.Stages.Length - 1; + var phaseClass = currentIndex > lastStageIndex + ? "is-complete" + : currentIndex >= firstStageIndex && currentIndex <= lastStageIndex + ? "is-current" + : "is-upcoming"; +
  1. + @phase.Name.Replace("Phase ", string.Empty) + @phase.Label + @StageSummary(phase.Stages, currentIndex, firstStageIndex) +
  2. + } +
+ +@functions { + private static string StageSummary(IReadOnlyList stages, int currentIndex, int firstStageIndex) + { + var localIndex = currentIndex - firstStageIndex; + if (localIndex >= 0 && localIndex < stages.Count) + { + return stages[localIndex]; + } + + return stages.Count == 1 ? stages[0] : $"{stages.Count} stages"; + } +} diff --git a/PlotLine/Views/Onboarding/_StoryIntelligencePipelineSummary.cshtml b/PlotLine/Views/Onboarding/_StoryIntelligencePipelineSummary.cshtml new file mode 100644 index 0000000..c087803 --- /dev/null +++ b/PlotLine/Views/Onboarding/_StoryIntelligencePipelineSummary.cshtml @@ -0,0 +1,36 @@ +@model StoryIntelligencePipelineDashboardViewModel + +
+
+ Chapters analysed + ✓ @Model.ChaptersAnalysed.ToString("N0") +
+
+ Scenes imported + ✓ @Model.ScenesImported.ToString("N0") +
+
+ Characters identified + @Model.CharactersIdentified.ToString("N0") +
+
+ Characters created / linked + @(Model.CharacterStageComplete ? $"Done: {Model.CharactersCreatedOrLinked:N0}" : "In review") +
+
+ Locations + Coming next +
+
+ Assets + Coming next +
+
+ Relationships + Coming next +
+
+ Knowledge + Coming next +
+
diff --git a/PlotLine/wwwroot/css/onboarding.css b/PlotLine/wwwroot/css/onboarding.css index 8a217d1..2755379 100644 --- a/PlotLine/wwwroot/css/onboarding.css +++ b/PlotLine/wwwroot/css/onboarding.css @@ -863,6 +863,122 @@ summary.story-review-chapter-heading { display: flex; justify-content: flex-end; gap: .65rem; + flex-wrap: wrap; +} + +.story-pipeline-stepper small { + display: block; + color: var(--bs-secondary-color); + font-size: .72rem; + font-weight: 700; + margin-top: .15rem; +} + +.story-character-card-grid, +.story-future-stage-grid { + display: grid; + gap: .75rem; +} + +.story-character-card { + border: 1px solid rgba(31, 42, 68, .1); + border-radius: 8px; + background: rgba(255, 255, 255, .64); + padding: .8rem; +} + +.story-character-card summary { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: .65rem; + align-items: center; + cursor: pointer; +} + +.story-character-card summary span { + display: grid; + gap: .1rem; + min-width: 0; +} + +.story-character-card summary small, +.story-character-card summary em { + color: var(--bs-secondary-color); + font-size: .82rem; + font-style: normal; + font-weight: 700; +} + +.story-character-card__body { + display: grid; + gap: .75rem; + padding-top: .85rem; +} + +.story-character-card__body dl { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: .65rem; + margin: 0; +} + +.story-character-card__body dt { + color: var(--bs-secondary-color); + font-size: .78rem; + font-weight: 800; + text-transform: uppercase; +} + +.story-character-card__body dd { + margin: .15rem 0 0; +} + +.story-character-actions { + display: flex; + flex-wrap: wrap; + gap: .75rem; + border: 0; + padding: 0; + margin: 0; +} + +.story-character-actions legend { + width: 100%; + color: var(--bs-secondary-color); + font-size: .78rem; + font-weight: 800; + text-transform: uppercase; +} + +.story-character-actions label { + display: inline-flex; + align-items: center; + gap: .35rem; + font-weight: 700; +} + +.story-future-stage-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin: 1rem 0; +} + +.story-future-stage { + border: 1px dashed rgba(31, 42, 68, .22); + border-radius: 8px; + padding: .85rem; + background: rgba(255, 255, 255, .45); +} + +.story-future-stage strong, +.story-future-stage span { + display: block; +} + +.story-future-stage span { + color: var(--bs-secondary-color); + font-size: .84rem; + font-weight: 700; + margin-top: .2rem; } .story-scene-preview-list { @@ -1283,6 +1399,8 @@ summary.story-review-chapter-heading { .story-live-stats, .story-review-summary, .story-review-scene-panel, + .story-character-card__body dl, + .story-future-stage-grid, .story-scene-preview dl, .story-live-current, .story-live-feed-grid,