diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 0a7dd5e..4af3462 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -62,6 +62,10 @@ var tests = new (string Name, Action Test)[] ("Illustration assignment repair enforces explicit evidence groups and strict subtypes", IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictSubtypes), ("Lean import removes replay links while retaining progress", LeanImportRemovesReplayLinksWhileRetainingProgress), ("Story Intelligence progress does not prematurely report safe close", StoryIntelligenceProgressDoesNotPrematurelyReportSafeClose), + ("Structural import creates scenes before Word sync", StructuralImportCreatesScenesBeforeWordSync), + ("Structural import persists paragraph mapping and source text", StructuralImportPersistsParagraphMappingAndSourceText), + ("Word sync requires save acknowledgement", WordSyncRequiresSaveAcknowledgement), + ("Story Intelligence enriches existing structural scenes", StoryIntelligenceEnrichesExistingStructuralScenes), ("Phase 21T repairs durable character understanding", Phase21TRepairsDurableCharacterUnderstanding), ("Phase 21U extracts mandatory character appearance fixtures", Phase21UExtractsMandatoryCharacterAppearanceFixtures), ("Character sex hints are conservative and reviewable", CharacterSexHintsAreConservativeAndReviewable), @@ -1324,11 +1328,66 @@ static void StoryIntelligenceProgressDoesNotPrematurelyReportSafeClose() var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var progressView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceProgress.cshtml")); - Assert(!progressView.Contains("You can close Word", StringComparison.OrdinalIgnoreCase), "Progress page must not say Word can close before scene markers are synchronised."); Assert(!progressView.Contains("close this browser tab", StringComparison.OrdinalIgnoreCase), "Progress page must not give a blanket browser safe-close instruction before Word sync acknowledgement exists."); Assert(!progressView.Contains("safely leave this page", StringComparison.OrdinalIgnoreCase), "Progress page must not infer safe-close from queued/background analysis alone."); - Assert(progressView.Contains("Keep Word and the Companion open", StringComparison.Ordinal), "Progress page should tell the user Word/Companion are still needed for scene marker safety."); - Assert(progressView.Contains("Do not close Word yet", StringComparison.Ordinal), "Progress page should explicitly block premature Word closure."); + Assert(progressView.Contains("Word markers are already saved", StringComparison.Ordinal), "Story Intelligence progress should reflect that structural Word sync has already completed."); + Assert(progressView.Contains("Word does not need to remain open", StringComparison.Ordinal), "Optional Story Intelligence should not depend on Word remaining open."); +} + +static void StructuralImportCreatesScenesBeforeWordSync() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs")); + var buildSql = File.ReadAllText(Path.Combine(root, "Sql/113_Phase20F_OnboardingManuscriptBuild.sql")); + + Assert(onboarding.Contains("structureAnalysis.AnalyseChapterAsync", StringComparison.Ordinal), "Approved onboarding import should run structural scene-boundary analysis."); + Assert(onboarding.Contains("Scenes = structuralScenes.Select", StringComparison.Ordinal), "Validated structural boundaries should be passed to the onboarding build."); + Assert(buildSql.Contains("INSERT dbo.Scenes", StringComparison.Ordinal), "Onboarding build SQL should create canonical Scene records."); + Assert(onboarding.Contains("BuildSceneMarkerInstructions(structuralScenes, result.SceneMappings)", StringComparison.Ordinal), "Permanent SceneIDs should exist before Word marker instructions are built."); +} + +static void StructuralImportPersistsParagraphMappingAndSourceText() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var models = File.ReadAllText(Path.Combine(root, "Models/ManuscriptScanModels.cs")); + var companion = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js")); + var migration = File.ReadAllText(Path.Combine(root, "Sql/149_Phase24_StructuralImportMarkerSync.sql")); + var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs")); + + Assert(models.Contains("StructuralParagraphOrdinal", StringComparison.Ordinal), "Scan payload should carry structural paragraph ordinals."); + Assert(models.Contains("WordParagraphIndex", StringComparison.Ordinal), "Scan payload should carry Word paragraph indexes."); + Assert(companion.Contains("structuralParagraphOrdinal", StringComparison.Ordinal), "Word scan should emit explicit structural paragraph mapping."); + Assert(migration.Contains("ManuscriptStructuralChapterSources", StringComparison.Ordinal), "Structural import should persist chapter source text."); + Assert(onboarding.Contains("SaveStructuralChapterSourcesAsync", StringComparison.Ordinal), "Onboarding import should save source text after canonical ChapterIDs exist."); +} + +static void WordSyncRequiresSaveAcknowledgement() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var companion = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js")); + var hub = File.ReadAllText(Path.Combine(root, "Hubs/WordCompanionFollowHub.cs")); + var presence = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-presence.js")); + var migration = File.ReadAllText(Path.Combine(root, "Sql/149_Phase24_StructuralImportMarkerSync.sql")); + + Assert(companion.Contains("saveWordDocumentIfAvailable", StringComparison.Ordinal), "Companion should save Word before acknowledging marker sync."); + Assert(companion.Contains("CompleteOnboardingBuildMarkerSync", StringComparison.Ordinal), "Companion should acknowledge successful marker sync."); + Assert(companion.Contains("FailOnboardingBuildMarkerSync", StringComparison.Ordinal), "Companion should report failed marker sync."); + Assert(hub.Contains("MarkMarkerSyncCompletedAsync", StringComparison.Ordinal), "Server should persist completed marker sync."); + Assert(presence.Contains("OnboardingBuildMarkerSyncCompleted", StringComparison.Ordinal), "Browser should wait for marker sync completion before redirecting."); + Assert(migration.Contains("MarkerSyncCompletedUtc", StringComparison.Ordinal), "Database should persist marker sync completion time."); +} + +static void StoryIntelligenceEnrichesExistingStructuralScenes() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var repository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligenceResultRepository.cs")); + var runner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.cs")); + var commit = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceImportCommitService.cs")); + + Assert(runner.Contains("ListChapterScenesAsync", StringComparison.Ordinal), "Story Intelligence scene results should be linked to existing SceneIDs."); + Assert(repository.Contains("SceneID = existingScenes.Count > index", StringComparison.Ordinal), "Story Intelligence commit should update existing structural scenes when present."); + Assert(repository.Contains("Enriched existing structural scenes", StringComparison.Ordinal), "Commit audit should distinguish enrichment from scene creation."); + Assert(commit.Contains("readiness.ActiveSceneCount != importScenes.Count", StringComparison.Ordinal), "Review readiness should allow matching structural scenes while blocking mismatches."); } static void StoryIntelligenceDoesNotRetryExhaustedBillingQuota() diff --git a/PlotLine/Data/StoryIntelligenceResultRepository.cs b/PlotLine/Data/StoryIntelligenceResultRepository.cs index 9c0c89d..142304e 100644 --- a/PlotLine/Data/StoryIntelligenceResultRepository.cs +++ b/PlotLine/Data/StoryIntelligenceResultRepository.cs @@ -34,6 +34,7 @@ public interface IStoryIntelligenceResultRepository Task GetRunAsync(int runId); Task GetChapterResultAsync(int runId); Task> ListSceneResultsAsync(int runId); + Task> ListChapterScenesAsync(int chapterId); Task GetImportCommitAsync(int runId); Task GetChapterImportReadinessAsync(int chapterId); Task CommitImportAsync(StoryIntelligenceImportCommitRequest request); @@ -517,6 +518,20 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn return rows.ToList(); } + public async Task> ListChapterScenesAsync(int chapterId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "dbo.Scene_ListByChapter", + new { ChapterID = chapterId }, + commandType: CommandType.StoredProcedure); + return rows.Where(scene => !scene.IsArchived) + .OrderBy(scene => scene.SortOrder) + .ThenBy(scene => scene.SceneNumber) + .ThenBy(scene => scene.SceneID) + .ToList(); + } + public async Task GetImportCommitAsync(int runId) { using var connection = connectionFactory.CreateConnection(); @@ -566,14 +581,30 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn new { request.ChapterID }, transaction, commandType: CommandType.StoredProcedure); + var existingScenes = new List(); + var orderedScenes = request.Scenes.OrderBy(scene => scene.TemporarySceneNumber).ToList(); if (!readiness.CanCommit) { - transaction.Rollback(); - return new StoryIntelligenceImportCommitResult + existingScenes = (await connection.QueryAsync( + "dbo.Scene_ListByChapter", + new { request.ChapterID }, + transaction, + commandType: CommandType.StoredProcedure)) + .Where(scene => !scene.IsArchived) + .OrderBy(scene => scene.SortOrder) + .ThenBy(scene => scene.SceneNumber) + .ThenBy(scene => scene.SceneID) + .ToList(); + + if (existingScenes.Count != orderedScenes.Count) { - Success = false, - Message = readiness.BlockReason ?? "This chapter already contains scenes." - }; + transaction.Rollback(); + return new StoryIntelligenceImportCommitResult + { + Success = false, + Message = $"This chapter contains {existingScenes.Count:N0} existing scene(s), but Story Intelligence produced {orderedScenes.Count:N0} scene result(s)." + }; + } } if (!string.IsNullOrWhiteSpace(request.ChapterSummary) || request.ChapterPurposeID.HasValue) @@ -590,7 +621,6 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn commandType: CommandType.StoredProcedure); } - var orderedScenes = request.Scenes.OrderBy(scene => scene.TemporarySceneNumber).ToList(); for (var index = 0; index < orderedScenes.Count; index++) { var item = orderedScenes[index]; @@ -612,7 +642,7 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn "dbo.Scene_Save", new { - SceneID = (int?)null, + SceneID = existingScenes.Count > index ? existingScenes[index].SceneID : (int?)null, ChapterID = request.ChapterID, SceneNumber = Convert.ToDecimal(item.TemporarySceneNumber), SceneTitle = sceneTitle, @@ -737,7 +767,7 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn } var notes = new List(); - notes.Add("Committed into empty chapter."); + notes.Add(existingScenes.Count > 0 ? "Enriched existing structural scenes." : "Committed into empty chapter."); notes.AddRange(request.Warnings.Where(warning => !string.IsNullOrWhiteSpace(warning))); notes.AddRange(commitWarnings.Where(warning => !string.IsNullOrWhiteSpace(warning))); @@ -764,7 +794,9 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn CommitID = commitId, ScenesCreated = scenesCreated, MetricsCreated = metricsCreated, - Message = $"Created {scenesCreated:N0} scene(s)." + Message = existingScenes.Count > 0 + ? $"Enriched {scenesCreated:N0} existing scene(s)." + : $"Created {scenesCreated:N0} scene(s)." }; } catch (Exception ex) diff --git a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs index 2da1620..9931bce 100644 --- a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs +++ b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs @@ -237,6 +237,9 @@ public sealed class PersistedStoryIntelligenceRunner( failedScenes: failedScenes, totalTokens: totals.TotalTokens); + var existingScenes = run.ChapterID.HasValue + ? await repository.ListChapterScenesAsync(run.ChapterID.Value) + : Array.Empty(); var sceneTemplate = await prompts.LoadPromptAsync(ScenePromptFile, cancellationToken); foreach (var block in sceneBlocks) { @@ -319,7 +322,7 @@ public sealed class PersistedStoryIntelligenceRunner( ProjectID = run.ProjectID, BookID = run.BookID, ChapterID = run.ChapterID, - SceneID = null, + SceneID = existingScenes.FirstOrDefault(scene => Convert.ToInt32(scene.SceneNumber) == block.TemporarySceneNumber)?.SceneID, TemporarySceneNumber = block.TemporarySceneNumber, StartParagraph = block.StartParagraph, EndParagraph = block.EndParagraph, diff --git a/PlotLine/Services/StoryIntelligenceImportCommitService.cs b/PlotLine/Services/StoryIntelligenceImportCommitService.cs index 90ec844..86c599e 100644 --- a/PlotLine/Services/StoryIntelligenceImportCommitService.cs +++ b/PlotLine/Services/StoryIntelligenceImportCommitService.cs @@ -134,14 +134,6 @@ public sealed class StoryIntelligenceImportCommitService( blockers.Add("No saved scene results are available to import."); } - var readiness = run.ChapterID.HasValue - ? await storyRuns.GetChapterImportReadinessAsync(run.ChapterID.Value) - : null; - if (readiness is not null && !readiness.CanCommit) - { - blockers.Add(readiness.BlockReason ?? "This chapter already contains scenes."); - } - if (existingCommit is not null && string.Equals(existingCommit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase)) { blockers.Add("This Story Intelligence run has already been committed."); @@ -203,6 +195,14 @@ public sealed class StoryIntelligenceImportCommitService( blockers.Add("No scenes are ready to create for this chapter."); } + var readiness = run.ChapterID.HasValue + ? await storyRuns.GetChapterImportReadinessAsync(run.ChapterID.Value) + : null; + if (readiness is not null && !readiness.CanCommit && readiness.ActiveSceneCount != importScenes.Count) + { + blockers.Add($"This chapter contains {readiness.ActiveSceneCount:N0} existing scene(s), but Story Intelligence produced {importScenes.Count:N0} scene result(s)."); + } + var projectName = run.ProjectID.HasValue ? (await projects.GetAsync(run.ProjectID.Value))?.ProjectName ?? $"Project {run.ProjectID.Value:N0}" : "None"; var bookTitle = run.BookID.HasValue ? (await books.GetAsync(run.BookID.Value))?.BookDisplayTitle ?? $"Book {run.BookID.Value:N0}" : "None"; var chapter = run.ChapterID.HasValue ? await chapters.GetAsync(run.ChapterID.Value) : null;