Stabilise Story Intelligence materialisation
This commit is contained in:
parent
78f401b417
commit
6dec8a4345
@ -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()
|
||||||
|
|||||||
@ -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.";
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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 })
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
333
PlotLine/Services/StoryIntelligenceMaterialisationService.cs
Normal file
333
PlotLine/Services/StoryIntelligenceMaterialisationService.cs
Normal 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);
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user