diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index a11f20d..4e56cbb 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -20,6 +20,7 @@ var tests = new (string Name, Action Test)[] ("Truncated JSON is rejected", RejectsTruncatedJson), ("Repaired JSON deserialises into SceneIntelligenceScene", RepairedJsonDeserialises), ("Chapter Structure confidence 0. repairs and deserialises", ChapterStructureConfidenceRepairs), + ("Chapter Structure boundary normaliser repairs gaps and overlaps", ChapterStructureBoundaryNormaliserRepairsGapsAndOverlaps), ("Shared parser reports raw chapter output on unrecoverable JSON", SharedParserReportsRawOutput), ("Character filtering rejects generic groups", CharacterFilteringRejectsGenericGroups), ("Character filtering preserves titled names", CharacterFilteringPreservesTitledNames), @@ -858,6 +859,54 @@ static void ChapterStructureConfidenceRepairs() Assert(result.Warnings.Any(warning => warning.Contains("$.sceneBoundaries[0].confidence", StringComparison.Ordinal)), "Repair warning did not include JSON path."); } +static void ChapterStructureBoundaryNormaliserRepairsGapsAndOverlaps() +{ + var chapter = new ChapterStructureModel + { + SchemaVersion = "1.0", + ChapterSummary = "A chapter with several scene movements.", + SceneBoundaries = + [ + new ChapterSceneBoundary + { + SceneNumber = 1, + StartParagraph = 1, + EndParagraph = 4, + StructuralSummary = "Opening scene summary with enough context for validation.", + Confidence = 0.8m, + Reason = "Opening location." + }, + new ChapterSceneBoundary + { + SceneNumber = 2, + StartParagraph = 4, + EndParagraph = 7, + StructuralSummary = "Second scene summary with enough context for validation.", + Confidence = 0.8m, + Reason = "A new exchange begins." + }, + new ChapterSceneBoundary + { + SceneNumber = 3, + StartParagraph = 9, + EndParagraph = 10, + StructuralSummary = "Final scene summary with enough context for validation.", + Confidence = 0.8m, + Reason = "Final turn." + } + ] + }; + + var result = ChapterStructureBoundaryNormaliser.Normalise(chapter, 10); + var boundaries = result.ChapterStructure?.SceneBoundaries; + + Assert(boundaries is { Count: 3 }, "Boundary normaliser should preserve the detected scene count."); + Assert(boundaries[0].StartParagraph == 1 && boundaries[0].EndParagraph == 4, "First boundary should remain unchanged."); + Assert(boundaries[1].StartParagraph == 5 && boundaries[1].EndParagraph == 7, "Overlap should be repaired by moving scene 2 start to paragraph 5."); + Assert(boundaries[2].StartParagraph == 8 && boundaries[2].EndParagraph == 10, "Gap should be repaired by moving scene 3 start to paragraph 8."); + Assert(result.Issues.Count >= 2, "Boundary repairs should be reported as warnings."); +} + static void SharedParserReportsRawOutput() { const string raw = """{"schemaVersion":"1.0","sceneBoundaries":["""; @@ -1534,7 +1583,7 @@ static void StructuralImportPersistsCanonicalSceneSourceText() Assert(migration.Contains("IX_SceneManuscriptSources_BookChapter", StringComparison.Ordinal), "Scene source text should be indexed for book/chapter retrieval."); Assert(onboarding.Contains("SaveSceneManuscriptSourcesAsync", StringComparison.Ordinal), "Structural import should persist scene source text before Word sync."); Assert(onboarding.IndexOf("await SaveSceneManuscriptSourcesAsync(preview, structuralScenes, result)", StringComparison.Ordinal) - < onboarding.IndexOf("await PublishBuildProgress(userId, preview.PreviewID, \"Updating and saving Word markers", StringComparison.Ordinal), "Scene source text should be persisted before marker sync can complete."); + < onboarding.IndexOf("await PublishBuildProgress(userId, preview.PreviewID, \"Updating Word document with scene markers", StringComparison.Ordinal), "Scene source text should be persisted before marker sync can complete."); Assert(analyzer.Contains("string.Join(Environment.NewLine + Environment.NewLine", StringComparison.Ordinal), "Scene source text should preserve paragraph separation."); Assert(analyzer.Contains("item.IncludeInSourceText", StringComparison.Ordinal), "Scene source text should exclude non-prose structural paragraphs such as hidden markers/separators."); } @@ -1588,16 +1637,24 @@ static void CoreImportWizardOrdersScenesBeforeCharacters() var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs")); Assert(review.Contains("value=\"structure\"", StringComparison.Ordinal), "Chapter review should submit to structural import, not character review."); + Assert(review.Contains("Continue to Scene Detection", StringComparison.Ordinal), "Chapter review should move to scene detection with clear wording."); + Assert(!review.Contains("Include all scenes", StringComparison.Ordinal), "Chapter review should not show provisional scene bulk controls."); + Assert(!review.Contains("Exclude all scenes", StringComparison.Ordinal), "Chapter review should not show provisional scene bulk controls."); + Assert(!review.Contains("Include scene", StringComparison.Ordinal), "Chapter review should not show provisional scene selectors."); + Assert(!review.Contains("No scene breaks were detected. PlotDirector will treat this chapter as one scene for now.", StringComparison.Ordinal), "Chapter review should not expose one-scene-per-chapter fallback text."); + Assert(review.Split("name=\"intent\" value=\"structure\"", StringSplitOptions.None).Length - 1 == 1, "Chapter review should have exactly one structure progression action."); Assert(controller.Contains("var readyToImport = string.Equals(intent, \"structure\"", StringComparison.Ordinal), "Controller should treat structure intent as the scene-creation gate."); Assert(controller.Contains("Import the approved chapters and scenes before reviewing characters.", StringComparison.Ordinal), "Character review should be guarded until structural import exists."); Assert(controller.Contains("continueToCharacters || continueCharacterReview || backCharacterReview || completeCoreImport", StringComparison.Ordinal), "Structure-save errors should remain on chapter review rather than jumping to characters."); Assert(controller.Contains("FinaliseCharacterReviewAsync", StringComparison.Ordinal), "Character review should have a distinct post-structure finalisation path."); Assert(onboarding.Contains("ValidateScanStructure(review, preview);\n review = new ManuscriptScanReviewDecision", StringComparison.Ordinal), "Saving chapter review should validate only structure before scene import."); Assert(onboarding.Contains("ValidateScanStructure(review, preview);\n\n var chapterDecisions", StringComparison.Ordinal), "Building approved structure should not validate unresolved character decisions."); + Assert(onboarding.Contains("BuildSceneStructureSummary(chapterDecisions, structuralScenes)", StringComparison.Ordinal), "Detected scene results should be preserved for review before characters."); Assert(onboarding.Contains("ValidateScanReview(new ManuscriptScanReviewDecision", StringComparison.Ordinal), "Character validation should run when finalising character review."); Assert(onboarding.Contains("Import the approved chapters and scenes before reviewing characters.", StringComparison.Ordinal), "Character finalisation should refuse to run before structural scenes exist."); + Assert(complete.Contains("Detected scene structure", StringComparison.Ordinal), "Scene detection result page should show detected counts by chapter."); Assert(complete.Contains("Review characters", StringComparison.Ordinal), "After Word sync, the next onboarding step should be character review."); - Assert(complete.Contains("Word may now be closed; keep this browser workflow open", StringComparison.Ordinal), "Word safe-close must be distinct from browser/core-import completion."); + Assert(complete.Contains("Word may now be closed if you wish; keep this browser workflow open", StringComparison.Ordinal), "Word safe-close must be distinct from browser/core-import completion."); } static void CoreImportRemapsCharactersAfterReview() @@ -1894,11 +1951,13 @@ static void OnboardingWizardKeepsCharacterPagesInsideOneMainStep() var storyService = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/OnboardingStoryIntelligenceService.cs")); Assert(!header.Contains("BuildDynamicSteps", StringComparison.Ordinal), "Onboarding header should not create one top-level step per character page."); - Assert(header.Contains("new(\"Import Scenes\", 8)", StringComparison.Ordinal), "Onboarding header should include scene import as a distinct main step."); + Assert(header.Contains("new(\"Detect Scenes\", 8)", StringComparison.Ordinal), "Onboarding header should include scene detection as a distinct main step."); Assert(header.Contains("new(\"Review Characters\", 9)", StringComparison.Ordinal), "Onboarding header should keep character review as one main step."); + Assert(!header.Contains("Story Summary", StringComparison.Ordinal), "Core onboarding header should not present Story Summary as a mandatory import step."); + Assert(!header.Contains("Story Context", StringComparison.Ordinal), "Core onboarding header should not present Story Context as a mandatory import step."); Assert(review.Contains("var currentStep = isCharacterStep ? 9 : 7;", StringComparison.Ordinal), "Scan review should keep every character page on the same main wizard step."); Assert(review.Contains("onboarding-subprogress", StringComparison.Ordinal), "Character pagination should be shown as section-level progress."); - Assert(storyService.Contains("const int totalWizardSteps = 12;", StringComparison.Ordinal), "Story Intelligence summary/context should use the fixed wizard step count."); + Assert(storyService.Contains("const int totalWizardSteps = 10;", StringComparison.Ordinal), "Story Intelligence summary/context should use the fixed core wizard step count."); } static void StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel() diff --git a/PlotLine/Hubs/WordCompanionFollowHub.cs b/PlotLine/Hubs/WordCompanionFollowHub.cs index 6347f95..a7f5b86 100644 --- a/PlotLine/Hubs/WordCompanionFollowHub.cs +++ b/PlotLine/Hubs/WordCompanionFollowHub.cs @@ -213,9 +213,25 @@ public sealed class WordCompanionFollowHub( PreviewID = previewId }); - var result = await onboardingService.BuildApprovedStructureAsync( - previewId, - progress => Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", progress)); + OnboardingManuscriptBuildResult? result; + try + { + result = await onboardingService.BuildApprovedStructureAsync( + previewId, + progress => Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", progress)); + } + catch (InvalidOperationException ex) + { + await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", new OnboardingBuildProgress + { + Status = "Failed", + Message = ex.Message, + PercentComplete = 25, + PreviewID = previewId + }); + throw new HubException(ex.Message); + } + if (result is null) { throw new HubException("The reviewed scan could not be found."); diff --git a/PlotLine/Models/ManuscriptScanModels.cs b/PlotLine/Models/ManuscriptScanModels.cs index c31244d..7bbc026 100644 --- a/PlotLine/Models/ManuscriptScanModels.cs +++ b/PlotLine/Models/ManuscriptScanModels.cs @@ -199,9 +199,30 @@ public sealed class OnboardingManuscriptBuildResult public IReadOnlyList CharacterMappings { get; init; } = []; public IReadOnlyList ChapterMarkerInstructions { get; init; } = []; public IReadOnlyList SceneMarkerInstructions { get; init; } = []; + public IReadOnlyList SceneStructure { get; init; } = []; public string? MarkerWarning { get; init; } } +public sealed class OnboardingSceneStructureSummary +{ + public string TemporaryChapterKey { get; init; } = string.Empty; + public int ChapterNumber { get; init; } + public string ChapterTitle { get; init; } = string.Empty; + public int SceneCount { get; init; } + public int WordCount { get; init; } + public IReadOnlyList Scenes { get; init; } = []; +} + +public sealed class OnboardingSceneStructureDetail +{ + public int SceneNumberWithinChapter { get; init; } + public string Title { get; init; } = string.Empty; + public int WordCount { get; init; } + public string? StructuralSummary { get; init; } + public int StartParagraph { get; init; } + public int EndParagraph { get; init; } +} + public sealed class OnboardingBuildChapterMap { public string TemporaryChapterKey { get; init; } = string.Empty; diff --git a/PlotLine/Services/ChapterStructureBoundaryNormaliser.cs b/PlotLine/Services/ChapterStructureBoundaryNormaliser.cs index 6f438c3..24b0ccd 100644 --- a/PlotLine/Services/ChapterStructureBoundaryNormaliser.cs +++ b/PlotLine/Services/ChapterStructureBoundaryNormaliser.cs @@ -13,6 +13,7 @@ public static class ChapterStructureBoundaryNormaliser return new ChapterStructureBoundaryNormalisationResult(chapterStructure, []); } + var issues = new List(); var boundaries = chapterStructure.SceneBoundaries; var finalIndex = boundaries.Count - 1; var final = boundaries[finalIndex]; @@ -22,31 +23,23 @@ public static class ChapterStructureBoundaryNormaliser if (IsTrailingEmptyBoundary(final, previous, paragraphCount, phantomParagraph) || IsTrailingInvalidPlaceholder(final, previous, paragraphCount, phantomParagraph)) { - var normalisedBoundaries = boundaries + boundaries = boundaries .Take(finalIndex) .Select((boundary, index) => CopyBoundary(boundary, index + 1)) .ToList(); - var normalised = new ChapterStructureModel - { - SchemaVersion = chapterStructure.SchemaVersion, - ChapterSummary = chapterStructure.ChapterSummary, - SceneBoundaries = normalisedBoundaries, - ExtensionData = chapterStructure.ExtensionData - }; - - var issue = new ValidationIssue + issues.Add(new ValidationIssue { Severity = "Warning", Path = $"sceneBoundaries[{finalIndex}]", Message = BuildNormalisationMessage(final, finalIndex, paragraphCount, phantomParagraph), SuggestedFix = "Do not return a final scene boundary unless it references an actual supplied paragraph." - }; - - return new ChapterStructureBoundaryNormalisationResult(normalised, [issue]); + }); } - return new ChapterStructureBoundaryNormalisationResult(chapterStructure, []); + var continuity = RepairContinuity(chapterStructure, boundaries, paragraphCount); + issues.AddRange(continuity.Issues); + return new ChapterStructureBoundaryNormalisationResult(continuity.ChapterStructure, issues); } public static void AddIssuesTo(ValidationResult validation, ChapterStructureBoundaryNormalisationResult normalisation) @@ -98,6 +91,137 @@ public static class ChapterStructureBoundaryNormaliser ExtensionData = boundary.ExtensionData }; + private static ChapterStructureBoundaryNormalisationResult RepairContinuity( + ChapterStructureModel chapterStructure, + IReadOnlyList sourceBoundaries, + int paragraphCount) + { + if (sourceBoundaries.Count == 0) + { + return new ChapterStructureBoundaryNormalisationResult(chapterStructure, []); + } + + var issues = new List(); + var ordered = sourceBoundaries + .Select((boundary, index) => new { Boundary = boundary, OriginalIndex = index }) + .OrderBy(item => item.Boundary.StartParagraph ?? int.MaxValue) + .ThenBy(item => item.Boundary.EndParagraph ?? int.MaxValue) + .ToList(); + + if (ordered.Where((item, index) => item.OriginalIndex != index).Any()) + { + issues.Add(new ValidationIssue + { + Severity = "Warning", + Path = "sceneBoundaries", + Message = "Reordered scene boundaries by paragraph position before validation.", + SuggestedFix = "Return scene boundaries in reading order." + }); + } + + var changed = issues.Count > 0; + var repaired = new List(); + var expectedStart = 1; + for (var index = 0; index < ordered.Count; index++) + { + var boundary = ordered[index].Boundary; + if (!boundary.StartParagraph.HasValue || !boundary.EndParagraph.HasValue) + { + repaired.Add(CopyBoundary(boundary, index + 1)); + continue; + } + + var start = boundary.StartParagraph.Value; + var end = boundary.EndParagraph.Value; + if (start != expectedStart) + { + issues.Add(new ValidationIssue + { + Severity = "Warning", + Path = $"sceneBoundaries[{index}].startParagraph", + Message = $"Adjusted scene {index + 1:N0} start paragraph from {start:N0} to {expectedStart:N0} to remove an AI boundary gap or overlap.", + SuggestedFix = $"Use startParagraph {expectedStart:N0} for this boundary." + }); + start = expectedStart; + changed = true; + } + + if (end < start) + { + issues.Add(new ValidationIssue + { + Severity = "Warning", + Path = $"sceneBoundaries[{index}].endParagraph", + Message = $"Adjusted scene {index + 1:N0} end paragraph from {end:N0} to {start:N0} after boundary continuity repair.", + SuggestedFix = "Return an endParagraph greater than or equal to startParagraph." + }); + end = start; + changed = true; + } + + if (end > paragraphCount) + { + issues.Add(new ValidationIssue + { + Severity = "Warning", + Path = $"sceneBoundaries[{index}].endParagraph", + Message = $"Clamped scene {index + 1:N0} end paragraph from {end:N0} to the chapter paragraph count {paragraphCount:N0}.", + SuggestedFix = $"Use an endParagraph from 1 to {paragraphCount:N0}." + }); + end = paragraphCount; + changed = true; + } + + repaired.Add(new ChapterSceneBoundary + { + SceneNumber = index + 1, + StartParagraph = start, + EndParagraph = end, + StructuralSummary = boundary.StructuralSummary, + Confidence = boundary.Confidence, + Reason = boundary.Reason, + ExtensionData = boundary.ExtensionData + }); + expectedStart = end + 1; + } + + if (expectedStart <= paragraphCount && repaired.Count > 0) + { + var final = repaired[^1]; + issues.Add(new ValidationIssue + { + Severity = "Warning", + Path = $"sceneBoundaries[{repaired.Count - 1}].endParagraph", + Message = $"Extended the final scene boundary to paragraph {paragraphCount:N0} so detected scenes cover the full chapter.", + SuggestedFix = $"End the final scene at paragraph {paragraphCount:N0}." + }); + repaired[^1] = new ChapterSceneBoundary + { + SceneNumber = final.SceneNumber, + StartParagraph = final.StartParagraph, + EndParagraph = paragraphCount, + StructuralSummary = final.StructuralSummary, + Confidence = final.Confidence, + Reason = final.Reason, + ExtensionData = final.ExtensionData + }; + changed = true; + } + + if (!changed) + { + return new ChapterStructureBoundaryNormalisationResult(chapterStructure, []); + } + + return new ChapterStructureBoundaryNormalisationResult(new ChapterStructureModel + { + SchemaVersion = chapterStructure.SchemaVersion, + ChapterSummary = chapterStructure.ChapterSummary, + SceneBoundaries = repaired, + ExtensionData = chapterStructure.ExtensionData + }, issues); + } + private static bool IsTrailingEmptyBoundary( ChapterSceneBoundary final, ChapterSceneBoundary previous, diff --git a/PlotLine/Services/ManuscriptStructureAnalysisService.cs b/PlotLine/Services/ManuscriptStructureAnalysisService.cs index 063de55..47bac1b 100644 --- a/PlotLine/Services/ManuscriptStructureAnalysisService.cs +++ b/PlotLine/Services/ManuscriptStructureAnalysisService.cs @@ -43,18 +43,40 @@ public sealed class ManuscriptStructureAnalysisService( var prompt = BuildChapterPrompt(template, BuildChapterContextJson(request), StoryIntelligenceParagraphs.Number(paragraphs)); var result = await client.ExecutePromptAsync(prompt, promptVersion, cancellationToken, clientStatus.ChapterStructureModel); var chapterJson = ExtractOutputText(result.RawResponseText); - var parsed = JsonSerializer.Deserialize(chapterJson, JsonOptions); + var parseResult = StoryIntelligenceAiJsonParser.Parse(chapterJson, JsonOptions); + if (!parseResult.Success) + { + return ManuscriptStructureAnalysisResult.Failed( + request.TemporaryChapterKey, + parseResult.ErrorMessage ?? "Chapter Structure JSON could not be parsed.", + parseResult.RawJson); + } + + if (parseResult.Warnings.Count > 0) + { + logger.LogWarning( + "Repaired structural scene-boundary JSON. ProjectID={ProjectID} BookID={BookID} TemporaryChapterKey={TemporaryChapterKey} Warnings={Warnings}", + request.ProjectID, + request.BookID, + request.TemporaryChapterKey, + string.Join("; ", parseResult.Warnings)); + } + + var parsed = parseResult.Parsed; var normalisation = ChapterStructureBoundaryNormaliser.Normalise(parsed, paragraphs.Count); var chapter = normalisation.ChapterStructure; var validation = validator.Validate(chapter, paragraphs.Count); ChapterStructureBoundaryNormaliser.AddIssuesTo(validation, normalisation); + var persistedJson = normalisation.Issues.Count > 0 + ? ChapterStructureBoundaryNormaliser.SerialiseParsedModel(chapter, normalisation, JsonOptions) + : parseResult.RepairedJson ?? parseResult.RawJson; if (!validation.IsValid || chapter?.SceneBoundaries is null || chapter.SceneBoundaries.Count == 0) { return ManuscriptStructureAnalysisResult.Failed( request.TemporaryChapterKey, BuildValidationMessage(validation), - chapterJson); + persistedJson); } var scenes = new List(); @@ -68,7 +90,7 @@ public sealed class ManuscriptStructureAnalysisService( return ManuscriptStructureAnalysisResult.Failed( request.TemporaryChapterKey, $"Scene {sceneNumber:N0} has an invalid paragraph range.", - chapterJson); + persistedJson); } var startMap = request.ParagraphMap.FirstOrDefault(item => item.StructuralParagraphOrdinal == start); @@ -78,7 +100,7 @@ public sealed class ManuscriptStructureAnalysisService( return ManuscriptStructureAnalysisResult.Failed( request.TemporaryChapterKey, $"Scene {sceneNumber:N0} could not be mapped back to a Word paragraph.", - chapterJson); + persistedJson); } var sceneParagraphs = paragraphs.Skip(start - 1).Take(end - start + 1).ToList(); @@ -108,7 +130,7 @@ public sealed class ManuscriptStructureAnalysisService( request.TemporaryChapterKey, scenes.Count); - return ManuscriptStructureAnalysisResult.Succeeded(request.TemporaryChapterKey, scenes, chapterJson); + return ManuscriptStructureAnalysisResult.Succeeded(request.TemporaryChapterKey, scenes, persistedJson); } catch (Exception ex) when (ex is not OperationCanceledException) { diff --git a/PlotLine/Services/OnboardingService.cs b/PlotLine/Services/OnboardingService.cs index 4e4a792..ff9922a 100644 --- a/PlotLine/Services/OnboardingService.cs +++ b/PlotLine/Services/OnboardingService.cs @@ -235,15 +235,16 @@ public sealed class OnboardingService( throw new InvalidOperationException("Review the scan and choose Save and continue before preparing chapters."); } - await PublishBuildProgress(userId, preview.PreviewID, "Creating chapters...", 10, progress); + await PublishBuildProgress(userId, preview.PreviewID, "Preparing approved chapters for scene detection...", 10, progress); ValidateScanStructure(review, preview); var chapterDecisions = review.Chapters.Where(chapter => chapter.Include).OrderBy(chapter => chapter.ChapterNumber).ToList(); - await PublishBuildProgress(userId, preview.PreviewID, "Detecting scenes...", 25, progress); + await PublishBuildProgress(userId, preview.PreviewID, "Detecting scene boundaries...", 20, progress); var structuralScenes = new List(); - foreach (var chapterDecision in chapterDecisions) + for (var chapterIndex = 0; chapterIndex < chapterDecisions.Count; chapterIndex++) { + var chapterDecision = chapterDecisions[chapterIndex]; var chapterPreview = preview.Chapters.FirstOrDefault(item => item.TemporaryChapterKey == chapterDecision.TemporaryChapterKey) ?? throw new InvalidOperationException($"The approved chapter '{chapterDecision.Title}' could not be found in the scan preview."); if (string.IsNullOrWhiteSpace(chapterPreview.ChapterText)) @@ -256,6 +257,13 @@ public sealed class OnboardingService( throw new InvalidOperationException($"The approved chapter '{chapterDecision.Title}' does not have paragraph mapping. Scan the manuscript again before importing."); } + var analysisPercent = 20 + (chapterIndex * 20 / Math.Max(1, chapterDecisions.Count)); + await PublishBuildProgress( + userId, + preview.PreviewID, + $"Analysing Chapter {chapterIndex + 1:N0} of {chapterDecisions.Count:N0} - {chapterDecision.Title}", + analysisPercent, + progress); var analysis = await structureAnalysis.AnalyseChapterAsync(new ManuscriptStructureAnalysisRequest { ProjectID = preview.ProjectID, @@ -276,7 +284,7 @@ public sealed class OnboardingService( structuralScenes.AddRange(analysis.Scenes); } - await PublishBuildProgress(userId, preview.PreviewID, "Preparing chapters and scenes for import...", 45, progress); + await PublishBuildProgress(userId, preview.PreviewID, "Validating detected scene structure...", 45, progress); var request = new OnboardingManuscriptBuildRequest { PreviewID = preview.PreviewID, @@ -301,7 +309,7 @@ public sealed class OnboardingService( Characters = [] }; - await PublishBuildProgress(userId, preview.PreviewID, "Creating PlotDirector structure...", 60, progress); + await PublishBuildProgress(userId, preview.PreviewID, "Creating PlotDirector scenes...", 60, progress); var result = await builds.BuildAsync(userId, request) ?? throw new InvalidOperationException($"The project chapters could not be prepared. Approved chapters: {request.Chapters.Count}. Return to review, save your selections, and try again."); result = new OnboardingManuscriptBuildResult @@ -325,13 +333,14 @@ public sealed class OnboardingService( CharacterMappings = result.CharacterMappings, ChapterMarkerInstructions = BuildChapterMarkerInstructions(preview, result.ChapterMappings), SceneMarkerInstructions = BuildSceneMarkerInstructions(structuralScenes, result.SceneMappings), + SceneStructure = BuildSceneStructureSummary(chapterDecisions, structuralScenes), MarkerWarning = result.MarkerWarning }; await SaveStructuralChapterSourcesAsync(preview, result); await SaveSceneManuscriptSourcesAsync(preview, structuralScenes, result); await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID); await scanPreviews.SaveBuildResultAsync(userId, result); - await PublishBuildProgress(userId, preview.PreviewID, "Updating and saving Word markers. Keep Word and this browser page open.", 90, progress); + await PublishBuildProgress(userId, preview.PreviewID, "Updating Word document with scene markers. Keep Word open while the document is saved.", 90, progress); return result; } @@ -444,6 +453,7 @@ public sealed class OnboardingService( CharacterMappings = characterMappings, ChapterMarkerInstructions = build.ChapterMarkerInstructions, SceneMarkerInstructions = build.SceneMarkerInstructions, + SceneStructure = build.SceneStructure, MarkerWarning = build.MarkerWarning }; @@ -500,6 +510,37 @@ public sealed class OnboardingService( .ToList(); } + private static IReadOnlyList BuildSceneStructureSummary( + IReadOnlyList chapters, + IReadOnlyList scenes) + => chapters + .OrderBy(chapter => chapter.ChapterNumber) + .Select(chapter => + { + var chapterScenes = scenes + .Where(scene => string.Equals(scene.TemporaryChapterKey, chapter.TemporaryChapterKey, StringComparison.Ordinal)) + .OrderBy(scene => scene.SceneNumberWithinChapter) + .ToList(); + return new OnboardingSceneStructureSummary + { + TemporaryChapterKey = chapter.TemporaryChapterKey, + ChapterNumber = chapter.ChapterNumber, + ChapterTitle = chapter.Title, + SceneCount = chapterScenes.Count, + WordCount = chapterScenes.Sum(scene => scene.WordCount), + Scenes = chapterScenes.Select(scene => new OnboardingSceneStructureDetail + { + SceneNumberWithinChapter = scene.SceneNumberWithinChapter, + Title = scene.Title, + WordCount = scene.WordCount, + StructuralSummary = scene.StructuralSummary, + StartParagraph = scene.StartParagraph, + EndParagraph = scene.EndParagraph + }).ToList() + }; + }) + .ToList(); + private async Task SaveStructuralChapterSourcesAsync(ManuscriptScanPreview preview, OnboardingManuscriptBuildResult result) { var chapterIds = result.ChapterMappings.ToDictionary(item => item.TemporaryChapterKey, item => item.ChapterID, StringComparer.Ordinal); @@ -703,6 +744,9 @@ public sealed class OnboardingService( var selectedChapterKeys = chapters.Where(chapter => chapter.Include).Select(chapter => chapter.TemporaryChapterKey).ToHashSet(StringComparer.Ordinal); var selectedScenes = chapters.SelectMany(chapter => chapter.Scenes).Where(scene => scene.Include && selectedChapterKeys.Contains(scene.TemporaryChapterKey)).ToList(); + var selectedChapterWordCount = chapters + .Where(chapter => chapter.Include) + .Sum(chapter => chapter.WordCount); return new ManuscriptScanReviewViewModel { @@ -718,7 +762,7 @@ public sealed class OnboardingService( SelectedChapterCount = selectedChapterKeys.Count, SelectedSceneCount = selectedScenes.Count, SelectedCharacterCount = characters.Count(character => character.Include && !character.IsAlias && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase)), - SelectedWordCount = selectedScenes.Sum(scene => scene.WordCount), + SelectedWordCount = selectedChapterWordCount, Chapters = chapters, CharacterCandidates = characters, ExistingCharacterOptions = existingOptions @@ -764,18 +808,12 @@ public sealed class OnboardingService( private static void ValidateScanStructure(ManuscriptScanReviewDecision review, ManuscriptScanPreview preview) { var previewChapters = preview.Chapters.ToDictionary(chapter => chapter.TemporaryChapterKey, StringComparer.Ordinal); - var previewScenes = preview.Scenes.ToDictionary(scene => scene.TemporarySceneKey, StringComparer.Ordinal); var includedChapterKeys = review.Chapters.Where(chapter => chapter.Include).Select(chapter => chapter.TemporaryChapterKey).ToHashSet(StringComparer.Ordinal); if (includedChapterKeys.Count == 0) { throw new InvalidOperationException("Choose at least one chapter before continuing."); } - if (!review.Scenes.Any(scene => scene.Include && includedChapterKeys.Contains(scene.TemporaryChapterKey))) - { - throw new InvalidOperationException("Choose at least one scene before continuing."); - } - if (review.Chapters.Where(chapter => chapter.Include).Any(chapter => string.IsNullOrWhiteSpace(chapter.Title))) { throw new InvalidOperationException("Every included chapter needs a title."); @@ -786,24 +824,6 @@ public sealed class OnboardingService( throw new InvalidOperationException("Chapter order is not valid. Restore the scan defaults and try again."); } - foreach (var scene in review.Scenes.Where(scene => scene.Include)) - { - if (!includedChapterKeys.Contains(scene.TemporaryChapterKey)) - { - throw new InvalidOperationException("Included scenes must belong to included chapters."); - } - - if (!previewScenes.TryGetValue(scene.TemporarySceneKey, out var previewScene)) - { - throw new InvalidOperationException("One of the selected scenes no longer exists in the scan preview."); - } - - if (previewScene.WordCount <= 0 && !scene.AllowZeroWords) - { - throw new InvalidOperationException("Included scenes with no words must be explicitly allowed."); - } - } - foreach (var chapter in review.Chapters) { if (!previewChapters.ContainsKey(chapter.TemporaryChapterKey)) diff --git a/PlotLine/Services/OnboardingStoryIntelligenceService.cs b/PlotLine/Services/OnboardingStoryIntelligenceService.cs index 445e931..bd7d358 100644 --- a/PlotLine/Services/OnboardingStoryIntelligenceService.cs +++ b/PlotLine/Services/OnboardingStoryIntelligenceService.cs @@ -72,8 +72,8 @@ public sealed class OnboardingStoryIntelligenceService( var existingBatch = await batchStore.GetLatestForPreviewAsync(userId, preview.PreviewID); var missingTextCount = included.Count(chapter => string.IsNullOrWhiteSpace(chapter.Preview.ChapterText)); const int storySummaryStep = 10; - const int storyContextStep = 11; - const int totalWizardSteps = 12; + const int storyContextStep = 10; + const int totalWizardSteps = 10; return new StoryIntelligenceOverviewViewModel { PreviewID = preview.PreviewID, diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs index f59e696..21a2157 100644 --- a/PlotLine/ViewModels/OnboardingViewModels.cs +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -128,8 +128,8 @@ public sealed class StoryIntelligenceOverviewViewModel public int ApprovedWordCount { get; init; } public int MissingChapterTextCount { get; init; } public int WizardCurrentStep { get; init; } = 10; - public int WizardContextStep { get; init; } = 11; - public int WizardTotalSteps { get; init; } = 12; + public int WizardContextStep { get; init; } = 10; + public int WizardTotalSteps { get; init; } = 10; public string? Message { get; init; } public Guid? ExistingBatchID { get; init; } public StoryIntelligenceJobProgress? ExistingJob { get; init; } diff --git a/PlotLine/Views/Onboarding/BuildComplete.cshtml b/PlotLine/Views/Onboarding/BuildComplete.cshtml index 9572fbd..76424ad 100644 --- a/PlotLine/Views/Onboarding/BuildComplete.cshtml +++ b/PlotLine/Views/Onboarding/BuildComplete.cshtml @@ -6,12 +6,12 @@
- +
-

Core Import Complete

+

@(coreComplete ? "Core Import Complete" : "Scene Detection Complete")

-

Your manuscript has been imported.

+

@(coreComplete ? "Core import is complete." : "Scene structure has been detected.")

@if (coreComplete) @@ -22,11 +22,11 @@ { @:This manuscript structure has already been created in PlotDirector, so nothing was duplicated. Review characters to complete the core import. } - else - { - @:PlotDirector created the approved chapters and scenes, persisted their manuscript text, updated the Word markers, and saved the manuscript. Word may now be closed; keep this browser workflow open to review characters. - } -

+ else + { + @:PlotDirector created the approved chapters and detected scenes, persisted their manuscript text, updated the Word markers, and saved the manuscript. Word may now be closed if you wish; keep this browser workflow open to review characters. + } +

@if (!string.IsNullOrWhiteSpace(Model.MarkerWarning)) {

@Model.MarkerWarning

@@ -52,6 +52,41 @@
+ @if (Model.SceneStructure.Any()) + { +
+

Detected scene structure

+
+ @foreach (var chapter in Model.SceneStructure) + { +
+ + Chapter @chapter.ChapterNumber + @chapter.ChapterTitle + @chapter.SceneCount.ToString("N0") scene@(chapter.SceneCount == 1 ? string.Empty : "s") / @chapter.WordCount.ToString("N0") words + + @if (chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.StructuralSummary))) + { +
    + @foreach (var scene in chapter.Scenes) + { +
  1. + @scene.Title + @scene.WordCount.ToString("N0") words / paragraphs @scene.StartParagraph-@scene.EndParagraph + @if (!string.IsNullOrWhiteSpace(scene.StructuralSummary)) + { +

    @scene.StructuralSummary

    + } +
  2. + } +
+ } +
+ } +
+
+ } + @if (coreComplete) {
diff --git a/PlotLine/Views/Onboarding/Index.cshtml b/PlotLine/Views/Onboarding/Index.cshtml index 65a0adb..0fcb12f 100644 --- a/PlotLine/Views/Onboarding/Index.cshtml +++ b/PlotLine/Views/Onboarding/Index.cshtml @@ -239,11 +239,11 @@ else {
-

Step @currentJourneyOrder of 12

+

Step @currentJourneyOrder of 10

@if (Model.IsMicrosoftWordPath) { -

@(structureReadyToImport ? "Import scenes and Word markers" : currentJourneyOrder >= 6 ? "Scan Manuscript" : "Connect your Word Companion")

-

@(structureReadyToImport ? "Keep Word and this browser page open while PlotDirector detects scene boundaries, creates the manuscript structure, inserts bookmark IDs, and saves the document." : "Open your manuscript in Word and start the Companion. When it is connected, use the wizard button below to scan and continue.")

+

@(structureReadyToImport ? "Detect Scenes" : currentJourneyOrder >= 6 ? "Scan Manuscript" : "Connect your Word Companion")

+

@(structureReadyToImport ? "PlotDirector will now analyse the approved chapters to determine where each scene begins and ends. Keep Word open while scene markers are added and the document is saved." : "Open your manuscript in Word and start the Companion. When it is connected, use the wizard button below to scan and continue.")

} else { @@ -293,10 +293,6 @@ Chapters @Model.ScanState.ChapterCount
-
- Scenes - @Model.ScanState.SceneCount -
Characters @Model.ScanState.CharacterCandidateCount @@ -310,8 +306,8 @@ {
-

Review saved. PlotDirector can now import the approved manuscript structure.

-

Next: detect scene boundaries, create PlotDirector scenes, then update and save Word markers.

+

Chapter review saved. PlotDirector can now detect the scene structure.

+

Next: analyse the approved chapters and create the detected scenes.

-
- Scenes - @Model.SelectedSceneCount / @Model.SceneCount -
Characters @Model.SelectedCharacterCount / @visibleCharacterCount @@ -106,8 +102,6 @@
- -
} @@ -124,7 +118,6 @@
@{ var chapterIndex = 0; - var sceneIndex = 0; } @foreach (var chapter in Model.Chapters) { @@ -132,7 +125,7 @@ Chapter @chapter.ChapterNumber @chapter.ReviewTitle - @chapter.WordCount.ToString("N0") words / @chapter.Scenes.Count scene@(chapter.Scenes.Count == 1 ? string.Empty : "s") + @chapter.WordCount.ToString("N0") words @@ -145,55 +138,11 @@ Chapter title - - @if (chapter.Scenes.Any()) - { -
    - @foreach (var scene in chapter.Scenes) - { -
  1. - - - - - - @scene.WordCount.ToString("N0") words - @if (scene.WordCount == 0) - { - - } - @if (!string.IsNullOrWhiteSpace(scene.OpeningTextPreview)) - { -

    @scene.OpeningTextPreview

    - } -
  2. - sceneIndex++; - } -
- } - else - { -

No scene breaks were detected. PlotDirector will treat this chapter as one scene for now.

- } chapterIndex++; }
} -
- -
diff --git a/PlotLine/Views/Onboarding/_OnboardingJourneyHeader.cshtml b/PlotLine/Views/Onboarding/_OnboardingJourneyHeader.cshtml index 3e0b5ae..bdc4648 100644 --- a/PlotLine/Views/Onboarding/_OnboardingJourneyHeader.cshtml +++ b/PlotLine/Views/Onboarding/_OnboardingJourneyHeader.cshtml @@ -36,11 +36,9 @@ new("Book", 5), new("Connect Word", 6), new("Review Chapters", 7), - new("Import Scenes", 8), + new("Detect Scenes", 8), new("Review Characters", 9), - new("Story Summary", 10), - new("Story Context", 11), - new("Complete", 12) + new("Complete", 10) ]; private static string StepClass(int order, int currentStep) diff --git a/PlotLine/wwwroot/js/word-companion-presence.js b/PlotLine/wwwroot/js/word-companion-presence.js index 057bd06..bbd6533 100644 --- a/PlotLine/wwwroot/js/word-companion-presence.js +++ b/PlotLine/wwwroot/js/word-companion-presence.js @@ -226,7 +226,7 @@ node.classList.add("is-running"); }); document.querySelectorAll("[data-onboarding-build-message]").forEach((node) => { - node.textContent = "Preparing the approved chapters..."; + node.textContent = "Preparing approved chapters for scene detection..."; }); document.querySelectorAll("[data-onboarding-build-percent]").forEach((node) => { node.textContent = "0%"; @@ -238,7 +238,7 @@ const result = await connection.invoke("StartOnboardingProjectBuild", previewId); pendingBuildPreviewId = field(result, "previewID", "PreviewID") || previewId; document.querySelectorAll("[data-onboarding-build-message]").forEach((node) => { - node.textContent = "Updating and saving Word markers. Keep Word and this browser page open."; + node.textContent = "Updating Word document with scene markers. Keep Word open while the document is saved."; }); } catch (error) { button.disabled = false;