From 6dec8a434530e1a406984f2ede413cc40c951e3a Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sat, 29 Aug 2026 11:52:52 +0000 Subject: [PATCH] Stabilise Story Intelligence materialisation --- PlotLine.Tests/Program.cs | 162 ++++++++- PlotLine/Controllers/OnboardingController.cs | 14 +- .../StoryIntelligencePipelineRepository.cs | 18 +- .../StoryIntelligencePersistenceModels.cs | 13 + PlotLine/Program.cs | 2 +- .../PersistedStoryIntelligenceRunner.cs | 12 + .../PersistedStoryIntelligenceWorker.cs | 19 +- .../StoryIntelligenceImportCommitService.cs | 105 +----- ...StoryIntelligenceMaterialisationService.cs | 333 ++++++++++++++++++ ...StoryIntelligenceNarrativeReviewService.cs | 18 +- .../StoryIntelligencePipelineStateService.cs | 10 +- ...StoryIntelligenceModernMaterialisation.sql | 90 +++++ .../Views/Onboarding/StoryIntelligence.cshtml | 2 +- .../StoryIntelligenceContext.cshtml | 2 +- 14 files changed, 666 insertions(+), 134 deletions(-) create mode 100644 PlotLine/Services/StoryIntelligenceMaterialisationService.cs create mode 100644 PlotLine/Sql/173_Phase26I_StoryIntelligenceModernMaterialisation.sql diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index bc5f835..6f8fa3e 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -180,6 +180,10 @@ var tests = new (string Name, Action Test)[] ("Story Intelligence downstream imports support canonical scene results", StoryIntelligenceDownstreamImportsSupportCanonicalSceneResults), ("Story Intelligence runner supplies metrics and Plot Lines", StoryIntelligenceRunnerSuppliesMetricsAndPlotLines), ("Story Intelligence metric persistence uses configured keys", StoryIntelligenceMetricPersistenceUsesConfiguredKeys), + ("Story Intelligence stabilisation gate materialises strict and legacy metrics", StoryIntelligenceStabilisationGateMaterialisesStrictAndLegacyMetrics), + ("Story Intelligence modern materialisation owns finalisation boundary", StoryIntelligenceModernMaterialisationOwnsFinalisationBoundary), + ("Story Intelligence narrative materialisation reads strict scene results", StoryIntelligenceNarrativeMaterialisationReadsStrictSceneResults), + ("Story Intelligence stabilisation routes normal users to book", StoryIntelligenceStabilisationRoutesNormalUsersToBook), ("Story Intelligence narrative candidates require review", StoryIntelligenceNarrativeCandidatesRequireReview), ("Story Intelligence discovers Plot Lines without preconfiguration", StoryIntelligenceDiscoversPlotLinesWithoutPreconfiguration), ("Story Intelligence matches existing Plot Lines without duplicates", StoryIntelligenceMatchesExistingPlotLinesWithoutDuplicates), @@ -469,6 +473,7 @@ static void StoryIntelligenceSceneAnalysisCompletionBarrierReleasesReview() { var root = PlotLineRoot(); var migration = File.ReadAllText(Path.Combine(root, "Sql/172_Phase26H_StoryIntelligenceSceneAnalysisCompletionBarrier.sql")); + var materialisationMigration = File.ReadAllText(Path.Combine(root, "Sql/173_Phase26I_StoryIntelligenceModernMaterialisation.sql")); var repository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligencePipelineRepository.cs")); var pipelineState = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligencePipelineStateService.cs")); var runner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.cs")); @@ -486,12 +491,12 @@ static void StoryIntelligenceSceneAnalysisCompletionBarrierReleasesReview() Assert(migration.Contains("@CompletedScenes + @FailedScenes >= @TotalExpectedScenes", StringComparison.Ordinal), "All expected scenes should be terminal before review is released."); Assert(!migration.Contains("StoryIntelligenceImportCommits", StringComparison.Ordinal), "Canonical scene-analysis completion should not depend on legacy import commit rows."); Assert(migration.Contains("AND Status = N'InProgress'", StringComparison.Ordinal), "The finaliser update should be idempotent and claim only an active pipeline."); - Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceBookPipeline_TryCompleteNextReadySceneAnalysis", StringComparison.Ordinal), "Idle workers should recover books stranded after restart before finalisation."); + Assert(materialisationMigration.Contains("CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceBookPipeline_FindNextReadySceneAnalysis", StringComparison.Ordinal), "Idle workers should find books stranded after restart without finalising before materialisation."); Assert(repository.Contains("TryCompleteSceneAnalysisAsync", StringComparison.Ordinal), "Repository should expose the per-book barrier procedure."); - Assert(repository.Contains("TryCompleteNextReadySceneAnalysisAsync", StringComparison.Ordinal), "Repository should expose the restart recovery barrier procedure."); + Assert(repository.Contains("FindNextReadySceneAnalysisAsync", StringComparison.Ordinal), "Repository should expose the restart recovery lookup procedure."); Assert(pipelineState.Contains("CompleteSceneAnalysisIfReadyAsync", StringComparison.Ordinal), "Pipeline state service should complete scene analysis after the final chapter run."); - Assert(pipelineState.Contains("RecoverNextReadySceneAnalysisAsync", StringComparison.Ordinal), "Pipeline state service should support restart-safe recovery."); + Assert(pipelineState.Contains("FindNextReadySceneAnalysisAsync", StringComparison.Ordinal), "Pipeline state service should support restart-safe recovery without completing before materialisation."); Assert(runner.Contains("CompleteBookSceneAnalysisIfReadyAsync", StringComparison.Ordinal), "Runner should check the book barrier after chapter completion."); Assert(runner.Contains("await CompleteBookSceneAnalysisIfReadyAsync(run, cancellationToken);", StringComparison.Ordinal), "Runner should use the barrier instead of emailing while the pipeline is still InProgress."); Assert(worker.Contains("RecoverReadySceneAnalysisAsync", StringComparison.Ordinal), "Idle worker polling should repair ready pipelines even without new scene events."); @@ -4697,14 +4702,153 @@ static void StoryIntelligenceMetricPersistenceUsesConfiguredKeys() var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceImportCommitService.cs")); var repository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligenceResultRepository.cs")); + var materialisation = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceMaterialisationService.cs")); - Assert(service.Contains("map[$\"metric_{metric.MetricTypeID}\"] = metric", StringComparison.Ordinal), "Metric persistence should map configured context keys back to MetricTypeID."); - Assert(service.Contains("map.TryAdd(cleanedName, metric)", StringComparison.Ordinal), "Metric persistence should also accept configured metric names."); - Assert(service.Contains("Math.Clamp(metric.Score.Value, metricType.MinValue, metricType.MaxValue)", StringComparison.Ordinal), "Metric values should be clamped to configured scale."); + Assert(materialisation.Contains("map[$\"metric_{metric.MetricTypeID}\"] = metric", StringComparison.Ordinal), "Metric persistence should map configured context keys back to MetricTypeID."); + Assert(materialisation.Contains("map.TryAdd(cleanedName, metric)", StringComparison.Ordinal), "Metric persistence should also accept configured metric names."); + Assert(materialisation.Contains("Math.Clamp(metric.Score.Value, metricType.MinValue, metricType.MaxValue)", StringComparison.Ordinal), "Metric values should be clamped to configured scale."); + Assert(service.Contains("StoryIntelligenceMaterialisationService.BuildMetrics", StringComparison.Ordinal), "Legacy admin commit should reuse the modern-neutral metric converter."); Assert(repository.Contains("\"dbo.SceneMetric_SaveValue\"", StringComparison.Ordinal), "Metric values should persist through the existing stored procedure."); Assert(repository.Contains("new { SceneID = sceneId, metric.MetricTypeID, metric.Value, metric.Notes }", StringComparison.Ordinal), "Metrics should persist by SceneID and MetricTypeID."); } +static void StoryIntelligenceStabilisationGateMaterialisesStrictAndLegacyMetrics() +{ + var metricTypes = new[] + { + new SceneMetricType { MetricTypeID = 1, MetricName = "Overall Intensity", MinValue = 1, MaxValue = 10, IsActive = true, IsEnabledForProject = true }, + new SceneMetricType { MetricTypeID = 2, MetricName = "Tension", MinValue = 1, MaxValue = 10, IsActive = true, IsEnabledForProject = true }, + new SceneMetricType { MetricTypeID = 3, MetricName = "Emotional Weight", MinValue = 1, MaxValue = 10, IsActive = true, IsEnabledForProject = true }, + new SceneMetricType { MetricTypeID = 4, MetricName = "Action", MinValue = 1, MaxValue = 10, IsActive = true, IsEnabledForProject = true }, + new SceneMetricType { MetricTypeID = 5, MetricName = "Darkness", MinValue = 1, MaxValue = 10, IsActive = true, IsEnabledForProject = true }, + new SceneMetricType { MetricTypeID = 6, MetricName = "Romance", MinValue = 1, MaxValue = 10, IsActive = true, IsEnabledForProject = true }, + new SceneMetricType { MetricTypeID = 7, MetricName = "Sexual Charge", MinValue = 1, MaxValue = 10, IsActive = true, IsEnabledForProject = true }, + new SceneMetricType { MetricTypeID = 8, MetricName = "Violence", MinValue = 1, MaxValue = 10, IsActive = true, IsEnabledForProject = true }, + new SceneMetricType { MetricTypeID = 9, MetricName = "Hope / Lightness", MinValue = 1, MaxValue = 10, IsActive = true, IsEnabledForProject = true }, + new SceneMetricType { MetricTypeID = 10, MetricName = "Comedy", MinValue = 1, MaxValue = 10, IsActive = true, IsEnabledForProject = true }, + new SceneMetricType { MetricTypeID = 11, MetricName = "Mystery", MinValue = 1, MaxValue = 10, IsActive = true, IsEnabledForProject = true } + }; + var map = StoryIntelligenceMaterialisationService.BuildMetricMap(metricTypes); + var strictScene = new SceneIntelligenceScene + { + MetricValues = + [ + new SceneIntelligenceMetricValue { Key = "metric_1", Score = 8, Confidence = 0.91m }, + new SceneIntelligenceMetricValue { Key = "metric_2", Score = 7, Confidence = 0.85m }, + new SceneIntelligenceMetricValue { Key = "metric_3", Score = 12, Confidence = 0.8m } + ], + Metrics = new Dictionary + { + ["tension"] = new() { Score = 2, Confidence = 0.5m } + } + }; + var legacyScene = new SceneIntelligenceScene + { + Metrics = new Dictionary + { + ["pacing"] = new() { Score = 6, Confidence = 0.75m }, + ["humour"] = new() { Score = 3, Confidence = 0.7m }, + ["mystery"] = new() { Score = 9, Confidence = 0.88m } + } + }; + + var strictValues = StoryIntelligenceMaterialisationService.BuildMetrics(strictScene, map); + var legacyValues = StoryIntelligenceMaterialisationService.BuildMetrics(legacyScene, map); + + Assert(strictValues.Count == 3, "Strict metricValues fixture should produce one row per configured key."); + Assert(strictValues.Single(value => value.MetricTypeID == 1).Value == 8, "Configured metric_1 should map to Overall Intensity."); + Assert(strictValues.Single(value => value.MetricTypeID == 2).Value == 7, "Configured metric_2 should map to Tension."); + Assert(strictValues.Single(value => value.MetricTypeID == 3).Value == 10, "Scores must be clamped to the configured metric scale."); + Assert(strictValues.Single(value => value.MetricTypeID == 2).Value != 2, "Modern metricValues must take priority over the legacy Metrics dictionary."); + Assert(legacyValues.Count == 3, "Legacy Metrics fixture should still materialise broad old schema aliases."); + Assert(legacyValues.Any(value => value.MetricTypeID == 1 && value.Value == 6), "Legacy pacing should map to Overall Intensity."); + Assert(legacyValues.Any(value => value.MetricTypeID == 10 && value.Value == 3), "Legacy humour should map to Comedy."); + Assert(legacyValues.Any(value => value.MetricTypeID == 11 && value.Value == 9), "Legacy mystery should map to Mystery."); +} + +static void StoryIntelligenceModernMaterialisationOwnsFinalisationBoundary() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var runner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.cs")); + var worker = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceWorker.cs")); + var program = File.ReadAllText(Path.Combine(root, "Program.cs")); + var repository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligencePipelineRepository.cs")); + var migration = File.ReadAllText(Path.Combine(root, "Sql/173_Phase26I_StoryIntelligenceModernMaterialisation.sql")); + + var runnerMaterialise = runner.IndexOf("materialisation.MaterialiseReadyBookAsync", StringComparison.Ordinal); + var runnerComplete = runner.IndexOf("pipelineState.CompleteSceneAnalysisIfReadyAsync", StringComparison.Ordinal); + Assert(runnerMaterialise >= 0 && runnerComplete > runnerMaterialise, "Modern runner must materialise deterministic outputs before releasing Review Centre state."); + Assert(worker.Contains("FindNextReadySceneAnalysisAsync", StringComparison.Ordinal), "Recovery should find a ready pipeline before attempting completion."); + Assert(worker.IndexOf("materialisation.MaterialiseReadyBookAsync", StringComparison.Ordinal) < worker.IndexOf("pipelineState.CompleteSceneAnalysisIfReadyAsync", StringComparison.Ordinal), "Recovery must materialise before completion."); + Assert(!program.Contains("AddHostedService", StringComparison.Ordinal), "Legacy StoryIntelligenceWorker must not poll normal jobs."); + Assert(program.Contains("AddHostedService", StringComparison.Ordinal), "Modern persisted worker must remain registered."); + Assert(program.Contains("IStoryIntelligenceMaterialisationService, StoryIntelligenceMaterialisationService", StringComparison.Ordinal), "Modern materialisation service should be registered in DI."); + Assert(repository.Contains("StoryIntelligenceBookPipeline_FindNextReadySceneAnalysis", StringComparison.Ordinal), "Recovery lookup should use a stored procedure."); + Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceBookPipeline_FindNextReadySceneAnalysis", StringComparison.Ordinal), "Forward migration should define the read-only recovery lookup procedure."); +} + +static void StoryIntelligenceNarrativeMaterialisationReadsStrictSceneResults() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceNarrativeReviewService.cs")); + var fixture = new SceneIntelligenceScene + { + SchemaVersion = "1.0", + NarrativeArcs = + [ + new SceneIntelligenceNarrativeArc + { + Title = "The Lighthouse Signal", + Description = "A coastal signal recurs as a book-level mystery.", + Scale = "major", + ChangeType = "Introduced", + Significance = "major", + ContinuityKey = "lighthouse signal mystery", + Evidence = "The lamp flashes in a pattern no one can explain.", + Confidence = 0.9m + } + ], + MetricValues = [new SceneIntelligenceMetricValue { Key = "metric_1", Score = 7, Confidence = 0.8m }] + }; + var signals = StoryIntelligenceNarrativeSignalConsolidator.Consolidate( + fixture.NarrativeArcs.Select(arc => new StoryIntelligenceNarrativeSignalInput( + 501, + arc.Title, + arc.Description, + string.Equals(arc.Scale, "thread", StringComparison.OrdinalIgnoreCase) ? "Thread" : "Plot Line", + arc.ChangeType ?? arc.ArcType ?? "Developed", + arc.Significance ?? "major", + arc.ContinuityKey, + arc.ParentArcTitle, + arc.ArcType ?? "Narrative arc", + arc.Confidence, + arc.MatchingExistingPlotLineId, + arc.MatchingExistingThreadId, + arc.Evidence)), + []); + + Assert(service.Contains("IsReadableSceneResult", StringComparison.Ordinal), "Narrative review should not require the obsolete summary object to read modern strict scene results."); + Assert(service.Contains("!string.IsNullOrWhiteSpace(scene.SchemaVersion)", StringComparison.Ordinal), "Modern strict scene results should be accepted by schemaVersion."); + Assert(service.Contains("scene.NarrativeArcs?.Count > 0", StringComparison.Ordinal), "Narrative materialisation should accept scenes that carry narrative arcs."); + Assert(signals.Single().ShouldCreateCandidate, "A non-Alpha-Flame major narrative fixture should generate a review candidate through the generic consolidator."); +} + +static void StoryIntelligenceStabilisationRoutesNormalUsersToBook() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs")); + var buildComplete = File.ReadAllText(Path.Combine(root, "Views/Onboarding/BuildComplete.cshtml")); + var overview = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligence.cshtml")); + var context = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceContext.cshtml")); + + var startAction = ExtractBetween(controller, "public async Task StartStoryIntelligence()", "[HttpPost(\"story-intelligence/book/{bookId:int}/start\")]"); + Assert(startAction.Contains("return RedirectToBook(progress);", StringComparison.Ordinal), "Fresh onboarding Story Intelligence start should return to the Book page."); + Assert(!startAction.Contains("RedirectToAction(nameof(StoryIntelligenceProgress)", StringComparison.Ordinal), "Fresh onboarding start must not route normal users to the old progress page."); + Assert(buildComplete.Contains("asp-action=\"StartStoryIntelligence\"", StringComparison.Ordinal), "Core Import Complete should still start the analysis."); + Assert(overview.Contains("asp-controller=\"Books\" asp-action=\"Details\"", StringComparison.Ordinal), "Existing analysis overview should return to Book details."); + Assert(context.Contains("asp-controller=\"Books\" asp-action=\"Details\"", StringComparison.Ordinal), "Existing analysis context page should return to Book details."); +} + static void StoryIntelligenceNarrativeCandidatesRequireReview() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); @@ -5037,9 +5181,11 @@ static void StoryIntelligenceStructuredMetricValuesValidateAndImport() Assert(validation.IsValid, string.Join("; ", validation.Errors.Select(error => $"{error.Path}: {error.Message}"))); + var materialisation = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceMaterialisationService.cs")); var commit = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceImportCommitService.cs")); - Assert(commit.Contains("parsed.MetricValues", StringComparison.Ordinal), "Import should normalise strict metricValues for existing metric persistence."); - Assert(commit.Contains("metrics[metric.Key.Trim()]", StringComparison.Ordinal), "Metric normalisation should key values by configured metric key."); + Assert(materialisation.Contains("parsed.MetricValues", StringComparison.Ordinal), "Modern materialisation should normalise strict metricValues for persistence."); + Assert(materialisation.Contains("metric.Key?.Trim()", StringComparison.Ordinal), "Metric normalisation should key values by configured metric key."); + Assert(commit.Contains("StoryIntelligenceMaterialisationService.BuildMetrics", StringComparison.Ordinal), "Legacy admin commit should delegate metric conversion to modern materialisation helpers."); } static void StoryIntelligenceStructuredSchemaCoversCompleteResponseModel() diff --git a/PlotLine/Controllers/OnboardingController.cs b/PlotLine/Controllers/OnboardingController.cs index 694cbcd..747ff62 100644 --- a/PlotLine/Controllers/OnboardingController.cs +++ b/PlotLine/Controllers/OnboardingController.cs @@ -86,7 +86,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard var model = await storyIntelligence.GetOverviewAsync(); if (model.ExistingBatchID.HasValue) { - return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId = model.ExistingBatchID.Value }); + return RedirectToBook(model); } if (model.PreviewID == Guid.Empty) @@ -142,7 +142,14 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard return RedirectToAction(nameof(Index)); } - return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId = job.BatchID }); + var progress = await storyIntelligence.GetProgressAsync(job.BatchID); + if (progress is not null) + { + TempData["ArchiveMessage"] = "Story Intelligence is now analysing your book. Progress is shown in the page header."; + return RedirectToBook(progress); + } + + return RedirectToAction("Details", "Books", new { id = job.BookID }); } [HttpPost("story-intelligence/book/{bookId:int}/start")] @@ -598,6 +605,9 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard private IActionResult RedirectToBook(StoryIntelligenceProgressViewModel model) => RedirectToAction("Details", "Books", new { id = model.BookID }); + private IActionResult RedirectToBook(StoryIntelligenceOverviewViewModel model) + => RedirectToAction("Details", "Books", new { id = model.BookID }); + private IActionResult RedirectToReviewCentreWithCharacterGate(Guid batchId) { TempData["OnboardingStoryIntelligenceMessage"] = "Resolve Characters before reviewing this category."; diff --git a/PlotLine/Data/StoryIntelligencePipelineRepository.cs b/PlotLine/Data/StoryIntelligencePipelineRepository.cs index 075b7a0..f7237c2 100644 --- a/PlotLine/Data/StoryIntelligencePipelineRepository.cs +++ b/PlotLine/Data/StoryIntelligencePipelineRepository.cs @@ -12,8 +12,8 @@ public interface IStoryIntelligencePipelineRepository Task> ListForUserAsync(int userId); Task UpsertAsync(StoryIntelligenceBookPipelineSaveRequest request); Task> ListCommittedRunsByBookAsync(int bookId, int userId); + Task FindNextReadySceneAnalysisAsync(); Task TryCompleteSceneAnalysisAsync(int bookId, int userId); - Task TryCompleteNextReadySceneAnalysisAsync(); Task TryBeginCompletionNotificationAsync(int bookId, int userId); } @@ -85,6 +85,14 @@ public sealed class StoryIntelligencePipelineRepository(ISqlConnectionFactory co return rows.ToList(); } + public async Task FindNextReadySceneAnalysisAsync() + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.StoryIntelligenceBookPipeline_FindNextReadySceneAnalysis", + commandType: CommandType.StoredProcedure); + } + public async Task TryCompleteSceneAnalysisAsync(int bookId, int userId) { using var connection = connectionFactory.CreateConnection(); @@ -94,14 +102,6 @@ public sealed class StoryIntelligencePipelineRepository(ISqlConnectionFactory co commandType: CommandType.StoredProcedure); } - public async Task TryCompleteNextReadySceneAnalysisAsync() - { - using var connection = connectionFactory.CreateConnection(); - return await connection.QuerySingleOrDefaultAsync( - "dbo.StoryIntelligenceBookPipeline_TryCompleteNextReadySceneAnalysis", - commandType: CommandType.StoredProcedure); - } - public async Task TryBeginCompletionNotificationAsync(int bookId, int userId) { using var connection = connectionFactory.CreateConnection(); diff --git a/PlotLine/Models/StoryIntelligencePersistenceModels.cs b/PlotLine/Models/StoryIntelligencePersistenceModels.cs index 6fc57a2..ac5a6d3 100644 --- a/PlotLine/Models/StoryIntelligencePersistenceModels.cs +++ b/PlotLine/Models/StoryIntelligencePersistenceModels.cs @@ -107,6 +107,19 @@ public sealed class StoryIntelligenceSceneAnalysisCompletion public bool CompletedNow { get; init; } } +public sealed class StoryIntelligenceSceneAnalysisRecoveryCandidate +{ + public int StoryIntelligenceBookPipelineID { get; init; } + public int ProjectID { get; init; } + public int BookID { get; init; } + public int UserID { get; init; } + public int? LastRunID { get; init; } + public int RunCount { get; init; } + public int TotalExpectedScenes { get; init; } + public int CompletedScenes { get; init; } + public int FailedScenes { get; init; } +} + public sealed class StoryIntelligenceBookPipelineSaveRequest { public int ProjectID { get; init; } diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index e83ee94..61297f3 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -235,6 +235,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -262,7 +263,6 @@ public class Program builder.Services.AddHostedService(); if (builder.Configuration.GetValue("StoryIntelligence:WorkersEnabled", true)) { - builder.Services.AddHostedService(); builder.Services.AddHostedService(); } if (builder.Configuration.GetValue("Illustrations:GenerationWorkerEnabled", true)) diff --git a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs index a9cd02b..e085e9a 100644 --- a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs +++ b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs @@ -29,6 +29,7 @@ public sealed class PersistedStoryIntelligenceRunner( IStoryIntelligenceProgressNotifier notifier, IStoryMemoryService storyMemory, ICharacterIntelligenceService characterIntelligence, + IStoryIntelligenceMaterialisationService materialisation, IStoryIntelligencePipelineStateService pipelineState, IStoryIntelligenceEntitlementService entitlement, IStoryIntelligenceCompletionNotificationService completionNotifications, @@ -791,6 +792,17 @@ public sealed class PersistedStoryIntelligenceRunner( return; } + var materialised = await materialisation.MaterialiseReadyBookAsync(bookId, run.UserID, cancellationToken); + if (!materialised.IsReady) + { + logger.LogInformation( + "Story Intelligence finalisation deferred for BookID={BookID}. RunID={RunID}. Reason={Reason}", + bookId, + run.StoryIntelligenceRunID, + materialised.Reason); + return; + } + var completion = await pipelineState.CompleteSceneAnalysisIfReadyAsync(bookId, run.UserID); if (completion is { CompletedNow: true }) { diff --git a/PlotLine/Services/PersistedStoryIntelligenceWorker.cs b/PlotLine/Services/PersistedStoryIntelligenceWorker.cs index 2107f81..72f3cc6 100644 --- a/PlotLine/Services/PersistedStoryIntelligenceWorker.cs +++ b/PlotLine/Services/PersistedStoryIntelligenceWorker.cs @@ -60,9 +60,26 @@ public sealed class PersistedStoryIntelligenceWorker( { using var scope = scopeFactory.CreateScope(); var pipelineState = scope.ServiceProvider.GetRequiredService(); + var materialisation = scope.ServiceProvider.GetRequiredService(); var notifier = scope.ServiceProvider.GetRequiredService(); var completionNotifications = scope.ServiceProvider.GetRequiredService(); - var completion = await pipelineState.RecoverNextReadySceneAnalysisAsync(); + var candidate = await pipelineState.FindNextReadySceneAnalysisAsync(); + if (candidate is null) + { + return; + } + + var materialised = await materialisation.MaterialiseReadyBookAsync(candidate.BookID, candidate.UserID, stoppingToken); + if (!materialised.IsReady) + { + logger.LogInformation( + "Story Intelligence recovery finalisation deferred for BookID={BookID}. Reason={Reason}", + candidate.BookID, + materialised.Reason); + return; + } + + var completion = await pipelineState.CompleteSceneAnalysisIfReadyAsync(candidate.BookID, candidate.UserID); if (completion is not { CompletedNow: true }) { return; diff --git a/PlotLine/Services/StoryIntelligenceImportCommitService.cs b/PlotLine/Services/StoryIntelligenceImportCommitService.cs index fdcebee..a6e8983 100644 --- a/PlotLine/Services/StoryIntelligenceImportCommitService.cs +++ b/PlotLine/Services/StoryIntelligenceImportCommitService.cs @@ -29,21 +29,6 @@ public sealed class StoryIntelligenceImportCommitService( PropertyNameCaseInsensitive = true }; - private static readonly IReadOnlyDictionary MetricAliases = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["action"] = ["Action"], - ["emotion"] = ["Emotion", "Emotional Weight", "Emotional Resonance"], - ["tension"] = ["Tension"], - ["conflict"] = ["Conflict", "Relationship Conflict"], - ["mystery"] = ["Mystery"], - ["romance"] = ["Romance", "Emotional Intimacy"], - ["humour"] = ["Humour", "Humor", "Comedy", "Comic Relief"], - ["humor"] = ["Humour", "Humor", "Comedy", "Comic Relief"], - ["revelation"] = ["Revelation", "Reveal"], - ["pacing"] = ["Pacing", "PacingIntensity", "Pacing Intensity", "Overall Intensity"], - ["pacingIntensity"] = ["Pacing", "PacingIntensity", "Pacing Intensity", "Overall Intensity"] - }; - public async Task BuildConfirmationAsync(int runId) { var prepared = await PrepareAsync(runId); @@ -434,95 +419,11 @@ public sealed class StoryIntelligenceImportCommitService( } } - private static Dictionary BuildMetricMap(IReadOnlyList metricTypes) - { - var byName = metricTypes.ToDictionary(metric => Clean(metric.MetricName), metric => metric, StringComparer.OrdinalIgnoreCase); - var map = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var metric in metricTypes) - { - map[$"metric_{metric.MetricTypeID}"] = metric; - var cleanedName = Clean(metric.MetricName); - if (!string.IsNullOrWhiteSpace(cleanedName)) - { - map.TryAdd(cleanedName, metric); - } - } - - foreach (var (schemaName, aliases) in MetricAliases) - { - var metric = aliases.Select(alias => byName.TryGetValue(Clean(alias), out var match) ? match : null).FirstOrDefault(match => match is not null); - if (metric is not null) - { - map[schemaName] = metric; - } - } - - return map; - } + private static IReadOnlyDictionary BuildMetricMap(IReadOnlyList metricTypes) + => StoryIntelligenceMaterialisationService.BuildMetricMap(metricTypes); private static IReadOnlyList BuildMetrics(SceneIntelligenceScene parsed, IReadOnlyDictionary metricMap) - { - var metrics = ReadMetrics(parsed); - if (metrics.Count == 0) - { - return []; - } - - var values = new List(); - var importedMetricTypeIds = new HashSet(); - foreach (var (name, metric) in metrics) - { - if (!metric.Score.HasValue || !metricMap.TryGetValue(name, out var metricType)) - { - continue; - } - - if (!importedMetricTypeIds.Add(metricType.MetricTypeID)) - { - continue; - } - - values.Add(new SceneMetricValue - { - MetricTypeID = metricType.MetricTypeID, - MetricName = metricType.MetricName, - Value = Math.Clamp(metric.Score.Value, metricType.MinValue, metricType.MaxValue), - Notes = metric.Confidence.HasValue ? $"Story Intelligence confidence {metric.Confidence.Value:0.##}." : "Imported from Story Intelligence." - }); - } - - return values; - } - - private static IReadOnlyDictionary ReadMetrics(SceneIntelligenceScene parsed) - { - if (parsed.Metrics is { Count: > 0 }) - { - return parsed.Metrics; - } - - if (parsed.MetricValues is null || parsed.MetricValues.Count == 0) - { - return new Dictionary(StringComparer.OrdinalIgnoreCase); - } - - var metrics = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var metric in parsed.MetricValues) - { - if (string.IsNullOrWhiteSpace(metric.Key)) - { - continue; - } - - metrics[metric.Key.Trim()] = new SceneIntelligenceMetric - { - Score = metric.Score, - Confidence = metric.Confidence - }; - } - - return metrics; - } + => StoryIntelligenceMaterialisationService.BuildMetrics(parsed, metricMap); private static IReadOnlyList MatchPurposes(SceneIntelligenceScene parsed, IReadOnlyDictionary purposeMap) { diff --git a/PlotLine/Services/StoryIntelligenceMaterialisationService.cs b/PlotLine/Services/StoryIntelligenceMaterialisationService.cs new file mode 100644 index 0000000..f7479c0 --- /dev/null +++ b/PlotLine/Services/StoryIntelligenceMaterialisationService.cs @@ -0,0 +1,333 @@ +using System.Text.Json; +using PlotLine.Data; +using PlotLine.Models; + +namespace PlotLine.Services; + +public interface IStoryIntelligenceMaterialisationService +{ + Task MaterialiseReadyBookAsync(int bookId, int userId, CancellationToken cancellationToken = default); +} + +public sealed class StoryIntelligenceMaterialisationService( + IStoryIntelligenceResultRepository results, + IStoryIntelligencePipelineRepository pipelines, + ISceneMetricTypeRepository metricTypes, + ISceneRepository scenes, + IChapterRepository chapters, + IStoryIntelligenceNarrativeReviewService narrative, + ILogger logger) : IStoryIntelligenceMaterialisationService +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + public async Task MaterialiseReadyBookAsync(int bookId, int userId, CancellationToken cancellationToken = default) + { + var pipeline = await pipelines.GetByBookForUserAsync(bookId, userId); + if (pipeline is null) + { + return StoryIntelligenceMaterialisationResult.NotReady(bookId, "No Story Intelligence pipeline exists for this book and user."); + } + + var allRuns = (await results.ListRunsByBookForUserAsync(bookId, userId)) + .Where(run => run.ProjectID == pipeline.ProjectID && run.BookID == bookId) + .OrderBy(run => run.CreatedUtc) + .ThenBy(run => run.StoryIntelligenceRunID) + .ToList(); + if (allRuns.Count == 0) + { + return StoryIntelligenceMaterialisationResult.NotReady(bookId, "No persisted Story Intelligence runs exist for this book and user."); + } + + var lastRun = pipeline.LastRunID.HasValue + ? allRuns.FirstOrDefault(run => run.StoryIntelligenceRunID == pipeline.LastRunID.Value) + : null; + lastRun ??= allRuns.Last(); + var windowStart = lastRun.CreatedUtc.AddMinutes(-30); + var scopedRuns = allRuns + .Where(run => run.CreatedUtc >= windowStart) + .OrderBy(run => run.ChapterNumber ?? decimal.MaxValue) + .ThenBy(run => run.StoryIntelligenceRunID) + .ToList(); + + if (scopedRuns.Any(run => run.Status is StoryIntelligenceRunStatuses.Pending or StoryIntelligenceRunStatuses.Running)) + { + return StoryIntelligenceMaterialisationResult.NotReady(bookId, "Story Intelligence still has active chapter runs."); + } + + if (scopedRuns.Any(run => run.Status is StoryIntelligenceRunStatuses.Failed or StoryIntelligenceRunStatuses.Cancelled)) + { + return StoryIntelligenceMaterialisationResult.NotReady(bookId, "Story Intelligence has failed or cancelled chapter runs."); + } + + var canonicalScenes = new List(); + foreach (var chapter in await chapters.ListByBookAsync(bookId)) + { + canonicalScenes.AddRange(await scenes.ListByChapterAsync(chapter.ChapterID)); + } + + var expectedScenes = canonicalScenes.Count(scene => !scene.IsArchived); + var completedScenes = scopedRuns.Sum(run => run.CompletedScenes ?? 0); + var failedScenes = scopedRuns.Sum(run => run.FailedScenes ?? 0); + if (expectedScenes == 0 || completedScenes + failedScenes < expectedScenes) + { + return StoryIntelligenceMaterialisationResult.NotReady( + bookId, + $"Scene results are not complete. Completed/failed {completedScenes + failedScenes:N0} of {expectedScenes:N0} canonical scene(s)."); + } + + var configuredMetrics = (await metricTypes.ListForManagementAsync(pipeline.ProjectID)) + .Where(metric => metric.IsActive && metric.IsEnabledForProject) + .OrderBy(metric => metric.SortOrder) + .ThenBy(metric => metric.MetricName) + .ToList(); + var metricMap = BuildMetricMap(configuredMetrics); + + var metricValuesSaved = 0; + var sceneResultsRead = 0; + var sceneResultsParsed = 0; + var batchItems = new List(); + + foreach (var run in scopedRuns.Where(run => run.Status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (run.ChapterID.HasValue) + { + batchItems.Add(new OnboardingStoryIntelligenceBatchItem + { + TemporaryChapterKey = $"book-{bookId}-chapter-{run.ChapterID.Value}", + ChapterNumber = Convert.ToInt32(run.ChapterNumber ?? 0), + ChapterTitle = string.Empty, + ChapterID = run.ChapterID.Value, + RunID = run.StoryIntelligenceRunID + }); + } + + foreach (var sceneResult in await results.ListSceneResultsAsync(run.StoryIntelligenceRunID)) + { + cancellationToken.ThrowIfCancellationRequested(); + sceneResultsRead++; + if (!sceneResult.SceneID.HasValue || sceneResult.ValidationErrorsCount > 0) + { + continue; + } + + var parsed = TryReadScene(sceneResult); + if (parsed is null) + { + continue; + } + + sceneResultsParsed++; + var metrics = BuildMetrics(parsed, metricMap); + if (metrics.Count == 0) + { + continue; + } + + await scenes.SaveMetricValuesAsync(sceneResult.SceneID.Value, metrics); + metricValuesSaved += metrics.Count; + } + } + + var narrativeCandidatesBefore = (await narrative.ListCandidatesByBookAsync(bookId)).Count; + var narrativeCandidatesAfter = narrativeCandidatesBefore; + if (batchItems.Count > 0) + { + var batch = new OnboardingStoryIntelligenceBatch + { + UserID = userId, + OnboardingID = 0, + PreviewID = Guid.Empty, + ProjectID = pipeline.ProjectID, + BookID = bookId, + ProjectName = pipeline.ProjectName, + BookTitle = pipeline.BookDisplayTitle, + Items = batchItems + }; + await narrative.EnsureCandidatesAsync(batch); + narrativeCandidatesAfter = (await narrative.ListCandidatesByBookAsync(bookId)).Count; + } + + logger.LogInformation( + "Story Intelligence deterministic materialisation completed for BookID={BookID}. PipelineID={PipelineID} Runs={RunCount} SceneResultsRead={SceneResultsRead} SceneResultsParsed={SceneResultsParsed} MetricsSaved={MetricsSaved} NarrativeCandidatesBefore={NarrativeCandidatesBefore} NarrativeCandidatesAfter={NarrativeCandidatesAfter}.", + bookId, + pipeline.StoryIntelligenceBookPipelineID, + scopedRuns.Count, + sceneResultsRead, + sceneResultsParsed, + metricValuesSaved, + narrativeCandidatesBefore, + narrativeCandidatesAfter); + + return new StoryIntelligenceMaterialisationResult( + bookId, + pipeline.StoryIntelligenceBookPipelineID, + IsReady: true, + Reason: null, + RunCount: scopedRuns.Count, + ExpectedScenes: expectedScenes, + SceneResultsRead: sceneResultsRead, + SceneResultsParsed: sceneResultsParsed, + MetricValuesSaved: metricValuesSaved, + NarrativeCandidatesBefore: narrativeCandidatesBefore, + NarrativeCandidatesAfter: narrativeCandidatesAfter); + } + + public static IReadOnlyList BuildMetrics(SceneIntelligenceScene parsed, IReadOnlyDictionary metricMap) + { + var metrics = ReadMetrics(parsed); + if (metrics.Count == 0) + { + return []; + } + + var values = new List(); + var importedMetricTypeIds = new HashSet(); + foreach (var (name, metric) in metrics) + { + if (!metric.Score.HasValue || !metricMap.TryGetValue(name, out var metricType)) + { + continue; + } + + if (!importedMetricTypeIds.Add(metricType.MetricTypeID)) + { + continue; + } + + values.Add(new SceneMetricValue + { + MetricTypeID = metricType.MetricTypeID, + MetricName = metricType.MetricName, + Value = Math.Clamp(metric.Score.Value, metricType.MinValue, metricType.MaxValue), + Notes = metric.Confidence.HasValue ? $"Story Intelligence confidence {metric.Confidence.Value:0.##}." : "Imported from Story Intelligence." + }); + } + + return values; + } + + public static IReadOnlyDictionary BuildMetricMap(IReadOnlyList metricTypes) + { + var byName = metricTypes.ToDictionary(metric => Clean(metric.MetricName), metric => metric, StringComparer.OrdinalIgnoreCase); + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var metric in metricTypes) + { + map[$"metric_{metric.MetricTypeID}"] = metric; + var cleanedName = Clean(metric.MetricName); + if (!string.IsNullOrWhiteSpace(cleanedName)) + { + map.TryAdd(cleanedName, metric); + } + } + + foreach (var (schemaName, aliases) in MetricAliases) + { + var metric = aliases.Select(alias => byName.TryGetValue(Clean(alias), out var match) ? match : null).FirstOrDefault(match => match is not null); + if (metric is not null) + { + map[schemaName] = metric; + } + } + + return map; + } + + private static IReadOnlyDictionary ReadMetrics(SceneIntelligenceScene parsed) + { + if (parsed.MetricValues is { Count: > 0 }) + { + return parsed.MetricValues + .Select(metric => new { Key = metric.Key?.Trim(), metric.Score, metric.Confidence }) + .Where(metric => !string.IsNullOrWhiteSpace(metric.Key)) + .ToDictionary( + metric => metric.Key!, + metric => new SceneIntelligenceMetric { Score = metric.Score, Confidence = metric.Confidence }, + StringComparer.OrdinalIgnoreCase); + } + + return parsed.Metrics is { Count: > 0 } + ? parsed.Metrics + : new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result) + { + foreach (var json in new[] { result.ParsedJson, result.OutputTextJson }) + { + if (string.IsNullOrWhiteSpace(json)) + { + continue; + } + + try + { + var direct = JsonSerializer.Deserialize(json, JsonOptions); + if (direct?.Summary is not null || direct?.MetricValues?.Count > 0 || direct?.Metrics?.Count > 0) + { + return direct; + } + + using var document = JsonDocument.Parse(json); + if (document.RootElement.TryGetProperty("parsedScene", out var parsedScene)) + { + return parsedScene.Deserialize(JsonOptions); + } + } + catch (JsonException) + { + // Try the next persisted JSON form. + } + } + + return null; + } + + private static string Clean(string? value) + => string.IsNullOrWhiteSpace(value) + ? string.Empty + : new string(value.Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray()); + + private static readonly IReadOnlyDictionary MetricAliases = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["overallIntensity"] = ["Overall Intensity", "Intensity"], + ["tension"] = ["Tension"], + ["emotionalWeight"] = ["Emotional Weight", "Emotion", "Emotional Resonance"], + ["emotion"] = ["Emotion", "Emotional Weight", "Emotional Resonance"], + ["action"] = ["Action"], + ["conflict"] = ["Conflict", "Relationship Conflict", "Tension"], + ["darkness"] = ["Darkness"], + ["romance"] = ["Romance"], + ["sexualCharge"] = ["Sexual Charge"], + ["violence"] = ["Violence"], + ["hopeLightness"] = ["Hope / Lightness", "Hope", "Lightness"], + ["comedy"] = ["Comedy", "Humour", "Humor"], + ["humour"] = ["Humour", "Humor", "Comedy", "Comic Relief"], + ["humor"] = ["Humour", "Humor", "Comedy", "Comic Relief"], + ["mystery"] = ["Mystery"], + ["revelation"] = ["Revelation", "Reveal", "Mystery"], + ["pacing"] = ["Pacing", "PacingIntensity", "Pacing Intensity", "Overall Intensity"], + ["pacingIntensity"] = ["Pacing", "PacingIntensity", "Pacing Intensity", "Overall Intensity"] + }; +} + +public sealed record StoryIntelligenceMaterialisationResult( + int BookID, + int? StoryIntelligenceBookPipelineID, + bool IsReady, + string? Reason, + int RunCount, + int ExpectedScenes, + int SceneResultsRead, + int SceneResultsParsed, + int MetricValuesSaved, + int NarrativeCandidatesBefore, + int NarrativeCandidatesAfter) +{ + public static StoryIntelligenceMaterialisationResult NotReady(int bookId, string reason) + => new(bookId, null, false, reason, 0, 0, 0, 0, 0, 0, 0); +} diff --git a/PlotLine/Services/StoryIntelligenceNarrativeReviewService.cs b/PlotLine/Services/StoryIntelligenceNarrativeReviewService.cs index 15b4505..bc66c1a 100644 --- a/PlotLine/Services/StoryIntelligenceNarrativeReviewService.cs +++ b/PlotLine/Services/StoryIntelligenceNarrativeReviewService.cs @@ -11,6 +11,8 @@ namespace PlotLine.Services; public interface IStoryIntelligenceNarrativeReviewService { Task GetReviewAsync(OnboardingStoryIntelligenceBatch batch); + Task EnsureCandidatesAsync(OnboardingStoryIntelligenceBatch batch); + Task> ListCandidatesByBookAsync(int bookId); Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceNarrativeImportForm form, int userId); Task GetResultAsync(OnboardingStoryIntelligenceBatch batch); } @@ -65,6 +67,9 @@ public sealed class StoryIntelligenceNarrativeReviewService( }; } + public Task> ListCandidatesByBookAsync(int bookId) + => candidates.ListByBookAsync(bookId); + public async Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceNarrativeImportForm form, int userId) { await EnsureCandidatesAsync(batch); @@ -282,7 +287,7 @@ public sealed class StoryIntelligenceNarrativeReviewService( CandidatesRejected = batch.LastNarrativeImportResult?.CandidatesRejected ?? 0 }); - private async Task EnsureCandidatesAsync(OnboardingStoryIntelligenceBatch batch) + public async Task EnsureCandidatesAsync(OnboardingStoryIntelligenceBatch batch) { var existingPlotLines = await plots.ListPlotLinesAsync(batch.ProjectID); var existingThreads = await plots.ListPlotThreadsByProjectAsync(batch.ProjectID); @@ -604,7 +609,7 @@ public sealed class StoryIntelligenceNarrativeReviewService( try { var direct = JsonSerializer.Deserialize(json, JsonOptions); - if (direct?.Summary is not null) + if (direct is not null && IsReadableSceneResult(direct)) { return direct; } @@ -624,6 +629,15 @@ public sealed class StoryIntelligenceNarrativeReviewService( return null; } + private static bool IsReadableSceneResult(SceneIntelligenceScene scene) + => !string.IsNullOrWhiteSpace(scene.SchemaVersion) + || scene.Summary is not null + || scene.NarrativeArcs?.Count > 0 + || scene.QuestionsRaised?.Count > 0 + || scene.TimelineClues?.Count > 0 + || scene.MetricValues?.Count > 0 + || scene.Metrics?.Count > 0; + private static string CleanTitle(string? preferred, string fallback) => string.IsNullOrWhiteSpace(preferred) ? fallback.Trim() : preferred.Trim(); diff --git a/PlotLine/Services/StoryIntelligencePipelineStateService.cs b/PlotLine/Services/StoryIntelligencePipelineStateService.cs index ad709ba..81893c8 100644 --- a/PlotLine/Services/StoryIntelligencePipelineStateService.cs +++ b/PlotLine/Services/StoryIntelligencePipelineStateService.cs @@ -17,7 +17,7 @@ public interface IStoryIntelligencePipelineStateService Task RecordKnowledgeImportAsync(int projectId, int bookId); Task RecordNarrativeImportAsync(int projectId, int bookId); Task CompleteSceneAnalysisIfReadyAsync(int bookId, int userId); - Task RecoverNextReadySceneAnalysisAsync(); + Task FindNextReadySceneAnalysisAsync(); Task> ListCommittedRunsByBookAsync(int bookId, int userId); } @@ -194,12 +194,8 @@ public sealed class StoryIntelligencePipelineStateService( return completion; } - public async Task RecoverNextReadySceneAnalysisAsync() - { - var completion = await pipelines.TryCompleteNextReadySceneAnalysisAsync(); - LogSceneAnalysisCompletion(completion, "recovered"); - return completion; - } + public Task FindNextReadySceneAnalysisAsync() + => pipelines.FindNextReadySceneAnalysisAsync(); public Task> ListCommittedRunsByBookAsync(int bookId, int userId) => pipelines.ListCommittedRunsByBookAsync(bookId, userId); diff --git a/PlotLine/Sql/173_Phase26I_StoryIntelligenceModernMaterialisation.sql b/PlotLine/Sql/173_Phase26I_StoryIntelligenceModernMaterialisation.sql new file mode 100644 index 0000000..12a8e14 --- /dev/null +++ b/PlotLine/Sql/173_Phase26I_StoryIntelligenceModernMaterialisation.sql @@ -0,0 +1,90 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceBookPipeline_FindNextReadySceneAnalysis +AS +BEGIN + SET NOCOUNT ON; + + ;WITH CandidatePipelines AS + ( + SELECT + pipeline.StoryIntelligenceBookPipelineID, + pipeline.ProjectID, + pipeline.BookID, + latest.UserID, + latest.StoryIntelligenceRunID AS LastRunID, + latest.CreatedUtc AS LastRunCreatedUtc, + ( + SELECT COUNT(1) + FROM dbo.Scenes scene + INNER JOIN dbo.Chapters chapter + ON chapter.ChapterID = scene.ChapterID + AND chapter.BookID = pipeline.BookID + AND chapter.IsArchived = 0 + WHERE scene.IsArchived = 0 + ) AS TotalExpectedScenes + FROM dbo.StoryIntelligenceBookPipelines pipeline + CROSS APPLY + ( + SELECT TOP (1) + run.StoryIntelligenceRunID, + run.UserID, + run.CreatedUtc + FROM dbo.StoryIntelligenceRuns run + WHERE run.BookID = pipeline.BookID + AND run.ProjectID = pipeline.ProjectID + ORDER BY run.CreatedUtc DESC, run.StoryIntelligenceRunID DESC + ) latest + WHERE pipeline.Status = N'InProgress' + AND pipeline.CurrentStage = N'Chapters' + ), + CandidateRunCounts AS + ( + SELECT + candidate.StoryIntelligenceBookPipelineID, + candidate.ProjectID, + candidate.BookID, + candidate.UserID, + candidate.LastRunID, + candidate.TotalExpectedScenes, + COUNT(run.StoryIntelligenceRunID) AS RunCount, + SUM(ISNULL(run.CompletedScenes, 0)) AS CompletedScenes, + SUM(ISNULL(run.FailedScenes, 0)) AS FailedScenes, + SUM(CASE WHEN run.Status IN (N'Pending', N'Running') THEN 1 ELSE 0 END) AS ActiveRunCount, + SUM(CASE WHEN run.Status IN (N'Failed', N'Cancelled') THEN 1 ELSE 0 END) AS FailedRunCount + FROM CandidatePipelines candidate + INNER JOIN dbo.StoryIntelligenceRuns run + ON run.BookID = candidate.BookID + AND run.ProjectID = candidate.ProjectID + AND run.UserID = candidate.UserID + AND run.CreatedUtc >= DATEADD(minute, -30, candidate.LastRunCreatedUtc) + GROUP BY + candidate.StoryIntelligenceBookPipelineID, + candidate.ProjectID, + candidate.BookID, + candidate.UserID, + candidate.LastRunID, + candidate.TotalExpectedScenes + ) + SELECT TOP (1) + StoryIntelligenceBookPipelineID, + ProjectID, + BookID, + UserID, + LastRunID, + RunCount, + TotalExpectedScenes, + CompletedScenes, + FailedScenes + FROM CandidateRunCounts + WHERE RunCount > 0 + AND ActiveRunCount = 0 + AND FailedRunCount = 0 + AND TotalExpectedScenes > 0 + AND CompletedScenes + FailedScenes >= TotalExpectedScenes + ORDER BY StoryIntelligenceBookPipelineID; +END; +GO diff --git a/PlotLine/Views/Onboarding/StoryIntelligence.cshtml b/PlotLine/Views/Onboarding/StoryIntelligence.cshtml index d5cdd90..8ff70a8 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligence.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligence.cshtml @@ -27,7 +27,7 @@

Analysis has already started

You can return to progress or open the Review Centre when analysis is ready.

- Return to book + Return to book
diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceContext.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceContext.cshtml index 652a99a..dd7ac65 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceContext.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceContext.cshtml @@ -23,7 +23,7 @@

Analysis has already started

You can return to progress or open the Review Centre when analysis is ready.

- Return to book + Return to book