Stabilise Story Intelligence materialisation

This commit is contained in:
Nick Beckley 2026-08-29 11:52:52 +00:00
parent 78f401b417
commit 6dec8a4345
14 changed files with 666 additions and 134 deletions

View File

@ -180,6 +180,10 @@ var tests = new (string Name, Action Test)[]
("Story Intelligence downstream imports support canonical scene results", StoryIntelligenceDownstreamImportsSupportCanonicalSceneResults), ("Story Intelligence downstream imports support canonical scene results", StoryIntelligenceDownstreamImportsSupportCanonicalSceneResults),
("Story Intelligence runner supplies metrics and Plot Lines", StoryIntelligenceRunnerSuppliesMetricsAndPlotLines), ("Story Intelligence runner supplies metrics and Plot Lines", StoryIntelligenceRunnerSuppliesMetricsAndPlotLines),
("Story Intelligence metric persistence uses configured keys", StoryIntelligenceMetricPersistenceUsesConfiguredKeys), ("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 narrative candidates require review", StoryIntelligenceNarrativeCandidatesRequireReview),
("Story Intelligence discovers Plot Lines without preconfiguration", StoryIntelligenceDiscoversPlotLinesWithoutPreconfiguration), ("Story Intelligence discovers Plot Lines without preconfiguration", StoryIntelligenceDiscoversPlotLinesWithoutPreconfiguration),
("Story Intelligence matches existing Plot Lines without duplicates", StoryIntelligenceMatchesExistingPlotLinesWithoutDuplicates), ("Story Intelligence matches existing Plot Lines without duplicates", StoryIntelligenceMatchesExistingPlotLinesWithoutDuplicates),
@ -469,6 +473,7 @@ static void StoryIntelligenceSceneAnalysisCompletionBarrierReleasesReview()
{ {
var root = PlotLineRoot(); var root = PlotLineRoot();
var migration = File.ReadAllText(Path.Combine(root, "Sql/172_Phase26H_StoryIntelligenceSceneAnalysisCompletionBarrier.sql")); 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 repository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligencePipelineRepository.cs"));
var pipelineState = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligencePipelineStateService.cs")); var pipelineState = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligencePipelineStateService.cs"));
var runner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.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("@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("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("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("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("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("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(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."); 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 root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceImportCommitService.cs")); var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceImportCommitService.cs"));
var repository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligenceResultRepository.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(materialisation.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(materialisation.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("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("\"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."); 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<string, SceneIntelligenceMetric>
{
["tension"] = new() { Score = 2, Confidence = 0.5m }
}
};
var legacyScene = new SceneIntelligenceScene
{
Metrics = new Dictionary<string, SceneIntelligenceMetric>
{
["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<StoryIntelligenceWorker>", StringComparison.Ordinal), "Legacy StoryIntelligenceWorker must not poll normal jobs.");
Assert(program.Contains("AddHostedService<PersistedStoryIntelligenceWorker>", 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<IActionResult> 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() static void StoryIntelligenceNarrativeCandidatesRequireReview()
{ {
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); 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}"))); 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")); 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(materialisation.Contains("parsed.MetricValues", StringComparison.Ordinal), "Modern materialisation should normalise strict metricValues for persistence.");
Assert(commit.Contains("metrics[metric.Key.Trim()]", StringComparison.Ordinal), "Metric normalisation should key values by configured metric key."); 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() static void StoryIntelligenceStructuredSchemaCoversCompleteResponseModel()

View File

@ -86,7 +86,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
var model = await storyIntelligence.GetOverviewAsync(); var model = await storyIntelligence.GetOverviewAsync();
if (model.ExistingBatchID.HasValue) if (model.ExistingBatchID.HasValue)
{ {
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId = model.ExistingBatchID.Value }); return RedirectToBook(model);
} }
if (model.PreviewID == Guid.Empty) if (model.PreviewID == Guid.Empty)
@ -142,7 +142,14 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return RedirectToAction(nameof(Index)); 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")] [HttpPost("story-intelligence/book/{bookId:int}/start")]
@ -598,6 +605,9 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
private IActionResult RedirectToBook(StoryIntelligenceProgressViewModel model) private IActionResult RedirectToBook(StoryIntelligenceProgressViewModel model)
=> RedirectToAction("Details", "Books", new { id = model.BookID }); => RedirectToAction("Details", "Books", new { id = model.BookID });
private IActionResult RedirectToBook(StoryIntelligenceOverviewViewModel model)
=> RedirectToAction("Details", "Books", new { id = model.BookID });
private IActionResult RedirectToReviewCentreWithCharacterGate(Guid batchId) private IActionResult RedirectToReviewCentreWithCharacterGate(Guid batchId)
{ {
TempData["OnboardingStoryIntelligenceMessage"] = "Resolve Characters before reviewing this category."; TempData["OnboardingStoryIntelligenceMessage"] = "Resolve Characters before reviewing this category.";

View File

@ -12,8 +12,8 @@ public interface IStoryIntelligencePipelineRepository
Task<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(int userId); Task<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(int userId);
Task<StoryIntelligenceBookPipelineState> UpsertAsync(StoryIntelligenceBookPipelineSaveRequest request); Task<StoryIntelligenceBookPipelineState> UpsertAsync(StoryIntelligenceBookPipelineSaveRequest request);
Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId); Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId);
Task<StoryIntelligenceSceneAnalysisRecoveryCandidate?> FindNextReadySceneAnalysisAsync();
Task<StoryIntelligenceSceneAnalysisCompletion?> TryCompleteSceneAnalysisAsync(int bookId, int userId); Task<StoryIntelligenceSceneAnalysisCompletion?> TryCompleteSceneAnalysisAsync(int bookId, int userId);
Task<StoryIntelligenceSceneAnalysisCompletion?> TryCompleteNextReadySceneAnalysisAsync();
Task<StoryIntelligenceImportNotificationTarget?> TryBeginCompletionNotificationAsync(int bookId, int userId); Task<StoryIntelligenceImportNotificationTarget?> TryBeginCompletionNotificationAsync(int bookId, int userId);
} }
@ -85,6 +85,14 @@ public sealed class StoryIntelligencePipelineRepository(ISqlConnectionFactory co
return rows.ToList(); return rows.ToList();
} }
public async Task<StoryIntelligenceSceneAnalysisRecoveryCandidate?> FindNextReadySceneAnalysisAsync()
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceSceneAnalysisRecoveryCandidate>(
"dbo.StoryIntelligenceBookPipeline_FindNextReadySceneAnalysis",
commandType: CommandType.StoredProcedure);
}
public async Task<StoryIntelligenceSceneAnalysisCompletion?> TryCompleteSceneAnalysisAsync(int bookId, int userId) public async Task<StoryIntelligenceSceneAnalysisCompletion?> TryCompleteSceneAnalysisAsync(int bookId, int userId)
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();
@ -94,14 +102,6 @@ public sealed class StoryIntelligencePipelineRepository(ISqlConnectionFactory co
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
} }
public async Task<StoryIntelligenceSceneAnalysisCompletion?> TryCompleteNextReadySceneAnalysisAsync()
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceSceneAnalysisCompletion>(
"dbo.StoryIntelligenceBookPipeline_TryCompleteNextReadySceneAnalysis",
commandType: CommandType.StoredProcedure);
}
public async Task<StoryIntelligenceImportNotificationTarget?> TryBeginCompletionNotificationAsync(int bookId, int userId) public async Task<StoryIntelligenceImportNotificationTarget?> TryBeginCompletionNotificationAsync(int bookId, int userId)
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();

View File

@ -107,6 +107,19 @@ public sealed class StoryIntelligenceSceneAnalysisCompletion
public bool CompletedNow { get; init; } 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 sealed class StoryIntelligenceBookPipelineSaveRequest
{ {
public int ProjectID { get; init; } public int ProjectID { get; init; }

View File

@ -235,6 +235,7 @@ public class Program
builder.Services.AddScoped<ICharacterIntelligenceService, CharacterIntelligenceService>(); builder.Services.AddScoped<ICharacterIntelligenceService, CharacterIntelligenceService>();
builder.Services.AddScoped<INameIntelligenceService, NameIntelligenceService>(); builder.Services.AddScoped<INameIntelligenceService, NameIntelligenceService>();
builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>(); builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>();
builder.Services.AddScoped<IStoryIntelligenceMaterialisationService, StoryIntelligenceMaterialisationService>();
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>(); builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
builder.Services.AddScoped<IStoryIntelligenceGlobalProgressService, StoryIntelligenceGlobalProgressService>(); builder.Services.AddScoped<IStoryIntelligenceGlobalProgressService, StoryIntelligenceGlobalProgressService>();
builder.Services.AddScoped<IStoryIntelligenceEntitlementService, StoryIntelligenceEntitlementService>(); builder.Services.AddScoped<IStoryIntelligenceEntitlementService, StoryIntelligenceEntitlementService>();
@ -262,7 +263,6 @@ public class Program
builder.Services.AddHostedService<EmailQueueWorker>(); builder.Services.AddHostedService<EmailQueueWorker>();
if (builder.Configuration.GetValue("StoryIntelligence:WorkersEnabled", true)) if (builder.Configuration.GetValue("StoryIntelligence:WorkersEnabled", true))
{ {
builder.Services.AddHostedService<StoryIntelligenceWorker>();
builder.Services.AddHostedService<PersistedStoryIntelligenceWorker>(); builder.Services.AddHostedService<PersistedStoryIntelligenceWorker>();
} }
if (builder.Configuration.GetValue("Illustrations:GenerationWorkerEnabled", true)) if (builder.Configuration.GetValue("Illustrations:GenerationWorkerEnabled", true))

View File

@ -29,6 +29,7 @@ public sealed class PersistedStoryIntelligenceRunner(
IStoryIntelligenceProgressNotifier notifier, IStoryIntelligenceProgressNotifier notifier,
IStoryMemoryService storyMemory, IStoryMemoryService storyMemory,
ICharacterIntelligenceService characterIntelligence, ICharacterIntelligenceService characterIntelligence,
IStoryIntelligenceMaterialisationService materialisation,
IStoryIntelligencePipelineStateService pipelineState, IStoryIntelligencePipelineStateService pipelineState,
IStoryIntelligenceEntitlementService entitlement, IStoryIntelligenceEntitlementService entitlement,
IStoryIntelligenceCompletionNotificationService completionNotifications, IStoryIntelligenceCompletionNotificationService completionNotifications,
@ -791,6 +792,17 @@ public sealed class PersistedStoryIntelligenceRunner(
return; 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); var completion = await pipelineState.CompleteSceneAnalysisIfReadyAsync(bookId, run.UserID);
if (completion is { CompletedNow: true }) if (completion is { CompletedNow: true })
{ {

View File

@ -60,9 +60,26 @@ public sealed class PersistedStoryIntelligenceWorker(
{ {
using var scope = scopeFactory.CreateScope(); using var scope = scopeFactory.CreateScope();
var pipelineState = scope.ServiceProvider.GetRequiredService<IStoryIntelligencePipelineStateService>(); var pipelineState = scope.ServiceProvider.GetRequiredService<IStoryIntelligencePipelineStateService>();
var materialisation = scope.ServiceProvider.GetRequiredService<IStoryIntelligenceMaterialisationService>();
var notifier = scope.ServiceProvider.GetRequiredService<IStoryIntelligenceProgressNotifier>(); var notifier = scope.ServiceProvider.GetRequiredService<IStoryIntelligenceProgressNotifier>();
var completionNotifications = scope.ServiceProvider.GetRequiredService<IStoryIntelligenceCompletionNotificationService>(); var completionNotifications = scope.ServiceProvider.GetRequiredService<IStoryIntelligenceCompletionNotificationService>();
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 }) if (completion is not { CompletedNow: true })
{ {
return; return;

View File

@ -29,21 +29,6 @@ public sealed class StoryIntelligenceImportCommitService(
PropertyNameCaseInsensitive = true PropertyNameCaseInsensitive = true
}; };
private static readonly IReadOnlyDictionary<string, string[]> MetricAliases = new Dictionary<string, string[]>(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<StoryIntelligenceImportConfirmationViewModel?> BuildConfirmationAsync(int runId) public async Task<StoryIntelligenceImportConfirmationViewModel?> BuildConfirmationAsync(int runId)
{ {
var prepared = await PrepareAsync(runId); var prepared = await PrepareAsync(runId);
@ -434,95 +419,11 @@ public sealed class StoryIntelligenceImportCommitService(
} }
} }
private static Dictionary<string, SceneMetricType> BuildMetricMap(IReadOnlyList<SceneMetricType> metricTypes) private static IReadOnlyDictionary<string, SceneMetricType> BuildMetricMap(IReadOnlyList<SceneMetricType> metricTypes)
{ => StoryIntelligenceMaterialisationService.BuildMetricMap(metricTypes);
var byName = metricTypes.ToDictionary(metric => Clean(metric.MetricName), metric => metric, StringComparer.OrdinalIgnoreCase);
var map = new Dictionary<string, SceneMetricType>(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 IReadOnlyList<SceneMetricValue> BuildMetrics(SceneIntelligenceScene parsed, IReadOnlyDictionary<string, SceneMetricType> metricMap) private static IReadOnlyList<SceneMetricValue> BuildMetrics(SceneIntelligenceScene parsed, IReadOnlyDictionary<string, SceneMetricType> metricMap)
{ => StoryIntelligenceMaterialisationService.BuildMetrics(parsed, metricMap);
var metrics = ReadMetrics(parsed);
if (metrics.Count == 0)
{
return [];
}
var values = new List<SceneMetricValue>();
var importedMetricTypeIds = new HashSet<int>();
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<string, SceneIntelligenceMetric> ReadMetrics(SceneIntelligenceScene parsed)
{
if (parsed.Metrics is { Count: > 0 })
{
return parsed.Metrics;
}
if (parsed.MetricValues is null || parsed.MetricValues.Count == 0)
{
return new Dictionary<string, SceneIntelligenceMetric>(StringComparer.OrdinalIgnoreCase);
}
var metrics = new Dictionary<string, SceneIntelligenceMetric>(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;
}
private static IReadOnlyList<int> MatchPurposes(SceneIntelligenceScene parsed, IReadOnlyDictionary<string, int> purposeMap) private static IReadOnlyList<int> MatchPurposes(SceneIntelligenceScene parsed, IReadOnlyDictionary<string, int> purposeMap)
{ {

View File

@ -0,0 +1,333 @@
using System.Text.Json;
using PlotLine.Data;
using PlotLine.Models;
namespace PlotLine.Services;
public interface IStoryIntelligenceMaterialisationService
{
Task<StoryIntelligenceMaterialisationResult> 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<StoryIntelligenceMaterialisationService> logger) : IStoryIntelligenceMaterialisationService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
public async Task<StoryIntelligenceMaterialisationResult> 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<Scene>();
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<OnboardingStoryIntelligenceBatchItem>();
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<SceneMetricValue> BuildMetrics(SceneIntelligenceScene parsed, IReadOnlyDictionary<string, SceneMetricType> metricMap)
{
var metrics = ReadMetrics(parsed);
if (metrics.Count == 0)
{
return [];
}
var values = new List<SceneMetricValue>();
var importedMetricTypeIds = new HashSet<int>();
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<string, SceneMetricType> BuildMetricMap(IReadOnlyList<SceneMetricType> metricTypes)
{
var byName = metricTypes.ToDictionary(metric => Clean(metric.MetricName), metric => metric, StringComparer.OrdinalIgnoreCase);
var map = new Dictionary<string, SceneMetricType>(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<string, SceneIntelligenceMetric> 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<string, SceneIntelligenceMetric>(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<SceneIntelligenceScene>(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<SceneIntelligenceScene>(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<string, string[]> MetricAliases = new Dictionary<string, string[]>(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);
}

View File

@ -11,6 +11,8 @@ namespace PlotLine.Services;
public interface IStoryIntelligenceNarrativeReviewService public interface IStoryIntelligenceNarrativeReviewService
{ {
Task<StoryIntelligenceNarrativeReviewViewModel> GetReviewAsync(OnboardingStoryIntelligenceBatch batch); Task<StoryIntelligenceNarrativeReviewViewModel> GetReviewAsync(OnboardingStoryIntelligenceBatch batch);
Task EnsureCandidatesAsync(OnboardingStoryIntelligenceBatch batch);
Task<IReadOnlyList<StoryIntelligenceNarrativeCandidate>> ListCandidatesByBookAsync(int bookId);
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceNarrativeImportForm form, int userId); Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceNarrativeImportForm form, int userId);
Task<StoryIntelligenceNarrativeImportResultViewModel?> GetResultAsync(OnboardingStoryIntelligenceBatch batch); Task<StoryIntelligenceNarrativeImportResultViewModel?> GetResultAsync(OnboardingStoryIntelligenceBatch batch);
} }
@ -65,6 +67,9 @@ public sealed class StoryIntelligenceNarrativeReviewService(
}; };
} }
public Task<IReadOnlyList<StoryIntelligenceNarrativeCandidate>> ListCandidatesByBookAsync(int bookId)
=> candidates.ListByBookAsync(bookId);
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceNarrativeImportForm form, int userId) public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceNarrativeImportForm form, int userId)
{ {
await EnsureCandidatesAsync(batch); await EnsureCandidatesAsync(batch);
@ -282,7 +287,7 @@ public sealed class StoryIntelligenceNarrativeReviewService(
CandidatesRejected = batch.LastNarrativeImportResult?.CandidatesRejected ?? 0 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 existingPlotLines = await plots.ListPlotLinesAsync(batch.ProjectID);
var existingThreads = await plots.ListPlotThreadsByProjectAsync(batch.ProjectID); var existingThreads = await plots.ListPlotThreadsByProjectAsync(batch.ProjectID);
@ -604,7 +609,7 @@ public sealed class StoryIntelligenceNarrativeReviewService(
try try
{ {
var direct = JsonSerializer.Deserialize<SceneIntelligenceScene>(json, JsonOptions); var direct = JsonSerializer.Deserialize<SceneIntelligenceScene>(json, JsonOptions);
if (direct?.Summary is not null) if (direct is not null && IsReadableSceneResult(direct))
{ {
return direct; return direct;
} }
@ -624,6 +629,15 @@ public sealed class StoryIntelligenceNarrativeReviewService(
return null; 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) private static string CleanTitle(string? preferred, string fallback)
=> string.IsNullOrWhiteSpace(preferred) ? fallback.Trim() : preferred.Trim(); => string.IsNullOrWhiteSpace(preferred) ? fallback.Trim() : preferred.Trim();

View File

@ -17,7 +17,7 @@ public interface IStoryIntelligencePipelineStateService
Task RecordKnowledgeImportAsync(int projectId, int bookId); Task RecordKnowledgeImportAsync(int projectId, int bookId);
Task RecordNarrativeImportAsync(int projectId, int bookId); Task RecordNarrativeImportAsync(int projectId, int bookId);
Task<StoryIntelligenceSceneAnalysisCompletion?> CompleteSceneAnalysisIfReadyAsync(int bookId, int userId); Task<StoryIntelligenceSceneAnalysisCompletion?> CompleteSceneAnalysisIfReadyAsync(int bookId, int userId);
Task<StoryIntelligenceSceneAnalysisCompletion?> RecoverNextReadySceneAnalysisAsync(); Task<StoryIntelligenceSceneAnalysisRecoveryCandidate?> FindNextReadySceneAnalysisAsync();
Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId); Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId);
} }
@ -194,12 +194,8 @@ public sealed class StoryIntelligencePipelineStateService(
return completion; return completion;
} }
public async Task<StoryIntelligenceSceneAnalysisCompletion?> RecoverNextReadySceneAnalysisAsync() public Task<StoryIntelligenceSceneAnalysisRecoveryCandidate?> FindNextReadySceneAnalysisAsync()
{ => pipelines.FindNextReadySceneAnalysisAsync();
var completion = await pipelines.TryCompleteNextReadySceneAnalysisAsync();
LogSceneAnalysisCompletion(completion, "recovered");
return completion;
}
public Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId) public Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId)
=> pipelines.ListCommittedRunsByBookAsync(bookId, userId); => pipelines.ListCommittedRunsByBookAsync(bookId, userId);

View File

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

View File

@ -27,7 +27,7 @@
<h2>Analysis has already started</h2> <h2>Analysis has already started</h2>
<p>You can return to progress or open the Review Centre when analysis is ready.</p> <p>You can return to progress or open the Review Centre when analysis is ready.</p>
<div class="onboarding-actions"> <div class="onboarding-actions">
<a class="btn btn-primary" asp-action="StoryIntelligenceProgress" asp-route-batchId="@Model.ExistingBatchID">Return to book</a> <a class="btn btn-primary" asp-controller="Books" asp-action="Details" asp-route-id="@Model.BookID">Return to book</a>
<form asp-action="ScanManuscript" method="post"> <form asp-action="ScanManuscript" method="post">
<button class="btn btn-outline-secondary" type="submit">Scan manuscript again</button> <button class="btn btn-outline-secondary" type="submit">Scan manuscript again</button>
</form> </form>

View File

@ -23,7 +23,7 @@
<h2>Analysis has already started</h2> <h2>Analysis has already started</h2>
<p>You can return to progress or open the Review Centre when analysis is ready.</p> <p>You can return to progress or open the Review Centre when analysis is ready.</p>
<div class="onboarding-actions"> <div class="onboarding-actions">
<a class="btn btn-primary" asp-action="StoryIntelligenceProgress" asp-route-batchId="@Model.ExistingBatchID">Return to book</a> <a class="btn btn-primary" asp-controller="Books" asp-action="Details" asp-route-id="@Model.BookID">Return to book</a>
<form asp-action="ScanManuscript" method="post"> <form asp-action="ScanManuscript" method="post">
<button class="btn btn-outline-secondary" type="submit">Scan manuscript again</button> <button class="btn btn-outline-secondary" type="submit">Scan manuscript again</button>
</form> </form>